Add more tests; make fall back more resilient and visible

main
Vignesh Natarajan 2026-01-27 22:46:11 -08:00 committed by Vignesh
parent 2c30ba400b
commit 3a57106c1e
10 changed files with 149 additions and 10 deletions

View File

@ -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. - Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. - 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. - 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.

View File

@ -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(

View File

@ -284,6 +284,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,
@ -492,9 +493,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);
@ -584,7 +584,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) { if (syncUpdate.label) {
lastLabel = syncUpdate.label; lastLabel = syncUpdate.label;

View File

@ -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";

View File

@ -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 };

View 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();
});
});

View File

@ -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;
}
} }

View File

@ -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();

View File

@ -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();

View File

@ -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>;
} }