From 3a57106c1e8e95e488e94152f1fac62e72644b75 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Tue, 27 Jan 2026 22:46:11 -0800 Subject: [PATCH] Add more tests; make fall back more resilient and visible --- CHANGELOG.md | 2 +- src/cli/memory-cli.test.ts | 6 +- src/cli/memory-cli.ts | 6 +- src/memory/index.ts | 6 +- src/memory/manager.ts | 3 +- src/memory/qmd-manager.test.ts | 95 +++++++++++++++++++++++++++++++ src/memory/qmd-manager.ts | 16 ++++++ src/memory/search-manager.test.ts | 2 + src/memory/search-manager.ts | 17 +++++- src/memory/types.ts | 6 ++ 10 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 src/memory/qmd-manager.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8f55859..3a1f44e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1459,4 +1459,4 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. - Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. - Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler. -- CLI: run `openclaw agent` via the Gateway by default; use `--local` to force embedded mode. +- CLI: run `openclaw agent` via the Gateway by default; use `--local` to force embedded mode. \ No newline at end of file diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index eeadffa36..1eb77980f 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -242,7 +242,7 @@ describe("memory cli", () => { await program.parseAsync(["memory", "status", "--index"], { from: "user" }); 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(close).toHaveBeenCalled(); @@ -267,7 +267,7 @@ describe("memory cli", () => { await program.parseAsync(["memory", "index"], { from: "user" }); 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(log).toHaveBeenCalledWith("Memory index updated (main)."); @@ -294,7 +294,7 @@ describe("memory cli", () => { await program.parseAsync(["memory", "index"], { from: "user" }); 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(error).toHaveBeenCalledWith( diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 58557eec6..f7216b014 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -284,6 +284,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { try { await manager.sync({ reason: "cli", + force: true, progress: (syncUpdate) => { update({ completed: syncUpdate.completed, @@ -492,9 +493,8 @@ export function registerMemoryCli(program: Command) { .command("index") .description("Reindex memory files") .option("--agent ", "Agent id (default: default agent)") - .option("--force", "Force full reindex", false) .option("--verbose", "Verbose logging", false) - .action(async (opts: MemoryCommandOptions & { force?: boolean }) => { + .action(async (opts: MemoryCommandOptions) => { setVerbose(Boolean(opts.verbose)); const cfg = loadConfig(); const agentIds = resolveAgentIds(cfg, opts.agent); @@ -584,7 +584,7 @@ export function registerMemoryCli(program: Command) { try { await manager.sync({ reason: "cli", - force: opts.force, + force: true, progress: (syncUpdate) => { if (syncUpdate.label) { lastLabel = syncUpdate.label; diff --git a/src/memory/index.ts b/src/memory/index.ts index a4d9a4da1..4d2df05a3 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -1,3 +1,7 @@ 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"; diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 5d42c571b..724b1320c 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -46,6 +46,7 @@ import { ensureMemoryIndexSchema } from "./memory-schema.js"; import { requireNodeSqlite } from "./sqlite.js"; import { loadSqliteVecExtension } from "./sqlite-vec.js"; import type { + MemoryEmbeddingProbeResult, MemoryProviderStatus, MemorySearchManager, MemorySearchResult, @@ -504,7 +505,7 @@ export class MemoryIndexManager implements MemorySearchManager { return this.ensureVectorReady(); } - async probeEmbeddingAvailability(): Promise<{ ok: boolean; error?: string }> { + async probeEmbeddingAvailability(): Promise { try { await this.embedBatchWithRetry(["ping"]); return { ok: true }; diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts new file mode 100644 index 000000000..2fd2e2c1b --- /dev/null +++ b/src/memory/qmd-manager.test.ts @@ -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(); + }); +}); diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index a54390112..77159da3a 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -16,6 +16,7 @@ import { } from "./session-files.js"; import { requireNodeSqlite } from "./sqlite.js"; import type { + MemoryEmbeddingProbeResult, MemoryProviderStatus, MemorySearchManager, MemorySearchResult, @@ -294,6 +295,10 @@ export class QmdMemoryManager implements MemorySearchManager { }; } + async probeEmbeddingAvailability(): Promise { + return { ok: true }; + } + async probeVectorAvailability(): Promise { return true; } @@ -314,6 +319,9 @@ export class QmdMemoryManager implements MemorySearchManager { private async runUpdate(reason: string, force?: boolean): Promise { if (this.pendingUpdate && !force) return this.pendingUpdate; + if (this.shouldSkipUpdate(force)) { + return; + } const run = async () => { if (this.sessionExporter) { await this.exportSessions(); @@ -629,4 +637,12 @@ export class QmdMemoryManager implements MemorySearchManager { } 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; + } } diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index 7dd822fa5..f7119caa1 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -17,6 +17,7 @@ const mockPrimary = { sourceCounts: [{ source: "memory" as const, files: 0, chunks: 0 }], })), sync: vi.fn(async () => {}), + probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), probeVectorAvailability: vi.fn(async () => true), close: vi.fn(async () => {}), }; @@ -41,6 +42,7 @@ beforeEach(() => { mockPrimary.readFile.mockClear(); mockPrimary.status.mockClear(); mockPrimary.sync.mockClear(); + mockPrimary.probeEmbeddingAvailability.mockClear(); mockPrimary.probeVectorAvailability.mockClear(); mockPrimary.close.mockClear(); QmdMemoryManager.create.mockClear(); diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index a642f4658..5d98f6451 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -3,7 +3,11 @@ import type { MoltbotConfig } from "../config/config.js"; import { resolveMemoryBackendConfig } from "./backend-config.js"; import type { ResolvedQmdConfig } from "./backend-config.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 QMD_MANAGER_CACHE = new Map(); @@ -148,6 +152,17 @@ class FallbackMemoryManager implements MemorySearchManager { await fallback?.sync?.(params); } + async probeEmbeddingAvailability(): Promise { + 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() { if (!this.primaryFailed) { return await this.deps.primary.probeVectorAvailability(); diff --git a/src/memory/types.ts b/src/memory/types.ts index 40abf99ea..2013fcc3e 100644 --- a/src/memory/types.ts +++ b/src/memory/types.ts @@ -10,6 +10,11 @@ export type MemorySearchResult = { citation?: string; }; +export type MemoryEmbeddingProbeResult = { + ok: boolean; + error?: string; +}; + export type MemorySyncProgressUpdate = { completed: number; total: number; @@ -68,6 +73,7 @@ export interface MemorySearchManager { force?: boolean; progress?: (update: MemorySyncProgressUpdate) => void; }): Promise; + probeEmbeddingAvailability(): Promise; probeVectorAvailability(): Promise; close?(): Promise; }