2026-02-21 22:30:28 +00:00
|
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
|
import {
|
|
|
|
|
resetMemoryToolMockState,
|
|
|
|
|
setMemoryBackend,
|
|
|
|
|
setMemoryReadFileImpl,
|
|
|
|
|
setMemorySearchImpl,
|
|
|
|
|
type MemoryReadParams,
|
|
|
|
|
} from "../../../test/helpers/memory-tool-manager-mock.js";
|
2026-02-17 14:33:26 +09:00
|
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
2026-02-15 21:58:27 +00:00
|
|
|
import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js";
|
2026-01-27 21:57:15 -08:00
|
|
|
|
2026-02-17 14:33:26 +09:00
|
|
|
function asOpenClawConfig(config: Partial<OpenClawConfig>): OpenClawConfig {
|
|
|
|
|
return config as OpenClawConfig;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
function createToolConfig() {
|
|
|
|
|
return asOpenClawConfig({ agents: { list: [{ id: "main", default: true }] } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createMemoryGetToolOrThrow(config: OpenClawConfig = createToolConfig()) {
|
|
|
|
|
const tool = createMemoryGetTool({ config });
|
|
|
|
|
if (!tool) {
|
|
|
|
|
throw new Error("tool missing");
|
|
|
|
|
}
|
|
|
|
|
return tool;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 21:57:15 -08:00
|
|
|
beforeEach(() => {
|
2026-02-21 22:30:28 +00:00
|
|
|
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 }),
|
|
|
|
|
});
|
2026-01-27 21:57:15 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("memory search citations", () => {
|
|
|
|
|
it("appends source information when citations are enabled", async () => {
|
2026-02-21 22:30:28 +00:00
|
|
|
setMemoryBackend("builtin");
|
2026-02-17 14:33:26 +09:00
|
|
|
const cfg = asOpenClawConfig({
|
|
|
|
|
memory: { citations: "on" },
|
|
|
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
|
|
|
});
|
2026-01-27 21:57:15 -08:00
|
|
|
const tool = createMemorySearchTool({ config: cfg });
|
2026-02-02 22:56:20 +01:00
|
|
|
if (!tool) {
|
|
|
|
|
throw new Error("tool missing");
|
|
|
|
|
}
|
2026-01-27 21:57:15 -08:00
|
|
|
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 () => {
|
2026-02-21 22:30:28 +00:00
|
|
|
setMemoryBackend("builtin");
|
2026-02-17 14:33:26 +09:00
|
|
|
const cfg = asOpenClawConfig({
|
|
|
|
|
memory: { citations: "off" },
|
|
|
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
|
|
|
});
|
2026-01-27 21:57:15 -08:00
|
|
|
const tool = createMemorySearchTool({ config: cfg });
|
2026-02-02 22:56:20 +01:00
|
|
|
if (!tool) {
|
|
|
|
|
throw new Error("tool missing");
|
|
|
|
|
}
|
2026-01-27 21:57:15 -08:00
|
|
|
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();
|
|
|
|
|
});
|
2026-02-02 20:53:02 +01:00
|
|
|
|
|
|
|
|
it("clamps decorated snippets to qmd injected budget", async () => {
|
2026-02-21 22:30:28 +00:00
|
|
|
setMemoryBackend("qmd");
|
2026-02-17 14:33:26 +09:00
|
|
|
const cfg = asOpenClawConfig({
|
2026-02-02 20:53:02 +01:00
|
|
|
memory: { citations: "on", backend: "qmd", qmd: { limits: { maxInjectedChars: 20 } } },
|
|
|
|
|
agents: { list: [{ id: "main", default: true }] },
|
2026-02-17 14:33:26 +09:00
|
|
|
});
|
2026-02-02 20:53:02 +01:00
|
|
|
const tool = createMemorySearchTool({ config: cfg });
|
2026-02-02 22:56:20 +01:00
|
|
|
if (!tool) {
|
|
|
|
|
throw new Error("tool missing");
|
|
|
|
|
}
|
2026-02-02 20:53:02 +01:00
|
|
|
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);
|
|
|
|
|
});
|
2026-02-02 20:10:45 -08:00
|
|
|
|
|
|
|
|
it("honors auto mode for direct chats", async () => {
|
2026-02-21 22:30:28 +00:00
|
|
|
setMemoryBackend("builtin");
|
2026-02-17 14:33:26 +09:00
|
|
|
const cfg = asOpenClawConfig({
|
2026-02-02 20:10:45 -08:00
|
|
|
memory: { citations: "auto" },
|
|
|
|
|
agents: { list: [{ id: "main", default: true }] },
|
2026-02-17 14:33:26 +09:00
|
|
|
});
|
2026-02-02 20:10:45 -08:00
|
|
|
const tool = createMemorySearchTool({
|
|
|
|
|
config: cfg,
|
|
|
|
|
agentSessionKey: "agent:main:discord:dm:u123",
|
|
|
|
|
});
|
|
|
|
|
if (!tool) {
|
|
|
|
|
throw new Error("tool missing");
|
|
|
|
|
}
|
|
|
|
|
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 () => {
|
2026-02-21 22:30:28 +00:00
|
|
|
setMemoryBackend("builtin");
|
2026-02-17 14:33:26 +09:00
|
|
|
const cfg = asOpenClawConfig({
|
2026-02-02 20:10:45 -08:00
|
|
|
memory: { citations: "auto" },
|
|
|
|
|
agents: { list: [{ id: "main", default: true }] },
|
2026-02-17 14:33:26 +09:00
|
|
|
});
|
2026-02-02 20:10:45 -08:00
|
|
|
const tool = createMemorySearchTool({
|
|
|
|
|
config: cfg,
|
|
|
|
|
agentSessionKey: "agent:main:discord:group:c123",
|
|
|
|
|
});
|
|
|
|
|
if (!tool) {
|
|
|
|
|
throw new Error("tool missing");
|
|
|
|
|
}
|
|
|
|
|
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:/);
|
|
|
|
|
});
|
2026-01-27 21:57:15 -08:00
|
|
|
});
|
2026-02-15 21:58:27 +00:00
|
|
|
|
|
|
|
|
describe("memory tools", () => {
|
|
|
|
|
it("does not throw when memory_search fails (e.g. embeddings 429)", async () => {
|
2026-02-21 22:30:28 +00:00
|
|
|
setMemorySearchImpl(async () => {
|
2026-02-15 21:58:27 +00:00
|
|
|
throw new Error("openai embeddings failed: 429 insufficient_quota");
|
2026-02-21 22:30:28 +00:00
|
|
|
});
|
2026-02-15 21:58:27 +00:00
|
|
|
|
|
|
|
|
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
|
|
|
|
const tool = createMemorySearchTool({ config: cfg });
|
|
|
|
|
expect(tool).not.toBeNull();
|
|
|
|
|
if (!tool) {
|
|
|
|
|
throw new Error("tool missing");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await tool.execute("call_1", { query: "hello" });
|
|
|
|
|
expect(result.details).toEqual({
|
|
|
|
|
results: [],
|
|
|
|
|
disabled: true,
|
2026-02-20 20:30:52 -08:00
|
|
|
unavailable: true,
|
2026-02-15 21:58:27 +00:00
|
|
|
error: "openai embeddings failed: 429 insufficient_quota",
|
2026-02-20 20:30:52 -08:00
|
|
|
warning: "Memory search is unavailable because the embedding provider quota is exhausted.",
|
|
|
|
|
action: "Top up or switch embedding provider, then retry memory_search.",
|
2026-02-15 21:58:27 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not throw when memory_get fails", async () => {
|
2026-02-21 22:30:28 +00:00
|
|
|
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
|
2026-02-15 21:58:27 +00:00
|
|
|
throw new Error("path required");
|
2026-02-21 22:30:28 +00:00
|
|
|
});
|
2026-02-15 21:58:27 +00:00
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
const tool = createMemoryGetToolOrThrow();
|
2026-02-15 21:58:27 +00:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-19 01:35:28 -05:00
|
|
|
|
|
|
|
|
it("returns empty text without error when file does not exist (ENOENT)", async () => {
|
2026-02-21 22:30:28 +00:00
|
|
|
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
|
2026-02-19 01:35:28 -05:00
|
|
|
return { text: "", path: "memory/2026-02-19.md" };
|
2026-02-21 22:30:28 +00:00
|
|
|
});
|
2026-02-19 01:35:28 -05:00
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
const tool = createMemoryGetToolOrThrow();
|
2026-02-19 01:35:28 -05:00
|
|
|
|
|
|
|
|
const result = await tool.execute("call_enoent", { path: "memory/2026-02-19.md" });
|
|
|
|
|
expect(result.details).toEqual({
|
|
|
|
|
text: "",
|
|
|
|
|
path: "memory/2026-02-19.md",
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-15 21:58:27 +00:00
|
|
|
});
|