refactor: consolidate agent/editor plugins under integrations/ (#5491)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Tests for cli/config-file.ts — file-based config helpers.
|
||||
*
|
||||
* All filesystem operations are mocked via fs-safe.ts so tests never
|
||||
* touch the real disk.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../fs-safe.ts", () => ({
|
||||
readText: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readText, exists, writeText, mkdirp } from "../fs-safe.ts";
|
||||
import {
|
||||
readPluginAuth,
|
||||
writePluginAuth,
|
||||
getBaseUrl,
|
||||
DEFAULT_BASE_URL,
|
||||
} from "../cli/config-file.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockExists = exists as ReturnType<typeof vi.fn>;
|
||||
const mockReadText = readText as ReturnType<typeof vi.fn>;
|
||||
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
|
||||
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
|
||||
|
||||
function setConfigFile(obj: Record<string, unknown>): void {
|
||||
mockExists.mockReturnValue(true);
|
||||
mockReadText.mockReturnValue(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function setNoFile(): void {
|
||||
mockExists.mockReturnValue(false);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readPluginAuth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readPluginAuth", () => {
|
||||
it("returns empty object when config file does not exist", () => {
|
||||
setNoFile();
|
||||
expect(readPluginAuth()).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object when config has no plugins section", () => {
|
||||
setConfigFile({ someOtherKey: true });
|
||||
expect(readPluginAuth()).toEqual({});
|
||||
});
|
||||
|
||||
it("reads all fields correctly from nested config", () => {
|
||||
setConfigFile({
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: "sk-test-123",
|
||||
baseUrl: "https://custom.api.com",
|
||||
userId: "user-1",
|
||||
mode: "platform",
|
||||
autoRecall: true,
|
||||
autoCapture: false,
|
||||
topK: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auth = readPluginAuth();
|
||||
expect(auth).toEqual({
|
||||
apiKey: "sk-test-123",
|
||||
baseUrl: "https://custom.api.com",
|
||||
userId: "user-1",
|
||||
mode: "platform",
|
||||
autoRecall: true,
|
||||
autoCapture: false,
|
||||
topK: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles snake_case aliases (api_key, base_url, user_id)", () => {
|
||||
setConfigFile({
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
enabled: true,
|
||||
config: {
|
||||
api_key: "sk-snake",
|
||||
base_url: "https://snake.api.com",
|
||||
user_id: "user-snake",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auth = readPluginAuth();
|
||||
expect(auth.apiKey).toBe("sk-snake");
|
||||
expect(auth.baseUrl).toBe("https://snake.api.com");
|
||||
expect(auth.userId).toBe("user-snake");
|
||||
});
|
||||
|
||||
it("throws error when JSON is invalid (prevents config destruction)", () => {
|
||||
mockExists.mockReturnValue(true);
|
||||
mockReadText.mockReturnValue("not valid json {{{");
|
||||
expect(() => readPluginAuth()).toThrow(/Failed to parse[\s\S]*Fix the JSON syntax error/);
|
||||
});
|
||||
|
||||
it("returns empty object when config file is empty", () => {
|
||||
mockExists.mockReturnValue(true);
|
||||
mockReadText.mockReturnValue(" ");
|
||||
expect(readPluginAuth()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writePluginAuth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("writePluginAuth", () => {
|
||||
it("creates nested structure from scratch when no config exists", () => {
|
||||
setNoFile();
|
||||
// exists returns false for both the file (readFullConfig) and the dir (writeFullConfig)
|
||||
mockExists.mockReturnValue(false);
|
||||
|
||||
writePluginAuth({ apiKey: "sk-new", userId: "u1" });
|
||||
|
||||
expect(mockMkdirp).toHaveBeenCalled();
|
||||
expect(mockWriteText).toHaveBeenCalledOnce();
|
||||
|
||||
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
|
||||
expect(written.plugins.entries["openclaw-mem0"].enabled).toBe(true);
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
|
||||
"sk-new",
|
||||
);
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("merges into existing config preserving other data", () => {
|
||||
setConfigFile({
|
||||
otherSetting: "keep-me",
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: "sk-old",
|
||||
mode: "platform",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writePluginAuth({ baseUrl: "https://new.api.com" });
|
||||
|
||||
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
|
||||
// Existing fields preserved
|
||||
expect(written.otherSetting).toBe("keep-me");
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
|
||||
"sk-old",
|
||||
);
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.mode).toBe(
|
||||
"platform",
|
||||
);
|
||||
// New field added
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.baseUrl).toBe(
|
||||
"https://new.api.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates directory if missing", () => {
|
||||
// File doesn't exist (readFullConfig returns {}), dir doesn't exist
|
||||
mockExists.mockReturnValue(false);
|
||||
|
||||
writePluginAuth({ apiKey: "sk-test" });
|
||||
|
||||
expect(mockMkdirp).toHaveBeenCalledWith(
|
||||
expect.stringContaining(".openclaw"),
|
||||
0o700,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips undefined values", () => {
|
||||
setNoFile();
|
||||
mockExists.mockReturnValue(false);
|
||||
|
||||
writePluginAuth({
|
||||
apiKey: "sk-set",
|
||||
baseUrl: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
|
||||
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
|
||||
const cfg = written.plugins.entries["openclaw-mem0"].config;
|
||||
expect(cfg.apiKey).toBe("sk-set");
|
||||
expect(cfg).not.toHaveProperty("baseUrl");
|
||||
expect(cfg).not.toHaveProperty("userId");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getBaseUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getBaseUrl", () => {
|
||||
it("returns configured URL when baseUrl is set", () => {
|
||||
setConfigFile({
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
enabled: true,
|
||||
config: { baseUrl: "https://custom.example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getBaseUrl()).toBe("https://custom.example.com");
|
||||
});
|
||||
|
||||
it("returns default URL when baseUrl is not configured", () => {
|
||||
setNoFile();
|
||||
expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* 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.1", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.searchThreshold).toBe(0.1);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// v3.0.0: customPrompt removed, use customInstructions instead
|
||||
it("customPrompt input falls back to customInstructions", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", customPrompt: "My prompt" });
|
||||
expect(cfg.customInstructions).toBe("My prompt");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("allows anonymousTelemetryId", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", anonymousTelemetryId: "123" });
|
||||
expect(cfg.anonymousTelemetryId).toBe("123");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — mode parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — mode parsing", () => {
|
||||
it('"oss" is not a valid mode and defaults to "platform"', () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "oss", apiKey: "k" });
|
||||
expect(cfg.mode).toBe("platform");
|
||||
});
|
||||
|
||||
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' via explicit string (no apiKey needed)", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
|
||||
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("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);
|
||||
});
|
||||
|
||||
// v3.0.0: customPrompt renamed to customInstructions (backwards compat: customPrompt maps to customInstructions)
|
||||
it("customPrompt input maps to customInstructions output", () => {
|
||||
const custom = "My custom prompt";
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
customPrompt: custom,
|
||||
});
|
||||
expect(cfg.customInstructions).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");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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: "open-source", oss: ossConfig });
|
||||
expect(cfg.mode).toBe("open-source");
|
||||
expect(cfg.oss).toEqual(ossConfig);
|
||||
});
|
||||
|
||||
it("ignores oss when it is not a plain object", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: "not-an-object" });
|
||||
expect(cfg.oss).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores oss when it is an array", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: [1, 2, 3] });
|
||||
expect(cfg.oss).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores oss when it is null", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source", 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,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Tests for dream-gate.ts — activity tracking, gate logic, and lock mechanism
|
||||
* for automatic memory consolidation.
|
||||
*
|
||||
* All filesystem operations are mocked via fs-safe.ts.
|
||||
* Time-dependent tests use vi.useFakeTimers().
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../fs-safe.ts", () => ({
|
||||
readText: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readText, writeText, mkdirp, unlink } from "../fs-safe.ts";
|
||||
import {
|
||||
incrementSessionCount,
|
||||
checkCheapGates,
|
||||
checkMemoryGate,
|
||||
acquireDreamLock,
|
||||
releaseDreamLock,
|
||||
recordDreamCompletion,
|
||||
getDreamState,
|
||||
} from "../dream-gate.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockReadText = readText as ReturnType<typeof vi.fn>;
|
||||
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
|
||||
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
|
||||
const mockUnlink = unlink as ReturnType<typeof vi.fn>;
|
||||
|
||||
const STATE_DIR = "/tmp/test-state";
|
||||
|
||||
interface DreamState {
|
||||
lastConsolidatedAt: number;
|
||||
sessionsSince: number;
|
||||
lastSessionId: string | null;
|
||||
}
|
||||
|
||||
function setDreamState(state: DreamState): void {
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream-state.json")) {
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
}
|
||||
|
||||
function setNoState(): void {
|
||||
mockReadText.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
}
|
||||
|
||||
function getWrittenState(): DreamState {
|
||||
const call = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream-state.json"),
|
||||
);
|
||||
if (!call) throw new Error("No state file written");
|
||||
return JSON.parse(call[1] as string);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockMkdirp.mockReturnValue(undefined);
|
||||
mockUnlink.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// incrementSessionCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("incrementSessionCount", () => {
|
||||
it("increments counter for a new session", () => {
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 3,
|
||||
lastSessionId: "session-old",
|
||||
});
|
||||
|
||||
incrementSessionCount(STATE_DIR, "session-new");
|
||||
|
||||
const written = getWrittenState();
|
||||
expect(written.sessionsSince).toBe(4);
|
||||
expect(written.lastSessionId).toBe("session-new");
|
||||
});
|
||||
|
||||
it("deduplicates same session (no increment)", () => {
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 3,
|
||||
lastSessionId: "session-same",
|
||||
});
|
||||
|
||||
incrementSessionCount(STATE_DIR, "session-same");
|
||||
|
||||
// writeText should NOT have been called for the state file
|
||||
const stateWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream-state.json"),
|
||||
);
|
||||
expect(stateWrites).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkCheapGates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCheapGates", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fails time gate when consolidation was too recent", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Last consolidated 1 hour ago, but minHours is 24
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 1 * 3_600_000,
|
||||
sessionsSince: 100,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, { minHours: 24, minSessions: 5 });
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("time");
|
||||
});
|
||||
|
||||
it("fails session gate when too few sessions", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Last consolidated 48 hours ago (passes time gate), but only 2 sessions
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 48 * 3_600_000,
|
||||
sessionsSince: 2,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
});
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("sessions");
|
||||
});
|
||||
|
||||
it("passes both gates when conditions are met", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// 48 hours ago, 10 sessions — both gates pass
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 48 * 3_600_000,
|
||||
sessionsSince: 10,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
});
|
||||
expect(result.proceed).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses defaults when config is empty", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Never consolidated (0), 100 sessions — should pass with defaults (24h, 5 sessions)
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 100,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {});
|
||||
expect(result.proceed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkMemoryGate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkMemoryGate", () => {
|
||||
it("fails when too few memories", () => {
|
||||
const result = checkMemoryGate(5, { minMemories: 20 });
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.reason).toContain("memories");
|
||||
expect(result.reason).toContain("5");
|
||||
});
|
||||
|
||||
it("passes when enough memories", () => {
|
||||
const result = checkMemoryGate(25, { minMemories: 20 });
|
||||
expect(result.pass).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acquireDreamLock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("acquireDreamLock", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("succeeds when no lock exists", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// readText throws for lock file (not found), writeText succeeds for wx create
|
||||
mockReadText.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockWriteText.mockReturnValue(undefined);
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify it wrote a lock file with wx flag
|
||||
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrite).toBeDefined();
|
||||
const lockData = JSON.parse(lockWrite![1] as string);
|
||||
expect(lockData.pid).toBe(process.pid);
|
||||
expect(lockData.startedAt).toBe(now);
|
||||
expect(lockWrite![2]).toEqual({ flag: "wx" });
|
||||
});
|
||||
|
||||
it("fails when lock exists and is fresh", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Lock was created 10 minutes ago — still fresh (< 1 hour)
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream.lock")) {
|
||||
return JSON.stringify({
|
||||
pid: 12345,
|
||||
startedAt: now - 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Should NOT have written a new lock
|
||||
const lockWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("succeeds when lock is stale (>1hr old)", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Lock was created 2 hours ago — stale
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream.lock")) {
|
||||
return JSON.stringify({
|
||||
pid: 99999,
|
||||
startedAt: now - 2 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockWriteText.mockReturnValue(undefined);
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Should have unlinked the stale lock
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
|
||||
// Should have written a new lock
|
||||
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrite).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// releaseDreamLock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("releaseDreamLock", () => {
|
||||
it("removes lock file", () => {
|
||||
releaseDreamLock(STATE_DIR);
|
||||
expect(mockUnlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("dream.lock"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// recordDreamCompletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("recordDreamCompletion", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resets session counter and records timestamp", () => {
|
||||
const now = 1700000000000;
|
||||
vi.setSystemTime(now);
|
||||
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 15,
|
||||
lastSessionId: "session-xyz",
|
||||
});
|
||||
|
||||
recordDreamCompletion(STATE_DIR);
|
||||
|
||||
const written = getWrittenState();
|
||||
expect(written.lastConsolidatedAt).toBe(now);
|
||||
expect(written.sessionsSince).toBe(0);
|
||||
expect(written.lastSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getDreamState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getDreamState", () => {
|
||||
it("returns default state when no file exists", () => {
|
||||
setNoState();
|
||||
|
||||
const state = getDreamState(STATE_DIR);
|
||||
expect(state).toEqual({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 0,
|
||||
lastSessionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns persisted state when file exists", () => {
|
||||
const persisted = {
|
||||
lastConsolidatedAt: 1700000000000,
|
||||
sessionsSince: 7,
|
||||
lastSessionId: "session-abc",
|
||||
};
|
||||
setDreamState(persisted);
|
||||
|
||||
const state = getDreamState(STATE_DIR);
|
||||
expect(state).toEqual(persisted);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isNoiseMessage,
|
||||
isGenericAssistantMessage,
|
||||
isSessionSpecificContent,
|
||||
stripNoiseFromContent,
|
||||
filterMessagesForExtraction,
|
||||
} from "../filtering.ts";
|
||||
|
||||
describe("isNoiseMessage", () => {
|
||||
it("returns true for heartbeat messages", () => {
|
||||
expect(isNoiseMessage("HEARTBEAT_OK")).toBe(true);
|
||||
expect(isNoiseMessage("NO_REPLY")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for single-word acknowledgments", () => {
|
||||
expect(isNoiseMessage("ok")).toBe(true);
|
||||
expect(isNoiseMessage("done")).toBe(true);
|
||||
expect(isNoiseMessage("thanks")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for substantive content", () => {
|
||||
expect(isNoiseMessage("My name is John and I live in Tokyo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGenericAssistantMessage", () => {
|
||||
it("returns true for generic acknowledgments", () => {
|
||||
expect(isGenericAssistantMessage("I see you've shared this. How can I help?")).toBe(true);
|
||||
expect(isGenericAssistantMessage("Got it! How can I assist you?")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for substantive responses", () => {
|
||||
expect(isGenericAssistantMessage("Based on the code, the bug is in line 42 where the null check is missing.")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSessionSpecificContent", () => {
|
||||
it("returns true for tool availability discussions", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"I do not currently see Mem0 write/update/delete tools exposed in this session."
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for plugin capability statements", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"As of 2026-04-18, the openclaw-mem0 plugin does not expose a memory wiki capability."
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for session-specific tool lists", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"The tools I have access to in this session are memory_search and memory_get."
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for user preferences", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"My name is Kartik and I like to watch Pokemon."
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for technical facts that span sessions", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"The project uses TypeScript 5.0 and Node.js 20."
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterMessagesForExtraction", () => {
|
||||
it("filters out session-specific tool discussions", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What tools do you have access to?" },
|
||||
{ role: "assistant", content: "I have memory_search and memory_get tools. The other tools are not exposed in this session." },
|
||||
{ role: "user", content: "My name is Kartik" },
|
||||
{ role: "assistant", content: "Got it, I'll remember that!" },
|
||||
];
|
||||
|
||||
const filtered = filterMessagesForExtraction(messages);
|
||||
|
||||
// Should keep the name statement, filter the tool discussion
|
||||
expect(filtered.some(m => m.content.includes("Kartik"))).toBe(true);
|
||||
expect(filtered.some(m => m.content.includes("not exposed in this session"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps valuable user facts", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "I prefer dark mode and use VS Code for development." },
|
||||
{ role: "assistant", content: "Noted! Dark mode in VS Code is great for reducing eye strain." },
|
||||
];
|
||||
|
||||
const filtered = filterMessagesForExtraction(messages);
|
||||
expect(filtered.length).toBe(2);
|
||||
expect(filtered[0].content).toContain("dark mode");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { bootstrapTelemetryFlag } from "../fs-safe.ts";
|
||||
|
||||
describe("bootstrapTelemetryFlag", () => {
|
||||
beforeEach(() => {
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("sets globalThis override when MEM0_TELEMETRY is set", () => {
|
||||
vi.stubEnv("MEM0_TELEMETRY", "false");
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBe("false");
|
||||
});
|
||||
|
||||
it("does not set globalThis override when MEM0_TELEMETRY is unset", () => {
|
||||
vi.stubEnv("MEM0_TELEMETRY", undefined as unknown as string);
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through truthy values", () => {
|
||||
vi.stubEnv("MEM0_TELEMETRY", "true");
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { jsonOut, jsonErr, redactSecrets } from "../cli/json-helpers.ts";
|
||||
|
||||
describe("jsonOut", () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => { writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); });
|
||||
afterEach(() => { writeSpy.mockRestore(); });
|
||||
|
||||
it("returns false and prints nothing when json is falsy", () => {
|
||||
expect(jsonOut({}, { ok: true })).toBe(false);
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true and prints JSON to stdout when json is true", () => {
|
||||
expect(jsonOut({ json: true }, { ok: true, count: 3 })).toBe(true);
|
||||
expect(writeSpy).toHaveBeenCalledOnce();
|
||||
const parsed = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
||||
expect(parsed).toEqual({ ok: true, count: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonErr", () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => { writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); });
|
||||
afterEach(() => { writeSpy.mockRestore(); });
|
||||
|
||||
it("returns false when json is falsy", () => {
|
||||
expect(jsonErr({}, "bad")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true and prints error JSON to stdout", () => {
|
||||
expect(jsonErr({ json: true }, "Something broke")).toBe(true);
|
||||
const parsed = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
||||
expect(parsed).toEqual({ ok: false, error: "Something broke" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactSecrets", () => {
|
||||
it("redacts string values for known secret keys", () => {
|
||||
const input = { apiKey: "m0-abcdefghijklmnop", name: "test" };
|
||||
const result = redactSecrets(input, new Set(["apiKey"]));
|
||||
expect(result.apiKey).toBe("m0-a...mnop");
|
||||
expect(result.name).toBe("test");
|
||||
});
|
||||
|
||||
it("handles short keys", () => {
|
||||
const result = redactSecrets({ apiKey: "ab" }, new Set(["apiKey"]));
|
||||
expect(result.apiKey).toBe("ab***");
|
||||
});
|
||||
|
||||
it("skips non-string values", () => {
|
||||
const result = redactSecrets({ count: 5 }, new Set(["count"]));
|
||||
expect(result.count).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
LLM_PROVIDERS,
|
||||
EMBEDDER_PROVIDERS,
|
||||
VECTOR_PROVIDERS,
|
||||
KNOWN_EMBEDDER_DIMS,
|
||||
buildOssLlmConfig,
|
||||
buildOssEmbedderConfig,
|
||||
buildOssVectorConfig,
|
||||
collectionNameForDims,
|
||||
validateOssFlags,
|
||||
checkQdrantConnectivity,
|
||||
checkOllamaConnectivity,
|
||||
checkPgConnectivity,
|
||||
} from "../cli/oss-wizard.ts";
|
||||
|
||||
describe("LLM_PROVIDERS", () => {
|
||||
it("has 3 providers", () => {
|
||||
expect(LLM_PROVIDERS).toHaveLength(3);
|
||||
expect(LLM_PROVIDERS.map((p) => p.id)).toEqual(["openai", "ollama", "anthropic"]);
|
||||
});
|
||||
|
||||
it("openai requires API key", () => {
|
||||
const openai = LLM_PROVIDERS.find((p) => p.id === "openai")!;
|
||||
expect(openai.needsApiKey).toBe(true);
|
||||
expect(openai.defaultModel).toBe("gpt-5-mini");
|
||||
});
|
||||
|
||||
it("ollama needs no API key but needs URL", () => {
|
||||
const ollama = LLM_PROVIDERS.find((p) => p.id === "ollama")!;
|
||||
expect(ollama.needsApiKey).toBe(false);
|
||||
expect(ollama.needsUrl).toBe(true);
|
||||
expect(ollama.defaultUrl).toBe("http://localhost:11434");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EMBEDDER_PROVIDERS", () => {
|
||||
it("has 2 providers", () => {
|
||||
expect(EMBEDDER_PROVIDERS).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KNOWN_EMBEDDER_DIMS", () => {
|
||||
it("maps default models to dims", () => {
|
||||
expect(KNOWN_EMBEDDER_DIMS["text-embedding-3-small"]).toBe(1536);
|
||||
expect(KNOWN_EMBEDDER_DIMS["nomic-embed-text"]).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssLlmConfig", () => {
|
||||
it("builds openai config with API key", () => {
|
||||
const result = buildOssLlmConfig("openai", { apiKey: "sk-test" });
|
||||
expect(result).toEqual({
|
||||
provider: "openai",
|
||||
config: { model: "gpt-5-mini", apiKey: "sk-test" },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds ollama config with custom URL and model", () => {
|
||||
const result = buildOssLlmConfig("ollama", { url: "http://myhost:11434", model: "mistral" });
|
||||
expect(result).toEqual({
|
||||
provider: "ollama",
|
||||
config: { model: "mistral", url: "http://myhost:11434" },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds ollama config with default URL", () => {
|
||||
const result = buildOssLlmConfig("ollama", {});
|
||||
expect(result.config.url).toBe("http://localhost:11434");
|
||||
});
|
||||
|
||||
it("ignores url for non-ollama providers", () => {
|
||||
const result = buildOssLlmConfig("anthropic", { apiKey: "sk-ant", url: "http://ignored" });
|
||||
expect(result.config).not.toHaveProperty("url");
|
||||
expect(result.config).toHaveProperty("apiKey", "sk-ant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssEmbedderConfig", () => {
|
||||
it("builds openai embedder", () => {
|
||||
const result = buildOssEmbedderConfig("openai", { apiKey: "sk-test" });
|
||||
expect(result.config.model).toBe("text-embedding-3-small");
|
||||
expect(result.dims).toBe(1536);
|
||||
});
|
||||
|
||||
it("builds ollama embedder with url field", () => {
|
||||
const result = buildOssEmbedderConfig("ollama", { url: "http://myhost:11434" });
|
||||
expect(result.config.url).toBe("http://myhost:11434");
|
||||
expect(result.config).not.toHaveProperty("ollama_base_url");
|
||||
expect(result.config.model).toBe("nomic-embed-text");
|
||||
expect(result.dims).toBe(768);
|
||||
});
|
||||
|
||||
it("falls back to provider default dims for custom model", () => {
|
||||
const result = buildOssEmbedderConfig("ollama", { model: "custom-embed" });
|
||||
expect(result.dims).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VECTOR_PROVIDERS", () => {
|
||||
it("has 2 providers", () => {
|
||||
expect(VECTOR_PROVIDERS).toHaveLength(2);
|
||||
expect(VECTOR_PROVIDERS.map((p) => p.id)).toEqual(["qdrant", "pgvector"]);
|
||||
});
|
||||
|
||||
it("qdrant requires server connection", () => {
|
||||
const qdrant = VECTOR_PROVIDERS.find((p) => p.id === "qdrant")!;
|
||||
expect(qdrant.needsConnection).toBe(true);
|
||||
expect(qdrant.defaultUrl).toBe("http://localhost:6333");
|
||||
expect(qdrant.setupHint).toContain("docker");
|
||||
});
|
||||
|
||||
it("pgvector requires connection and has setup hint", () => {
|
||||
const pg = VECTOR_PROVIDERS.find((p) => p.id === "pgvector")!;
|
||||
expect(pg.needsConnection).toBe(true);
|
||||
expect(pg.defaultPort).toBe(5432);
|
||||
expect(pg.setupHint).toContain("pgvector");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssVectorConfig", () => {
|
||||
it("builds qdrant with default url and dims", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 1536 });
|
||||
expect(result.config.url).toBe("http://localhost:6333");
|
||||
expect(result.config.onDisk).toBe(true);
|
||||
expect(result.config.dimension).toBe(1536);
|
||||
});
|
||||
|
||||
it("builds qdrant with custom url", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { url: "http://qdrant.local:6333", dims: 768 });
|
||||
expect(result.config.url).toBe("http://qdrant.local:6333");
|
||||
expect(result.config.onDisk).toBe(true);
|
||||
expect(result.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("builds qdrant with api key for cloud", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { url: "https://cloud.qdrant.io", apiKey: "qd-key", dims: 1536 });
|
||||
expect(result.config.apiKey).toBe("qd-key");
|
||||
expect(result.config.url).toBe("https://cloud.qdrant.io");
|
||||
});
|
||||
|
||||
it("builds pgvector with connection details", () => {
|
||||
const result = buildOssVectorConfig("pgvector", {
|
||||
host: "db.local", port: "5432", user: "me", password: "pw", dbname: "mydb", dims: 512,
|
||||
});
|
||||
expect(result.config.host).toBe("db.local");
|
||||
expect(result.config.dimension).toBe(512);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectionNameForDims", () => {
|
||||
it("generates dimension-based collection name", () => {
|
||||
expect(collectionNameForDims(1536)).toBe("mem0_1536d");
|
||||
expect(collectionNameForDims(768)).toBe("mem0_768d");
|
||||
expect(collectionNameForDims(384)).toBe("mem0_384d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssVectorConfig dimension safety", () => {
|
||||
it("sets both dimension and embeddingModelDims when dims provided", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(result.config.dimension).toBe(768);
|
||||
expect(result.config.embeddingModelDims).toBe(768);
|
||||
});
|
||||
|
||||
it("sets collectionName based on dims", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(result.config.collectionName).toBe("mem0_768d");
|
||||
});
|
||||
|
||||
it("uses different collection names for different dims", () => {
|
||||
const r1 = buildOssVectorConfig("qdrant", { dims: 1536 });
|
||||
const r2 = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(r1.config.collectionName).not.toBe(r2.config.collectionName);
|
||||
});
|
||||
|
||||
it("omits dimension fields when dims not provided", () => {
|
||||
const result = buildOssVectorConfig("qdrant", {});
|
||||
expect(result.config.dimension).toBeUndefined();
|
||||
expect(result.config.embeddingModelDims).toBeUndefined();
|
||||
expect(result.config.collectionName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("works with pgvector too", () => {
|
||||
const result = buildOssVectorConfig("pgvector", {
|
||||
host: "localhost", port: "5432", user: "me", password: "pw", dbname: "test", dims: 768,
|
||||
});
|
||||
expect(result.config.dimension).toBe(768);
|
||||
expect(result.config.embeddingModelDims).toBe(768);
|
||||
expect(result.config.collectionName).toBe("mem0_768d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkQdrantConnectivity", () => {
|
||||
it("returns error for unreachable host", async () => {
|
||||
const result = await checkQdrantConnectivity("http://localhost:19999");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Cannot reach Qdrant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkOllamaConnectivity", () => {
|
||||
it("returns error for unreachable host", async () => {
|
||||
const result = await checkOllamaConnectivity("http://localhost:19998");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Cannot reach Ollama");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPgConnectivity", () => {
|
||||
it("returns error for unreachable host", async () => {
|
||||
const result = await checkPgConnectivity("localhost", 19997);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("PostgreSQL not reachable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOssFlags", () => {
|
||||
it("returns error when openai LLM has no key", () => {
|
||||
const result = validateOssFlags({ ossLlm: "openai" });
|
||||
expect(result.error).toContain("--oss-llm-key");
|
||||
});
|
||||
|
||||
it("passes for ollama with no key", () => {
|
||||
const result = validateOssFlags({ ossLlm: "ollama", ossEmbedder: "ollama", ossVector: "qdrant" });
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error for unknown provider", () => {
|
||||
const result = validateOssFlags({ ossLlm: "bogus" });
|
||||
expect(result.error).toContain("Unknown LLM provider");
|
||||
});
|
||||
|
||||
it("returns error when pgvector missing user", () => {
|
||||
const result = validateOssFlags({
|
||||
ossLlm: "ollama", ossEmbedder: "ollama", ossVector: "pgvector",
|
||||
});
|
||||
expect(result.error).toContain("--oss-vector-user");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Tests for providers.ts — providerToBackend adapter layer.
|
||||
*
|
||||
* Verifies that the Backend wrapper correctly delegates to the
|
||||
* underlying Mem0Provider methods with proper argument mapping.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { providerToBackend } from "../providers.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock provider factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockProvider() {
|
||||
return {
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "m1", memory: "found", score: 0.9 }]),
|
||||
add: vi.fn().mockResolvedValue({
|
||||
results: [{ id: "m1", event: "ADD", memory: "stored" }],
|
||||
}),
|
||||
get: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "m1", memory: "test", created_at: "2026-01-01" }),
|
||||
getAll: vi.fn().mockResolvedValue([{ id: "m1", memory: "listed" }]),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAll: vi.fn().mockResolvedValue(undefined),
|
||||
history: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_USER = "test-user";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — search", () => {
|
||||
// v3.0.0: keyword_search, reranking removed from SDK
|
||||
it("delegates to provider.search with correct options", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const results = await backend.search("hello world", {
|
||||
topK: 10,
|
||||
threshold: 0.5,
|
||||
filters: { category: "preference" },
|
||||
});
|
||||
|
||||
expect(provider.search).toHaveBeenCalledWith("hello world", {
|
||||
user_id: DEFAULT_USER,
|
||||
top_k: 10,
|
||||
threshold: 0.5,
|
||||
filters: { category: "preference" },
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0] as any).id).toBe("m1");
|
||||
});
|
||||
|
||||
it("uses default userId when opts.userId is not provided", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
await backend.search("query");
|
||||
|
||||
// v3.0.0: keyword_search, reranking removed
|
||||
expect(provider.search).toHaveBeenCalledWith("query", {
|
||||
user_id: DEFAULT_USER,
|
||||
top_k: undefined,
|
||||
threshold: undefined,
|
||||
filters: undefined,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — add", () => {
|
||||
it("delegates to provider.add with content as message", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.add("Remember this fact");
|
||||
|
||||
expect(provider.add).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "Remember this fact" }],
|
||||
expect.objectContaining({ user_id: DEFAULT_USER }),
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes messages array when provided", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const messages = [
|
||||
{ role: "user", content: "Hi" },
|
||||
{ role: "assistant", content: "Hello" },
|
||||
];
|
||||
await backend.add(undefined, messages);
|
||||
|
||||
expect(provider.add).toHaveBeenCalledWith(
|
||||
messages,
|
||||
expect.objectContaining({ user_id: DEFAULT_USER }),
|
||||
);
|
||||
});
|
||||
|
||||
// v3.0.0: immutable, expiration_date removed from SDK
|
||||
it("forwards optional add options", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
await backend.add("fact", undefined, {
|
||||
runId: "run-1",
|
||||
metadata: { source: "test" },
|
||||
infer: false,
|
||||
});
|
||||
|
||||
expect(provider.add).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "fact" }],
|
||||
expect.objectContaining({
|
||||
user_id: DEFAULT_USER,
|
||||
run_id: "run-1",
|
||||
metadata: { source: "test" },
|
||||
infer: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — get", () => {
|
||||
it("delegates to provider.get", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.get("mem-123");
|
||||
|
||||
expect(provider.get).toHaveBeenCalledWith("mem-123");
|
||||
expect((result as any).id).toBe("m1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listMemories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — listMemories", () => {
|
||||
it("delegates to provider.getAll", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const results = await backend.listMemories({ pageSize: 50 });
|
||||
|
||||
expect(provider.getAll).toHaveBeenCalledWith({
|
||||
user_id: DEFAULT_USER,
|
||||
page_size: 50,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses default userId when opts.userId is not provided", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
await backend.listMemories();
|
||||
|
||||
expect(provider.getAll).toHaveBeenCalledWith({
|
||||
user_id: DEFAULT_USER,
|
||||
page_size: undefined,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — update", () => {
|
||||
it("calls provider.update with content", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.update("mem-123", "updated text");
|
||||
|
||||
expect(provider.update).toHaveBeenCalledWith("mem-123", "updated text");
|
||||
expect((result as any).id).toBe("mem-123");
|
||||
expect((result as any).updated).toBe(true);
|
||||
});
|
||||
|
||||
it("warns and skips when only metadata is provided (no content)", async () => {
|
||||
const provider = createMockProvider();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.update("mem-123", undefined, {
|
||||
tag: "important",
|
||||
});
|
||||
|
||||
expect(provider.update).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("metadata updates are not supported"),
|
||||
);
|
||||
expect((result as any).id).toBe("mem-123");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — delete", () => {
|
||||
it("calls provider.delete with memoryId", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.delete("mem-456");
|
||||
|
||||
expect(provider.delete).toHaveBeenCalledWith("mem-456");
|
||||
expect((result as any).deleted).toBe("mem-456");
|
||||
});
|
||||
|
||||
it("calls provider.deleteAll when opts.all is true", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.delete(undefined, {
|
||||
all: true,
|
||||
userId: "custom-user",
|
||||
});
|
||||
|
||||
expect(provider.deleteAll).toHaveBeenCalledWith("custom-user");
|
||||
expect((result as any).deleted).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteEntities (platform-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — deleteEntities", () => {
|
||||
it("throws platform-only error", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
await expect(
|
||||
backend.deleteEntities({ userId: DEFAULT_USER }),
|
||||
).rejects.toThrow("platform mode");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — status", () => {
|
||||
it("returns connected: true", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.status();
|
||||
|
||||
expect((result as any).connected).toBe(true);
|
||||
expect((result as any).backend).toBe("oss");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock config-file before importing telemetry
|
||||
vi.mock("../cli/config-file.ts", () => ({
|
||||
readPluginAuth: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
import { captureEvent } from "../telemetry.ts";
|
||||
import { readPluginAuth } from "../cli/config-file.ts";
|
||||
|
||||
describe("telemetry", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset telemetry enabled state
|
||||
(globalThis as any).__mem0_telemetry_override = undefined;
|
||||
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
});
|
||||
|
||||
it("captureEvent does not throw", () => {
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("captureEvent accepts properties and context", () => {
|
||||
expect(() =>
|
||||
captureEvent("test_event", { key: "val" }, { apiKey: "m0-key", mode: "platform" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("captureEvent is silent when telemetry disabled", () => {
|
||||
(globalThis as any).__mem0_telemetry_override = "false";
|
||||
// Force re-evaluation by resetting cached value
|
||||
// Since _telemetryEnabled is module-level, we test indirectly
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses userEmail as distinct ID when available", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
||||
userEmail: "test@example.com",
|
||||
});
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to a generated anonymous id when no apiKey", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({});
|
||||
expect(() => captureEvent("test_event", {}, {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles readPluginAuth errors gracefully", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
throw new Error("config read failed");
|
||||
});
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,889 @@
|
||||
/**
|
||||
* 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 { ToolDeps } from "../tools/index.ts";
|
||||
import { registerAllTools } from "../tools/index.ts";
|
||||
import { createMemorySearchTool } from "../tools/memory-search.ts";
|
||||
import { createMemoryAddTool } from "../tools/memory-add.ts";
|
||||
import { createMemoryGetTool } from "../tools/memory-get.ts";
|
||||
import { createMemoryDeleteTool } from "../tools/memory-delete.ts";
|
||||
import { createMemoryListTool } from "../tools/memory-list.ts";
|
||||
import { createMemoryUpdateTool } from "../tools/memory-update.ts";
|
||||
import { createMemoryEventListTool } from "../tools/memory-event-list.ts";
|
||||
import { createMemoryEventStatusTool } from "../tools/memory-event-status.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockToolDeps(overrides = {}): ToolDeps {
|
||||
return {
|
||||
api: {
|
||||
registerTool: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn() },
|
||||
} as any,
|
||||
cfg: {
|
||||
mode: "platform",
|
||||
userId: "testuser",
|
||||
topK: 5,
|
||||
autoCapture: true,
|
||||
autoRecall: true,
|
||||
searchThreshold: 0.1,
|
||||
customInstructions: "test",
|
||||
customCategories: {},
|
||||
} 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,
|
||||
captureToolEvent: vi.fn(),
|
||||
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 8 times", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
registerAllTools(ctx);
|
||||
expect(ctx.api.registerTool).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it("registers tools with the correct names", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
registerAllTools(ctx);
|
||||
|
||||
// Tools are registered as required (single argument — no metadata object).
|
||||
// The name comes from the tool definition itself (call[0]).
|
||||
const names = (
|
||||
ctx.api.registerTool as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((call: unknown[]) => (call[0] as { name: string }).name);
|
||||
|
||||
expect(names).toEqual([
|
||||
"memory_search",
|
||||
"memory_add",
|
||||
"memory_get",
|
||||
"memory_list",
|
||||
"memory_update",
|
||||
"memory_delete",
|
||||
"memory_event_list",
|
||||
"memory_event_status",
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers tools with optional: false metadata", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
registerAllTools(ctx);
|
||||
|
||||
const calls = (ctx.api.registerTool as ReturnType<typeof vi.fn>).mock.calls;
|
||||
for (const call of calls) {
|
||||
expect(call).toHaveLength(2);
|
||||
expect(call[1]).toEqual({ optional: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory shape checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tool factory shape", () => {
|
||||
const factories = [
|
||||
{ fn: createMemorySearchTool, expectedName: "memory_search" },
|
||||
{ fn: createMemoryAddTool, expectedName: "memory_add" },
|
||||
{ fn: createMemoryGetTool, expectedName: "memory_get" },
|
||||
{ fn: createMemoryDeleteTool, expectedName: "memory_delete" },
|
||||
{ fn: createMemoryListTool, expectedName: "memory_list" },
|
||||
{ fn: createMemoryEventListTool, expectedName: "memory_event_list" },
|
||||
{ fn: createMemoryEventStatusTool, expectedName: "memory_event_status" },
|
||||
];
|
||||
|
||||
for (const { fn, expectedName } of factories) {
|
||||
describe(expectedName, () => {
|
||||
it("returns an object with name, label, description, parameters, and execute", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
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 = createMockToolDeps();
|
||||
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 = createMockToolDeps({
|
||||
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 = createMockToolDeps({
|
||||
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 = createMockToolDeps();
|
||||
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 = createMockToolDeps();
|
||||
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 = createMockToolDeps({
|
||||
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 as 4th arg (sessionKey)
|
||||
expect(ctx.buildSearchOptions).toHaveBeenCalledWith(
|
||||
"testuser",
|
||||
undefined,
|
||||
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 = createMockToolDeps({
|
||||
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_add execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_add execute", () => {
|
||||
it("calls provider.add with the text and returns stored result", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryAddTool(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 = createMockToolDeps();
|
||||
const tool = createMemoryAddTool(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 = createMockToolDeps();
|
||||
const tool = createMemoryAddTool(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 = createMockToolDeps({
|
||||
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 = createMemoryAddTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-4", { text: "test" });
|
||||
|
||||
expect(result.content[0].text).toContain("Memory add 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 = createMockToolDeps({
|
||||
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 = createMemoryAddTool(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(result.details.mode).toBe("skills");
|
||||
expect(result.details.category).toBe("preference");
|
||||
});
|
||||
|
||||
it("blocks subagent sessions from storing", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi
|
||||
.fn()
|
||||
.mockReturnValue("agent:main:subagent:uuid-123"),
|
||||
});
|
||||
const tool = createMemoryAddTool(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 = createMockToolDeps({
|
||||
skillsActive: false,
|
||||
provider: {
|
||||
search: searchMock,
|
||||
add: addMock,
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryAddTool(ctx);
|
||||
|
||||
await tool.execute("call-7", { text: "new fact" });
|
||||
|
||||
// Mem0 backend handles dedup internally — no separate search call
|
||||
expect(searchMock).not.toHaveBeenCalled();
|
||||
expect(addMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// memory_get execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_get execute", () => {
|
||||
it("calls provider.get with the memoryId and returns formatted result", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
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 = createMockToolDeps({
|
||||
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 memoryId via provider.delete", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-1", { memoryId: "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 = createMockToolDeps({
|
||||
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 = createMockToolDeps({
|
||||
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 = createMockToolDeps({
|
||||
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 = createMockToolDeps();
|
||||
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 = createMockToolDeps({
|
||||
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("returns error when no mode param is specified", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-9", {});
|
||||
|
||||
expect(result.content[0].text).toContain(
|
||||
"Provide memoryId, query, or all:true",
|
||||
);
|
||||
expect(result.details.error).toBe("missing_param");
|
||||
});
|
||||
|
||||
it("blocks subagent sessions from deleting", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi
|
||||
.fn()
|
||||
.mockReturnValue("agent:main:subagent:uuid-456"),
|
||||
});
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-10", { memoryId: "m1" });
|
||||
|
||||
expect(ctx.provider!.delete).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe("subagent_blocked");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
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", { memoryId: "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 = createMockToolDeps();
|
||||
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 = createMockToolDeps({
|
||||
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 = createMockToolDeps({
|
||||
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 = createMockToolDeps();
|
||||
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 = createMockToolDeps({
|
||||
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 = createMockToolDeps({
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// memory_update execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_update execute", () => {
|
||||
it("calls provider.update and returns success", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryUpdateTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-1", {
|
||||
memoryId: "mem-123",
|
||||
text: "Updated preference",
|
||||
});
|
||||
|
||||
expect(ctx.provider!.update).toHaveBeenCalledWith(
|
||||
"mem-123",
|
||||
"Updated preference",
|
||||
);
|
||||
expect(result.content[0].text).toContain("Updated memory mem-123");
|
||||
expect(result.content[0].text).toContain("Updated preference");
|
||||
expect(result.details.action).toBe("updated");
|
||||
expect(result.details.id).toBe("mem-123");
|
||||
});
|
||||
|
||||
it("truncates long text in response", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryUpdateTool(ctx);
|
||||
|
||||
const longText = "A".repeat(120);
|
||||
const result = await tool.execute("call-2", {
|
||||
memoryId: "mem-456",
|
||||
text: longText,
|
||||
});
|
||||
|
||||
expect(ctx.provider!.update).toHaveBeenCalledWith("mem-456", longText);
|
||||
// The response text should contain the first 80 chars followed by "..."
|
||||
expect(result.content[0].text).toContain("A".repeat(80) + "...");
|
||||
expect(result.content[0].text).not.toContain("A".repeat(81));
|
||||
expect(result.details.action).toBe("updated");
|
||||
});
|
||||
|
||||
it("blocks subagent sessions", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi
|
||||
.fn()
|
||||
.mockReturnValue("agent:main:subagent:uuid-789"),
|
||||
});
|
||||
const tool = createMemoryUpdateTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-3", {
|
||||
memoryId: "mem-123",
|
||||
text: "should not update",
|
||||
});
|
||||
|
||||
expect(ctx.provider!.update).not.toHaveBeenCalled();
|
||||
expect(result.content[0].text).toContain(
|
||||
"not available in subagent sessions",
|
||||
);
|
||||
expect(result.details.error).toBe("subagent_blocked");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn().mockRejectedValue(new Error("update conflict")),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryUpdateTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-4", {
|
||||
memoryId: "mem-123",
|
||||
text: "new text",
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("Memory update failed");
|
||||
expect(result.content[0].text).toContain("update conflict");
|
||||
expect(result.details.error).toContain("update conflict");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user