Add more tests; make fall back more resilient and visible
This commit is contained in:
parent
4e8000e745
commit
f3f1640c7b
@ -35,9 +35,6 @@ Status: beta.
|
|||||||
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
|
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
|
||||||
- Docs: add LINE channel guide. Thanks @thewilloftheshadow.
|
- Docs: add LINE channel guide. Thanks @thewilloftheshadow.
|
||||||
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
|
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
|
||||||
- Memory: add optional QMD-backed memory backend with fallbacks to the existing Markdown index. Thanks @vgnsh.
|
|
||||||
- Memory: surface QMD startup errors so we fall back to the legacy index when the CLI is missing, and allow extra QMD collections outside the workspace to return snippets via the `qmd/<collection>/…` path prefix.
|
|
||||||
- Memory: export session transcripts into the QMD index when `memory.qmd.sessions.enabled` is set and honor `memory.citations` when formatting snippets.
|
|
||||||
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
|
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
|
||||||
- Onboarding: strengthen security warning copy for beta + access control expectations.
|
- Onboarding: strengthen security warning copy for beta + access control expectations.
|
||||||
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
|
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
|
||||||
|
|||||||
@ -242,7 +242,7 @@ describe("memory cli", () => {
|
|||||||
await program.parseAsync(["memory", "status", "--index"], { from: "user" });
|
await program.parseAsync(["memory", "status", "--index"], { from: "user" });
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledWith(
|
expect(sync).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "cli", progress: expect.any(Function) }),
|
expect.objectContaining({ reason: "cli", force: true, progress: expect.any(Function) }),
|
||||||
);
|
);
|
||||||
expect(probeEmbeddingAvailability).toHaveBeenCalled();
|
expect(probeEmbeddingAvailability).toHaveBeenCalled();
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
@ -267,7 +267,7 @@ describe("memory cli", () => {
|
|||||||
await program.parseAsync(["memory", "index"], { from: "user" });
|
await program.parseAsync(["memory", "index"], { from: "user" });
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledWith(
|
expect(sync).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
expect.objectContaining({ reason: "cli", force: true, progress: expect.any(Function) }),
|
||||||
);
|
);
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
|
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
|
||||||
@ -294,7 +294,7 @@ describe("memory cli", () => {
|
|||||||
await program.parseAsync(["memory", "index"], { from: "user" });
|
await program.parseAsync(["memory", "index"], { from: "user" });
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledWith(
|
expect(sync).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
expect.objectContaining({ reason: "cli", force: true, progress: expect.any(Function) }),
|
||||||
);
|
);
|
||||||
expect(close).toHaveBeenCalled();
|
expect(close).toHaveBeenCalled();
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -240,6 +240,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
|||||||
try {
|
try {
|
||||||
await manager.sync({
|
await manager.sync({
|
||||||
reason: "cli",
|
reason: "cli",
|
||||||
|
force: true,
|
||||||
progress: (syncUpdate) => {
|
progress: (syncUpdate) => {
|
||||||
update({
|
update({
|
||||||
completed: syncUpdate.completed,
|
completed: syncUpdate.completed,
|
||||||
@ -443,9 +444,8 @@ export function registerMemoryCli(program: Command) {
|
|||||||
.command("index")
|
.command("index")
|
||||||
.description("Reindex memory files")
|
.description("Reindex memory files")
|
||||||
.option("--agent <id>", "Agent id (default: default agent)")
|
.option("--agent <id>", "Agent id (default: default agent)")
|
||||||
.option("--force", "Force full reindex", false)
|
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
|
.action(async (opts: MemoryCommandOptions) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const agentIds = resolveAgentIds(cfg, opts.agent);
|
const agentIds = resolveAgentIds(cfg, opts.agent);
|
||||||
@ -527,7 +527,7 @@ export function registerMemoryCli(program: Command) {
|
|||||||
try {
|
try {
|
||||||
await manager.sync({
|
await manager.sync({
|
||||||
reason: "cli",
|
reason: "cli",
|
||||||
force: opts.force,
|
force: true,
|
||||||
progress: (syncUpdate) => {
|
progress: (syncUpdate) => {
|
||||||
if (syncUpdate.label) lastLabel = syncUpdate.label;
|
if (syncUpdate.label) lastLabel = syncUpdate.label;
|
||||||
lastCompleted = syncUpdate.completed;
|
lastCompleted = syncUpdate.completed;
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
export { MemoryIndexManager } from "./manager.js";
|
export { MemoryIndexManager } from "./manager.js";
|
||||||
export type { MemorySearchResult, MemorySearchManager } from "./types.js";
|
export type {
|
||||||
|
MemoryEmbeddingProbeResult,
|
||||||
|
MemorySearchManager,
|
||||||
|
MemorySearchResult,
|
||||||
|
} from "./types.js";
|
||||||
export { getMemorySearchManager, type MemorySearchManagerResult } from "./search-manager.js";
|
export { getMemorySearchManager, type MemorySearchManagerResult } from "./search-manager.js";
|
||||||
|
|||||||
@ -46,6 +46,7 @@ import { ensureMemoryIndexSchema } from "./memory-schema.js";
|
|||||||
import { requireNodeSqlite } from "./sqlite.js";
|
import { requireNodeSqlite } from "./sqlite.js";
|
||||||
import { loadSqliteVecExtension } from "./sqlite-vec.js";
|
import { loadSqliteVecExtension } from "./sqlite-vec.js";
|
||||||
import type {
|
import type {
|
||||||
|
MemoryEmbeddingProbeResult,
|
||||||
MemoryProviderStatus,
|
MemoryProviderStatus,
|
||||||
MemorySearchManager,
|
MemorySearchManager,
|
||||||
MemorySearchResult,
|
MemorySearchResult,
|
||||||
@ -504,7 +505,7 @@ export class MemoryIndexManager implements MemorySearchManager {
|
|||||||
return this.ensureVectorReady();
|
return this.ensureVectorReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
async probeEmbeddingAvailability(): Promise<{ ok: boolean; error?: string }> {
|
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
|
||||||
try {
|
try {
|
||||||
await this.embedBatchWithRetry(["ping"]);
|
await this.embedBatchWithRetry(["ping"]);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
95
src/memory/qmd-manager.test.ts
Normal file
95
src/memory/qmd-manager.test.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("node:child_process", () => {
|
||||||
|
const spawn = vi.fn((cmd: string, _args: string[]) => {
|
||||||
|
const stdout = new EventEmitter();
|
||||||
|
const stderr = new EventEmitter();
|
||||||
|
const child = new EventEmitter() as {
|
||||||
|
stdout: EventEmitter;
|
||||||
|
stderr: EventEmitter;
|
||||||
|
kill: () => void;
|
||||||
|
emit: (event: string, code: number) => boolean;
|
||||||
|
};
|
||||||
|
child.stdout = stdout;
|
||||||
|
child.stderr = stderr;
|
||||||
|
child.kill = () => {
|
||||||
|
child.emit("close", 0);
|
||||||
|
};
|
||||||
|
setImmediate(() => {
|
||||||
|
stdout.emit("data", "");
|
||||||
|
stderr.emit("data", "");
|
||||||
|
child.emit("close", 0);
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
return { spawn };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { spawn as mockedSpawn } from "node:child_process";
|
||||||
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
import { resolveMemoryBackendConfig } from "./backend-config.js";
|
||||||
|
import { QmdMemoryManager } from "./qmd-manager.js";
|
||||||
|
|
||||||
|
const spawnMock = mockedSpawn as unknown as vi.Mock;
|
||||||
|
|
||||||
|
describe("QmdMemoryManager", () => {
|
||||||
|
let tmpRoot: string;
|
||||||
|
let workspaceDir: string;
|
||||||
|
let stateDir: string;
|
||||||
|
let cfg: MoltbotConfig;
|
||||||
|
const agentId = "main";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
spawnMock.mockClear();
|
||||||
|
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-manager-test-"));
|
||||||
|
workspaceDir = path.join(tmpRoot, "workspace");
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
stateDir = path.join(tmpRoot, "state");
|
||||||
|
await fs.mkdir(stateDir, { recursive: true });
|
||||||
|
process.env.MOLTBOT_STATE_DIR = stateDir;
|
||||||
|
cfg = {
|
||||||
|
agents: {
|
||||||
|
list: [{ id: agentId, default: true, workspace: workspaceDir }],
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
backend: "qmd",
|
||||||
|
qmd: {
|
||||||
|
includeDefaultMemory: false,
|
||||||
|
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||||
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as MoltbotConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
delete process.env.MOLTBOT_STATE_DIR;
|
||||||
|
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces back-to-back sync calls", async () => {
|
||||||
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
||||||
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
||||||
|
expect(manager).toBeTruthy();
|
||||||
|
if (!manager) throw new Error("manager missing");
|
||||||
|
|
||||||
|
await manager.sync({ reason: "manual" });
|
||||||
|
expect(spawnMock.mock.calls.length).toBe(2);
|
||||||
|
|
||||||
|
await manager.sync({ reason: "manual-again" });
|
||||||
|
expect(spawnMock.mock.calls.length).toBe(2);
|
||||||
|
|
||||||
|
(manager as unknown as { lastUpdateAt: number | null }).lastUpdateAt =
|
||||||
|
Date.now() - (resolved.qmd?.update.debounceMs ?? 0) - 10;
|
||||||
|
|
||||||
|
await manager.sync({ reason: "after-wait" });
|
||||||
|
expect(spawnMock.mock.calls.length).toBe(4);
|
||||||
|
|
||||||
|
await manager.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
} from "./session-files.js";
|
} from "./session-files.js";
|
||||||
import { requireNodeSqlite } from "./sqlite.js";
|
import { requireNodeSqlite } from "./sqlite.js";
|
||||||
import type {
|
import type {
|
||||||
|
MemoryEmbeddingProbeResult,
|
||||||
MemoryProviderStatus,
|
MemoryProviderStatus,
|
||||||
MemorySearchManager,
|
MemorySearchManager,
|
||||||
MemorySearchResult,
|
MemorySearchResult,
|
||||||
@ -294,6 +295,10 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
async probeVectorAvailability(): Promise<boolean> {
|
async probeVectorAvailability(): Promise<boolean> {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -314,6 +319,9 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
|
|
||||||
private async runUpdate(reason: string, force?: boolean): Promise<void> {
|
private async runUpdate(reason: string, force?: boolean): Promise<void> {
|
||||||
if (this.pendingUpdate && !force) return this.pendingUpdate;
|
if (this.pendingUpdate && !force) return this.pendingUpdate;
|
||||||
|
if (this.shouldSkipUpdate(force)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (this.sessionExporter) {
|
if (this.sessionExporter) {
|
||||||
await this.exportSessions();
|
await this.exportSessions();
|
||||||
@ -629,4 +637,12 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
}
|
}
|
||||||
return clamped;
|
return clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldSkipUpdate(force?: boolean): boolean {
|
||||||
|
if (force) return false;
|
||||||
|
const debounceMs = this.qmd.update.debounceMs;
|
||||||
|
if (debounceMs <= 0) return false;
|
||||||
|
if (!this.lastUpdateAt) return false;
|
||||||
|
return Date.now() - this.lastUpdateAt < debounceMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const mockPrimary = {
|
|||||||
sourceCounts: [{ source: "memory" as const, files: 0, chunks: 0 }],
|
sourceCounts: [{ source: "memory" as const, files: 0, chunks: 0 }],
|
||||||
})),
|
})),
|
||||||
sync: vi.fn(async () => {}),
|
sync: vi.fn(async () => {}),
|
||||||
|
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
|
||||||
probeVectorAvailability: vi.fn(async () => true),
|
probeVectorAvailability: vi.fn(async () => true),
|
||||||
close: vi.fn(async () => {}),
|
close: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
@ -41,6 +42,7 @@ beforeEach(() => {
|
|||||||
mockPrimary.readFile.mockClear();
|
mockPrimary.readFile.mockClear();
|
||||||
mockPrimary.status.mockClear();
|
mockPrimary.status.mockClear();
|
||||||
mockPrimary.sync.mockClear();
|
mockPrimary.sync.mockClear();
|
||||||
|
mockPrimary.probeEmbeddingAvailability.mockClear();
|
||||||
mockPrimary.probeVectorAvailability.mockClear();
|
mockPrimary.probeVectorAvailability.mockClear();
|
||||||
mockPrimary.close.mockClear();
|
mockPrimary.close.mockClear();
|
||||||
QmdMemoryManager.create.mockClear();
|
QmdMemoryManager.create.mockClear();
|
||||||
|
|||||||
@ -3,7 +3,11 @@ import type { MoltbotConfig } from "../config/config.js";
|
|||||||
import { resolveMemoryBackendConfig } from "./backend-config.js";
|
import { resolveMemoryBackendConfig } from "./backend-config.js";
|
||||||
import type { ResolvedQmdConfig } from "./backend-config.js";
|
import type { ResolvedQmdConfig } from "./backend-config.js";
|
||||||
import type { MemoryIndexManager } from "./manager.js";
|
import type { MemoryIndexManager } from "./manager.js";
|
||||||
import type { MemorySearchManager, MemorySyncProgressUpdate } from "./types.js";
|
import type {
|
||||||
|
MemoryEmbeddingProbeResult,
|
||||||
|
MemorySearchManager,
|
||||||
|
MemorySyncProgressUpdate,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("memory");
|
const log = createSubsystemLogger("memory");
|
||||||
const QMD_MANAGER_CACHE = new Map<string, MemorySearchManager>();
|
const QMD_MANAGER_CACHE = new Map<string, MemorySearchManager>();
|
||||||
@ -148,6 +152,17 @@ class FallbackMemoryManager implements MemorySearchManager {
|
|||||||
await fallback?.sync?.(params);
|
await fallback?.sync?.(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
|
||||||
|
if (!this.primaryFailed) {
|
||||||
|
return await this.deps.primary.probeEmbeddingAvailability();
|
||||||
|
}
|
||||||
|
const fallback = await this.ensureFallback();
|
||||||
|
if (fallback) {
|
||||||
|
return await fallback.probeEmbeddingAvailability();
|
||||||
|
}
|
||||||
|
return { ok: false, error: this.lastError ?? "memory embeddings unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
async probeVectorAvailability() {
|
async probeVectorAvailability() {
|
||||||
if (!this.primaryFailed) {
|
if (!this.primaryFailed) {
|
||||||
return await this.deps.primary.probeVectorAvailability();
|
return await this.deps.primary.probeVectorAvailability();
|
||||||
|
|||||||
@ -10,6 +10,11 @@ export type MemorySearchResult = {
|
|||||||
citation?: string;
|
citation?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MemoryEmbeddingProbeResult = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MemorySyncProgressUpdate = {
|
export type MemorySyncProgressUpdate = {
|
||||||
completed: number;
|
completed: number;
|
||||||
total: number;
|
total: number;
|
||||||
@ -68,6 +73,7 @@ export interface MemorySearchManager {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
progress?: (update: MemorySyncProgressUpdate) => void;
|
progress?: (update: MemorySyncProgressUpdate) => void;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>;
|
||||||
probeVectorAvailability(): Promise<boolean>;
|
probeVectorAvailability(): Promise<boolean>;
|
||||||
close?(): Promise<void>;
|
close?(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user