import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { getMemorySearchManagerMockCalls, getReadAgentMemoryFileMockCalls, resetMemoryToolMockState, setMemoryBackend, setMemoryReadFileImpl, setMemorySearchImpl, setMemoryWorkspaceDir, type MemoryReadParams, } from "../../../test/helpers/memory-tool-manager-mock.js"; import { asOpenClawConfig, createAutoCitationsMemorySearchTool, createDefaultMemoryToolConfig, createMemoryGetToolOrThrow, createMemorySearchToolOrThrow, expectUnavailableMemorySearchDetails, } from "./tools.test-helpers.js"; async function waitFor(task: () => Promise, timeoutMs: number = 1500): Promise { const startedAt = Date.now(); let lastError: unknown; while (Date.now() - startedAt < timeoutMs) { try { return await task(); } catch (error) { lastError = error; await new Promise((resolve) => { setTimeout(resolve, 20); }); } } if (lastError instanceof Error) { throw lastError; } throw new Error("Timed out waiting for async test condition"); } beforeEach(() => { resetMemoryToolMockState({ backend: "builtin", searchImpl: async () => [ { path: "MEMORY.md", startLine: 5, endLine: 7, score: 0.9, snippet: "@@ -5,3 @@\nAssistant: noted", source: "memory" as const, }, ], readFileImpl: async (params: MemoryReadParams) => ({ text: "", path: params.relPath }), }); }); describe("memory search citations", () => { it("appends source information when citations are enabled", async () => { setMemoryBackend("builtin"); const cfg = asOpenClawConfig({ memory: { citations: "on" }, agents: { list: [{ id: "main", default: true }] }, }); const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_on", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/); expect(details.results[0]?.citation).toBe("MEMORY.md#L5-L7"); }); it("leaves snippet untouched when citations are off", async () => { setMemoryBackend("builtin"); const cfg = asOpenClawConfig({ memory: { citations: "off" }, agents: { list: [{ id: "main", default: true }] }, }); const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_off", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; expect(details.results[0]?.snippet).not.toMatch(/Source:/); expect(details.results[0]?.citation).toBeUndefined(); }); it("clamps decorated snippets to qmd injected budget", async () => { setMemoryBackend("qmd"); const cfg = asOpenClawConfig({ memory: { citations: "on", backend: "qmd", qmd: { limits: { maxInjectedChars: 20 } } }, agents: { list: [{ id: "main", default: true }] }, }); const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_qmd", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; expect(details.results[0]?.snippet.length).toBeLessThanOrEqual(20); }); it("honors auto mode for direct chats", async () => { setMemoryBackend("builtin"); const tool = createAutoCitationsMemorySearchTool("agent:main:discord:dm:u123"); const result = await tool.execute("auto_mode_direct", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string }> }; expect(details.results[0]?.snippet).toMatch(/Source:/); }); it("suppresses citations for auto mode in group chats", async () => { setMemoryBackend("builtin"); const tool = createAutoCitationsMemorySearchTool("agent:main:discord:group:c123"); const result = await tool.execute("auto_mode_group", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string }> }; expect(details.results[0]?.snippet).not.toMatch(/Source:/); }); }); describe("memory tools", () => { it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { setMemorySearchImpl(async () => { throw new Error("openai embeddings failed: 429 insufficient_quota"); }); const cfg = createDefaultMemoryToolConfig(); const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_1", { query: "hello" }); expectUnavailableMemorySearchDetails(result.details, { error: "openai embeddings failed: 429 insufficient_quota", warning: "Memory search is unavailable because the embedding provider quota is exhausted.", action: "Top up or switch embedding provider, then retry memory_search.", }); }); it("does not throw when memory_get fails", async () => { setMemoryReadFileImpl(async (_params: MemoryReadParams) => { throw new Error("path required"); }); const tool = createMemoryGetToolOrThrow(); const result = await tool.execute("call_2", { path: "memory/NOPE.md" }); expect(result.details).toEqual({ path: "memory/NOPE.md", text: "", disabled: true, error: "path required", }); }); it("returns empty text without error when file does not exist (ENOENT)", async () => { setMemoryReadFileImpl(async (_params: MemoryReadParams) => { return { text: "", path: "memory/2026-02-19.md" }; }); const tool = createMemoryGetToolOrThrow(); const result = await tool.execute("call_enoent", { path: "memory/2026-02-19.md" }); expect(result.details).toEqual({ text: "", path: "memory/2026-02-19.md", }); }); it("uses the builtin direct memory file path for memory_get", async () => { setMemoryBackend("builtin"); const tool = createMemoryGetToolOrThrow(); const result = await tool.execute("call_builtin_fast_path", { path: "memory/2026-02-19.md" }); expect(result.details).toEqual({ text: "", path: "memory/2026-02-19.md", }); expect(getReadAgentMemoryFileMockCalls()).toBe(1); expect(getMemorySearchManagerMockCalls()).toBe(0); }); it("persists short-term recall events from memory_search tool hits", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-tools-recall-")); try { setMemoryBackend("builtin"); setMemoryWorkspaceDir(workspaceDir); setMemorySearchImpl(async () => [ { path: "memory/2026-04-03.md", startLine: 1, endLine: 2, score: 0.95, snippet: "Move backups to S3 Glacier.", source: "memory" as const, }, ]); const tool = createMemorySearchToolOrThrow(); await tool.execute("call_recall_persist", { query: "glacier backup" }); const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json"); const storeRaw = await waitFor(async () => await fs.readFile(storePath, "utf-8")); const store = JSON.parse(storeRaw) as { entries?: Record; }; const entries = Object.values(store.entries ?? {}); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ path: "memory/2026-04-03.md", recallCount: 1, }); } finally { await fs.rm(workspaceDir, { recursive: true, force: true }); } }); });