fix: adding login and fixing the plugin to follow the openclaw plugin standards (#4686)

This commit is contained in:
Kartik
2026-04-03 20:59:23 +05:30
committed by GitHub
parent 66230b3f1f
commit cfb3f58e4a
33 changed files with 5254 additions and 1376 deletions
+351
View File
@@ -0,0 +1,351 @@
/**
* Tests for the Backend interface module: error classes and type-level
* interface verification.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import {
AuthError,
NotFoundError,
APIError,
type Backend,
type AddOptions,
type SearchOptions,
type ListOptions,
type DeleteOptions,
type EntityIds,
} from "../backend/base.ts";
import { PlatformBackend } from "../backend/platform.ts";
// ---------------------------------------------------------------------------
// AuthError
// ---------------------------------------------------------------------------
describe("AuthError", () => {
it("uses the default message when none is provided", () => {
const err = new AuthError();
expect(err.message).toBe(
"Authentication failed. Your API key may be invalid or expired.",
);
});
it("accepts a custom message", () => {
const err = new AuthError("Token revoked");
expect(err.message).toBe("Token revoked");
});
it("has name 'AuthError'", () => {
const err = new AuthError();
expect(err.name).toBe("AuthError");
});
it("is an instance of Error", () => {
const err = new AuthError();
expect(err).toBeInstanceOf(Error);
});
});
// ---------------------------------------------------------------------------
// NotFoundError
// ---------------------------------------------------------------------------
describe("NotFoundError", () => {
it("includes the path in the message", () => {
const err = new NotFoundError("/v1/memories/abc-123");
expect(err.message).toBe("Resource not found: /v1/memories/abc-123");
});
it("has name 'NotFoundError'", () => {
const err = new NotFoundError("/any");
expect(err.name).toBe("NotFoundError");
});
it("is an instance of Error", () => {
const err = new NotFoundError("/any");
expect(err).toBeInstanceOf(Error);
});
});
// ---------------------------------------------------------------------------
// APIError
// ---------------------------------------------------------------------------
describe("APIError", () => {
it("includes both path and detail in the message", () => {
const err = new APIError("/v1/memories", "Invalid JSON body");
expect(err.message).toBe("Bad request to /v1/memories: Invalid JSON body");
});
it("has name 'APIError'", () => {
const err = new APIError("/x", "y");
expect(err.name).toBe("APIError");
});
it("is an instance of Error", () => {
const err = new APIError("/x", "y");
expect(err).toBeInstanceOf(Error);
});
});
// ---------------------------------------------------------------------------
// Backend interface — compile-time verification
// ---------------------------------------------------------------------------
describe("Backend interface (type-level)", () => {
it("can be referenced as a type", () => {
// This test verifies that the Backend type and option interfaces
// import correctly and are usable at the type level.
const _backendRef: Backend | undefined = undefined;
const _addOpts: AddOptions = {};
const _searchOpts: SearchOptions = {};
const _listOpts: ListOptions = {};
const _deleteOpts: DeleteOptions = {};
const _entityIds: EntityIds = {};
// If this file compiles and this test runs, the interface is valid.
expect(_backendRef).toBeUndefined();
expect(_addOpts).toBeDefined();
expect(_searchOpts).toBeDefined();
expect(_listOpts).toBeDefined();
expect(_deleteOpts).toBeDefined();
expect(_entityIds).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// PlatformBackend
// ---------------------------------------------------------------------------
describe("PlatformBackend", () => {
const BASE_URL = "https://api.mem0.ai";
const API_KEY = "test-api-key-123";
function createBackend(): PlatformBackend {
return new PlatformBackend({ apiKey: API_KEY, baseUrl: BASE_URL });
}
function mockFetchResponse(
status: number,
body: unknown,
statusText = "OK",
): typeof fetch {
return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText,
json: vi.fn().mockResolvedValue(body),
}) as unknown as typeof fetch;
}
afterEach(() => {
vi.unstubAllGlobals();
});
// -- Constructor ---------------------------------------------------------
it("creates an instance with apiKey and baseUrl", () => {
const backend = createBackend();
expect(backend).toBeInstanceOf(PlatformBackend);
});
it("strips trailing slashes from baseUrl", () => {
const backend = new PlatformBackend({
apiKey: API_KEY,
baseUrl: "https://api.mem0.ai///",
});
// We can verify by calling status and checking the base_url in the response
const mock = mockFetchResponse(200, { status: "ok" });
vi.stubGlobal("fetch", mock);
return backend.status().then((result) => {
expect(result.base_url).toBe("https://api.mem0.ai");
});
});
// -- add() ---------------------------------------------------------------
it("add() sends POST to /v1/memories/ with correct body structure", async () => {
const mock = mockFetchResponse(200, { id: "mem-1", memory: "test" });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.add("Remember this", undefined, {
userId: "user-1",
});
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("https://api.mem0.ai/v1/memories/");
expect(opts.method).toBe("POST");
expect(opts.headers).toMatchObject({
Authorization: `Token ${API_KEY}`,
"Content-Type": "application/json",
});
const body = JSON.parse(opts.body);
expect(body.messages).toEqual([{ role: "user", content: "Remember this" }]);
expect(body.user_id).toBe("user-1");
expect(result).toEqual({ id: "mem-1", memory: "test" });
});
it("add() passes messages directly when provided", async () => {
const mock = mockFetchResponse(200, { id: "mem-2" });
vi.stubGlobal("fetch", mock);
const messages = [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "Hello!" },
];
const backend = createBackend();
await backend.add(undefined, messages);
const body = JSON.parse(
(mock as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
);
expect(body.messages).toEqual(messages);
});
// -- search() ------------------------------------------------------------
it("search() sends POST to /v2/memories/search/", async () => {
const mock = mockFetchResponse(200, [
{ id: "mem-1", score: 0.95, memory: "test" },
]);
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const results = await backend.search("find this", { userId: "u1" });
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("https://api.mem0.ai/v2/memories/search/");
expect(opts.method).toBe("POST");
const body = JSON.parse(opts.body);
expect(body.query).toBe("find this");
expect(body.top_k).toBe(10);
expect(body.threshold).toBe(0.3);
expect(body.filters).toEqual({ user_id: "u1" });
expect(results).toEqual([{ id: "mem-1", score: 0.95, memory: "test" }]);
});
it("search() unwraps results from object envelope", async () => {
const mock = mockFetchResponse(200, {
results: [{ id: "mem-1" }],
});
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const results = await backend.search("query");
expect(results).toEqual([{ id: "mem-1" }]);
});
// -- get() ---------------------------------------------------------------
it("get() sends GET to /v1/memories/{id}/", async () => {
const mock = mockFetchResponse(200, { id: "mem-abc", memory: "test" });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.get("mem-abc");
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("https://api.mem0.ai/v1/memories/mem-abc/");
expect(opts.method).toBe("GET");
expect(result).toEqual({ id: "mem-abc", memory: "test" });
});
// -- delete() with memoryId ----------------------------------------------
it("delete() with memoryId sends DELETE to /v1/memories/{id}/", async () => {
const mock = mockFetchResponse(200, { deleted: true });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.delete("mem-del-1");
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("https://api.mem0.ai/v1/memories/mem-del-1/");
expect(opts.method).toBe("DELETE");
expect(result).toEqual({ deleted: true });
});
// -- delete() with all=true ----------------------------------------------
it("delete() with all=true sends DELETE to /v1/memories/ with scope params", async () => {
const mock = mockFetchResponse(200, { deleted: 5 });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.delete(undefined, {
all: true,
userId: "user-1",
agentId: "agent-1",
});
expect(mock).toHaveBeenCalledOnce();
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe(
"https://api.mem0.ai/v1/memories/?user_id=user-1&agent_id=agent-1",
);
expect(opts.method).toBe("DELETE");
expect(result).toEqual({ deleted: 5 });
});
// -- status() ------------------------------------------------------------
it("status() returns connected:true on successful ping", async () => {
const mock = mockFetchResponse(200, { status: "ok" });
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.status();
expect(result.connected).toBe(true);
expect(result.backend).toBe("platform");
expect(result.base_url).toBe(BASE_URL);
});
it("status() returns connected:false on failure", async () => {
const mock = vi
.fn()
.mockRejectedValue(new Error("Network error")) as unknown as typeof fetch;
vi.stubGlobal("fetch", mock);
const backend = createBackend();
const result = await backend.status();
expect(result.connected).toBe(false);
expect(result.backend).toBe("platform");
expect(result.error).toBe("Network error");
});
// -- Error handling ------------------------------------------------------
it("throws AuthError on 401", async () => {
const mock = mockFetchResponse(401, {}, "Unauthorized");
vi.stubGlobal("fetch", mock);
const backend = createBackend();
await expect(backend.get("mem-1")).rejects.toThrow(AuthError);
});
it("throws NotFoundError on 404", async () => {
const mock = mockFetchResponse(404, {}, "Not Found");
vi.stubGlobal("fetch", mock);
const backend = createBackend();
await expect(backend.get("mem-nonexistent")).rejects.toThrow(NotFoundError);
});
it("throws APIError on 400", async () => {
const mock = mockFetchResponse(
400,
{ detail: "Invalid request body" },
"Bad Request",
);
vi.stubGlobal("fetch", mock);
const backend = createBackend();
await expect(backend.add("bad data")).rejects.toThrow(APIError);
});
it("throws generic Error on other non-ok status", async () => {
const mock = mockFetchResponse(
500,
{ detail: "Internal server error" },
"Internal Server Error",
);
vi.stubGlobal("fetch", mock);
const backend = createBackend();
await expect(backend.get("mem-1")).rejects.toThrow("HTTP 500");
});
});
+502
View File
@@ -0,0 +1,502 @@
/**
* Tests for config.ts — mem0ConfigSchema.parse() and exported constants.
*/
import { describe, it, expect } from "vitest";
import {
mem0ConfigSchema,
DEFAULT_CUSTOM_INSTRUCTIONS,
DEFAULT_CUSTOM_CATEGORIES,
} from "../config.ts";
// ---------------------------------------------------------------------------
// Exported constants
// ---------------------------------------------------------------------------
describe("DEFAULT_CUSTOM_INSTRUCTIONS", () => {
it("is a non-empty string", () => {
expect(typeof DEFAULT_CUSTOM_INSTRUCTIONS).toBe("string");
expect(DEFAULT_CUSTOM_INSTRUCTIONS.length).toBeGreaterThan(0);
});
});
describe("DEFAULT_CUSTOM_CATEGORIES", () => {
it("is a non-empty object with string values", () => {
expect(typeof DEFAULT_CUSTOM_CATEGORIES).toBe("object");
const keys = Object.keys(DEFAULT_CUSTOM_CATEGORIES);
expect(keys.length).toBeGreaterThan(0);
for (const key of keys) {
expect(typeof DEFAULT_CUSTOM_CATEGORIES[key]).toBe("string");
}
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — defaults
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — defaults", () => {
it("mode defaults to 'platform' when omitted", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.mode).toBe("platform");
});
it("userId falls back to a non-empty string when not provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(typeof cfg.userId).toBe("string");
expect(cfg.userId.length).toBeGreaterThan(0);
});
it("autoCapture defaults to true", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.autoCapture).toBe(true);
});
it("autoRecall defaults to true", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.autoRecall).toBe(true);
});
it("topK defaults to 5", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.topK).toBe(5);
});
it("searchThreshold defaults to 0.5", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.searchThreshold).toBe(0.5);
});
it("enableGraph defaults to false", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.enableGraph).toBe(false);
});
it("customInstructions defaults to DEFAULT_CUSTOM_INSTRUCTIONS", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.customInstructions).toBe(DEFAULT_CUSTOM_INSTRUCTIONS);
});
it("customCategories defaults to DEFAULT_CUSTOM_CATEGORIES", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
});
it("customPrompt defaults to DEFAULT_CUSTOM_INSTRUCTIONS", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.customPrompt).toBe(DEFAULT_CUSTOM_INSTRUCTIONS);
});
it("oss defaults to undefined", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.oss).toBeUndefined();
});
it("skills defaults to undefined", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.skills).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — mode parsing
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — mode parsing", () => {
it('"oss" is normalized to "open-source"', () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss" });
expect(cfg.mode).toBe("open-source");
});
it('"open-source" stays as "open-source"', () => {
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
expect(cfg.mode).toBe("open-source");
});
it("any other string defaults to 'platform'", () => {
const cfg = mem0ConfigSchema.parse({ mode: "something-else", apiKey: "k" });
expect(cfg.mode).toBe("platform");
});
it("undefined mode defaults to 'platform'", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k" });
expect(cfg.mode).toBe("platform");
});
it("numeric mode defaults to 'platform'", () => {
const cfg = mem0ConfigSchema.parse({ mode: 42, apiKey: "k" });
expect(cfg.mode).toBe("platform");
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — userId precedence
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — userId", () => {
it("userId from config takes precedence over os.userInfo() fallback", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "test-key",
userId: "custom-user",
});
expect(cfg.userId).toBe("custom-user");
});
it("empty string userId falls back to os.userInfo()", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", userId: "" });
// Empty string is falsy, so the fallback should kick in
expect(typeof cfg.userId).toBe("string");
expect(cfg.userId.length).toBeGreaterThan(0);
});
it("non-string userId falls back to os.userInfo()", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", userId: 123 });
expect(typeof cfg.userId).toBe("string");
expect(cfg.userId.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — needsSetup
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — needsSetup", () => {
// Note: needsSetup = (mode === "platform" && !resolvedApiKey).
// resolvedApiKey can come from the config OR from ~/.mem0/config.json fallback.
// When no apiKey is provided and no config file exists, needsSetup is true.
// When ~/.mem0/config.json has a key, the fallback populates resolvedApiKey.
it("needsSetup is consistent: false when apiKey resolves, true otherwise (no apiKey in config)", () => {
const cfg = mem0ConfigSchema.parse({ mode: "platform" });
// needsSetup should be true only if NO apiKey was resolved (including from ~/.mem0/config.json)
if (cfg.apiKey) {
expect(cfg.needsSetup).toBe(false);
} else {
expect(cfg.needsSetup).toBe(true);
}
});
it("needsSetup is consistent with empty config", () => {
const cfg = mem0ConfigSchema.parse({});
if (cfg.apiKey) {
expect(cfg.needsSetup).toBe(false);
} else {
expect(cfg.needsSetup).toBe(true);
}
});
it("is false when apiKey is explicitly provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "my-api-key" });
expect(cfg.needsSetup).toBe(false);
});
it("is false when mode is open-source (no apiKey needed)", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss" });
expect(cfg.needsSetup).toBe(false);
});
it("is false when mode is 'open-source' (no apiKey needed)", () => {
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
expect(cfg.needsSetup).toBe(false);
});
it("needsSetup is always false when apiKey is a valid string", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key-123" });
expect(cfg.apiKey).toBe("test-key-123");
expect(cfg.needsSetup).toBe(false);
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — error cases
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — error cases", () => {
it("throws on unknown keys", () => {
expect(() =>
mem0ConfigSchema.parse({ apiKey: "k", unknownKey: "value" }),
).toThrow(/unknown keys.*unknownKey/);
});
it("throws when multiple unknown keys are present", () => {
expect(() =>
mem0ConfigSchema.parse({ apiKey: "k", foo: 1, bar: 2 }),
).toThrow(/unknown keys/);
});
it("throws on null input", () => {
expect(() => mem0ConfigSchema.parse(null)).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on undefined input", () => {
expect(() => mem0ConfigSchema.parse(undefined)).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on string input", () => {
expect(() => mem0ConfigSchema.parse("not an object")).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on number input", () => {
expect(() => mem0ConfigSchema.parse(42)).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on array input", () => {
expect(() => mem0ConfigSchema.parse([1, 2, 3])).toThrow(
"openclaw-mem0 config required",
);
});
it("throws on boolean input", () => {
expect(() => mem0ConfigSchema.parse(true)).toThrow(
"openclaw-mem0 config required",
);
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — explicit overrides
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — explicit overrides", () => {
it("autoCapture can be set to false", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
autoCapture: false,
});
expect(cfg.autoCapture).toBe(false);
});
it("autoRecall can be set to false", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
autoRecall: false,
});
expect(cfg.autoRecall).toBe(false);
});
it("enableGraph can be set to true", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
enableGraph: true,
});
expect(cfg.enableGraph).toBe(true);
});
it("custom topK is used when provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", topK: 20 });
expect(cfg.topK).toBe(20);
});
it("custom searchThreshold is used when provided", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
searchThreshold: 0.8,
});
expect(cfg.searchThreshold).toBe(0.8);
});
it("custom customInstructions override defaults", () => {
const custom = "My custom instructions";
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customInstructions: custom,
});
expect(cfg.customInstructions).toBe(custom);
});
it("custom customPrompt overrides defaults", () => {
const custom = "My custom prompt";
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customPrompt: custom,
});
expect(cfg.customPrompt).toBe(custom);
});
it("custom customCategories override defaults", () => {
const cats = { myCategory: "description" };
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customCategories: cats,
});
expect(cfg.customCategories).toEqual(cats);
});
it("baseUrl is passed through when provided", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
baseUrl: "https://custom.api.com",
});
expect(cfg.baseUrl).toBe("https://custom.api.com");
});
it("orgId is passed through when provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", orgId: "org-123" });
expect(cfg.orgId).toBe("org-123");
});
it("projectId is passed through when provided", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
projectId: "proj-456",
});
expect(cfg.projectId).toBe("proj-456");
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — oss config
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — oss config", () => {
it("parses oss object when provided", () => {
const ossConfig = {
embedder: {
provider: "openai",
config: { model: "text-embedding-3-small" },
},
vectorStore: { provider: "qdrant", config: { host: "localhost" } },
llm: { provider: "openai", config: { model: "gpt-4" } },
historyDbPath: "/tmp/history.db",
disableHistory: false,
};
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: ossConfig });
expect(cfg.oss).toEqual(ossConfig);
});
it("ignores oss when it is not a plain object", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: "not-an-object" });
expect(cfg.oss).toBeUndefined();
});
it("ignores oss when it is an array", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: [1, 2, 3] });
expect(cfg.oss).toBeUndefined();
});
it("ignores oss when it is null", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: null });
expect(cfg.oss).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — skills config
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — skills config", () => {
it("parses skills object when provided", () => {
const skillsConfig = {
triage: {
enabled: true,
importanceThreshold: 3,
enableGraph: false,
credentialPatterns: ["sk-", "ghp_"],
},
recall: {
enabled: true,
strategy: "smart" as const,
tokenBudget: 2000,
maxMemories: 10,
},
dream: {
enabled: true,
auto: true,
minHours: 12,
minSessions: 3,
minMemories: 15,
},
domain: "engineering",
customRules: {
include: ["tool configs"],
exclude: ["passwords"],
},
categories: {
identity: {
importance: 5,
ttl: null,
immutable: true,
},
},
};
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
skills: skillsConfig,
});
expect(cfg.skills).toEqual(skillsConfig);
});
it("skills is undefined when not provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k" });
expect(cfg.skills).toBeUndefined();
});
it("skills is undefined when set to a non-object value", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: "invalid" });
expect(cfg.skills).toBeUndefined();
});
it("skills is undefined when set to an array", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: [1, 2] });
expect(cfg.skills).toBeUndefined();
});
it("skills is undefined when set to null", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: null });
expect(cfg.skills).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — customCategories edge cases
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — customCategories edge cases", () => {
it("non-object customCategories falls back to defaults", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customCategories: "not-an-object",
});
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
});
it("array customCategories falls back to defaults", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customCategories: ["a", "b"],
});
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
});
it("null customCategories falls back to defaults", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
customCategories: null,
});
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
});
});
// ---------------------------------------------------------------------------
// mem0ConfigSchema.parse() — non-string apiKey
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — apiKey edge cases", () => {
// Note: When a non-string apiKey is provided, the parser treats it as
// undefined. However, readMem0ConfigFile() may still provide a fallback
// apiKey from ~/.mem0/config.json if one exists on the system.
it("non-string apiKey is not used directly from config", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: 12345 });
// The numeric value is not used directly — apiKey comes from fallback or is undefined
// Either way, the non-string value is never the resolved apiKey
expect(cfg.apiKey).not.toBe(12345);
});
it("boolean apiKey is not used directly from config", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: true });
expect(cfg.apiKey).not.toBe(true);
});
it("string apiKey takes precedence over any fallback", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "explicit-key" });
expect(cfg.apiKey).toBe("explicit-key");
expect(cfg.needsSetup).toBe(false);
});
});
+815
View File
@@ -0,0 +1,815 @@
/**
* Tests for the tool factory functions in tools/.
*
* Verifies each factory returns the expected shape (name, label,
* description, parameters, execute) and that execute() delegates
* to the correct provider/backend methods.
*/
import { describe, it, expect, vi } from "vitest";
import type { ToolContext } from "../tools/index.ts";
import { registerAllTools } from "../tools/index.ts";
import { createMemorySearchTool } from "../tools/memory-search.ts";
import { createMemoryStoreTool } from "../tools/memory-store.ts";
import { createMemoryGetTool } from "../tools/memory-get.ts";
import { createMemoryDeleteTool } from "../tools/memory-delete.ts";
import { createMemoryListTool } from "../tools/memory-list.ts";
// ---------------------------------------------------------------------------
// Mock helper
// ---------------------------------------------------------------------------
function createMockToolContext(overrides = {}): ToolContext {
return {
api: {
registerTool: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn() },
} as any,
backend: {
get: vi.fn().mockResolvedValue({ id: "test-id", memory: "test memory" }),
delete: vi.fn().mockResolvedValue(undefined),
status: vi.fn().mockResolvedValue({ connected: true }),
deleteEntities: vi.fn().mockResolvedValue(undefined),
} as any,
cfg: {
mode: "platform",
userId: "testuser",
topK: 5,
enableGraph: false,
autoCapture: true,
autoRecall: true,
searchThreshold: 0.5,
customInstructions: "test",
customCategories: {},
customPrompt: "test",
} as any,
provider: {
search: vi
.fn()
.mockResolvedValue([{ id: "m1", memory: "test memory", score: 0.9 }]),
add: vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored" }],
}),
getAll: vi.fn().mockResolvedValue([{ id: "m1", memory: "test memory" }]),
update: vi.fn().mockResolvedValue({ memory: "updated" }),
delete: vi.fn().mockResolvedValue(undefined),
deleteAll: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue({
id: "test-id",
memory: "test memory",
created_at: "2026-01-01",
updated_at: "2026-01-02",
}),
history: vi.fn().mockResolvedValue([]),
getHistory: vi.fn().mockResolvedValue([]),
} as any,
resolveUserId: vi.fn().mockReturnValue("testuser"),
effectiveUserId: vi.fn().mockReturnValue("testuser"),
agentUserId: vi.fn().mockReturnValue("testuser:agent:test"),
getCurrentSessionId: vi.fn().mockReturnValue(undefined),
skillsActive: false,
buildAddOptions: vi
.fn()
.mockReturnValue({ user_id: "testuser", source: "OPENCLAW" }),
buildSearchOptions: vi
.fn()
.mockReturnValue({ user_id: "testuser", top_k: 5, source: "OPENCLAW" }),
...overrides,
};
}
// ---------------------------------------------------------------------------
// registerAllTools
// ---------------------------------------------------------------------------
describe("registerAllTools", () => {
it("calls api.registerTool exactly 7 times", () => {
const ctx = createMockToolContext();
registerAllTools(ctx);
expect(ctx.api.registerTool).toHaveBeenCalledTimes(7);
});
it("registers tools with the correct names", () => {
const ctx = createMockToolContext();
registerAllTools(ctx);
const names = (
ctx.api.registerTool as ReturnType<typeof vi.fn>
).mock.calls.map((call: unknown[]) => (call[1] as { name: string }).name);
expect(names).toEqual([
"memory_search",
"memory_store",
"memory_get",
"memory_list",
"memory_update",
"memory_delete",
"memory_history",
]);
});
});
// ---------------------------------------------------------------------------
// Tool factory shape checks
// ---------------------------------------------------------------------------
describe("tool factory shape", () => {
const factories = [
{ fn: createMemorySearchTool, expectedName: "memory_search" },
{ fn: createMemoryStoreTool, expectedName: "memory_store" },
{ fn: createMemoryGetTool, expectedName: "memory_get" },
{ fn: createMemoryDeleteTool, expectedName: "memory_delete" },
{ fn: createMemoryListTool, expectedName: "memory_list" },
];
for (const { fn, expectedName } of factories) {
describe(expectedName, () => {
it("returns an object with name, label, description, parameters, and execute", () => {
const ctx = createMockToolContext();
const tool = fn(ctx);
expect(tool.name).toBe(expectedName);
expect(typeof tool.label).toBe("string");
expect(tool.label.length).toBeGreaterThan(0);
expect(typeof tool.description).toBe("string");
expect(tool.description.length).toBeGreaterThan(0);
expect(tool.parameters).toBeDefined();
expect(typeof tool.execute).toBe("function");
});
});
}
});
// ---------------------------------------------------------------------------
// memory_search execute
// ---------------------------------------------------------------------------
describe("memory_search execute", () => {
it("returns formatted results when provider returns matches", async () => {
const ctx = createMockToolContext();
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-1", {
query: "user preferences",
});
expect(ctx.provider!.search).toHaveBeenCalled();
expect(result.content[0].text).toContain("Found 1 memories");
expect(result.content[0].text).toContain("test memory");
expect(result.content[0].text).toContain("90%");
expect(result.details.count).toBe(1);
expect(result.details.memories).toHaveLength(1);
expect(result.details.memories[0].id).toBe("m1");
});
it("returns 'no relevant memories' when provider returns empty", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-2", { query: "nothing" });
expect(result.content[0].text).toBe("No relevant memories found.");
expect(result.details.count).toBe(0);
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn().mockRejectedValue(new Error("network failure")),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-3", { query: "test" });
expect(result.content[0].text).toContain("Memory search failed");
expect(result.content[0].text).toContain("network failure");
expect(result.details.error).toContain("network failure");
});
it("calls resolveUserId with provided agentId and userId", async () => {
const ctx = createMockToolContext();
const tool = createMemorySearchTool(ctx);
await tool.execute("call-4", {
query: "test",
agentId: "researcher",
userId: "alice",
});
expect(ctx.resolveUserId).toHaveBeenCalledWith({
agentId: "researcher",
userId: "alice",
});
});
it("passes limit to buildSearchOptions", async () => {
const ctx = createMockToolContext();
const tool = createMemorySearchTool(ctx);
await tool.execute("call-5", { query: "test", limit: 10 });
expect(ctx.buildSearchOptions).toHaveBeenCalledWith("testuser", 10);
});
it("searches only session scope when scope='session' and session exists", async () => {
const searchMock = vi
.fn()
.mockResolvedValue([{ id: "s1", memory: "session mem", score: 0.8 }]);
const ctx = createMockToolContext({
getCurrentSessionId: vi.fn().mockReturnValue("session-abc"),
provider: {
search: searchMock,
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-6", {
query: "test",
scope: "session",
});
// Should call buildSearchOptions with session ID
expect(ctx.buildSearchOptions).toHaveBeenCalledWith(
"testuser",
undefined,
"session-abc",
);
expect(result.details.count).toBe(1);
});
it("deduplicates results in 'all' scope", async () => {
const searchMock = vi
.fn()
// First call: long-term
.mockResolvedValueOnce([
{ id: "m1", memory: "shared memory", score: 0.95 },
])
// Second call: session
.mockResolvedValueOnce([
{ id: "m1", memory: "shared memory", score: 0.85 },
{ id: "m2", memory: "session only", score: 0.7 },
]);
const ctx = createMockToolContext({
getCurrentSessionId: vi.fn().mockReturnValue("session-xyz"),
provider: {
search: searchMock,
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-7", {
query: "test",
scope: "all",
});
// m1 appears only once (from long-term), m2 is session-only
expect(result.details.count).toBe(2);
const ids = result.details.memories.map((m: any) => m.id);
expect(ids).toEqual(["m1", "m2"]);
});
});
// ---------------------------------------------------------------------------
// memory_store execute
// ---------------------------------------------------------------------------
describe("memory_store execute", () => {
it("calls provider.add with the text and returns stored result", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-1", {
text: "User prefers dark mode",
});
expect(ctx.provider!.add).toHaveBeenCalled();
const addCall = (ctx.provider!.add as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(addCall[0]).toEqual([
{ role: "user", content: "User prefers dark mode" },
]);
expect(result.content[0].text).toContain("Stored");
expect(result.details.action).toBe("stored");
});
it("returns error when no text or facts are provided", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-2", {});
expect(result.content[0].text).toContain("No facts provided");
expect(result.details.error).toBe("missing_facts");
});
it("supports facts array", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-3", {
facts: ["fact one", "fact two"],
});
expect(ctx.provider!.add).toHaveBeenCalled();
const addCall = (ctx.provider!.add as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(addCall[0]).toEqual([
{ role: "user", content: "fact one\nfact two" },
]);
expect(result.details.action).toBe("stored");
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn().mockRejectedValue(new Error("API error")),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-4", { text: "test" });
expect(result.content[0].text).toContain("Memory store failed");
expect(result.details.error).toContain("API error");
});
it("uses skills mode with infer=false when skillsActive is true", async () => {
const addMock = vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored in skills mode" }],
});
const ctx = createMockToolContext({
skillsActive: true,
provider: {
search: vi.fn().mockResolvedValue([]),
add: addMock,
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-5", {
text: "skills fact",
category: "preference",
});
expect(addMock).toHaveBeenCalledOnce();
const addOpts = addMock.mock.calls[0][1];
expect(addOpts.infer).toBe(false);
expect(addOpts.source).toBe("OPENCLAW");
expect(result.details.mode).toBe("skills");
expect(result.details.category).toBe("preference");
});
it("blocks subagent sessions from storing", async () => {
const ctx = createMockToolContext({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-123"),
});
const tool = createMemoryStoreTool(ctx);
const result = await tool.execute("call-6", { text: "subagent fact" });
expect(ctx.provider!.add).not.toHaveBeenCalled();
expect(result.details.error).toBe("subagent_blocked");
});
it("performs dedup search before adding in legacy mode", async () => {
const searchMock = vi.fn().mockResolvedValue([]);
const addMock = vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored" }],
});
const ctx = createMockToolContext({
skillsActive: false,
provider: {
search: searchMock,
add: addMock,
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
await tool.execute("call-7", { text: "new fact" });
// Search should be called for dedup before add
expect(searchMock).toHaveBeenCalledOnce();
expect(addMock).toHaveBeenCalledOnce();
});
});
// ---------------------------------------------------------------------------
// memory_get execute
// ---------------------------------------------------------------------------
describe("memory_get execute", () => {
it("calls provider.get with the memoryId and returns formatted result", async () => {
const ctx = createMockToolContext();
const tool = createMemoryGetTool(ctx);
const result = await tool.execute("call-1", { memoryId: "test-id" });
expect(ctx.provider!.get).toHaveBeenCalledWith("test-id");
expect(result.content[0].text).toContain("Memory test-id");
expect(result.content[0].text).toContain("test memory");
expect(result.content[0].text).toContain("Created:");
expect(result.details.memory).toBeDefined();
expect(result.details.memory.id).toBe("test-id");
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn().mockRejectedValue(new Error("not found")),
history: vi.fn(),
},
});
const tool = createMemoryGetTool(ctx);
const result = await tool.execute("call-2", { memoryId: "bad-id" });
expect(result.content[0].text).toContain("Memory get failed");
expect(result.details.error).toContain("not found");
});
});
// ---------------------------------------------------------------------------
// memory_delete execute
// ---------------------------------------------------------------------------
describe("memory_delete execute", () => {
it("deletes by memory_id via provider.delete", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-1", { memory_id: "mem-abc" });
expect(ctx.provider!.delete).toHaveBeenCalledWith("mem-abc");
expect(result.content[0].text).toBe("Memory mem-abc deleted.");
expect(result.details.action).toBe("deleted");
expect(result.details.id).toBe("mem-abc");
});
it("searches and auto-deletes single high-confidence match by query", async () => {
const searchMock = vi
.fn()
.mockResolvedValue([{ id: "m1", memory: "match", score: 0.95 }]);
const deleteMock = vi.fn().mockResolvedValue(undefined);
const ctx = createMockToolContext({
provider: {
search: searchMock,
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: deleteMock,
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-2", {
query: "find and delete",
});
expect(searchMock).toHaveBeenCalled();
expect(deleteMock).toHaveBeenCalledWith("m1");
expect(result.content[0].text).toContain("Deleted:");
expect(result.details.action).toBe("deleted");
});
it("returns candidates when query matches multiple ambiguous results", async () => {
const searchMock = vi.fn().mockResolvedValue([
{ id: "m1", memory: "candidate one", score: 0.7 },
{ id: "m2", memory: "candidate two", score: 0.6 },
]);
const deleteMock = vi.fn();
const ctx = createMockToolContext({
provider: {
search: searchMock,
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: deleteMock,
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-3", {
query: "ambiguous",
});
// Should NOT have called delete
expect(deleteMock).not.toHaveBeenCalled();
expect(result.content[0].text).toContain("Found 2 candidates");
expect(result.details.action).toBe("candidates");
expect(result.details.candidates).toHaveLength(2);
});
it("returns no matching memories when query yields empty results", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-4", { query: "nothing" });
expect(result.content[0].text).toBe("No matching memories found.");
expect(result.details.found).toBe(0);
});
it("requires confirm:true for bulk delete (all)", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-5", { all: true });
expect(result.content[0].text).toContain("confirm: true");
expect(result.details.error).toBe("confirmation_required");
});
it("performs bulk delete when all:true and confirm:true", async () => {
const deleteAllMock = vi.fn().mockResolvedValue(undefined);
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteAll: deleteAllMock,
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-6", {
all: true,
confirm: true,
});
expect(deleteAllMock).toHaveBeenCalledWith("testuser");
expect(result.content[0].text).toContain("All memories deleted");
expect(result.details.action).toBe("deleted_all");
});
it("requires confirm:true for entity cascade delete", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-7", { entity: true });
expect(result.details.error).toBe("confirmation_required");
});
it("performs entity cascade delete when entity:true and confirm:true", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-8", {
entity: true,
confirm: true,
user_id: "alice",
});
expect(ctx.backend.deleteEntities).toHaveBeenCalledWith({
userId: "alice",
});
expect(result.details.action).toBe("entity_deleted");
});
it("returns error when no mode param is specified", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-9", {});
expect(result.content[0].text).toContain(
"Provide memory_id, query, all, or entity",
);
expect(result.details.error).toBe("missing_param");
});
it("blocks subagent sessions from deleting", async () => {
const ctx = createMockToolContext({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-456"),
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-10", { memory_id: "m1" });
expect(ctx.provider!.delete).not.toHaveBeenCalled();
expect(result.details.error).toBe("subagent_blocked");
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn().mockRejectedValue(new Error("delete failed")),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-11", { memory_id: "m1" });
expect(result.content[0].text).toContain("Memory delete failed");
expect(result.details.error).toContain("delete failed");
});
});
// ---------------------------------------------------------------------------
// memory_list execute
// ---------------------------------------------------------------------------
describe("memory_list execute", () => {
it("calls provider.getAll and returns formatted list", async () => {
const ctx = createMockToolContext();
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-1", {});
expect(ctx.provider!.getAll).toHaveBeenCalled();
expect(result.content[0].text).toContain("1 memories");
expect(result.content[0].text).toContain("test memory");
expect(result.details.count).toBe(1);
expect(result.details.memories).toHaveLength(1);
});
it("returns 'no memories stored' when provider returns empty", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn().mockResolvedValue([]),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-2", {});
expect(result.content[0].text).toBe("No memories stored yet.");
expect(result.details.count).toBe(0);
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn().mockRejectedValue(new Error("list failed")),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-3", {});
expect(result.content[0].text).toContain("Memory list failed");
expect(result.details.error).toContain("list failed");
});
it("resolves userId from agentId", async () => {
const ctx = createMockToolContext();
const tool = createMemoryListTool(ctx);
await tool.execute("call-4", { agentId: "researcher" });
expect(ctx.resolveUserId).toHaveBeenCalledWith({
agentId: "researcher",
userId: undefined,
});
});
it("deduplicates results in 'all' scope", async () => {
const getAllMock = vi
.fn()
// First call: long-term
.mockResolvedValueOnce([{ id: "m1", memory: "shared" }])
// Second call: session
.mockResolvedValueOnce([
{ id: "m1", memory: "shared" },
{ id: "m2", memory: "session only" },
]);
const ctx = createMockToolContext({
getCurrentSessionId: vi.fn().mockReturnValue("session-123"),
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: getAllMock,
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-5", { scope: "all" });
expect(result.details.count).toBe(2);
const ids = result.details.memories.map((m: any) => m.id);
expect(ids).toEqual(["m1", "m2"]);
});
it("only fetches session memories when scope='session'", async () => {
const getAllMock = vi
.fn()
.mockResolvedValue([{ id: "s1", memory: "session mem" }]);
const ctx = createMockToolContext({
getCurrentSessionId: vi.fn().mockReturnValue("sess-abc"),
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: getAllMock,
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-6", { scope: "session" });
// Should call getAll once with run_id
expect(getAllMock).toHaveBeenCalledOnce();
const opts = getAllMock.mock.calls[0][0];
expect(opts.run_id).toBe("sess-abc");
expect(result.details.count).toBe(1);
});
});