chore: import upstream snapshot with attribution
CI / test (20, macos-latest) (push) Has been cancelled
CI / test (20, ubuntu-latest) (push) Has been cancelled
CI / test (22, macos-latest) (push) Has been cancelled
CI / test (22, ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:01:18 +08:00
commit 979fb22d7c
613 changed files with 113494 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
store,
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string) => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const m = store.get(scope);
return m ? (Array.from(m.values()) as T[]) : [];
},
};
}
describe("access-tracker", () => {
it("getAccessLog returns empty log for unknown id", async () => {
const { getAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
const log = await getAccessLog(kv as never, "mem_xyz");
expect(log).toEqual({ memoryId: "mem_xyz", count: 0, lastAt: "", recent: [] });
});
it("recordAccess increments count and lastAt", async () => {
const { recordAccess, getAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
await recordAccess(kv as never, "mem_a", 1_000_000);
await recordAccess(kv as never, "mem_a", 2_000_000);
await recordAccess(kv as never, "mem_a", 3_000_000);
const log = await getAccessLog(kv as never, "mem_a");
expect(log.count).toBe(3);
expect(log.recent).toEqual([1_000_000, 2_000_000, 3_000_000]);
expect(log.lastAt).toBe(new Date(3_000_000).toISOString());
});
it("recent[] is bounded to last 20 entries", async () => {
const { recordAccess, getAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
for (let i = 1; i <= 50; i++) {
await recordAccess(kv as never, "mem_b", i * 1000);
}
const log = await getAccessLog(kv as never, "mem_b");
expect(log.count).toBe(50);
expect(log.recent.length).toBe(20);
// Should be the LAST 20: 31_000..50_000
expect(log.recent[0]).toBe(31_000);
expect(log.recent[19]).toBe(50_000);
});
it("recordAccessBatch deduplicates and writes once per id", async () => {
const { recordAccessBatch, getAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
await recordAccessBatch(
kv as never,
["mem_a", "mem_b", "mem_a", "mem_b", "mem_c"],
5_000_000,
);
expect((await getAccessLog(kv as never, "mem_a")).count).toBe(1);
expect((await getAccessLog(kv as never, "mem_b")).count).toBe(1);
expect((await getAccessLog(kv as never, "mem_c")).count).toBe(1);
});
it("recordAccess swallows kv.set errors (must not break reads)", async () => {
const { recordAccess } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
kv.set = (async () => {
throw new Error("boom");
}) as never;
await expect(recordAccess(kv as never, "mem_a")).resolves.toBeUndefined();
});
it("concurrent recordAccess calls do not lose increments (keyed mutex)", async () => {
const { recordAccess, getAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
await Promise.all(
Array.from({ length: 25 }, (_, i) =>
recordAccess(kv as never, "mem_race", i * 100),
),
);
const log = await getAccessLog(kv as never, "mem_race");
expect(log.count).toBe(25);
});
it("recordAccessBatch: a single failing id does not block siblings", async () => {
const { recordAccessBatch, getAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
const realSet = kv.set.bind(kv);
kv.set = (async (scope: string, key: string, val: unknown) => {
if (key === "mem_slow") throw new Error("write failed");
return realSet(scope, key, val);
}) as never;
await recordAccessBatch(
kv as never,
["mem_slow", "mem_fast_a", "mem_fast_b"],
1_000_000,
);
expect((await getAccessLog(kv as never, "mem_fast_a")).count).toBe(1);
expect((await getAccessLog(kv as never, "mem_fast_b")).count).toBe(1);
expect((await getAccessLog(kv as never, "mem_slow")).count).toBe(0);
});
it("ignores empty / falsy memory ids", async () => {
const { recordAccess, recordAccessBatch } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
await recordAccess(kv as never, "");
await recordAccessBatch(kv as never, ["", "mem_x", ""]);
expect(kv.store.get("mem:access")?.has("")).toBeFalsy();
expect(kv.store.get("mem:access")?.get("mem_x")).toBeTruthy();
});
it("deleteAccessLog removes the target entry and leaves siblings intact", async () => {
const { recordAccess, deleteAccessLog, getAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
await recordAccess(kv as never, "mem_a");
await recordAccess(kv as never, "mem_b");
await deleteAccessLog(kv as never, "mem_a");
expect(kv.store.get("mem:access")?.has("mem_a")).toBe(false);
expect((await getAccessLog(kv as never, "mem_b")).count).toBe(1);
});
it("deleteAccessLog is a no-op for unknown ids and empty ids", async () => {
const { deleteAccessLog, recordAccess } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
await recordAccess(kv as never, "mem_keep");
await deleteAccessLog(kv as never, "");
await deleteAccessLog(kv as never, "mem_unknown");
expect(kv.store.get("mem:access")?.has("mem_keep")).toBe(true);
});
it("deleteAccessLog swallows kv.delete errors", async () => {
const { deleteAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const kv = mockKV();
kv.delete = (async () => {
throw new Error("boom");
}) as never;
await expect(
deleteAccessLog(kv as never, "mem_x"),
).resolves.toBeUndefined();
});
});
describe("normalizeAccessLog", () => {
it("returns a well-formed empty log for nullish / non-object input", async () => {
const { normalizeAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const log = normalizeAccessLog(null);
expect(log).toEqual({
memoryId: "",
count: 0,
lastAt: "",
recent: [],
});
expect(normalizeAccessLog(undefined).count).toBe(0);
expect(normalizeAccessLog("garbage").count).toBe(0);
});
it("coerces count to a non-negative integer", async () => {
const { normalizeAccessLog } = await import(
"../src/functions/access-tracker.js"
);
expect(normalizeAccessLog({ count: -5 }).count).toBe(0);
expect(normalizeAccessLog({ count: 3.7 }).count).toBe(3);
expect(normalizeAccessLog({ count: NaN }).count).toBe(0);
expect(normalizeAccessLog({ count: "123" }).count).toBe(0);
});
it("preserves large lifetime counts (NOT capped at ring buffer size)", async () => {
const { normalizeAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const log = normalizeAccessLog({ memoryId: "m", count: 500, recent: [1] });
expect(log.count).toBe(500);
});
it("truncates recent[] to the last 20 entries and drops non-finite values", async () => {
const { normalizeAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const input = Array.from({ length: 40 }, (_, i) => i * 1000);
const withGarbage = [...input, NaN, Infinity, "bad" as unknown as number];
const log = normalizeAccessLog({ recent: withGarbage });
expect(log.recent.length).toBe(20);
expect(log.recent[0]).toBe(20_000);
expect(log.recent[19]).toBe(39_000);
});
it("count is at least recent.length when count < recent.length", async () => {
const { normalizeAccessLog } = await import(
"../src/functions/access-tracker.js"
);
const log = normalizeAccessLog({
count: 2,
recent: [1, 2, 3, 4, 5],
});
expect(log.count).toBeGreaterThanOrEqual(5);
});
it("fills in memoryId only when field is a string", async () => {
const { normalizeAccessLog } = await import(
"../src/functions/access-tracker.js"
);
expect(normalizeAccessLog({ memoryId: "mem_x" }).memoryId).toBe("mem_x");
expect(normalizeAccessLog({ memoryId: 42 }).memoryId).toBe("");
});
});
+453
View File
@@ -0,0 +1,453 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerActionsFunction } from "../src/functions/actions.js";
import type { Action, ActionEdge } from "../src/types.js";
import { mockKV, mockSdk } from "./helpers/mocks.js";
describe("Actions Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerActionsFunction(sdk as never, kv as never);
});
describe("mem::action-create", () => {
it("creates an action with valid data", async () => {
const result = (await sdk.trigger("mem::action-create", {
title: "Fix login bug",
description: "Users cannot log in with SSO",
priority: 7,
createdBy: "agent-1",
project: "webapp",
tags: ["bug", "auth"],
})) as { success: boolean; action: Action; edges: ActionEdge[] };
expect(result.success).toBe(true);
expect(result.action.id).toMatch(/^act_/);
expect(result.action.title).toBe("Fix login bug");
expect(result.action.description).toBe("Users cannot log in with SSO");
expect(result.action.status).toBe("pending");
expect(result.action.priority).toBe(7);
expect(result.action.createdBy).toBe("agent-1");
expect(result.action.project).toBe("webapp");
expect(result.action.tags).toEqual(["bug", "auth"]);
expect(result.action.createdAt).toBeDefined();
expect(result.action.updatedAt).toBeDefined();
expect(result.edges).toEqual([]);
});
it("returns error when title is missing", async () => {
const result = (await sdk.trigger("mem::action-create", {
description: "No title provided",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("title is required");
});
it("clamps priority 0 to default 5 (falsy fallback)", async () => {
const result = (await sdk.trigger("mem::action-create", {
title: "Zero priority task",
priority: 0,
})) as { success: boolean; action: Action };
expect(result.success).toBe(true);
expect(result.action.priority).toBe(5);
});
it("clamps negative priority to 1", async () => {
const result = (await sdk.trigger("mem::action-create", {
title: "Negative priority task",
priority: -3,
})) as { success: boolean; action: Action };
expect(result.success).toBe(true);
expect(result.action.priority).toBe(1);
});
it("clamps priority 15 to 10", async () => {
const result = (await sdk.trigger("mem::action-create", {
title: "High priority task",
priority: 15,
})) as { success: boolean; action: Action };
expect(result.success).toBe(true);
expect(result.action.priority).toBe(10);
});
it("validates parent action exists", async () => {
const result = (await sdk.trigger("mem::action-create", {
title: "Child task",
parentId: "nonexistent_parent",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("parent action not found");
});
it("creates action with valid parent", async () => {
const parentResult = (await sdk.trigger("mem::action-create", {
title: "Parent task",
})) as { success: boolean; action: Action };
const childResult = (await sdk.trigger("mem::action-create", {
title: "Child task",
parentId: parentResult.action.id,
})) as { success: boolean; action: Action };
expect(childResult.success).toBe(true);
expect(childResult.action.parentId).toBe(parentResult.action.id);
});
it("creates inline edges with valid types", async () => {
const targetResult = (await sdk.trigger("mem::action-create", {
title: "Target action",
})) as { success: boolean; action: Action };
const result = (await sdk.trigger("mem::action-create", {
title: "Source action",
edges: [
{ type: "requires", targetActionId: targetResult.action.id },
{ type: "unlocks", targetActionId: targetResult.action.id },
],
})) as { success: boolean; action: Action; edges: ActionEdge[] };
expect(result.success).toBe(true);
expect(result.edges.length).toBe(2);
expect(result.edges[0].id).toMatch(/^ae_/);
expect(result.edges[0].type).toBe("requires");
expect(result.edges[0].sourceActionId).toBe(result.action.id);
expect(result.edges[0].targetActionId).toBe(targetResult.action.id);
expect(result.edges[1].type).toBe("unlocks");
});
it("returns error for inline edge with invalid type", async () => {
const targetResult = (await sdk.trigger("mem::action-create", {
title: "Target action",
})) as { success: boolean; action: Action };
const result = (await sdk.trigger("mem::action-create", {
title: "Source action",
edges: [
{ type: "invalid_type", targetActionId: targetResult.action.id },
],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("invalid edge type");
});
it("returns error for inline edge with nonexistent target", async () => {
const result = (await sdk.trigger("mem::action-create", {
title: "Source action",
edges: [{ type: "requires", targetActionId: "nonexistent_id" }],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("target action not found");
});
});
describe("mem::action-update", () => {
it("updates an action with valid data", async () => {
const createResult = (await sdk.trigger("mem::action-create", {
title: "Original title",
priority: 5,
})) as { success: boolean; action: Action };
const updateResult = (await sdk.trigger("mem::action-update", {
actionId: createResult.action.id,
title: "Updated title",
priority: 8,
status: "active",
assignedTo: "agent-2",
tags: ["updated"],
})) as { success: boolean; action: Action };
expect(updateResult.success).toBe(true);
expect(updateResult.action.title).toBe("Updated title");
expect(updateResult.action.priority).toBe(8);
expect(updateResult.action.status).toBe("active");
expect(updateResult.action.assignedTo).toBe("agent-2");
expect(updateResult.action.tags).toEqual(["updated"]);
});
it("returns error when actionId is missing", async () => {
const result = (await sdk.trigger("mem::action-update", {
title: "no id",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("actionId is required");
});
it("returns error for nonexistent action", async () => {
const result = (await sdk.trigger("mem::action-update", {
actionId: "nonexistent_id",
status: "done",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("action not found");
});
it("propagates completion when status set to done", async () => {
const actionB = (await sdk.trigger("mem::action-create", {
title: "Dependency B",
})) as { success: boolean; action: Action };
const actionA = (await sdk.trigger("mem::action-create", {
title: "Action A depends on B",
edges: [{ type: "requires", targetActionId: actionB.action.id }],
})) as { success: boolean; action: Action };
await sdk.trigger("mem::action-update", {
actionId: actionA.action.id,
status: "blocked",
});
await sdk.trigger("mem::action-update", {
actionId: actionB.action.id,
status: "done",
});
const getResult = (await sdk.trigger("mem::action-get", {
actionId: actionA.action.id,
})) as { success: boolean; action: Action };
expect(getResult.action.status).toBe("pending");
});
});
describe("mem::action-edge-create", () => {
it("creates an edge between two actions", async () => {
const source = (await sdk.trigger("mem::action-create", {
title: "Source",
})) as { success: boolean; action: Action };
const target = (await sdk.trigger("mem::action-create", {
title: "Target",
})) as { success: boolean; action: Action };
const result = (await sdk.trigger("mem::action-edge-create", {
sourceActionId: source.action.id,
targetActionId: target.action.id,
type: "requires",
})) as { success: boolean; edge: ActionEdge };
expect(result.success).toBe(true);
expect(result.edge.id).toMatch(/^ae_/);
expect(result.edge.type).toBe("requires");
expect(result.edge.sourceActionId).toBe(source.action.id);
expect(result.edge.targetActionId).toBe(target.action.id);
expect(result.edge.createdAt).toBeDefined();
});
it("returns error when required fields are missing", async () => {
const result = (await sdk.trigger("mem::action-edge-create", {
sourceActionId: "some_id",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("required");
});
it("returns error for invalid edge type", async () => {
const source = (await sdk.trigger("mem::action-create", {
title: "Source",
})) as { success: boolean; action: Action };
const target = (await sdk.trigger("mem::action-create", {
title: "Target",
})) as { success: boolean; action: Action };
const result = (await sdk.trigger("mem::action-edge-create", {
sourceActionId: source.action.id,
targetActionId: target.action.id,
type: "invalid_type",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("type must be one of");
});
it("returns error for nonexistent source action", async () => {
const target = (await sdk.trigger("mem::action-create", {
title: "Target",
})) as { success: boolean; action: Action };
const result = (await sdk.trigger("mem::action-edge-create", {
sourceActionId: "nonexistent",
targetActionId: target.action.id,
type: "requires",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("source action not found");
});
it("returns error for nonexistent target action", async () => {
const source = (await sdk.trigger("mem::action-create", {
title: "Source",
})) as { success: boolean; action: Action };
const result = (await sdk.trigger("mem::action-edge-create", {
sourceActionId: source.action.id,
targetActionId: "nonexistent",
type: "requires",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("target action not found");
});
});
describe("mem::action-list", () => {
beforeEach(async () => {
await sdk.trigger("mem::action-create", {
title: "Task A",
status: "pending",
project: "alpha",
tags: ["frontend"],
});
await new Promise((r) => setTimeout(r, 5));
await sdk.trigger("mem::action-create", {
title: "Task B",
project: "alpha",
tags: ["backend"],
});
await new Promise((r) => setTimeout(r, 5));
await sdk.trigger("mem::action-create", {
title: "Task C",
project: "beta",
tags: ["frontend", "backend"],
});
});
it("returns all actions", async () => {
const result = (await sdk.trigger("mem::action-list", {})) as {
success: boolean;
actions: Action[];
};
expect(result.success).toBe(true);
expect(result.actions.length).toBe(3);
});
it("filters by status", async () => {
const all = (await sdk.trigger("mem::action-list", {})) as {
actions: Action[];
};
const firstAction = all.actions[0];
await sdk.trigger("mem::action-update", {
actionId: firstAction.id,
status: "done",
});
const result = (await sdk.trigger("mem::action-list", {
status: "done",
})) as { success: boolean; actions: Action[] };
expect(result.success).toBe(true);
expect(result.actions.length).toBe(1);
expect(result.actions[0].status).toBe("done");
});
it("filters by project", async () => {
const result = (await sdk.trigger("mem::action-list", {
project: "alpha",
})) as { success: boolean; actions: Action[] };
expect(result.success).toBe(true);
expect(result.actions.length).toBe(2);
expect(result.actions.every((a) => a.project === "alpha")).toBe(true);
});
it("filters by tags", async () => {
const result = (await sdk.trigger("mem::action-list", {
tags: ["backend"],
})) as { success: boolean; actions: Action[] };
expect(result.success).toBe(true);
expect(result.actions.length).toBe(2);
expect(
result.actions.every((a) => a.tags.includes("backend")),
).toBe(true);
});
it("respects limit", async () => {
const result = (await sdk.trigger("mem::action-list", {
limit: 2,
})) as { success: boolean; actions: Action[] };
expect(result.success).toBe(true);
expect(result.actions.length).toBe(2);
});
});
describe("mem::action-get", () => {
it("returns action with edges and children", async () => {
const parent = (await sdk.trigger("mem::action-create", {
title: "Parent",
})) as { success: boolean; action: Action };
const child = (await sdk.trigger("mem::action-create", {
title: "Child",
parentId: parent.action.id,
})) as { success: boolean; action: Action };
const other = (await sdk.trigger("mem::action-create", {
title: "Other",
})) as { success: boolean; action: Action };
await sdk.trigger("mem::action-edge-create", {
sourceActionId: parent.action.id,
targetActionId: other.action.id,
type: "unlocks",
});
const result = (await sdk.trigger("mem::action-get", {
actionId: parent.action.id,
})) as {
success: boolean;
action: Action;
edges: ActionEdge[];
children: Action[];
};
expect(result.success).toBe(true);
expect(result.action.id).toBe(parent.action.id);
expect(result.edges.length).toBe(1);
expect(result.edges[0].type).toBe("unlocks");
expect(result.children.length).toBe(1);
expect(result.children[0].id).toBe(child.action.id);
});
it("returns error for missing actionId", async () => {
const result = (await sdk.trigger("mem::action-get", {})) as {
success: boolean;
error: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain("actionId is required");
});
it("returns error for nonexistent action", async () => {
const result = (await sdk.trigger("mem::action-get", {
actionId: "nonexistent",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("action not found");
});
});
});
+269
View File
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// AGENT_ID scope for multi-agent memory isolation.
describe("loadAgentScope (#554)", () => {
const ORIG = process.env["AGENT_ID"];
beforeEach(() => {
vi.resetModules();
delete process.env["AGENT_ID"];
});
afterEach(() => {
if (ORIG === undefined) delete process.env["AGENT_ID"];
else process.env["AGENT_ID"] = ORIG;
});
it("returns null when AGENT_ID is unset", async () => {
const { loadAgentScope, getAgentId } = await import("../src/config.js");
expect(loadAgentScope()).toBeNull();
expect(getAgentId()).toBeUndefined();
});
it("returns the agentId + scope mode when AGENT_ID is set", async () => {
process.env["AGENT_ID"] = "architect";
const { loadAgentScope, getAgentId } = await import("../src/config.js");
expect(loadAgentScope()).toEqual({ agentId: "architect", mode: "shared" });
expect(getAgentId()).toBe("architect");
});
it("trims whitespace and rejects empty after trim", async () => {
process.env["AGENT_ID"] = " ";
const { loadAgentScope } = await import("../src/config.js");
expect(loadAgentScope()).toBeNull();
});
it("caps length at 128 chars to keep KV writes well-formed", async () => {
process.env["AGENT_ID"] = "x".repeat(500);
const { getAgentId } = await import("../src/config.js");
expect(getAgentId()!.length).toBe(128);
});
});
describe("mem::remember stamps agentId on the Memory (#554)", () => {
const ORIG = process.env["AGENT_ID"];
beforeEach(() => {
vi.resetModules();
delete process.env["AGENT_ID"];
});
afterEach(() => {
if (ORIG === undefined) delete process.env["AGENT_ID"];
else process.env["AGENT_ID"] = ORIG;
});
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
store,
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string) => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const m = store.get(scope);
return m ? (Array.from(m.values()) as T[]) : [];
},
};
}
function mockSdk() {
const fns = new Map<string, Function>();
return {
fns,
registerFunction: (idOrOpts: string | { id: string }, fn: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
fns.set(id, fn);
},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = fns.get(id);
if (fn) return fn(payload);
return null;
},
};
}
it("stamps env AGENT_ID on Memory when no body override", async () => {
process.env["AGENT_ID"] = "developer";
const { registerRememberFunction } = await import(
"../src/functions/remember.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = (await sdk.trigger("mem::remember", {
content: "prefer async-std over tokio",
type: "preference",
})) as { memory: { id: string; agentId?: string } };
expect(result.memory.agentId).toBe("developer");
});
it("body agentId overrides env AGENT_ID", async () => {
process.env["AGENT_ID"] = "architect";
const { registerRememberFunction } = await import(
"../src/functions/remember.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = (await sdk.trigger("mem::remember", {
content: "use http-cache for github API",
agentId: "reviewer",
})) as { memory: { id: string; agentId?: string } };
expect(result.memory.agentId).toBe("reviewer");
});
it("no env, no body → no agentId on Memory (legacy)", async () => {
const { registerRememberFunction } = await import(
"../src/functions/remember.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = (await sdk.trigger("mem::remember", {
content: "legacy unscoped memory",
})) as { memory: { id: string; agentId?: string } };
expect(result.memory.agentId).toBeUndefined();
});
it("body agentId is trimmed of leading/trailing whitespace", async () => {
const { registerRememberFunction } = await import(
"../src/functions/remember.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = (await sdk.trigger("mem::remember", {
content: "trim me",
agentId: " architect ",
})) as { memory: { id: string; agentId?: string } };
expect(result.memory.agentId).toBe("architect");
});
it("body agentId longer than 128 chars is truncated to 128", async () => {
const { registerRememberFunction } = await import(
"../src/functions/remember.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const long = "x".repeat(500);
const result = (await sdk.trigger("mem::remember", {
content: "cap length",
agentId: long,
})) as { memory: { id: string; agentId?: string } };
expect(result.memory.agentId).toBeDefined();
expect(result.memory.agentId!.length).toBe(128);
expect(result.memory.agentId).toBe("x".repeat(128));
});
it("whitespace-only body agentId falls back to env AGENT_ID", async () => {
process.env["AGENT_ID"] = "developer";
const { registerRememberFunction } = await import(
"../src/functions/remember.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = (await sdk.trigger("mem::remember", {
content: "fall through to env",
agentId: " ",
})) as { memory: { id: string; agentId?: string } };
expect(result.memory.agentId).toBe("developer");
});
it("empty-string body agentId with no env → no agentId stamped", async () => {
const { registerRememberFunction } = await import(
"../src/functions/remember.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = (await sdk.trigger("mem::remember", {
content: "no env no usable body",
agentId: "",
})) as { memory: { id: string; agentId?: string } };
expect(result.memory.agentId).toBeUndefined();
});
});
describe("AGENTMEMORY_AGENT_SCOPE mode (#554)", () => {
const ORIG_ID = process.env["AGENT_ID"];
const ORIG_MODE = process.env["AGENTMEMORY_AGENT_SCOPE"];
beforeEach(() => {
vi.resetModules();
delete process.env["AGENT_ID"];
delete process.env["AGENTMEMORY_AGENT_SCOPE"];
});
afterEach(() => {
if (ORIG_ID === undefined) delete process.env["AGENT_ID"];
else process.env["AGENT_ID"] = ORIG_ID;
if (ORIG_MODE === undefined) delete process.env["AGENTMEMORY_AGENT_SCOPE"];
else process.env["AGENTMEMORY_AGENT_SCOPE"] = ORIG_MODE;
});
it("defaults to shared mode when AGENT_ID set but scope unset", async () => {
process.env["AGENT_ID"] = "developer";
const { loadAgentScope, isAgentScopeIsolated } = await import(
"../src/config.js"
);
expect(loadAgentScope()).toEqual({
agentId: "developer",
mode: "shared",
});
expect(isAgentScopeIsolated()).toBe(false);
});
it("flips to isolated when AGENTMEMORY_AGENT_SCOPE=isolated", async () => {
process.env["AGENT_ID"] = "developer";
process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";
const { loadAgentScope, isAgentScopeIsolated } = await import(
"../src/config.js"
);
expect(loadAgentScope()).toEqual({
agentId: "developer",
mode: "isolated",
});
expect(isAgentScopeIsolated()).toBe(true);
});
it("isolated requires AGENT_ID to also be set (no scope without id)", async () => {
process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";
const { isAgentScopeIsolated } = await import("../src/config.js");
expect(isAgentScopeIsolated()).toBe(false);
});
it("unknown scope values fall back to shared", async () => {
process.env["AGENT_ID"] = "developer";
process.env["AGENTMEMORY_AGENT_SCOPE"] = "weird";
const { isAgentScopeIsolated } = await import("../src/config.js");
expect(isAgentScopeIsolated()).toBe(false);
});
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../src/state/keyed-mutex.js", () => ({
withKeyedLock: <T>(_key: string, fn: () => Promise<T>) => fn(),
}));
vi.mock("../src/functions/audit.js", () => ({
recordAudit: vi.fn(),
}));
vi.mock("../src/functions/access-tracker.js", () => ({
recordAccessBatch: vi.fn(),
deleteAccessLog: vi.fn(),
}));
const configState = {
agentId: undefined as string | undefined,
isolated: false,
};
vi.mock("../src/config.js", () => ({
getAgentId: () => configState.agentId,
isAgentScopeIsolated: () => configState.isolated,
}));
import {
registerSearchFunction,
getSearchIndex,
setIndexPersistence,
} from "../src/functions/search.js";
import { KV } from "../src/state/schema.js";
import type { CompressedObservation, Session, SearchResult } from "../src/types.js";
function makeMockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function makeMockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (id: string, handler: Function) => {
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id =
typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload =
typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function registered: ${id}`);
return fn(payload);
},
};
}
async function seedTwoAgents(kv: ReturnType<typeof makeMockKV>) {
const sessionA: Session = {
id: "sess-a",
project: "shared",
cwd: "/work",
startTime: "2026-01-01T00:00:00Z",
type: "code",
} as Session;
const sessionB: Session = {
id: "sess-b",
project: "shared",
cwd: "/work",
startTime: "2026-01-01T00:00:00Z",
type: "code",
} as Session;
await kv.set(KV.sessions, sessionA.id, sessionA);
await kv.set(KV.sessions, sessionB.id, sessionB);
const obsA: CompressedObservation = {
id: "obs-a-secret",
sessionId: "sess-a",
timestamp: "2026-01-01T01:00:00Z",
type: "user_prompt",
title: "agent A private",
facts: ["SECRET_MARKER value AAA"],
narrative: "agent A wrote a secret",
concepts: ["secret", "private"],
files: [],
importance: 8,
agentId: "agent_a",
} as CompressedObservation;
const obsB: CompressedObservation = {
id: "obs-b-public",
sessionId: "sess-b",
timestamp: "2026-01-01T02:00:00Z",
type: "user_prompt",
title: "agent B note",
facts: ["SECRET_MARKER value BBB"],
narrative: "agent B wrote about the same marker",
concepts: ["secret"],
files: [],
importance: 6,
agentId: "agent_b",
} as CompressedObservation;
await kv.set(KV.observations("sess-a"), obsA.id, obsA);
await kv.set(KV.observations("sess-b"), obsB.id, obsB);
// Mirror the indexer's behavior so the BM25 path returns both rows.
const idx = getSearchIndex();
idx.add(obsA);
idx.add(obsB);
}
describe("mem::search agent-scope isolation (#817 follow-up)", () => {
let sdk: ReturnType<typeof makeMockSdk>;
let kv: ReturnType<typeof makeMockKV>;
beforeEach(() => {
sdk = makeMockSdk();
kv = makeMockKV();
setIndexPersistence(null);
// Reset index between tests.
const idx = getSearchIndex();
(idx as unknown as { clear?: () => void }).clear?.();
configState.agentId = undefined;
configState.isolated = false;
registerSearchFunction(sdk as never, kv as never);
});
it("isolated mode + env AGENT_ID excludes other agent's observations", async () => {
configState.isolated = true;
configState.agentId = "agent_a";
await seedTwoAgents(kv);
const result = (await sdk.trigger("mem::search", {
query: "SECRET_MARKER",
limit: 10,
})) as { results: SearchResult[] };
expect(result.results.length).toBeGreaterThan(0);
for (const r of result.results) {
expect(r.observation.agentId).toBe("agent_a");
}
expect(result.results.find((r) => r.observation.id === "obs-b-public")).toBeUndefined();
});
it('isolated mode + agentId: "*" wildcard bypasses and returns both agents', async () => {
configState.isolated = true;
configState.agentId = "agent_a";
await seedTwoAgents(kv);
const result = (await sdk.trigger("mem::search", {
query: "SECRET_MARKER",
limit: 10,
agentId: "*",
})) as { results: SearchResult[] };
const ids = result.results.map((r) => r.observation.id);
expect(ids).toContain("obs-a-secret");
expect(ids).toContain("obs-b-public");
});
it("isolated mode with no AGENT_ID fails closed (throws), does not leak", async () => {
configState.isolated = true;
configState.agentId = undefined;
await seedTwoAgents(kv);
await expect(
sdk.trigger("mem::search", { query: "SECRET_MARKER", limit: 10 }),
).rejects.toThrow(/AGENTMEMORY_AGENT_SCOPE=isolated/);
});
it("non-isolated mode (default) returns all rows regardless of agentId", async () => {
configState.isolated = false;
configState.agentId = undefined;
await seedTwoAgents(kv);
const result = (await sdk.trigger("mem::search", {
query: "SECRET_MARKER",
limit: 10,
})) as { results: SearchResult[] };
const ids = result.results.map((r) => r.observation.id);
expect(ids).toContain("obs-a-secret");
expect(ids).toContain("obs-b-public");
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// #781: concurrent siblings on the agent-sdk provider used to bail out
// empty because the recursion guard mutated process.env synchronously
// before the first await. With the guard scoped to AsyncLocalStorage,
// each sibling runs in its own context and receives the real SDK result.
// vi.mock is hoisted above module-scope `const`/`let`, so the factory's
// closure can't safely reference non-hoisted bindings. Use vi.hoisted to
// declare the mock's mutable state alongside the mock itself.
const state = vi.hoisted(() => ({
queryCalls: [] as Array<{ systemPrompt: string; userPrompt: string }>,
mockResult: "<result>ok</result>" as
| string
| ((systemPrompt: string, userPrompt: string) => string),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: ({
prompt,
options,
}: {
prompt: string;
options: { systemPrompt: string };
}) => {
state.queryCalls.push({ systemPrompt: options.systemPrompt, userPrompt: prompt });
async function* gen() {
const value =
typeof state.mockResult === "function"
? await state.mockResult(options.systemPrompt, prompt)
: state.mockResult;
yield { type: "result", result: value } as { type: "result"; result: string };
}
return gen();
},
}));
import { AgentSDKProvider } from "../src/providers/agent-sdk.js";
describe("AgentSDKProvider recursion guard (#781)", () => {
beforeEach(() => {
state.queryCalls.length = 0;
state.mockResult = "<result>ok</result>";
delete process.env.AGENTMEMORY_SDK_CHILD;
});
afterEach(() => {
delete process.env.AGENTMEMORY_SDK_CHILD;
});
it("concurrent summarize calls each return the SDK result (no empty siblings)", async () => {
const provider = new AgentSDKProvider();
const results = await Promise.all([
provider.summarize("sys", "chunk 1"),
provider.summarize("sys", "chunk 2"),
provider.summarize("sys", "chunk 3"),
provider.summarize("sys", "chunk 4"),
]);
expect(results).toEqual([
"<result>ok</result>",
"<result>ok</result>",
"<result>ok</result>",
"<result>ok</result>",
]);
expect(state.queryCalls.length).toBe(4);
expect(state.queryCalls.map((c) => c.userPrompt)).toEqual([
"chunk 1",
"chunk 2",
"chunk 3",
"chunk 4",
]);
});
it("compress and summarize share the same guard scope without interfering", async () => {
const provider = new AgentSDKProvider();
const [a, b, c] = await Promise.all([
provider.summarize("sys", "s1"),
provider.compress("sys", "c1"),
provider.summarize("sys", "s2"),
]);
expect(a).toBe("<result>ok</result>");
expect(b).toBe("<result>ok</result>");
expect(c).toBe("<result>ok</result>");
expect(state.queryCalls.length).toBe(3);
});
it("sets AGENTMEMORY_SDK_CHILD=1 while inside the SDK call (so spawned subprocesses inherit it)", async () => {
const provider = new AgentSDKProvider();
let observedEnv: string | undefined;
state.mockResult = (sysPrompt, _userPrompt) => {
observedEnv = process.env.AGENTMEMORY_SDK_CHILD;
return `<result>${sysPrompt}</result>`;
};
expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined();
await provider.summarize("sys", "user");
expect(observedEnv).toBe("1");
expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined();
});
it("restores AGENTMEMORY_SDK_CHILD to its prior value after the call", async () => {
const provider = new AgentSDKProvider();
process.env.AGENTMEMORY_SDK_CHILD = "prev-value";
await provider.summarize("sys", "user");
expect(process.env.AGENTMEMORY_SDK_CHILD).toBe("prev-value");
});
it("keeps AGENTMEMORY_SDK_CHILD=1 for the full overlap of concurrent calls", async () => {
const provider = new AgentSDKProvider();
// Allow the calls to overlap: each call records the env value it
// saw, then a tick later records it again. With a refcounted guard
// both observations on both calls should see "1"; with the old
// per-call snapshot one call's restore would null the env while
// the sibling is still mid-flight.
const observations: Array<{ id: string; phase: string; env: string | undefined }> = [];
state.mockResult = async (sysPrompt, _user) => {
observations.push({ id: sysPrompt, phase: "enter", env: process.env.AGENTMEMORY_SDK_CHILD });
await new Promise((resolve) => setTimeout(resolve, 5));
observations.push({ id: sysPrompt, phase: "exit", env: process.env.AGENTMEMORY_SDK_CHILD });
return `<result>${sysPrompt}</result>`;
};
await Promise.all([
provider.summarize("a", "x"),
provider.summarize("b", "y"),
provider.summarize("c", "z"),
]);
expect(observations.length).toBe(6);
for (const o of observations) {
expect(o.env).toBe("1");
}
expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined();
});
it("genuine re-entry (an inner call inside the same async tree) still degrades to empty", async () => {
const provider = new AgentSDKProvider();
let innerResult = "not-set";
state.mockResult = async (_sys, _user) => {
// Simulate the SDK callback re-entering the provider while the
// outer call is still active. The ALS frame is active here, so
// the inner call must return "" to break the recursion.
innerResult = await provider.summarize("sys-inner", "user-inner");
return "<result>outer</result>";
};
const outer = await provider.summarize("sys", "user");
expect(outer).toBe("<result>outer</result>");
expect(innerResult).toBe("");
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { recordAudit, queryAudit } from "../src/functions/audit.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
describe("Audit Functions", () => {
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
kv = mockKV();
});
it("recordAudit creates an entry with proper fields", async () => {
const entry = await recordAudit(
kv as never,
"observe",
"mem::compress",
["obs_1", "obs_2"],
{ count: 2 },
0.85,
"user-1",
);
expect(entry.id).toMatch(/^aud_/);
expect(entry.timestamp).toBeDefined();
expect(entry.operation).toBe("observe");
expect(entry.functionId).toBe("mem::compress");
expect(entry.targetIds).toEqual(["obs_1", "obs_2"]);
expect(entry.details).toEqual({ count: 2 });
expect(entry.qualityScore).toBe(0.85);
expect(entry.userId).toBe("user-1");
});
it("queryAudit returns entries sorted by timestamp desc", async () => {
await recordAudit(kv as never, "observe", "fn1", ["a"], {});
await new Promise((r) => setTimeout(r, 10));
await recordAudit(kv as never, "delete", "fn2", ["b"], {});
const entries = await queryAudit(kv as never);
expect(entries.length).toBe(2);
expect(
new Date(entries[0].timestamp).getTime(),
).toBeGreaterThanOrEqual(new Date(entries[1].timestamp).getTime());
});
it("queryAudit filters by operation", async () => {
await recordAudit(kv as never, "observe", "fn1", [], {});
await recordAudit(kv as never, "delete", "fn2", [], {});
await recordAudit(kv as never, "observe", "fn3", [], {});
const entries = await queryAudit(kv as never, { operation: "observe" });
expect(entries.length).toBe(2);
expect(entries.every((e) => e.operation === "observe")).toBe(true);
});
it("queryAudit filters by dateFrom/dateTo", async () => {
const early = await recordAudit(kv as never, "observe", "fn1", [], {});
await new Promise((r) => setTimeout(r, 20));
const late = await recordAudit(kv as never, "delete", "fn2", [], {});
const entries = await queryAudit(kv as never, {
dateFrom: late.timestamp,
});
expect(entries.length).toBe(1);
expect(entries[0].operation).toBe("delete");
const entriesBefore = await queryAudit(kv as never, {
dateTo: early.timestamp,
});
expect(entriesBefore.length).toBe(1);
expect(entriesBefore[0].operation).toBe("observe");
});
it("queryAudit respects limit", async () => {
for (let i = 0; i < 10; i++) {
await recordAudit(kv as never, "observe", `fn${i}`, [], {});
}
const entries = await queryAudit(kv as never, { limit: 3 });
expect(entries.length).toBe(3);
});
});
+246
View File
@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { RawObservation } from "../src/types.js";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
store,
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string) => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const m = store.get(scope);
return m ? (Array.from(m.values()) as T[]) : [];
},
};
}
function mockSdk() {
const fns = new Map<string, Function>();
const triggered: Array<{ id: string; data: unknown }> = [];
return {
fns,
triggered,
registerFunction: (
idOrOpts: string | { id: string },
fn: Function,
_options?: Record<string, unknown>,
) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
fns.set(id, fn);
},
trigger: async (
idOrInput:
| string
| { function_id: string; payload: unknown; action?: unknown },
data?: unknown,
) => {
const id =
typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload =
typeof idOrInput === "string" ? data : idOrInput.payload;
triggered.push({ id, data: payload });
const fn = fns.get(id);
if (fn) return fn(payload);
return null;
},
};
}
function validPayload(overrides: Partial<Record<string, unknown>> = {}) {
return {
sessionId: "ses_test",
hookType: "post_tool_use",
timestamp: new Date().toISOString(),
data: {
tool_name: "Read",
tool_input: { file_path: "src/foo.ts" },
tool_output: "file contents here",
},
...overrides,
};
}
describe("mem::observe auto-compress gate (#138)", () => {
beforeEach(() => {
// Reset module cache so observe.js re-imports config.js with the
// fresh AGENTMEMORY_AUTO_COMPRESS env state. Without this, a later
// test that sets the env var can be undermined by cached module
// state from an earlier test (and vice versa).
vi.resetModules();
delete process.env["AGENTMEMORY_AUTO_COMPRESS"];
});
afterEach(() => {
delete process.env["AGENTMEMORY_AUTO_COMPRESS"];
});
it("default (AGENTMEMORY_AUTO_COMPRESS unset): does NOT fire mem::compress", async () => {
const { registerObserveFunction } = await import(
"../src/functions/observe.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
const result = (await sdk.trigger(
"mem::observe",
validPayload(),
)) as { observationId: string };
expect(result.observationId).toBeTruthy();
const compressCalls = sdk.triggered.filter((t) => t.id === "mem::compress");
expect(compressCalls).toHaveLength(0);
});
it("default: stores a synthetic CompressedObservation with the raw-derived fields", async () => {
const { registerObserveFunction } = await import(
"../src/functions/observe.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
const payload = validPayload();
await sdk.trigger("mem::observe", payload);
const scope = `mem:obs:${payload.sessionId}`;
const stored = kv.store.get(scope);
expect(stored).toBeDefined();
expect(stored!.size).toBe(1);
const [entry] = Array.from(stored!.values());
const obs = entry as {
type: string;
title: string;
files: string[];
confidence: number;
};
expect(obs.type).toBe("file_read");
expect(obs.title).toBe("Read");
expect(obs.files).toContain("src/foo.ts");
expect(obs.confidence).toBe(0.3);
});
it("AGENTMEMORY_AUTO_COMPRESS=true: fires mem::compress exactly once", async () => {
process.env["AGENTMEMORY_AUTO_COMPRESS"] = "true";
const { registerObserveFunction } = await import(
"../src/functions/observe.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
await sdk.trigger("mem::observe", validPayload());
const compressCalls = sdk.triggered.filter((t) => t.id === "mem::compress");
expect(compressCalls).toHaveLength(1);
});
it("AGENTMEMORY_AUTO_COMPRESS=false explicitly: does NOT fire mem::compress", async () => {
process.env["AGENTMEMORY_AUTO_COMPRESS"] = "false";
const { registerObserveFunction } = await import(
"../src/functions/observe.js"
);
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
await sdk.trigger("mem::observe", validPayload());
const compressCalls = sdk.triggered.filter((t) => t.id === "mem::compress");
expect(compressCalls).toHaveLength(0);
});
});
describe("buildSyntheticCompression", () => {
it("maps common tool names to the right ObservationType", async () => {
const { buildSyntheticCompression } = await import(
"../src/functions/compress-synthetic.js"
);
const base: RawObservation = {
id: "obs_1",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
hookType: "post_tool_use",
raw: {},
};
const cases: Array<[string, string]> = [
["Read", "file_read"],
["Write", "file_write"],
["Edit", "file_edit"],
["Bash", "command_run"],
["Grep", "search"],
["WebFetch", "web_fetch"],
["Task", "subagent"],
["UnknownTool", "other"],
];
for (const [name, expectedType] of cases) {
const synthetic = (
await import("../src/functions/compress-synthetic.js")
).buildSyntheticCompression({ ...base, toolName: name });
expect(synthetic.type, `${name} -> ${expectedType}`).toBe(expectedType);
}
// silence unused warning — buildSyntheticCompression is used above
expect(typeof buildSyntheticCompression).toBe("function");
});
it("extracts file paths from tool_input into the files array", async () => {
const { buildSyntheticCompression } = await import(
"../src/functions/compress-synthetic.js"
);
const synth = buildSyntheticCompression({
id: "obs_2",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
hookType: "post_tool_use",
toolName: "Edit",
toolInput: { file_path: "/app/src/bar.ts", pattern: "foo" },
raw: {},
});
expect(synth.files).toContain("/app/src/bar.ts");
expect(synth.files).toContain("foo");
expect(synth.type).toBe("file_edit");
});
it("truncates long narratives so it can't blow up the index", async () => {
const { buildSyntheticCompression } = await import(
"../src/functions/compress-synthetic.js"
);
const longInput = "x".repeat(2000);
const synth = buildSyntheticCompression({
id: "obs_3",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
hookType: "post_tool_use",
toolName: "Bash",
toolInput: { command: longInput },
toolOutput: longInput,
raw: {},
});
expect(synth.narrative.length).toBeLessThanOrEqual(400);
});
it("maps post_tool_failure to the error type even with no tool name", async () => {
const { buildSyntheticCompression } = await import(
"../src/functions/compress-synthetic.js"
);
const synth = buildSyntheticCompression({
id: "obs_4",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
hookType: "post_tool_failure",
raw: {},
});
expect(synth.type).toBe("error");
});
});
+273
View File
@@ -0,0 +1,273 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerAutoForgetFunction } from "../src/functions/auto-forget.js";
import {
getSearchIndex,
setIndexPersistence,
} from "../src/functions/search.js";
import { memoryToObservation } from "../src/state/memory-utils.js";
import type { Memory, CompressedObservation, Session } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: "mem_1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "pattern",
title: "Test memory",
content: "This is a test memory with enough words for comparison",
concepts: ["test"],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
...overrides,
};
}
describe("Auto-Forget Function", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerAutoForgetFunction(sdk as never, kv as never);
});
it("detects and deletes TTL-expired memories", async () => {
const expired = makeMemory({
id: "mem_expired",
forgetAfter: "2020-01-01T00:00:00Z",
});
await kv.set("mem:memories", "mem_expired", expired);
const result = (await sdk.trigger("mem::auto-forget", {})) as {
ttlExpired: string[];
};
expect(result.ttlExpired).toContain("mem_expired");
const deleted = await kv.get("mem:memories", "mem_expired");
expect(deleted).toBeNull();
});
it("detects contradiction between very similar memories", async () => {
const mem1 = makeMemory({
id: "mem_1",
content: "Use React hooks for state management in all components",
createdAt: "2026-01-01T00:00:00Z",
});
const mem2 = makeMemory({
id: "mem_2",
content: "Use React hooks for state management in all components",
createdAt: "2026-02-01T00:00:00Z",
});
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
const result = (await sdk.trigger("mem::auto-forget", {})) as {
contradictions: Array<{
memoryA: string;
memoryB: string;
similarity: number;
}>;
};
expect(result.contradictions.length).toBe(1);
const older = await kv.get<Memory>("mem:memories", "mem_1");
expect(older!.isLatest).toBe(false);
});
it("evicts low-value old observations", async () => {
const session: Session = {
id: "ses_1",
project: "my-project",
cwd: "/tmp",
startedAt: "2025-01-01T00:00:00Z",
status: "completed",
observationCount: 1,
};
await kv.set("mem:sessions", "ses_1", session);
const oldLowObs: CompressedObservation = {
id: "obs_old",
sessionId: "ses_1",
timestamp: "2025-01-01T00:00:00Z",
type: "other",
title: "trivial event",
facts: [],
narrative: "nothing important",
concepts: [],
files: [],
importance: 1,
};
await kv.set("mem:obs:ses_1", "obs_old", oldLowObs);
const result = (await sdk.trigger("mem::auto-forget", {})) as {
lowValueObs: string[];
};
expect(result.lowValueObs).toContain("obs_old");
});
it("dryRun mode identifies but does not delete anything", async () => {
const expired = makeMemory({
id: "mem_expired",
forgetAfter: "2020-01-01T00:00:00Z",
});
await kv.set("mem:memories", "mem_expired", expired);
const result = (await sdk.trigger("mem::auto-forget", { dryRun: true })) as {
ttlExpired: string[];
dryRun: boolean;
};
expect(result.dryRun).toBe(true);
expect(result.ttlExpired).toContain("mem_expired");
const stillExists = await kv.get("mem:memories", "mem_expired");
expect(stillExists).not.toBeNull();
});
describe("search-index cleanup", () => {
beforeEach(() => {
getSearchIndex().clear();
setIndexPersistence(null);
});
afterEach(() => {
setIndexPersistence(null);
});
it("removes TTL-expired memories from the BM25 index and flushes persistence", async () => {
const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) };
setIndexPersistence(persistence);
const expired = makeMemory({
id: "mem_expired",
forgetAfter: "2020-01-01T00:00:00Z",
});
await kv.set("mem:memories", "mem_expired", expired);
getSearchIndex().add(memoryToObservation(expired));
expect(getSearchIndex().has("mem_expired")).toBe(true);
await sdk.trigger("mem::auto-forget", {});
expect(getSearchIndex().has("mem_expired")).toBe(false);
expect(persistence.save).toHaveBeenCalled();
});
it("removes evicted low-value observations from the BM25 index", async () => {
const session: Session = {
id: "ses_1",
project: "my-project",
cwd: "/tmp",
startedAt: "2025-01-01T00:00:00Z",
status: "completed",
observationCount: 1,
};
await kv.set("mem:sessions", "ses_1", session);
const oldLowObs: CompressedObservation = {
id: "obs_old",
sessionId: "ses_1",
timestamp: "2025-01-01T00:00:00Z",
type: "other",
title: "trivial event",
facts: [],
narrative: "nothing important",
concepts: [],
files: [],
importance: 1,
};
await kv.set("mem:obs:ses_1", "obs_old", oldLowObs);
getSearchIndex().add(oldLowObs);
expect(getSearchIndex().has("obs_old")).toBe(true);
await sdk.trigger("mem::auto-forget", {});
expect(getSearchIndex().has("obs_old")).toBe(false);
});
it("does not flush persistence on dryRun", async () => {
const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) };
setIndexPersistence(persistence);
const expired = makeMemory({
id: "mem_expired",
forgetAfter: "2020-01-01T00:00:00Z",
});
await kv.set("mem:memories", "mem_expired", expired);
getSearchIndex().add(memoryToObservation(expired));
await sdk.trigger("mem::auto-forget", { dryRun: true });
// dryRun must not mutate the index or write to disk.
expect(getSearchIndex().has("mem_expired")).toBe(true);
expect(persistence.save).not.toHaveBeenCalled();
});
});
it("does not flag non-similar memories as contradictions", async () => {
const mem1 = makeMemory({
id: "mem_1",
content: "We use TypeScript with strict mode enabled for all backend services",
});
const mem2 = makeMemory({
id: "mem_2",
content: "The deployment pipeline runs integration tests before merging to main",
});
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
const result = (await sdk.trigger("mem::auto-forget", {})) as {
contradictions: unknown[];
};
expect(result.contradictions.length).toBe(0);
});
});
+275
View File
@@ -0,0 +1,275 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerCascadeFunction } from "../src/functions/cascade.js";
import type { Memory, GraphNode, GraphEdge } from "../src/types.js";
import { mockKV, mockSdk } from "./helpers/mocks.js";
describe("Cascade Update Function", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
vi.clearAllMocks();
registerCascadeFunction(sdk as never, kv as never);
});
it("returns error when supersededMemoryId is missing", async () => {
const result = (await sdk.trigger("mem::cascade-update", {})) as {
success: boolean;
error: string;
};
expect(result.success).toBe(false);
expect(result.error).toBe("supersededMemoryId is required");
});
it("returns error for non-existent memory", async () => {
const result = (await sdk.trigger("mem::cascade-update", {
supersededMemoryId: "mem_missing",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("superseded memory not found");
});
it("flags graph nodes referencing superseded observation IDs", async () => {
const memory: Memory = {
id: "mem_old",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "fact",
title: "Old fact",
content: "Old content",
concepts: ["react"],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: false,
sourceObservationIds: ["obs_a", "obs_b"],
};
await kv.set("mem:memories", "mem_old", memory);
const node: GraphNode = {
id: "node_1",
type: "concept",
name: "react",
properties: {},
sourceObservationIds: ["obs_a"],
createdAt: "2026-03-01T00:00:00Z",
};
await kv.set("mem:graph:nodes", "node_1", node);
const unrelatedNode: GraphNode = {
id: "node_2",
type: "file",
name: "index.ts",
properties: {},
sourceObservationIds: ["obs_c"],
createdAt: "2026-03-01T00:00:00Z",
};
await kv.set("mem:graph:nodes", "node_2", unrelatedNode);
const result = (await sdk.trigger("mem::cascade-update", {
supersededMemoryId: "mem_old",
})) as { success: boolean; flagged: { nodes: number; edges: number } };
expect(result.success).toBe(true);
expect(result.flagged.nodes).toBe(1);
const updated = await kv.get<GraphNode>("mem:graph:nodes", "node_1");
expect(updated!.stale).toBe(true);
const unchanged = await kv.get<GraphNode>("mem:graph:nodes", "node_2");
expect(unchanged!.stale).toBeUndefined();
});
it("flags graph edges referencing superseded observation IDs", async () => {
const memory: Memory = {
id: "mem_old2",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "pattern",
title: "Old pattern",
content: "Old pattern content",
concepts: ["testing"],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: false,
sourceObservationIds: ["obs_x"],
};
await kv.set("mem:memories", "mem_old2", memory);
const edge: GraphEdge = {
id: "edge_1",
type: "uses",
sourceNodeId: "node_a",
targetNodeId: "node_b",
weight: 1,
sourceObservationIds: ["obs_x", "obs_y"],
createdAt: "2026-03-01T00:00:00Z",
};
await kv.set("mem:graph:edges", "edge_1", edge);
const result = (await sdk.trigger("mem::cascade-update", {
supersededMemoryId: "mem_old2",
})) as { success: boolean; flagged: { edges: number } };
expect(result.success).toBe(true);
expect(result.flagged.edges).toBe(1);
const updated = await kv.get<GraphEdge>("mem:graph:edges", "edge_1");
expect(updated!.stale).toBe(true);
});
it("counts sibling memories sharing 2+ concepts", async () => {
const superseded: Memory = {
id: "mem_superseded",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "architecture",
title: "React architecture",
content: "Old arch",
concepts: ["react", "frontend", "typescript"],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: false,
};
await kv.set("mem:memories", "mem_superseded", superseded);
const sibling: Memory = {
id: "mem_sibling",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "pattern",
title: "React patterns",
content: "Sibling memory sharing concepts",
concepts: ["react", "typescript"],
files: [],
sessionIds: [],
strength: 6,
version: 1,
isLatest: true,
};
await kv.set("mem:memories", "mem_sibling", sibling);
const unrelated: Memory = {
id: "mem_unrelated",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "fact",
title: "Python setup",
content: "Unrelated memory",
concepts: ["python", "backend"],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: true,
};
await kv.set("mem:memories", "mem_unrelated", unrelated);
const result = (await sdk.trigger("mem::cascade-update", {
supersededMemoryId: "mem_superseded",
})) as { success: boolean; flagged: { siblingMemories: number }; total: number };
expect(result.success).toBe(true);
expect(result.flagged.siblingMemories).toBe(1);
expect(result.total).toBeGreaterThanOrEqual(1);
});
it("skips already stale nodes", async () => {
const memory: Memory = {
id: "mem_skip",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "fact",
title: "Skip test",
content: "Content",
concepts: [],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: false,
sourceObservationIds: ["obs_s"],
};
await kv.set("mem:memories", "mem_skip", memory);
const node: GraphNode = {
id: "node_stale",
type: "concept",
name: "already stale",
properties: {},
sourceObservationIds: ["obs_s"],
createdAt: "2026-03-01T00:00:00Z",
stale: true,
};
await kv.set("mem:graph:nodes", "node_stale", node);
const result = (await sdk.trigger("mem::cascade-update", {
supersededMemoryId: "mem_skip",
})) as { success: boolean; flagged: { nodes: number } };
expect(result.success).toBe(true);
expect(result.flagged.nodes).toBe(0);
});
it("does not flag siblings when fewer than 2 shared concepts", async () => {
const memory: Memory = {
id: "mem_one_concept",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "fact",
title: "One concept",
content: "Content",
concepts: ["react"],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: false,
};
await kv.set("mem:memories", "mem_one_concept", memory);
const result = (await sdk.trigger("mem::cascade-update", {
supersededMemoryId: "mem_one_concept",
})) as { success: boolean; flagged: { siblingMemories: number } };
expect(result.success).toBe(true);
expect(result.flagged.siblingMemories).toBe(0);
});
it("returns zero counts when no sourceObservationIds and < 2 concepts", async () => {
const memory: Memory = {
id: "mem_empty",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "fact",
title: "Empty refs",
content: "No references",
concepts: [],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: false,
};
await kv.set("mem:memories", "mem_empty", memory);
const result = (await sdk.trigger("mem::cascade-update", {
supersededMemoryId: "mem_empty",
})) as { success: boolean; total: number };
expect(result.success).toBe(true);
expect(result.total).toBe(0);
});
});
+494
View File
@@ -0,0 +1,494 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerCheckpointsFunction } from "../src/functions/checkpoints.js";
import type { Action, ActionEdge, Checkpoint } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeAction(
id: string,
status: Action["status"] = "blocked",
): Action {
return {
id,
title: `Action ${id}`,
description: `Description for ${id}`,
status,
priority: 5,
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
createdBy: "agent-setup",
tags: [],
sourceObservationIds: [],
sourceMemoryIds: [],
};
}
describe("Checkpoint Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(async () => {
sdk = mockSdk();
kv = mockKV();
registerCheckpointsFunction(sdk as never, kv as never);
});
describe("mem::checkpoint-create", () => {
it("creates a checkpoint with valid name", async () => {
const result = (await sdk.trigger("mem::checkpoint-create", {
name: "CI Build",
description: "Wait for CI to pass",
type: "ci",
})) as { success: boolean; checkpoint: Checkpoint };
expect(result.success).toBe(true);
expect(result.checkpoint.name).toBe("CI Build");
expect(result.checkpoint.description).toBe("Wait for CI to pass");
expect(result.checkpoint.status).toBe("pending");
expect(result.checkpoint.type).toBe("ci");
expect(result.checkpoint.id).toMatch(/^ckpt_/);
});
it("returns error when name is missing", async () => {
const result = (await sdk.trigger("mem::checkpoint-create", {
name: "",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("name is required");
});
it("defaults type to external when not specified", async () => {
const result = (await sdk.trigger("mem::checkpoint-create", {
name: "External Gate",
})) as { success: boolean; checkpoint: Checkpoint };
expect(result.success).toBe(true);
expect(result.checkpoint.type).toBe("external");
});
it("sets expiresAt when expiresInMs is provided", async () => {
const before = Date.now();
const result = (await sdk.trigger("mem::checkpoint-create", {
name: "Timed Gate",
expiresInMs: 60000,
})) as { success: boolean; checkpoint: Checkpoint };
expect(result.success).toBe(true);
expect(result.checkpoint.expiresAt).toBeDefined();
const expiresAt = new Date(result.checkpoint.expiresAt!).getTime();
expect(expiresAt).toBeGreaterThanOrEqual(before + 60000);
});
it("creates action edges for linkedActionIds", async () => {
await kv.set("mem:actions", "act_1", makeAction("act_1"));
await kv.set("mem:actions", "act_2", makeAction("act_2"));
const result = (await sdk.trigger("mem::checkpoint-create", {
name: "Deployment Gate",
type: "deploy",
linkedActionIds: ["act_1", "act_2"],
})) as { success: boolean; checkpoint: Checkpoint };
expect(result.success).toBe(true);
expect(result.checkpoint.linkedActionIds).toEqual(["act_1", "act_2"]);
const edges = await kv.list<ActionEdge>("mem:action-edges");
expect(edges.length).toBe(2);
expect(edges[0].type).toBe("gated_by");
expect(edges[0].targetActionId).toBe(result.checkpoint.id);
expect(edges.map((e) => e.sourceActionId).sort()).toEqual(["act_1", "act_2"]);
});
it("creates no edges when linkedActionIds is empty", async () => {
await sdk.trigger("mem::checkpoint-create", {
name: "No Links",
linkedActionIds: [],
});
const edges = await kv.list<ActionEdge>("mem:action-edges");
expect(edges.length).toBe(0);
});
});
describe("mem::checkpoint-resolve", () => {
it("resolves a pending checkpoint to passed", async () => {
const created = (await sdk.trigger("mem::checkpoint-create", {
name: "CI Gate",
type: "ci",
})) as { success: boolean; checkpoint: Checkpoint };
const result = (await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: created.checkpoint.id,
status: "passed",
resolvedBy: "ci-bot",
result: { buildId: 123 },
})) as { success: boolean; checkpoint: Checkpoint; unblockedCount: number };
expect(result.success).toBe(true);
expect(result.checkpoint.status).toBe("passed");
expect(result.checkpoint.resolvedBy).toBe("ci-bot");
expect(result.checkpoint.resolvedAt).toBeDefined();
expect(result.checkpoint.result).toEqual({ buildId: 123 });
});
it("resolves a pending checkpoint to failed", async () => {
const created = (await sdk.trigger("mem::checkpoint-create", {
name: "Approval Gate",
type: "approval",
})) as { success: boolean; checkpoint: Checkpoint };
const result = (await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: created.checkpoint.id,
status: "failed",
resolvedBy: "reviewer",
})) as { success: boolean; checkpoint: Checkpoint };
expect(result.success).toBe(true);
expect(result.checkpoint.status).toBe("failed");
});
it("returns error when checkpoint is already resolved", async () => {
const created = (await sdk.trigger("mem::checkpoint-create", {
name: "Already Done",
})) as { success: boolean; checkpoint: Checkpoint };
await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: created.checkpoint.id,
status: "passed",
});
const result = (await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: created.checkpoint.id,
status: "failed",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("checkpoint already passed");
});
it("returns error for nonexistent checkpoint", async () => {
const result = (await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: "ckpt_nonexistent",
status: "passed",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("checkpoint not found");
});
it("returns error when checkpointId or status is missing", async () => {
const result = (await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: "",
status: "passed",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("checkpointId and status are required");
});
it("unblocks gated actions when all checkpoints pass", async () => {
await kv.set("mem:actions", "act_1", makeAction("act_1", "blocked"));
const cp1 = (await sdk.trigger("mem::checkpoint-create", {
name: "Gate 1",
type: "ci",
linkedActionIds: ["act_1"],
})) as { success: boolean; checkpoint: Checkpoint };
const cp2 = (await sdk.trigger("mem::checkpoint-create", {
name: "Gate 2",
type: "approval",
linkedActionIds: ["act_1"],
})) as { success: boolean; checkpoint: Checkpoint };
await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: cp1.checkpoint.id,
status: "passed",
});
const actionAfterFirst = await kv.get<Action>("mem:actions", "act_1");
expect(actionAfterFirst!.status).toBe("blocked");
const result = (await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: cp2.checkpoint.id,
status: "passed",
})) as { success: boolean; unblockedCount: number };
expect(result.success).toBe(true);
expect(result.unblockedCount).toBe(1);
const action = await kv.get<Action>("mem:actions", "act_1");
expect(action!.status).toBe("pending");
});
it("does not unblock actions when checkpoint fails", async () => {
await kv.set("mem:actions", "act_1", makeAction("act_1", "blocked"));
const cp = (await sdk.trigger("mem::checkpoint-create", {
name: "Failing Gate",
linkedActionIds: ["act_1"],
})) as { success: boolean; checkpoint: Checkpoint };
const result = (await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: cp.checkpoint.id,
status: "failed",
})) as { success: boolean; unblockedCount: number };
expect(result.success).toBe(true);
expect(result.unblockedCount).toBe(0);
const action = await kv.get<Action>("mem:actions", "act_1");
expect(action!.status).toBe("blocked");
});
it("does not unblock actions that are not in blocked status", async () => {
await kv.set("mem:actions", "act_1", makeAction("act_1", "active"));
const cp = (await sdk.trigger("mem::checkpoint-create", {
name: "Gate for non-blocked",
linkedActionIds: ["act_1"],
})) as { success: boolean; checkpoint: Checkpoint };
const result = (await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: cp.checkpoint.id,
status: "passed",
})) as { success: boolean; unblockedCount: number };
expect(result.success).toBe(true);
expect(result.unblockedCount).toBe(0);
});
});
describe("mem::checkpoint-list", () => {
beforeEach(async () => {
await sdk.trigger("mem::checkpoint-create", {
name: "CI Check",
type: "ci",
});
await sdk.trigger("mem::checkpoint-create", {
name: "Approval Check",
type: "approval",
});
await sdk.trigger("mem::checkpoint-create", {
name: "Deploy Check",
type: "deploy",
});
});
it("lists all checkpoints when no filters applied", async () => {
const result = (await sdk.trigger("mem::checkpoint-list", {})) as {
success: boolean;
checkpoints: Checkpoint[];
};
expect(result.success).toBe(true);
expect(result.checkpoints.length).toBe(3);
});
it("filters checkpoints by status", async () => {
const all = (await sdk.trigger("mem::checkpoint-list", {})) as {
checkpoints: Checkpoint[];
};
const firstId = all.checkpoints[0].id;
await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: firstId,
status: "passed",
});
const pending = (await sdk.trigger("mem::checkpoint-list", {
status: "pending",
})) as { success: boolean; checkpoints: Checkpoint[] };
expect(pending.success).toBe(true);
expect(pending.checkpoints.length).toBe(2);
expect(pending.checkpoints.every((c) => c.status === "pending")).toBe(true);
const passed = (await sdk.trigger("mem::checkpoint-list", {
status: "passed",
})) as { success: boolean; checkpoints: Checkpoint[] };
expect(passed.checkpoints.length).toBe(1);
expect(passed.checkpoints[0].status).toBe("passed");
});
it("filters checkpoints by type", async () => {
const result = (await sdk.trigger("mem::checkpoint-list", {
type: "ci",
})) as { success: boolean; checkpoints: Checkpoint[] };
expect(result.success).toBe(true);
expect(result.checkpoints.length).toBe(1);
expect(result.checkpoints[0].type).toBe("ci");
expect(result.checkpoints[0].name).toBe("CI Check");
});
it("returns empty list when no checkpoints match filter", async () => {
const result = (await sdk.trigger("mem::checkpoint-list", {
type: "external",
})) as { success: boolean; checkpoints: Checkpoint[] };
expect(result.success).toBe(true);
expect(result.checkpoints.length).toBe(0);
});
it("sorts checkpoints by createdAt descending", async () => {
const result = (await sdk.trigger("mem::checkpoint-list", {})) as {
success: boolean;
checkpoints: Checkpoint[];
};
for (let i = 0; i < result.checkpoints.length - 1; i++) {
const current = new Date(result.checkpoints[i].createdAt).getTime();
const next = new Date(result.checkpoints[i + 1].createdAt).getTime();
expect(current).toBeGreaterThanOrEqual(next);
}
});
});
describe("mem::checkpoint-expire", () => {
it("expires pending checkpoints past their expiresAt", async () => {
const created = (await sdk.trigger("mem::checkpoint-create", {
name: "Expiring Gate",
expiresInMs: 1,
})) as { success: boolean; checkpoint: Checkpoint };
created.checkpoint.expiresAt = new Date(Date.now() - 60000).toISOString();
await kv.set("mem:checkpoints", created.checkpoint.id, created.checkpoint);
const result = (await sdk.trigger("mem::checkpoint-expire", {})) as {
success: boolean;
expired: number;
};
expect(result.success).toBe(true);
expect(result.expired).toBe(1);
const cp = await kv.get<Checkpoint>("mem:checkpoints", created.checkpoint.id);
expect(cp!.status).toBe("expired");
expect(cp!.resolvedAt).toBeDefined();
});
it("does not expire non-pending checkpoints", async () => {
const created = (await sdk.trigger("mem::checkpoint-create", {
name: "Already Passed",
expiresInMs: 1,
})) as { success: boolean; checkpoint: Checkpoint };
await sdk.trigger("mem::checkpoint-resolve", {
checkpointId: created.checkpoint.id,
status: "passed",
});
const cp = await kv.get<Checkpoint>("mem:checkpoints", created.checkpoint.id);
cp!.expiresAt = new Date(Date.now() - 60000).toISOString();
await kv.set("mem:checkpoints", created.checkpoint.id, cp);
const result = (await sdk.trigger("mem::checkpoint-expire", {})) as {
success: boolean;
expired: number;
};
expect(result.success).toBe(true);
expect(result.expired).toBe(0);
});
it("does not expire checkpoints without expiresAt", async () => {
await sdk.trigger("mem::checkpoint-create", {
name: "No Expiry",
});
const result = (await sdk.trigger("mem::checkpoint-expire", {})) as {
success: boolean;
expired: number;
};
expect(result.success).toBe(true);
expect(result.expired).toBe(0);
});
it("does not expire checkpoints whose expiresAt is in the future", async () => {
await sdk.trigger("mem::checkpoint-create", {
name: "Future Gate",
expiresInMs: 3600000,
});
const result = (await sdk.trigger("mem::checkpoint-expire", {})) as {
success: boolean;
expired: number;
};
expect(result.success).toBe(true);
expect(result.expired).toBe(0);
});
it("handles multiple expired checkpoints", async () => {
const cp1 = (await sdk.trigger("mem::checkpoint-create", {
name: "Expired 1",
expiresInMs: 1,
})) as { success: boolean; checkpoint: Checkpoint };
const cp2 = (await sdk.trigger("mem::checkpoint-create", {
name: "Expired 2",
expiresInMs: 1,
})) as { success: boolean; checkpoint: Checkpoint };
cp1.checkpoint.expiresAt = new Date(Date.now() - 60000).toISOString();
await kv.set("mem:checkpoints", cp1.checkpoint.id, cp1.checkpoint);
cp2.checkpoint.expiresAt = new Date(Date.now() - 30000).toISOString();
await kv.set("mem:checkpoints", cp2.checkpoint.id, cp2.checkpoint);
const result = (await sdk.trigger("mem::checkpoint-expire", {})) as {
success: boolean;
expired: number;
};
expect(result.success).toBe(true);
expect(result.expired).toBe(2);
});
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CircuitBreaker } from "../src/providers/circuit-breaker.js";
describe("CircuitBreaker", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts in closed state", () => {
const cb = new CircuitBreaker();
expect(cb.getState().state).toBe("closed");
expect(cb.isAllowed).toBe(true);
});
it("stays closed after fewer than 3 failures", () => {
const cb = new CircuitBreaker();
cb.recordFailure();
cb.recordFailure();
expect(cb.getState().state).toBe("closed");
expect(cb.isAllowed).toBe(true);
});
it("opens after 3 failures within the window", () => {
const cb = new CircuitBreaker();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.getState().state).toBe("open");
expect(cb.isAllowed).toBe(false);
});
it("resets failure count when failures are outside the window", () => {
const cb = new CircuitBreaker();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(61_000);
cb.recordFailure();
expect(cb.getState().state).toBe("closed");
expect(cb.getState().failures).toBe(1);
});
it("transitions to half-open after recovery timeout", () => {
const cb = new CircuitBreaker();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.isAllowed).toBe(false);
vi.advanceTimersByTime(30_000);
expect(cb.isAllowed).toBe(true);
expect(cb.getState().state).toBe("half-open");
});
it("closes on success in half-open state", () => {
const cb = new CircuitBreaker();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(30_000);
cb.isAllowed;
cb.recordSuccess();
expect(cb.getState().state).toBe("closed");
expect(cb.getState().failures).toBe(0);
expect(cb.getState().lastFailureAt).toBeNull();
});
it("reopens on failure in half-open state", () => {
const cb = new CircuitBreaker();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(30_000);
cb.isAllowed;
cb.recordFailure();
expect(cb.getState().state).toBe("open");
});
it("records lastFailureAt timestamp", () => {
const cb = new CircuitBreaker();
vi.setSystemTime(new Date("2026-01-15T10:00:00Z"));
cb.recordFailure();
expect(cb.getState().lastFailureAt).toBe(
new Date("2026-01-15T10:00:00Z").getTime(),
);
});
it("records openedAt timestamp", () => {
const cb = new CircuitBreaker();
vi.setSystemTime(new Date("2026-01-15T10:00:00Z"));
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.getState().openedAt).toBe(
new Date("2026-01-15T10:00:00Z").getTime(),
);
});
it("success in closed state is a no-op", () => {
const cb = new CircuitBreaker();
cb.recordSuccess();
expect(cb.getState().state).toBe("closed");
expect(cb.getState().failures).toBe(0);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadClaudeBridgeConfig } from "../src/config.js";
// bridge path must match Claude Code's slug convention exactly:
// ~/.claude/projects/<slug>/MEMORY.md
// where <slug> replaces every / and \ with - and KEEPS any leading -.
// The previous code stripped the leading - and added a /memory/
// subdirectory; the bridge then wrote a file Claude Code never read.
describe("loadClaudeBridgeConfig path (#625)", () => {
const ORIG_ENV = { ...process.env };
beforeEach(() => {
delete process.env["CLAUDE_MEMORY_BRIDGE"];
delete process.env["CLAUDE_PROJECT_PATH"];
delete process.env["CLAUDE_MEMORY_LINE_BUDGET"];
});
afterEach(() => {
process.env = { ...ORIG_ENV };
});
it("preserves leading - on POSIX absolute paths", () => {
process.env["CLAUDE_MEMORY_BRIDGE"] = "true";
process.env["CLAUDE_PROJECT_PATH"] = "/home/user/repos/my-project";
const cfg = loadClaudeBridgeConfig();
expect(cfg.memoryFilePath).toBe(
join(homedir(), ".claude", "projects", "-home-user-repos-my-project", "MEMORY.md"),
);
});
it("writes MEMORY.md directly under the slug dir, no memory/ subdir", () => {
process.env["CLAUDE_MEMORY_BRIDGE"] = "true";
process.env["CLAUDE_PROJECT_PATH"] = "/Users/x/agentmemory";
const cfg = loadClaudeBridgeConfig();
expect(cfg.memoryFilePath).not.toMatch(/[/\\]memory[/\\]MEMORY\.md$/);
expect(cfg.memoryFilePath).toMatch(/-Users-x-agentmemory[/\\]MEMORY\.md$/);
});
it("returns empty memoryFilePath when bridge disabled", () => {
const cfg = loadClaudeBridgeConfig();
expect(cfg.enabled).toBe(false);
expect(cfg.memoryFilePath).toBe("");
});
it("returns empty memoryFilePath when project path unset", () => {
process.env["CLAUDE_MEMORY_BRIDGE"] = "true";
const cfg = loadClaudeBridgeConfig();
expect(cfg.enabled).toBe(true);
expect(cfg.memoryFilePath).toBe("");
});
it("handles Windows-style backslash paths by swapping to -", () => {
process.env["CLAUDE_MEMORY_BRIDGE"] = "true";
process.env["CLAUDE_PROJECT_PATH"] = "C:\\Users\\x\\project";
const cfg = loadClaudeBridgeConfig();
expect(cfg.memoryFilePath).toMatch(/C:-Users-x-project[/\\]MEMORY\.md$/);
});
});
+179
View File
@@ -0,0 +1,179 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
}));
vi.mock("node:path", async () => ({
...(await vi.importActual("node:path")),
dirname: vi.fn().mockReturnValue("/tmp"),
}));
import { registerClaudeBridgeFunction } from "../src/functions/claude-bridge.js";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import type { ClaudeBridgeConfig, Memory } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
const enabledConfig: ClaudeBridgeConfig = {
enabled: true,
projectPath: "/tmp/my-project",
memoryFilePath: "/tmp/.claude/MEMORY.md",
lineBudget: 200,
};
const disabledConfig: ClaudeBridgeConfig = {
enabled: false,
projectPath: "",
memoryFilePath: "",
lineBudget: 200,
};
describe("Claude Bridge Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
vi.clearAllMocks();
});
it("claude-bridge-read returns content when file exists", async () => {
registerClaudeBridgeFunction(sdk as never, kv as never, enabledConfig);
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(
"# Memory\n\n## Project Summary\nA test project\n\n## Key Memories\nSome memories",
);
const result = (await sdk.trigger("mem::claude-bridge-read", {})) as {
success: boolean;
content: string;
sections: Record<string, string>;
};
expect(result.success).toBe(true);
expect(result.content).toContain("# Memory");
expect(result.sections).toBeDefined();
expect(result.sections["Project Summary"]).toBe("A test project");
});
it("claude-bridge-read returns empty when file does not exist", async () => {
registerClaudeBridgeFunction(sdk as never, kv as never, enabledConfig);
vi.mocked(existsSync).mockReturnValue(false);
const result = (await sdk.trigger("mem::claude-bridge-read", {})) as {
success: boolean;
content: string;
parsed: boolean;
};
expect(result.success).toBe(true);
expect(result.content).toBe("");
expect(result.parsed).toBe(false);
});
it("claude-bridge-sync writes MEMORY.md with memories", async () => {
registerClaudeBridgeFunction(sdk as never, kv as never, enabledConfig);
const mem: Memory = {
id: "mem_1",
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
type: "pattern",
title: "Auth pattern",
content: "Always validate tokens",
concepts: ["auth"],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
};
await kv.set("mem:memories", "mem_1", mem);
vi.mocked(existsSync).mockReturnValue(true);
const result = (await sdk.trigger("mem::claude-bridge-sync", {})) as {
success: boolean;
path: string;
lines: number;
};
expect(result.success).toBe(true);
expect(result.path).toBe("/tmp/.claude/MEMORY.md");
expect(writeFileSync).toHaveBeenCalled();
const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string;
expect(writtenContent).toContain("Auth pattern");
});
it("claude-bridge-sync returns error when not configured", async () => {
registerClaudeBridgeFunction(sdk as never, kv as never, disabledConfig);
const result = (await sdk.trigger("mem::claude-bridge-sync", {})) as {
success: boolean;
error: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain("not configured");
});
it("claude-bridge-read returns error when not configured", async () => {
registerClaudeBridgeFunction(sdk as never, kv as never, disabledConfig);
const result = (await sdk.trigger("mem::claude-bridge-read", {})) as {
success: boolean;
error: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain("not configured");
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import { resolve } from "node:path";
import {
buildMergedHooks,
findPluginRoot,
type HookManifest,
} from "../src/cli/connect/codex-hooks.js";
const PLUGIN_ROOT = resolve(__dirname, "..", "plugin");
describe("buildMergedHooks against plugin/hooks/hooks.json (Claude Code)", () => {
it("locates the same plugin root used by the codex variant", () => {
expect(findPluginRoot()).toBe(PLUGIN_ROOT);
});
it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => {
const merged = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json");
for (const entries of Object.values(merged.hooks)) {
for (const entry of entries) {
for (const handler of entry.hooks) {
expect(handler.command).not.toContain("${CLAUDE_PLUGIN_ROOT}");
expect(handler.command).toContain(`${PLUGIN_ROOT}/scripts/`);
}
}
}
});
it("includes Claude-only events that hooks.codex.json omits", () => {
const merged = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json");
const events = Object.keys(merged.hooks);
expect(events).toContain("SessionStart");
expect(events).toContain("Stop");
const claudeOnly = ["SessionEnd", "SubagentStop", "Notification"];
expect(
claudeOnly.some((e) => events.includes(e)),
`hooks.json should include at least one Claude-only event (${claudeOnly.join(", ")})`,
).toBe(true);
});
it("appends to existing user hooks without dropping them", () => {
const existing: HookManifest = {
hooks: {
SessionStart: [
{ hooks: [{ type: "command", command: "echo user-custom-claude" }] },
],
},
};
const merged = buildMergedHooks(existing, PLUGIN_ROOT, "hooks.json");
const sessionStart = merged.hooks["SessionStart"]!;
expect(
sessionStart.some((e) =>
e.hooks.some((h) => h.command === "echo user-custom-claude"),
),
).toBe(true);
expect(
sessionStart.some((e) =>
e.hooks.some((h) =>
h.command.includes(`${PLUGIN_ROOT}/scripts/session-start.mjs`),
),
),
).toBe(true);
});
it("re-install strips previous agentmemory entries (idempotent)", () => {
const first = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json");
const second = buildMergedHooks(first, PLUGIN_ROOT, "hooks.json");
for (const event of Object.keys(first.hooks)) {
expect(
second.hooks[event]!.length,
`${event} should not double after second install`,
).toBe(first.hooks[event]!.length);
}
});
});
+503
View File
@@ -0,0 +1,503 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
ADAPTERS,
knownAgents,
resolveAdapter,
} from "../src/cli/connect/index.js";
import type { ConnectAdapter } from "../src/cli/connect/types.js";
const EXPECTED_COPILOT_MCP_COMMAND =
process.platform === "win32"
? {
command: process.env["ComSpec"] || process.env["COMSPEC"] || "cmd.exe",
args: ["/d", "/s", "/c", "npx", "-y", "@agentmemory/mcp"],
}
: {
command: "npx",
args: ["-y", "@agentmemory/mcp"],
};
describe("agentmemory connect — dispatcher", () => {
it("resolves every known agent by lowercase name", () => {
for (const name of knownAgents()) {
const a = resolveAdapter(name);
expect(a, `expected adapter for ${name}`).not.toBeNull();
expect(a!.name).toBe(name);
}
});
it("resolves case-insensitively", () => {
expect(resolveAdapter("Claude-Code")?.name).toBe("claude-code");
expect(resolveAdapter("CURSOR")?.name).toBe("cursor");
});
it("returns null for unknown agents", () => {
expect(resolveAdapter("nonexistent-agent")).toBeNull();
expect(resolveAdapter("")).toBeNull();
});
it("ships the supported agent list", () => {
expect(knownAgents().sort()).toEqual(
[
"antigravity",
"claude-code",
"cline",
"copilot-cli",
"codex",
"continue",
"cursor",
"droid",
"gemini-cli",
"hermes",
"kiro",
"opencode",
"openclaw",
"openhuman",
"pi",
"qwen",
"warp",
"zed",
].sort(),
);
expect(ADAPTERS.length).toBe(18);
});
it("every adapter exposes detect() and install()", () => {
for (const a of ADAPTERS) {
expect(typeof a.detect).toBe("function");
expect(typeof a.install).toBe("function");
expect(typeof a.name).toBe("string");
expect(typeof a.displayName).toBe("string");
}
});
it("every adapter declares a category so onboarding never needs a separate list (#872)", () => {
for (const a of ADAPTERS) {
expect(
["native", "mcp"].includes(a.category as string),
`adapter ${a.name} must set category to "native" or "mcp"`,
).toBe(true);
}
});
});
describe("agentmemory connect — claude-code adapter (mock filesystem)", () => {
let tmpHome: string;
let originalHome: string | undefined;
let originalUserprofile: string | undefined;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), "am-connect-"));
originalHome = process.env["HOME"];
originalUserprofile = process.env["USERPROFILE"];
process.env["HOME"] = tmpHome;
process.env["USERPROFILE"] = tmpHome;
vi.resetModules();
});
afterEach(() => {
if (originalHome !== undefined) process.env["HOME"] = originalHome;
else delete process.env["HOME"];
if (originalUserprofile !== undefined)
process.env["USERPROFILE"] = originalUserprofile;
else delete process.env["USERPROFILE"];
rmSync(tmpHome, { recursive: true, force: true });
vi.resetModules();
});
async function loadAdapter(): Promise<ConnectAdapter> {
const mod = await import("../src/cli/connect/claude-code.js?t=" + Date.now());
return (mod as { adapter: ConnectAdapter }).adapter;
}
it("detect() returns false when ~/.claude doesn't exist", async () => {
const a = await loadAdapter();
expect(a.detect()).toBe(false);
});
it("install() writes mcpServers.agentmemory into ~/.claude.json and is idempotent", async () => {
const claudeDir = join(tmpHome, ".claude");
require("node:fs").mkdirSync(claudeDir, { recursive: true });
writeFileSync(
join(tmpHome, ".claude.json"),
JSON.stringify({ mcpServers: { other: { command: "x" } } }),
);
const a = await loadAdapter();
expect(a.detect()).toBe(true);
const first = await a.install({ dryRun: false, force: false });
expect(first.kind).toBe("installed");
const config = JSON.parse(readFileSync(join(tmpHome, ".claude.json"), "utf-8"));
expect(config.mcpServers.agentmemory.command).toBe("npx");
expect(config.mcpServers.agentmemory.args).toContain("@agentmemory/mcp");
expect(config.mcpServers.other.command).toBe("x");
const second = await a.install({ dryRun: false, force: false });
expect(second.kind).toBe("already-wired");
});
it("install() writes env passthrough block for AGENTMEMORY_URL + AGENTMEMORY_SECRET (#375)", async () => {
// Remote deployments (k8s, reverse proxy) set AGENTMEMORY_URL +
// AGENTMEMORY_SECRET in the shell. The wired MCP entry must honour
// those via ${VAR} expansion so a single entry covers both local
// and remote without the user needing to add a duplicate config
// that triggers a /doctor duplicate-server warning.
const claudeDir = join(tmpHome, ".claude");
require("node:fs").mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(tmpHome, ".claude.json"), JSON.stringify({}));
const a = await loadAdapter();
const result = await a.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const config = JSON.parse(readFileSync(join(tmpHome, ".claude.json"), "utf-8"));
const entry = config.mcpServers.agentmemory;
expect(entry.env).toBeDefined();
// env interpolation must carry a default so Claude Code
// doesn't silently drop the server when the user hasn't exported
// AGENTMEMORY_URL / AGENTMEMORY_SECRET. Defaults match the
// documented runtime (localhost:3111, no auth, all tools).
expect(entry.env.AGENTMEMORY_URL).toBe(
"${AGENTMEMORY_URL:-http://localhost:3111}",
);
expect(entry.env.AGENTMEMORY_SECRET).toBe("${AGENTMEMORY_SECRET:-}");
expect(entry.env.AGENTMEMORY_TOOLS).toBe("${AGENTMEMORY_TOOLS:-all}");
});
it("install() with --force re-writes even when already wired", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true });
writeFileSync(
join(tmpHome, ".claude.json"),
JSON.stringify({
mcpServers: {
agentmemory: { command: "npx", args: ["-y", "@agentmemory/mcp"] },
},
}),
);
const a = await loadAdapter();
const result = await a.install({ dryRun: false, force: true });
expect(result.kind).toBe("installed");
});
it("install() with --dry-run does not mutate the file", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true });
const before = JSON.stringify({ mcpServers: {} });
writeFileSync(join(tmpHome, ".claude.json"), before);
const a = await loadAdapter();
const result = await a.install({ dryRun: true, force: false });
expect(result.kind).toBe("installed");
const after = readFileSync(join(tmpHome, ".claude.json"), "utf-8");
expect(after).toBe(before);
});
it("install() creates a backup file under ~/.agentmemory/backups/", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true });
writeFileSync(
join(tmpHome, ".claude.json"),
JSON.stringify({ mcpServers: {} }),
);
const a = await loadAdapter();
const result = await a.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
if (result.kind === "installed") {
expect(result.backupPath).toBeDefined();
expect(existsSync(result.backupPath!)).toBe(true);
expect(result.backupPath!).toContain(join(".agentmemory", "backups"));
}
});
});
describe("agentmemory connect — opencode adapter (#872)", () => {
let tmpHome: string;
let originalHome: string | undefined;
let originalUserprofile: string | undefined;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), "am-opencode-"));
originalHome = process.env["HOME"];
originalUserprofile = process.env["USERPROFILE"];
process.env["HOME"] = tmpHome;
process.env["USERPROFILE"] = tmpHome;
vi.resetModules();
});
afterEach(() => {
if (originalHome !== undefined) process.env["HOME"] = originalHome;
else delete process.env["HOME"];
if (originalUserprofile !== undefined)
process.env["USERPROFILE"] = originalUserprofile;
else delete process.env["USERPROFILE"];
rmSync(tmpHome, { recursive: true, force: true });
vi.resetModules();
});
const cfgPath = () =>
join(tmpHome, ".config", "opencode", "opencode.json");
async function loadOpencode(): Promise<ConnectAdapter> {
const mod = await import("../src/cli/connect/opencode.js?t=" + Date.now());
return (mod as { adapter: ConnectAdapter }).adapter;
}
it("writes the opencode `mcp` schema (command as array) and preserves other servers", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".config", "opencode"), {
recursive: true,
});
writeFileSync(
cfgPath(),
JSON.stringify({ mcp: { other: { type: "local", command: ["x"] } } }),
);
const a = await loadOpencode();
expect(a.name).toBe("opencode");
expect(a.detect()).toBe(true);
const first = await a.install({ dryRun: false, force: false });
expect(first.kind).toBe("installed");
const config = JSON.parse(readFileSync(cfgPath(), "utf-8"));
const entry = config.mcp.agentmemory;
expect(entry.type).toBe("local");
expect(Array.isArray(entry.command)).toBe(true);
expect(entry.command).toContain("@agentmemory/mcp");
expect(entry.enabled).toBe(true);
expect(config.mcp.other.command).toEqual(["x"]);
const second = await a.install({ dryRun: false, force: false });
expect(second.kind).toBe("already-wired");
});
it("dry-run does not mutate the file", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".config", "opencode"), {
recursive: true,
});
const before = JSON.stringify({ mcp: {} });
writeFileSync(cfgPath(), before);
const a = await loadOpencode();
const result = await a.install({ dryRun: true, force: false });
expect(result.kind).toBe("installed");
expect(readFileSync(cfgPath(), "utf-8")).toBe(before);
});
});
describe("agentmemory connect — copilot-cli adapter (mock filesystem)", () => {
let tmpHome: string;
let originalHome: string | undefined;
let originalUserprofile: string | undefined;
let originalCopilotHome: string | undefined;
let importCounter = 0;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), "am-connect-"));
originalHome = process.env["HOME"];
originalUserprofile = process.env["USERPROFILE"];
originalCopilotHome = process.env["COPILOT_HOME"];
process.env["HOME"] = tmpHome;
process.env["USERPROFILE"] = tmpHome;
delete process.env["COPILOT_HOME"];
vi.resetModules();
});
afterEach(() => {
if (originalHome !== undefined) process.env["HOME"] = originalHome;
else delete process.env["HOME"];
if (originalUserprofile !== undefined)
process.env["USERPROFILE"] = originalUserprofile;
else delete process.env["USERPROFILE"];
if (originalCopilotHome !== undefined)
process.env["COPILOT_HOME"] = originalCopilotHome;
else delete process.env["COPILOT_HOME"];
rmSync(tmpHome, { recursive: true, force: true });
vi.resetModules();
});
async function loadAdapter(): Promise<ConnectAdapter> {
const mod = await import(
"../src/cli/connect/copilot-cli.js?t=" + Date.now() + "-" + importCounter++
);
return (mod as { adapter: ConnectAdapter }).adapter;
}
it("detect() returns false when ~/.copilot doesn't exist", async () => {
const a = await loadAdapter();
expect(a.detect()).toBe(false);
});
it("install() writes mcpServers.agentmemory into ~/.copilot/mcp-config.json and is idempotent", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
const a = await loadAdapter();
expect(a.detect()).toBe(true);
const first = await a.install({ dryRun: false, force: false });
expect(first.kind).toBe("installed");
const config = JSON.parse(
readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"),
);
expect(config.mcpServers.agentmemory).toEqual({
type: "local",
...EXPECTED_COPILOT_MCP_COMMAND,
env: {
AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}",
AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}",
AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}",
},
tools: ["*"],
});
const second = await a.install({ dryRun: false, force: false });
expect(second.kind).toBe("already-wired");
});
it("honors COPILOT_HOME when locating mcp-config.json", async () => {
const customCopilotHome = join(tmpHome, "custom-copilot-home");
process.env["COPILOT_HOME"] = customCopilotHome;
require("node:fs").mkdirSync(customCopilotHome, { recursive: true });
const a = await loadAdapter();
expect(a.detect()).toBe(true);
const result = await a.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
expect(result.mutatedPath).toBe(join(customCopilotHome, "mcp-config.json"));
expect(existsSync(join(customCopilotHome, "mcp-config.json"))).toBe(true);
expect(existsSync(join(tmpHome, ".copilot", "mcp-config.json"))).toBe(false);
});
it("install() preserves unrelated top-level keys and mcpServers entries", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
writeFileSync(
join(tmpHome, ".copilot", "mcp-config.json"),
JSON.stringify({
otherTopLevel: { keep: true },
mcpServers: { other: { type: "local", command: "other" } },
}),
);
const a = await loadAdapter();
const result = await a.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const config = JSON.parse(
readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"),
);
expect(config.otherTopLevel).toEqual({ keep: true });
expect(config.mcpServers.other).toEqual({ type: "local", command: "other" });
expect(config.mcpServers.agentmemory.command).toBe(
EXPECTED_COPILOT_MCP_COMMAND.command,
);
});
it("install() writes env passthrough block for AGENTMEMORY_URL + AGENTMEMORY_SECRET", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
const a = await loadAdapter();
const result = await a.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const config = JSON.parse(
readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"),
);
const entry = config.mcpServers.agentmemory;
expect(entry.env.AGENTMEMORY_URL).toBe(
"${AGENTMEMORY_URL:-http://localhost:3111}",
);
expect(entry.env.AGENTMEMORY_SECRET).toBe("${AGENTMEMORY_SECRET:-}");
expect(entry.env.AGENTMEMORY_TOOLS).toBe("${AGENTMEMORY_TOOLS:-all}");
});
it("install() with --force rewrites even when already wired", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
writeFileSync(
join(tmpHome, ".copilot", "mcp-config.json"),
JSON.stringify({
mcpServers: {
agentmemory: {
type: "local",
...EXPECTED_COPILOT_MCP_COMMAND,
env: {
AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}",
AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}",
AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}",
},
tools: ["memory_save"],
},
},
}),
);
const a = await loadAdapter();
const result = await a.install({ dryRun: false, force: true });
expect(result.kind).toBe("installed");
const config = JSON.parse(
readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"),
);
expect(config.mcpServers.agentmemory.tools).toEqual(["*"]);
});
it("install() with --dry-run does not mutate the file", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
const before = JSON.stringify({ mcpServers: {} });
writeFileSync(join(tmpHome, ".copilot", "mcp-config.json"), before);
const a = await loadAdapter();
const result = await a.install({ dryRun: true, force: false });
expect(result.kind).toBe("installed");
const after = readFileSync(
join(tmpHome, ".copilot", "mcp-config.json"),
"utf-8",
);
expect(after).toBe(before);
});
it("install() creates a backup file when config pre-exists", async () => {
require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true });
writeFileSync(
join(tmpHome, ".copilot", "mcp-config.json"),
JSON.stringify({ mcpServers: {} }),
);
const a = await loadAdapter();
const result = await a.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
if (result.kind === "installed") {
expect(result.backupPath).toBeDefined();
expect(existsSync(result.backupPath!)).toBe(true);
expect(result.backupPath!).toContain(join(".agentmemory", "backups"));
}
});
});
describe("agentmemory connect — stub adapters log + return stub", () => {
it("hermes adapter returns stub regardless of detect", async () => {
const { adapter } = await import("../src/cli/connect/hermes.js");
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("stub");
});
it("openhuman adapter returns stub", async () => {
const { adapter } = await import("../src/cli/connect/openhuman.js");
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("stub");
});
it("pi adapter returns stub", async () => {
const { adapter } = await import("../src/cli/connect/pi.js");
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("stub");
});
});
+239
View File
@@ -0,0 +1,239 @@
// Unit tests for the doctor v2 diagnostic catalog.
//
// We exercise the data structure (every entry has check/fix/message),
// the pure parseEnvFile / realProviderKeys helpers, and the dry-run plan
// formatting. The full interactive prompt loop lives in src/cli.ts and is
// driven by clack — exercising it would require a TTY and is out of scope.
import { describe, it, expect } from "vitest";
import {
buildDiagnostics,
DIAGNOSTIC_IDS,
dryRunPlan,
parseEnvFile,
placeholderProviderKeys,
realProviderKeys,
type DoctorContext,
type DoctorEffects,
} from "../src/cli/doctor-diagnostics.js";
function stubCtx(overrides: Partial<DoctorContext> = {}): DoctorContext {
return {
baseUrl: "http://localhost:3111",
viewerUrl: "http://localhost:3113",
envPath: "/tmp/test/.agentmemory/.env",
pidfilePath: "/tmp/test/.agentmemory/iii.pid",
enginePath: "/tmp/test/.agentmemory/engine-state.json",
pinnedVersion: "0.11.2",
...overrides,
};
}
function stubEffects(overrides: Partial<DoctorEffects> = {}): DoctorEffects {
return {
envFileExists: () => true,
readEnvFile: () => ({ ANTHROPIC_API_KEY: "sk-ant-real-key-value" }),
pidfileExists: () => false,
pidfilePidIsAlive: () => null,
findIiiBinary: () => "/Users/test/.local/bin/iii",
localBinIiiPath: () => "/Users/test/.local/bin/iii",
iiiBinaryVersion: () => "0.11.2",
viewerReachable: async () => true,
runInit: async () => ({ ok: true, message: "wrote .env" }),
openEditor: async () => ({ ok: true, message: "saved" }),
runIiiInstaller: async () => ({ ok: true, message: "installed" }),
runStop: async () => ({ ok: true, message: "stopped" }),
runStart: async () => ({ ok: true, message: "started" }),
clearEnginePidAndState: () => {},
...overrides,
};
}
describe("doctor v2 diagnostic catalog", () => {
it("exports a stable list of diagnostic ids", () => {
expect(DIAGNOSTIC_IDS).toContain("env-missing");
expect(DIAGNOSTIC_IDS).toContain("no-llm-provider-key");
expect(DIAGNOSTIC_IDS).toContain("engine-version-mismatch");
expect(DIAGNOSTIC_IDS).toContain("viewer-unreachable");
expect(DIAGNOSTIC_IDS).toContain("stale-pidfile");
expect(DIAGNOSTIC_IDS).toContain("env-placeholder-keys");
expect(DIAGNOSTIC_IDS).toContain("iii-on-path-not-local-bin");
});
it("every diagnostic has check, fix, message, and fixPreview", () => {
const diagnostics = buildDiagnostics(stubEffects());
expect(diagnostics.length).toBe(DIAGNOSTIC_IDS.length);
for (const d of diagnostics) {
expect(d.id).toMatch(/^[a-z][a-z0-9-]+$/);
expect(d.message.length).toBeGreaterThan(0);
expect(d.fixPreview.length).toBeGreaterThan(0);
expect(d.moreInfo.length).toBeGreaterThan(0);
expect(typeof d.check).toBe("function");
expect(typeof d.fix).toBe("function");
}
});
it("diagnostic ids are unique", () => {
const diagnostics = buildDiagnostics(stubEffects());
const ids = diagnostics.map((d) => d.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("env-missing fails when env file is absent", async () => {
const diagnostics = buildDiagnostics(stubEffects({ envFileExists: () => false }));
const envCheck = diagnostics.find((d) => d.id === "env-missing")!;
const status = await envCheck.check(stubCtx());
expect(status.ok).toBe(false);
});
it("env-missing passes when env file exists", async () => {
const diagnostics = buildDiagnostics(stubEffects({ envFileExists: () => true }));
const envCheck = diagnostics.find((d) => d.id === "env-missing")!;
const status = await envCheck.check(stubCtx());
expect(status.ok).toBe(true);
});
it("no-llm-provider-key fails when env has only placeholders", async () => {
const diagnostics = buildDiagnostics(
stubEffects({
envFileExists: () => true,
readEnvFile: () => ({ ANTHROPIC_API_KEY: "your-key-here" }),
}),
);
const check = diagnostics.find((d) => d.id === "no-llm-provider-key")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
});
it("no-llm-provider-key passes when one real key is set", async () => {
const diagnostics = buildDiagnostics(stubEffects());
const check = diagnostics.find((d) => d.id === "no-llm-provider-key")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(true);
});
it("engine-version-mismatch fails when iii reports the wrong version", async () => {
const diagnostics = buildDiagnostics(
stubEffects({ iiiBinaryVersion: () => "0.99.99" }),
);
const check = diagnostics.find((d) => d.id === "engine-version-mismatch")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
expect(status.detail).toContain("0.99.99");
expect(status.detail).toContain("0.11.2");
});
it("engine-version-mismatch passes when iii matches pinned version", async () => {
const diagnostics = buildDiagnostics(stubEffects());
const check = diagnostics.find((d) => d.id === "engine-version-mismatch")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(true);
});
it("viewer-unreachable fails when viewer probe returns false", async () => {
const diagnostics = buildDiagnostics(
stubEffects({ viewerReachable: async () => false }),
);
const check = diagnostics.find((d) => d.id === "viewer-unreachable")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
});
it("stale-pidfile passes when no pidfile exists", async () => {
const diagnostics = buildDiagnostics(stubEffects({ pidfileExists: () => false }));
const check = diagnostics.find((d) => d.id === "stale-pidfile")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(true);
});
it("stale-pidfile fails when pidfile points at a dead pid", async () => {
const diagnostics = buildDiagnostics(
stubEffects({ pidfileExists: () => true, pidfilePidIsAlive: () => false }),
);
const check = diagnostics.find((d) => d.id === "stale-pidfile")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
expect(status.detail).toBe("pid is gone");
});
it("env-placeholder-keys detects sk-ant-... placeholder", async () => {
const diagnostics = buildDiagnostics(
stubEffects({
envFileExists: () => true,
readEnvFile: () => ({ ANTHROPIC_API_KEY: "sk-ant-..." }),
}),
);
const check = diagnostics.find((d) => d.id === "env-placeholder-keys")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
expect(status.detail).toContain("ANTHROPIC_API_KEY");
});
it("iii-on-path-not-local-bin warns when iii lives in another location", async () => {
const diagnostics = buildDiagnostics(
stubEffects({
findIiiBinary: () => "/opt/homebrew/bin/iii",
localBinIiiPath: () => "/Users/test/.local/bin/iii",
}),
);
const check = diagnostics.find((d) => d.id === "iii-on-path-not-local-bin")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
expect(check.manualOnly).toBe(true);
});
it("dryRunPlan lists each failing diagnostic with the fix preview", () => {
const diagnostics = buildDiagnostics(stubEffects());
const results = diagnostics.map((d) => ({
diagnostic: d,
status: { ok: false, detail: "stub fail" },
}));
const lines = dryRunPlan(stubCtx(), results);
expect(lines.some((l) => l.includes("env-missing"))).toBe(true);
expect(lines.some((l) => l.includes("would fix:"))).toBe(true);
});
it("dryRunPlan reports all-passing state", () => {
const diagnostics = buildDiagnostics(stubEffects());
const results = diagnostics.map((d) => ({
diagnostic: d,
status: { ok: true },
}));
const lines = dryRunPlan(stubCtx(), results);
expect(lines.length).toBe(1);
expect(lines[0]).toContain("All checks passing");
});
});
describe("parseEnvFile", () => {
it("strips comments and blank lines", () => {
const env = parseEnvFile("# a comment\n\nFOO=bar\nBAZ=qux\n");
expect(env).toEqual({ FOO: "bar", BAZ: "qux" });
});
it("strips surrounding quotes", () => {
const env = parseEnvFile(`A="hello"\nB='world'\nC=plain\n`);
expect(env).toEqual({ A: "hello", B: "world", C: "plain" });
});
});
describe("realProviderKeys / placeholderProviderKeys", () => {
it("returns real keys only", () => {
const env = {
ANTHROPIC_API_KEY: "sk-ant-real-value",
OPENAI_API_KEY: "sk-...",
GEMINI_API_KEY: "",
OPENROUTER_API_KEY: "your-key-here",
};
expect(realProviderKeys(env)).toEqual(["ANTHROPIC_API_KEY"]);
expect(placeholderProviderKeys(env)).toContain("OPENAI_API_KEY");
expect(placeholderProviderKeys(env)).toContain("OPENROUTER_API_KEY");
expect(placeholderProviderKeys(env)).not.toContain("GEMINI_API_KEY");
});
it("treats xxx-style placeholders as fake", () => {
expect(placeholderProviderKeys({ ANTHROPIC_API_KEY: "xxxx-xxxx" })).toEqual([
"ANTHROPIC_API_KEY",
]);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const prompts = vi.hoisted(() => ({
note: vi.fn(),
multiselect: vi.fn(async () => {
throw new Error("interactive multiselect should not run in non-TTY onboarding");
}),
select: vi.fn(async () => {
throw new Error("interactive select should not run in non-TTY onboarding");
}),
confirm: vi.fn(async () => true),
isCancel: vi.fn(() => false),
cancel: vi.fn(),
log: {
warn: vi.fn(),
step: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@clack/prompts", () => prompts);
vi.mock("../src/cli/connect/index.js", () => ({
resolveAdapter: vi.fn(),
runAdapter: vi.fn(),
}));
const ORIGINAL_HOME = process.env["HOME"];
const ORIGINAL_USERPROFILE = process.env["USERPROFILE"];
const stdinTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
let sandboxHome: string;
function setTTY(value: boolean): void {
Object.defineProperty(process.stdin, "isTTY", { value, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value, configurable: true });
}
function restoreTTY(): void {
if (stdinTtyDescriptor) Object.defineProperty(process.stdin, "isTTY", stdinTtyDescriptor);
else delete (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY;
if (stdoutTtyDescriptor) Object.defineProperty(process.stdout, "isTTY", stdoutTtyDescriptor);
else delete (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY;
}
async function freshOnboarding() {
vi.resetModules();
return await import("../src/cli/onboarding.js");
}
describe("cli onboarding", () => {
beforeEach(() => {
sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-onboarding-"));
process.env["HOME"] = sandboxHome;
process.env["USERPROFILE"] = sandboxHome;
setTTY(false);
vi.clearAllMocks();
});
afterEach(() => {
restoreTTY();
if (ORIGINAL_HOME === undefined) delete process.env["HOME"];
else process.env["HOME"] = ORIGINAL_HOME;
if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"];
else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE;
rmSync(sandboxHome, { recursive: true, force: true });
});
it("does not prompt and records default preferences when onboarding runs without a TTY", async () => {
const { runOnboarding } = await freshOnboarding();
const result = await runOnboarding();
expect(result).toEqual({ agents: [], provider: null });
expect(prompts.multiselect).not.toHaveBeenCalled();
expect(prompts.select).not.toHaveBeenCalled();
expect(prompts.confirm).not.toHaveBeenCalled();
const preferencesPath = join(sandboxHome, ".agentmemory", "preferences.json");
expect(existsSync(preferencesPath)).toBe(true);
const preferences = JSON.parse(readFileSync(preferencesPath, "utf-8"));
expect(preferences).toMatchObject({
schemaVersion: 1,
lastAgent: null,
lastAgents: [],
lastProvider: null,
skipSplash: true,
});
expect(typeof preferences.firstRunAt).toBe("string");
});
});
+169
View File
@@ -0,0 +1,169 @@
// Unit tests for the `agentmemory remove` destruction plan.
//
// The plan module is pure-fs (just inspects what's present) so we sandbox
// a fake $HOME under tmpdir() and assert which plan items come back. The
// actual file deletion is wrapped in src/cli.ts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
buildRemovePlan,
formatPlan,
type ConnectManifest,
type RemoveContext,
} from "../src/cli/remove-plan.js";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
let sandbox: string;
function ctx(overrides: Partial<RemoveContext> = {}): RemoveContext {
return {
home: sandbox,
pinnedVersion: "0.11.2",
localBinIiiVersion: null,
connectManifest: null,
...overrides,
};
}
function touch(relPath: string, content = ""): void {
const full = join(sandbox, relPath);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, content);
}
function mkdir(relPath: string): void {
mkdirSync(join(sandbox, relPath), { recursive: true });
}
beforeEach(() => {
sandbox = mkdtempSync(join(tmpdir(), "agentmemory-remove-"));
});
afterEach(() => {
rmSync(sandbox, { recursive: true, force: true });
});
describe("buildRemovePlan", () => {
it("returns no applicable items on a clean system", () => {
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const applicable = plan.filter((p) => p.applicable);
expect(applicable.length).toBe(0);
});
it("includes pidfile + engine-state when both exist", () => {
touch(".agentmemory/iii.pid", "12345\n");
touch(".agentmemory/engine-state.json", "{}");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const ids = plan.filter((p) => p.applicable).map((p) => p.id);
expect(ids).toContain("stop-engine");
expect(ids).toContain("pidfile");
expect(ids).toContain("engine-state");
});
it("marks .env as alwaysAsk", () => {
touch(".agentmemory/.env", "ANTHROPIC_API_KEY=sk-ant-real\n");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const envItem = plan.find((p) => p.id === "env")!;
expect(envItem.applicable).toBe(true);
expect(envItem.alwaysAsk).toBe(true);
});
it("--keep-data hides .env, preferences, backups, and data-dir", () => {
touch(".agentmemory/.env", "x");
touch(".agentmemory/preferences.json", "{}");
mkdir(".agentmemory/backups");
mkdir(".agentmemory/data");
const plan = buildRemovePlan(ctx(), { force: false, keepData: true });
const applicable = plan.filter((p) => p.applicable).map((p) => p.id);
expect(applicable).not.toContain("env");
expect(applicable).not.toContain("preferences");
expect(applicable).not.toContain("backups");
expect(applicable).not.toContain("data-dir");
});
it("data-dir is alwaysAsk even on --force", () => {
mkdir(".agentmemory/data");
const plan = buildRemovePlan(ctx(), { force: true, keepData: false });
const item = plan.find((p) => p.id === "data-dir")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(true);
});
it("expands connect-manifest entries into individual plan items", () => {
const manifest: ConnectManifest = {
installed: [
{ target: join(sandbox, "fake-claude-symlink"), agent: "claude-code", symlink: true },
{ target: join(sandbox, "fake-cursor-link"), agent: "cursor" },
],
};
touch("fake-claude-symlink");
touch("fake-cursor-link");
const plan = buildRemovePlan(ctx({ connectManifest: manifest }), {
force: false,
keepData: false,
});
const connectItems = plan.filter((p) => p.id.startsWith("connect:"));
expect(connectItems.length).toBe(2);
expect(connectItems.every((p) => p.applicable)).toBe(true);
});
it("local-bin/iii is alwaysAsk when version does not match", () => {
touch(".local/bin/iii", "fakebin");
const plan = buildRemovePlan(
ctx({ localBinIiiVersion: "9.9.9" }),
{ force: false, keepData: false },
);
const item = plan.find((p) => p.id === "legacy-local-bin-iii")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(true);
});
it("local-bin/iii is auto-fixable when version matches pinned", () => {
touch(".local/bin/iii", "fakebin");
const plan = buildRemovePlan(
ctx({ localBinIiiVersion: "0.11.2" }),
{ force: false, keepData: false },
);
const item = plan.find((p) => p.id === "legacy-local-bin-iii")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(false);
expect(item.description).toContain("matches pinned");
});
it("local-bin/iii absent: no plan entry created", () => {
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
expect(plan.find((p) => p.id === "legacy-local-bin-iii")).toBeUndefined();
expect(plan.find((p) => p.id === "private-bin-iii")).toBeUndefined();
});
it("private ~/.agentmemory/bin/iii is removed without prompt", () => {
touch(".agentmemory/bin/iii", "fakebin");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const item = plan.find((p) => p.id === "private-bin-iii")!;
expect(item).toBeDefined();
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(false);
expect(item.description).toContain("private install");
});
});
describe("formatPlan", () => {
it("renders applicable items with numbers", () => {
touch(".agentmemory/iii.pid", "1");
touch(".agentmemory/engine-state.json", "{}");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const out = formatPlan(plan);
expect(out).toMatch(/^\s+1\./m);
expect(out).toContain("pidfile");
expect(out).toContain("engine-state.json");
});
it("marks alwaysAsk items with [asks]", () => {
touch(".agentmemory/.env", "x");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const out = formatPlan(plan);
expect(out).toContain("[asks]");
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect } from "vitest";
import { writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import {
buildMergedHooks,
findPluginRoot,
type HookManifest,
} from "../src/cli/connect/codex-hooks.js";
const PLUGIN_ROOT = resolve(__dirname, "..", "plugin");
describe("findPluginRoot", () => {
it("locates the bundled plugin/ directory from src/cli/connect/", () => {
const root = findPluginRoot();
expect(root).toBe(PLUGIN_ROOT);
});
});
describe("buildMergedHooks", () => {
it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => {
const merged = buildMergedHooks(null, PLUGIN_ROOT);
for (const entries of Object.values(merged.hooks)) {
for (const entry of entries) {
for (const handler of entry.hooks) {
expect(handler.command).not.toContain("${CLAUDE_PLUGIN_ROOT}");
expect(handler.command).toContain(`${PLUGIN_ROOT}/scripts/`);
}
}
}
});
it("preserves matchers from the bundled manifest (e.g. PreToolUse)", () => {
const merged = buildMergedHooks(null, PLUGIN_ROOT);
const preToolUse = merged.hooks["PreToolUse"];
expect(preToolUse).toBeDefined();
expect(preToolUse!.length).toBeGreaterThan(0);
expect(preToolUse![0].matcher).toBe("Edit|Write|Read|Glob|Grep");
});
it("includes all six expected lifecycle events", () => {
const merged = buildMergedHooks(null, PLUGIN_ROOT);
for (const event of [
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PreCompact",
"Stop",
]) {
expect(Object.keys(merged.hooks)).toContain(event);
}
});
it("appends to existing user hooks without dropping them", () => {
const existing: HookManifest = {
hooks: {
SessionStart: [
{
hooks: [{ type: "command", command: "echo user-custom" }],
},
],
UserPromptSubmit: [
{
hooks: [{ type: "command", command: "echo another-user-hook" }],
},
],
},
};
const merged = buildMergedHooks(existing, PLUGIN_ROOT);
const sessionStart = merged.hooks["SessionStart"]!;
const userHook = sessionStart.find((e) =>
e.hooks.some((h) => h.command === "echo user-custom"),
);
expect(userHook, "user's SessionStart hook should survive").toBeDefined();
const ours = sessionStart.find((e) =>
e.hooks.some((h) => h.command.includes(`${PLUGIN_ROOT}/scripts/session-start.mjs`)),
);
expect(ours, "agentmemory SessionStart hook should be appended").toBeDefined();
});
it("re-install strips previous agentmemory entries (idempotent by script path)", () => {
const first = buildMergedHooks(null, PLUGIN_ROOT);
const second = buildMergedHooks(first, PLUGIN_ROOT);
for (const event of Object.keys(first.hooks)) {
expect(
second.hooks[event]!.length,
`${event} should not double after second install`,
).toBe(first.hooks[event]!.length);
}
});
it("re-install preserves unrelated user entries", () => {
const userEntry = {
hooks: [{ type: "command", command: "echo user-untouchable" }],
};
const withUser: HookManifest = {
hooks: {
SessionStart: [userEntry],
Stop: [{ hooks: [{ type: "command", command: "echo also-user" }] }],
},
};
const installed = buildMergedHooks(withUser, PLUGIN_ROOT);
const reinstalled = buildMergedHooks(installed, PLUGIN_ROOT);
expect(
reinstalled.hooks["SessionStart"]!.some((e) =>
e.hooks.some((h) => h.command === "echo user-untouchable"),
),
).toBe(true);
expect(
reinstalled.hooks["Stop"]!.some((e) =>
e.hooks.some((h) => h.command === "echo also-user"),
),
).toBe(true);
});
it("handles empty existing manifest object", () => {
const merged = buildMergedHooks({ hooks: {} }, PLUGIN_ROOT);
expect(Object.keys(merged.hooks).length).toBeGreaterThan(0);
});
});
describe("buildMergedHooks file round-trip", () => {
it("produces JSON that parses back to a structurally equivalent manifest", () => {
const dir = join(tmpdir(), `agentmemory-codex-hooks-${process.pid}-${Date.now()}`);
mkdirSync(dir, { recursive: true });
const path = join(dir, "hooks.json");
try {
const merged = buildMergedHooks(null, PLUGIN_ROOT);
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
const reread = JSON.parse(readFileSync(path, "utf-8")) as HookManifest;
expect(Object.keys(reread.hooks).sort()).toEqual(Object.keys(merged.hooks).sort());
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+159
View File
@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import { readFileSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
const repoRoot = resolve(__dirname, "..");
const pluginRoot = join(repoRoot, "plugin");
function readJson<T = unknown>(path: string): T {
return JSON.parse(readFileSync(path, "utf-8")) as T;
}
type HookHandler = { type: string; command: string };
type HookEntry = { hooks: HookHandler[] };
function hookCommands(path: string): string[] {
const manifest = readJson<{ hooks: Record<string, HookEntry[]> }>(path);
return Object.values(manifest.hooks).flatMap((entries) =>
entries.flatMap((entry) => entry.hooks.map((handler) => handler.command)),
);
}
describe("Plugin hook manifests", () => {
it("quote plugin script paths so roots with spaces stay intact", () => {
for (const manifest of ["hooks.json", "hooks.codex.json"]) {
const commands = hookCommands(join(pluginRoot, "hooks", manifest));
expect(commands.length, `${manifest} should contain hook commands`).toBeGreaterThan(0);
for (const command of commands) {
expect(command).toMatch(/^node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/[^\s"]+\.mjs"$/);
}
}
});
});
describe("Codex plugin manifest (developers.openai.com/codex/plugins)", () => {
it("ships .codex-plugin/plugin.json with kebab-case name + version + references", () => {
const manifestPath = join(pluginRoot, ".codex-plugin/plugin.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = readJson<{
name: string;
version: string;
description?: string;
skills?: string;
mcpServers?: string;
hooks?: string;
}>(manifestPath);
expect(manifest.name).toBe("agentmemory");
expect(manifest.name).toMatch(/^[a-z][a-z0-9-]*$/);
expect(manifest.version).toMatch(/^\d+\.\d+\.\d+/);
expect(manifest.skills).toBeDefined();
expect(manifest.mcpServers).toBeDefined();
expect(manifest.hooks).toBeDefined();
});
it("manifest version matches main package.json", () => {
const pkgVer = readJson<{ version: string }>(join(repoRoot, "package.json")).version;
const codexVer = readJson<{ version: string }>(
join(pluginRoot, ".codex-plugin/plugin.json"),
).version;
expect(codexVer).toBe(pkgVer);
});
it("all referenced manifest paths resolve to existing files / directories", () => {
const manifest = readJson<{ skills: string; mcpServers: string; hooks: string }>(
join(pluginRoot, ".codex-plugin/plugin.json"),
);
expect(existsSync(join(pluginRoot, manifest.skills))).toBe(true);
expect(existsSync(join(pluginRoot, manifest.mcpServers))).toBe(true);
expect(existsSync(join(pluginRoot, manifest.hooks))).toBe(true);
});
it("plugin MCP server inherits remote agentmemory environment overrides", () => {
const mcp = readJson<{
mcpServers: Record<
string,
{
command: string;
args: string[];
env?: Record<string, string>;
}
>;
}>(join(pluginRoot, ".mcp.json"));
// env interpolation must include defaults so Claude Code (and
// any other MCP host that fails parse on unset ${VAR}) doesn't drop
// the server silently when the user hasn't exported the var.
expect(mcp.mcpServers.agentmemory?.env?.AGENTMEMORY_URL).toMatch(
/\$\{AGENTMEMORY_URL:-/,
);
expect(mcp.mcpServers.agentmemory?.env?.AGENTMEMORY_SECRET).toMatch(
/\$\{AGENTMEMORY_SECRET:-/,
);
});
it("hooks.codex.json contains only events Codex supports (no Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure)", () => {
const hooksPath = join(pluginRoot, "hooks/hooks.codex.json");
const hooks = readJson<{ hooks: Record<string, unknown> }>(hooksPath);
const events = Object.keys(hooks.hooks);
const codexSupported = new Set([
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PermissionRequest",
"PreCompact",
"PostCompact",
"Stop",
]);
for (const event of events) {
expect(codexSupported.has(event), `unexpected event "${event}" in hooks.codex.json`).toBe(true);
}
expect(events).toContain("SessionStart");
expect(events).toContain("UserPromptSubmit");
expect(events).toContain("PreToolUse");
expect(events).toContain("PostToolUse");
expect(events).toContain("PreCompact");
expect(events).toContain("Stop");
});
it("hook command scripts referenced in hooks.codex.json exist on disk", () => {
const hooks = readJson<{ hooks: Record<string, HookEntry[]> }>(
join(pluginRoot, "hooks/hooks.codex.json"),
);
const scriptRefs = new Set<string>();
for (const entries of Object.values(hooks.hooks)) {
for (const entry of entries) {
for (const handler of entry.hooks) {
const match = handler.command.match(/\$\{CLAUDE_PLUGIN_ROOT\}\/(scripts\/[^\s"]+)/);
if (match) scriptRefs.add(match[1]);
}
}
}
expect(scriptRefs.size).toBeGreaterThan(0);
for (const rel of scriptRefs) {
expect(existsSync(join(pluginRoot, rel)), `missing hook script: ${rel}`).toBe(true);
}
});
});
describe("Codex marketplace.json (.codex-plugin/marketplace.json at repo root)", () => {
it("ships a marketplace manifest pointing at the plugin/ subdirectory", () => {
const marketplacePath = join(repoRoot, ".codex-plugin/marketplace.json");
expect(existsSync(marketplacePath)).toBe(true);
const marketplace = readJson<{
name: string;
plugins: Array<{
name: string;
source: { source: string; url: string; path: string; ref?: string };
}>;
}>(marketplacePath);
expect(marketplace.name).toBe("agentmemory");
expect(marketplace.plugins).toHaveLength(1);
const entry = marketplace.plugins[0];
expect(entry.name).toBe("agentmemory");
expect(entry.source.source).toBe("git-subdir");
expect(entry.source.path).toBe("./plugin");
expect(entry.source.url).toMatch(/rohitg00\/agentmemory/);
});
});
+212
View File
@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const fileStore = new Map<string, string>();
const symlinkPaths = new Set<string>();
const openEloopPaths = new Set<string>();
vi.mock("node:fs/promises", () => ({
lstat: vi.fn(async (path: string) => {
if (symlinkPaths.has(path)) {
return { isSymbolicLink: () => true };
}
if (!fileStore.has(path)) {
throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${path}'`), {
code: "ENOENT",
});
}
return { isSymbolicLink: () => false };
}),
open: vi.fn(async (path: string) => {
if (openEloopPaths.has(path)) {
throw Object.assign(new Error("ELOOP: too many levels of symbolic links"), {
code: "ELOOP",
});
}
return {
writeFile: vi.fn(async (content: string) => {
fileStore.set(path, content);
}),
close: vi.fn(async () => {}),
};
}),
readFile: vi.fn(async (path: string) => {
const value = fileStore.get(path);
if (value === undefined) throw new Error("ENOENT");
return value;
}),
writeFile: vi.fn(async (path: string, content: string) => {
fileStore.set(path, content);
}),
}));
import { registerCompressFileFunction } from "../src/functions/compress-file.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
describe("mem::compress-file", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
let summarize: ReturnType<typeof vi.fn>;
beforeEach(() => {
fileStore.clear();
symlinkPaths.clear();
openEloopPaths.clear();
sdk = mockSdk();
kv = mockKV();
summarize = vi.fn();
registerCompressFileFunction(
sdk as never,
kv as never,
{ name: "test-provider", summarize, compress: summarize } as never,
);
});
it("rejects symlinks", async () => {
symlinkPaths.add("/tmp/notes.md");
const result = (await sdk.trigger("mem::compress-file", {
filePath: "/tmp/notes.md",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("symlink");
expect(summarize).not.toHaveBeenCalled();
expect(fileStore.size).toBe(0);
});
it("rejects TOCTOU symlink swap at write time via O_NOFOLLOW", async () => {
const path = "/tmp/notes.md";
fileStore.set(
path,
"# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nContent.",
);
summarize.mockResolvedValue(
"# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nShort.",
);
openEloopPaths.add(path);
const result = (await sdk.trigger("mem::compress-file", {
filePath: path,
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("symlink");
});
it("rejects non-markdown paths", async () => {
const result = (await sdk.trigger("mem::compress-file", {
filePath: "/tmp/readme.txt",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain(".md");
});
it("returns file not found for missing paths", async () => {
const result = (await sdk.trigger("mem::compress-file", {
filePath: "/tmp/nonexistent.md",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("not found");
});
it("compresses markdown and writes .original.md backup", async () => {
const path = "/tmp/notes.md";
fileStore.set(
path,
"# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nSome long explanation.",
);
summarize.mockResolvedValue(
"# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nShort explanation.",
);
const result = (await sdk.trigger("mem::compress-file", {
filePath: path,
})) as {
success: boolean;
backupPath: string;
compressedChars: number;
originalChars: number;
};
expect(result.success).toBe(true);
expect(result.backupPath).toBe("/tmp/notes.original.md");
expect(fileStore.get("/tmp/notes.original.md")).toContain("Some long explanation.");
expect(fileStore.get(path)).toContain("Short explanation.");
expect(result.compressedChars).toBeLessThan(result.originalChars);
});
it("fails validation when URLs change", async () => {
const path = "/tmp/guide.md";
fileStore.set(path, "# Guide\n\nhttps://example.com\n");
summarize.mockResolvedValue("# Guide\n\nhttps://different.example.com\n");
const result = (await sdk.trigger("mem::compress-file", {
filePath: path,
})) as { success: boolean; error: string; details: string[] };
expect(result.success).toBe(false);
expect(result.error).toContain("validation");
expect(result.details.some((d) => d.includes("url"))).toBe(true);
expect(fileStore.get("/tmp/guide.original.md")).toBeUndefined();
});
it("uses a distinct backup path for *.original.md inputs", async () => {
const path = "/tmp/notes.original.md";
fileStore.set(path, "# Title\n\nLong original body.");
summarize.mockResolvedValue("# Title\n\nShort body.");
const result = (await sdk.trigger("mem::compress-file", {
filePath: path,
})) as { success: boolean; backupPath: string };
expect(result.success).toBe(true);
expect(result.backupPath).toBe("/tmp/notes.original.backup.md");
expect(fileStore.get("/tmp/notes.original.backup.md")).toBe(
"# Title\n\nLong original body.",
);
expect(fileStore.get(path)).toBe("# Title\n\nShort body.");
});
});
+248
View File
@@ -0,0 +1,248 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerRelationsFunction } from "../src/functions/relations.js";
import type { Memory, MemoryRelation } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
getFunction: (id: string) => functions.get(id),
};
}
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: "mem_1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "pattern",
title: "Test memory",
content: "This is a test memory",
concepts: ["test"],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
...overrides,
};
}
describe("Confidence Scoring", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerRelationsFunction(sdk as never, kv as never);
});
it("computes default confidence from co-occurrence and recency", async () => {
const mem1 = makeMemory({
id: "mem_1",
sessionIds: ["ses_1", "ses_2"],
updatedAt: new Date().toISOString(),
});
const mem2 = makeMemory({
id: "mem_2",
sessionIds: ["ses_1", "ses_2", "ses_3"],
updatedAt: new Date().toISOString(),
});
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
const result = (await sdk.trigger("mem::relate", {
sourceId: "mem_1",
targetId: "mem_2",
type: "related",
})) as { success: boolean; relation: MemoryRelation };
expect(result.success).toBe(true);
expect(result.relation.confidence).toBeGreaterThan(0.5);
expect(result.relation.confidence).toBeLessThanOrEqual(1);
});
it("uses explicit confidence when provided", async () => {
const mem1 = makeMemory({ id: "mem_1" });
const mem2 = makeMemory({ id: "mem_2" });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
const result = (await sdk.trigger("mem::relate", {
sourceId: "mem_1",
targetId: "mem_2",
type: "related",
confidence: 0.95,
})) as { success: boolean; relation: MemoryRelation };
expect(result.relation.confidence).toBe(0.95);
});
it("clamps confidence to [0, 1]", async () => {
const mem1 = makeMemory({ id: "mem_1" });
const mem2 = makeMemory({ id: "mem_2" });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
const over = (await sdk.trigger("mem::relate", {
sourceId: "mem_1",
targetId: "mem_2",
type: "related",
confidence: 1.5,
})) as { success: boolean; relation: MemoryRelation };
expect(over.relation.confidence).toBe(1);
const mem3 = makeMemory({ id: "mem_3" });
await kv.set("mem:memories", "mem_3", mem3);
const under = (await sdk.trigger("mem::relate", {
sourceId: "mem_1",
targetId: "mem_3",
type: "related",
confidence: -0.5,
})) as { success: boolean; relation: MemoryRelation };
expect(under.relation.confidence).toBe(0);
});
it("mem::get-related sorts by confidence desc", async () => {
const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2", "mem_3"] });
const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1"] });
const mem3 = makeMemory({ id: "mem_3", relatedIds: ["mem_1"] });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
await kv.set("mem:memories", "mem_3", mem3);
await kv.set("mem:relations", "rel_low", {
type: "related",
sourceId: "mem_1",
targetId: "mem_2",
createdAt: new Date().toISOString(),
confidence: 0.3,
} as MemoryRelation);
await kv.set("mem:relations", "rel_high", {
type: "related",
sourceId: "mem_1",
targetId: "mem_3",
createdAt: new Date().toISOString(),
confidence: 0.9,
} as MemoryRelation);
const result = (await sdk.trigger("mem::get-related", {
memoryId: "mem_1",
maxHops: 1,
})) as { results: Array<{ memory: Memory; hop: number; confidence: number }> };
expect(result.results.length).toBe(2);
expect(result.results[0].confidence).toBeGreaterThanOrEqual(
result.results[1].confidence,
);
});
it("minConfidence filter works", async () => {
const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2", "mem_3"] });
const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1"] });
const mem3 = makeMemory({ id: "mem_3", relatedIds: ["mem_1"] });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
await kv.set("mem:memories", "mem_3", mem3);
await kv.set("mem:relations", "rel_low", {
type: "related",
sourceId: "mem_1",
targetId: "mem_2",
createdAt: new Date().toISOString(),
confidence: 0.2,
} as MemoryRelation);
await kv.set("mem:relations", "rel_high", {
type: "related",
sourceId: "mem_1",
targetId: "mem_3",
createdAt: new Date().toISOString(),
confidence: 0.8,
} as MemoryRelation);
const result = (await sdk.trigger("mem::get-related", {
memoryId: "mem_1",
maxHops: 1,
minConfidence: 0.5,
})) as { results: Array<{ memory: Memory; hop: number; confidence: number }> };
expect(result.results.length).toBe(1);
expect(result.results[0].memory.id).toBe("mem_3");
});
it("mem::evolve creates supersedes relation with confidence=1.0", async () => {
const original = makeMemory({ id: "mem_old", version: 1 });
await kv.set("mem:memories", "mem_old", original);
await sdk.trigger("mem::evolve", {
memoryId: "mem_old",
newContent: "Updated content",
});
const relations = await kv.list<MemoryRelation>("mem:relations");
const supersedesRel = relations.find((r) => r.type === "supersedes");
expect(supersedesRel).toBeDefined();
expect(supersedesRel!.confidence).toBe(1.0);
});
it("old relations without confidence default to 0.5", async () => {
const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2"] });
const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1"] });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
await kv.set("mem:relations", "rel_old", {
type: "related",
sourceId: "mem_1",
targetId: "mem_2",
createdAt: new Date().toISOString(),
} as MemoryRelation);
const result = (await sdk.trigger("mem::get-related", {
memoryId: "mem_1",
maxHops: 1,
})) as { results: Array<{ memory: Memory; hop: number; confidence: number }> };
expect(result.results.length).toBe(1);
expect(result.results[0].confidence).toBe(0.5);
});
});
+347
View File
@@ -0,0 +1,347 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
import { tmpdir, platform } from "node:os";
import { join } from "node:path";
// Connect adapters for Qwen Code, Antigravity, and Kiro. Each writes
// the canonical MCP block (npx @agentmemory/mcp + env defaults) into
// the agent's documented config path.
function freshHome(): string {
return mkdtempSync(join(tmpdir(), "am-connect-"));
}
describe("connect: Qwen Code", () => {
let home: string;
const ORIG = process.env["HOME"];
beforeEach(() => {
home = freshHome();
vi.resetModules();
process.env["HOME"] = home;
});
afterEach(() => {
process.env["HOME"] = ORIG;
rmSync(home, { recursive: true, force: true });
});
it("does not detect when ~/.qwen/ is absent", async () => {
const { adapter } = await import("../src/cli/connect/qwen.js");
expect(adapter.detect()).toBe(false);
});
it("writes mcpServers.agentmemory to ~/.qwen/settings.json", async () => {
mkdirSync(join(home, ".qwen"), { recursive: true });
const { adapter } = await import("../src/cli/connect/qwen.js");
expect(adapter.detect()).toBe(true);
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const cfg = JSON.parse(
readFileSync(join(home, ".qwen", "settings.json"), "utf-8"),
);
expect(cfg.mcpServers.agentmemory.command).toBe("npx");
expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp");
expect(cfg.mcpServers.agentmemory.env.AGENTMEMORY_URL).toMatch(
/\$\{AGENTMEMORY_URL:-/,
);
expect(cfg.mcpServers.agentmemory.env.AGENTMEMORY_TOOLS).toMatch(
/\$\{AGENTMEMORY_TOOLS:-all\}/,
);
});
});
describe("connect: Antigravity", () => {
let home: string;
const ORIG = process.env["HOME"];
beforeEach(() => {
home = freshHome();
vi.resetModules();
process.env["HOME"] = home;
});
afterEach(() => {
process.env["HOME"] = ORIG;
rmSync(home, { recursive: true, force: true });
});
it("writes mcpServers.agentmemory to the platform-specific config path", async () => {
const isMac = platform() === "darwin";
const userDir = isMac
? join(home, "Library", "Application Support", "Antigravity", "User")
: join(home, ".config", "Antigravity", "User");
mkdirSync(userDir, { recursive: true });
const { adapter } = await import("../src/cli/connect/antigravity.js");
expect(adapter.detect()).toBe(true);
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const cfg = JSON.parse(
readFileSync(join(userDir, "mcp_config.json"), "utf-8"),
);
expect(cfg.mcpServers.agentmemory.command).toBe("npx");
expect(cfg.mcpServers.agentmemory.env.AGENTMEMORY_URL).toMatch(
/\$\{AGENTMEMORY_URL:-/,
);
});
});
describe("connect: Kiro", () => {
let home: string;
const ORIG = process.env["HOME"];
beforeEach(() => {
home = freshHome();
vi.resetModules();
process.env["HOME"] = home;
});
afterEach(() => {
process.env["HOME"] = ORIG;
rmSync(home, { recursive: true, force: true });
});
it("does not detect when ~/.kiro/ is absent", async () => {
const { adapter } = await import("../src/cli/connect/kiro.js");
expect(adapter.detect()).toBe(false);
});
it("writes mcpServers.agentmemory to ~/.kiro/settings/mcp.json", async () => {
mkdirSync(join(home, ".kiro"), { recursive: true });
const { adapter } = await import("../src/cli/connect/kiro.js");
expect(adapter.detect()).toBe(true);
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const cfgPath = join(home, ".kiro", "settings", "mcp.json");
expect(existsSync(cfgPath)).toBe(true);
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
expect(cfg.mcpServers.agentmemory.command).toBe("npx");
expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp");
});
});
describe("connect: Warp", () => {
let home: string;
const ORIG = process.env["HOME"];
beforeEach(() => {
home = freshHome();
vi.resetModules();
process.env["HOME"] = home;
});
afterEach(() => {
process.env["HOME"] = ORIG;
rmSync(home, { recursive: true, force: true });
});
it("does not detect when ~/.warp/ is absent", async () => {
const { adapter } = await import("../src/cli/connect/warp.js");
expect(adapter.detect()).toBe(false);
});
it("writes mcpServers.agentmemory to ~/.warp/.mcp.json", async () => {
mkdirSync(join(home, ".warp"), { recursive: true });
const { adapter } = await import("../src/cli/connect/warp.js");
expect(adapter.detect()).toBe(true);
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const cfgPath = join(home, ".warp", ".mcp.json");
expect(existsSync(cfgPath)).toBe(true);
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
expect(cfg.mcpServers.agentmemory.command).toBe("npx");
expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp");
expect(cfg.mcpServers.agentmemory.env.AGENTMEMORY_URL).toMatch(
/\$\{AGENTMEMORY_URL:-/,
);
});
});
describe("connect: Cline", () => {
let home: string;
const ORIG = process.env["HOME"];
beforeEach(() => {
home = freshHome();
vi.resetModules();
process.env["HOME"] = home;
});
afterEach(() => {
process.env["HOME"] = ORIG;
rmSync(home, { recursive: true, force: true });
});
it("does not detect when ~/.cline/ is absent", async () => {
const { adapter } = await import("../src/cli/connect/cline.js");
expect(adapter.detect()).toBe(false);
});
it("writes mcpServers.agentmemory to ~/.cline/mcp.json", async () => {
mkdirSync(join(home, ".cline"), { recursive: true });
const { adapter } = await import("../src/cli/connect/cline.js");
expect(adapter.detect()).toBe(true);
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const cfg = JSON.parse(
readFileSync(join(home, ".cline", "mcp.json"), "utf-8"),
);
expect(cfg.mcpServers.agentmemory.command).toBe("npx");
expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp");
});
});
describe("connect: Droid (Factory.ai)", () => {
let home: string;
const ORIG = process.env["HOME"];
beforeEach(() => {
home = freshHome();
vi.resetModules();
process.env["HOME"] = home;
});
afterEach(() => {
process.env["HOME"] = ORIG;
rmSync(home, { recursive: true, force: true });
});
it("does not detect when ~/.factory/ is absent", async () => {
const { adapter } = await import("../src/cli/connect/droid.js");
expect(adapter.detect()).toBe(false);
});
it("writes mcpServers.agentmemory to ~/.factory/mcp.json with type:stdio", async () => {
mkdirSync(join(home, ".factory"), { recursive: true });
const { adapter } = await import("../src/cli/connect/droid.js");
expect(adapter.detect()).toBe(true);
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const cfg = JSON.parse(
readFileSync(join(home, ".factory", "mcp.json"), "utf-8"),
);
expect(cfg.mcpServers.agentmemory.command).toBe("npx");
expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp");
// Droid requires `type` per its documented schema
expect(cfg.mcpServers.agentmemory.type).toBe("stdio");
});
});
describe("connect: Zed", () => {
let home: string;
const ORIG = process.env["HOME"];
beforeEach(() => {
home = freshHome();
vi.resetModules();
process.env["HOME"] = home;
});
afterEach(() => {
process.env["HOME"] = ORIG;
rmSync(home, { recursive: true, force: true });
});
it("does not detect when ~/.config/zed/ is absent", async () => {
const { adapter } = await import("../src/cli/connect/zed.js");
expect(adapter.detect()).toBe(false);
});
it("writes context_servers.agentmemory to ~/.config/zed/settings.json", async () => {
mkdirSync(join(home, ".config", "zed"), { recursive: true });
const { adapter } = await import("../src/cli/connect/zed.js");
expect(adapter.detect()).toBe(true);
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const cfg = JSON.parse(
readFileSync(join(home, ".config", "zed", "settings.json"), "utf-8"),
);
expect(cfg.context_servers.agentmemory.command).toBe("npx");
expect(cfg.context_servers.agentmemory.args).toContain("@agentmemory/mcp");
expect(cfg.mcpServers).toBeUndefined();
});
});
describe("connect: Continue.dev", () => {
let home: string;
const ORIG = process.env["HOME"];
beforeEach(() => {
home = freshHome();
vi.resetModules();
process.env["HOME"] = home;
});
afterEach(() => {
process.env["HOME"] = ORIG;
rmSync(home, { recursive: true, force: true });
});
it("does not detect when ~/.continue/ is absent", async () => {
const { adapter } = await import("../src/cli/connect/continue.js");
expect(adapter.detect()).toBe(false);
});
it("creates config.yaml from scratch when neither yaml nor json exists", async () => {
mkdirSync(join(home, ".continue"), { recursive: true });
const { adapter } = await import("../src/cli/connect/continue.js");
expect(adapter.detect()).toBe(true);
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const yamlPath = join(home, ".continue", "config.yaml");
expect(existsSync(yamlPath)).toBe(true);
expect(existsSync(join(home, ".continue", "config.json"))).toBe(false);
const yaml = readFileSync(yamlPath, "utf-8");
expect(yaml).toContain("mcpServers:");
expect(yaml).toContain("name: agentmemory");
expect(yaml).toContain("@agentmemory/mcp");
expect(yaml).toContain("AGENTMEMORY_URL");
});
it("modifies existing legacy config.json", async () => {
mkdirSync(join(home, ".continue"), { recursive: true });
const { writeFileSync } = await import("node:fs");
writeFileSync(
join(home, ".continue", "config.json"),
JSON.stringify({ models: [], mcpServers: [] }),
);
const { adapter } = await import("../src/cli/connect/continue.js");
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("installed");
const cfg = JSON.parse(
readFileSync(join(home, ".continue", "config.json"), "utf-8"),
);
expect(Array.isArray(cfg.mcpServers)).toBe(true);
const entry = cfg.mcpServers.find(
(s: { name: string }) => s.name === "agentmemory",
);
expect(entry.command).toBe("npx");
expect(entry.args).toContain("@agentmemory/mcp");
});
it("returns stub when config.yaml already exists (refuses silent yaml mutation)", async () => {
mkdirSync(join(home, ".continue"), { recursive: true });
const { writeFileSync } = await import("node:fs");
writeFileSync(
join(home, ".continue", "config.yaml"),
"models: []\nmcpServers:\n - name: existing\n command: noop\n",
);
const { adapter } = await import("../src/cli/connect/continue.js");
const result = await adapter.install({ dryRun: false, force: false });
expect(result.kind).toBe("stub");
// user's yaml must be untouched
const yaml = readFileSync(
join(home, ".continue", "config.yaml"),
"utf-8",
);
expect(yaml).toContain("existing");
expect(yaml).not.toContain("agentmemory");
});
});
describe("connect: all eight new agents registered in ADAPTERS", () => {
beforeEach(() => {
vi.resetModules();
});
it("knownAgents includes qwen, antigravity, kiro, warp, cline, continue, zed, droid", async () => {
const { knownAgents } = await import("../src/cli/connect/index.js");
const agents = knownAgents();
for (const name of [
"qwen",
"antigravity",
"kiro",
"warp",
"cline",
"continue",
"zed",
"droid",
]) {
expect(agents).toContain(name);
}
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { getAllTools } from "../src/mcp/tools-registry.js";
import { VERSION } from "../src/version.js";
const ROOT = join(import.meta.dirname, "..");
function readText(relativePath: string): string {
return readFileSync(join(ROOT, relativePath), "utf-8");
}
function countRestApiEndpoints(): number {
const src = readText("src/triggers/api.ts");
return Array.from(src.matchAll(/api_path:\s*["`]/g)).length;
}
describe("Consistency checks", () => {
const toolCount = getAllTools().length;
const restEndpointCount = countRestApiEndpoints();
it("version.ts matches package.json", () => {
const pkg = JSON.parse(readText("package.json"));
expect(VERSION).toBe(pkg.version);
});
it("plugin.json version matches package.json", () => {
const pkg = JSON.parse(readText("package.json"));
const plugin = JSON.parse(readText("plugin/.claude-plugin/plugin.json"));
expect(plugin.version).toBe(pkg.version);
});
it("export-import.ts supports current version", () => {
const src = readText("src/functions/export-import.ts");
expect(src).toContain(`"${VERSION}"`);
});
it("README mentions correct MCP tool count", () => {
const readme = readText("README.md");
const toolCountPattern = new RegExp(`${toolCount}\\s+MCP tools`);
expect(readme).toMatch(toolCountPattern);
const toolResourcePattern = new RegExp(`${toolCount}\\s+tools,\\s+6\\s+resources`);
expect(readme).toMatch(toolResourcePattern);
});
it("documented REST endpoint counts match registered API paths", () => {
const readme = readText("README.md");
const agents = readText("AGENTS.md");
const index = readText("src/index.ts");
expect(restEndpointCount).toBeGreaterThan(0);
expect(readme).toContain(`${restEndpointCount} endpoints on port`);
expect(agents).toContain(`${restEndpointCount} REST endpoints`);
expect(index).toContain(`REST API: ${restEndpointCount} endpoints`);
});
it("all tool names are unique", () => {
const tools = getAllTools();
const names = new Set(tools.map((t) => t.name));
expect(names.size).toBe(tools.length);
});
it("all tools have name, description, and inputSchema", () => {
for (const tool of getAllTools()) {
expect(tool.name).toBeTruthy();
expect(tool.description).toBeTruthy();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe("object");
}
});
it("every host-path bind mount in docker-compose.yml is in the published files list (#136)", () => {
// Regression guard for #136: docker-compose.yml references
// ./iii-config.docker.yaml as a read-only bind mount, but the file
// was missing from the published tarball. Docker silently creates
// missing bind sources as empty directories, so the engine crashed
// with "Is a directory (os error 21)" at /app/config.yaml.
const compose = readText("docker-compose.yml");
const pkg = JSON.parse(readText("package.json"));
const files: string[] = pkg.files ?? [];
// Match `./<path>:<container-path>` style bind mounts. We only care
// about files that live in the repo root (so they'd be shipped via
// the `files` field). `iii-data:/data` (a named volume) has no `./`
// prefix and is correctly skipped.
const bindRe = /^\s*-\s+\.\/([^\s:]+):[^\s]+/gm;
const sources: string[] = [];
for (const m of compose.matchAll(bindRe)) sources.push(m[1]!);
expect(sources.length).toBeGreaterThan(0);
for (const src of sources) {
// Any nested path would need a directory entry in `files` (e.g.
// `dist/`); for top-level files, the exact name must be listed.
const topLevel = src.split("/")[0]!;
const covered =
files.includes(src) ||
files.includes(topLevel) ||
files.includes(`${topLevel}/`);
expect(
covered,
`docker-compose.yml mounts ./${src} but package.json "files" does not ship it — ${topLevel} would be auto-created as an empty dir on install, breaking \`npx @agentmemory/agentmemory\``,
).toBe(true);
}
});
});
+270
View File
@@ -0,0 +1,270 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../src/functions/audit.js", () => ({
recordAudit: vi.fn(),
}));
import { registerConsolidateFunction } from "../src/functions/consolidate.js";
import { KV } from "../src/state/schema.js";
import type { CompressedObservation, Memory, MemoryProvider, Session } from "../src/types.js";
function makeMockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function makeMockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (id: string, handler: Function) => {
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function registered: ${id}`);
return fn(payload);
},
};
}
function makeProvider(title = "synthesized memory title"): MemoryProvider {
return {
name: "mock",
compress: vi.fn().mockResolvedValue(
`<memory>
<type>pattern</type>
<title>${title}</title>
<content>synthesized content about the concept</content>
<concepts><concept>auth</concept></concepts>
<files><file>src/auth.ts</file></files>
<strength>7</strength>
</memory>`,
),
embed: vi.fn().mockResolvedValue(new Float32Array(384)),
embedBatch: vi.fn().mockResolvedValue([]),
dimensions: 384,
compressionModel: "mock-model",
};
}
function makeSession(id: string, project: string): Session {
return {
id,
project,
cwd: `/srv/${project}`,
startedAt: new Date().toISOString(),
status: "completed",
observationCount: 5,
};
}
function makeObs(id: string, sessionId: string, concept: string): CompressedObservation {
return {
id,
sessionId,
timestamp: new Date().toISOString(),
type: "decision",
title: `${concept} observation ${id}`,
facts: [`fact about ${concept}`],
narrative: `detailed narrative about ${concept} pattern usage`,
concepts: [concept],
files: ["src/auth.ts"],
importance: 8,
};
}
function makeExistingMemory(id: string, title: string, project?: string): Memory {
return {
id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "pattern",
title,
content: "existing content",
concepts: ["auth"],
files: ["src/auth.ts"],
sessionIds: [],
strength: 6,
version: 1,
isLatest: true,
...(project !== undefined && { project }),
};
}
describe("mem::consolidate — cross-project existingMatch guard", () => {
it("does not evolve a memory from a different project even when titles match", async () => {
const sdk = makeMockSdk();
const kv = makeMockKV();
const provider = makeProvider("synthesized memory title");
// A memory scoped to "web" with the same title the provider will generate
const webMemory = makeExistingMemory("mem_web", "synthesized memory title", "web");
await kv.set(KV.memories, webMemory.id, webMemory);
// Session and observations for "api" project
const apiSession = makeSession("sess_api", "api");
await kv.set(KV.sessions, apiSession.id, apiSession);
for (let i = 0; i < 3; i++) {
await kv.set(
KV.observations(apiSession.id),
`obs_${i}`,
makeObs(`obs_${i}`, apiSession.id, "auth"),
);
}
registerConsolidateFunction(sdk as never, kv as never, provider as never);
await sdk.trigger("mem::consolidate", { project: "api", minObservations: 1 });
// The web memory must remain untouched — isLatest still true
const webStored = await kv.get<Memory>(KV.memories, webMemory.id);
expect(webStored?.isLatest).toBe(true);
expect(webStored?.project).toBe("web");
// A new "api" memory should have been created
const allMemories = await kv.list<Memory>(KV.memories);
const apiMemories = allMemories.filter((m) => m.project === "api" && m.isLatest);
expect(apiMemories).toHaveLength(1);
expect(apiMemories[0].title).toBe("synthesized memory title");
});
it("evolves an existing memory within the same project when titles match", async () => {
const sdk = makeMockSdk();
const kv = makeMockKV();
const provider = makeProvider("synthesized memory title");
// A memory already scoped to "api" with the same title
const apiMemory = makeExistingMemory("mem_api_old", "synthesized memory title", "api");
await kv.set(KV.memories, apiMemory.id, apiMemory);
const apiSession = makeSession("sess_api", "api");
await kv.set(KV.sessions, apiSession.id, apiSession);
for (let i = 0; i < 3; i++) {
await kv.set(
KV.observations(apiSession.id),
`obs_${i}`,
makeObs(`obs_${i}`, apiSession.id, "auth"),
);
}
registerConsolidateFunction(sdk as never, kv as never, provider as never);
await sdk.trigger("mem::consolidate", { project: "api", minObservations: 1 });
// The old api memory should have been marked non-latest (evolved)
const oldMemory = await kv.get<Memory>(KV.memories, apiMemory.id);
expect(oldMemory?.isLatest).toBe(false);
// A new evolved memory should exist
const allMemories = await kv.list<Memory>(KV.memories);
const latestApi = allMemories.filter((m) => m.project === "api" && m.isLatest);
expect(latestApi).toHaveLength(1);
expect(latestApi[0].id).not.toBe(apiMemory.id);
expect(latestApi[0].parentId).toBe(apiMemory.id);
});
it("unscoped consolidation may evolve any existing memory regardless of project (background cron behavior)", async () => {
const sdk = makeMockSdk();
const kv = makeMockKV();
const provider = makeProvider("synthesized memory title");
// A scoped memory that an unscoped consolidation run should be able to evolve
const scopedMemory = makeExistingMemory("mem_api_old", "synthesized memory title", "api");
await kv.set(KV.memories, scopedMemory.id, scopedMemory);
// Session with no project restriction — unscoped consolidation
const session = makeSession("sess_any", "any");
await kv.set(KV.sessions, session.id, session);
for (let i = 0; i < 3; i++) {
await kv.set(
KV.observations(session.id),
`obs_${i}`,
makeObs(`obs_${i}`, session.id, "auth"),
);
}
registerConsolidateFunction(sdk as never, kv as never, provider as never);
// No project passed — unscoped consolidation
await sdk.trigger("mem::consolidate", { minObservations: 1 });
// The existing scoped memory should have been evolved (unscoped run is unrestricted)
const old = await kv.get<Memory>(KV.memories, scopedMemory.id);
expect(old?.isLatest).toBe(false);
// The evolved successor should be latest and carry no project (unscoped run)
const allMemories = await kv.list<Memory>(KV.memories);
const successor = allMemories.find((m) => m.isLatest && m.id !== scopedMemory.id);
expect(successor).toBeDefined();
expect(successor?.isLatest).toBe(true);
expect(successor?.project).toBeUndefined();
});
it("stamps the correct project on newly created memories", async () => {
const sdk = makeMockSdk();
const kv = makeMockKV();
const provider = makeProvider("brand new memory");
const session = makeSession("sess_api", "api");
await kv.set(KV.sessions, session.id, session);
for (let i = 0; i < 3; i++) {
await kv.set(
KV.observations(session.id),
`obs_${i}`,
makeObs(`obs_${i}`, session.id, "auth"),
);
}
registerConsolidateFunction(sdk as never, kv as never, provider as never);
await sdk.trigger("mem::consolidate", { project: "api", minObservations: 1 });
const memories = await kv.list<Memory>(KV.memories);
expect(memories).toHaveLength(1);
expect(memories[0].project).toBe("api");
});
it("leaves project undefined on memories when consolidate is called without a project", async () => {
const sdk = makeMockSdk();
const kv = makeMockKV();
const provider = makeProvider("unscoped memory");
const session = makeSession("sess_any", "any");
await kv.set(KV.sessions, session.id, session);
for (let i = 0; i < 3; i++) {
await kv.set(
KV.observations(session.id),
`obs_${i}`,
makeObs(`obs_${i}`, session.id, "auth"),
);
}
registerConsolidateFunction(sdk as never, kv as never, provider as never);
await sdk.trigger("mem::consolidate", { minObservations: 1 });
const memories = await kv.list<Memory>(KV.memories);
expect(memories).toHaveLength(1);
expect(memories[0].project).toBeUndefined();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ENV_KEYS = [
"CONSOLIDATION_ENABLED",
"AGENTMEMORY_PROVIDER",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"OPENAI_API_KEY_FOR_LLM",
"OPENROUTER_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"MINIMAX_API_KEY",
"OPENAI_BASE_URL",
];
const ORIGINAL_HOME = process.env["HOME"];
const ORIGINAL_USERPROFILE = process.env["USERPROFILE"];
const ORIGINAL: Record<string, string | undefined> = {};
let sandboxHome: string;
async function freshConfig() {
vi.resetModules();
return await import("../src/config.js");
}
function writeEnv(contents: string) {
const dir = join(sandboxHome, ".agentmemory");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, ".env"), contents);
}
describe("isConsolidationEnabled default behavior", () => {
beforeEach(() => {
sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-consolidation-"));
process.env["HOME"] = sandboxHome;
process.env["USERPROFILE"] = sandboxHome;
for (const k of ENV_KEYS) {
ORIGINAL[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
if (ORIGINAL_HOME === undefined) delete process.env["HOME"];
else process.env["HOME"] = ORIGINAL_HOME;
if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"];
else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE;
for (const k of ENV_KEYS) {
if (ORIGINAL[k] === undefined) delete process.env[k];
else process.env[k] = ORIGINAL[k];
}
rmSync(sandboxHome, { recursive: true, force: true });
});
it("returns false when no LLM provider configured (default BM25-only mode)", async () => {
writeEnv("");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(false);
});
it("returns true by default when ANTHROPIC_API_KEY is set", async () => {
writeEnv("ANTHROPIC_API_KEY=sk-test-123");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(true);
});
it("returns true by default when OPENAI_API_KEY is set", async () => {
writeEnv("OPENAI_API_KEY=sk-test-123");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(true);
});
it("returns true by default when OPENAI_BASE_URL is set (local OpenAI-compatible)", async () => {
writeEnv("OPENAI_BASE_URL=http://localhost:1234/v1");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(true);
});
it("returns true by default when AGENTMEMORY_PROVIDER=agent-sdk", async () => {
writeEnv("AGENTMEMORY_PROVIDER=agent-sdk");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(true);
});
it("explicit CONSOLIDATION_ENABLED=false overrides provider-based default", async () => {
writeEnv("ANTHROPIC_API_KEY=sk-test-123\nCONSOLIDATION_ENABLED=false");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(false);
});
it("explicit CONSOLIDATION_ENABLED=true overrides absence of provider", async () => {
writeEnv("CONSOLIDATION_ENABLED=true");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(true);
});
it("AGENTMEMORY_PROVIDER=noop returns false even with API key set", async () => {
writeEnv("AGENTMEMORY_PROVIDER=noop\nANTHROPIC_API_KEY=sk-test-123");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(false);
});
it("OPENAI_API_KEY_FOR_LLM=false scopes the key to embeddings only", async () => {
writeEnv("OPENAI_API_KEY=sk-test-123\nOPENAI_API_KEY_FOR_LLM=false");
const cfg = await freshConfig();
expect(cfg.isConsolidationEnabled()).toBe(false);
});
});
+252
View File
@@ -0,0 +1,252 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../src/config.js", () => ({
getConsolidationDecayDays: () => 30,
isConsolidationEnabled: vi.fn(() => true),
}));
import { registerConsolidationPipelineFunction } from "../src/functions/consolidation-pipeline.js";
import { isConsolidationEnabled } from "../src/config.js";
import type { SessionSummary, Memory, SemanticMemory, ProceduralMemory } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeSummary(i: number): SessionSummary {
return {
sessionId: `ses_${i}`,
project: "test-project",
createdAt: new Date(Date.now() - i * 86400000).toISOString(),
title: `Session ${i} summary`,
narrative: `Worked on feature ${i}`,
keyDecisions: [`Decision ${i}`],
filesModified: [`src/file${i}.ts`],
concepts: ["typescript", "testing"],
observationCount: 5,
};
}
function makePattern(i: number): Memory {
return {
id: `mem_${i}`,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
type: "pattern",
title: `Pattern ${i}`,
content: `Always do thing ${i}`,
concepts: ["testing"],
files: [],
sessionIds: ["ses_1", "ses_2"],
strength: 5,
version: 1,
isLatest: true,
};
}
describe("Consolidation Pipeline", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
});
it("pipeline skips semantic when fewer than 5 summaries", async () => {
const provider = {
name: "test",
compress: vi.fn(),
summarize: vi.fn(),
};
registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never);
for (let i = 0; i < 3; i++) {
await kv.set("mem:summaries", `ses_${i}`, makeSummary(i));
}
const result = (await sdk.trigger("mem::consolidate-pipeline", {
tier: "semantic",
})) as { success: boolean; results: Record<string, unknown> };
expect(result.success).toBe(true);
const semantic = result.results.semantic as { skipped: boolean; reason: string };
expect(semantic.skipped).toBe(true);
expect(semantic.reason).toContain("fewer than 5");
expect(provider.summarize).not.toHaveBeenCalled();
});
it("pipeline skips procedural when fewer than 2 patterns", async () => {
const provider = {
name: "test",
compress: vi.fn(),
summarize: vi.fn(),
};
registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never);
const mem: Memory = {
...makePattern(1),
sessionIds: ["ses_1", "ses_2"],
};
await kv.set("mem:memories", "mem_1", mem);
const result = (await sdk.trigger("mem::consolidate-pipeline", {
tier: "procedural",
})) as { success: boolean; results: Record<string, unknown> };
expect(result.success).toBe(true);
const procedural = result.results.procedural as { skipped: boolean; reason: string };
expect(procedural.skipped).toBe(true);
expect(procedural.reason).toContain("fewer than 2");
});
it("with enough summaries, creates semantic memories from provider response", async () => {
const provider = {
name: "test",
compress: vi.fn(),
summarize: vi.fn().mockResolvedValue(
`<facts><fact confidence="0.9">TypeScript is the primary language</fact></facts>`,
),
};
registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never);
for (let i = 0; i < 6; i++) {
await kv.set("mem:summaries", `ses_${i}`, makeSummary(i));
}
const result = (await sdk.trigger("mem::consolidate-pipeline", {
tier: "semantic",
})) as { success: boolean; results: Record<string, unknown> };
expect(result.success).toBe(true);
const semantic = result.results.semantic as { newFacts: number };
expect(semantic.newFacts).toBe(1);
const stored = await kv.list<SemanticMemory>("mem:semantic");
expect(stored.length).toBe(1);
expect(stored[0].fact).toBe("TypeScript is the primary language");
expect(stored[0].confidence).toBe(0.9);
});
it("with enough patterns, creates procedural memories from provider response", async () => {
const provider = {
name: "test",
compress: vi.fn(),
summarize: vi.fn().mockResolvedValue(
`<procedures><procedure name="Test Workflow" trigger="when writing tests"><step>Create test file</step><step>Write assertions</step></procedure></procedures>`,
),
};
registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never);
for (let i = 0; i < 3; i++) {
await kv.set("mem:memories", `mem_${i}`, makePattern(i));
}
const result = (await sdk.trigger("mem::consolidate-pipeline", {
tier: "procedural",
})) as { success: boolean; results: Record<string, unknown> };
expect(result.success).toBe(true);
const procedural = result.results.procedural as { newProcedures: number };
expect(procedural.newProcedures).toBe(1);
const stored = await kv.list<ProceduralMemory>("mem:procedural");
expect(stored.length).toBe(1);
expect(stored[0].name).toBe("Test Workflow");
expect(stored[0].steps.length).toBe(2);
expect(stored[0].triggerCondition).toBe("when writing tests");
});
it("consolidation records an audit entry", async () => {
const provider = {
name: "test",
compress: vi.fn(),
summarize: vi.fn(),
};
registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never);
await sdk.trigger("mem::consolidate-pipeline", { tier: "semantic" });
const audits = await kv.list("mem:audit");
expect(audits.length).toBe(1);
});
it("pipeline returns early when consolidation is disabled", async () => {
vi.mocked(isConsolidationEnabled).mockReturnValue(false);
const provider = {
name: "test",
compress: vi.fn(),
summarize: vi.fn(),
};
registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never);
const result = (await sdk.trigger("mem::consolidate-pipeline", {})) as {
success: boolean;
skipped?: boolean;
reason?: string;
};
expect(result.success).toBe(false);
expect(result.skipped).toBe(true);
expect(result.reason).toContain("CONSOLIDATION_ENABLED");
expect(provider.summarize).not.toHaveBeenCalled();
vi.mocked(isConsolidationEnabled).mockReturnValue(true);
});
it("pipeline proceeds with force=true even when consolidation is disabled", async () => {
vi.mocked(isConsolidationEnabled).mockReturnValue(false);
const provider = {
name: "test",
compress: vi.fn(),
summarize: vi.fn(),
};
registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never);
const result = (await sdk.trigger("mem::consolidate-pipeline", {
force: true,
})) as { success: boolean; results: Record<string, unknown> };
expect(result.success).toBe(true);
expect(result.results).toBeDefined();
vi.mocked(isConsolidationEnabled).mockReturnValue(true);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect } from "vitest";
import { spawn } from "node:child_process";
import { join } from "node:path";
const HOOKS_DIR = join(import.meta.dirname, "..", "plugin", "scripts");
// Spawns a compiled plugin hook as a subprocess, feeds it JSON on stdin,
// and returns { stdout, stderr, exitCode, tookMs }. The test is about
// making sure the hook writes NOTHING to stdout when context injection is
// disabled — which is what Claude Code reads to decide whether to prepend
// memory context to the next tool turn.
function runHook(
scriptName: string,
stdin: string,
env: Record<string, string>,
): Promise<{
stdout: string;
stderr: string;
exitCode: number | null;
tookMs: number;
}> {
return new Promise((resolve, reject) => {
const start = Date.now();
const child = spawn(
process.execPath,
[join(HOOKS_DIR, scriptName)],
{
env: {
// Start from a clean slate — don't leak test-runner env into
// the hook. Only pass PATH and anything explicitly set by the
// test case.
PATH: process.env["PATH"] ?? "",
...env,
},
stdio: ["pipe", "pipe", "pipe"],
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (exitCode) => {
resolve({ stdout, stderr, exitCode, tookMs: Date.now() - start });
});
child.stdin.write(stdin);
child.stdin.end();
});
}
describe("pre-tool-use hook — context injection gate (#143)", () => {
it("writes nothing to stdout when AGENTMEMORY_INJECT_CONTEXT is unset (default)", async () => {
const payload = JSON.stringify({
session_id: "ses_test",
tool_name: "Read",
tool_input: { file_path: "src/foo.ts" },
});
// No AGENTMEMORY_* env vars at all — simulates a fresh Claude Pro
// install with no ~/.agentmemory/.env overrides.
const result = await runHook("pre-tool-use.mjs", payload, {});
expect(result.stdout).toBe("");
expect(result.exitCode).toBe(0);
});
it("writes nothing to stdout when AGENTMEMORY_INJECT_CONTEXT=false explicitly", async () => {
const payload = JSON.stringify({
session_id: "ses_test",
tool_name: "Edit",
tool_input: { file_path: "src/foo.ts", old_string: "a", new_string: "b" },
});
const result = await runHook("pre-tool-use.mjs", payload, {
AGENTMEMORY_INJECT_CONTEXT: "false",
});
expect(result.stdout).toBe("");
expect(result.exitCode).toBe(0);
});
it("exits fast when disabled (no stdin consumption, no network fetch)", async () => {
// The disabled path must not open stdin or reach for fetch — it
// should return immediately. A 250ms budget is generous enough to
// account for Node startup on CI while still catching any accidental
// fetch round-trip or stdin buffering.
const result = await runHook("pre-tool-use.mjs", "", {});
expect(result.tookMs).toBeLessThan(1000);
expect(result.stdout).toBe("");
});
it("when AGENTMEMORY_INJECT_CONTEXT=true, hook still runs but safely errors on unreachable backend", async () => {
// Opt-in path. We point at a port that's guaranteed closed so the
// fetch fails fast; the hook must still exit cleanly (the whole
// point of the try/catch is not to break Claude Code) and must not
// echo anything to stdout when the fetch fails.
const payload = JSON.stringify({
session_id: "ses_test",
tool_name: "Read",
tool_input: { file_path: "src/foo.ts" },
});
const result = await runHook("pre-tool-use.mjs", payload, {
AGENTMEMORY_INJECT_CONTEXT: "true",
AGENTMEMORY_URL: "http://127.0.0.1:1",
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("");
});
});
describe("session-start hook — context injection gate (#143)", () => {
it("registers the session but writes nothing to stdout when AGENTMEMORY_INJECT_CONTEXT is unset", async () => {
// Session registration POST will fail against the unreachable URL,
// but the hook's try/catch must swallow that cleanly — Claude Code
// must never see an error at session start.
const payload = JSON.stringify({
session_id: "ses_test",
cwd: "/tmp/fake-project",
});
const result = await runHook("session-start.mjs", payload, {
AGENTMEMORY_URL: "http://127.0.0.1:1",
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("");
});
});
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { registerContextFunction } from "../src/functions/context.js";
import { KV } from "../src/state/schema.js";
import type { Lesson } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
if (!store.has(scope)) return [];
return Array.from(store.get(scope)!.values()) as T[];
},
};
}
type ContextHandler = (data: {
sessionId: string;
project: string;
budget?: number;
}) => Promise<{ context: string; blocks: number; tokens: number }>;
function wireContext(kv: ReturnType<typeof mockKV>, budget = 4000) {
let handler: ContextHandler | undefined;
const sdk = {
registerFunction: vi.fn((id: string, cb: ContextHandler) => {
if (id === "mem::context") handler = cb;
}),
} as unknown as import("iii-sdk").ISdk;
registerContextFunction(sdk, kv as never, budget);
if (!handler) throw new Error("mem::context not registered");
return handler;
}
function makeLesson(over: Partial<Lesson> = {}): Lesson {
const now = new Date().toISOString();
return {
id: over.id ?? `lesson_${Math.random().toString(36).slice(2)}`,
content: over.content ?? "default lesson content",
context: over.context ?? "",
confidence: over.confidence ?? 0.7,
reinforcements: over.reinforcements ?? 1,
source: over.source ?? "manual",
sourceIds: over.sourceIds ?? [],
project: over.project,
tags: over.tags ?? [],
createdAt: over.createdAt ?? now,
updatedAt: over.updatedAt ?? now,
lastReinforcedAt: over.lastReinforcedAt,
lastDecayedAt: over.lastDecayedAt,
decayRate: over.decayRate ?? 0.05,
deleted: over.deleted,
};
}
async function seedLesson(
kv: ReturnType<typeof mockKV>,
partial: Partial<Lesson>,
) {
const lesson = makeLesson(partial);
await kv.set(KV.lessons, lesson.id, lesson);
return lesson;
}
describe("mem::context — lessons auto-injection (#457)", () => {
let kv: ReturnType<typeof mockKV>;
let handler: ContextHandler;
beforeEach(() => {
kv = mockKV();
handler = wireContext(kv);
});
it("includes a 'Lessons Learned' block when KV has lessons for the project", async () => {
await seedLesson(kv, {
id: "lesson_a",
content: "always run npm test before commit",
project: "/tmp/proj",
confidence: 0.85,
});
const result = await handler({
sessionId: "ses_a",
project: "/tmp/proj",
});
expect(result.context).toContain("Lessons Learned");
expect(result.context).toContain("always run npm test before commit");
expect(result.blocks).toBeGreaterThan(0);
});
it("omits the lessons block entirely when KV has no lessons", async () => {
const result = await handler({
sessionId: "ses_empty",
project: "/tmp/proj",
});
expect(result.context).not.toContain("Lessons Learned");
});
it("ranks project-scoped lessons above global lessons", async () => {
await seedLesson(kv, {
id: "lesson_global",
content: "global-lesson-marker",
project: undefined,
confidence: 0.9,
});
await seedLesson(kv, {
id: "lesson_project",
content: "project-lesson-marker",
project: "/tmp/proj",
confidence: 0.7,
});
const result = await handler({
sessionId: "ses_rank",
project: "/tmp/proj",
});
const projectIdx = result.context.indexOf("project-lesson-marker");
const globalIdx = result.context.indexOf("global-lesson-marker");
expect(projectIdx).toBeGreaterThan(-1);
expect(globalIdx).toBeGreaterThan(-1);
expect(projectIdx).toBeLessThan(globalIdx);
});
it("excludes lessons scoped to a different project", async () => {
await seedLesson(kv, {
id: "lesson_other",
content: "other-project-lesson",
project: "/tmp/other-project",
confidence: 0.9,
});
const result = await handler({
sessionId: "ses_isolate",
project: "/tmp/proj",
});
expect(result.context).not.toContain("other-project-lesson");
});
it("excludes deleted lessons", async () => {
await seedLesson(kv, {
id: "lesson_deleted",
content: "tombstoned-lesson",
project: "/tmp/proj",
confidence: 0.9,
deleted: true,
});
const result = await handler({
sessionId: "ses_deleted",
project: "/tmp/proj",
});
expect(result.context).not.toContain("tombstoned-lesson");
});
it("caps at the top 10 lessons by confidence", async () => {
for (let i = 0; i < 15; i++) {
await seedLesson(kv, {
id: `lesson_${i}`,
content: `lesson-marker-${i}`,
project: "/tmp/proj",
confidence: i / 100,
});
}
const result = await handler({
sessionId: "ses_cap",
project: "/tmp/proj",
});
const matched = result.context.match(/lesson-marker-/g) ?? [];
expect(matched.length).toBe(10);
expect(result.context).toContain("lesson-marker-14");
expect(result.context).toContain("lesson-marker-5");
expect(result.context).not.toContain("lesson-marker-0");
});
it("shows lesson confidence in the rendered line", async () => {
await seedLesson(kv, {
id: "lesson_conf",
content: "test confidence rendering",
project: "/tmp/proj",
confidence: 0.83,
});
const result = await handler({
sessionId: "ses_conf",
project: "/tmp/proj",
});
expect(result.context).toContain("(0.83)");
});
it("appends optional context string when present", async () => {
await seedLesson(kv, {
id: "lesson_ctx",
content: "use TaskCreate for >5-file work",
context: "when working on multi-file refactors",
project: "/tmp/proj",
confidence: 0.8,
});
const result = await handler({
sessionId: "ses_ctx",
project: "/tmp/proj",
});
expect(result.context).toContain(
"use TaskCreate for >5-file work — when working on multi-file refactors",
);
});
});
+177
View File
@@ -0,0 +1,177 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { registerContextFunction } from "../src/functions/context.js";
import { KV } from "../src/state/schema.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
if (!store.has(scope)) return [];
return Array.from(store.get(scope)!.values()) as T[];
},
};
}
type ContextHandler = (data: {
sessionId: string;
project: string;
budget?: number;
}) => Promise<{ context: string; blocks: number; tokens: number }>;
function wireContext(kv: ReturnType<typeof mockKV>) {
let handler: ContextHandler | undefined;
const sdk = {
registerFunction: vi.fn((id: string, cb: ContextHandler) => {
if (id === "mem::context") handler = cb;
}),
} as unknown as import("iii-sdk").ISdk;
registerContextFunction(sdk, kv as never, 2000);
if (!handler) throw new Error("mem::context not registered");
return handler;
}
async function seedPinnedSlot(
kv: ReturnType<typeof mockKV>,
label: string,
content: string,
scope: "project" | "global" = "global",
) {
const target = scope === "global" ? KV.globalSlots : KV.slots;
await kv.set(target, label, {
label,
content,
description: "",
sizeLimit: 2000,
pinned: true,
readOnly: false,
scope,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
describe("mem::context — pinned slot injection", () => {
const ORIGINAL_SLOTS_ENV = process.env["AGENTMEMORY_SLOTS"];
afterEach(() => {
if (ORIGINAL_SLOTS_ENV === undefined) {
delete process.env["AGENTMEMORY_SLOTS"];
} else {
process.env["AGENTMEMORY_SLOTS"] = ORIGINAL_SLOTS_ENV;
}
});
describe("when AGENTMEMORY_SLOTS=true", () => {
let kv: ReturnType<typeof mockKV>;
let handler: ContextHandler;
beforeEach(() => {
process.env["AGENTMEMORY_SLOTS"] = "true";
kv = mockKV();
handler = wireContext(kv);
});
it("includes pinned global slot content in returned context", async () => {
await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global");
const result = await handler({
sessionId: "ses_a",
project: "/tmp/proj",
});
expect(result.context).toContain("tool_guidelines");
expect(result.context).toContain("rule-alpha");
expect(result.blocks).toBeGreaterThan(0);
});
it("renders multiple pinned slots, sorted by label", async () => {
await seedPinnedSlot(kv, "user_preferences", "pref-alpha", "global");
await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global");
const result = await handler({
sessionId: "ses_b",
project: "/tmp/proj",
});
const guidelinesIdx = result.context.indexOf("tool_guidelines");
const prefsIdx = result.context.indexOf("user_preferences");
expect(guidelinesIdx).toBeGreaterThan(-1);
expect(prefsIdx).toBeGreaterThan(-1);
expect(guidelinesIdx).toBeLessThan(prefsIdx);
});
it("skips unpinned slots even when they have content", async () => {
await kv.set(KV.globalSlots, "self_notes", {
label: "self_notes",
content: "unpinned-content-alpha",
description: "",
sizeLimit: 1500,
pinned: false,
readOnly: false,
scope: "global",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const result = await handler({
sessionId: "ses_c",
project: "/tmp/proj",
});
expect(result.context).not.toContain("unpinned-content-alpha");
});
it("skips empty pinned slots (the seeded defaults)", async () => {
await seedPinnedSlot(kv, "persona", "", "global");
const result = await handler({
sessionId: "ses_d",
project: "/tmp/proj",
});
expect(result.context).not.toContain("persona");
});
it("project-scoped slot shadows global slot with the same label", async () => {
await seedPinnedSlot(kv, "tool_guidelines", "global-value", "global");
await seedPinnedSlot(kv, "tool_guidelines", "project-value", "project");
const result = await handler({
sessionId: "ses_e",
project: "/tmp/proj",
});
expect(result.context).toContain("project-value");
expect(result.context).not.toContain("global-value");
});
});
describe("when AGENTMEMORY_SLOTS is off", () => {
it("does not include any slot content", async () => {
delete process.env["AGENTMEMORY_SLOTS"];
const kv = mockKV();
const handler = wireContext(kv);
await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global");
const result = await handler({
sessionId: "ses_f",
project: "/tmp/proj",
});
expect(result.context).not.toContain("tool_guidelines");
expect(result.context).not.toContain("rule-alpha");
});
});
});
+380
View File
@@ -0,0 +1,380 @@
import { describe, expect, it } from "vitest";
import { readFileSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
const repoRoot = resolve(__dirname, "..");
const pluginRoot = join(repoRoot, "plugin");
function readJson<T = unknown>(path: string): T {
return JSON.parse(readFileSync(path, "utf-8")) as T;
}
const SUPPORTED_COPILOT_EVENTS = new Set([
"sessionStart",
"userPromptSubmitted",
"preToolUse",
"postToolUse",
"postToolUseFailure",
"preCompact",
"agentStop",
"sessionEnd",
"subagentStart",
"subagentStop",
"notification",
]);
const REQUIRED_MINIMUM_EVENTS = [
"sessionStart",
"userPromptSubmitted",
"preToolUse",
"postToolUse",
"agentStop",
];
const KNOWN_SKILL_DIRS = [
"recall",
"remember",
"session-history",
"forget",
"handoff",
"recap",
"commit-context",
"commit-history",
];
describe("Copilot plugin manifest (plugin/plugin.json)", () => {
it("manifest exists with kebab-case name, version, and required fields", () => {
const manifestPath = join(pluginRoot, "plugin.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = readJson<{
name: string;
version: string;
description?: string;
skills?: string;
mcpServers?: string;
hooks?: string;
}>(manifestPath);
expect(manifest.name).toBe("agentmemory");
expect(manifest.name).toMatch(/^[a-z][a-z0-9-]*$/);
expect(manifest.version).toMatch(/^\d+\.\d+\.\d+/);
expect(manifest.skills).toBeDefined();
expect(manifest.mcpServers).toBeDefined();
expect(manifest.hooks).toBeDefined();
});
it("manifest version matches main package.json", () => {
const pkgVer = readJson<{ version: string }>(join(repoRoot, "package.json")).version;
const pluginVer = readJson<{ version: string }>(
join(pluginRoot, "plugin.json"),
).version;
expect(pluginVer).toBe(pkgVer);
});
it("all referenced manifest paths resolve to existing files / directories", () => {
const manifest = readJson<{ skills: string; mcpServers: string; hooks: string }>(
join(pluginRoot, "plugin.json"),
);
const manifestDir = pluginRoot;
expect(existsSync(resolve(manifestDir, manifest.skills))).toBe(true);
expect(existsSync(resolve(manifestDir, manifest.mcpServers))).toBe(true);
expect(existsSync(resolve(manifestDir, manifest.hooks))).toBe(true);
});
it("skills path resolves and contains all known skill directories", () => {
const manifest = readJson<{ skills: string }>(join(pluginRoot, "plugin.json"));
const manifestDir = pluginRoot;
const skillsPath = resolve(manifestDir, manifest.skills);
for (const skill of KNOWN_SKILL_DIRS) {
expect(
existsSync(join(skillsPath, skill)),
`missing skill directory: ${skill}`,
).toBe(true);
}
});
});
describe("Copilot MCP config (.mcp.copilot.json)", () => {
it("file exists with expected shape", () => {
const mcpPath = join(pluginRoot, ".mcp.copilot.json");
expect(existsSync(mcpPath)).toBe(true);
const config = readJson<{
mcpServers: {
agentmemory: {
type: string;
command: string;
args: string[];
env: Record<string, string>;
tools: string[];
};
};
}>(mcpPath);
const server = config.mcpServers.agentmemory;
expect(server.type).toBe("local");
expect(server.command).toBe("npx");
expect(server.args).toEqual(["-y", "@agentmemory/mcp"]);
expect(server.env["AGENTMEMORY_URL"]).toBe(
"${AGENTMEMORY_URL:-http://localhost:3111}",
);
expect(server.env["AGENTMEMORY_SECRET"]).toBe("${AGENTMEMORY_SECRET:-}");
expect(server.env["AGENTMEMORY_TOOLS"]).toBe("${AGENTMEMORY_TOOLS:-all}");
expect(server.tools).toContain("*");
});
});
describe("Copilot hooks config (hooks/hooks.copilot.json)", () => {
type HookEntry = {
type: string;
command?: string;
bash?: string;
powershell?: string;
matcher?: string;
};
function loadHooks() {
return readJson<{ version: number; hooks: Record<string, HookEntry[]> }>(
join(pluginRoot, "hooks/hooks.copilot.json"),
);
}
it("has top-level version === 1 and hooks object", () => {
const config = loadHooks();
expect(config.version).toBe(1);
expect(config.hooks).toBeDefined();
expect(typeof config.hooks).toBe("object");
});
it("contains only supported Copilot event names", () => {
const config = loadHooks();
for (const event of Object.keys(config.hooks)) {
expect(
SUPPORTED_COPILOT_EVENTS.has(event),
`unsupported event "${event}" in hooks.copilot.json`,
).toBe(true);
}
});
it("contains all required minimum events", () => {
const config = loadHooks();
const events = Object.keys(config.hooks);
for (const event of REQUIRED_MINIMUM_EVENTS) {
expect(events, `missing required event: ${event}`).toContain(event);
}
});
it("PreToolUse entry has the correct matcher", () => {
const config = loadHooks();
const preToolEntries = config.hooks["preToolUse"];
expect(preToolEntries).toBeDefined();
const withMatcher = preToolEntries.find(
(e) => e.matcher === "edit|write|create|read|view|glob|grep",
);
expect(
withMatcher,
"PreToolUse must have matcher edit|write|create|read|view|glob|grep",
).toBeDefined();
});
it("every handler has type === 'command' and exactly one of command/bash/powershell", () => {
const config = loadHooks();
for (const [event, entries] of Object.entries(config.hooks)) {
for (const handler of entries) {
expect(handler.type, `${event} handler type`).toBe("command");
const commandFields = [handler.command, handler.bash, handler.powershell].filter(
(v): v is string => typeof v === "string" && v.trim().length > 0,
);
expect(
commandFields.length,
`${event} handler must have exactly one of command/bash/powershell`,
).toBe(1);
}
}
});
it("every referenced script exists on disk", () => {
const config = loadHooks();
const scriptRefs = new Set<string>();
for (const entries of Object.values(config.hooks)) {
for (const handler of entries) {
const cmd = handler.command ?? handler.bash ?? handler.powershell ?? "";
const match = cmd.match(/\$\{(?:COPILOT_PLUGIN_ROOT|CLAUDE_PLUGIN_ROOT)\}\/(scripts\/[^\s]+)/);
if (match) scriptRefs.add(match[1]);
}
}
expect(scriptRefs.size).toBeGreaterThan(0);
for (const rel of scriptRefs) {
expect(existsSync(join(pluginRoot, rel)), `missing hook script: ${rel}`).toBe(true);
}
});
});
describe("Copilot hook scripts", () => {
type ObservedRequest = { path: string; body: Record<string, unknown> };
async function runHook(
script: string,
payload: Record<string, unknown>,
env: Record<string, string> = {},
): Promise<{ requests: ObservedRequest[]; stdout: string }> {
const requests: ObservedRequest[] = [];
const server = createServer((req, res) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
});
req.on("end", () => {
requests.push({
path: req.url ?? "",
body: raw ? (JSON.parse(raw) as Record<string, unknown>) : {},
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ context: "remembered context" }));
});
});
await new Promise<void>((resolveServer) => {
server.listen(0, "127.0.0.1", resolveServer);
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("test server did not bind to a TCP port");
}
try {
const child = spawn(process.execPath, [join(pluginRoot, script)], {
env: {
...process.env,
AGENTMEMORY_URL: `http://127.0.0.1:${address.port}`,
AGENTMEMORY_SECRET: "",
...env,
},
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.stdin.end(JSON.stringify(payload));
const exitCode = await new Promise<number | null>((resolveExit, reject) => {
const timeout = setTimeout(() => {
child.kill();
reject(new Error(`hook ${script} timed out`));
}, 5000);
child.on("error", reject);
child.on("close", (code) => {
clearTimeout(timeout);
resolveExit(code);
});
});
expect(exitCode, stderr).toBe(0);
return { requests, stdout };
} finally {
await new Promise<void>((resolveClose) => {
server.close(() => resolveClose());
});
}
}
it("session-start accepts Copilot camelCase sessionId", async () => {
const result = await runHook(
"scripts/session-start.mjs",
{ sessionId: "copilot-session", cwd: "C:\\repo" },
{ AGENTMEMORY_INJECT_CONTEXT: "true" },
);
expect(result.stdout).toBe("remembered context");
expect(result.requests[0]?.path).toBe("/agentmemory/session/start");
expect(result.requests[0]?.body).toMatchObject({
sessionId: "copilot-session",
project: "C:\\repo",
cwd: "C:\\repo",
});
});
it("pre-tool-use narrows Copilot sessionId to strings", async () => {
const result = await runHook(
"scripts/pre-tool-use.mjs",
{
sessionId: 123,
toolName: "read",
toolArgs: { path: "src/index.ts" },
},
{ AGENTMEMORY_INJECT_CONTEXT: "true" },
);
expect(result.stdout).toBe("remembered context");
expect(result.requests[0]?.path).toBe("/agentmemory/enrich");
expect(result.requests[0]?.body).toMatchObject({
sessionId: "unknown",
files: ["src/index.ts"],
terms: [],
toolName: "read",
});
});
it("prompt-submit accepts Copilot camelCase prompt payload", async () => {
const result = await runHook("scripts/prompt-submit.mjs", {
sessionId: "copilot-session",
cwd: "C:\\repo",
userPrompt: "remember this prompt",
});
expect(result.requests[0]?.path).toBe("/agentmemory/observe");
expect(result.requests[0]?.body).toMatchObject({
hookType: "prompt_submit",
sessionId: "copilot-session",
data: { prompt: "remember this prompt" },
});
});
it("post-tool-failure accepts Copilot camelCase tool and error payloads", async () => {
const result = await runHook("scripts/post-tool-failure.mjs", {
sessionId: "copilot-session",
cwd: "C:\\repo",
toolName: "edit",
toolArgs: { filePath: "src/index.ts" },
errorMessage: "failed",
});
expect(result.requests[0]?.path).toBe("/agentmemory/observe");
expect(result.requests[0]?.body).toMatchObject({
hookType: "post_tool_failure",
sessionId: "copilot-session",
data: {
tool_name: "edit",
tool_input: JSON.stringify({ filePath: "src/index.ts" }),
error: "failed",
},
});
});
it("notification accepts Copilot camelCase notificationType", async () => {
const result = await runHook("scripts/notification.mjs", {
sessionId: "copilot-session",
cwd: "C:\\repo",
notificationType: "permission_prompt",
title: "Tool approval",
message: "Approve edit",
});
expect(result.requests[0]?.path).toBe("/agentmemory/observe");
expect(result.requests[0]?.body).toMatchObject({
hookType: "notification",
sessionId: "copilot-session",
data: {
notification_type: "permission_prompt",
title: "Tool approval",
message: "Approve edit",
},
});
});
});
+284
View File
@@ -0,0 +1,284 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../src/state/keyed-mutex.js", () => ({
withKeyedLock: <T>(_key: string, fn: () => Promise<T>) => fn(),
}));
vi.mock("../src/functions/audit.js", () => ({
recordAudit: vi.fn(),
}));
vi.mock("../src/functions/access-tracker.js", () => ({
recordAccessBatch: vi.fn(),
deleteAccessLog: vi.fn(),
}));
vi.mock("../src/config.js", () => ({
getAgentId: () => undefined,
isAgentScopeIsolated: () => false,
}));
import { registerRememberFunction } from "../src/functions/remember.js";
import { registerSearchFunction, getSearchIndex, setIndexPersistence } from "../src/functions/search.js";
import { registerEnrichFunction } from "../src/functions/enrich.js";
import { KV } from "../src/state/schema.js";
import type { Session } from "../src/types.js";
function makeMockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function makeMockSdk() {
const functions = new Map<string, Function>();
const triggerOverrides = new Map<string, Function>();
return {
registerFunction: (id: string, handler: Function) => {
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload;
if (triggerOverrides.has(id)) return triggerOverrides.get(id)!(payload);
const fn = functions.get(id);
if (!fn) throw new Error(`No function registered: ${id}`);
return fn(payload);
},
overrideTrigger: (id: string, handler: Function) => {
triggerOverrides.set(id, handler);
},
};
}
async function seedSessions(kv: ReturnType<typeof makeMockKV>) {
const apiSession: Session = {
id: "sess-api",
project: "api",
cwd: "/srv/api",
startedAt: new Date().toISOString(),
status: "active",
observationCount: 0,
};
const webSession: Session = {
id: "sess-web",
project: "web",
cwd: "/srv/web",
startedAt: new Date().toISOString(),
status: "active",
observationCount: 0,
};
await kv.set(KV.sessions, apiSession.id, apiSession);
await kv.set(KV.sessions, webSession.id, webSession);
}
describe("cross-project isolation — end-to-end", () => {
let sdk: ReturnType<typeof makeMockSdk>;
let kv: ReturnType<typeof makeMockKV>;
beforeEach(async () => {
sdk = makeMockSdk();
kv = makeMockKV();
// Disable index persistence in tests so no file I/O occurs.
setIndexPersistence(null);
// Clear the singleton BM25 index between tests.
getSearchIndex().clear();
// Register all three functions against the shared KV.
registerRememberFunction(sdk as never, kv as never);
registerSearchFunction(sdk as never, kv as never);
// Enrich calls mem::search internally; wire the file-context trigger as a no-op.
registerEnrichFunction(sdk as never, kv as never);
sdk.overrideTrigger("mem::file-context", async () => ({ context: "" }));
await seedSessions(kv);
});
it("bug memory scoped to api does not appear in enrich context for web project", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt throws 401 when Authorization header has extra whitespace. Call .trim() before passing to middleware.",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-web",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
expect(result.context).not.toContain("agentmemory-past-errors");
expect(result.context).not.toContain("express-jwt");
});
it("bug memory scoped to api appears in enrich context for api project", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt throws 401 when Authorization header has extra whitespace. Call .trim() before passing to middleware.",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-api",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
expect(result.context).toContain("agentmemory-past-errors");
expect(result.context).toContain("express-jwt");
});
it("bug memory scoped to api is excluded from search results for web project", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt throws 401 when Authorization header has extra whitespace",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
// Force index rebuild so the freshly saved memory is indexed.
getSearchIndex().clear();
const result = await sdk.trigger("mem::search", {
query: "express-jwt whitespace",
project: "web",
}) as { results: Array<{ observation: { title: string; narrative?: string } }> };
const titles = result.results.map((r) => r.observation.title);
expect(titles.join(" ")).not.toContain("express-jwt");
});
it("bug memory scoped to api is included in search results for api project", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt throws 401 when Authorization header has extra whitespace",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
// Force index rebuild so the freshly saved memory is indexed.
getSearchIndex().clear();
const result = await sdk.trigger("mem::search", {
query: "express-jwt whitespace",
project: "api",
}) as { results: Array<{ observation: { title: string; narrative?: string } }> };
const combined = result.results
.map((r) => `${r.observation.title} ${r.observation.narrative ?? ""}`)
.join(" ");
expect(combined).toContain("express-jwt");
});
it("two projects with overlapping filenames see only their own bug memories", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt Authorization header whitespace causes 401",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
await sdk.trigger("mem::remember", {
content: "nextauth cookie domain mismatch breaks SSO on subdomains",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "web",
});
const apiEnrich = await sdk.trigger("mem::enrich", {
sessionId: "sess-api",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
expect(apiEnrich.context).toContain("express-jwt");
expect(apiEnrich.context).not.toContain("nextauth");
const webEnrich = await sdk.trigger("mem::enrich", {
sessionId: "sess-web",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
expect(webEnrich.context).toContain("nextauth");
expect(webEnrich.context).not.toContain("express-jwt");
});
it("unscoped (legacy) bug memory is visible to both projects", async () => {
await sdk.trigger("mem::remember", {
content: "generic auth middleware always validates content-type header",
type: "bug",
files: ["src/middleware/auth.ts"],
// no project — legacy / unscoped
});
const apiResult = await sdk.trigger("mem::enrich", {
sessionId: "sess-api",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
const webResult = await sdk.trigger("mem::enrich", {
sessionId: "sess-web",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
expect(apiResult.context).toContain("generic auth middleware");
expect(webResult.context).toContain("generic auth middleware");
});
it("memories from different projects do not supersede each other via Jaccard dedup", async () => {
const sharedContent = "jwt token must be trimmed before validation in the middleware layer";
await sdk.trigger("mem::remember", {
content: sharedContent,
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
// Save nearly identical content under a different project.
const result = await sdk.trigger("mem::remember", {
content: sharedContent,
type: "bug",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { memory: { id: string; project: string } };
// Both memories must survive as independent latest entries.
const memories = await kv.list(KV.memories) as Array<{ isLatest: boolean; project: string }>;
const latestByProject = memories.filter((m) => m.isLatest);
const projects = latestByProject.map((m) => m.project);
expect(projects).toContain("api");
expect(projects).toContain("web");
expect(result.memory.project).toBe("web");
});
});
+522
View File
@@ -0,0 +1,522 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerCrystallizeFunction } from "../src/functions/crystallize.js";
import type { Action, Crystal, MemoryProvider } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function mockProvider(): MemoryProvider {
return {
name: "test",
compress: vi.fn(),
summarize: vi.fn().mockResolvedValue(
'{"narrative":"test","keyOutcomes":["done"],"filesAffected":["a.ts"],"lessons":["learned"]}',
),
};
}
function makeAction(overrides: Partial<Action> & { id: string }): Action {
return {
title: "Test action",
description: "A test action",
status: "done",
priority: 5,
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date().toISOString(),
createdBy: "agent-1",
tags: [],
sourceObservationIds: [],
sourceMemoryIds: [],
...overrides,
};
}
describe("Crystallize Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
let provider: MemoryProvider;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
provider = mockProvider();
registerCrystallizeFunction(sdk as never, kv as never, provider);
});
describe("mem::crystallize", () => {
it("crystallizes completed actions with valid JSON response", async () => {
const action = makeAction({ id: "act_1", title: "Fix bug", status: "done" });
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::crystallize", {
actionIds: ["act_1"],
project: "webapp",
sessionId: "sess_1",
})) as { success: boolean; crystal: Crystal };
expect(result.success).toBe(true);
expect(result.crystal.id).toMatch(/^crys_/);
expect(result.crystal.narrative).toBe("test");
expect(result.crystal.keyOutcomes).toEqual(["done"]);
expect(result.crystal.filesAffected).toEqual(["a.ts"]);
expect(result.crystal.lessons).toEqual(["learned"]);
expect(result.crystal.sourceActionIds).toEqual(["act_1"]);
expect(result.crystal.project).toBe("webapp");
expect(result.crystal.sessionId).toBe("sess_1");
expect(result.crystal.createdAt).toBeDefined();
});
it("marks source actions with crystallizedInto", async () => {
const action = makeAction({ id: "act_mark", status: "done" });
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::crystallize", {
actionIds: ["act_mark"],
})) as { success: boolean; crystal: Crystal };
expect(result.success).toBe(true);
const updated = await kv.get<Action>("mem:actions", "act_mark");
expect(updated!.crystallizedInto).toBe(result.crystal.id);
});
it("falls back to raw text when provider returns non-JSON", async () => {
(provider.summarize as ReturnType<typeof vi.fn>).mockResolvedValue(
"Just a plain text summary with no JSON.",
);
const action = makeAction({ id: "act_nojson", status: "done" });
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::crystallize", {
actionIds: ["act_nojson"],
})) as { success: boolean; crystal: Crystal };
expect(result.success).toBe(true);
expect(result.crystal.narrative).toBe(
"Just a plain text summary with no JSON.",
);
expect(result.crystal.keyOutcomes).toEqual([]);
expect(result.crystal.filesAffected).toEqual([]);
expect(result.crystal.lessons).toEqual([]);
});
it("returns error for non-existent action", async () => {
const result = (await sdk.trigger("mem::crystallize", {
actionIds: ["act_ghost"],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("action not found: act_ghost");
});
it("returns error for non-done action", async () => {
const action = makeAction({ id: "act_pending", status: "pending" });
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::crystallize", {
actionIds: ["act_pending"],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain('status "pending"');
});
it("returns error for empty actionIds", async () => {
const result = (await sdk.trigger("mem::crystallize", {
actionIds: [],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("actionIds is required");
});
it("returns error when actionIds is missing", async () => {
const result = (await sdk.trigger("mem::crystallize", {})) as {
success: boolean;
error: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain("actionIds is required");
});
it("accepts cancelled actions", async () => {
const action = makeAction({ id: "act_cancel", status: "cancelled" });
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::crystallize", {
actionIds: ["act_cancel"],
})) as { success: boolean; crystal: Crystal };
expect(result.success).toBe(true);
expect(result.crystal.sourceActionIds).toEqual(["act_cancel"]);
});
it("crystallizes multiple actions in one call", async () => {
const a1 = makeAction({ id: "act_m1", status: "done", title: "First" });
const a2 = makeAction({ id: "act_m2", status: "done", title: "Second" });
await kv.set("mem:actions", a1.id, a1);
await kv.set("mem:actions", a2.id, a2);
const result = (await sdk.trigger("mem::crystallize", {
actionIds: ["act_m1", "act_m2"],
})) as { success: boolean; crystal: Crystal };
expect(result.success).toBe(true);
expect(result.crystal.sourceActionIds).toEqual(["act_m1", "act_m2"]);
});
it("returns failure when provider throws", async () => {
(provider.summarize as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("API down"),
);
const action = makeAction({ id: "act_fail", status: "done" });
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::crystallize", {
actionIds: ["act_fail"],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("crystallization failed");
expect(result.error).toContain("API down");
});
});
describe("mem::crystal-list", () => {
beforeEach(async () => {
const c1: Crystal = {
id: "crys_1",
narrative: "First crystal",
keyOutcomes: [],
filesAffected: [],
lessons: [],
sourceActionIds: ["act_1"],
project: "alpha",
sessionId: "sess_a",
createdAt: new Date("2025-01-01").toISOString(),
};
const c2: Crystal = {
id: "crys_2",
narrative: "Second crystal",
keyOutcomes: [],
filesAffected: [],
lessons: [],
sourceActionIds: ["act_2"],
project: "beta",
sessionId: "sess_b",
createdAt: new Date("2025-02-01").toISOString(),
};
const c3: Crystal = {
id: "crys_3",
narrative: "Third crystal",
keyOutcomes: [],
filesAffected: [],
lessons: [],
sourceActionIds: ["act_3"],
project: "alpha",
sessionId: "sess_a",
createdAt: new Date("2025-03-01").toISOString(),
};
await kv.set("mem:crystals", c1.id, c1);
await kv.set("mem:crystals", c2.id, c2);
await kv.set("mem:crystals", c3.id, c3);
});
it("returns all crystals sorted by createdAt desc", async () => {
const result = (await sdk.trigger("mem::crystal-list", {})) as {
success: boolean;
crystals: Crystal[];
};
expect(result.success).toBe(true);
expect(result.crystals.length).toBe(3);
expect(result.crystals[0].id).toBe("crys_3");
expect(result.crystals[1].id).toBe("crys_2");
expect(result.crystals[2].id).toBe("crys_1");
});
it("filters by project", async () => {
const result = (await sdk.trigger("mem::crystal-list", {
project: "alpha",
})) as { success: boolean; crystals: Crystal[] };
expect(result.success).toBe(true);
expect(result.crystals.length).toBe(2);
expect(result.crystals.every((c) => c.project === "alpha")).toBe(true);
});
it("filters by sessionId", async () => {
const result = (await sdk.trigger("mem::crystal-list", {
sessionId: "sess_b",
})) as { success: boolean; crystals: Crystal[] };
expect(result.success).toBe(true);
expect(result.crystals.length).toBe(1);
expect(result.crystals[0].id).toBe("crys_2");
});
it("respects limit", async () => {
const result = (await sdk.trigger("mem::crystal-list", {
limit: 1,
})) as { success: boolean; crystals: Crystal[] };
expect(result.success).toBe(true);
expect(result.crystals.length).toBe(1);
expect(result.crystals[0].id).toBe("crys_3");
});
});
describe("mem::crystal-get", () => {
it("returns crystal by id", async () => {
const crystal: Crystal = {
id: "crys_get_1",
narrative: "Found it",
keyOutcomes: ["yes"],
filesAffected: ["b.ts"],
lessons: ["test"],
sourceActionIds: ["act_x"],
createdAt: new Date().toISOString(),
};
await kv.set("mem:crystals", crystal.id, crystal);
const result = (await sdk.trigger("mem::crystal-get", {
crystalId: "crys_get_1",
})) as { success: boolean; crystal: Crystal };
expect(result.success).toBe(true);
expect(result.crystal.id).toBe("crys_get_1");
expect(result.crystal.narrative).toBe("Found it");
});
it("returns error for non-existent crystal", async () => {
const result = (await sdk.trigger("mem::crystal-get", {
crystalId: "crys_missing",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("crystal not found");
});
it("returns error when crystalId is missing", async () => {
const result = (await sdk.trigger("mem::crystal-get", {})) as {
success: boolean;
error: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain("crystalId is required");
});
});
describe("mem::auto-crystallize", () => {
it("returns group summaries in dryRun mode", async () => {
const action = makeAction({
id: "act_dry",
status: "done",
project: "proj",
});
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::auto-crystallize", {
dryRun: true,
})) as {
success: boolean;
dryRun: boolean;
groupCount: number;
groups: { groupKey: string; actionCount: number; actionIds: string[] }[];
crystalIds: string[];
};
expect(result.success).toBe(true);
expect(result.dryRun).toBe(true);
expect(result.groupCount).toBe(1);
expect(result.groups[0].actionIds).toContain("act_dry");
expect(result.crystalIds).toEqual([]);
});
it("groups by parentId when present", async () => {
const parent = makeAction({
id: "act_parent",
status: "done",
parentId: undefined,
});
const child1 = makeAction({
id: "act_child1",
status: "done",
parentId: "act_parent",
});
const child2 = makeAction({
id: "act_child2",
status: "done",
parentId: "act_parent",
});
await kv.set("mem:actions", parent.id, parent);
await kv.set("mem:actions", child1.id, child1);
await kv.set("mem:actions", child2.id, child2);
const result = (await sdk.trigger("mem::auto-crystallize", {
dryRun: true,
})) as {
success: boolean;
groups: { groupKey: string; actionCount: number; actionIds: string[] }[];
};
expect(result.success).toBe(true);
const parentGroup = result.groups.find((g) => g.groupKey === "act_parent");
expect(parentGroup).toBeDefined();
expect(parentGroup!.actionCount).toBe(2);
});
it("groups by project when no parentId", async () => {
const a1 = makeAction({ id: "act_proj1", status: "done", project: "webapp" });
const a2 = makeAction({ id: "act_proj2", status: "done", project: "webapp" });
const a3 = makeAction({ id: "act_proj3", status: "done", project: "api" });
await kv.set("mem:actions", a1.id, a1);
await kv.set("mem:actions", a2.id, a2);
await kv.set("mem:actions", a3.id, a3);
const result = (await sdk.trigger("mem::auto-crystallize", {
dryRun: true,
})) as {
success: boolean;
groups: { groupKey: string; actionCount: number }[];
};
expect(result.success).toBe(true);
const webGroup = result.groups.find((g) => g.groupKey === "webapp");
const apiGroup = result.groups.find((g) => g.groupKey === "api");
expect(webGroup).toBeDefined();
expect(webGroup!.actionCount).toBe(2);
expect(apiGroup).toBeDefined();
expect(apiGroup!.actionCount).toBe(1);
});
it("skips already-crystallized actions", async () => {
const action = makeAction({
id: "act_already",
status: "done",
crystallizedInto: "crys_existing",
});
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::auto-crystallize", {
dryRun: true,
})) as { success: boolean; groupCount: number };
expect(result.success).toBe(true);
expect(result.groupCount).toBe(0);
});
it("skips actions newer than threshold", async () => {
const recentAction = makeAction({
id: "act_recent",
status: "done",
createdAt: new Date().toISOString(),
});
await kv.set("mem:actions", recentAction.id, recentAction);
const result = (await sdk.trigger("mem::auto-crystallize", {
olderThanDays: 7,
dryRun: true,
})) as { success: boolean; groupCount: number };
expect(result.success).toBe(true);
expect(result.groupCount).toBe(0);
});
it("creates crystals for each group in non-dryRun mode", async () => {
const a1 = makeAction({ id: "act_auto1", status: "done", project: "proj1" });
const a2 = makeAction({ id: "act_auto2", status: "done", project: "proj2" });
await kv.set("mem:actions", a1.id, a1);
await kv.set("mem:actions", a2.id, a2);
const result = (await sdk.trigger("mem::auto-crystallize", {})) as {
success: boolean;
groupCount: number;
crystalIds: string[];
};
expect(result.success).toBe(true);
expect(result.groupCount).toBe(2);
expect(result.crystalIds.length).toBe(2);
expect(result.crystalIds[0]).toMatch(/^crys_/);
expect(result.crystalIds[1]).toMatch(/^crys_/);
});
it("filters by project when specified", async () => {
const a1 = makeAction({ id: "act_fp1", status: "done", project: "keep" });
const a2 = makeAction({ id: "act_fp2", status: "done", project: "skip" });
await kv.set("mem:actions", a1.id, a1);
await kv.set("mem:actions", a2.id, a2);
const result = (await sdk.trigger("mem::auto-crystallize", {
project: "keep",
dryRun: true,
})) as {
success: boolean;
groupCount: number;
groups: { groupKey: string; actionCount: number }[];
};
expect(result.success).toBe(true);
expect(result.groupCount).toBe(1);
expect(result.groups[0].groupKey).toBe("keep");
});
it("returns empty when no qualifying actions exist", async () => {
const result = (await sdk.trigger("mem::auto-crystallize", {})) as {
success: boolean;
groupCount: number;
crystalIds: string[];
};
expect(result.success).toBe(true);
expect(result.groupCount).toBe(0);
expect(result.crystalIds).toEqual([]);
});
});
});
+300
View File
@@ -0,0 +1,300 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import {
registerSmartSearchFunction,
getFollowupStats,
resetFollowupStatsForTests,
flushPendingFollowups,
} from "../src/functions/smart-search.js";
import { registerRecentSearchesSweepFunction } from "../src/functions/recent-searches-sweep.js";
import { KV } from "../src/state/schema.js";
import type { HybridSearchResult } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> =>
Array.from(store.get(scope)?.values() ?? []) as T[],
getStore: () => store,
};
}
function mockSdk(kv: ReturnType<typeof mockKV>) {
const functions = new Map<string, Function>();
const sdk = {
registerFunction: (
idOrOpts: string | { id: string },
handler: Function,
) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload?: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload =
typeof idOrInput === "string" ? data : (idOrInput as any).payload;
const fn = functions.get(id);
if (!fn) {
if (id === "mem::lesson-recall") return { success: true, lessons: [] };
throw new Error(`No function: ${id}`);
}
const result = await fn(payload);
// smart-search now runs followup detection off the critical
// response path; drain it before returning so test assertions
// see consistent state.
if (id === "mem::smart-search") await flushPendingFollowups();
return result;
},
} as any;
void kv;
return sdk;
}
function makeHit(obsId: string, sessionId = "ses_1"): HybridSearchResult {
return {
observation: {
id: obsId,
sessionId,
timestamp: new Date().toISOString(),
title: `obs ${obsId}`,
narrative: "n",
type: "pattern",
concepts: [],
files: [],
} as any,
sessionId,
combinedScore: 0.8,
} as HybridSearchResult;
}
describe("Smart-search followup-rate diagnostic (#771)", () => {
let sdk: any;
let kv: ReturnType<typeof mockKV>;
let searchResults: HybridSearchResult[];
beforeEach(() => {
delete process.env.AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS;
resetFollowupStatsForTests();
kv = mockKV();
sdk = mockSdk(kv);
searchResults = [];
registerSmartSearchFunction(sdk, kv as any, async () => searchResults);
registerRecentSearchesSweepFunction(sdk, kv as any);
});
afterEach(() => {
delete process.env.AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS;
});
it("records the first agent-initiated search but does not flag it as a followup", async () => {
searchResults = [makeHit("obs_a"), makeHit("obs_b")];
await sdk.trigger("mem::smart-search", {
query: "auth flow",
sessionId: "ses_1",
});
const stats = getFollowupStats();
expect(stats.agentInitiatedSearches).toBe(1);
expect(stats.followupWithinWindow).toBe(0);
const stored = await kv.get(KV.recentSearches, "ses_1");
expect(stored).not.toBeNull();
expect((stored as any).sessionId).toBe("ses_1");
expect((stored as any).query).toBe("auth flow");
});
it("flags a follow-up when the second search inside the window returns a disjoint set", async () => {
searchResults = [makeHit("obs_a"), makeHit("obs_b")];
await sdk.trigger("mem::smart-search", {
query: "auth flow",
sessionId: "ses_1",
});
searchResults = [makeHit("obs_c"), makeHit("obs_d")];
await sdk.trigger("mem::smart-search", {
query: "token expiry handling",
sessionId: "ses_1",
});
const stats = getFollowupStats();
expect(stats.agentInitiatedSearches).toBe(2);
expect(stats.followupWithinWindow).toBe(1);
expect(stats.rate).toBeCloseTo(0.5);
});
it("does not flag a follow-up when result sets overlap", async () => {
searchResults = [makeHit("obs_a"), makeHit("obs_b")];
await sdk.trigger("mem::smart-search", {
query: "auth flow",
sessionId: "ses_1",
});
searchResults = [makeHit("obs_b"), makeHit("obs_c")];
await sdk.trigger("mem::smart-search", {
query: "auth token",
sessionId: "ses_1",
});
const stats = getFollowupStats();
expect(stats.followupWithinWindow).toBe(0);
});
it("does not flag a follow-up on an identical re-query (retry, not follow-up)", async () => {
searchResults = [makeHit("obs_a")];
await sdk.trigger("mem::smart-search", {
query: "auth flow",
sessionId: "ses_1",
});
// Different result set on the retry (e.g. flaky search index), but
// same query — still not a follow-up.
searchResults = [makeHit("obs_b")];
await sdk.trigger("mem::smart-search", {
query: "auth flow",
sessionId: "ses_1",
});
expect(getFollowupStats().followupWithinWindow).toBe(0);
});
it("does not flag a follow-up when prior search is outside the window", async () => {
process.env.AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS = "1";
searchResults = [makeHit("obs_a")];
await sdk.trigger("mem::smart-search", {
query: "first",
sessionId: "ses_1",
});
// Backdate the stored search so the window has elapsed.
const stored = (await kv.get(KV.recentSearches, "ses_1")) as any;
stored.at = Date.now() - 5_000;
await kv.set(KV.recentSearches, "ses_1", stored);
searchResults = [makeHit("obs_b")];
await sdk.trigger("mem::smart-search", {
query: "second",
sessionId: "ses_1",
});
expect(getFollowupStats().followupWithinWindow).toBe(0);
});
it("skips viewer-originated searches (source === 'viewer')", async () => {
searchResults = [makeHit("obs_a")];
await sdk.trigger("mem::smart-search", {
query: "from viewer",
sessionId: "ses_1",
source: "viewer",
});
expect(getFollowupStats().agentInitiatedSearches).toBe(0);
// Viewer call shouldn't write to recent-searches either, otherwise
// a subsequent agent call would treat the viewer search as prior.
expect(await kv.get(KV.recentSearches, "ses_1")).toBeNull();
});
it("skips searches without a sessionId (direct sdk callers)", async () => {
searchResults = [makeHit("obs_a")];
await sdk.trigger("mem::smart-search", { query: "no session" });
expect(getFollowupStats().agentInitiatedSearches).toBe(0);
});
it("recent-searches sweep deletes rows older than 24h, keeps fresh ones", async () => {
const fresh = {
sessionId: "ses_fresh",
query: "x",
resultIds: [],
at: Date.now() - 1_000,
};
const stale = {
sessionId: "ses_stale",
query: "x",
resultIds: [],
at: Date.now() - 25 * 60 * 60 * 1000,
};
await kv.set(KV.recentSearches, fresh.sessionId, fresh);
await kv.set(KV.recentSearches, stale.sessionId, stale);
const result = (await sdk.trigger(
"mem::diagnostic::recent-searches-sweep",
{},
)) as { swept: number };
expect(result.swept).toBe(1);
expect(await kv.get(KV.recentSearches, "ses_fresh")).not.toBeNull();
expect(await kv.get(KV.recentSearches, "ses_stale")).toBeNull();
});
it("skips detection when current results are empty (retrieval failure, not reader failure)", async () => {
searchResults = [makeHit("obs_a"), makeHit("obs_b")];
await sdk.trigger("mem::smart-search", {
query: "first",
sessionId: "ses_1",
});
// Empty result set on the next call. Without the empty-skip guard
// the empty-vs-prior comparison would be vacuously "disjoint" and
// inflate the rate. Skip detection entirely.
searchResults = [];
await sdk.trigger("mem::smart-search", {
query: "second",
sessionId: "ses_1",
});
const stats = getFollowupStats();
// Only the first call counts as agent-initiated; the empty-result
// second call is skipped entirely.
expect(stats.agentInitiatedSearches).toBe(1);
expect(stats.followupWithinWindow).toBe(0);
});
it("followup-stats function returns the configured window and live counts", async () => {
process.env.AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS = "45";
searchResults = [makeHit("obs_a")];
await sdk.trigger("mem::smart-search", {
query: "q1",
sessionId: "ses_1",
});
searchResults = [makeHit("obs_b")];
await sdk.trigger("mem::smart-search", {
query: "q2",
sessionId: "ses_1",
});
const stats = (await sdk.trigger(
"mem::diagnostic::followup-stats",
{},
)) as {
success: boolean;
windowSeconds: number;
agentInitiatedSearches: number;
followupWithinWindow: number;
rate: number;
};
expect(stats.success).toBe(true);
expect(stats.windowSeconds).toBe(45);
expect(stats.agentInitiatedSearches).toBe(2);
expect(stats.followupWithinWindow).toBe(1);
expect(stats.rate).toBeCloseTo(0.5);
});
});
+868
View File
@@ -0,0 +1,868 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerDiagnosticsFunction } from "../src/functions/diagnostics.js";
import type {
Action,
ActionEdge,
DiagnosticCheck,
Lease,
Sentinel,
Sketch,
Signal,
Session,
Memory,
MeshPeer,
} from "../src/types.js";
import { KV } from "../src/state/schema.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeAction(overrides: Partial<Action> = {}): Action {
return {
id: `act_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
title: "Test action",
description: "",
status: "pending",
priority: 5,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: "agent-1",
tags: [],
sourceObservationIds: [],
sourceMemoryIds: [],
...overrides,
};
}
function makeLease(overrides: Partial<Lease> = {}): Lease {
return {
id: `lease_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
actionId: "act_missing",
agentId: "agent-1",
acquiredAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
status: "active",
...overrides,
};
}
function makeEdge(overrides: Partial<ActionEdge> = {}): ActionEdge {
return {
id: `ae_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
type: "requires",
sourceActionId: "src",
targetActionId: "tgt",
createdAt: new Date().toISOString(),
...overrides,
};
}
function makeSentinel(overrides: Partial<Sentinel> = {}): Sentinel {
return {
id: `sen_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
name: "Test sentinel",
type: "timer",
status: "watching",
config: {},
createdAt: new Date().toISOString(),
linkedActionIds: [],
...overrides,
};
}
function makeSketch(overrides: Partial<Sketch> = {}): Sketch {
return {
id: `sk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
title: "Test sketch",
description: "",
status: "active",
actionIds: [],
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
...overrides,
};
}
function makeSignal(overrides: Partial<Signal> = {}): Signal {
return {
id: `sig_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
from: "agent-1",
type: "info",
content: "test",
createdAt: new Date().toISOString(),
...overrides,
};
}
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: `ses_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
project: "test",
cwd: "/tmp",
startedAt: new Date().toISOString(),
status: "active",
observationCount: 0,
...overrides,
};
}
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: `mem_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "fact",
title: "Test memory",
content: "content",
concepts: [],
files: [],
sessionIds: [],
strength: 1,
version: 1,
isLatest: true,
...overrides,
};
}
function makePeer(overrides: Partial<MeshPeer> = {}): MeshPeer {
return {
id: `peer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
url: "http://localhost:3111",
name: "Test peer",
status: "connected",
sharedScopes: [],
...overrides,
};
}
describe("Diagnostics Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerDiagnosticsFunction(sdk as never, kv as never);
});
describe("mem::diagnose", () => {
it("empty system passes all checks", async () => {
const result = (await sdk.trigger("mem::diagnose", {})) as {
success: boolean;
checks: DiagnosticCheck[];
summary: { pass: number; warn: number; fail: number; fixable: number };
};
expect(result.success).toBe(true);
// 15 = 8 original (actions, leases, sentinels, sketches, signals,
// sessions, memories, mesh) + 6 added in #lesson-visibility
// (lessons, summaries, semantic, procedural, crystals, insights) +
// 1 added in #memory-project-scope (memory-project-coverage).
expect(result.summary.pass).toBe(15);
expect(result.summary.warn).toBe(0);
expect(result.summary.fail).toBe(0);
expect(result.summary.fixable).toBe(0);
expect(result.checks.every((c) => c.status === "pass")).toBe(true);
});
it("active action with no lease produces warn", async () => {
const action = makeAction({ status: "active" });
await kv.set(KV.actions, action.id, action);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["actions"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("active-no-lease:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("warn");
expect(check!.fixable).toBe(false);
});
it("blocked action with all deps done produces fail (fixable)", async () => {
const dep = makeAction({ status: "done" });
const blocked = makeAction({ status: "blocked" });
const edge = makeEdge({
sourceActionId: blocked.id,
targetActionId: dep.id,
type: "requires",
});
await kv.set(KV.actions, dep.id, dep);
await kv.set(KV.actions, blocked.id, blocked);
await kv.set(KV.actionEdges, edge.id, edge);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["actions"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("blocked-deps-done:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("fail");
expect(check!.fixable).toBe(true);
});
it("pending action with unsatisfied deps produces fail (fixable)", async () => {
const dep = makeAction({ status: "active" });
const pending = makeAction({ status: "pending" });
const edge = makeEdge({
sourceActionId: pending.id,
targetActionId: dep.id,
type: "requires",
});
await kv.set(KV.actions, dep.id, dep);
await kv.set(KV.actions, pending.id, pending);
await kv.set(KV.actionEdges, edge.id, edge);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["actions"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("pending-unsatisfied-deps:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("fail");
expect(check!.fixable).toBe(true);
});
it("expired active lease produces fail (fixable)", async () => {
const action = makeAction({ status: "active" });
const lease = makeLease({
actionId: action.id,
status: "active",
expiresAt: new Date(Date.now() - 60_000).toISOString(),
});
await kv.set(KV.actions, action.id, action);
await kv.set(KV.leases, lease.id, lease);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["leases"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("expired-lease:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("fail");
expect(check!.fixable).toBe(true);
});
it("orphaned lease (action gone) produces fail (fixable)", async () => {
const lease = makeLease({
actionId: "act_gone",
status: "active",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
await kv.set(KV.leases, lease.id, lease);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["leases"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("orphaned-lease:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("fail");
expect(check!.fixable).toBe(true);
});
it("expired watching sentinel produces fail (fixable)", async () => {
const sentinel = makeSentinel({
status: "watching",
expiresAt: new Date(Date.now() - 60_000).toISOString(),
});
await kv.set(KV.sentinels, sentinel.id, sentinel);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["sentinels"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("expired-sentinel:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("fail");
expect(check!.fixable).toBe(true);
});
it("sentinel referencing missing action produces warn", async () => {
const sentinel = makeSentinel({
linkedActionIds: ["act_nonexistent"],
});
await kv.set(KV.sentinels, sentinel.id, sentinel);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["sentinels"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("sentinel-missing-action:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("warn");
expect(check!.fixable).toBe(false);
});
it("expired active sketch produces fail (fixable)", async () => {
const sketch = makeSketch({
status: "active",
expiresAt: new Date(Date.now() - 60_000).toISOString(),
});
await kv.set(KV.sketches, sketch.id, sketch);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["sketches"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("expired-sketch:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("fail");
expect(check!.fixable).toBe(true);
});
it("expired signal produces fail (fixable)", async () => {
const signal = makeSignal({
expiresAt: new Date(Date.now() - 60_000).toISOString(),
});
await kv.set(KV.signals, signal.id, signal);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["signals"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("expired-signal:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("fail");
expect(check!.fixable).toBe(true);
});
it("active session older than 24h produces warn", async () => {
const session = makeSession({
status: "active",
startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
});
await kv.set(KV.sessions, session.id, session);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["sessions"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("abandoned-session:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("warn");
expect(check!.fixable).toBe(false);
});
it("memory with stale isLatest produces fail (fixable)", async () => {
const oldMemory = makeMemory({ isLatest: true });
const newMemory = makeMemory({ supersedes: [oldMemory.id] });
await kv.set(KV.memories, oldMemory.id, oldMemory);
await kv.set(KV.memories, newMemory.id, newMemory);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["memories"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("memory-stale-latest:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("fail");
expect(check!.fixable).toBe(true);
});
it("memory superseding non-existent produces warn", async () => {
const memory = makeMemory({ supersedes: ["mem_gone"] });
await kv.set(KV.memories, memory.id, memory);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["memories"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("memory-missing-supersedes:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("warn");
expect(check!.fixable).toBe(false);
});
it("stale mesh peer produces warn", async () => {
const peer = makePeer({
lastSyncAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
});
await kv.set(KV.mesh, peer.id, peer);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["mesh"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("stale-peer:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("warn");
expect(check!.fixable).toBe(false);
});
it("error mesh peer produces warn", async () => {
const peer = makePeer({ status: "error" });
await kv.set(KV.mesh, peer.id, peer);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["mesh"],
})) as { checks: DiagnosticCheck[] };
const check = result.checks.find((c) =>
c.name.startsWith("error-peer:"),
);
expect(check).toBeDefined();
expect(check!.status).toBe("warn");
expect(check!.fixable).toBe(false);
});
it("filters by categories", async () => {
const action = makeAction({ status: "active" });
await kv.set(KV.actions, action.id, action);
const signal = makeSignal({
expiresAt: new Date(Date.now() - 60_000).toISOString(),
});
await kv.set(KV.signals, signal.id, signal);
const result = (await sdk.trigger("mem::diagnose", {
categories: ["signals"],
})) as { checks: DiagnosticCheck[] };
expect(result.checks.every((c) => c.category === "signals")).toBe(true);
expect(
result.checks.some((c) => c.category === "actions"),
).toBe(false);
});
});
describe("mem::heal", () => {
it("unblocks stuck blocked action", async () => {
const dep = makeAction({ status: "done" });
const blocked = makeAction({ status: "blocked", title: "Stuck task" });
const edge = makeEdge({
sourceActionId: blocked.id,
targetActionId: dep.id,
type: "requires",
});
await kv.set(KV.actions, dep.id, dep);
await kv.set(KV.actions, blocked.id, blocked);
await kv.set(KV.actionEdges, edge.id, edge);
const result = (await sdk.trigger("mem::heal", {
categories: ["actions"],
})) as { success: boolean; fixed: number; details: string[] };
expect(result.success).toBe(true);
expect(result.fixed).toBe(1);
expect(result.details[0]).toContain("Unblocked");
const updated = await kv.get<Action>(KV.actions, blocked.id);
expect(updated!.status).toBe("pending");
});
it("blocks pending action with unsatisfied deps", async () => {
const dep = makeAction({ status: "active" });
const pending = makeAction({
status: "pending",
title: "Should be blocked",
});
const edge = makeEdge({
sourceActionId: pending.id,
targetActionId: dep.id,
type: "requires",
});
await kv.set(KV.actions, dep.id, dep);
await kv.set(KV.actions, pending.id, pending);
await kv.set(KV.actionEdges, edge.id, edge);
const result = (await sdk.trigger("mem::heal", {
categories: ["actions"],
})) as { success: boolean; fixed: number; details: string[] };
expect(result.success).toBe(true);
expect(result.fixed).toBe(1);
expect(result.details[0]).toContain("Blocked");
const updated = await kv.get<Action>(KV.actions, pending.id);
expect(updated!.status).toBe("blocked");
});
it("expires stale lease and resets action", async () => {
const action = makeAction({
status: "active",
assignedTo: "agent-1",
});
const lease = makeLease({
actionId: action.id,
agentId: "agent-1",
status: "active",
expiresAt: new Date(Date.now() - 60_000).toISOString(),
});
await kv.set(KV.actions, action.id, action);
await kv.set(KV.leases, lease.id, lease);
const result = (await sdk.trigger("mem::heal", {
categories: ["leases"],
})) as { success: boolean; fixed: number; details: string[] };
expect(result.success).toBe(true);
expect(result.fixed).toBe(1);
expect(result.details[0]).toContain("Expired lease");
const updatedLease = await kv.get<Lease>(KV.leases, lease.id);
expect(updatedLease!.status).toBe("expired");
const updatedAction = await kv.get<Action>(KV.actions, action.id);
expect(updatedAction!.status).toBe("pending");
expect(updatedAction!.assignedTo).toBeUndefined();
});
it("deletes orphaned lease", async () => {
const lease = makeLease({
actionId: "act_gone",
status: "released",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
await kv.set(KV.leases, lease.id, lease);
const result = (await sdk.trigger("mem::heal", {
categories: ["leases"],
})) as { success: boolean; fixed: number; details: string[] };
expect(result.success).toBe(true);
expect(result.fixed).toBe(1);
expect(result.details[0]).toContain("Deleted orphaned lease");
const deleted = await kv.get<Lease>(KV.leases, lease.id);
expect(deleted).toBeNull();
});
it("expires stale sentinel", async () => {
const sentinel = makeSentinel({
status: "watching",
name: "Stale watcher",
expiresAt: new Date(Date.now() - 60_000).toISOString(),
});
await kv.set(KV.sentinels, sentinel.id, sentinel);
const result = (await sdk.trigger("mem::heal", {
categories: ["sentinels"],
})) as { success: boolean; fixed: number; details: string[] };
expect(result.success).toBe(true);
expect(result.fixed).toBe(1);
expect(result.details[0]).toContain("Expired sentinel");
const updated = await kv.get<Sentinel>(KV.sentinels, sentinel.id);
expect(updated!.status).toBe("expired");
});
it("dry run reports but does not fix", async () => {
const dep = makeAction({ status: "done" });
const blocked = makeAction({ status: "blocked", title: "Stuck task" });
const edge = makeEdge({
sourceActionId: blocked.id,
targetActionId: dep.id,
type: "requires",
});
await kv.set(KV.actions, dep.id, dep);
await kv.set(KV.actions, blocked.id, blocked);
await kv.set(KV.actionEdges, edge.id, edge);
const result = (await sdk.trigger("mem::heal", {
categories: ["actions"],
dryRun: true,
})) as { success: boolean; fixed: number; details: string[] };
expect(result.success).toBe(true);
expect(result.fixed).toBe(1);
expect(result.details[0]).toContain("[dry-run]");
const unchanged = await kv.get<Action>(KV.actions, blocked.id);
expect(unchanged!.status).toBe("blocked");
});
});
describe("per-store tally categories (#lesson-visibility)", () => {
it("lessons category: passes with valid live lessons + ignores tombstoned", async () => {
await kv.set(KV.lessons, "lsn_live", {
id: "lsn_live", content: "x", context: "", confidence: 0.8,
reinforcements: 0, source: "manual", sourceIds: [], tags: [],
createdAt: "", updatedAt: "", decayRate: 0.05,
});
await kv.set(KV.lessons, "lsn_tomb", {
id: "lsn_tomb", content: "x", context: "", confidence: 0.5,
reinforcements: 0, source: "manual", sourceIds: [], tags: [],
createdAt: "", updatedAt: "", decayRate: 0.05, deleted: true,
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["lessons"],
})) as { checks: DiagnosticCheck[] };
const ok = result.checks.find((c) => c.name === "lessons-ok");
expect(ok?.status).toBe("pass");
expect(ok?.message).toMatch(/All 1 lessons.*1 tombstoned/);
});
it("lessons category: warns on out-of-range confidence", async () => {
await kv.set(KV.lessons, "lsn_bad", {
id: "lsn_bad", content: "x", context: "", confidence: 1.5,
reinforcements: 0, source: "manual", sourceIds: [], tags: [],
createdAt: "", updatedAt: "", decayRate: 0.05,
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["lessons"],
})) as { checks: DiagnosticCheck[] };
const warn = result.checks.find((c) => c.name.startsWith("lesson-bad-confidence:"));
expect(warn?.status).toBe("warn");
});
it("summaries category: warns on missing title", async () => {
await kv.set(KV.summaries, "ses_1", {
sessionId: "ses_1", project: "p", createdAt: "", title: "",
narrative: "n", keyDecisions: [], filesModified: [], concepts: [],
observationCount: 1,
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["summaries"],
})) as { checks: DiagnosticCheck[] };
const warn = result.checks.find((c) => c.name.startsWith("summary-missing-title:"));
expect(warn?.status).toBe("warn");
});
it("procedural category: warns on empty steps", async () => {
await kv.set(KV.procedural, "proc_1", {
id: "proc_1", name: "noop", steps: [], triggerCondition: "x",
frequency: 1, sourceSessionIds: [], strength: 0.5,
createdAt: "", updatedAt: "",
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["procedural"],
})) as { checks: DiagnosticCheck[] };
const warn = result.checks.find((c) => c.name.startsWith("procedural-empty-steps:"));
expect(warn?.status).toBe("warn");
});
it("crystals category: warns on empty narrative", async () => {
await kv.set(KV.crystals, "cry_1", {
id: "cry_1", narrative: "", keyOutcomes: [], filesAffected: [],
lessons: [], sourceActionIds: [], createdAt: "",
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["crystals"],
})) as { checks: DiagnosticCheck[] };
const warn = result.checks.find((c) => c.name.startsWith("crystal-empty-narrative:"));
expect(warn?.status).toBe("warn");
});
it("insights category: warns on out-of-range confidence", async () => {
await kv.set(KV.insights, "ins_bad", {
id: "ins_bad", title: "t", content: "c", confidence: -0.1,
reinforcements: 0, sourceConceptCluster: [], sourceMemoryIds: [],
sourceLessonIds: [], sourceCrystalIds: [], tags: [],
createdAt: "", updatedAt: "", decayRate: 0.05,
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["insights"],
})) as { checks: DiagnosticCheck[] };
const warn = result.checks.find((c) => c.name.startsWith("insight-bad-confidence:"));
expect(warn?.status).toBe("warn");
});
it("semantic category: warns on out-of-range confidence", async () => {
await kv.set(KV.semantic, "sem_bad", {
id: "sem_bad", fact: "f", confidence: 2.0, sourceSessionIds: [],
sourceMemoryIds: [], accessCount: 0, lastAccessedAt: "",
strength: 0, createdAt: "", updatedAt: "",
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["semantic"],
})) as { checks: DiagnosticCheck[] };
const warn = result.checks.find((c) => c.name.startsWith("semantic-bad-confidence:"));
expect(warn?.status).toBe("warn");
});
it("categories filter accepts new categories and skips others", async () => {
const result = (await sdk.trigger("mem::diagnose", {
categories: ["lessons", "summaries"],
})) as { checks: DiagnosticCheck[] };
expect(result.checks.every((c) => c.category === "lessons" || c.category === "summaries")).toBe(true);
expect(result.checks.some((c) => c.category === "lessons")).toBe(true);
expect(result.checks.some((c) => c.category === "summaries")).toBe(true);
});
describe("defensive row-shape handling (CodeRabbit #473 review)", () => {
it("NaN/Infinity confidence on a lesson is flagged as warn, not silently passed", async () => {
await kv.set(KV.lessons, "lsn_nan", {
id: "lsn_nan", content: "x", context: "", confidence: NaN,
reinforcements: 0, source: "manual", sourceIds: [], tags: [],
createdAt: "", updatedAt: "", decayRate: 0.05,
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["lessons"],
})) as { checks: DiagnosticCheck[] };
const warn = result.checks.find((c) => c.name.startsWith("lesson-bad-confidence:"));
expect(warn?.status).toBe("warn");
});
it("non-string summary title doesn't throw — surfaces as warn", async () => {
await kv.set(KV.summaries, "ses_bad_title", {
sessionId: "ses_bad_title",
project: "p",
createdAt: "",
title: null as unknown as string, // simulate corrupted row
narrative: "n",
keyDecisions: [],
filesModified: [],
concepts: [],
observationCount: 1,
});
// The bug to guard against: the old code called .trim() unconditionally,
// which throws on null/number, which aborts the whole diagnose run and
// any later category check never executes. Verify diagnose completes
// AND surfaces the bad row.
const result = (await sdk.trigger("mem::diagnose", {
categories: ["summaries", "lessons"],
})) as { checks: DiagnosticCheck[]; success?: boolean };
expect(result.success).toBe(true);
const warn = result.checks.find((c) => c.name.startsWith("summary-missing-title:"));
expect(warn?.status).toBe("warn");
// Later category still ran:
expect(result.checks.some((c) => c.category === "lessons")).toBe(true);
});
it("non-string crystal narrative doesn't throw — surfaces as warn", async () => {
await kv.set(KV.crystals, "cry_bad", {
id: "cry_bad",
narrative: undefined as unknown as string,
keyOutcomes: [],
filesAffected: [],
lessons: [],
sourceActionIds: [],
createdAt: "",
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["crystals"],
})) as { checks: DiagnosticCheck[]; success?: boolean };
expect(result.success).toBe(true);
const warn = result.checks.find((c) => c.name.startsWith("crystal-empty-narrative:"));
expect(warn?.status).toBe("warn");
});
it("Infinity confidence on insight + semantic both flagged", async () => {
await kv.set(KV.insights, "ins_inf", {
id: "ins_inf",
title: "t",
content: "c",
confidence: Infinity,
reinforcements: 0,
sourceConceptCluster: [],
sourceMemoryIds: [],
sourceLessonIds: [],
sourceCrystalIds: [],
tags: [],
createdAt: "",
updatedAt: "",
decayRate: 0.05,
});
await kv.set(KV.semantic, "sem_nan", {
id: "sem_nan",
fact: "f",
confidence: NaN,
sourceSessionIds: [],
sourceMemoryIds: [],
accessCount: 0,
lastAccessedAt: "",
strength: 0,
createdAt: "",
updatedAt: "",
});
const result = (await sdk.trigger("mem::diagnose", {
categories: ["insights", "semantic"],
})) as { checks: DiagnosticCheck[] };
expect(result.checks.find((c) => c.name === "insight-bad-confidence:ins_inf")?.status).toBe("warn");
expect(result.checks.find((c) => c.name === "semantic-bad-confidence:sem_nan")?.status).toBe("warn");
});
});
});
});
+252
View File
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
createEmbeddingProvider,
withDimensionGuard,
} from "../src/providers/embedding/index.js";
import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js";
import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js";
import type { EmbeddingProvider } from "../src/types.js";
describe("createEmbeddingProvider", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env["GEMINI_API_KEY"];
delete process.env["OPENAI_API_KEY"];
delete process.env["VOYAGE_API_KEY"];
delete process.env["COHERE_API_KEY"];
delete process.env["OPENROUTER_API_KEY"];
delete process.env["EMBEDDING_PROVIDER"];
});
afterEach(() => {
process.env = originalEnv;
});
it("returns null when no API keys are set", () => {
const provider = createEmbeddingProvider();
expect(provider).toBeNull();
});
it("returns GeminiEmbeddingProvider when GEMINI_API_KEY is set", () => {
process.env["GEMINI_API_KEY"] = "test-key-123";
const provider = createEmbeddingProvider();
expect(provider).toBeInstanceOf(GeminiEmbeddingProvider);
expect(provider!.name).toBe("gemini");
});
it("returns OpenAIEmbeddingProvider when OPENAI_API_KEY is set", () => {
process.env["OPENAI_API_KEY"] = "test-key-456";
const provider = createEmbeddingProvider();
expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider);
expect(provider!.name).toBe("openai");
});
it("EMBEDDING_PROVIDER override takes precedence", () => {
process.env["GEMINI_API_KEY"] = "test-key-123";
process.env["OPENAI_API_KEY"] = "test-key-456";
process.env["EMBEDDING_PROVIDER"] = "openai";
const provider = createEmbeddingProvider();
expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider);
});
});
describe("OpenAIEmbeddingProvider", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env["OPENAI_BASE_URL"];
delete process.env["OPENAI_EMBEDDING_BASE_URL"];
delete process.env["OPENAI_EMBEDDING_API_KEY"];
delete process.env["OPENAI_EMBEDDING_MODEL"];
delete process.env["OPENAI_EMBEDDING_DIMENSIONS"];
});
afterEach(() => {
process.env = originalEnv;
});
it("uses default base URL and model when env vars are not set", () => {
const provider = new OpenAIEmbeddingProvider("test-key");
expect(provider.name).toBe("openai");
expect(provider.dimensions).toBe(1536);
});
it("throws when no API key is provided", () => {
delete process.env["OPENAI_API_KEY"];
delete process.env["OPENAI_EMBEDDING_API_KEY"];
expect(() => new OpenAIEmbeddingProvider()).toThrow(/API key is required.*OPENAI_EMBEDDING_API_KEY.*OPENAI_API_KEY/);
});
it("respects OPENAI_BASE_URL env var", async () => {
process.env["OPENAI_BASE_URL"] = "https://my-proxy.example.com";
const provider = new OpenAIEmbeddingProvider("test-key");
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), { status: 200 }),
);
await provider.embed("hello");
expect(fetchSpy).toHaveBeenCalledWith(
"https://my-proxy.example.com/v1/embeddings",
expect.any(Object),
);
fetchSpy.mockRestore();
});
it("respects OPENAI_EMBEDDING_MODEL env var", async () => {
process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-3-large";
const provider = new OpenAIEmbeddingProvider("test-key");
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), { status: 200 }),
);
await provider.embed("hello");
const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string);
expect(body.model).toBe("text-embedding-3-large");
fetchSpy.mockRestore();
});
it("derives dimensions from model in the known-models table", () => {
process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-3-large";
const large = new OpenAIEmbeddingProvider("test-key");
expect(large.dimensions).toBe(3072);
process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-ada-002";
const ada = new OpenAIEmbeddingProvider("test-key");
expect(ada.dimensions).toBe(1536);
process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-3-small";
const small = new OpenAIEmbeddingProvider("test-key");
expect(small.dimensions).toBe(1536);
});
it("OPENAI_EMBEDDING_DIMENSIONS overrides the model-derived dimensions", () => {
process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-3-large";
process.env["OPENAI_EMBEDDING_DIMENSIONS"] = "768";
const provider = new OpenAIEmbeddingProvider("test-key");
expect(provider.dimensions).toBe(768);
});
it("falls back to 1536 for unknown custom models", () => {
process.env["OPENAI_EMBEDDING_MODEL"] = "mystery-self-hosted-model";
const provider = new OpenAIEmbeddingProvider("test-key");
expect(provider.dimensions).toBe(1536);
});
it("rejects invalid OPENAI_EMBEDDING_DIMENSIONS values", () => {
process.env["OPENAI_EMBEDDING_DIMENSIONS"] = "not-a-number";
expect(() => new OpenAIEmbeddingProvider("test-key")).toThrow(
/OPENAI_EMBEDDING_DIMENSIONS must be a positive integer/,
);
process.env["OPENAI_EMBEDDING_DIMENSIONS"] = "-5";
expect(() => new OpenAIEmbeddingProvider("test-key")).toThrow(
/OPENAI_EMBEDDING_DIMENSIONS must be a positive integer/,
);
process.env["OPENAI_EMBEDDING_DIMENSIONS"] = "0";
expect(() => new OpenAIEmbeddingProvider("test-key")).toThrow(
/OPENAI_EMBEDDING_DIMENSIONS must be a positive integer/,
);
});
});
describe("withDimensionGuard", () => {
function fakeProvider(opts: {
dimensions: number;
embed: () => Float32Array;
batch?: () => Float32Array[];
image?: () => Float32Array;
}): EmbeddingProvider {
const provider: EmbeddingProvider = {
name: "fake",
dimensions: opts.dimensions,
embed: async () => opts.embed(),
embedBatch: async () => opts.batch?.() ?? [opts.embed()],
};
if (opts.image) provider.embedImage = async () => opts.image!();
return provider;
}
it("preserves the wrapped provider's prototype so instanceof keeps working", async () => {
class FakeProvider implements EmbeddingProvider {
readonly name = "fake-class";
readonly dimensions = 4;
async embed(): Promise<Float32Array> {
return new Float32Array([1, 2, 3, 4]);
}
async embedBatch(): Promise<Float32Array[]> {
return [new Float32Array([1, 2, 3, 4])];
}
}
const guarded = withDimensionGuard(new FakeProvider());
expect(guarded).toBeInstanceOf(FakeProvider);
expect(guarded.name).toBe("fake-class");
expect(guarded.dimensions).toBe(4);
});
it("passes through vectors that match the declared dimensions", async () => {
const guarded = withDimensionGuard(
fakeProvider({
dimensions: 4,
embed: () => new Float32Array([1, 2, 3, 4]),
batch: () => [new Float32Array([1, 2, 3, 4]), new Float32Array([5, 6, 7, 8])],
}),
);
await expect(guarded.embed("x")).resolves.toEqual(new Float32Array([1, 2, 3, 4]));
await expect(guarded.embedBatch(["a", "b"])).resolves.toHaveLength(2);
});
it("throws when embed() returns the wrong dimension", async () => {
const guarded = withDimensionGuard(
fakeProvider({
dimensions: 4,
embed: () => new Float32Array([1, 2, 3]),
}),
);
await expect(guarded.embed("x")).rejects.toThrow(
/dimension mismatch in fake\.embed: expected 4, got 3/,
);
});
it("throws when any vector in embedBatch() returns the wrong dimension", async () => {
const guarded = withDimensionGuard(
fakeProvider({
dimensions: 4,
embed: () => new Float32Array([1, 2, 3, 4]),
batch: () => [new Float32Array([1, 2, 3, 4]), new Float32Array([1, 2])],
}),
);
await expect(guarded.embedBatch(["a", "b"])).rejects.toThrow(
/dimension mismatch in fake\.embedBatch\[1\]: expected 4, got 2/,
);
});
it("guards embedImage when present and omits it when absent", async () => {
const withImage = withDimensionGuard(
fakeProvider({
dimensions: 4,
embed: () => new Float32Array([1, 2, 3, 4]),
image: () => new Float32Array([1, 2]),
}),
);
expect(withImage.embedImage).toBeDefined();
await expect(withImage.embedImage!("/tmp/x")).rejects.toThrow(
/dimension mismatch in fake\.embedImage: expected 4, got 2/,
);
const withoutImage = withDimensionGuard(
fakeProvider({
dimensions: 4,
embed: () => new Float32Array([1, 2, 3, 4]),
}),
);
expect(withoutImage.embedImage).toBeUndefined();
});
});
+253
View File
@@ -0,0 +1,253 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerEnrichFunction } from "../src/functions/enrich.js";
import type { Memory } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
const triggerOverrides = new Map<string, Function>();
return {
registerFunction: (id: string, handler: Function) => {
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload;
if (triggerOverrides.has(id)) return triggerOverrides.get(id)!(payload);
const fn = functions.get(id);
if (!fn) throw new Error(`No function registered: ${id}`);
return fn(payload);
},
overrideTrigger: (id: string, handler: Function) => {
triggerOverrides.set(id, handler);
},
};
}
function makeBugMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: "mem_bug_1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "bug",
title: "express-jwt whitespace bug",
content: "express-jwt throws 401 when Authorization header has extra whitespace after Bearer",
concepts: ["auth", "jwt"],
files: ["src/middleware/auth.ts"],
sessionIds: ["sess-api-001"],
strength: 8,
version: 1,
isLatest: true,
...overrides,
};
}
describe("mem::enrich — project isolation for bug memories", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerEnrichFunction(sdk as never, kv as never);
sdk.overrideTrigger("mem::file-context", async () => ({ context: "" }));
sdk.overrideTrigger("mem::search", async () => ({ results: [] }));
});
it("does not surface a scoped bug memory when caller project differs", async () => {
await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: "api" }));
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-web-001",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
expect(result.context).not.toContain("agentmemory-past-errors");
expect(result.context).not.toContain("express-jwt");
});
it("surfaces a scoped bug memory when caller project matches", async () => {
await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: "api" }));
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-api-001",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
expect(result.context).toContain("agentmemory-past-errors");
expect(result.context).toContain("express-jwt");
});
it("surfaces an unscoped (legacy) bug memory regardless of caller project", async () => {
await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: undefined }));
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-web-001",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
// Unscoped memories remain visible everywhere for backward-compat
expect(result.context).toContain("agentmemory-past-errors");
expect(result.context).toContain("express-jwt");
});
it("surfaces an unscoped bug memory when caller provides no project", async () => {
await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: undefined }));
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-api-001",
files: ["src/middleware/auth.ts"],
}) as { context: string };
expect(result.context).toContain("agentmemory-past-errors");
});
it("surfaces a scoped bug memory when caller provides no project", async () => {
await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: "api" }));
// No project on the caller — guard does not engage, memory is visible
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-api-001",
files: ["src/middleware/auth.ts"],
}) as { context: string };
expect(result.context).toContain("agentmemory-past-errors");
});
it("isolates multiple memories from different projects correctly", async () => {
await kv.set("mem:memories", "mem_api", makeBugMemory({
id: "mem_api",
project: "api",
title: "express-jwt whitespace",
content: "express-jwt whitespace issue",
files: ["src/middleware/auth.ts"],
}));
await kv.set("mem:memories", "mem_web", makeBugMemory({
id: "mem_web",
project: "web",
title: "nextauth cookie",
content: "nextauth cookie domain mismatch",
files: ["src/middleware/auth.ts"],
}));
const apiResult = await sdk.trigger("mem::enrich", {
sessionId: "sess-api-001",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
expect(apiResult.context).toContain("express-jwt");
expect(apiResult.context).not.toContain("nextauth");
const webResult = await sdk.trigger("mem::enrich", {
sessionId: "sess-web-001",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
expect(webResult.context).toContain("nextauth");
expect(webResult.context).not.toContain("express-jwt");
});
it("only includes latest bug memories, respecting project scope", async () => {
await kv.set("mem:memories", "mem_old", makeBugMemory({
id: "mem_old",
project: "api",
title: "old express bug",
content: "old express auth bug now fixed",
files: ["src/middleware/auth.ts"],
isLatest: false,
}));
await kv.set("mem:memories", "mem_new", makeBugMemory({
id: "mem_new",
project: "api",
title: "new express bug",
content: "new express auth edge case",
files: ["src/middleware/auth.ts"],
isLatest: true,
}));
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-api-001",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
expect(result.context).toContain("new express bug");
expect(result.context).not.toContain("old express auth bug");
});
});
describe("mem::enrich — project forwarded to mem::search", () => {
it("passes project to the search trigger when provided", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerEnrichFunction(sdk as never, kv as never);
let capturedSearchPayload: Record<string, unknown> = {};
sdk.overrideTrigger("mem::file-context", async () => ({ context: "" }));
sdk.overrideTrigger("mem::search", async (payload: unknown) => {
capturedSearchPayload = payload as Record<string, unknown>;
return { results: [] };
});
await sdk.trigger("mem::enrich", {
sessionId: "sess-api-001",
files: ["src/middleware/auth.ts"],
project: "api",
});
expect(capturedSearchPayload.project).toBe("api");
});
it("does not pass project to search when caller provides none", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerEnrichFunction(sdk as never, kv as never);
let capturedSearchPayload: Record<string, unknown> = {};
sdk.overrideTrigger("mem::file-context", async () => ({ context: "" }));
sdk.overrideTrigger("mem::search", async (payload: unknown) => {
capturedSearchPayload = payload as Record<string, unknown>;
return { results: [] };
});
await sdk.trigger("mem::enrich", {
sessionId: "sess-api-001",
files: ["src/middleware/auth.ts"],
});
expect(capturedSearchPayload.project).toBeUndefined();
});
});
+213
View File
@@ -0,0 +1,213 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerEnrichFunction } from "../src/functions/enrich.js";
import type { Memory } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: "mem_1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "bug",
title: "Known bug",
content: "Null pointer in handler",
concepts: ["bug"],
files: ["src/handler.ts"],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
...overrides,
};
}
function mockSdk() {
const functions = new Map<string, Function>();
const triggerOverrides = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
if (triggerOverrides.has(id)) {
return triggerOverrides.get(id)!(payload);
}
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
overrideTrigger: (id: string, handler: Function) => {
triggerOverrides.set(id, handler);
},
getFunction: (id: string) => functions.get(id),
};
}
describe("Enrich Function", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerEnrichFunction(sdk as never, kv as never);
});
it("returns file context and relevant memories", async () => {
sdk.overrideTrigger(
"mem::file-context",
async () => ({ context: "File was edited in session ses_1" }),
);
sdk.overrideTrigger("mem::search", async () => ({
results: [
{ observation: { narrative: "User fixed a bug in handler" } },
],
}));
const bugMem = makeMemory({
id: "bug_1",
files: ["src/handler.ts"],
type: "bug",
});
await kv.set("mem:memories", "bug_1", bugMem);
const result = (await sdk.trigger("mem::enrich", {
sessionId: "ses_1",
files: ["src/handler.ts"],
})) as { context: string; truncated: boolean };
expect(result.context).toContain("File was edited in session ses_1");
expect(result.context).toContain("agentmemory-relevant-context");
expect(result.context).toContain("agentmemory-past-errors");
expect(result.truncated).toBe(false);
});
it("extracts terms from Grep/Glob pattern for search", async () => {
let capturedQuery = "";
sdk.overrideTrigger("mem::file-context", async () => ({ context: "" }));
sdk.overrideTrigger("mem::search", async (data: any) => {
capturedQuery = data.query;
return { results: [] };
});
await sdk.trigger("mem::enrich", {
sessionId: "ses_1",
files: ["src/utils.ts"],
terms: ["handleError"],
toolName: "Grep",
});
expect(capturedQuery).toContain("handleError");
});
it("truncates context at 4000 chars", async () => {
const longContext = "x".repeat(5000);
sdk.overrideTrigger(
"mem::file-context",
async () => ({ context: longContext }),
);
sdk.overrideTrigger("mem::search", async () => ({ results: [] }));
const result = (await sdk.trigger("mem::enrich", {
sessionId: "ses_1",
files: ["src/big.ts"],
})) as { context: string; truncated: boolean };
expect(result.context.length).toBe(4000);
expect(result.truncated).toBe(true);
});
it("returns empty context when no data found", async () => {
sdk.overrideTrigger("mem::file-context", async () => ({ context: "" }));
sdk.overrideTrigger("mem::search", async () => ({ results: [] }));
const result = (await sdk.trigger("mem::enrich", {
sessionId: "ses_1",
files: ["src/new-file.ts"],
})) as { context: string; truncated: boolean };
expect(result.context).toBe("");
expect(result.truncated).toBe(false);
});
it("handles failed triggers without crashing", async () => {
sdk.overrideTrigger("mem::file-context", async () => {
throw new Error("file-context failed");
});
sdk.overrideTrigger("mem::search", async () => {
throw new Error("search failed");
});
const result = (await sdk.trigger("mem::enrich", {
sessionId: "ses_1",
files: ["src/handler.ts"],
})) as { context: string; truncated: boolean };
expect(result.context).toBeDefined();
expect(result.truncated).toBe(false);
});
it("includes bug memories that overlap with requested files", async () => {
sdk.overrideTrigger("mem::file-context", async () => ({ context: "" }));
sdk.overrideTrigger("mem::search", async () => ({ results: [] }));
const bugMem = makeMemory({
id: "bug_match",
type: "bug",
title: "Race condition",
content: "Race condition in worker pool",
files: ["src/worker.ts"],
isLatest: true,
});
const nonBugMem = makeMemory({
id: "pattern_1",
type: "pattern",
title: "Code pattern",
content: "Singleton pattern used",
files: ["src/worker.ts"],
isLatest: true,
});
await kv.set("mem:memories", "bug_match", bugMem);
await kv.set("mem:memories", "pattern_1", nonBugMem);
const result = (await sdk.trigger("mem::enrich", {
sessionId: "ses_1",
files: ["src/worker.ts"],
})) as { context: string; truncated: boolean };
expect(result.context).toContain("Race condition");
expect(result.context).not.toContain("Singleton pattern");
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ORIGINAL_HOME = process.env["HOME"];
const ORIGINAL_USERPROFILE = process.env["USERPROFILE"];
let sandboxHome: string;
async function freshConfig() {
vi.resetModules();
return await import("../src/config.js");
}
function writeEnv(contents: string) {
const dir = join(sandboxHome, ".agentmemory");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, ".env"), contents);
}
describe("loadEnvFile", () => {
beforeEach(() => {
sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-env-"));
process.env["HOME"] = sandboxHome;
process.env["USERPROFILE"] = sandboxHome;
delete process.env["AGENTMEMORY_AUTO_COMPRESS"];
delete process.env["AGENTMEMORY_DROP_STALE_INDEX"];
delete process.env["CONSOLIDATION_ENABLED"];
delete process.env["GRAPH_EXTRACTION_ENABLED"];
delete process.env["TOKEN"];
delete process.env["HASHVAL"];
});
afterEach(() => {
if (ORIGINAL_HOME === undefined) delete process.env["HOME"];
else process.env["HOME"] = ORIGINAL_HOME;
if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"];
else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE;
rmSync(sandboxHome, { recursive: true, force: true });
});
it("strips trailing inline # comments on unquoted values", async () => {
writeEnv(
[
"AGENTMEMORY_AUTO_COMPRESS=true # opt in to LLM compression",
"CONSOLIDATION_ENABLED=true # daily summarization",
"GRAPH_EXTRACTION_ENABLED=true # entity graph",
].join("\n"),
);
const cfg = await freshConfig();
expect(cfg.isAutoCompressEnabled()).toBe(true);
expect(cfg.isConsolidationEnabled()).toBe(true);
expect(cfg.isGraphExtractionEnabled()).toBe(true);
});
it("preserves # inside double-quoted values", async () => {
writeEnv('TOKEN="abc#def"');
const cfg = await freshConfig();
expect(cfg.getEnvVar("TOKEN")).toBe("abc#def");
});
it("preserves # inside single-quoted values", async () => {
writeEnv("TOKEN='abc#def'");
const cfg = await freshConfig();
expect(cfg.getEnvVar("TOKEN")).toBe("abc#def");
});
it("treats hash without leading space as part of value", async () => {
writeEnv("HASHVAL=abc#def");
const cfg = await freshConfig();
expect(cfg.getEnvVar("HASHVAL")).toBe("abc#def");
});
it("strips inline comment after a quoted value and unwraps quotes", async () => {
writeEnv('TOKEN="abc" # trailing comment');
const cfg = await freshConfig();
expect(cfg.getEnvVar("TOKEN")).toBe("abc");
});
it("strips inline comment after a single-quoted value and unwraps quotes", async () => {
writeEnv("TOKEN='abc' # trailing comment");
const cfg = await freshConfig();
expect(cfg.getEnvVar("TOKEN")).toBe("abc");
});
it("reads AGENTMEMORY_DROP_STALE_INDEX from the env file", async () => {
writeEnv("AGENTMEMORY_DROP_STALE_INDEX=true");
const cfg = await freshConfig();
expect(cfg.isDropStaleIndexEnabled()).toBe(true);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { grepAdapter } from "../eval/runner/adapters/grep.js";
import { aggregate, scoreQuestion } from "../eval/runner/score.js";
import type { Question, Session } from "../eval/runner/types.js";
const DATA_DIR = resolve(__dirname, "..", "eval", "data", "coding-agent-life-v1");
const sessions = JSON.parse(readFileSync(`${DATA_DIR}/sessions.json`, "utf8")) as Session[];
const queries = JSON.parse(readFileSync(`${DATA_DIR}/queries.json`, "utf8")) as Array<
Omit<Question, "haystack">
>;
describe("eval scaffold", () => {
it("coding-agent-life-v1 corpus is well-formed", () => {
expect(sessions.length).toBeGreaterThan(0);
expect(queries.length).toBeGreaterThan(0);
const sessionIds = new Set(sessions.map((s) => s.id));
for (const q of queries) {
expect(q.goldSessionIds.length).toBeGreaterThan(0);
for (const id of q.goldSessionIds) {
expect(sessionIds.has(id)).toBe(true);
}
}
});
it("grep adapter ranks gold session in top-5 for most queries", async () => {
const state = await grepAdapter.init(sessions);
let hits = 0;
for (const q of queries) {
const ranked = await grepAdapter.query(q.question, state, 5);
const topIds = new Set(ranked.map((r) => r.sessionId));
if (q.goldSessionIds.some((id) => topIds.has(id))) hits += 1;
}
expect(hits / queries.length).toBeGreaterThan(0.5);
});
it("scoreQuestion computes P@K, R@K, hit, topGoldRank", () => {
const q: Question = {
id: "test",
type: "single-session",
question: "?",
goldSessionIds: ["a", "b"],
haystack: [],
};
const ranked = [
{ sessionId: "x", score: 0.9 },
{ sessionId: "a", score: 0.7 },
{ sessionId: "y", score: 0.5 },
{ sessionId: "b", score: 0.3 },
];
const row = scoreQuestion(q, ranked, 5, "test", 12);
expect(row.hit).toBe(true);
expect(row.recallAtK).toBe(1);
expect(row.precisionAtK).toBeCloseTo(2 / 5);
expect(row.topGoldRank).toBe(2);
});
it("scoreQuestion handles miss", () => {
const q: Question = {
id: "test",
type: "x",
question: "?",
goldSessionIds: ["a"],
haystack: [],
};
const ranked = [
{ sessionId: "x", score: 1 },
{ sessionId: "y", score: 0.5 },
];
const row = scoreQuestion(q, ranked, 5, "test", 5);
expect(row.hit).toBe(false);
expect(row.recallAtK).toBe(0);
expect(row.topGoldRank).toBeNull();
});
it("aggregate computes per-adapter and per-type means", () => {
const q: Question = {
id: "1",
type: "t1",
question: "?",
goldSessionIds: ["a"],
haystack: [],
};
const row1 = scoreQuestion(q, [{ sessionId: "a", score: 1 }], 5, "grep", 10);
const row2 = scoreQuestion(q, [{ sessionId: "x", score: 1 }], 5, "grep", 20);
const agg = aggregate([row1, row2]);
expect(agg.byAdapter.grep.hit).toBe(1);
expect(agg.byAdapter.grep.n).toBe(2);
expect(agg.byType.t1.grep.n).toBe(2);
});
});
+300
View File
@@ -0,0 +1,300 @@
import { describe, it, expect } from "vitest";
import {
ObserveInputSchema,
CompressOutputSchema,
SummaryOutputSchema,
SearchInputSchema,
ContextInputSchema,
RememberInputSchema,
} from "../src/eval/schemas.js";
import { validateInput, validateOutput } from "../src/eval/validator.js";
import {
scoreCompression,
scoreSummary,
scoreContextRelevance,
} from "../src/eval/quality.js";
describe("Zod Schemas", () => {
describe("ObserveInputSchema", () => {
it("accepts valid input", () => {
const result = ObserveInputSchema.safeParse({
hookType: "post_tool_use",
sessionId: "ses_abc",
project: "my-project",
cwd: "/home/user",
timestamp: "2026-01-01T00:00:00Z",
data: { tool_name: "Read" },
});
expect(result.success).toBe(true);
});
it("rejects missing sessionId", () => {
const result = ObserveInputSchema.safeParse({
hookType: "post_tool_use",
project: "my-project",
cwd: "/home/user",
timestamp: "2026-01-01T00:00:00Z",
data: {},
});
expect(result.success).toBe(false);
});
it("rejects invalid hookType", () => {
const result = ObserveInputSchema.safeParse({
hookType: "invalid_hook",
sessionId: "ses_abc",
project: "my-project",
cwd: "/home/user",
timestamp: "2026-01-01T00:00:00Z",
data: {},
});
expect(result.success).toBe(false);
});
});
describe("CompressOutputSchema", () => {
it("accepts valid output", () => {
const result = CompressOutputSchema.safeParse({
type: "file_edit",
title: "Edit auth module",
facts: ["Added JWT validation"],
narrative: "Modified the auth middleware to validate tokens",
concepts: ["auth"],
files: ["src/auth.ts"],
importance: 7,
});
expect(result.success).toBe(true);
});
it("rejects empty facts array", () => {
const result = CompressOutputSchema.safeParse({
type: "file_edit",
title: "Edit auth module",
facts: [],
narrative: "Modified the auth middleware to validate tokens",
concepts: [],
files: [],
importance: 5,
});
expect(result.success).toBe(false);
});
it("rejects title over 120 chars", () => {
const result = CompressOutputSchema.safeParse({
type: "file_edit",
title: "x".repeat(121),
facts: ["fact"],
narrative: "A narrative that is long enough",
concepts: [],
files: [],
importance: 5,
});
expect(result.success).toBe(false);
});
it("rejects importance outside 1-10", () => {
const result = CompressOutputSchema.safeParse({
type: "file_edit",
title: "Test",
facts: ["fact"],
narrative: "A valid narrative here",
concepts: [],
files: [],
importance: 11,
});
expect(result.success).toBe(false);
});
it("rejects narrative under 10 chars", () => {
const result = CompressOutputSchema.safeParse({
type: "file_edit",
title: "Test",
facts: ["fact"],
narrative: "short",
concepts: [],
files: [],
importance: 5,
});
expect(result.success).toBe(false);
});
});
describe("SummaryOutputSchema", () => {
it("accepts valid summary", () => {
const result = SummaryOutputSchema.safeParse({
title: "Session Summary",
narrative: "This session focused on implementing authentication features and fixing bugs",
keyDecisions: ["Use JWT"],
filesModified: ["auth.ts"],
concepts: ["auth"],
});
expect(result.success).toBe(true);
});
it("rejects short narrative", () => {
const result = SummaryOutputSchema.safeParse({
title: "Summary",
narrative: "Too short",
keyDecisions: [],
filesModified: [],
concepts: [],
});
expect(result.success).toBe(false);
});
});
describe("SearchInputSchema", () => {
it("accepts valid search", () => {
expect(SearchInputSchema.safeParse({ query: "auth" }).success).toBe(true);
});
it("accepts search with limit", () => {
expect(
SearchInputSchema.safeParse({ query: "auth", limit: 10 }).success,
).toBe(true);
});
it("rejects empty query", () => {
expect(SearchInputSchema.safeParse({ query: "" }).success).toBe(false);
});
});
describe("ContextInputSchema", () => {
it("accepts valid input", () => {
expect(
ContextInputSchema.safeParse({
sessionId: "ses_1",
project: "proj",
}).success,
).toBe(true);
});
});
describe("RememberInputSchema", () => {
it("accepts valid input", () => {
expect(
RememberInputSchema.safeParse({
content: "Always use TypeScript",
type: "preference",
}).success,
).toBe(true);
});
it("rejects empty content", () => {
expect(
RememberInputSchema.safeParse({ content: "" }).success,
).toBe(false);
});
});
});
describe("Validator", () => {
it("returns valid with correct data", () => {
const result = validateInput(SearchInputSchema, { query: "test" }, "search");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.data.query).toBe("test");
}
});
it("returns invalid with error details", () => {
const result = validateInput(SearchInputSchema, { query: "" }, "search");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.result.functionId).toBe("search");
expect(result.result.errors.length).toBeGreaterThan(0);
}
});
it("validateOutput works same as validateInput", () => {
const result = validateOutput(
CompressOutputSchema,
{
type: "file_edit",
title: "Test",
facts: ["a"],
narrative: "A long enough narrative",
concepts: [],
files: [],
importance: 5,
},
"compress",
);
expect(result.valid).toBe(true);
});
});
describe("Quality Scoring", () => {
describe("scoreCompression", () => {
it("returns 0 for empty object", () => {
expect(scoreCompression({})).toBe(0);
});
it("returns 100 for perfect observation", () => {
const score = scoreCompression({
type: "file_edit",
title: "A good title",
facts: ["fact 1", "fact 2", "fact 3"],
narrative: "A narrative that is definitely more than fifty characters long and provides good context",
concepts: ["auth", "jwt"],
importance: 7,
});
expect(score).toBe(100);
});
it("scores partial observations between 0 and 100", () => {
const score = scoreCompression({
title: "Test",
facts: ["one"],
narrative: "Short but valid narrative",
});
expect(score).toBeGreaterThan(0);
expect(score).toBeLessThan(100);
});
});
describe("scoreSummary", () => {
it("returns 0 for empty object", () => {
expect(scoreSummary({})).toBe(0);
});
it("returns high score for complete summary", () => {
const score = scoreSummary({
title: "Session Summary Title",
narrative:
"This is a detailed narrative about what happened during the session with enough content to be meaningful and complete for review purposes",
keyDecisions: ["Used JWT for auth", "Chose PostgreSQL"],
filesModified: ["src/auth.ts", "src/db.ts"],
concepts: ["authentication", "database"],
});
expect(score).toBeGreaterThanOrEqual(90);
});
});
describe("scoreContextRelevance", () => {
it("returns 0 for empty context", () => {
expect(scoreContextRelevance("", "proj")).toBe(0);
});
it("scores higher when project is mentioned", () => {
const withProject = scoreContextRelevance(
"<context>This is for my-project with details</context>",
"my-project",
);
const without = scoreContextRelevance(
"<context>Some generic context details</context>",
"my-project",
);
expect(withProject).toBeGreaterThan(without);
});
it("scores higher with more XML sections", () => {
const multi = scoreContextRelevance(
"<summary>A</summary><observations>B</observations><memories>C</memories><patterns>D</patterns>",
"test",
);
const single = scoreContextRelevance("<summary>A</summary>", "test");
expect(multi).toBeGreaterThan(single);
});
});
});
+240
View File
@@ -0,0 +1,240 @@
import { describe, expect, it, vi } from "vitest";
import type {
CompressedObservation,
RawObservation,
Session,
} from "../src/types.js";
import { registerEvictFunction } from "../src/functions/evict.js";
import { KV } from "../src/state/schema.js";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
type Store = Map<string, Map<string, unknown>>;
type Handler = (payload: unknown) => unknown | Promise<unknown>;
function daysAgo(days: number): string {
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
}
function makeSession(id: string): Session {
return {
id,
project: "agentmemory",
cwd: "/repo/agentmemory",
startedAt: daysAgo(31),
status: "active",
observationCount: 1,
};
}
function makeObservation(sessionId: string): CompressedObservation {
return {
id: "obs_1",
sessionId,
timestamp: daysAgo(31),
type: "decision",
title: "Chose sqlite storage",
facts: ["Use sqlite for local state"],
narrative: "The session chose sqlite for local state.",
concepts: ["sqlite"],
files: ["src/state/kv.ts"],
importance: 8,
};
}
function makeRawObservation(sessionId: string): RawObservation {
return {
id: "raw_1",
sessionId,
timestamp: daysAgo(31),
hookType: "post_tool_use",
toolName: "Edit",
raw: { file_path: "src/state/kv.ts" },
};
}
function mockKV(store: Store, listFailures: Set<string> = new Set()) {
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
if (listFailures.has(scope)) {
throw new Error(`list failed for ${scope}`);
}
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const handlers = new Map<string, Handler>();
const calls: Array<{ function_id: string; payload: unknown }> = [];
return {
calls,
sdk: {
registerFunction: (functionId: string, handler: Handler) => {
handlers.set(functionId, handler);
},
trigger: async (input: { function_id: string; payload: unknown }) => {
calls.push(input);
const handler = handlers.get(input.function_id);
if (!handler) throw new Error(`missing handler: ${input.function_id}`);
return handler(input.payload);
},
},
};
}
function storeForObservations(
sessionId: string,
observations: Array<CompressedObservation | RawObservation>,
): Store {
const session = makeSession(sessionId);
return new Map([
[KV.sessions, new Map([[session.id, session]])],
[KV.summaries, new Map()],
[
KV.observations(session.id),
new Map(observations.map((observation) => [observation.id, observation])),
],
[KV.config, new Map()],
[KV.audit, new Map()],
]);
}
function storeForObservedSession(sessionId: string): Store {
return storeForObservations(sessionId, [makeObservation(sessionId)]);
}
describe("mem::evict stale sessions", () => {
it("runs session recovery before deleting a stale observed session", async () => {
const sessionId = "ses_stale";
const store = storeForObservedSession(sessionId);
const kv = mockKV(store);
const { sdk, calls } = mockSdk();
registerEvictFunction(sdk as never, kv as never);
sdk.registerFunction("event::session::stopped", async (payload) => {
expect(payload).toEqual({ sessionId });
expect(await kv.get(KV.sessions, sessionId)).toMatchObject({
id: sessionId,
});
return { success: true };
});
sdk.registerFunction("mem::consolidate-pipeline", () => ({
success: true,
}));
const result = (await sdk.trigger({
function_id: "mem::evict",
payload: {},
})) as { staleSessions: number };
expect(result.staleSessions).toBe(1);
expect(await kv.get(KV.sessions, sessionId)).toBeNull();
const audits = await kv.list<{
details: { reason: string };
}>(KV.audit);
expect(audits[0].details.reason).toBe(
"stale_session_recovered_then_evicted",
);
expect(calls.map((call) => call.function_id)).toContain(
"event::session::stopped",
);
expect(calls.map((call) => call.function_id)).toContain(
"mem::consolidate-pipeline",
);
});
it("keeps a stale observed session when recovery fails", async () => {
const sessionId = "ses_unrecovered";
const store = storeForObservedSession(sessionId);
const kv = mockKV(store);
const { sdk, calls } = mockSdk();
registerEvictFunction(sdk as never, kv as never);
sdk.registerFunction("event::session::stopped", () => ({
success: false,
error: "no_provider",
}));
const result = (await sdk.trigger({
function_id: "mem::evict",
payload: {},
})) as { staleSessions: number };
expect(result.staleSessions).toBe(0);
expect(await kv.get(KV.sessions, sessionId)).toMatchObject({
id: sessionId,
});
expect(calls.map((call) => call.function_id)).toContain(
"event::session::stopped",
);
expect(calls.map((call) => call.function_id)).not.toContain(
"mem::consolidate-pipeline",
);
});
it("keeps a stale session when observation scanning fails", async () => {
const sessionId = "ses_scan_failed";
const store = storeForObservedSession(sessionId);
const kv = mockKV(store, new Set([KV.observations(sessionId)]));
const { sdk, calls } = mockSdk();
registerEvictFunction(sdk as never, kv as never);
sdk.registerFunction("event::session::stopped", () => ({
success: true,
}));
const result = (await sdk.trigger({
function_id: "mem::evict",
payload: {},
})) as { staleSessions: number };
expect(result.staleSessions).toBe(0);
expect(await kv.get(KV.sessions, sessionId)).toMatchObject({
id: sessionId,
});
expect(calls.map((call) => call.function_id)).not.toContain(
"event::session::stopped",
);
});
it("keeps a stale session that only has raw observations", async () => {
const sessionId = "ses_raw_only";
const store = storeForObservations(sessionId, [
makeRawObservation(sessionId),
]);
const kv = mockKV(store);
const { sdk, calls } = mockSdk();
registerEvictFunction(sdk as never, kv as never);
sdk.registerFunction("event::session::stopped", () => ({
success: true,
}));
const result = (await sdk.trigger({
function_id: "mem::evict",
payload: {},
})) as { staleSessions: number };
expect(result.staleSessions).toBe(0);
expect(await kv.get(KV.sessions, sessionId)).toMatchObject({
id: sessionId,
});
expect(calls.map((call) => call.function_id)).not.toContain(
"event::session::stopped",
);
});
});
+252
View File
@@ -0,0 +1,252 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerExportImportFunction } from "../src/functions/export-import.js";
import type {
Session,
CompressedObservation,
Memory,
SessionSummary,
ExportData,
} from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
const testSession: Session = {
id: "ses_1",
project: "my-project",
cwd: "/tmp",
startedAt: "2026-02-01T00:00:00Z",
status: "completed",
observationCount: 1,
};
const testObs: CompressedObservation = {
id: "obs_1",
sessionId: "ses_1",
timestamp: "2026-02-01T10:00:00Z",
type: "file_edit",
title: "Edit auth",
facts: ["Added check"],
narrative: "Auth changes",
concepts: ["auth"],
files: ["src/auth.ts"],
importance: 7,
};
const testMemory: Memory = {
id: "mem_1",
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
type: "pattern",
title: "Auth pattern",
content: "Always validate tokens",
concepts: ["auth"],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
};
const testSummary: SessionSummary = {
sessionId: "ses_1",
project: "my-project",
createdAt: "2026-02-01T00:00:00Z",
title: "Auth work",
narrative: "Worked on auth",
keyDecisions: ["Use JWT"],
filesModified: ["src/auth.ts"],
concepts: ["auth"],
observationCount: 1,
};
describe("Export/Import Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(async () => {
sdk = mockSdk();
kv = mockKV();
registerExportImportFunction(sdk as never, kv as never);
await kv.set("mem:sessions", "ses_1", testSession);
await kv.set("mem:obs:ses_1", "obs_1", testObs);
await kv.set("mem:memories", "mem_1", testMemory);
await kv.set("mem:summaries", "ses_1", testSummary);
});
it("export produces valid ExportData structure", async () => {
const result = (await sdk.trigger("mem::export", {})) as ExportData;
expect(result.version).toBe("0.9.27");
expect(result.exportedAt).toBeDefined();
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].id).toBe("ses_1");
expect(result.observations["ses_1"].length).toBe(1);
expect(result.memories.length).toBe(1);
expect(result.summaries.length).toBe(1);
});
it("import with merge strategy adds data", async () => {
const exportData: ExportData = {
version: "0.3.0",
exportedAt: new Date().toISOString(),
sessions: [{ ...testSession, id: "ses_2", observationCount: 0 }],
observations: {},
memories: [{ ...testMemory, id: "mem_2", title: "New pattern" }],
summaries: [],
};
const result = (await sdk.trigger("mem::import", {
exportData,
strategy: "merge",
})) as { success: boolean; sessions: number; memories: number };
expect(result.success).toBe(true);
expect(result.sessions).toBe(1);
expect(result.memories).toBe(1);
const allSessions = await kv.list("mem:sessions");
expect(allSessions.length).toBe(2);
});
it("import with skip strategy does not overwrite existing", async () => {
const exportData: ExportData = {
version: "0.3.0",
exportedAt: new Date().toISOString(),
sessions: [testSession],
observations: { ses_1: [testObs] },
memories: [testMemory],
summaries: [testSummary],
};
const result = (await sdk.trigger("mem::import", {
exportData,
strategy: "skip",
})) as { success: boolean; skipped: number; sessions: number };
expect(result.success).toBe(true);
expect(result.skipped).toBeGreaterThan(0);
expect(result.sessions).toBe(0);
});
it("import with replace strategy clears existing data first", async () => {
const newSession: Session = {
id: "ses_new",
project: "new-project",
cwd: "/tmp/new",
startedAt: "2026-03-01T00:00:00Z",
status: "active",
observationCount: 0,
};
const exportData: ExportData = {
version: "0.3.0",
exportedAt: new Date().toISOString(),
sessions: [newSession],
observations: {},
memories: [],
summaries: [],
};
const result = (await sdk.trigger("mem::import", {
exportData,
strategy: "replace",
})) as { success: boolean; sessions: number };
expect(result.success).toBe(true);
expect(result.sessions).toBe(1);
const oldSession = await kv.get("mem:sessions", "ses_1");
expect(oldSession).toBeNull();
});
it("export then import round-trip preserves data", async () => {
const exported = (await sdk.trigger("mem::export", {})) as ExportData;
const freshKv = mockKV();
const freshSdk = mockSdk();
registerExportImportFunction(freshSdk as never, freshKv as never);
const importResult = (await freshSdk.trigger("mem::import", {
exportData: exported,
strategy: "merge",
})) as {
success: boolean;
sessions: number;
observations: number;
memories: number;
};
expect(importResult.success).toBe(true);
expect(importResult.sessions).toBe(1);
expect(importResult.observations).toBe(1);
expect(importResult.memories).toBe(1);
const reExported = (await freshSdk.trigger(
"mem::export",
{},
)) as ExportData;
expect(reExported.sessions.length).toBe(exported.sessions.length);
expect(reExported.memories.length).toBe(exported.memories.length);
});
it("import rejects unsupported version", async () => {
const exportData = {
version: "1.0.0",
exportedAt: new Date().toISOString(),
sessions: [],
observations: {},
memories: [],
summaries: [],
} as unknown as ExportData;
const result = (await sdk.trigger("mem::import", {
exportData,
strategy: "merge",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("Unsupported export version");
});
});
+449
View File
@@ -0,0 +1,449 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerFacetsFunction } from "../src/functions/facets.js";
import type { Facet } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
describe("Facets Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerFacetsFunction(sdk as never, kv as never);
});
describe("mem::facet-tag", () => {
it("tags a target with a facet", async () => {
const result = (await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: "priority",
value: "high",
})) as { success: boolean; facet: Facet };
expect(result.success).toBe(true);
expect(result.facet.id).toMatch(/^fct_/);
expect(result.facet.targetId).toBe("act_123");
expect(result.facet.targetType).toBe("action");
expect(result.facet.dimension).toBe("priority");
expect(result.facet.value).toBe("high");
expect(result.facet.createdAt).toBeDefined();
});
it("returns error when dimension is empty", async () => {
const result = (await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: " ",
value: "high",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("dimension is required");
});
it("returns error when value is empty", async () => {
const result = (await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: "priority",
value: "",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("value is required");
});
it("returns error for invalid targetType", async () => {
const result = (await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "invalid",
dimension: "priority",
value: "high",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("targetType must be one of");
});
it("skips duplicate tag", async () => {
await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: "priority",
value: "high",
});
const result = (await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: "priority",
value: "high",
})) as { success: boolean; facet: Facet; skipped: boolean };
expect(result.success).toBe(true);
expect(result.skipped).toBe(true);
});
});
describe("mem::facet-untag", () => {
it("removes a specific value from a dimension", async () => {
await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: "priority",
value: "high",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: "priority",
value: "urgent",
});
const result = (await sdk.trigger("mem::facet-untag", {
targetId: "act_123",
dimension: "priority",
value: "high",
})) as { success: boolean; removed: number };
expect(result.success).toBe(true);
expect(result.removed).toBe(1);
const remaining = (await sdk.trigger("mem::facet-get", {
targetId: "act_123",
})) as { success: boolean; dimensions: Array<{ dimension: string; values: string[] }> };
expect(remaining.dimensions[0].values).toEqual(["urgent"]);
});
it("removes all values in a dimension when value is omitted", async () => {
await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: "priority",
value: "high",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_123",
targetType: "action",
dimension: "priority",
value: "urgent",
});
const result = (await sdk.trigger("mem::facet-untag", {
targetId: "act_123",
dimension: "priority",
})) as { success: boolean; removed: number };
expect(result.success).toBe(true);
expect(result.removed).toBe(2);
const remaining = (await sdk.trigger("mem::facet-get", {
targetId: "act_123",
})) as { success: boolean; dimensions: Array<{ dimension: string; values: string[] }> };
expect(remaining.dimensions).toEqual([]);
});
});
describe("mem::facet-query", () => {
beforeEach(async () => {
await sdk.trigger("mem::facet-tag", {
targetId: "act_1",
targetType: "action",
dimension: "priority",
value: "high",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_1",
targetType: "action",
dimension: "status",
value: "active",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_2",
targetType: "action",
dimension: "priority",
value: "low",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_2",
targetType: "action",
dimension: "status",
value: "active",
});
await sdk.trigger("mem::facet-tag", {
targetId: "mem_1",
targetType: "memory",
dimension: "priority",
value: "high",
});
});
it("queries with matchAll (AND logic)", async () => {
const result = (await sdk.trigger("mem::facet-query", {
matchAll: ["priority:high", "status:active"],
})) as { success: boolean; results: Array<{ targetId: string }> };
expect(result.success).toBe(true);
expect(result.results.length).toBe(1);
expect(result.results[0].targetId).toBe("act_1");
});
it("queries with matchAny (OR logic)", async () => {
const result = (await sdk.trigger("mem::facet-query", {
matchAny: ["priority:high", "priority:low"],
})) as { success: boolean; results: Array<{ targetId: string }> };
expect(result.success).toBe(true);
expect(result.results.length).toBe(3);
});
it("queries with both matchAll and matchAny", async () => {
const result = (await sdk.trigger("mem::facet-query", {
matchAll: ["status:active"],
matchAny: ["priority:high"],
})) as { success: boolean; results: Array<{ targetId: string; matchedFacets: string[] }> };
expect(result.success).toBe(true);
expect(result.results.length).toBe(1);
expect(result.results[0].targetId).toBe("act_1");
expect(result.results[0].matchedFacets).toContain("status:active");
expect(result.results[0].matchedFacets).toContain("priority:high");
});
it("returns error when neither matchAll nor matchAny provided", async () => {
const result = (await sdk.trigger("mem::facet-query", {})) as {
success: boolean;
error: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain("at least one of matchAll or matchAny is required");
});
it("filters by targetType", async () => {
const result = (await sdk.trigger("mem::facet-query", {
matchAny: ["priority:high"],
targetType: "memory",
})) as { success: boolean; results: Array<{ targetId: string; targetType: string }> };
expect(result.success).toBe(true);
expect(result.results.length).toBe(1);
expect(result.results[0].targetId).toBe("mem_1");
expect(result.results[0].targetType).toBe("memory");
});
it("respects limit", async () => {
const result = (await sdk.trigger("mem::facet-query", {
matchAny: ["priority:high", "priority:low"],
limit: 1,
})) as { success: boolean; results: Array<{ targetId: string }> };
expect(result.success).toBe(true);
expect(result.results.length).toBe(1);
});
});
describe("mem::facet-get", () => {
it("returns facets grouped by dimension", async () => {
await sdk.trigger("mem::facet-tag", {
targetId: "act_1",
targetType: "action",
dimension: "priority",
value: "high",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_1",
targetType: "action",
dimension: "priority",
value: "urgent",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_1",
targetType: "action",
dimension: "status",
value: "active",
});
const result = (await sdk.trigger("mem::facet-get", {
targetId: "act_1",
})) as { success: boolean; dimensions: Array<{ dimension: string; values: string[] }> };
expect(result.success).toBe(true);
expect(result.dimensions.length).toBe(2);
const priorityDim = result.dimensions.find((d) => d.dimension === "priority");
expect(priorityDim).toBeDefined();
expect(priorityDim!.values).toEqual(["high", "urgent"]);
const statusDim = result.dimensions.find((d) => d.dimension === "status");
expect(statusDim).toBeDefined();
expect(statusDim!.values).toEqual(["active"]);
});
it("returns empty dimensions for target with no facets", async () => {
const result = (await sdk.trigger("mem::facet-get", {
targetId: "nonexistent",
})) as { success: boolean; dimensions: Array<{ dimension: string; values: string[] }> };
expect(result.success).toBe(true);
expect(result.dimensions).toEqual([]);
});
});
describe("mem::facet-stats", () => {
beforeEach(async () => {
await sdk.trigger("mem::facet-tag", {
targetId: "act_1",
targetType: "action",
dimension: "priority",
value: "high",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_2",
targetType: "action",
dimension: "priority",
value: "high",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_3",
targetType: "action",
dimension: "priority",
value: "low",
});
await sdk.trigger("mem::facet-tag", {
targetId: "mem_1",
targetType: "memory",
dimension: "category",
value: "bugfix",
});
});
it("returns dimensions with value counts", async () => {
const result = (await sdk.trigger("mem::facet-stats", {})) as {
success: boolean;
dimensions: Array<{
dimension: string;
values: Array<{ value: string; count: number }>;
}>;
totalFacets: number;
};
expect(result.success).toBe(true);
expect(result.totalFacets).toBe(4);
expect(result.dimensions.length).toBe(2);
const priorityDim = result.dimensions.find((d) => d.dimension === "priority");
expect(priorityDim).toBeDefined();
const highVal = priorityDim!.values.find((v) => v.value === "high");
expect(highVal!.count).toBe(2);
const lowVal = priorityDim!.values.find((v) => v.value === "low");
expect(lowVal!.count).toBe(1);
});
it("filters by targetType", async () => {
const result = (await sdk.trigger("mem::facet-stats", {
targetType: "memory",
})) as {
success: boolean;
dimensions: Array<{
dimension: string;
values: Array<{ value: string; count: number }>;
}>;
totalFacets: number;
};
expect(result.success).toBe(true);
expect(result.totalFacets).toBe(1);
expect(result.dimensions.length).toBe(1);
expect(result.dimensions[0].dimension).toBe("category");
expect(result.dimensions[0].values[0].value).toBe("bugfix");
expect(result.dimensions[0].values[0].count).toBe(1);
});
});
describe("mem::facet-dimensions", () => {
it("returns unique dimension names with counts", async () => {
await sdk.trigger("mem::facet-tag", {
targetId: "act_1",
targetType: "action",
dimension: "priority",
value: "high",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_2",
targetType: "action",
dimension: "priority",
value: "low",
});
await sdk.trigger("mem::facet-tag", {
targetId: "act_1",
targetType: "action",
dimension: "status",
value: "active",
});
const result = (await sdk.trigger("mem::facet-dimensions", {})) as {
success: boolean;
dimensions: Array<{ dimension: string; count: number }>;
};
expect(result.success).toBe(true);
expect(result.dimensions.length).toBe(2);
const priorityDim = result.dimensions.find((d) => d.dimension === "priority");
expect(priorityDim).toBeDefined();
expect(priorityDim!.count).toBe(2);
const statusDim = result.dimensions.find((d) => d.dimension === "status");
expect(statusDim).toBeDefined();
expect(statusDim!.count).toBe(1);
});
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { FallbackChainProvider } from "../src/providers/fallback-chain.js";
import type { MemoryProvider } from "../src/types.js";
function makeProvider(
name: string,
impl?: Partial<MemoryProvider>,
): MemoryProvider {
return {
name,
compress: impl?.compress ?? (async () => `compressed by ${name}`),
summarize: impl?.summarize ?? (async () => `summarized by ${name}`),
};
}
describe("FallbackChainProvider", () => {
it("returns result from first provider when it succeeds", async () => {
const chain = new FallbackChainProvider([
makeProvider("primary"),
makeProvider("secondary"),
]);
const result = await chain.compress("sys", "user");
expect(result).toBe("compressed by primary");
});
it("falls back to second provider when first fails", async () => {
const failing: MemoryProvider = {
name: "failing",
compress: async () => {
throw new Error("primary down");
},
summarize: async () => {
throw new Error("primary down");
},
};
const chain = new FallbackChainProvider([
failing,
makeProvider("backup"),
]);
const result = await chain.compress("sys", "user");
expect(result).toBe("compressed by backup");
});
it("throws the last error when all providers fail", async () => {
const failing1: MemoryProvider = {
name: "fail1",
compress: async () => {
throw new Error("fail1 error");
},
summarize: async () => {
throw new Error("fail1 error");
},
};
const failing2: MemoryProvider = {
name: "fail2",
compress: async () => {
throw new Error("fail2 error");
},
summarize: async () => {
throw new Error("fail2 error");
},
};
const chain = new FallbackChainProvider([failing1, failing2]);
await expect(chain.compress("sys", "user")).rejects.toThrow("fail2 error");
});
it("formats the name correctly", () => {
const chain = new FallbackChainProvider([
makeProvider("anthropic"),
makeProvider("gemini"),
makeProvider("openrouter"),
]);
expect(chain.name).toBe("fallback(anthropic -> gemini -> openrouter)");
});
it("summarize also uses fallback chain", async () => {
const failing: MemoryProvider = {
name: "failing",
compress: async () => {
throw new Error("down");
},
summarize: async () => {
throw new Error("down");
},
};
const chain = new FallbackChainProvider([
failing,
makeProvider("backup"),
]);
const result = await chain.summarize("sys", "user");
expect(result).toBe("summarized by backup");
});
});
+190
View File
@@ -0,0 +1,190 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// #778: fallback providers used to inherit the primary provider's
// model name and 404 on every call. Each fallback must resolve its
// own env-driven default model.
const captured: Array<{ provider: string; model: string }> = [];
vi.mock("../src/providers/openai.js", () => ({
OpenAIProvider: class {
name = "openai";
constructor(_key: string, model: string) {
captured.push({ provider: "openai", model });
}
async compress() {
return "";
}
async summarize() {
return "";
}
},
}));
vi.mock("../src/providers/openrouter.js", () => ({
OpenRouterProvider: class {
name = "openrouter";
constructor(_key: string, model: string, _max: number, url?: string) {
captured.push({
provider: url?.includes("googleapis") ? "gemini" : "openrouter",
model,
});
}
async compress() {
return "";
}
async summarize() {
return "";
}
},
}));
vi.mock("../src/providers/anthropic.js", () => ({
AnthropicProvider: class {
name = "anthropic";
constructor(_key: string, model: string) {
captured.push({ provider: "anthropic", model });
}
async compress() {
return "";
}
async summarize() {
return "";
}
},
}));
vi.mock("../src/providers/minimax.js", () => ({
MinimaxProvider: class {
name = "minimax";
constructor(_key: string, model: string) {
captured.push({ provider: "minimax", model });
}
async compress() {
return "";
}
async summarize() {
return "";
}
},
}));
import { createFallbackProvider } from "../src/providers/index.js";
import type { ProviderConfig, FallbackConfig } from "../src/types.js";
describe("Fallback provider model resolution (#778)", () => {
const savedEnv: Record<string, string | undefined> = {};
const envKeys = [
"OPENAI_API_KEY",
"OPENAI_MODEL",
"GEMINI_API_KEY",
"GEMINI_MODEL",
"GOOGLE_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_MODEL",
"OPENROUTER_API_KEY",
"OPENROUTER_MODEL",
"MINIMAX_API_KEY",
"MINIMAX_MODEL",
];
beforeEach(() => {
captured.length = 0;
for (const k of envKeys) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
for (const k of envKeys) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
it("primary OpenAI + fallback Gemini: Gemini is built with GEMINI_MODEL, NOT the primary's model", () => {
process.env.OPENAI_API_KEY = "sk-openai";
process.env.GEMINI_API_KEY = "gemini-key";
process.env.GEMINI_MODEL = "gemini-2.5-flash";
const primary: ProviderConfig = {
provider: "openai",
model: "gpt-4o-mini",
maxTokens: 4096,
};
const fallback: FallbackConfig = { providers: ["gemini"] };
createFallbackProvider(primary, fallback);
const openaiCall = captured.find((c) => c.provider === "openai");
const geminiCall = captured.find((c) => c.provider === "gemini");
expect(openaiCall?.model).toBe("gpt-4o-mini");
expect(geminiCall?.model).toBe("gemini-2.5-flash");
expect(geminiCall?.model).not.toBe("gpt-4o-mini");
});
it("Gemini fallback uses the documented default when GEMINI_MODEL is unset", () => {
process.env.OPENAI_API_KEY = "sk-openai";
process.env.GEMINI_API_KEY = "gemini-key";
createFallbackProvider(
{ provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 },
{ providers: ["gemini"] },
);
const geminiCall = captured.find((c) => c.provider === "gemini");
expect(geminiCall?.model).toBe("gemini-2.5-flash");
});
it("primary Anthropic + fallback OpenAI + Minimax: each fallback uses its own default", () => {
process.env.ANTHROPIC_API_KEY = "anth-key";
process.env.OPENAI_API_KEY = "openai-key";
process.env.OPENAI_MODEL = "gpt-5";
process.env.MINIMAX_API_KEY = "mini-key";
createFallbackProvider(
{
provider: "anthropic",
model: "claude-sonnet-4-20250514",
maxTokens: 4096,
},
{ providers: ["openai", "minimax"] },
);
const openai = captured.find((c) => c.provider === "openai");
const minimax = captured.find((c) => c.provider === "minimax");
expect(openai?.model).toBe("gpt-5");
expect(minimax?.model).toBe("MiniMax-M2.7");
// Neither inherits the Anthropic model name.
expect(openai?.model).not.toBe("claude-sonnet-4-20250514");
expect(minimax?.model).not.toBe("claude-sonnet-4-20250514");
});
it("env override on the fallback provider's MODEL var wins over the default", () => {
process.env.OPENAI_API_KEY = "sk";
process.env.GEMINI_API_KEY = "gk";
process.env.GEMINI_MODEL = "gemini-2.5-pro";
createFallbackProvider(
{ provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 },
{ providers: ["gemini"] },
);
expect(captured.find((c) => c.provider === "gemini")?.model).toBe(
"gemini-2.5-pro",
);
});
it("fallback that matches the primary provider is skipped (no duplicate)", () => {
process.env.OPENAI_API_KEY = "sk";
createFallbackProvider(
{ provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 },
{ providers: ["openai", "gemini"] },
);
const openaiCalls = captured.filter((c) => c.provider === "openai");
expect(openaiCalls.length).toBe(1);
});
});
+346
View File
@@ -0,0 +1,346 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { fetchWithTimeout } from "../src/providers/_fetch.js";
import { MinimaxProvider } from "../src/providers/minimax.js";
import { OpenRouterProvider } from "../src/providers/openrouter.js";
import { OpenAIProvider } from "../src/providers/openai.js";
import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js";
import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js";
import { CohereEmbeddingProvider } from "../src/providers/embedding/cohere.js";
import { VoyageEmbeddingProvider } from "../src/providers/embedding/voyage.js";
import { OpenRouterEmbeddingProvider } from "../src/providers/embedding/openrouter.js";
// A fetch mock that never resolves — simulates a hung upstream.
function hangingFetch(_url: string, _init?: RequestInit): Promise<Response> {
// honour AbortSignal so the timeout actually cancels us
const init = _init ?? {};
return new Promise<Response>((_resolve, reject) => {
if (init.signal) {
if (init.signal.aborted) {
reject(new DOMException("AbortError", "AbortError"));
return;
}
init.signal.addEventListener("abort", () => {
reject(new DOMException("AbortError", "AbortError"));
});
}
});
}
// ─────────────────────────────────────────────────────────────
// fetchWithTimeout unit tests
// ─────────────────────────────────────────────────────────────
describe("fetchWithTimeout", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("resolves normally when fetch completes within the timeout", async () => {
vi.restoreAllMocks();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
const res = await fetchWithTimeout("https://example.com", {}, 1000);
expect(res.status).toBe(200);
});
it("aborts with an AbortError when fetch hangs beyond the configured timeout", async () => {
await expect(
fetchWithTimeout("https://example.com", {}, 50),
).rejects.toThrow();
});
it("reads AGENTMEMORY_LLM_TIMEOUT_MS as the default timeout when no explicit ms is given", async () => {
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
// no explicit third arg — must pick up the env var
await expect(
fetchWithTimeout("https://example.com", {}),
).rejects.toThrow();
});
it("falls back to 60 000 ms when AGENTMEMORY_LLM_TIMEOUT_MS is not set (type check only)", () => {
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
vi.restoreAllMocks();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 204 }),
);
const p = fetchWithTimeout("https://example.com", {});
expect(p).toBeInstanceOf(Promise);
return p;
});
});
// ─────────────────────────────────────────────────────────────
// Provider hang regression tests
// Each provider must call fetchWithTimeout, which honours the
// AbortSignal when the explicit timeoutMs is tiny (50 ms).
// ─────────────────────────────────────────────────────────────
describe("Provider hang regression — MinimaxProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("compress() aborts after timeout when upstream hangs", async () => {
const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800);
await expect(provider.compress("system", "user")).rejects.toThrow();
});
});
describe("Provider hang regression — OpenRouterProvider (covers Gemini LLM path)", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("compress() aborts after timeout when upstream hangs", async () => {
const provider = new OpenRouterProvider(
"test-key",
"gemini-2.5-flash",
1024,
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
);
await expect(provider.compress("system", "user")).rejects.toThrow();
});
});
describe("Provider hang regression — GeminiEmbeddingProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("embedBatch() aborts after timeout when upstream hangs", async () => {
const provider = new GeminiEmbeddingProvider("test-key");
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
});
});
describe("Provider hang regression — OpenAIEmbeddingProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("embedBatch() aborts after timeout when upstream hangs", async () => {
const provider = new OpenAIEmbeddingProvider("test-key");
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
});
});
describe("Provider hang regression — CohereEmbeddingProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("embedBatch() aborts after timeout when upstream hangs", async () => {
const provider = new CohereEmbeddingProvider("test-key");
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
});
});
describe("Provider hang regression — VoyageEmbeddingProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("embedBatch() aborts after timeout when upstream hangs", async () => {
const provider = new VoyageEmbeddingProvider("test-key");
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
});
});
describe("Provider hang regression — OpenRouterEmbeddingProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("embedBatch() aborts after timeout when upstream hangs", async () => {
const provider = new OpenRouterEmbeddingProvider("test-key");
await expect(provider.embedBatch(["hello"])).rejects.toThrow();
});
});
// ─────────────────────────────────────────────────────────────
// #446 — OpenAI LLM provider env-var precedence
//
// v0.9.17 shipped OPENAI_TIMEOUT_MS (OpenAI-scoped). PR #379 then
// shipped AGENTMEMORY_LLM_TIMEOUT_MS (shared). The provider now
// honours both: OPENAI_TIMEOUT_MS wins for back-compat, with
// AGENTMEMORY_LLM_TIMEOUT_MS as the global fall-back.
// ─────────────────────────────────────────────────────────────
describe("OpenAIProvider timeout env precedence (#446)", () => {
beforeEach(() => {
delete process.env["OPENAI_TIMEOUT_MS"];
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["OPENAI_TIMEOUT_MS"];
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
it("OPENAI_TIMEOUT_MS alone aborts the OpenAI LLM call", async () => {
process.env["OPENAI_TIMEOUT_MS"] = "30";
const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024);
await expect(provider.compress("system", "user")).rejects.toThrow(
/timed out after 30ms/,
);
});
it("AGENTMEMORY_LLM_TIMEOUT_MS alone aborts the OpenAI LLM call", async () => {
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "30";
const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024);
await expect(provider.compress("system", "user")).rejects.toThrow(
/timed out after 30ms/,
);
});
it("OPENAI_TIMEOUT_MS wins when both are set (back-compat)", async () => {
process.env["OPENAI_TIMEOUT_MS"] = "30";
// Set the global to a much larger value — if precedence is wrong,
// we'd time out at 5000ms and the test would hang past the 5s
// vitest default. We assert the message ms to lock the precedence.
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "5000";
const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024);
await expect(provider.compress("system", "user")).rejects.toThrow(
/timed out after 30ms/,
);
});
it("falls back to the 60 000 ms default when neither is set", () => {
// We don't actually wait 60s — the provider stores timeoutMs at
// construction. Construct, then assert the bound via the error
// message after the hang aborts at a tiny pre-set value.
const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024);
// Access the resolved timeout via the constructed field name. The
// class keeps `timeoutMs` private; reaching in via the index
// access keeps the test on the public observed behaviour: the ms
// value reported in the timeout error message must be 60000.
const ms = (provider as unknown as { timeoutMs: number }).timeoutMs;
expect(ms).toBe(60_000);
});
it("rejects malformed env values like '30ms' or '1_000' (CodeRabbit catch)", () => {
// parseInt would have silently returned 30 / 1 for these typos —
// strict parse now rejects them and the provider falls back to
// the 60 000 ms default so a malformed env doesn't masquerade as
// an aggressive bound.
// Whitespace-only padding (" 30 ") is legitimate env-var handling — we
// trim before validating. The cases below are real typos parseInt would
// silently swallow.
for (const bad of ["30ms", "1_000", "60s", "30abc", "-30", "0"]) {
process.env["OPENAI_TIMEOUT_MS"] = bad;
const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024);
const ms = (provider as unknown as { timeoutMs: number }).timeoutMs;
expect(ms).toBe(60_000);
delete process.env["OPENAI_TIMEOUT_MS"];
}
});
});
// ─────────────────────────────────────────────────────────────
// #627 — OpenAI provider must read message.reasoning_content
// DeepSeek V4 / Qwen3 / GLM / Kimi return reasoning_content (with
// underscore); only checking `reasoning` left thinking-model output
// dropped on the floor and tripped the compress circuit breaker.
// ─────────────────────────────────────────────────────────────
describe("OpenAIProvider thinking-model fallback (#627)", () => {
beforeEach(() => {
delete process.env["OPENAI_TIMEOUT_MS"];
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
afterEach(() => {
vi.restoreAllMocks();
});
function mockOpenAIResponse(body: object): void {
vi.spyOn(globalThis, "fetch").mockImplementation(
(async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})) as typeof fetch,
);
}
it("returns reasoning_content when content is empty (DeepSeek V4 / Qwen3 shape)", async () => {
mockOpenAIResponse({
choices: [
{
message: {
content: "",
reasoning_content: "thinking-mode output",
},
},
],
});
const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024);
const out = await provider.compress("system", "user");
expect(out).toBe("thinking-mode output");
});
it("still returns reasoning (no underscore) for older o-series shape", async () => {
mockOpenAIResponse({
choices: [{ message: { content: "", reasoning: "older shape" } }],
});
const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024);
const out = await provider.compress("system", "user");
expect(out).toBe("older shape");
});
it("content wins over both reasoning fields when present", async () => {
mockOpenAIResponse({
choices: [
{
message: {
content: "real content",
reasoning: "ignore",
reasoning_content: "also ignore",
},
},
],
});
const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024);
const out = await provider.compress("system", "user");
expect(out).toBe("real content");
});
});
+2
View File
@@ -0,0 +1,2 @@
{"type":"user","uuid":"u1","sessionId":"sess-basic","timestamp":"2026-04-17T10:00:00.000Z","cwd":"/Users/alice/project","message":{"role":"user","content":[{"type":"text","text":"Fix the login bug"}]}}
{"type":"assistant","uuid":"a1","sessionId":"sess-basic","timestamp":"2026-04-17T10:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Looking into it now."}]}}
+4
View File
@@ -0,0 +1,4 @@
{"type":"user","uuid":"u1","sessionId":"sess-err","timestamp":"2026-04-17T12:00:00.000Z","cwd":"/tmp/x","message":{"role":"user","content":[{"type":"text","text":"Run tests"}]}}
not-valid-json-line
{"type":"assistant","uuid":"a1","sessionId":"sess-err","timestamp":"2026-04-17T12:00:01.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"npm test"}}]}}
{"type":"user","uuid":"u2","sessionId":"sess-err","timestamp":"2026-04-17T12:00:02.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"exit 1","is_error":true}]}}
+4
View File
@@ -0,0 +1,4 @@
{"type":"user","uuid":"u1","sessionId":"sess-tool","timestamp":"2026-04-17T11:00:00.000Z","cwd":"/Users/bob/repo","message":{"role":"user","content":[{"type":"text","text":"List the files"}]}}
{"type":"assistant","uuid":"a1","sessionId":"sess-tool","timestamp":"2026-04-17T11:00:02.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}
{"type":"user","uuid":"u2","sessionId":"sess-tool","timestamp":"2026-04-17T11:00:03.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"README.md\nsrc\n"}]}}
{"type":"assistant","uuid":"a2","sessionId":"sess-tool","timestamp":"2026-04-17T11:00:04.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Two entries."}]}}
+492
View File
@@ -0,0 +1,492 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerFrontierFunction } from "../src/functions/frontier.js";
import { registerActionsFunction } from "../src/functions/actions.js";
import type { Action, ActionEdge, Checkpoint, Lease } from "../src/types.js";
import type { FrontierItem } from "../src/functions/frontier.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeAction(overrides: Partial<Action>): Action {
const now = new Date().toISOString();
return {
id: overrides.id || `act_${Math.random().toString(36).slice(2, 10)}`,
title: overrides.title || "Test action",
description: overrides.description || "",
status: overrides.status || "pending",
priority: overrides.priority || 5,
createdAt: overrides.createdAt || now,
updatedAt: overrides.updatedAt || now,
createdBy: overrides.createdBy || "agent-1",
assignedTo: overrides.assignedTo,
project: overrides.project,
tags: overrides.tags || [],
sourceObservationIds: overrides.sourceObservationIds || [],
sourceMemoryIds: overrides.sourceMemoryIds || [],
result: overrides.result,
parentId: overrides.parentId,
metadata: overrides.metadata,
};
}
describe("Frontier Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerActionsFunction(sdk as never, kv as never);
registerFrontierFunction(sdk as never, kv as never);
});
describe("mem::frontier", () => {
it("returns empty frontier when no actions exist", async () => {
const result = (await sdk.trigger("mem::frontier", {})) as {
success: boolean;
frontier: FrontierItem[];
totalActions: number;
totalUnblocked: number;
};
expect(result.success).toBe(true);
expect(result.frontier).toEqual([]);
expect(result.totalActions).toBe(0);
expect(result.totalUnblocked).toBe(0);
});
it("returns pending actions sorted by score", async () => {
const lowPriority = makeAction({
id: "act_low",
title: "Low priority",
priority: 2,
});
const highPriority = makeAction({
id: "act_high",
title: "High priority",
priority: 9,
});
await kv.set("mem:actions", lowPriority.id, lowPriority);
await kv.set("mem:actions", highPriority.id, highPriority);
const result = (await sdk.trigger("mem::frontier", {})) as {
success: boolean;
frontier: FrontierItem[];
};
expect(result.success).toBe(true);
expect(result.frontier.length).toBe(2);
expect(result.frontier[0].action.id).toBe("act_high");
expect(result.frontier[1].action.id).toBe("act_low");
expect(result.frontier[0].score).toBeGreaterThan(
result.frontier[1].score,
);
});
it("excludes done and cancelled actions", async () => {
const pending = makeAction({
id: "act_pending",
title: "Pending",
status: "pending",
});
const done = makeAction({
id: "act_done",
title: "Done",
status: "done",
});
const cancelled = makeAction({
id: "act_cancelled",
title: "Cancelled",
status: "cancelled",
});
await kv.set("mem:actions", pending.id, pending);
await kv.set("mem:actions", done.id, done);
await kv.set("mem:actions", cancelled.id, cancelled);
const result = (await sdk.trigger("mem::frontier", {})) as {
success: boolean;
frontier: FrontierItem[];
totalActions: number;
};
expect(result.success).toBe(true);
expect(result.frontier.length).toBe(1);
expect(result.frontier[0].action.id).toBe("act_pending");
expect(result.totalActions).toBe(3);
});
it("excludes blocked actions with unsatisfied requires edge", async () => {
const dependency = makeAction({
id: "act_dep",
title: "Dependency",
status: "pending",
});
const blocked = makeAction({
id: "act_blocked",
title: "Blocked",
status: "blocked",
});
await kv.set("mem:actions", dependency.id, dependency);
await kv.set("mem:actions", blocked.id, blocked);
const edge: ActionEdge = {
id: "ae_1",
type: "requires",
sourceActionId: blocked.id,
targetActionId: dependency.id,
createdAt: new Date().toISOString(),
};
await kv.set("mem:action-edges", edge.id, edge);
const result = (await sdk.trigger("mem::frontier", {})) as {
success: boolean;
frontier: FrontierItem[];
};
expect(result.success).toBe(true);
const ids = result.frontier.map((f) => f.action.id);
expect(ids).toContain("act_dep");
expect(ids).not.toContain("act_blocked");
});
it("respects project filter", async () => {
const alphaAction = makeAction({
id: "act_alpha",
title: "Alpha task",
project: "alpha",
});
const betaAction = makeAction({
id: "act_beta",
title: "Beta task",
project: "beta",
});
await kv.set("mem:actions", alphaAction.id, alphaAction);
await kv.set("mem:actions", betaAction.id, betaAction);
const result = (await sdk.trigger("mem::frontier", {
project: "alpha",
})) as { success: boolean; frontier: FrontierItem[] };
expect(result.success).toBe(true);
expect(result.frontier.length).toBe(1);
expect(result.frontier[0].action.project).toBe("alpha");
});
it("higher priority scores higher", async () => {
const low = makeAction({
id: "act_low",
title: "Low",
priority: 1,
createdAt: new Date().toISOString(),
});
const high = makeAction({
id: "act_high",
title: "High",
priority: 10,
createdAt: new Date().toISOString(),
});
await kv.set("mem:actions", low.id, low);
await kv.set("mem:actions", high.id, high);
const result = (await sdk.trigger("mem::frontier", {})) as {
success: boolean;
frontier: FrontierItem[];
};
expect(result.frontier[0].action.id).toBe("act_high");
expect(result.frontier[0].score).toBeGreaterThan(
result.frontier[1].score,
);
});
it("excludes actions gated by pending checkpoint", async () => {
const gatedAction = makeAction({
id: "act_gated",
title: "Gated action",
status: "pending",
});
const checkpoint: Checkpoint = {
id: "ckpt_1",
name: "CI check",
description: "Waiting for CI",
status: "pending",
type: "ci",
createdAt: new Date().toISOString(),
linkedActionIds: ["act_gated"],
};
await kv.set("mem:actions", gatedAction.id, gatedAction);
await kv.set("mem:checkpoints", checkpoint.id, checkpoint);
const gateEdge: ActionEdge = {
id: "ae_gate",
type: "gated_by",
sourceActionId: gatedAction.id,
targetActionId: checkpoint.id,
createdAt: new Date().toISOString(),
};
await kv.set("mem:action-edges", gateEdge.id, gateEdge);
const result = (await sdk.trigger("mem::frontier", {})) as {
success: boolean;
frontier: FrontierItem[];
};
expect(result.frontier.length).toBe(0);
});
it("excludes actions conflicting with active actions", async () => {
const activeAction = makeAction({
id: "act_active",
title: "Active task",
status: "active",
});
const conflictAction = makeAction({
id: "act_conflict",
title: "Conflicting task",
status: "pending",
});
await kv.set("mem:actions", activeAction.id, activeAction);
await kv.set("mem:actions", conflictAction.id, conflictAction);
const conflictEdge: ActionEdge = {
id: "ae_conflict",
type: "conflicts_with",
sourceActionId: conflictAction.id,
targetActionId: activeAction.id,
createdAt: new Date().toISOString(),
};
await kv.set("mem:action-edges", conflictEdge.id, conflictEdge);
const result = (await sdk.trigger("mem::frontier", {})) as {
success: boolean;
frontier: FrontierItem[];
};
const ids = result.frontier.map((f) => f.action.id);
expect(ids).toContain("act_active");
expect(ids).not.toContain("act_conflict");
});
it("active actions get score bonus", async () => {
const pendingAction = makeAction({
id: "act_pending",
title: "Pending",
status: "pending",
priority: 5,
createdAt: new Date().toISOString(),
});
const activeAction = makeAction({
id: "act_active",
title: "Active",
status: "active",
priority: 5,
createdAt: new Date().toISOString(),
});
await kv.set("mem:actions", pendingAction.id, pendingAction);
await kv.set("mem:actions", activeAction.id, activeAction);
const result = (await sdk.trigger("mem::frontier", {})) as {
success: boolean;
frontier: FrontierItem[];
};
const activeItem = result.frontier.find(
(f) => f.action.id === "act_active",
)!;
const pendingItem = result.frontier.find(
(f) => f.action.id === "act_pending",
)!;
expect(activeItem.score).toBeGreaterThan(pendingItem.score);
});
});
describe("mem::next", () => {
it("returns top suggestion when actions exist", async () => {
const action = makeAction({
id: "act_1",
title: "Top task",
priority: 8,
tags: ["urgent"],
});
await kv.set("mem:actions", action.id, action);
const result = (await sdk.trigger("mem::next", {})) as {
success: boolean;
suggestion: {
actionId: string;
title: string;
description: string;
priority: number;
score: number;
tags: string[];
} | null;
message: string;
totalActions: number;
};
expect(result.success).toBe(true);
expect(result.suggestion).not.toBeNull();
expect(result.suggestion!.actionId).toBe("act_1");
expect(result.suggestion!.title).toBe("Top task");
expect(result.suggestion!.priority).toBe(8);
expect(result.suggestion!.tags).toEqual(["urgent"]);
expect(result.message).toContain("Top task");
expect(result.totalActions).toBe(1);
});
it("returns null suggestion when no actions exist", async () => {
const result = (await sdk.trigger("mem::next", {})) as {
success: boolean;
suggestion: null;
message: string;
totalActions: number;
};
expect(result.success).toBe(true);
expect(result.suggestion).toBeNull();
expect(result.message).toContain("No actionable work");
expect(result.totalActions).toBe(0);
});
it("returns null when all actions are done", async () => {
const doneAction = makeAction({
id: "act_done",
title: "Completed",
status: "done",
});
await kv.set("mem:actions", doneAction.id, doneAction);
const result = (await sdk.trigger("mem::next", {})) as {
success: boolean;
suggestion: null;
message: string;
totalActions: number;
};
expect(result.success).toBe(true);
expect(result.suggestion).toBeNull();
expect(result.totalActions).toBe(1);
});
it("propagates failure when frontier fails", async () => {
const originalFunctions = new Map<string, Function>();
const failSdk = {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
originalFunctions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
if (id === "mem::frontier") {
return { success: false, error: "internal failure" };
}
const fn = originalFunctions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
const failKv = mockKV();
registerFrontierFunction(failSdk as never, failKv as never);
const nextFn = originalFunctions.get("mem::next")!;
const result = (await nextFn({})) as {
success: boolean;
suggestion: null;
message: string;
totalActions: number;
};
expect(result.success).toBe(false);
expect(result.suggestion).toBeNull();
expect(result.message).toContain("Failed to compute frontier");
expect(result.totalActions).toBe(0);
});
it("respects project filter", async () => {
const alphaAction = makeAction({
id: "act_alpha",
title: "Alpha task",
project: "alpha",
priority: 5,
});
const betaAction = makeAction({
id: "act_beta",
title: "Beta task",
project: "beta",
priority: 10,
});
await kv.set("mem:actions", alphaAction.id, alphaAction);
await kv.set("mem:actions", betaAction.id, betaAction);
const result = (await sdk.trigger("mem::next", {
project: "alpha",
})) as {
success: boolean;
suggestion: { actionId: string; title: string } | null;
};
expect(result.success).toBe(true);
expect(result.suggestion).not.toBeNull();
expect(result.suggestion!.actionId).toBe("act_alpha");
});
});
});
+377
View File
@@ -0,0 +1,377 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { FilesystemWatcher, configFromEnv } from "../integrations/filesystem-watcher/watcher.mjs";
function tempDir(): string {
return mkdtempSync(join(tmpdir(), "fs-watch-"));
}
function wait(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
describe("FilesystemWatcher", { retry: 2 }, () => {
let root: string;
const originalFetch = globalThis.fetch;
let captured: Array<{ url: string; body: unknown; headers: Record<string, string> }>;
beforeEach(() => {
root = tempDir();
captured = [];
(globalThis as { fetch: typeof fetch }).fetch = (async (
url: string | URL,
init?: RequestInit,
) => {
captured.push({
url: url.toString(),
body: init?.body ? JSON.parse(init.body as string) : null,
headers: (init?.headers || {}) as Record<string, string>,
});
return new Response("{}", { status: 200 });
}) as unknown as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
try {
rmSync(root, { recursive: true, force: true });
} catch {}
});
it("emits a post_tool_use observation with HookPayload shape on write", async () => {
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
w.start();
try {
writeFileSync(join(root, "notes.md"), "hello world\n");
await wait(1500);
expect(captured.length).toBeGreaterThanOrEqual(1);
const obs = captured[captured.length - 1];
expect(obs.url).toBe("http://localhost:3111/agentmemory/observe");
const body = obs.body as {
hookType: string;
sessionId: string;
project: string;
cwd: string;
timestamp: string;
data: { changeKind: string; files: string[]; content: string; source: string };
};
expect(body.hookType).toBe("post_tool_use");
expect(typeof body.sessionId).toBe("string");
expect(body.sessionId.length).toBeGreaterThan(0);
expect(typeof body.project).toBe("string");
expect(body.project.length).toBeGreaterThan(0);
expect(body.cwd).toBe(root);
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(body.data.source).toBe("filesystem-watcher");
expect(body.data.changeKind).toBe("file_change");
expect(body.data.files).toContain("notes.md");
expect(body.data.content).toContain("hello world");
} finally {
w.stop();
}
});
it("emits changeKind=file_delete when a watched file is removed", async () => {
writeFileSync(join(root, "old.md"), "bye\n");
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
w.start();
try {
unlinkSync(join(root, "old.md"));
await wait(1500);
const deletes = captured.filter(
(c) => (c.body as { data: { changeKind: string } }).data?.changeKind === "file_delete",
);
expect(deletes.length).toBeGreaterThanOrEqual(1);
} finally {
w.stop();
}
});
it("throws if no watched roots could be attached", () => {
const w = new FilesystemWatcher({
roots: ["/definitely/does/not/exist/xyz123"],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(() => w.start()).toThrow(/could not watch any of the configured roots/);
});
it("ignores paths that match the default ignore set", async () => {
mkdirSync(join(root, "node_modules"), { recursive: true });
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
w.start();
try {
writeFileSync(join(root, "node_modules", "ignored.js"), "x");
await wait(1500);
const matches = captured.filter((c) =>
(c.body as { data: { files: string[] } }).data?.files?.some((f) => f.includes("ignored.js")),
);
expect(matches).toHaveLength(0);
} finally {
w.stop();
}
});
it("attaches Bearer auth when a secret is configured", async () => {
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
secret: "shhh",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
w.start();
try {
writeFileSync(join(root, "secret.md"), "bearer test\n");
await wait(1500);
expect(captured.length).toBeGreaterThanOrEqual(1);
const headers = captured[captured.length - 1].headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer shhh");
} finally {
w.stop();
}
});
it("redacts sensitive dotenv preview values before sending observations", async () => {
writeFileSync(
join(root, ".env"),
[
"OPENAI_API_KEY=sk-test-secret-value",
"PUBLIC_FLAG=enabled",
"AUTHORIZATION=Bearer live-token-value",
].join("\n"),
);
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
await w.flush(root, ".env");
expect(captured).toHaveLength(1);
const content = (captured[0].body as { data: { content: string } }).data.content;
expect(content).toContain("OPENAI_API_KEY=[REDACTED]");
expect(content).toContain("PUBLIC_FLAG=enabled");
expect(content).toContain("AUTHORIZATION=[REDACTED]");
expect(content).not.toContain("sk-test-secret-value");
expect(content).not.toContain("live-token-value");
});
it("redacts quoted JSON-style sensitive keys before sending observations", async () => {
writeFileSync(
join(root, "settings.json"),
[
'{',
' "api_key": "json-preview-secret",',
' "public_flag": "enabled"',
'}',
].join("\n"),
);
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
await w.flush(root, "settings.json");
expect(captured).toHaveLength(1);
const content = (captured[0].body as { data: { content: string } }).data.content;
expect(content).toContain('"api_key": [REDACTED]');
expect(content).toContain('"public_flag": "enabled"');
expect(content).not.toContain("json-preview-secret");
});
it("redacts bearer tokens from regular text previews before sending observations", async () => {
writeFileSync(join(root, "request.txt"), "Authorization: Bearer plaintext-token-value\n");
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
await w.flush(root, "request.txt");
expect(captured).toHaveLength(1);
const content = (captured[0].body as { data: { content: string } }).data.content;
expect(content).toContain("Authorization: Bearer [REDACTED]");
expect(content).not.toContain("plaintext-token-value");
});
it("collapses multi-line PEM private-key blocks while keeping BEGIN/END markers", async () => {
const dashes = "-".repeat(5);
const rsaBegin = `${dashes}BEGIN RSA PRIVATE KEY${dashes}`;
const rsaEnd = `${dashes}END RSA PRIVATE KEY${dashes}`;
const sshBegin = `${dashes}BEGIN OPENSSH PRIVATE KEY${dashes}`;
const sshEnd = `${dashes}END OPENSSH PRIVATE KEY${dashes}`;
writeFileSync(
join(root, "id_rsa.txt"),
[
rsaBegin,
"MIIEowIBAAKCAQEAuRFakeRsaBodyLine1ShouldNeverLeakToObservationPipeline",
"MoreFakeBase64BodyForRsaKeyMaterialThatMustStayRedacted",
"YetAnotherSecretLineOfBase64KeyContentNoOneShouldRead",
rsaEnd,
"",
sshBegin,
"b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtb25l",
"b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtdHdv",
sshEnd,
"",
].join("\n"),
);
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
await w.flush(root, "id_rsa.txt");
expect(captured).toHaveLength(1);
const content = (captured[0].body as { data: { content: string } }).data.content;
expect(content).toContain(rsaBegin);
expect(content).toContain(rsaEnd);
expect(content).toContain(sshBegin);
expect(content).toContain(sshEnd);
expect(content).toContain("[REDACTED]");
expect(content).not.toContain("MIIEowIBAAKCAQEAuRFakeRsaBodyLine1");
expect(content).not.toContain("MoreFakeBase64BodyForRsaKeyMaterial");
expect(content).not.toContain("YetAnotherSecretLineOfBase64KeyContent");
expect(content).not.toContain("b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtb25l");
expect(content).not.toContain("b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtdHdv");
});
it("redacts inline PEM blocks embedded in single-line JSON values", async () => {
const dashes = "-".repeat(5);
const pemBegin = `${dashes}BEGIN PRIVATE KEY${dashes}`;
const pemEnd = `${dashes}END PRIVATE KEY${dashes}`;
const inlinePem = `${pemBegin}\\nMIIEvgIBADANBgkqhkiG9w0FakeServiceAccountBody\\n${pemEnd}`;
writeFileSync(
join(root, "service-account.json"),
`{\n "type": "service_account",\n "private_key": "${inlinePem}",\n "client_email": "demo@example.com"\n}\n`,
);
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
await w.flush(root, "service-account.json");
expect(captured).toHaveLength(1);
const content = (captured[0].body as { data: { content: string } }).data.content;
expect(content).toContain(pemBegin);
expect(content).toContain(pemEnd);
expect(content).toContain("[REDACTED]");
expect(content).not.toContain("MIIEvgIBADANBgkqhkiG9w0FakeServiceAccountBody");
expect(content).toContain('"client_email": "demo@example.com"');
});
it("redacts standalone JWT-looking strings outside Bearer context", async () => {
const jwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
writeFileSync(
join(root, "notes.txt"),
["session token below:", jwt, "end of token"].join("\n"),
);
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
await w.flush(root, "notes.txt");
expect(captured).toHaveLength(1);
const content = (captured[0].body as { data: { content: string } }).data.content;
expect(content).toContain("[REDACTED]");
expect(content).not.toContain(jwt);
expect(content).toContain("end of token");
});
it("does not redact base64-looking words that are not three-segment JWTs of sufficient length", async () => {
const notJwt = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const shortThreeSegment = "eyJabc.def.ghi";
expect(notJwt.length).toBe(62);
expect(shortThreeSegment.length).toBeLessThan(100);
writeFileSync(
join(root, "fixture.txt"),
["random base64-ish word:", notJwt, "tiny segmented thing:", shortThreeSegment].join("\n"),
);
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
await w.flush(root, "fixture.txt");
expect(captured).toHaveLength(1);
const content = (captured[0].body as { data: { content: string } }).data.content;
expect(content).toContain(notJwt);
expect(content).toContain(shortThreeSegment);
expect(content).not.toContain("[REDACTED]");
});
it("debounces rapid writes to a single observation", async () => {
const w = new FilesystemWatcher({
roots: [root],
baseUrl: "http://localhost:3111",
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
w.start();
try {
const target = join(root, "burst.md");
writeFileSync(target, "1\n");
writeFileSync(target, "2\n");
writeFileSync(target, "3\n");
writeFileSync(target, "4\n");
await wait(900);
const hits = captured.filter((c) =>
(c.body as { data: { files: string[] } }).data?.files?.[0] === "burst.md",
);
expect(hits.length).toBeLessThanOrEqual(2);
} finally {
w.stop();
}
});
});
describe("configFromEnv", () => {
it("parses comma-separated dirs and ignore patterns", () => {
const cfg = configFromEnv({
AGENTMEMORY_FS_WATCH_DIRS: " /a , /b ",
AGENTMEMORY_FS_WATCH_IGNORE: "foo$, ^bar",
AGENTMEMORY_URL: "http://localhost:3111",
AGENTMEMORY_SECRET: "tok",
AGENTMEMORY_PROJECT: "demo",
});
expect(cfg.roots).toEqual(["/a", "/b"]);
expect(cfg.baseUrl).toBe("http://localhost:3111");
expect(cfg.secret).toBe("tok");
expect(cfg.project).toBe("demo");
expect(cfg.ignorePatterns).toHaveLength(2);
expect(cfg.ignorePatterns[0].test("abcfoo")).toBe(true);
expect(cfg.ignorePatterns[1].test("barbaz")).toBe(true);
});
it("returns empty roots when the env var is missing", () => {
const cfg = configFromEnv({});
expect(cfg.roots).toEqual([]);
});
});
+256
View File
@@ -0,0 +1,256 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerGovernanceFunction } from "../src/functions/governance.js";
import {
getSearchIndex,
setIndexPersistence,
} from "../src/functions/search.js";
import { memoryToObservation } from "../src/state/memory-utils.js";
import type { Memory, AuditEntry } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeMemory(id: string, type: Memory["type"] = "pattern"): Memory {
return {
id,
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
type,
title: `Memory ${id}`,
content: `Content for ${id}`,
concepts: ["test"],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
};
}
describe("Governance Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(async () => {
sdk = mockSdk();
kv = mockKV();
registerGovernanceFunction(sdk as never, kv as never);
await kv.set("mem:memories", "mem_1", makeMemory("mem_1", "pattern"));
await kv.set("mem:memories", "mem_2", makeMemory("mem_2", "bug"));
await kv.set("mem:memories", "mem_3", makeMemory("mem_3", "pattern"));
});
it("governance-delete removes specified memories", async () => {
const result = (await sdk.trigger("mem::governance-delete", {
memoryIds: ["mem_1"],
reason: "outdated",
})) as { success: boolean; deleted: number; total: number };
expect(result.success).toBe(true);
expect(result.deleted).toBe(1);
expect(result.total).toBe(1);
const remaining = await kv.list("mem:memories");
expect(remaining.length).toBe(2);
});
it("governance-delete handles non-existent IDs gracefully", async () => {
const result = (await sdk.trigger("mem::governance-delete", {
memoryIds: ["nonexistent_1", "nonexistent_2"],
})) as { success: boolean; deleted: number; total: number };
expect(result.success).toBe(true);
expect(result.deleted).toBe(0);
expect(result.total).toBe(2);
const remaining = await kv.list("mem:memories");
expect(remaining.length).toBe(3);
});
it("governance-bulk deletes by type filter", async () => {
const result = (await sdk.trigger("mem::governance-bulk", {
type: ["pattern"],
})) as { success: boolean; deleted: number };
expect(result.success).toBe(true);
expect(result.deleted).toBe(2);
const remaining = await kv.list<Memory>("mem:memories");
expect(remaining.length).toBe(1);
expect(remaining[0].type).toBe("bug");
});
it("governance-bulk respects dryRun", async () => {
const result = (await sdk.trigger("mem::governance-bulk", {
type: ["pattern"],
dryRun: true,
})) as { success: boolean; dryRun: boolean; wouldDelete: number; ids: string[] };
expect(result.success).toBe(true);
expect(result.dryRun).toBe(true);
expect(result.wouldDelete).toBe(2);
expect(result.ids).toContain("mem_1");
expect(result.ids).toContain("mem_3");
const remaining = await kv.list("mem:memories");
expect(remaining.length).toBe(3);
});
// Delete paths must tear down the BM25 index entry and trigger an
// IndexPersistence save so a hard process exit can't restore a stale
// snapshot at next boot.
describe("search index cleanup on delete", () => {
function indexedObs(id: string, title: string) {
return memoryToObservation({
id,
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
type: "fact",
title,
content: title,
concepts: [],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
});
}
function mockPersistence() {
return {
scheduleSave: vi.fn(),
save: vi.fn(async () => {}),
};
}
beforeEach(() => {
// SearchIndex is a module-level singleton — wipe it so cases
// don't bleed into each other.
getSearchIndex().clear();
setIndexPersistence(null);
});
// The persistence singleton is module-scoped; without this reset
// the last test's mock would leak into sibling tests in the outer
// suite and trigger unexpected spy invocations.
afterEach(() => {
setIndexPersistence(null);
});
it("governance-delete removes the memory from the search index", async () => {
getSearchIndex().add(indexedObs("mem_1", "alpha"));
getSearchIndex().add(indexedObs("mem_2", "beta"));
expect(getSearchIndex().has("mem_1")).toBe(true);
await sdk.trigger("mem::governance-delete", {
memoryIds: ["mem_1"],
});
expect(getSearchIndex().has("mem_1")).toBe(false);
expect(getSearchIndex().has("mem_2")).toBe(true);
});
it("governance-delete flushes persistence immediately", async () => {
const persistence = mockPersistence();
setIndexPersistence(persistence);
getSearchIndex().add(indexedObs("mem_1", "alpha"));
await sdk.trigger("mem::governance-delete", { memoryIds: ["mem_1"] });
// Delete paths must use the synchronous save (not the debounced
// scheduleSave) so a process exit immediately after delete can't
// resurrect the entry on next boot.
expect(persistence.save).toHaveBeenCalled();
});
it("governance-delete skips persistence flush when nothing was deleted", async () => {
const persistence = mockPersistence();
setIndexPersistence(persistence);
await sdk.trigger("mem::governance-delete", {
memoryIds: ["nonexistent_999"],
});
expect(persistence.save).not.toHaveBeenCalled();
});
it("governance-bulk removes deleted memories from the search index", async () => {
getSearchIndex().add(indexedObs("mem_1", "alpha"));
getSearchIndex().add(indexedObs("mem_2", "beta"));
getSearchIndex().add(indexedObs("mem_3", "gamma"));
await sdk.trigger("mem::governance-bulk", { type: ["pattern"] });
// mem_1 and mem_3 are type "pattern" per the outer beforeEach.
expect(getSearchIndex().has("mem_1")).toBe(false);
expect(getSearchIndex().has("mem_3")).toBe(false);
expect(getSearchIndex().has("mem_2")).toBe(true);
});
it("governance-bulk flushes persistence immediately", async () => {
const persistence = mockPersistence();
setIndexPersistence(persistence);
getSearchIndex().add(indexedObs("mem_1", "alpha"));
await sdk.trigger("mem::governance-bulk", { type: ["pattern"] });
expect(persistence.save).toHaveBeenCalled();
});
});
it("audit-query returns audit entries", async () => {
await sdk.trigger("mem::governance-delete", {
memoryIds: ["mem_1"],
reason: "cleanup",
});
const entries = (await sdk.trigger("mem::audit-query", {})) as AuditEntry[];
expect(entries.length).toBe(1);
expect(entries[0].operation).toBe("delete");
expect(entries[0].functionId).toBe("mem::governance-delete");
});
});
+298
View File
@@ -0,0 +1,298 @@
import { describe, it, expect, beforeEach } from "vitest";
import { GraphRetrieval } from "../src/functions/graph-retrieval.js";
import type { GraphNode, GraphEdge } from "../src/types.js";
function mockKV(
nodes: GraphNode[] = [],
edges: GraphEdge[] = [],
) {
const store = new Map<string, Map<string, unknown>>();
const nodesMap = new Map<string, unknown>();
for (const n of nodes) nodesMap.set(n.id, n);
store.set("mem:graph:nodes", nodesMap);
const edgesMap = new Map<string, unknown>();
for (const e of edges) edgesMap.set(e.id, e);
store.set("mem:graph:edges", edgesMap);
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function makeNode(
id: string,
name: string,
type: GraphNode["type"] = "concept",
obsIds: string[] = ["obs_1"],
): GraphNode {
return {
id,
type,
name,
properties: {},
sourceObservationIds: obsIds,
createdAt: new Date().toISOString(),
};
}
function makeEdge(
id: string,
sourceNodeId: string,
targetNodeId: string,
type: GraphEdge["type"] = "related_to",
weight = 0.8,
): GraphEdge {
return {
id,
type,
sourceNodeId,
targetNodeId,
weight,
sourceObservationIds: ["obs_1"],
createdAt: new Date().toISOString(),
tcommit: new Date().toISOString(),
isLatest: true,
};
}
describe("GraphRetrieval", () => {
it("finds entities by name", async () => {
const nodes = [
makeNode("n1", "React", "library", ["obs_1"]),
makeNode("n2", "Vue", "library", ["obs_2"]),
];
const kv = mockKV(nodes, []);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["React"]);
expect(results.length).toBeGreaterThan(0);
expect(results[0].obsId).toBe("obs_1");
});
it("finds entities by partial name match", async () => {
const nodes = [makeNode("n1", "auth-middleware", "function", ["obs_1"])];
const kv = mockKV(nodes, []);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["auth"]);
expect(results.length).toBeGreaterThan(0);
});
it("traverses graph edges to find related observations", async () => {
const nodes = [
makeNode("n1", "React", "library", ["obs_1"]),
makeNode("n2", "Component", "concept", ["obs_2"]),
];
const edges = [makeEdge("e1", "n1", "n2", "uses")];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["React"], 2);
const obsIds = results.map((r) => r.obsId);
expect(obsIds).toContain("obs_1");
expect(obsIds).toContain("obs_2");
});
it("returns empty for no matches", async () => {
const kv = mockKV([], []);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["nonexistent"]);
expect(results).toEqual([]);
});
it("expands from existing chunks", async () => {
const nodes = [
makeNode("n1", "auth.ts", "file", ["obs_1"]),
makeNode("n2", "jwt", "concept", ["obs_2"]),
];
const edges = [makeEdge("e1", "n1", "n2", "uses")];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.expandFromChunks(["obs_1"]);
const obsIds = results.map((r) => r.obsId);
expect(obsIds).toContain("obs_2");
});
it("does not duplicate already-seen observations in expansion", async () => {
const nodes = [makeNode("n1", "file.ts", "file", ["obs_1", "obs_2"])];
const kv = mockKV(nodes, []);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.expandFromChunks(["obs_1"]);
const obsIds = results.map((r) => r.obsId);
expect(obsIds).not.toContain("obs_1");
});
it("performs temporal query - current state", async () => {
const nodes = [makeNode("n1", "Alice", "person", ["obs_1"])];
const edges = [
makeEdge("e1", "n1", "n1", "located_in" as any, 0.9),
{
...makeEdge("e2", "n1", "n1", "located_in" as any, 0.9),
tvalid: "2024-06-01",
isLatest: true,
},
];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const result = await retrieval.temporalQuery("Alice");
expect(result.entity).toBeDefined();
expect(result.entity!.name).toBe("Alice");
expect(result.currentState.length).toBeGreaterThan(0);
});
it("returns null entity for unknown name", async () => {
const kv = mockKV([], []);
const retrieval = new GraphRetrieval(kv as never);
const result = await retrieval.temporalQuery("Unknown");
expect(result.entity).toBeNull();
});
it("scores closer paths higher", async () => {
const nodes = [
makeNode("n1", "React", "library", ["obs_1"]),
makeNode("n2", "Hook", "concept", ["obs_2"]),
makeNode("n3", "State", "concept", ["obs_3"]),
];
const edges = [
makeEdge("e1", "n1", "n2", "uses", 0.9),
makeEdge("e2", "n2", "n3", "related_to", 0.8),
];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["React"], 3);
const directScore = results.find((r) => r.obsId === "obs_1")?.score ?? 0;
const indirectScore = results.find((r) => r.obsId === "obs_3")?.score ?? 0;
expect(directScore).toBeGreaterThan(indirectScore);
});
// Dijkstra path selection (#328). The BFS implementation this
// replaced visited a node via its first-discovered path regardless
// of edge weight. Dijkstra picks the highest-weight (lowest
// 1/weight cost) path, so a one-hop weak edge no longer beats a
// two-hop chain of strong edges to the same node.
it("picks the weight-optimal path under Dijkstra, not the edge-count-shortest one (#328)", async () => {
const nodes = [
makeNode("n1", "Start", "concept", ["obs_start"]),
makeNode("n2", "Mid", "concept", ["obs_mid"]),
makeNode("n3", "End", "concept", ["obs_end"]),
];
const edges = [
// Direct n1 → n3 path with a weak edge. BFS would prefer this.
makeEdge("e_direct", "n1", "n3", "related_to", 0.15),
// Two-hop chain n1 → n2 → n3 with strong edges. Total cost
// (1/0.9) + (1/0.9) ≈ 2.22, vs direct 1/0.15 ≈ 6.67.
// Dijkstra picks the chain.
makeEdge("e_strong_a", "n1", "n2", "related_to", 0.9),
makeEdge("e_strong_b", "n2", "n3", "related_to", 0.9),
];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["Start"], 3);
const endResult = results.find((r) => r.obsId === "obs_end");
expect(endResult).toBeDefined();
// Path is [Start → Mid → End] (length 3) — Dijkstra picked the
// chain of two strong edges over the direct weak one.
expect(endResult!.pathLength).toBe(3);
expect(endResult!.graphContext).toContain("Mid");
});
it("handles disconnected nodes without crashing", async () => {
const nodes = [
makeNode("n1", "A", "concept", ["obs_a"]),
makeNode("n2", "B", "concept", ["obs_b"]),
// n3 is unreachable from the matched node.
makeNode("n3", "Lonely", "concept", ["obs_lonely"]),
];
const edges = [makeEdge("e1", "n1", "n2", "related_to", 0.7)];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["A"], 5);
expect(results.find((r) => r.obsId === "obs_a")).toBeDefined();
expect(results.find((r) => r.obsId === "obs_b")).toBeDefined();
expect(results.find((r) => r.obsId === "obs_lonely")).toBeUndefined();
});
it("clamps near-zero edge weights without dividing by zero", async () => {
const nodes = [
makeNode("n1", "Anchor", "concept", ["obs_anchor"]),
makeNode("n2", "Weak", "concept", ["obs_weak"]),
];
// weight: 0 is malformed but we shouldn't crash on it; the clamp
// floor at 0.01 means traversal completes with a very high cost
// rather than throwing or producing Infinity.
const edges = [makeEdge("e1", "n1", "n2", "related_to", 0)];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["Anchor"], 2);
const weak = results.find((r) => r.obsId === "obs_weak");
expect(weak).toBeDefined();
expect(Number.isFinite(weak!.score)).toBe(true);
});
it("scores startNode observations at 1.0 via the fallback path, not 0.5 via the path-scoring loop (#328 review)", async () => {
// Regression for a bug surfaced by inline review on #463: if the
// traversal includes a length-1 path for the startNode itself,
// the generic path-scoring loop in searchByEntities computes
// avgWeight=0.5 (empty edgeWeights → fallback) and pathLength=1,
// yielding score=0.5, then marks the obs as visited. The
// dedicated score=1.0 fallback loop for startNode obs is then
// skipped via the visitedObs guard — dead code.
const nodes = [
makeNode("n1", "React", "library", ["obs_root"]),
makeNode("n2", "Hook", "concept", ["obs_neighbor"]),
];
const edges = [makeEdge("e1", "n1", "n2", "uses", 0.8)];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["React"], 2);
const root = results.find((r) => r.obsId === "obs_root");
expect(root).toBeDefined();
expect(root!.score).toBe(1.0);
expect(root!.pathLength).toBe(0);
});
it("respects maxDepth bound (Dijkstra stops at edge-count depth)", async () => {
// Chain n1 -> n2 -> n3 -> n4. With maxDepth=2 we should reach n3
// but not n4 — edge-count semantics preserved from the old BFS.
const nodes = [
makeNode("n1", "Start", "concept", ["obs_1"]),
makeNode("n2", "Hop1", "concept", ["obs_2"]),
makeNode("n3", "Hop2", "concept", ["obs_3"]),
makeNode("n4", "Hop3", "concept", ["obs_4"]),
];
const edges = [
makeEdge("e1", "n1", "n2", "related_to", 0.8),
makeEdge("e2", "n2", "n3", "related_to", 0.8),
makeEdge("e3", "n3", "n4", "related_to", 0.8),
];
const kv = mockKV(nodes, edges);
const retrieval = new GraphRetrieval(kv as never);
const results = await retrieval.searchByEntities(["Start"], 2);
expect(results.find((r) => r.obsId === "obs_3")).toBeDefined();
expect(results.find((r) => r.obsId === "obs_4")).toBeUndefined();
});
});
+732
View File
@@ -0,0 +1,732 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerGraphFunction } from "../src/functions/graph.js";
import type {
CompressedObservation,
GraphNode,
GraphEdge,
GraphQueryResult,
} from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
const mockProvider = {
name: "test",
compress: vi.fn().mockResolvedValue(`<entities>
<entity type="file" name="src/index.ts"><property key="path">src/index.ts</property></entity>
<entity type="function" name="main"><property key="lang">typescript</property></entity>
</entities>
<relationships>
<relationship type="uses" source="src/index.ts" target="main" weight="0.9"/>
</relationships>`),
summarize: vi.fn(),
};
const testObs: CompressedObservation = {
id: "obs_1",
sessionId: "ses_1",
timestamp: "2026-02-01T10:00:00Z",
type: "file_edit",
title: "Edit index file",
facts: ["Modified main function"],
narrative: "Updated index.ts with main function",
concepts: ["typescript", "entry-point"],
files: ["src/index.ts"],
importance: 7,
};
describe("Graph Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
vi.clearAllMocks();
registerGraphFunction(sdk as never, kv as never, mockProvider as never);
});
it("graph-extract creates nodes and edges from XML response", async () => {
const result = (await sdk.trigger("mem::graph-extract", {
observations: [testObs],
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
expect(result.success).toBe(true);
expect(result.nodesAdded).toBe(2);
expect(result.edgesAdded).toBe(1);
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
expect(nodes.length).toBe(2);
expect(nodes.find((n) => n.name === "src/index.ts")).toBeDefined();
expect(nodes.find((n) => n.name === "main")).toBeDefined();
const edges = await kv.list<GraphEdge>("mem:graph:edges");
expect(edges.length).toBe(1);
expect(edges[0].type).toBe("uses");
});
it("graph-extract accepts self-closing entity tags", async () => {
mockProvider.compress.mockResolvedValueOnce(`<entities>
<entity type="file" name="src/index.ts"/>
<entity type="function" name="main"><property key="lang">typescript</property></entity>
</entities>
<relationships>
<relationship type="uses" source="src/index.ts" target="main" weight="0.9"/>
</relationships>`);
const result = (await sdk.trigger("mem::graph-extract", {
observations: [testObs],
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
expect(result.success).toBe(true);
expect(result.nodesAdded).toBe(2);
expect(result.edgesAdded).toBe(1);
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
expect(nodes.some((n) => n.name === "src/index.ts")).toBe(true);
expect(nodes.some((n) => n.name === "main")).toBe(true);
const edges = await kv.list<GraphEdge>("mem:graph:edges");
expect(edges).toHaveLength(1);
expect(edges[0].type).toBe("uses");
});
it("graph-extract tolerates reordered attributes (#635)", async () => {
// Codex CLI's LLM tends to emit attribute order name→type and
// source→target→type rather than the hard-coded type-first /
// type/source/target/weight sequence the old parser required.
mockProvider.compress.mockResolvedValueOnce(`<entities>
<entity name="src/index.ts" type="file"/>
<entity name="main" type="function"><property key="lang">typescript</property></entity>
</entities>
<relationships>
<relationship source="src/index.ts" target="main" type="uses" weight="0.9"/>
</relationships>`);
const result = (await sdk.trigger("mem::graph-extract", {
observations: [testObs],
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
expect(result.success).toBe(true);
expect(result.nodesAdded).toBe(2);
expect(result.edgesAdded).toBe(1);
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
expect(nodes.find((n) => n.name === "src/index.ts")?.type).toBe("file");
expect(nodes.find((n) => n.name === "main")?.type).toBe("function");
const edges = await kv.list<GraphEdge>("mem:graph:edges");
expect(edges).toHaveLength(1);
expect(edges[0].type).toBe("uses");
expect(edges[0].weight).toBeCloseTo(0.9, 5);
});
it("graph-query with search returns matching nodes", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const result = (await sdk.trigger("mem::graph-query", {
query: "index",
})) as GraphQueryResult;
expect(result.nodes.length).toBeGreaterThanOrEqual(1);
expect(result.nodes.some((n) => n.name.includes("index"))).toBe(true);
});
it("graph-query with startNodeId does BFS traversal", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
const fileNode = nodes.find((n) => n.name === "src/index.ts")!;
const result = (await sdk.trigger("mem::graph-query", {
startNodeId: fileNode.id,
maxDepth: 2,
})) as GraphQueryResult;
expect(result.nodes.length).toBeGreaterThanOrEqual(1);
expect(result.edges.length).toBeGreaterThanOrEqual(1);
expect(result.depth).toBe(2);
});
it("graph-stats returns counts by type", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const result = (await sdk.trigger("mem::graph-stats", {})) as {
totalNodes: number;
totalEdges: number;
nodesByType: Record<string, number>;
edgesByType: Record<string, number>;
};
expect(result.totalNodes).toBe(2);
expect(result.totalEdges).toBe(1);
expect(result.nodesByType.file).toBe(1);
expect(result.nodesByType.function).toBe(1);
expect(result.edgesByType.uses).toBe(1);
});
it("graph-extract returns error for empty observations", async () => {
const result = (await sdk.trigger("mem::graph-extract", {
observations: [],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("No observations");
});
// #753: an unbounded {} body used to materialize every node+edge in
// one payload, which exceeded the iii state response channel on
// large corpora (11k+ nodes) and returned HTTP 500 "Invocation
// stopped". The fix caps the page at DEFAULT_GRAPH_QUERY_LIMIT (500)
// and surfaces totalNodes / totalEdges so callers know it was
// truncated.
it("caps an unbounded graph-query body to a default page and reports totals", async () => {
// Seed a graph with more nodes than the default page size.
const NODE_COUNT = 1200;
for (let i = 0; i < NODE_COUNT; i++) {
const node: GraphNode = {
id: `n_${i.toString().padStart(4, "0")}`,
type: "concept",
name: `node-${i}`,
properties: {},
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
} as GraphNode;
await kv.set("mem:graph:nodes", node.id, node);
}
// A few edges among the first 50 nodes so high-degree ranking has
// something to grade.
for (let i = 0; i < 50; i++) {
const edge: GraphEdge = {
id: `e_${i}`,
type: "related_to",
sourceNodeId: `n_${i.toString().padStart(4, "0")}`,
targetNodeId: `n_${((i + 1) % 50).toString().padStart(4, "0")}`,
weight: 1,
evidence: [],
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
} as GraphEdge;
await kv.set("mem:graph:edges", edge.id, edge);
}
// Post-#814 the empty-body path reads the snapshot exclusively.
// Backfill the snapshot from the seeded data first.
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
const unbounded = (await sdk.trigger(
"mem::graph-query",
{},
)) as GraphQueryResult;
expect(unbounded.totalNodes).toBe(NODE_COUNT);
expect(unbounded.nodes.length).toBe(500);
expect(unbounded.truncated).toBe(true);
expect(unbounded.limit).toBe(500);
expect(unbounded.offset).toBe(0);
// The 50 connected nodes should be on the first page since the
// default ranks by degree.
const connectedOnPage = unbounded.nodes.filter((n) => /^n_00[0-4]\d$/.test(n.id));
expect(connectedOnPage.length).toBe(50);
});
it("honors limit and offset for paged graph-query traversal", async () => {
for (let i = 0; i < 50; i++) {
const node: GraphNode = {
id: `p_${i.toString().padStart(3, "0")}`,
type: "concept",
name: `node-${i}`,
properties: {},
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
} as GraphNode;
await kv.set("mem:graph:nodes", node.id, node);
}
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
const page1 = (await sdk.trigger("mem::graph-query", {
limit: 10,
offset: 0,
})) as GraphQueryResult;
const page2 = (await sdk.trigger("mem::graph-query", {
limit: 10,
offset: 10,
})) as GraphQueryResult;
expect(page1.nodes.length).toBe(10);
expect(page2.nodes.length).toBe(10);
expect(page1.totalNodes).toBe(50);
expect(page2.totalNodes).toBe(50);
expect(page1.truncated).toBe(true);
// The two pages must not overlap.
const overlap = page1.nodes.filter((n) =>
page2.nodes.some((p) => p.id === n.id),
);
expect(overlap.length).toBe(0);
});
it("clamps an explicit limit above the cap to the cap value", async () => {
for (let i = 0; i < 10; i++) {
await kv.set("mem:graph:nodes", `c_${i}`, {
id: `c_${i}`,
type: "concept",
name: `n-${i}`,
properties: {},
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
});
}
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
const huge = (await sdk.trigger("mem::graph-query", {
limit: 999999,
})) as GraphQueryResult;
expect(huge.limit).toBeLessThanOrEqual(5000);
expect(huge.nodes.length).toBe(10);
expect(huge.truncated).toBe(false);
});
it("paginate excludes edges whose endpoints fall outside the page", async () => {
for (let i = 0; i < 60; i++) {
await kv.set("mem:graph:nodes", `x_${i.toString().padStart(3, "0")}`, {
id: `x_${i.toString().padStart(3, "0")}`,
type: "concept",
name: `n-${i}`,
properties: {},
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
});
}
// Make the first 10 nodes a tightly connected cluster so they
// rank highest by degree and land on the page deterministically.
for (let i = 0; i < 10; i++) {
const next = (i + 1) % 10;
await kv.set("mem:graph:edges", `cluster_${i}`, {
id: `cluster_${i}`,
type: "related_to",
sourceNodeId: `x_${i.toString().padStart(3, "0")}`,
targetNodeId: `x_${next.toString().padStart(3, "0")}`,
weight: 1,
evidence: [],
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
});
}
// Cross-page edge: source in the high-degree cluster (on page),
// target is an isolated node (degree 1; cluster nodes have
// degree 2 so the target ranks below the cap).
await kv.set("mem:graph:edges", "cross", {
id: "cross",
type: "related_to",
sourceNodeId: "x_005",
targetNodeId: "x_055",
weight: 1,
evidence: [],
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
});
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
const page = (await sdk.trigger("mem::graph-query", {
limit: 10,
offset: 0,
})) as GraphQueryResult;
// The cross-page edge should not appear in the page response —
// otherwise the viewer renders a dangling line to a node it
// doesn't have.
expect(page.edges.find((e) => e.id === "cross")).toBeUndefined();
// Cluster edges among page nodes ARE present.
expect(page.edges.filter((e) => e.id.startsWith("cluster_")).length).toBe(10);
// totalEdges counts every edge in the full result universe.
expect(page.totalEdges).toBe(11);
});
// #814: precomputed snapshot path. The viewer-tab default-cap query
// and graph-stats both have to work at 75K-node scale where the
// full kv.list enumeration exceeds the iii invocation budget.
describe("snapshot cache (#814)", () => {
async function seed(nodeCount: number, edgeCount: number) {
for (let i = 0; i < nodeCount; i++) {
await kv.set("mem:graph:nodes", `n_${i}`, {
id: `n_${i}`,
type: i % 3 === 0 ? "file" : "function",
name: `node-${i}`,
properties: {},
sourceObservationIds: [`obs_${i}`],
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
stale: false,
});
}
for (let i = 0; i < edgeCount; i++) {
const src = `n_${i % nodeCount}`;
const dst = `n_${(i + 1) % nodeCount}`;
await kv.set("mem:graph:edges", `e_${i}`, {
id: `e_${i}`,
type: i % 2 === 0 ? "uses" : "imports",
sourceNodeId: src,
targetNodeId: dst,
weight: 1,
evidence: [],
sourceObservationIds: [`obs_${i}`],
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
stale: false,
});
}
}
it("snapshot-rebuild persists top-degree subgraph + aggregate stats", async () => {
await seed(50, 100);
const result = (await sdk.trigger("mem::graph-snapshot-rebuild", { force: true })) as {
success: boolean;
totalNodes: number;
totalEdges: number;
topNodes: number;
topEdges: number;
};
expect(result.success).toBe(true);
expect(result.totalNodes).toBe(50);
expect(result.totalEdges).toBe(100);
// 50 nodes is below the SNAPSHOT_TOP_NODES cap, so every node
// lands in the snapshot.
expect(result.topNodes).toBe(50);
const snap = await kv.get<{
version: number;
topNodes: unknown[];
stats: { totalNodes: number; nodesByType: Record<string, number> };
}>("mem:graph:snapshot", "current");
expect(snap).not.toBeNull();
expect(snap!.version).toBe(1);
expect(snap!.stats.totalNodes).toBe(50);
// nodesByType reflects every type seen.
expect(snap!.stats.nodesByType["file"]).toBeGreaterThan(0);
expect(snap!.stats.nodesByType["function"]).toBeGreaterThan(0);
});
it("graph-query empty-body branch serves from snapshot once it exists", async () => {
await seed(20, 30);
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
const result = (await sdk.trigger("mem::graph-query", {})) as GraphQueryResult;
expect(result.fromSnapshot).toBe(true);
expect(result.totalNodes).toBe(20);
expect(result.totalEdges).toBe(30);
});
it("graph-query nodeType filter respects snapshot type counts", async () => {
await seed(30, 0);
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
const fileQuery = (await sdk.trigger("mem::graph-query", {
nodeType: "file",
})) as GraphQueryResult;
expect(fileQuery.fromSnapshot).toBe(true);
// 30 nodes, every 3rd is "file" → 10 files.
expect(fileQuery.totalNodes).toBe(10);
for (const n of fileQuery.nodes) {
expect(n.type).toBe("file");
}
});
it("graph-stats returns from snapshot when not dirty", async () => {
await seed(15, 25);
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
const stats = (await sdk.trigger("mem::graph-stats", {})) as {
totalNodes: number;
totalEdges: number;
fromSnapshot: boolean;
};
expect(stats.fromSnapshot).toBe(true);
expect(stats.totalNodes).toBe(15);
expect(stats.totalEdges).toBe(25);
});
it("graph-extract updates snapshot inline (no kv.list, dirty stays false)", async () => {
// Post-#814 v2 the snapshot is updated incrementally on every
// extract — no dirty flag bounces. Test asserts that after an
// extract the snapshot reflects the new nodes/edges.
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const snap = await kv.get<{
dirty: boolean;
stats: { totalNodes: number };
}>("mem:graph:snapshot", "current");
expect(snap?.dirty).toBe(false);
// testObs produces 2 nodes (src/index.ts, main) + 1 edge.
expect(snap?.stats.totalNodes).toBeGreaterThanOrEqual(1);
});
it("graph-extract maintains name-index for O(1) dedup on re-extract", async () => {
// First extract creates nodes.
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const nameIndex = await kv.get<string>(
"mem:graph:name-index",
"file|src/index.ts",
);
expect(typeof nameIndex).toBe("string");
// Re-extract the same observation. With name-index lookup the
// existing node merges; no duplicates.
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const nodes = await kv.list<{ name: string; type: string }>(
"mem:graph:nodes",
);
const fileNodes = nodes.filter(
(n) => n.name === "src/index.ts" && n.type === "file",
);
expect(fileNodes.length).toBe(1);
});
it("graph-stats returns empty envelope + warning when no snapshot exists", async () => {
// Seed nodes but never rebuild the snapshot — simulates a legacy
// corpus on a post-#814 upgrade.
await seed(5, 5);
const stats = (await sdk.trigger("mem::graph-stats", {})) as {
totalNodes: number;
totalEdges: number;
fromSnapshot: boolean;
warning?: string;
};
expect(stats.fromSnapshot).toBe(false);
expect(stats.totalNodes).toBe(0);
expect(stats.warning).toMatch(/snapshot-rebuild|graph\/reset/);
});
it("graph-reset clears state and writes empty snapshot", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const result = (await sdk.trigger("mem::graph-reset", {})) as {
success: boolean;
cleared: Record<string, number>;
};
expect(result.success).toBe(true);
const snap = await kv.get<{
stats: { totalNodes: number };
}>("mem:graph:snapshot", "current");
expect(snap?.stats.totalNodes).toBe(0);
});
it("graph-reset writes empty snapshot; legacy rows stay as orphans (#825)", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
// Index entries exist after the extract.
const nameBefore = await kv.get(
"mem:graph:name-index",
"file|src/index.ts",
);
expect(nameBefore).not.toBeNull();
await sdk.trigger("mem::graph-reset", {});
// Post-#825: reset is enumeration-free. It writes an empty
// snapshot; the legacy index rows remain on disk as orphans
// but are never read by any post-#816 code path (hot path
// reads only the snapshot, which is now empty). Asserting the
// visible behavior: snapshot empty, hot path returns empty.
const snap = await kv.get<{
stats: { totalNodes: number; totalEdges: number };
}>("mem:graph:snapshot", "current");
expect(snap?.stats.totalNodes).toBe(0);
expect(snap?.stats.totalEdges).toBe(0);
});
});
// CodeRabbit feedback: cover the timeout-budget fallback path and
// the oversized-corpus rebuild refusal. The hot path never enumerates
// any more, but the rebuild endpoint AND the BFS / query branches
// still call kv.list — both need explicit failure-mode tests.
describe("budget + tooLarge guards (#814 v2)", () => {
function slowKV(delayMs: number) {
const base = mockKV();
return {
...base,
list: async <T>(scope: string): Promise<T[]> => {
await new Promise((r) => setTimeout(r, delayMs));
return base.list<T>(scope);
},
};
}
it("graph-query startNodeId returns warning envelope when enumeration exceeds budget", async () => {
const slow = slowKV(7000); // > LIVE_ENUMERATION_BUDGET_MS (6000ms)
const localSdk = mockSdk();
registerGraphFunction(localSdk as never, slow as never, mockProvider as never);
const result = (await localSdk.trigger("mem::graph-query", {
startNodeId: "n_missing",
})) as GraphQueryResult;
expect(result.warning).toBeTruthy();
expect(result.warning).toMatch(/budget|enumeration/i);
}, 10000);
// CodeRabbit raised that slowKV(setTimeout) doesn't simulate a
// blocked event loop. The real production failure is iii rejecting
// the trigger with "Invocation stopped" after the worker dies
// (heartbeat starvation). A rejecting kv.list mock covers that
// catch-path directly without introducing a busy-wait that would
// also starve the budget timer and produce a flaky test.
function rejectingKV() {
const base = mockKV();
return {
...base,
list: async <T>(_scope: string): Promise<T[]> => {
throw new Error("Invocation stopped");
},
};
}
it("graph-query rejects-from-engine path returns warning envelope (worker-death simulation)", async () => {
const rejector = rejectingKV();
const localSdk = mockSdk();
registerGraphFunction(
localSdk as never,
rejector as never,
mockProvider as never,
);
const result = (await localSdk.trigger("mem::graph-query", {
startNodeId: "n_missing",
})) as GraphQueryResult;
expect(result.warning).toBeTruthy();
expect(result.nodes).toEqual([]);
});
it("graph-snapshot-rebuild refuses corpora past REBUILD_SAFE_NODE_CEILING", async () => {
// Direct-poke the mock store with > 25K node values so kv.list
// returns them without paying the per-set cost. Each node only
// needs id/type/name/stale=false for the rebuild path.
const localKv = mockKV();
// Walk the implementation detail: mockKV stores entries in a
// Map under the scope key. Push directly to that map via the
// public `set` API in a tight loop.
const COUNT = 25001;
const sets: Array<Promise<unknown>> = [];
for (let i = 0; i < COUNT; i++) {
sets.push(
localKv.set("mem:graph:nodes", `bn_${i}`, {
id: `bn_${i}`,
type: "concept",
name: `bulk-${i}`,
properties: {},
sourceObservationIds: [],
createdAt: "2026-01-01T00:00:00Z",
stale: false,
}),
);
}
await Promise.all(sets);
const localSdk = mockSdk();
registerGraphFunction(localSdk as never, localKv as never, mockProvider as never);
const result = (await localSdk.trigger(
"mem::graph-snapshot-rebuild",
{ force: true },
)) as { success: boolean; tooLarge?: boolean; totalNodes?: number };
expect(result.success).toBe(false);
expect(result.tooLarge).toBe(true);
expect(result.totalNodes).toBeGreaterThanOrEqual(25001);
});
// #825: new pre-flight refusal when no snapshot exists (signals
// legacy corpus that would crash on kv.list). force=true bypasses.
it("graph-snapshot-rebuild refuses on legacy corpus (no snapshot) without force", async () => {
const localKv = mockKV();
// Seed nodes but never persist a snapshot → simulates a corpus
// built on a pre-#814 agentmemory.
await localKv.set("mem:graph:nodes", "legacy_n", {
id: "legacy_n",
type: "concept",
name: "legacy",
properties: {},
sourceObservationIds: [],
createdAt: "2026-01-01T00:00:00Z",
stale: false,
});
const localSdk = mockSdk();
registerGraphFunction(localSdk as never, localKv as never, mockProvider as never);
const result = (await localSdk.trigger(
"mem::graph-snapshot-rebuild",
{},
)) as { success: boolean; legacyCorpus?: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.legacyCorpus).toBe(true);
expect(result.error).toMatch(/graph\/reset|force/);
});
it("graph-reset is enumeration-free (does not call kv.list)", async () => {
// Wrap the mock kv.list with a counter; assert it stays at 0
// across a full reset cycle.
const localKv = mockKV();
let listCalls = 0;
const baseList = localKv.list;
localKv.list = async <T,>(scope: string): Promise<T[]> => {
listCalls += 1;
return baseList.call(localKv, scope) as Promise<T[]>;
};
const localSdk = mockSdk();
registerGraphFunction(localSdk as never, localKv as never, mockProvider as never);
const result = (await localSdk.trigger("mem::graph-reset", {})) as {
success: boolean;
};
expect(result.success).toBe(true);
expect(listCalls).toBe(0);
});
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { evaluateHealth } from "../src/health/thresholds.js";
import type { HealthSnapshot } from "../src/types.js";
function snap(over: Partial<HealthSnapshot> = {}): HealthSnapshot {
return {
connectionState: "connected",
workers: [],
memory: { heapUsed: 0, heapTotal: 1, rss: 0, external: 0 },
cpu: { userMicros: 0, systemMicros: 0, percent: 0 },
eventLoopLagMs: 0,
uptimeSeconds: 1,
kvConnectivity: { status: "ok", latencyMs: 1 },
status: "healthy",
alerts: [],
...over,
};
}
describe("evaluateHealth memory severity", () => {
it("stays healthy when heap fills a tiny steady-state process (issue #158)", () => {
const s = snap({
memory: {
heapUsed: 45 * 1024 * 1024,
heapTotal: 46 * 1024 * 1024,
rss: 120 * 1024 * 1024,
external: 0,
},
});
const { status, alerts, notes } = evaluateHealth(s);
expect(status).toBe("healthy");
expect(alerts.find((a) => a.startsWith("memory_critical_"))).toBeUndefined();
expect(alerts.find((a) => a.startsWith("memory_warn_"))).toBeUndefined();
expect(alerts.find((a) => a.startsWith("memory_heap_tight_"))).toBeUndefined();
expect(notes.find((n) => n.startsWith("memory_heap_tight_"))).toBeDefined();
});
it("goes critical when heap ratio is high AND RSS is above the floor", () => {
const s = snap({
memory: {
heapUsed: 970 * 1024 * 1024,
heapTotal: 1000 * 1024 * 1024,
rss: 1100 * 1024 * 1024,
external: 0,
},
});
const { status, alerts } = evaluateHealth(s);
expect(status).toBe("critical");
expect(alerts.some((a) => a.startsWith("memory_critical_"))).toBe(true);
});
it("records heap_tight in the warn band when RSS is below the floor", () => {
const s = snap({
memory: {
heapUsed: 85 * 1024 * 1024,
heapTotal: 100 * 1024 * 1024,
rss: 50 * 1024 * 1024,
external: 0,
},
});
const { status, alerts, notes } = evaluateHealth(s);
expect(status).toBe("healthy");
expect(notes.some((n) => n.startsWith("memory_heap_tight_"))).toBe(true);
expect(alerts.some((a) => a.startsWith("memory_heap_tight_"))).toBe(false);
expect(alerts.some((a) => a.startsWith("memory_warn_"))).toBe(false);
expect(alerts.some((a) => a.startsWith("memory_critical_"))).toBe(false);
});
it("goes degraded when heap is above warn AND RSS is above the floor", () => {
const s = snap({
memory: {
heapUsed: 850 * 1024 * 1024,
heapTotal: 1000 * 1024 * 1024,
rss: 900 * 1024 * 1024,
external: 0,
},
});
const { status, alerts } = evaluateHealth(s, { memoryRssFloorBytes: 800 * 1024 * 1024 });
expect(status).toBe("degraded");
expect(alerts.some((a) => a.startsWith("memory_warn_"))).toBe(true);
});
it("respects caller-supplied memoryRssFloorBytes", () => {
const s = snap({
memory: {
heapUsed: 98,
heapTotal: 100,
rss: 50 * 1024 * 1024,
external: 0,
},
});
const loose = evaluateHealth(s, { memoryRssFloorBytes: 10 * 1024 * 1024 });
expect(loose.status).toBe("critical");
const strict = evaluateHealth(s, { memoryRssFloorBytes: 1024 * 1024 * 1024 });
expect(strict.status).toBe("healthy");
});
});
+53
View File
@@ -0,0 +1,53 @@
import { vi } from "vitest";
type Handler = (data: unknown) => Promise<unknown>;
export function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
export function mockSdk() {
const functions = new Map<string, Handler>();
return {
registerFunction: (
idOrOpts: string | { id: string },
handler: Handler,
_options?: Record<string, unknown>,
) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: vi.fn(),
trigger: async (
idOrInput:
| string
| { function_id: string; payload: unknown; action?: unknown },
data?: unknown,
) => {
const id =
typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload =
typeof idOrInput === "string" ? data : (idOrInput.payload as unknown);
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
const expectedHermesHooks = [
"prefetch",
"sync_turn",
"on_session_end",
"on_pre_compress",
"on_memory_write",
"system_prompt_block",
];
function readHermesPluginHooks(): string[] {
const manifest = readFileSync("integrations/hermes/plugin.yaml", "utf8");
const hooks: string[] = [];
let inHooks = false;
for (const line of manifest.split(/\r?\n/)) {
if (line.trim() === "hooks:") {
inHooks = true;
continue;
}
if (!inHooks) continue;
if (line.trim() === "") continue;
if (!line.startsWith(" ")) break;
const match = line.match(/^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/);
if (match) hooks.push(match[1]);
}
return hooks;
}
function isHermesLifecycleHook(methodName: string): boolean {
return (
methodName === "prefetch" ||
methodName === "sync_turn" ||
methodName === "system_prompt_block" ||
methodName.startsWith("on_")
);
}
function readAgentMemoryProviderHookMethods(): string[] {
const source = readFileSync("integrations/hermes/__init__.py", "utf8");
const methods: string[] = [];
const providerMethodPattern = /^ def ([a-z_][a-z0-9_]*)\(/gm;
for (const match of source.matchAll(providerMethodPattern)) {
const methodName = match[1];
if (isHermesLifecycleHook(methodName)) methods.push(methodName);
}
return methods;
}
describe("Hermes plugin manifest", () => {
it("declares every implemented lifecycle hook", () => {
const declaredHooks = readHermesPluginHooks();
const implementedHooks = readAgentMemoryProviderHookMethods();
expect([...declaredHooks].sort()).toEqual([...implementedHooks].sort());
expect(declaredHooks).toEqual(expectedHermesHooks);
});
it("preloads AGENTMEMORY_URL default at import time", () => {
const source = readFileSync("integrations/hermes/__init__.py", "utf8");
expect(source).toMatch(
/os\.environ\.setdefault\(\s*["']AGENTMEMORY_URL["']\s*,\s*DEFAULT_BASE_URL\s*\)/,
);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveProject } from "../src/hooks/_project.js";
describe("resolveProject — hook project basename resolver", () => {
const originalEnv = process.env.AGENTMEMORY_PROJECT_NAME;
beforeEach(() => {
delete process.env.AGENTMEMORY_PROJECT_NAME;
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.AGENTMEMORY_PROJECT_NAME;
} else {
process.env.AGENTMEMORY_PROJECT_NAME = originalEnv;
}
});
it("AGENTMEMORY_PROJECT_NAME env wins over everything", () => {
process.env.AGENTMEMORY_PROJECT_NAME = "my-override";
expect(resolveProject("/var/log")).toBe("my-override");
expect(resolveProject(process.cwd())).toBe("my-override");
});
it("trims whitespace on env override", () => {
process.env.AGENTMEMORY_PROJECT_NAME = " spaced ";
expect(resolveProject("/var/log")).toBe("spaced");
});
it("ignores empty env override", () => {
process.env.AGENTMEMORY_PROJECT_NAME = " ";
const repoBasename = "agentmemory";
expect(resolveProject(process.cwd())).toBe(repoBasename);
});
it("returns git toplevel basename when cwd is inside a repo", () => {
const top = resolveProject(process.cwd());
expect(top).toBe("agentmemory");
});
it("returns git toplevel basename from a nested subdir", () => {
const nested = join(process.cwd(), "src", "hooks");
expect(resolveProject(nested)).toBe("agentmemory");
});
it("falls back to basename(cwd) when not in a git repo", () => {
const dir = mkdtempSync(join(tmpdir(), "amem-noproj-"));
try {
expect(resolveProject(dir)).toBe(dir.split("/").pop());
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("defaults to process.cwd() when no cwd argument given", () => {
expect(resolveProject()).toBe("agentmemory");
});
it("defaults to process.cwd() when cwd argument is empty", () => {
expect(resolveProject("")).toBe("agentmemory");
expect(resolveProject(" ")).toBe("agentmemory");
});
});
+183
View File
@@ -0,0 +1,183 @@
import { describe, it, expect, beforeEach } from "vitest";
import { HybridSearch } from "../src/state/hybrid-search.js";
import { SearchIndex } from "../src/state/search-index.js";
import type { CompressedObservation, EmbeddingProvider } from "../src/types.js";
function makeObs(
overrides: Partial<CompressedObservation> = {},
): CompressedObservation {
return {
id: "obs_1",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
type: "file_edit",
title: "Edit auth middleware",
subtitle: "JWT validation",
facts: ["Added token check"],
narrative: "Modified the auth middleware to validate JWT tokens",
concepts: ["authentication", "jwt"],
files: ["src/middleware/auth.ts"],
importance: 7,
...overrides,
};
}
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
describe("HybridSearch", () => {
let bm25: SearchIndex;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
bm25 = new SearchIndex();
kv = mockKV();
});
it("returns BM25-only results when no vector index is provided", async () => {
const obs = makeObs({ id: "obs_1", sessionId: "ses_1" });
bm25.add(obs);
await kv.set("mem:obs:ses_1", "obs_1", obs);
const hybrid = new HybridSearch(bm25, null, null, kv as never);
const results = await hybrid.search("auth");
expect(results.length).toBe(1);
expect(results[0].observation.id).toBe("obs_1");
expect(results[0].vectorScore).toBe(0);
expect(results[0].bm25Score).toBeGreaterThan(0);
});
it("returns empty results for no-match query", async () => {
const obs = makeObs({ id: "obs_1", sessionId: "ses_1" });
bm25.add(obs);
await kv.set("mem:obs:ses_1", "obs_1", obs);
const hybrid = new HybridSearch(bm25, null, null, kv as never);
const results = await hybrid.search("database");
expect(results).toEqual([]);
});
it("combinedScore is derived from bm25Score when no vector index", async () => {
const obs = makeObs({ id: "obs_1", sessionId: "ses_1" });
bm25.add(obs);
await kv.set("mem:obs:ses_1", "obs_1", obs);
const hybrid = new HybridSearch(bm25, null, null, kv as never);
const results = await hybrid.search("auth");
expect(results[0].combinedScore).toBeGreaterThan(0);
expect(results[0].vectorScore).toBe(0);
expect(results[0].graphScore).toBe(0);
});
it("results are sorted by combinedScore descending", async () => {
const obs1 = makeObs({
id: "obs_1",
sessionId: "ses_1",
title: "auth handler",
narrative: "auth auth auth module",
concepts: ["auth"],
});
const obs2 = makeObs({
id: "obs_2",
sessionId: "ses_1",
title: "database setup",
narrative: "auth connection config",
concepts: ["database"],
});
bm25.add(obs1);
bm25.add(obs2);
await kv.set("mem:obs:ses_1", "obs_1", obs1);
await kv.set("mem:obs:ses_1", "obs_2", obs2);
const hybrid = new HybridSearch(bm25, null, null, kv as never);
const results = await hybrid.search("auth");
expect(results.length).toBe(2);
expect(results[0].combinedScore).toBeGreaterThanOrEqual(
results[1].combinedScore,
);
});
it("respects limit parameter", async () => {
for (let i = 0; i < 10; i++) {
const obs = makeObs({
id: `obs_${i}`,
sessionId: "ses_1",
title: `auth feature ${i}`,
});
bm25.add(obs);
await kv.set("mem:obs:ses_1", `obs_${i}`, obs);
}
const hybrid = new HybridSearch(bm25, null, null, kv as never);
const results = await hybrid.search("auth", 3);
expect(results.length).toBe(3);
});
it("skips observations not found in KV", async () => {
const obs = makeObs({ id: "obs_missing", sessionId: "ses_1" });
bm25.add(obs);
const hybrid = new HybridSearch(bm25, null, null, kv as never);
const results = await hybrid.search("auth");
expect(results).toEqual([]);
});
it("falls back to KV.memories when an indexed entry is a saved memory (#265)", async () => {
// mem::remember writes to KV.memories under the synthetic sessionId
// "memory" — the BM25 index sees that synthetic sessionId, but
// KV.observations("memory") never has anything.
const indexable = makeObs({
id: "mem_abc",
sessionId: "memory",
title: "Test memory for search",
narrative: "Test memory for search",
concepts: ["test", "search"],
});
bm25.add(indexable);
const memory = {
id: "mem_abc",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "fact",
title: "Test memory for search",
content: "Test memory for search",
concepts: ["test", "search"],
files: [],
sessionIds: [],
strength: 7,
version: 1,
isLatest: true,
};
await kv.set("mem:memories", "mem_abc", memory);
const hybrid = new HybridSearch(bm25, null, null, kv as never);
const results = await hybrid.search("test memory search");
expect(results.length).toBe(1);
expect(results[0].observation.id).toBe("mem_abc");
expect(results[0].observation.narrative).toBe("Test memory for search");
expect(results[0].observation.concepts).toEqual(["test", "search"]);
});
});
+793
View File
@@ -0,0 +1,793 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { IndexPersistence } from "../src/state/index-persistence.js";
import { SearchIndex } from "../src/state/search-index.js";
import { VectorIndex } from "../src/state/vector-index.js";
import type { CompressedObservation } from "../src/types.js";
const BM25_SCOPE = "mem:index:bm25";
const BM25_LEGACY_KEY = "data";
const BM25_MANIFEST_KEY = "data:manifest";
const VECTOR_LEGACY_KEY = "vectors";
const VECTOR_MANIFEST_KEY = "vectors:manifest";
type TestIndexShardManifest = {
v: 1;
generation?: string;
shards: Array<{ scope: string; key: string; chars: number }>;
chars: number;
};
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
type MockKV = ReturnType<typeof mockKV>;
function makeObs(
overrides: Partial<CompressedObservation> = {},
): CompressedObservation {
return {
id: "obs_1",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
type: "file_edit",
title: "Edit auth middleware",
subtitle: "JWT validation",
facts: ["Added token check"],
narrative: "Modified the auth middleware to validate JWT tokens",
concepts: ["authentication", "jwt"],
files: ["src/middleware/auth.ts"],
importance: 7,
...overrides,
};
}
function makeBm25(id: string, title: string): SearchIndex {
const bm25 = new SearchIndex();
bm25.add(makeObs({ id, title, narrative: `${title} narrative` }));
return bm25;
}
function makeVector(id = "obs_1"): VectorIndex {
const vector = new VectorIndex();
vector.add(id, "ses_1", new Float32Array([0.1, 0.2, 0.3]));
return vector;
}
async function getBm25Manifest(kv: MockKV): Promise<TestIndexShardManifest> {
const manifest = await kv.get<TestIndexShardManifest>(
BM25_SCOPE,
BM25_MANIFEST_KEY,
);
expect(manifest).not.toBeNull();
return manifest!;
}
describe("IndexPersistence", () => {
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
vi.useFakeTimers();
kv = mockKV();
});
afterEach(() => {
vi.useRealTimers();
});
it("saves and loads BM25 index round-trip", async () => {
const bm25 = new SearchIndex();
bm25.add(makeObs({ id: "obs_1", title: "auth handler" }));
const persistence = new IndexPersistence(kv as never, bm25, null);
await persistence.save();
const loaded = await persistence.load();
expect(loaded.bm25).not.toBeNull();
expect(loaded.bm25!.size).toBe(1);
const results = loaded.bm25!.search("auth");
expect(results.length).toBe(1);
});
it("saves BM25 index shards outside the BM25 metadata scope", async () => {
const bm25 = new SearchIndex();
bm25.add(
makeObs({
id: "obs_1",
title: "auth handler ".repeat(40),
narrative: "JWT middleware validation ".repeat(40),
}),
);
const persistence = new IndexPersistence(kv as never, bm25, null, {
shardChars: 80,
createGeneration: () => "gen_bm25",
});
await persistence.save();
const manifest = await getBm25Manifest(kv);
expect(manifest.generation).toBe("gen_bm25");
expect(manifest.shards.length).toBeGreaterThan(1);
expect(manifest.shards[0].scope).toContain(":gen_bm25:");
await expect(kv.get(BM25_SCOPE, BM25_LEGACY_KEY)).resolves.toBeNull();
await expect(
kv.get(manifest.shards[0].scope, manifest.shards[0].key),
).resolves.toEqual(expect.any(String));
const loaded = await persistence.load();
expect(loaded.bm25).not.toBeNull();
expect(loaded.bm25!.search("auth").length).toBe(1);
});
it("loads legacy monolithic BM25 and vector snapshots", async () => {
const bm25 = makeBm25("obs_1", "legacy auth handler");
const vector = makeVector("obs_1");
await kv.set(BM25_SCOPE, BM25_LEGACY_KEY, bm25.serialize());
await kv.set(BM25_SCOPE, VECTOR_LEGACY_KEY, vector.serialize());
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25).not.toBeNull();
expect(loaded.bm25!.search("legacy").length).toBe(1);
expect(loaded.vector).not.toBeNull();
expect(loaded.vector!.size).toBe(1);
});
it("fails closed instead of falling back when manifest reads fail", async () => {
const legacy = makeBm25("obs_legacy", "legacy stale snapshot");
await kv.set(BM25_SCOPE, BM25_LEGACY_KEY, legacy.serialize());
const failingKv = {
...kv,
get: vi.fn(async <T>(scope: string, key: string): Promise<T | null> => {
if (scope === BM25_SCOPE && key === BM25_MANIFEST_KEY) {
throw new Error("manifest backend unavailable");
}
return kv.get(scope, key);
}),
};
const loaded = await new IndexPersistence(
failingKv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25).toBeNull();
});
it("fails closed when legacy snapshot reads fail", async () => {
const failingKv = {
...kv,
get: vi.fn(async <T>(scope: string, key: string): Promise<T | null> => {
if (scope === BM25_SCOPE && key === BM25_LEGACY_KEY) {
throw new Error("legacy backend unavailable");
}
return kv.get(scope, key);
}),
};
const loaded = await new IndexPersistence(
failingKv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25).toBeNull();
});
it("loads sharded manifests that omit optional generation metadata", async () => {
const bm25 = makeBm25("obs_1", "deterministic shard auth");
const serialized = bm25.serialize();
const chunks = [serialized.slice(0, 50), serialized.slice(50)];
await kv.set("mem:index:bm25:bm25:00000", "data", chunks[0]);
await kv.set("mem:index:bm25:bm25:00001", "data", chunks[1]);
await kv.set<TestIndexShardManifest>(BM25_SCOPE, BM25_MANIFEST_KEY, {
v: 1,
chars: serialized.length,
shards: [
{
scope: "mem:index:bm25:bm25:00000",
key: "data",
chars: chunks[0].length,
},
{
scope: "mem:index:bm25:bm25:00001",
key: "data",
chars: chunks[1].length,
},
],
});
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25).not.toBeNull();
expect(loaded.bm25!.search("deterministic").length).toBe(1);
});
it("saves and loads vector index round-trip", async () => {
const bm25 = new SearchIndex();
const vector = makeVector();
const persistence = new IndexPersistence(kv as never, bm25, vector);
await persistence.save();
const loaded = await persistence.load();
expect(loaded.vector).not.toBeNull();
expect(loaded.vector!.size).toBe(1);
});
it("saves vector index shards outside the BM25 scope", async () => {
const bm25 = new SearchIndex();
const vector = new VectorIndex();
vector.add(
"obs_1",
"ses_1",
new Float32Array(Array.from({ length: 32 }, (_, i) => i)),
);
const persistence = new IndexPersistence(kv as never, bm25, vector, {
shardChars: 40,
createGeneration: () => "gen_vector",
});
await persistence.save();
const manifest = await kv.get<TestIndexShardManifest>(
BM25_SCOPE,
VECTOR_MANIFEST_KEY,
);
expect(manifest).not.toBeNull();
expect(manifest!.generation).toBe("gen_vector");
expect(manifest!.shards.length).toBeGreaterThan(1);
expect(manifest!.shards[0].scope).toContain(":gen_vector:");
await expect(kv.get(BM25_SCOPE, VECTOR_LEGACY_KEY)).resolves.toBeNull();
await expect(
kv.get(manifest!.shards[0].scope, manifest!.shards[0].key),
).resolves.toEqual(expect.any(String));
const loaded = await persistence.load();
expect(loaded.vector).not.toBeNull();
expect(loaded.vector!.size).toBe(1);
});
it("persists empty vector snapshots so cleared vectors do not reload", async () => {
const previousBm25 = makeBm25("obs_old", "alpha previous snapshot");
const previousVector = makeVector("obs_old");
await new IndexPersistence(kv as never, previousBm25, previousVector, {
shardChars: 80,
createGeneration: () => "gen_old",
}).save();
const nextBm25 = makeBm25("obs_new", "bravo new snapshot");
const emptyVector = new VectorIndex();
await new IndexPersistence(kv as never, nextBm25, emptyVector, {
shardChars: 80,
createGeneration: () => "gen_empty",
}).save();
const vectorManifest = await kv.get<TestIndexShardManifest>(
BM25_SCOPE,
VECTOR_MANIFEST_KEY,
);
expect(vectorManifest).not.toBeNull();
expect(vectorManifest!.generation).toBe("gen_empty");
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25!.search("bravo").length).toBe(1);
expect(loaded.vector).not.toBeNull();
expect(loaded.vector!.size).toBe(0);
});
it("avoids one oversized state::set string payload for persisted indexes", async () => {
const maxStringPayloadChars = 80;
const bm25 = new SearchIndex();
bm25.add(
makeObs({
id: "obs_1",
title: "large persisted snapshot ".repeat(40),
narrative: "oversized state set reproduction ".repeat(40),
}),
);
const vector = new VectorIndex();
vector.add(
"obs_1",
"ses_1",
new Float32Array(Array.from({ length: 64 }, (_, i) => i / 10)),
);
const guardedKv = {
...kv,
set: vi.fn(async <T>(scope: string, key: string, data: T): Promise<T> => {
if (
typeof data === "string" &&
data.length > maxStringPayloadChars
) {
throw new Error(`oversized state::set payload: ${scope}/${key}`);
}
return kv.set(scope, key, data);
}),
};
await new IndexPersistence(guardedKv as never, bm25, vector, {
shardChars: maxStringPayloadChars,
createGeneration: () => "gen_payload_limit",
}).save();
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25!.search("oversized").length).toBe(1);
expect(loaded.vector!.size).toBe(1);
});
it("falls back to the default shard size for fractional values below one", async () => {
const bm25 = makeBm25("obs_fraction", "fractional shard config");
let newShardWrites = 0;
const guardedKv = {
...kv,
set: vi.fn(async <T>(scope: string, key: string, data: T): Promise<T> => {
if (scope.includes(":gen_fraction:")) {
newShardWrites += 1;
if (newShardWrites > 3) {
throw new Error("fractional shard size caused zero-width shards");
}
}
return kv.set(scope, key, data);
}),
};
await new IndexPersistence(guardedKv as never, bm25, null, {
shardChars: 0.5,
createGeneration: () => "gen_fraction",
}).save();
const manifest = await getBm25Manifest(kv);
expect(manifest.generation).toBe("gen_fraction");
expect(manifest.shards.length).toBe(1);
expect(newShardWrites).toBe(1);
});
it("keeps the previous generation when a shard write fails before manifest commit", async () => {
const previous = makeBm25("obs_old", "alpha previous snapshot");
await new IndexPersistence(kv as never, previous, null, {
shardChars: 80,
createGeneration: () => "gen_old",
}).save();
const previousManifest = await getBm25Manifest(kv);
let newShardWrites = 0;
const failingKv = {
...kv,
set: vi.fn(async <T>(scope: string, key: string, data: T): Promise<T> => {
if (scope.includes(":gen_new:")) {
newShardWrites += 1;
if (newShardWrites === 2) throw new Error("shard write failed");
}
return kv.set(scope, key, data);
}),
};
const next = makeBm25("obs_new", "bravo new snapshot");
await new IndexPersistence(failingKv as never, next, null, {
shardChars: 80,
createGeneration: () => "gen_new",
}).save();
await expect(kv.get(BM25_SCOPE, BM25_MANIFEST_KEY)).resolves.toEqual(
previousManifest,
);
await expect(
kv.get("mem:index:bm25:bm25:gen_new:00000", "data"),
).resolves.toBeNull();
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25!.search("alpha").length).toBe(1);
expect(loaded.bm25!.search("bravo").length).toBe(0);
});
it("keeps the previous generation when manifest set rejects before commit", async () => {
const previous = makeBm25("obs_old", "alpha previous snapshot");
await new IndexPersistence(kv as never, previous, null, {
shardChars: 80,
createGeneration: () => "gen_old",
}).save();
const previousManifest = await getBm25Manifest(kv);
const failingKv = {
...kv,
set: vi.fn(async <T>(scope: string, key: string, data: T): Promise<T> => {
if (scope === BM25_SCOPE && key === BM25_MANIFEST_KEY) {
throw new Error("manifest write failed");
}
return kv.set(scope, key, data);
}),
};
const next = makeBm25("obs_new", "bravo new snapshot");
await new IndexPersistence(failingKv as never, next, null, {
shardChars: 80,
createGeneration: () => "gen_new",
}).save();
await expect(kv.get(BM25_SCOPE, BM25_MANIFEST_KEY)).resolves.toEqual(
previousManifest,
);
await expect(
kv.get("mem:index:bm25:bm25:gen_new:00000", "data"),
).resolves.toBeNull();
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25!.search("alpha").length).toBe(1);
expect(loaded.bm25!.search("bravo").length).toBe(0);
});
it("keeps a generation loadable when manifest set commits before rejecting", async () => {
const previous = makeBm25("obs_old", "alpha previous snapshot");
await new IndexPersistence(kv as never, previous, null, {
shardChars: 80,
createGeneration: () => "gen_old",
}).save();
const failingKv = {
...kv,
set: vi.fn(async <T>(scope: string, key: string, data: T): Promise<T> => {
if (scope === BM25_SCOPE && key === BM25_MANIFEST_KEY) {
await kv.set(scope, key, data);
throw new Error("manifest write timed out after commit");
}
return kv.set(scope, key, data);
}),
};
const next = makeBm25("obs_new", "bravo new snapshot");
await new IndexPersistence(failingKv as never, next, null, {
shardChars: 80,
createGeneration: () => "gen_new",
}).save();
const manifest = await getBm25Manifest(kv);
expect(manifest.generation).toBe("gen_new");
await expect(
kv.get("mem:index:bm25:bm25:gen_new:00000", "data"),
).resolves.toEqual(expect.any(String));
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25!.search("bravo").length).toBe(1);
});
it("deletes a shard that committed before set rejected", async () => {
const previous = makeBm25("obs_old", "alpha previous snapshot");
await new IndexPersistence(kv as never, previous, null, {
shardChars: 80,
createGeneration: () => "gen_old",
}).save();
const previousManifest = await getBm25Manifest(kv);
const failingKv = {
...kv,
set: vi.fn(async <T>(scope: string, key: string, data: T): Promise<T> => {
if (scope === "mem:index:bm25:bm25:gen_new:00000") {
await kv.set(scope, key, data);
throw new Error("state::set timed out after commit");
}
return kv.set(scope, key, data);
}),
};
const next = makeBm25("obs_new", "bravo new snapshot");
await new IndexPersistence(failingKv as never, next, null, {
shardChars: 80,
createGeneration: () => "gen_new",
}).save();
await expect(kv.get(BM25_SCOPE, BM25_MANIFEST_KEY)).resolves.toEqual(
previousManifest,
);
await expect(
kv.get("mem:index:bm25:bm25:gen_new:00000", "data"),
).resolves.toBeNull();
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25!.search("alpha").length).toBe(1);
expect(loaded.bm25!.search("bravo").length).toBe(0);
});
it("loads the new generation even when old generation cleanup fails", async () => {
const previous = makeBm25("obs_old", "alpha previous snapshot");
await new IndexPersistence(kv as never, previous, null, {
shardChars: 80,
createGeneration: () => "gen_old",
}).save();
const cleanupKv = {
...kv,
delete: vi.fn(async () => {
throw new Error("cleanup failed");
}),
};
const next = makeBm25("obs_new", "bravo new snapshot");
await new IndexPersistence(cleanupKv as never, next, null, {
shardChars: 80,
createGeneration: () => "gen_new",
}).save();
const manifest = await getBm25Manifest(kv);
expect(manifest.generation).toBe("gen_new");
expect(cleanupKv.delete).toHaveBeenCalled();
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25!.search("bravo").length).toBe(1);
expect(loaded.bm25!.search("alpha").length).toBe(0);
});
it("keeps the previous vector generation when vector save fails after BM25 publish", async () => {
const previousBm25 = makeBm25("obs_old", "alpha previous snapshot");
const previousVector = makeVector("obs_old");
await new IndexPersistence(kv as never, previousBm25, previousVector, {
shardChars: 80,
createGeneration: () => "gen_old",
}).save();
const failingKv = {
...kv,
set: vi.fn(async <T>(scope: string, key: string, data: T): Promise<T> => {
if (scope === BM25_SCOPE && key === VECTOR_MANIFEST_KEY) {
throw new Error("vector manifest write failed");
}
return kv.set(scope, key, data);
}),
};
const nextBm25 = makeBm25("obs_new", "bravo new snapshot");
const nextVector = new VectorIndex();
nextVector.add("obs_new", "ses_1", new Float32Array([0.4, 0.5, 0.6]));
await new IndexPersistence(failingKv as never, nextBm25, nextVector, {
shardChars: 80,
createGeneration: () => "gen_new",
}).save();
await expect(
kv.get("mem:index:bm25:vectors:gen_new:00000", "data"),
).resolves.toBeNull();
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25!.search("bravo").length).toBe(1);
expect(loaded.vector!.size).toBe(1);
expect(
loaded.vector!.search(new Float32Array([0.1, 0.2, 0.3]))[0]?.obsId,
).toBe("obs_old");
});
it("fails closed when a manifest shard is missing", async () => {
const bm25 = makeBm25("obs_1", "alpha sharded snapshot");
await new IndexPersistence(kv as never, bm25, null, {
shardChars: 80,
createGeneration: () => "gen_missing",
}).save();
const manifest = await getBm25Manifest(kv);
await kv.delete(manifest.shards[0].scope, manifest.shards[0].key);
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25).toBeNull();
});
it("fails closed when a manifest shard length mismatches", async () => {
const bm25 = makeBm25("obs_1", "alpha sharded snapshot");
await new IndexPersistence(kv as never, bm25, null, {
shardChars: 80,
createGeneration: () => "gen_mismatch",
}).save();
const manifest = await getBm25Manifest(kv);
const firstShard = manifest.shards[0];
const chunk = await kv.get<string>(firstShard.scope, firstShard.key);
await kv.set(firstShard.scope, firstShard.key, `${chunk}x`);
const loaded = await new IndexPersistence(
kv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25).toBeNull();
});
it("fails closed before reading invalid shard descriptors", async () => {
await kv.set<TestIndexShardManifest>(BM25_SCOPE, BM25_MANIFEST_KEY, {
v: 1,
chars: 10,
shards: [{ scope: "", key: "data", chars: 10 }],
});
const guardedKv = {
...kv,
get: vi.fn(async <T>(scope: string, key: string): Promise<T | null> => {
if (scope === "") {
throw new Error("invalid shard descriptor was read");
}
return kv.get(scope, key);
}),
};
const loaded = await new IndexPersistence(
guardedKv as never,
new SearchIndex(),
null,
).load();
expect(loaded.bm25).toBeNull();
expect(guardedKv.get).not.toHaveBeenCalledWith("", "data");
});
it("scheduleSave debounces multiple calls", async () => {
const bm25 = new SearchIndex();
const persistence = new IndexPersistence(kv as never, bm25, null);
persistence.scheduleSave();
persistence.scheduleSave();
persistence.scheduleSave();
await expect(kv.get(BM25_SCOPE, BM25_MANIFEST_KEY)).resolves.toBeNull();
vi.advanceTimersByTime(5000);
await vi.runAllTimersAsync();
const saved = await kv.get<string>(BM25_SCOPE, BM25_MANIFEST_KEY);
expect(saved).not.toBeNull();
});
it("stop clears the pending timer", async () => {
const bm25 = new SearchIndex();
bm25.add(makeObs({ id: "obs_1", title: "auth handler" }));
const persistence = new IndexPersistence(kv as never, bm25, null);
persistence.scheduleSave();
persistence.stop();
vi.advanceTimersByTime(10000);
const saved = await kv.get<string>(BM25_SCOPE, BM25_MANIFEST_KEY);
expect(saved).toBeNull();
});
it("returns null indexes when nothing has been saved", async () => {
const bm25 = new SearchIndex();
const persistence = new IndexPersistence(kv as never, bm25, null);
const loaded = await persistence.load();
expect(loaded.bm25).toBeNull();
expect(loaded.vector).toBeNull();
});
it("scheduled save swallows kv.set rejection without unhandledRejection (#204)", async () => {
const failingKv = {
...mockKV(),
set: vi.fn(async () => {
const err = new Error(
"TIMEOUT: invocation timed out after 30000ms",
) as Error & { code?: string; function_id?: string };
err.code = "TIMEOUT";
err.function_id = "state::set";
throw err;
}),
};
const bm25 = new SearchIndex();
bm25.add(makeObs({ id: "obs_1", title: "auth handler" }));
const persistence = new IndexPersistence(failingKv as never, bm25, null);
let unhandled = false;
const onUnhandled = () => {
unhandled = true;
};
process.on("unhandledRejection", onUnhandled);
try {
persistence.scheduleSave();
vi.advanceTimersByTime(5000);
await vi.runAllTimersAsync();
// give microtasks a chance to flush
await Promise.resolve();
expect(failingKv.set).toHaveBeenCalled();
expect(unhandled).toBe(false);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("save() does not throw when kv.set rejects (#204)", async () => {
const failingKv = {
...mockKV(),
set: vi.fn(async () => {
throw new Error("TIMEOUT");
}),
};
const bm25 = new SearchIndex();
bm25.add(makeObs({ id: "obs_1", title: "auth handler" }));
const persistence = new IndexPersistence(failingKv as never, bm25, null);
await expect(persistence.save()).resolves.toBeUndefined();
});
// #797: first run after upgrading to 0.9.25 crashed with
// 'TypeError: Cannot read properties of undefined (reading "v")'
// because some iii-state adapters return `undefined` (not `null`)
// for a missing key. The load path's `value !== null` check passed
// undefined to loadManifestData, which then read `undefined.v`.
it("load() returns null instead of crashing when kv.get returns undefined for the manifest (#797)", async () => {
const undefinedKv = {
...mockKV(),
get: vi.fn(async () => undefined),
};
const persistence = new IndexPersistence(
undefinedKv as never,
new SearchIndex(),
null,
);
const loaded = await persistence.load();
expect(loaded.bm25).toBeNull();
expect(loaded.vector).toBeNull();
});
it("load() does not crash when a manifest row value is the wrong shape (#797)", async () => {
const wrongShapeKv = {
...mockKV(),
get: vi.fn(async () => "not-a-manifest"),
};
const persistence = new IndexPersistence(
wrongShapeKv as never,
new SearchIndex(),
null,
);
await expect(persistence.load()).resolves.toBeDefined();
});
});
+241
View File
@@ -0,0 +1,241 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { inferMemoryProjects } from "../src/functions/migrate.js";
import { KV } from "../src/state/schema.js";
import type { Memory, Session } from "../src/types.js";
function makeMockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function makeMemory(id: string, sessionIds: string[], project?: string): Memory {
return {
id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "bug",
title: `Memory ${id}`,
content: `Content for ${id}`,
concepts: [],
files: [],
sessionIds,
strength: 5,
version: 1,
isLatest: true,
...(project !== undefined && { project }),
};
}
function makeSession(id: string, project: string): Session {
return {
id,
project,
cwd: `/srv/${project}`,
startedAt: new Date().toISOString(),
status: "completed",
observationCount: 0,
};
}
describe("inferMemoryProjects", () => {
it("skips memories that already have a project", async () => {
const kv = makeMockKV();
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", [], "api"));
const result = await inferMemoryProjects(kv);
expect(result.skipped).toBe(1);
expect(result.updated).toBe(0);
expect(result.ambiguous).toBe(0);
const stored = await kv.get<Memory>(KV.memories, "mem_a");
expect(stored?.project).toBe("api");
});
it("marks a memory ambiguous when it has no sessionIds", async () => {
const kv = makeMockKV();
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", []));
const result = await inferMemoryProjects(kv);
expect(result.ambiguous).toBe(1);
expect(result.updated).toBe(0);
const stored = await kv.get<Memory>(KV.memories, "mem_a");
expect(stored?.project).toBeUndefined();
});
it("marks a memory ambiguous when none of its sessions have a project", async () => {
const kv = makeMockKV();
const session: Session = {
id: "sess_a",
project: "",
cwd: "/tmp",
startedAt: new Date().toISOString(),
status: "completed",
observationCount: 0,
};
await kv.set(KV.sessions, "sess_a", session);
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_a"]));
const result = await inferMemoryProjects(kv);
expect(result.ambiguous).toBe(1);
expect(result.updated).toBe(0);
});
it("marks a memory ambiguous when all its sessions are missing from KV", async () => {
const kv = makeMockKV();
// Memory references sessions that don't exist (e.g. deleted)
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["ghost_sess_1", "ghost_sess_2"]));
const result = await inferMemoryProjects(kv);
expect(result.ambiguous).toBe(1);
expect(result.updated).toBe(0);
});
it("infers project when all sessions belong to the same project", async () => {
const kv = makeMockKV();
await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api"));
await kv.set(KV.sessions, "sess_2", makeSession("sess_2", "api"));
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1", "sess_2"]));
const result = await inferMemoryProjects(kv);
expect(result.updated).toBe(1);
expect(result.skipped).toBe(0);
expect(result.ambiguous).toBe(0);
const stored = await kv.get<Memory>(KV.memories, "mem_a");
expect(stored?.project).toBe("api");
});
it("infers the majority project when sessions span multiple projects", async () => {
const kv = makeMockKV();
await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api"));
await kv.set(KV.sessions, "sess_2", makeSession("sess_2", "api"));
await kv.set(KV.sessions, "sess_3", makeSession("sess_3", "web"));
// api appears 2 times, web 1 time — api wins strict majority
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1", "sess_2", "sess_3"]));
const result = await inferMemoryProjects(kv);
expect(result.updated).toBe(1);
const stored = await kv.get<Memory>(KV.memories, "mem_a");
expect(stored?.project).toBe("api");
});
it("marks a memory ambiguous when sessions tie across two projects", async () => {
const kv = makeMockKV();
await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api"));
await kv.set(KV.sessions, "sess_2", makeSession("sess_2", "web"));
// exact 1-1 tie — no strict majority
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1", "sess_2"]));
const result = await inferMemoryProjects(kv);
expect(result.ambiguous).toBe(1);
expect(result.updated).toBe(0);
const stored = await kv.get<Memory>(KV.memories, "mem_a");
expect(stored?.project).toBeUndefined();
});
it("counts correctly but does not write to KV in dry-run mode", async () => {
const kv = makeMockKV();
await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api"));
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1"]));
const result = await inferMemoryProjects(kv, true);
expect(result.updated).toBe(1);
// KV must not have been written
const stored = await kv.get<Memory>(KV.memories, "mem_a");
expect(stored?.project).toBeUndefined();
});
it("handles a mix of already-scoped, updatable, and ambiguous memories in one pass", async () => {
const kv = makeMockKV();
await kv.set(KV.sessions, "sess_api", makeSession("sess_api", "api"));
await kv.set(KV.sessions, "sess_web", makeSession("sess_web", "web"));
// Already scoped
await kv.set(KV.memories, "mem_scoped", makeMemory("mem_scoped", [], "existing"));
// Will be updated
await kv.set(KV.memories, "mem_update", makeMemory("mem_update", ["sess_api"]));
// No sessionIds — ambiguous
await kv.set(KV.memories, "mem_no_sess", makeMemory("mem_no_sess", []));
// Tie — ambiguous
await kv.set(KV.memories, "mem_tie", makeMemory("mem_tie", ["sess_api", "sess_web"]));
const result = await inferMemoryProjects(kv);
expect(result.skipped).toBe(1);
expect(result.updated).toBe(1);
expect(result.ambiguous).toBe(2);
const updated = await kv.get<Memory>(KV.memories, "mem_update");
expect(updated?.project).toBe("api");
const scoped = await kv.get<Memory>(KV.memories, "mem_scoped");
expect(scoped?.project).toBe("existing");
const noSess = await kv.get<Memory>(KV.memories, "mem_no_sess");
expect(noSess?.project).toBeUndefined();
const tie = await kv.get<Memory>(KV.memories, "mem_tie");
expect(tie?.project).toBeUndefined();
});
it("ignores missing sessions when voting but still infers if remainder has majority", async () => {
const kv = makeMockKV();
await kv.set(KV.sessions, "sess_real", makeSession("sess_real", "api"));
// ghost_sess does not exist in KV — should be silently skipped in voting
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_real", "ghost_sess"]));
const result = await inferMemoryProjects(kv);
// Only one vote collected (api), which is a strict majority of 1 project out of 1
expect(result.updated).toBe(1);
const stored = await kv.get<Memory>(KV.memories, "mem_a");
expect(stored?.project).toBe("api");
});
it("is idempotent when run twice in succession", async () => {
const kv = makeMockKV();
await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api"));
await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1"]));
const first = await inferMemoryProjects(kv);
expect(first.updated).toBe(1);
const second = await inferMemoryProjects(kv);
expect(second.updated).toBe(0);
expect(second.skipped).toBe(1);
const stored = await kv.get<Memory>(KV.memories, "mem_a");
expect(stored?.project).toBe("api");
});
});
+210
View File
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import openclawPlugin from "../integrations/openclaw/plugin.mjs";
import { createPlaintextBearerAuthGuard } from "../integrations/pi/security.ts";
type OpenClawHandler = (event: Record<string, unknown>) => Promise<unknown>;
function mockFetch(): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ results: [] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
return fetchMock;
}
function registerOpenClaw(baseUrl: string) {
const handlers = new Map<string, OpenClawHandler>();
const warn = vi.fn();
openclawPlugin.register({
pluginConfig: { base_url: baseUrl },
logger: { warn },
on(event: string, handler: OpenClawHandler) {
handlers.set(event, handler);
},
});
return { handlers, warn };
}
describe("OpenClaw plaintext bearer guard", () => {
const originalFetch = globalThis.fetch;
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv, AGENTMEMORY_SECRET: "secret" };
delete process.env["AGENTMEMORY_REQUIRE_HTTPS"];
mockFetch();
});
afterEach(() => {
globalThis.fetch = originalFetch;
process.env = { ...originalEnv };
});
it("keeps loopback HTTP silent", async () => {
const { handlers, warn } = registerOpenClaw("http://localhost:3111");
await handlers.get("before_agent_start")?.({ prompt: "recall auth work" });
expect(warn).not.toHaveBeenCalled();
});
it("warns once for non-loopback HTTP with a bearer secret", async () => {
const { handlers, warn } = registerOpenClaw("http://remote.example:3111");
await handlers.get("before_agent_start")?.({ prompt: "first" });
await handlers.get("before_agent_start")?.({ prompt: "second" });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://remote.example:3111");
});
it("keeps HTTPS with a bearer secret silent", async () => {
const { handlers, warn } = registerOpenClaw("https://remote.example");
await handlers.get("before_agent_start")?.({ prompt: "recall auth work" });
expect(warn).not.toHaveBeenCalled();
});
it("fails before any request when HTTPS is required", () => {
process.env["AGENTMEMORY_REQUIRE_HTTPS"] = "1";
const fetchMock = mockFetch();
expect(() => registerOpenClaw("http://remote.example:3111")).toThrow(
/plaintext HTTP to http:\/\/remote\.example:3111/,
);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("pi plaintext bearer guard", () => {
it("keeps loopback HTTP silent", () => {
const warn = vi.fn();
const guard = createPlaintextBearerAuthGuard(warn, {});
guard("http://127.0.0.1:3111", "secret");
expect(warn).not.toHaveBeenCalled();
});
it("warns once for non-loopback HTTP with a bearer secret", () => {
const warn = vi.fn();
const guard = createPlaintextBearerAuthGuard(warn, {});
guard("http://remote.example:3111", "secret");
guard("http://remote.example:3111", "secret");
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://remote.example:3111");
});
it("keeps HTTPS with a bearer secret silent", () => {
const warn = vi.fn();
const guard = createPlaintextBearerAuthGuard(warn, {});
guard("https://remote.example", "secret");
expect(warn).not.toHaveBeenCalled();
});
it("fails before callers can issue a request when HTTPS is required", () => {
const warn = vi.fn();
const guard = createPlaintextBearerAuthGuard(warn, {
AGENTMEMORY_REQUIRE_HTTPS: "1",
});
expect(() => guard("http://remote.example:3111", "secret")).toThrow(
/plaintext HTTP to http:\/\/remote\.example:3111/,
);
expect(warn).not.toHaveBeenCalled();
});
it("treats IPv6 loopback ([::1]) as loopback (URL parser strips brackets)", () => {
const warn = vi.fn();
const guard = createPlaintextBearerAuthGuard(warn, {});
guard("http://[::1]:3111", "secret");
expect(warn).not.toHaveBeenCalled();
});
it("warns for private LAN IPs — RFC1918 ranges are NOT loopback", () => {
const warn = vi.fn();
const guard = createPlaintextBearerAuthGuard(warn, {});
guard("http://192.168.1.50:3111", "secret");
guard("http://10.0.0.42:3111", "secret");
expect(warn).toHaveBeenCalledTimes(1); // warn-once
expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://192.168.1.50:3111");
});
it("does not warn when no secret is set — guard only fires when a bearer would actually be sent", () => {
const warn = vi.fn();
const guard = createPlaintextBearerAuthGuard(warn, {});
guard("http://remote.example:3111", "");
guard("http://remote.example:3111", undefined);
expect(warn).not.toHaveBeenCalled();
});
it("treats hostnames that LOOK loopback but aren't (localhost.evil.com) as remote", () => {
const warn = vi.fn();
const guard = createPlaintextBearerAuthGuard(warn, {});
guard("http://localhost.evil.com:3111", "secret");
expect(warn).toHaveBeenCalledTimes(1);
});
});
describe("Hermes plaintext bearer guard", () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "agentmemory-hermes-test-"));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it("covers loopback, remote HTTP, HTTPS, and require-HTTPS behavior", () => {
const script = String.raw`
import importlib.util
import os
import sys
spec = importlib.util.spec_from_file_location("agentmemory_hermes", "integrations/hermes/__init__.py")
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)
for key in ("AGENTMEMORY_SECRET", "AGENTMEMORY_URL", "AGENTMEMORY_REQUIRE_HTTPS"):
os.environ.pop(key, None)
warnings = []
mod._reset_plaintext_bearer_guard_for_tests()
mod._check_plaintext_bearer_guard("http://localhost:3111", "secret", warnings.append)
assert warnings == [], warnings
mod._reset_plaintext_bearer_guard_for_tests()
mod._check_plaintext_bearer_guard("http://remote.example:3111", "secret", warnings.append)
mod._check_plaintext_bearer_guard("http://remote.example:3111", "secret", warnings.append)
assert len(warnings) == 1, warnings
assert "plaintext HTTP to http://remote.example:3111" in warnings[0], warnings
warnings = []
mod._reset_plaintext_bearer_guard_for_tests()
mod._check_plaintext_bearer_guard("https://remote.example", "secret", warnings.append)
assert warnings == [], warnings
calls = []
def fake_urlopen(req, timeout=0):
calls.append(req)
raise AssertionError("request should not be sent")
mod.urlopen = fake_urlopen
os.environ["AGENTMEMORY_REQUIRE_HTTPS"] = "1"
try:
mod._api("http://remote.example:3111", "health", method="GET", secret="secret")
except RuntimeError as exc:
assert "plaintext HTTP to http://remote.example:3111" in str(exc), exc
else:
raise AssertionError("expected RuntimeError")
assert calls == [], calls
`;
const result = spawnSync("python3", ["-c", script], {
cwd: process.cwd(),
env: { ...process.env, HOME: home },
encoding: "utf8",
});
expect(result.status, result.stderr || result.stdout).toBe(0);
});
});
+301
View File
@@ -0,0 +1,301 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
const BASE_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111";
const SECRET = process.env["AGENTMEMORY_SECRET"] || "";
const SESSION_ID = `test_${Date.now()}`;
const PROJECT = "/tmp/test-project";
function url(path: string): string {
return `${BASE_URL}${path}`;
}
function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (SECRET) {
headers["Authorization"] = `Bearer ${SECRET}`;
}
return headers;
}
async function json(res: Response): Promise<unknown> {
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return text;
}
}
describe("agentmemory integration", () => {
beforeAll(async () => {
const res = await fetch(url("/agentmemory/health")).catch(() => null);
if (!res || !res.ok) {
throw new Error(
`agentmemory is not running at ${BASE_URL}. Start it with: docker compose up -d && npm start`,
);
}
});
describe("health", () => {
it("returns ok", async () => {
const res = await fetch(url("/agentmemory/health"));
expect(res.status).toBe(200);
const body = (await json(res)) as { status: string; service: string };
expect(["ok", "healthy"]).toContain(body.status);
expect(body.service).toBe("agentmemory");
});
});
describe("session lifecycle", () => {
it("starts a session", async () => {
const res = await fetch(url("/agentmemory/session/start"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
sessionId: SESSION_ID,
project: PROJECT,
cwd: PROJECT,
}),
});
expect(res.status).toBe(200);
const body = (await json(res)) as {
session: { id: string; status: string };
context: string;
};
expect(body.session.id).toBe(SESSION_ID);
expect(body.session.status).toBe("active");
expect(typeof body.context).toBe("string");
});
it("lists sessions including the new one", async () => {
const res = await fetch(url("/agentmemory/sessions"));
expect(res.status).toBe(200);
const body = (await json(res)) as {
sessions: Array<{ id: string }>;
};
expect(Array.isArray(body.sessions)).toBe(true);
const found = body.sessions.find((s) => s.id === SESSION_ID);
expect(found).toBeDefined();
});
it("ends the session", async () => {
const res = await fetch(url("/agentmemory/session/end"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ sessionId: SESSION_ID }),
});
expect(res.status).toBe(200);
const body = (await json(res)) as { success: boolean };
expect(body.success).toBe(true);
});
it("session is marked completed", async () => {
const res = await fetch(url("/agentmemory/sessions"));
const body = (await json(res)) as {
sessions: Array<{ id: string; status: string; endedAt?: string }>;
};
const session = body.sessions.find((s) => s.id === SESSION_ID);
expect(session).toBeDefined();
expect(session!.status).toBe("completed");
expect(session!.endedAt).toBeDefined();
});
});
describe("observations", () => {
const OBS_SESSION = `test_obs_${Date.now()}`;
beforeAll(async () => {
await fetch(url("/agentmemory/session/start"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
sessionId: OBS_SESSION,
project: PROJECT,
cwd: PROJECT,
}),
});
});
afterAll(async () => {
await fetch(url("/agentmemory/session/end"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ sessionId: OBS_SESSION }),
});
});
it("captures an observation", async () => {
const res = await fetch(url("/agentmemory/observe"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
hookType: "post_tool_use",
sessionId: OBS_SESSION,
project: PROJECT,
cwd: PROJECT,
timestamp: new Date().toISOString(),
data: {
tool: "Edit",
file: "src/auth.ts",
content: "Added JWT token validation middleware",
},
}),
});
expect(res.status).toBe(201);
});
it("captures a second observation", async () => {
const res = await fetch(url("/agentmemory/observe"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
hookType: "post_tool_use",
sessionId: OBS_SESSION,
project: PROJECT,
cwd: PROJECT,
timestamp: new Date().toISOString(),
data: {
tool: "Bash",
command: "npm test",
output: "Tests: 12 passed, 0 failed",
},
}),
});
expect(res.status).toBe(201);
});
it("lists observations for the session", async () => {
const res = await fetch(
url(`/agentmemory/observations?sessionId=${OBS_SESSION}`),
);
expect(res.status).toBe(200);
const body = (await json(res)) as {
observations: Array<{ id: string; sessionId: string }>;
};
expect(Array.isArray(body.observations)).toBe(true);
});
it("returns 400 without sessionId", async () => {
const res = await fetch(url("/agentmemory/observations"));
expect(res.status).toBe(400);
const body = (await json(res)) as { error: string };
expect(body.error).toBe("sessionId required");
});
});
describe("search", () => {
it("searches observations", async () => {
const res = await fetch(url("/agentmemory/search"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ query: "auth", limit: 5 }),
});
expect(res.status).toBe(200);
const body = await json(res);
expect(body).toBeDefined();
});
it("returns results for empty limit", async () => {
const res = await fetch(url("/agentmemory/search"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ query: "test" }),
});
expect(res.status).toBe(200);
});
});
describe("context", () => {
it("generates context for a project", async () => {
const res = await fetch(url("/agentmemory/context"), {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
sessionId: "ctx-test",
project: PROJECT,
}),
});
expect(res.status).toBe(200);
const body = (await json(res)) as { context: string };
expect(typeof body.context).toBe("string");
});
});
describe("viewer", () => {
it("serves the viewer HTML", async () => {
const res = await fetch(url("/agentmemory/viewer"), {
headers: SECRET ? authHeaders() : undefined,
});
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("html");
});
});
describe("dashboard list endpoints", () => {
it("GET /semantic returns { semantic: [...] }", async () => {
const res = await fetch(url("/agentmemory/semantic"), {
headers: SECRET ? authHeaders() : undefined,
});
expect(res.status).toBe(200);
const body = (await json(res)) as { semantic: unknown[] };
expect(Array.isArray(body.semantic)).toBe(true);
});
it("GET /procedural returns { procedural: [...] }", async () => {
const res = await fetch(url("/agentmemory/procedural"), {
headers: SECRET ? authHeaders() : undefined,
});
expect(res.status).toBe(200);
const body = (await json(res)) as { procedural: unknown[] };
expect(Array.isArray(body.procedural)).toBe(true);
});
it("GET /relations returns { relations: [...] }", async () => {
const res = await fetch(url("/agentmemory/relations"), {
headers: SECRET ? authHeaders() : undefined,
});
expect(res.status).toBe(200);
const body = (await json(res)) as { relations: unknown[] };
expect(Array.isArray(body.relations)).toBe(true);
});
});
describe("auth", () => {
it("health endpoint is always public", async () => {
const res = await fetch(url("/agentmemory/health"));
expect(res.status).toBe(200);
});
if (SECRET) {
it("rejects unauthenticated requests", async () => {
const res = await fetch(url("/agentmemory/search"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "test" }),
});
expect(res.status).toBe(401);
});
it("rejects wrong bearer token", async () => {
const res = await fetch(url("/agentmemory/search"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer wrong-token",
},
body: JSON.stringify({ query: "test" }),
});
expect(res.status).toBe(401);
});
it("rejects unauthenticated viewer requests on the API port", async () => {
const res = await fetch(url("/agentmemory/viewer"));
expect(res.status).toBe(401);
});
}
});
});
+400
View File
@@ -0,0 +1,400 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerLeasesFunction } from "../src/functions/leases.js";
import type { Action, Lease } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeAction(
id: string,
status: Action["status"] = "pending",
): Action {
return {
id,
title: `Action ${id}`,
description: `Description for ${id}`,
status,
priority: 5,
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
createdBy: "agent-setup",
tags: [],
sourceObservationIds: [],
sourceMemoryIds: [],
};
}
describe("Lease Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(async () => {
sdk = mockSdk();
kv = mockKV();
registerLeasesFunction(sdk as never, kv as never);
await kv.set("mem:actions", "act_1", makeAction("act_1", "pending"));
await kv.set("mem:actions", "act_2", makeAction("act_2", "done"));
await kv.set("mem:actions", "act_3", makeAction("act_3", "cancelled"));
await kv.set("mem:actions", "act_4", makeAction("act_4", "pending"));
});
describe("mem::lease-acquire", () => {
it("acquires a lease for a valid action", async () => {
const result = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease; renewed: boolean };
expect(result.success).toBe(true);
expect(result.lease.actionId).toBe("act_1");
expect(result.lease.agentId).toBe("agent-a");
expect(result.lease.status).toBe("active");
expect(result.renewed).toBe(false);
expect(result.lease.id).toMatch(/^lse_/);
});
it("returns error when actionId or agentId is missing", async () => {
const r1 = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "",
})) as { success: boolean; error: string };
expect(r1.success).toBe(false);
expect(r1.error).toBe("actionId and agentId are required");
const r2 = (await sdk.trigger("mem::lease-acquire", {
actionId: "",
agentId: "agent-a",
})) as { success: boolean; error: string };
expect(r2.success).toBe(false);
expect(r2.error).toBe("actionId and agentId are required");
});
it("returns error for nonexistent action", async () => {
const result = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_nonexistent",
agentId: "agent-a",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("action not found");
});
it("returns error for done action", async () => {
const result = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_2",
agentId: "agent-a",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("action already completed");
});
it("returns error for cancelled action", async () => {
const result = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_3",
agentId: "agent-a",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("action already completed");
});
it("returns existing lease when same agent already holds it", async () => {
const first = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease };
const second = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease; renewed: boolean; message: string };
expect(second.success).toBe(true);
expect(second.lease.id).toBe(first.lease.id);
expect(second.renewed).toBe(false);
expect(second.message).toBe("Already holding this lease");
});
it("returns conflict error when different agent holds the lease", async () => {
await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
});
const result = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-b",
})) as { success: boolean; error: string; heldBy: string; expiresAt: string };
expect(result.success).toBe(false);
expect(result.error).toBe("action already leased");
expect(result.heldBy).toBe("agent-a");
expect(result.expiresAt).toBeDefined();
});
it("sets action status to active after acquire", async () => {
await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
});
const action = await kv.get<Action>("mem:actions", "act_1");
expect(action!.status).toBe("active");
expect(action!.assignedTo).toBe("agent-a");
});
});
describe("mem::lease-release", () => {
it("releases an active lease", async () => {
await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
});
const result = (await sdk.trigger("mem::lease-release", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; released: boolean };
expect(result.success).toBe(true);
expect(result.released).toBe(true);
});
it("sets action to done when result is provided", async () => {
await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
});
await sdk.trigger("mem::lease-release", {
actionId: "act_1",
agentId: "agent-a",
result: "completed successfully",
});
const action = await kv.get<Action>("mem:actions", "act_1");
expect(action!.status).toBe("done");
expect(action!.result).toBe("completed successfully");
expect(action!.assignedTo).toBeUndefined();
});
it("sets action to pending when no result is provided", async () => {
await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
});
await sdk.trigger("mem::lease-release", {
actionId: "act_1",
agentId: "agent-a",
});
const action = await kv.get<Action>("mem:actions", "act_1");
expect(action!.status).toBe("pending");
expect(action!.assignedTo).toBeUndefined();
});
it("returns error when no active lease exists for agent", async () => {
const result = (await sdk.trigger("mem::lease-release", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("no active lease found for this agent");
});
it("returns error when actionId or agentId is missing", async () => {
const result = (await sdk.trigger("mem::lease-release", {
actionId: "",
agentId: "agent-a",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("actionId and agentId are required");
});
});
describe("mem::lease-renew", () => {
it("renews an active non-expired lease", async () => {
const acquired = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease };
const originalExpiry = acquired.lease.expiresAt;
const result = (await sdk.trigger("mem::lease-renew", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease };
expect(result.success).toBe(true);
expect(result.lease.renewedAt).toBeDefined();
expect(
new Date(result.lease.expiresAt).getTime(),
).toBeGreaterThanOrEqual(new Date(originalExpiry).getTime());
});
it("returns error when lease is expired", async () => {
const acquired = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease };
acquired.lease.expiresAt = new Date(Date.now() - 60000).toISOString();
await kv.set("mem:leases", acquired.lease.id, acquired.lease);
const result = (await sdk.trigger("mem::lease-renew", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("no active (non-expired) lease to renew");
});
it("returns error when actionId or agentId is missing", async () => {
const result = (await sdk.trigger("mem::lease-renew", {
actionId: "",
agentId: "agent-a",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("actionId and agentId are required");
});
});
describe("mem::lease-cleanup", () => {
it("expires active leases past their expiresAt and resets actions to pending", async () => {
const acquired = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease };
acquired.lease.expiresAt = new Date(Date.now() - 60000).toISOString();
await kv.set("mem:leases", acquired.lease.id, acquired.lease);
const result = (await sdk.trigger("mem::lease-cleanup", {})) as {
success: boolean;
expired: number;
};
expect(result.success).toBe(true);
expect(result.expired).toBe(1);
const lease = await kv.get<Lease>("mem:leases", acquired.lease.id);
expect(lease!.status).toBe("expired");
const action = await kv.get<Action>("mem:actions", "act_1");
expect(action!.status).toBe("pending");
expect(action!.assignedTo).toBeUndefined();
});
it("does not expire non-expired active leases", async () => {
await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
});
const result = (await sdk.trigger("mem::lease-cleanup", {})) as {
success: boolean;
expired: number;
};
expect(result.success).toBe(true);
expect(result.expired).toBe(0);
const action = await kv.get<Action>("mem:actions", "act_1");
expect(action!.status).toBe("active");
});
it("handles multiple expired leases across different actions", async () => {
const a1 = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease };
const a4 = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_4",
agentId: "agent-b",
})) as { success: boolean; lease: Lease };
a1.lease.expiresAt = new Date(Date.now() - 60000).toISOString();
await kv.set("mem:leases", a1.lease.id, a1.lease);
a4.lease.expiresAt = new Date(Date.now() - 30000).toISOString();
await kv.set("mem:leases", a4.lease.id, a4.lease);
const result = (await sdk.trigger("mem::lease-cleanup", {})) as {
success: boolean;
expired: number;
};
expect(result.success).toBe(true);
expect(result.expired).toBe(2);
});
it("does not reset action when action is no longer active", async () => {
const acquired = (await sdk.trigger("mem::lease-acquire", {
actionId: "act_1",
agentId: "agent-a",
})) as { success: boolean; lease: Lease };
acquired.lease.expiresAt = new Date(Date.now() - 60000).toISOString();
await kv.set("mem:leases", acquired.lease.id, acquired.lease);
const action = await kv.get<Action>("mem:actions", "act_1");
action!.status = "done";
await kv.set("mem:actions", "act_1", action);
await sdk.trigger("mem::lease-cleanup", {});
const updatedAction = await kv.get<Action>("mem:actions", "act_1");
expect(updatedAction!.status).toBe("done");
});
});
});
+352
View File
@@ -0,0 +1,352 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerLessonsFunctions } from "../src/functions/lessons.js";
import type { Lesson } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
describe("Lessons", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerLessonsFunctions(sdk as never, kv as never);
});
describe("mem::lesson-save", () => {
it("creates a lesson with default confidence 0.5", async () => {
const result = (await sdk.trigger("mem::lesson-save", {
content: "Always use execFile instead of exec",
context: "Security best practice",
project: "/test",
tags: ["security"],
})) as { success: boolean; action: string; lesson: Lesson };
expect(result.success).toBe(true);
expect(result.action).toBe("created");
expect(result.lesson.confidence).toBe(0.5);
expect(result.lesson.content).toBe("Always use execFile instead of exec");
expect(result.lesson.source).toBe("manual");
expect(result.lesson.reinforcements).toBe(0);
});
it("accepts custom confidence", async () => {
const result = (await sdk.trigger("mem::lesson-save", {
content: "Test lesson",
confidence: 0.8,
})) as { lesson: Lesson };
expect(result.lesson.confidence).toBe(0.8);
});
it("clamps invalid confidence to default", async () => {
const result = (await sdk.trigger("mem::lesson-save", {
content: "Bad confidence",
confidence: 5.0,
})) as { lesson: Lesson };
expect(result.lesson.confidence).toBe(0.5);
});
it("strengthens existing lesson on duplicate content", async () => {
const first = (await sdk.trigger("mem::lesson-save", {
content: "Duplicate lesson",
})) as { action: string; lesson: Lesson };
expect(first.action).toBe("created");
const originalId = first.lesson.id;
const second = (await sdk.trigger("mem::lesson-save", {
content: "Duplicate lesson",
})) as { action: string; lesson: Lesson };
expect(second.action).toBe("strengthened");
expect(second.lesson.id).toBe(originalId);
expect(second.lesson.reinforcements).toBe(1);
expect(second.lesson.confidence).toBeGreaterThan(0.5);
});
it("rejects empty content", async () => {
const result = (await sdk.trigger("mem::lesson-save", {
content: "",
})) as { success: boolean };
expect(result.success).toBe(false);
});
it("sets crystal source and sourceIds when provided", async () => {
const result = (await sdk.trigger("mem::lesson-save", {
content: "Crystal-derived lesson",
source: "crystal",
sourceIds: ["crys_123"],
confidence: 0.6,
})) as { lesson: Lesson };
expect(result.lesson.source).toBe("crystal");
expect(result.lesson.sourceIds).toEqual(["crys_123"]);
expect(result.lesson.confidence).toBe(0.6);
});
});
describe("mem::lesson-recall", () => {
beforeEach(async () => {
await sdk.trigger("mem::lesson-save", {
content: "Database indexing improves query performance",
project: "/app",
tags: ["database"],
confidence: 0.9,
});
await sdk.trigger("mem::lesson-save", {
content: "Always validate user input at boundaries",
project: "/app",
tags: ["security"],
confidence: 0.3,
});
await sdk.trigger("mem::lesson-save", {
content: "Use TypeScript strict mode for type safety",
project: "/other",
tags: ["typescript"],
});
});
it("finds lessons matching query", async () => {
const result = (await sdk.trigger("mem::lesson-recall", {
query: "database performance",
})) as { success: boolean; lessons: Array<Lesson & { score: number }> };
expect(result.success).toBe(true);
expect(result.lessons.length).toBeGreaterThan(0);
expect(result.lessons[0].content).toContain("Database indexing");
});
it("filters by project", async () => {
const result = (await sdk.trigger("mem::lesson-recall", {
query: "type safety typescript",
project: "/other",
})) as { lessons: Lesson[] };
expect(result.lessons.length).toBe(1);
expect(result.lessons[0].project).toBe("/other");
});
it("filters by minConfidence", async () => {
const result = (await sdk.trigger("mem::lesson-recall", {
query: "validate input",
minConfidence: 0.5,
})) as { lessons: Lesson[] };
expect(result.lessons.length).toBe(0);
});
it("returns empty for no matches", async () => {
const result = (await sdk.trigger("mem::lesson-recall", {
query: "xyznonexistent",
})) as { lessons: Lesson[] };
expect(result.lessons.length).toBe(0);
});
it("rejects empty query", async () => {
const result = (await sdk.trigger("mem::lesson-recall", {
query: "",
})) as { success: boolean };
expect(result.success).toBe(false);
});
});
describe("mem::lesson-list", () => {
beforeEach(async () => {
await sdk.trigger("mem::lesson-save", { content: "Lesson A", confidence: 0.9, project: "/app" });
await sdk.trigger("mem::lesson-save", { content: "Lesson B", confidence: 0.3, project: "/app" });
await sdk.trigger("mem::lesson-save", { content: "Lesson C", confidence: 0.7, source: "crystal" });
});
it("lists all lessons sorted by confidence", async () => {
const result = (await sdk.trigger("mem::lesson-list", {})) as { lessons: Lesson[] };
expect(result.lessons.length).toBe(3);
expect(result.lessons[0].confidence).toBe(0.9);
expect(result.lessons[2].confidence).toBe(0.3);
});
it("filters by project", async () => {
const result = (await sdk.trigger("mem::lesson-list", { project: "/app" })) as { lessons: Lesson[] };
expect(result.lessons.length).toBe(2);
});
it("filters by source", async () => {
const result = (await sdk.trigger("mem::lesson-list", { source: "crystal" })) as { lessons: Lesson[] };
expect(result.lessons.length).toBe(1);
});
it("filters by minConfidence", async () => {
const result = (await sdk.trigger("mem::lesson-list", { minConfidence: 0.5 })) as { lessons: Lesson[] };
expect(result.lessons.length).toBe(2);
});
it("respects limit", async () => {
const result = (await sdk.trigger("mem::lesson-list", { limit: 1 })) as { lessons: Lesson[] };
expect(result.lessons.length).toBe(1);
});
});
describe("mem::lesson-strengthen", () => {
it("increases confidence with diminishing returns", async () => {
const saved = (await sdk.trigger("mem::lesson-save", {
content: "Strengthen me",
confidence: 0.5,
})) as { lesson: Lesson };
const result = (await sdk.trigger("mem::lesson-strengthen", {
lessonId: saved.lesson.id,
})) as { success: boolean; lesson: Lesson };
expect(result.success).toBe(true);
expect(result.lesson.reinforcements).toBe(1);
expect(result.lesson.confidence).toBeCloseTo(0.55, 2);
expect(result.lesson.lastReinforcedAt).toBeDefined();
});
it("caps confidence at 1.0", async () => {
const saved = (await sdk.trigger("mem::lesson-save", {
content: "High confidence",
confidence: 0.95,
})) as { lesson: Lesson };
const result = (await sdk.trigger("mem::lesson-strengthen", {
lessonId: saved.lesson.id,
})) as { lesson: Lesson };
expect(result.lesson.confidence).toBeLessThanOrEqual(1.0);
});
it("fails for missing lessonId", async () => {
const result = (await sdk.trigger("mem::lesson-strengthen", {
lessonId: "nonexistent",
})) as { success: boolean };
expect(result.success).toBe(false);
});
});
describe("mem::lesson-decay-sweep", () => {
it("decays old lessons incrementally", async () => {
const saved = (await sdk.trigger("mem::lesson-save", {
content: "Old lesson",
confidence: 0.8,
})) as { lesson: Lesson };
const lessons = await kv.list<Lesson>("mem:lessons");
const lesson = lessons[0];
lesson.createdAt = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
await kv.set("mem:lessons", lesson.id, lesson);
const result = (await sdk.trigger("mem::lesson-decay-sweep", {})) as {
decayed: number;
softDeleted: number;
};
expect(result.decayed).toBe(1);
const after = await kv.get<Lesson>("mem:lessons", lesson.id);
expect(after!.confidence).toBeLessThan(0.8);
expect(after!.lastDecayedAt).toBeDefined();
});
it("does not decay lessons less than 1 week old", async () => {
await sdk.trigger("mem::lesson-save", {
content: "Recent lesson",
confidence: 0.5,
});
const result = (await sdk.trigger("mem::lesson-decay-sweep", {})) as {
decayed: number;
};
expect(result.decayed).toBe(0);
});
it("soft-deletes low-confidence unreinforced lessons", async () => {
const saved = (await sdk.trigger("mem::lesson-save", {
content: "Weak lesson",
confidence: 0.12,
})) as { lesson: Lesson };
const lesson = await kv.get<Lesson>("mem:lessons", saved.lesson.id);
lesson!.createdAt = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString();
await kv.set("mem:lessons", lesson!.id, lesson!);
const result = (await sdk.trigger("mem::lesson-decay-sweep", {})) as {
softDeleted: number;
};
expect(result.softDeleted).toBe(1);
const after = await kv.get<Lesson>("mem:lessons", saved.lesson.id);
expect(after!.deleted).toBe(true);
});
it("uses lastDecayedAt for incremental delta (not full age)", async () => {
const saved = (await sdk.trigger("mem::lesson-save", {
content: "Incremental decay",
confidence: 0.8,
})) as { lesson: Lesson };
const lesson = await kv.get<Lesson>("mem:lessons", saved.lesson.id);
lesson!.createdAt = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString();
lesson!.lastDecayedAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
lesson!.confidence = 0.6;
await kv.set("mem:lessons", lesson!.id, lesson!);
await sdk.trigger("mem::lesson-decay-sweep", {});
const after = await kv.get<Lesson>("mem:lessons", saved.lesson.id);
expect(after!.confidence).toBeCloseTo(0.55, 2);
expect(after!.confidence).toBeGreaterThan(0.4);
});
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { resolveEnvOrEmpty } from "../src/mcp/rest-proxy.js";
const VAR = "AGENTMEMORY_TEST_URL";
describe("resolveEnvOrEmpty — guards against literal ${VAR} placeholders", () => {
let original: string | undefined;
beforeEach(() => {
original = process.env[VAR];
delete process.env[VAR];
});
afterEach(() => {
if (original === undefined) delete process.env[VAR];
else process.env[VAR] = original;
});
it("returns '' when env var is unset", () => {
expect(resolveEnvOrEmpty(VAR)).toBe("");
});
it("returns '' when env var is empty string", () => {
process.env[VAR] = "";
expect(resolveEnvOrEmpty(VAR)).toBe("");
});
it("returns '' when env var is literal ${VAR} placeholder", () => {
process.env[VAR] = "${AGENTMEMORY_TEST_URL}";
expect(resolveEnvOrEmpty(VAR)).toBe("");
});
it("returns '' when env var is a different literal placeholder", () => {
process.env[VAR] = "${SOME_OTHER_VAR}";
expect(resolveEnvOrEmpty(VAR)).toBe("");
});
it("preserves a real URL value", () => {
process.env[VAR] = "https://memory.prod.example/api";
expect(resolveEnvOrEmpty(VAR)).toBe("https://memory.prod.example/api");
});
it("preserves a real secret value that happens to contain a $ char", () => {
process.env[VAR] = "secret-with-$dollar";
expect(resolveEnvOrEmpty(VAR)).toBe("secret-with-$dollar");
});
it("does not treat ${ at start without matching } as placeholder", () => {
process.env[VAR] = "${unclosed";
expect(resolveEnvOrEmpty(VAR)).toBe("${unclosed");
});
it("does not treat $VAR (no braces) as placeholder", () => {
process.env[VAR] = "$AGENTMEMORY_URL";
expect(resolveEnvOrEmpty(VAR)).toBe("$AGENTMEMORY_URL");
});
});
+222
View File
@@ -0,0 +1,222 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerMcpEndpoints } from "../src/mcp/server.js";
import type { Session, SessionSummary, Memory } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
const triggerOverrides = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
if (triggerOverrides.has(id)) {
return triggerOverrides.get(id)!(payload);
}
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
overrideTrigger: (id: string, handler: Function) => {
triggerOverrides.set(id, handler);
},
getFunction: (id: string) => functions.get(id),
};
}
function makeReq(body?: unknown, headers?: Record<string, string>) {
return {
body,
headers: headers || {},
query_params: {},
};
}
describe("MCP Prompts", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerMcpEndpoints(sdk as never, kv as never);
});
it("lists 3 prompts", async () => {
const fn = sdk.getFunction("mcp::prompts::list")!;
const result = (await fn(makeReq())) as {
status_code: number;
body: { prompts: unknown[] };
};
expect(result.status_code).toBe(200);
expect(result.body.prompts).toHaveLength(3);
});
it("recall_context returns messages with search results", async () => {
sdk.overrideTrigger("mem::search", async () => ({
results: [{ observation: { title: "Found something" } }],
}));
const mem: Memory = {
id: "mem_1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "pattern",
title: "Auth pattern",
content: "Always use JWT",
concepts: ["auth"],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: true,
};
await kv.set("mem:memories", "mem_1", mem);
const fn = sdk.getFunction("mcp::prompts::get")!;
const result = (await fn(
makeReq({
name: "recall_context",
arguments: { task_description: "implement auth" },
}),
)) as {
status_code: number;
body: { messages: Array<{ role: string; content: { text: string } }> };
};
expect(result.status_code).toBe(200);
expect(result.body.messages).toHaveLength(1);
expect(result.body.messages[0].role).toBe("user");
expect(result.body.messages[0].content.text).toContain("implement auth");
});
it("session_handoff returns session data", async () => {
const session: Session = {
id: "ses_1",
project: "/test",
cwd: "/test",
startedAt: "2026-02-01T00:00:00Z",
status: "completed",
observationCount: 10,
};
await kv.set("mem:sessions", "ses_1", session);
const summary: SessionSummary = {
sessionId: "ses_1",
project: "/test",
createdAt: "2026-02-01T00:00:00Z",
title: "Auth implementation",
narrative: "Implemented JWT auth",
keyDecisions: ["Used JWT"],
filesModified: ["src/auth.ts"],
concepts: ["auth"],
observationCount: 10,
};
await kv.set("mem:summaries", "ses_1", summary);
const fn = sdk.getFunction("mcp::prompts::get")!;
const result = (await fn(
makeReq({
name: "session_handoff",
arguments: { session_id: "ses_1" },
}),
)) as {
status_code: number;
body: { messages: Array<{ role: string; content: { text: string } }> };
};
expect(result.status_code).toBe(200);
expect(result.body.messages[0].content.text).toContain("Session Handoff");
expect(result.body.messages[0].content.text).toContain("ses_1");
});
it("detect_patterns returns analysis", async () => {
sdk.overrideTrigger("mem::patterns", async () => ({
fileCoOccurrence: [{ files: ["a.ts", "b.ts"], count: 5 }],
}));
const fn = sdk.getFunction("mcp::prompts::get")!;
const result = (await fn(
makeReq({
name: "detect_patterns",
arguments: { project: "/myapp" },
}),
)) as {
status_code: number;
body: { messages: Array<{ role: string; content: { text: string } }> };
};
expect(result.status_code).toBe(200);
expect(result.body.messages[0].content.text).toContain("Pattern Analysis");
});
it("returns 400 for missing required arg", async () => {
const fn = sdk.getFunction("mcp::prompts::get")!;
const result = (await fn(
makeReq({
name: "recall_context",
arguments: {},
}),
)) as { status_code: number };
expect(result.status_code).toBe(400);
});
it("returns 400 for unknown prompt name", async () => {
const fn = sdk.getFunction("mcp::prompts::get")!;
const result = (await fn(
makeReq({
name: "nonexistent_prompt",
arguments: {},
}),
)) as { status_code: number };
expect(result.status_code).toBe(400);
});
it("returns 400 for non-string argument value", async () => {
const fn = sdk.getFunction("mcp::prompts::get")!;
const result = (await fn(
makeReq({
name: "recall_context",
arguments: { task_description: 42 },
}),
)) as { status_code: number };
expect(result.status_code).toBe(400);
});
});
+290
View File
@@ -0,0 +1,290 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerMcpEndpoints } from "../src/mcp/server.js";
import type { Session, SessionSummary, Memory } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
const triggerOverrides = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
if (triggerOverrides.has(id)) {
return triggerOverrides.get(id)!(payload);
}
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
overrideTrigger: (id: string, handler: Function) => {
triggerOverrides.set(id, handler);
},
getFunction: (id: string) => functions.get(id),
};
}
function makeReq(body?: unknown, headers?: Record<string, string>) {
return {
body,
headers: headers || {},
query_params: {},
};
}
describe("MCP Resources", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerMcpEndpoints(sdk as never, kv as never);
});
it("lists 6 resources", async () => {
const fn = sdk.getFunction("mcp::resources::list")!;
const result = (await fn(makeReq())) as {
status_code: number;
body: { resources: unknown[] };
};
expect(result.status_code).toBe(200);
expect(result.body.resources).toHaveLength(6);
});
it("reads agentmemory://status", async () => {
const session: Session = {
id: "ses_1",
project: "/test",
cwd: "/test",
startedAt: new Date().toISOString(),
status: "active",
observationCount: 5,
};
await kv.set("mem:sessions", "ses_1", session);
const fn = sdk.getFunction("mcp::resources::read")!;
const result = (await fn(makeReq({ uri: "agentmemory://status" }))) as {
status_code: number;
body: { contents: Array<{ text: string }> };
};
expect(result.status_code).toBe(200);
const data = JSON.parse(result.body.contents[0].text);
expect(data.sessionCount).toBe(1);
});
it("reads agentmemory://project/{name}/profile", async () => {
sdk.overrideTrigger("mem::profile", async () => ({
project: "/myapp",
topConcepts: [{ concept: "auth", frequency: 5 }],
}));
const fn = sdk.getFunction("mcp::resources::read")!;
const result = (await fn(
makeReq({ uri: "agentmemory://project/myapp/profile" }),
)) as {
status_code: number;
body: { contents: Array<{ text: string }> };
};
expect(result.status_code).toBe(200);
const data = JSON.parse(result.body.contents[0].text);
expect(data.project).toBe("/myapp");
});
it("reads agentmemory://project/{name}/recent with sorted summaries", async () => {
const summaries: SessionSummary[] = [
{
sessionId: "ses_1",
project: "myapp",
createdAt: "2026-01-01T00:00:00Z",
title: "Old session",
narrative: "old",
keyDecisions: [],
filesModified: [],
concepts: [],
observationCount: 1,
},
{
sessionId: "ses_2",
project: "myapp",
createdAt: "2026-02-01T00:00:00Z",
title: "New session",
narrative: "new",
keyDecisions: [],
filesModified: [],
concepts: [],
observationCount: 2,
},
{
sessionId: "ses_3",
project: "other",
createdAt: "2026-02-15T00:00:00Z",
title: "Other project",
narrative: "other",
keyDecisions: [],
filesModified: [],
concepts: [],
observationCount: 3,
},
];
for (const s of summaries) {
await kv.set("mem:summaries", s.sessionId, s);
}
const fn = sdk.getFunction("mcp::resources::read")!;
const result = (await fn(
makeReq({ uri: "agentmemory://project/myapp/recent" }),
)) as {
status_code: number;
body: { contents: Array<{ text: string }> };
};
expect(result.status_code).toBe(200);
const data = JSON.parse(result.body.contents[0].text);
expect(data).toHaveLength(2);
expect(data[0].sessionId).toBe("ses_2");
});
it("reads agentmemory://memories/latest", async () => {
const memories: Memory[] = [
{
id: "mem_1",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
type: "pattern",
title: "Latest pattern",
content: "content",
concepts: [],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: true,
},
{
id: "mem_2",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-15T00:00:00Z",
type: "bug",
title: "Old bug",
content: "content",
concepts: [],
files: [],
sessionIds: [],
strength: 3,
version: 2,
isLatest: false,
},
];
for (const m of memories) {
await kv.set("mem:memories", m.id, m);
}
const fn = sdk.getFunction("mcp::resources::read")!;
const result = (await fn(
makeReq({ uri: "agentmemory://memories/latest" }),
)) as {
status_code: number;
body: { contents: Array<{ text: string }> };
};
expect(result.status_code).toBe(200);
const data = JSON.parse(result.body.contents[0].text);
expect(data).toHaveLength(1);
expect(data[0].id).toBe("mem_1");
expect(data[0].title).toBe("Latest pattern");
});
it("returns 404 for unknown URI", async () => {
const fn = sdk.getFunction("mcp::resources::read")!;
const result = (await fn(
makeReq({ uri: "agentmemory://nonexistent" }),
)) as { status_code: number };
expect(result.status_code).toBe(404);
});
it("returns 401 when auth fails", async () => {
const authedSdk = mockSdk();
const authedKv = mockKV();
registerMcpEndpoints(authedSdk as never, authedKv as never, "test-secret");
const fn = authedSdk.getFunction("mcp::resources::list")!;
const result = (await fn(makeReq())) as { status_code: number };
expect(result.status_code).toBe(401);
const authedResult = (await fn(
makeReq(undefined, { authorization: "Bearer test-secret" }),
)) as { status_code: number };
expect(authedResult.status_code).toBe(200);
});
it("handles URI with special characters via decodeURIComponent", async () => {
sdk.overrideTrigger("mem::profile", async (data: any) => ({
project: data.project,
topConcepts: [],
}));
const fn = sdk.getFunction("mcp::resources::read")!;
const result = (await fn(
makeReq({
uri: "agentmemory://project/my%20app%2Fsubdir/profile",
}),
)) as {
status_code: number;
body: { contents: Array<{ text: string }> };
};
expect(result.status_code).toBe(200);
const data = JSON.parse(result.body.contents[0].text);
expect(data.project).toBe("my app/subdir");
});
it("returns 400 for malformed percent-encoding in URI", async () => {
const fn = sdk.getFunction("mcp::resources::read")!;
const result = (await fn(
makeReq({
uri: "agentmemory://project/bad%E0encoding/profile",
}),
)) as { status_code: number; body: { error: string } };
expect(result.status_code).toBe(400);
expect(result.body.error).toContain("percent-encoding");
});
});
+386
View File
@@ -0,0 +1,386 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { handleToolCall } from "../src/mcp/standalone.js";
import { resetHandleForTests } from "../src/mcp/rest-proxy.js";
import { InMemoryKV } from "../src/mcp/in-memory-kv.js";
type FetchMock = ReturnType<typeof vi.fn>;
function installFetch(handler: (url: string, init?: RequestInit) => Response): FetchMock {
const fn = vi.fn(async (url: string | URL, init?: RequestInit) =>
handler(url.toString(), init),
);
(globalThis as { fetch: typeof fetch }).fetch = fn as unknown as typeof fetch;
return fn;
}
const BASE = "http://localhost:3111";
describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
resetHandleForTests();
process.env["AGENTMEMORY_URL"] = BASE;
delete process.env["AGENTMEMORY_SECRET"];
});
afterEach(() => {
resetHandleForTests();
globalThis.fetch = originalFetch;
delete process.env["AGENTMEMORY_URL"];
});
it("proxies memory_sessions to GET /agentmemory/sessions when server is up", async () => {
const calls: Array<{ url: string; method: string }> = [];
installFetch((url, init) => {
calls.push({ url, method: init?.method || "GET" });
if (url.endsWith("/agentmemory/livez")) {
return new Response("ok", { status: 200 });
}
if (url.includes("/agentmemory/sessions")) {
return new Response(
JSON.stringify({ sessions: [{ id: "sess-1", observations: 69 }] }),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
});
const res = await handleToolCall("memory_sessions", { limit: 5 });
const body = JSON.parse(res.content[0].text);
expect(body.sessions).toHaveLength(1);
expect(body.sessions[0].id).toBe("sess-1");
expect(calls.find((c) => c.url.includes("/sessions"))).toBeDefined();
});
it("proxies memory_smart_search to POST /agentmemory/smart-search", async () => {
installFetch((url, init) => {
if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 });
if (url.endsWith("/agentmemory/smart-search")) {
const body = JSON.parse((init?.body as string) || "{}");
return new Response(
JSON.stringify({
mode: "compact",
query: body.query,
results: [{ id: "m1", score: 0.9 }],
}),
{ status: 200 },
);
}
return new Response("", { status: 404 });
});
const res = await handleToolCall("memory_smart_search", { query: "auth bug", limit: 5 });
const body = JSON.parse(res.content[0].text);
expect(body.query).toBe("auth bug");
expect(body.results[0].id).toBe("m1");
});
it("proxies memory_recall to POST /agentmemory/search and forwards format/token_budget (#507)", async () => {
const calls: Array<{ url: string; body?: unknown }> = [];
installFetch((url, init) => {
if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 });
const body = init?.body ? JSON.parse(init.body as string) : undefined;
calls.push({ url, body });
if (url.endsWith("/agentmemory/search")) {
return new Response(
JSON.stringify({
mode: "full",
facts: [{ id: "m1" }],
narrative: "n",
concepts: ["c"],
files: ["f"],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
});
const res = await handleToolCall("memory_recall", {
query: "auth bug",
limit: 5,
format: "full",
token_budget: 800,
});
const body = JSON.parse(res.content[0].text);
expect(body.mode).toBe("full");
expect(body.facts[0].id).toBe("m1");
const searchCall = calls.find((c) => c.url.endsWith("/agentmemory/search"));
expect(searchCall).toBeDefined();
expect(searchCall?.body).toEqual({
query: "auth bug",
limit: 5,
format: "full",
token_budget: 800,
});
expect(calls.find((c) => c.url.endsWith("/agentmemory/smart-search"))).toBeUndefined();
});
it("memory_recall defaults format to 'full' when omitted (#507)", async () => {
let recallBody: Record<string, unknown> | undefined;
installFetch((url, init) => {
if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 });
if (url.endsWith("/agentmemory/search")) {
recallBody = init?.body ? JSON.parse(init.body as string) : undefined;
return new Response(JSON.stringify({ mode: "full", facts: [] }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
await handleToolCall("memory_recall", { query: "x" });
expect(recallBody?.["format"]).toBe("full");
expect(recallBody).not.toHaveProperty("token_budget");
});
it("proxies memory_governance_delete to the DELETE REST endpoint", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
installFetch((url, init) => {
const method = init?.method || "GET";
if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 });
calls.push({
url,
method,
body: init?.body ? JSON.parse(init.body as string) : undefined,
});
if (url.endsWith("/agentmemory/governance/memories") && method === "DELETE") {
return new Response(JSON.stringify({ success: true, deleted: 2 }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("method not allowed", { status: 405, statusText: "Method Not Allowed" });
});
const res = await handleToolCall("memory_governance_delete", {
memoryIds: "mem_1, mem_2",
reason: "cleanup stale test data",
});
expect(JSON.parse(res.content[0].text)).toEqual({ success: true, deleted: 2 });
expect(calls).toEqual([
{
url: `${BASE}/agentmemory/governance/memories`,
method: "DELETE",
body: {
memoryIds: ["mem_1", "mem_2"],
reason: "cleanup stale test data",
},
},
]);
});
it("local fallback returns the same shape as proxy for memory_smart_search", async () => {
installFetch(() => {
throw new Error("ECONNREFUSED");
});
const localKv = new InMemoryKV(undefined);
await handleToolCall("memory_save", { content: "shape-check entry" }, localKv);
const res = await handleToolCall("memory_smart_search", { query: "shape" }, localKv);
const body = JSON.parse(res.content[0].text);
expect(body).toHaveProperty("mode", "compact");
expect(Array.isArray(body.results)).toBe(true);
expect(body.results[0].content).toBe("shape-check entry");
});
it("attaches Bearer token on the proxied tool request, not just the probe", async () => {
process.env["AGENTMEMORY_SECRET"] = "s3cret";
const authByPath = new Map<string, string | undefined>();
installFetch((url, init) => {
const auth = (init?.headers as Record<string, string> | undefined)?.[
"authorization"
];
const u = new URL(url);
authByPath.set(u.pathname, auth);
if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 });
return new Response(JSON.stringify({ sessions: [] }), { status: 200 });
});
await handleToolCall("memory_sessions", {});
expect(authByPath.get("/agentmemory/livez")).toBe("Bearer s3cret");
expect(authByPath.get("/agentmemory/sessions")).toBe("Bearer s3cret");
});
it("falls back to local InMemoryKV when server is unreachable", async () => {
installFetch(() => {
throw new Error("ECONNREFUSED");
});
const localKv = new InMemoryKV(undefined);
await handleToolCall("memory_save", { content: "local only" }, localKv);
const recall = await handleToolCall("memory_recall", { query: "local" }, localKv);
const out = JSON.parse(recall.content[0].text);
expect(out.mode).toBe("compact");
expect(out.results).toHaveLength(1);
expect(out.results[0].content).toBe("local only");
});
it("invalidates the handle on proxy failure, so the next call re-probes", async () => {
let probeCount = 0;
let serverUp = true;
installFetch((url) => {
if (url.endsWith("/agentmemory/livez")) {
probeCount++;
return serverUp ? new Response("ok", { status: 200 }) : new Response("", { status: 500 });
}
return new Response("boom", { status: 500, statusText: "Internal Server Error" });
});
const localKv = new InMemoryKV(undefined);
await handleToolCall("memory_save", { content: "first fallback" }, localKv);
expect(probeCount).toBe(1);
serverUp = false;
await handleToolCall("memory_save", { content: "second fallback" }, localKv);
expect(probeCount).toBe(2);
});
it("forwards non-essential tools to /agentmemory/mcp/call (#234)", async () => {
const calls: Array<{ url: string; body?: unknown }> = [];
installFetch((url, init) => {
if (url.endsWith("/agentmemory/livez")) {
return new Response("ok", { status: 200 });
}
if (url.endsWith("/agentmemory/mcp/call")) {
const body = init?.body ? JSON.parse(init.body as string) : null;
calls.push({ url, body });
return new Response(
JSON.stringify({
content: [
{
type: "text",
text: JSON.stringify({ saved: "lesson_xyz" }),
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
});
const res = await handleToolCall("memory_lesson_save", {
title: "Always pin lockfiles",
content: "...",
});
const body = JSON.parse(res.content[0].text);
expect(body.saved).toBe("lesson_xyz");
expect(calls).toHaveLength(1);
expect(calls[0].body).toEqual({
name: "memory_lesson_save",
arguments: { title: "Always pin lockfiles", content: "..." },
});
});
it("rejects non-essential tools when no server is reachable (#234)", async () => {
installFetch(() => {
throw new Error("ECONNREFUSED");
});
const localKv = new InMemoryKV(undefined);
await expect(
handleToolCall("memory_lesson_save", { title: "x" }, localKv),
).rejects.toThrow(/Unknown tool: memory_lesson_save/);
});
it("does not retry local after a validation error", async () => {
const fetchFn = installFetch((url) => {
if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 });
return new Response("{}", { status: 200 });
});
const localKv = new InMemoryKV(undefined);
await expect(
handleToolCall("memory_save", { content: "" }, localKv),
).rejects.toThrow("content is required");
const remembersCalled = fetchFn.mock.calls.some(([url]) =>
String(url).endsWith("/agentmemory/remember"),
);
expect(remembersCalled).toBe(false);
});
it("AGENTMEMORY_FORCE_PROXY=1 skips livez probe and trusts the server", async () => {
process.env["AGENTMEMORY_FORCE_PROXY"] = "1";
const calls: string[] = [];
installFetch((url, init) => {
calls.push(url);
if (url.endsWith("/agentmemory/livez")) {
throw new Error("probe should be skipped");
}
if (url.endsWith("/agentmemory/remember")) {
return new Response(JSON.stringify({ id: "m-1", action: "created" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
});
try {
await handleToolCall("memory_save", { content: "force-proxy" });
expect(calls.some((u) => u.endsWith("/agentmemory/livez"))).toBe(false);
expect(calls.some((u) => u.endsWith("/agentmemory/remember"))).toBe(true);
} finally {
delete process.env["AGENTMEMORY_FORCE_PROXY"];
}
});
it("logs probe failure to stderr so sandboxed clients can diagnose silently dropped tools", async () => {
installFetch((url) => {
if (url.endsWith("/agentmemory/livez")) {
throw new Error("ECONNREFUSED 127.0.0.1:3111");
}
return new Response("not found", { status: 404 });
});
const writes: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array) => {
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
return true;
}) as typeof process.stderr.write;
try {
const localKv = new InMemoryKV(undefined);
await handleToolCall("memory_save", { content: "diag" }, localKv);
} finally {
process.stderr.write = origWrite;
}
const joined = writes.join("");
expect(joined).toMatch(/livez probe .* failed/);
expect(joined).toMatch(/AGENTMEMORY_FORCE_PROXY/);
});
it("local fallback tools/list returns all 7 IMPLEMENTED_TOOLS regardless of AGENTMEMORY_TOOLS env (#234)", async () => {
const { handleToolsList } = await import("../src/mcp/standalone.js");
installFetch(() => {
throw new Error("ECONNREFUSED");
});
delete process.env["AGENTMEMORY_TOOLS"];
const before = await handleToolsList();
const beforeTools = before.tools as Array<{ name: string }>;
expect(beforeTools.map((t) => t.name).sort()).toEqual([
"memory_audit",
"memory_export",
"memory_governance_delete",
"memory_recall",
"memory_save",
"memory_sessions",
"memory_smart_search",
]);
expect(beforeTools).toHaveLength(7);
resetHandleForTests();
process.env["AGENTMEMORY_TOOLS"] = "core";
const core = await handleToolsList();
expect((core.tools as unknown[]).length).toBe(7);
delete process.env["AGENTMEMORY_TOOLS"];
});
it("AGENTMEMORY_PROBE_TIMEOUT_MS overrides the default probe timeout", async () => {
process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"] = "50";
let probeStarted = 0;
installFetch((url) => {
if (url.endsWith("/agentmemory/livez")) {
probeStarted++;
return new Response("ok", { status: 200 });
}
return new Response("not found", { status: 404 });
});
try {
const localKv = new InMemoryKV(undefined);
await handleToolCall("memory_save", { content: "timeout-knob" }, localKv);
expect(probeStarted).toBe(1);
} finally {
delete process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"];
}
});
});
+452
View File
@@ -0,0 +1,452 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
}));
vi.mock("../src/mcp/transport.js", () => ({
createStdioTransport: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
}));
vi.mock("../src/config.js", () => ({
getStandalonePersistPath: vi.fn(() => "/tmp/test-standalone.json"),
}));
import {
getAllTools,
CORE_TOOLS,
V040_TOOLS,
} from "../src/mcp/tools-registry.js";
import { InMemoryKV } from "../src/mcp/in-memory-kv.js";
import { handleToolCall } from "../src/mcp/standalone.js";
import {
resetHandleForTests,
setLivezProbe,
} from "../src/mcp/rest-proxy.js";
import { writeFileSync } from "node:fs";
// Issue #449: hard-coded fetch() against :3111 in the livez probe was racing
// with vitest's mock setup, making this file the "10-11 pre-existing failures"
// referenced in the last 5 release notes. Stub the probe with an instant
// ok:false response so the shim takes the deterministic InMemoryKV fallback
// path on every test. Guard the real network with a fetch trap so any
// regression that bypasses the DI seam fails loudly instead of timing out.
const instantLocalFallbackProbe = vi.fn(async () => ({
ok: false,
status: 0,
statusText: "stubbed: forced local fallback",
}));
const fetchTrap = vi.fn(async (url: unknown) => {
throw new Error(
`unexpected real fetch() call in mcp-standalone.test.ts: ${String(url)} — the livez probe DI stub should have absorbed this`,
);
});
describe("Tools Registry", () => {
it("getAllTools returns all tools with unique names", () => {
const tools = getAllTools();
expect(tools.length).toBeGreaterThanOrEqual(41);
const names = new Set(tools.map((t) => t.name));
expect(names.size).toBe(tools.length);
for (const required of [
"memory_verify",
"memory_lesson_save",
"memory_lesson_recall",
"memory_obsidian_export",
"memory_save",
"memory_recall",
]) {
expect(tools.some((t) => t.name === required)).toBe(true);
}
});
it("CORE_TOOLS has 14 items", () => {
expect(CORE_TOOLS.length).toBe(14);
});
it("V040_TOOLS has 8 items", () => {
expect(V040_TOOLS.length).toBe(8);
});
it("all tools have required name, description, inputSchema fields", () => {
const tools = getAllTools();
for (const tool of tools) {
expect(tool.name).toBeDefined();
expect(typeof tool.name).toBe("string");
expect(tool.name.length).toBeGreaterThan(0);
expect(tool.description).toBeDefined();
expect(typeof tool.description).toBe("string");
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe("object");
expect(tool.inputSchema.properties).toBeDefined();
}
});
});
describe("InMemoryKV", () => {
let kv: InMemoryKV;
beforeEach(() => {
kv = new InMemoryKV();
});
it("get/set/list/delete operations work", async () => {
await kv.set("scope1", "key1", { value: "hello" });
const result = await kv.get<{ value: string }>("scope1", "key1");
expect(result).toEqual({ value: "hello" });
const list = await kv.list("scope1");
expect(list.length).toBe(1);
await kv.delete("scope1", "key1");
const afterDelete = await kv.get("scope1", "key1");
expect(afterDelete).toBeNull();
});
it("list returns empty array for unknown scope", async () => {
const result = await kv.list("nonexistent");
expect(result).toEqual([]);
});
it("persist writes JSON", async () => {
const kvWithPersist = new InMemoryKV("/tmp/test-kv.json");
await kvWithPersist.set("scope1", "key1", { data: "test" });
kvWithPersist.persist();
expect(writeFileSync).toHaveBeenCalledWith(
"/tmp/test-kv.json",
expect.any(String),
"utf-8",
);
const written = vi.mocked(writeFileSync).mock.calls[0][1] as string;
const parsed = JSON.parse(written);
expect(parsed.scope1.key1).toEqual({ data: "test" });
});
it("set overwrites existing values", async () => {
await kv.set("scope1", "key1", "first");
await kv.set("scope1", "key1", "second");
const result = await kv.get("scope1", "key1");
expect(result).toBe("second");
const list = await kv.list("scope1");
expect(list.length).toBe(1);
});
});
describe("handleToolCall", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.mocked(writeFileSync).mockClear();
instantLocalFallbackProbe.mockClear();
fetchTrap.mockClear();
// Order matters: resetHandleForTests() restores the default probe and
// clears the cached handle. Install the stub AFTER the reset so the
// shim's next resolveHandle() call hits the stubbed instant-fail path
// instead of the real 2s AbortController fetch.
resetHandleForTests();
setLivezProbe(instantLocalFallbackProbe);
(globalThis as { fetch: typeof fetch }).fetch = fetchTrap as unknown as typeof fetch;
});
afterEach(() => {
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
resetHandleForTests();
});
it("livez probe stub is invoked instead of the real fetch (issue #449)", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "regression guard" }, kv);
expect(instantLocalFallbackProbe).toHaveBeenCalledTimes(1);
expect(fetchTrap).not.toHaveBeenCalled();
});
it("memory_save persists to disk immediately after saving", async () => {
const kv = new InMemoryKV("/tmp/test-handle.json");
const result = await handleToolCall(
"memory_save",
{ content: "Test memory content" },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.saved).toMatch(/^mem_/);
expect(writeFileSync).toHaveBeenCalledWith(
"/tmp/test-handle.json",
expect.any(String),
"utf-8",
);
});
it("memory_save without persist path does not call writeFileSync", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "No persist path" }, kv);
expect(writeFileSync).not.toHaveBeenCalled();
});
it("memory_save throws when content is missing", async () => {
const kv = new InMemoryKV();
await expect(
handleToolCall("memory_save", {}, kv),
).rejects.toThrow("content is required");
});
it("memory_save rejects non-string content safely (no runtime TypeError)", async () => {
const kv = new InMemoryKV();
// These would have crashed on .trim() before the type-guard fix.
for (const bogus of [42, {}, [], null, undefined, true]) {
await expect(
handleToolCall("memory_save", { content: bogus }, kv),
).rejects.toThrow("content is required");
}
});
it("memory_recall returns matching memories", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "TypeScript is great" }, kv);
await handleToolCall("memory_save", { content: "Python is also great" }, kv);
const result = await handleToolCall(
"memory_recall",
{ query: "typescript" },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].content).toBe("TypeScript is great");
});
it("memory_save accepts concepts/files as arrays (plugin skill format, #139)", async () => {
const kv = new InMemoryKV();
const result = await handleToolCall(
"memory_save",
{
content: "Use HMAC for API auth",
concepts: ["hmac", "api-auth", "security"],
files: ["src/auth.ts", "src/middleware.ts"],
},
kv,
);
const saved = JSON.parse(result.content[0].text);
const mem = await kv.get<{ concepts: string[]; files: string[] }>(
"mem:memories",
saved.saved,
);
expect(mem?.concepts).toEqual(["hmac", "api-auth", "security"]);
expect(mem?.files).toEqual(["src/auth.ts", "src/middleware.ts"]);
});
it("memory_save still accepts concepts/files as comma-separated strings (legacy)", async () => {
const kv = new InMemoryKV();
const result = await handleToolCall(
"memory_save",
{
content: "JWT refresh rotation",
concepts: "jwt, refresh, rotation",
files: "src/auth.ts",
},
kv,
);
const saved = JSON.parse(result.content[0].text);
const mem = await kv.get<{ concepts: string[]; files: string[] }>(
"mem:memories",
saved.saved,
);
expect(mem?.concepts).toEqual(["jwt", "refresh", "rotation"]);
expect(mem?.files).toEqual(["src/auth.ts"]);
});
it("memory_smart_search falls back to substring match in the standalone shim (#139)", async () => {
const kv = new InMemoryKV();
await handleToolCall(
"memory_save",
{ content: "Use bcrypt for password hashing" },
kv,
);
await handleToolCall(
"memory_save",
{ content: "Use argon2id for new projects" },
kv,
);
const result = await handleToolCall(
"memory_smart_search",
{ query: "bcrypt", limit: 5 },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].content).toBe("Use bcrypt for password hashing");
});
it("memory_smart_search rejects empty query to prevent match-all in forget flow (#139)", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "anything" }, kv);
await expect(
handleToolCall("memory_smart_search", {}, kv),
).rejects.toThrow("query is required");
await expect(
handleToolCall("memory_smart_search", { query: "" }, kv),
).rejects.toThrow("query is required");
await expect(
handleToolCall("memory_smart_search", { query: " " }, kv),
).rejects.toThrow("query is required");
});
it("memory_smart_search searches files and concepts, not just title/content (#139)", async () => {
const kv = new InMemoryKV();
await handleToolCall(
"memory_save",
{
content: "generic note",
concepts: ["oauth", "token-rotation"],
files: ["src/auth/refresh.ts"],
},
kv,
);
await handleToolCall("memory_save", { content: "unrelated" }, kv);
// Find by file path
const byFile = JSON.parse(
(
await handleToolCall(
"memory_smart_search",
{ query: "src/auth/refresh.ts" },
kv,
)
).content[0].text,
);
expect(byFile.results).toHaveLength(1);
expect(byFile.results[0].files).toContain("src/auth/refresh.ts");
// Find by concept
const byConcept = JSON.parse(
(
await handleToolCall(
"memory_smart_search",
{ query: "token-rotation" },
kv,
)
).content[0].text,
);
expect(byConcept.results).toHaveLength(1);
});
it("memory_sessions honours the limit arg (#139)", async () => {
const kv = new InMemoryKV();
for (let i = 0; i < 5; i++) {
await kv.set("mem:sessions", `ses_${i}`, {
id: `ses_${i}`,
project: "demo",
});
}
const result = await handleToolCall(
"memory_sessions",
{ limit: 2 },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.sessions).toHaveLength(2);
});
it("parseLimit clamps bad/malicious limit values to a safe range", async () => {
const kv = new InMemoryKV();
for (let i = 0; i < 150; i++) {
await handleToolCall("memory_save", { content: `mem ${i}` }, kv);
}
// Negative / NaN / Infinity / string / object — all should fall back
// to the default (10) for memory_smart_search.
for (const bogus of [-1, NaN, Infinity, "abc", {}, true]) {
const r = await handleToolCall(
"memory_smart_search",
{ query: "mem", limit: bogus },
kv,
);
expect(JSON.parse(r.content[0].text).results).toHaveLength(10);
}
// An absurdly large limit gets clamped to MAX_LIMIT (100).
const huge = await handleToolCall(
"memory_smart_search",
{ query: "mem", limit: 99999 },
kv,
);
expect(JSON.parse(huge.content[0].text).results).toHaveLength(100);
});
it("memory_governance_delete removes memories by id array (#139)", async () => {
const kv = new InMemoryKV();
const a = JSON.parse(
(await handleToolCall("memory_save", { content: "one" }, kv)).content[0]
.text,
);
const b = JSON.parse(
(await handleToolCall("memory_save", { content: "two" }, kv)).content[0]
.text,
);
const c = JSON.parse(
(await handleToolCall("memory_save", { content: "three" }, kv)).content[0]
.text,
);
const result = await handleToolCall(
"memory_governance_delete",
{ memoryIds: [a.saved, c.saved] },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.deleted).toBe(2);
expect(parsed.requested).toBe(2);
const remaining = await kv.list<Record<string, unknown>>("mem:memories");
expect(remaining).toHaveLength(1);
expect((remaining[0] as { id: string }).id).toBe(b.saved);
});
it("memory_governance_delete accepts CSV-string memoryIds too", async () => {
const kv = new InMemoryKV();
const saved = JSON.parse(
(await handleToolCall("memory_save", { content: "x" }, kv)).content[0]
.text,
);
const result = await handleToolCall(
"memory_governance_delete",
{ memoryIds: saved.saved, reason: "test csv" },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.deleted).toBe(1);
expect(parsed.reason).toBe("test csv");
});
it("memory_governance_delete throws when memoryIds is missing or empty", async () => {
const kv = new InMemoryKV();
await expect(
handleToolCall("memory_governance_delete", {}, kv),
).rejects.toThrow("memoryIds is required");
await expect(
handleToolCall("memory_governance_delete", { memoryIds: [] }, kv),
).rejects.toThrow("memoryIds is required");
});
it("memory_governance_delete silently skips unknown ids", async () => {
const kv = new InMemoryKV();
const saved = JSON.parse(
(await handleToolCall("memory_save", { content: "real" }, kv)).content[0]
.text,
);
const result = await handleToolCall(
"memory_governance_delete",
{ memoryIds: [saved.saved, "mem_does_not_exist"] },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.deleted).toBe(1);
expect(parsed.requested).toBe(2);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { readFileSync } from "node:fs";
import {
getAllTools,
getVisibleTools,
} from "../src/mcp/tools-registry.js";
// plugin manifests and README advertise 51 MCP tools. The old
// default was AGENTMEMORY_TOOLS=core which silently capped the surface
// at 8 essentials with no indication the other 43 existed. Default
// flipped to "all"; the lean set is still accessible via
// AGENTMEMORY_TOOLS=core.
describe("MCP tool surface default (#553)", () => {
const ORIG = process.env["AGENTMEMORY_TOOLS"];
beforeEach(() => {
delete process.env["AGENTMEMORY_TOOLS"];
});
afterEach(() => {
if (ORIG === undefined) delete process.env["AGENTMEMORY_TOOLS"];
else process.env["AGENTMEMORY_TOOLS"] = ORIG;
});
it("default returns the full 51-tool surface, matching plugin advertising", () => {
const visible = getVisibleTools();
const all = getAllTools();
expect(visible.length).toBe(all.length);
expect(visible.length).toBeGreaterThanOrEqual(48);
});
it("AGENTMEMORY_TOOLS=all returns the same full set", () => {
process.env["AGENTMEMORY_TOOLS"] = "all";
expect(getVisibleTools().length).toBe(getAllTools().length);
});
it("AGENTMEMORY_TOOLS=core returns the 8 essential tools", () => {
process.env["AGENTMEMORY_TOOLS"] = "core";
const names = new Set(getVisibleTools().map((t) => t.name));
expect(names.size).toBe(8);
for (const t of [
"memory_save",
"memory_recall",
"memory_consolidate",
"memory_smart_search",
"memory_sessions",
"memory_diagnose",
"memory_lesson_save",
"memory_reflect",
]) {
expect(names.has(t)).toBe(true);
}
});
it("plugin .mcp.json provides default env interpolation so CC parse never fails (#510)", () => {
const raw = readFileSync("plugin/.mcp.json", "utf-8");
const cfg = JSON.parse(raw) as {
mcpServers: { agentmemory: { env: Record<string, string> } };
};
const env = cfg.mcpServers.agentmemory.env;
// Per Claude Code MCP docs: ${VAR} without a default fails config
// parse when VAR is unset, silently dropping the server. ${VAR:-x}
// form is what unblocks fresh installs that haven't exported
// AGENTMEMORY_URL.
expect(env["AGENTMEMORY_URL"]).toMatch(/\$\{AGENTMEMORY_URL:-/);
expect(env["AGENTMEMORY_SECRET"]).toMatch(/\$\{AGENTMEMORY_SECRET:-/);
expect(env["AGENTMEMORY_TOOLS"]).toMatch(/\$\{AGENTMEMORY_TOOLS:-all\}/);
});
});
+275
View File
@@ -0,0 +1,275 @@
import { describe, it, expect, vi } from "vitest";
import {
createMessageParser,
formatResponse,
processLine,
type JsonRpcResponse,
type RequestHandler,
} from "../src/mcp/transport.js";
function collector() {
const out: JsonRpcResponse[] = [];
const err: string[] = [];
return {
out,
err,
writeOut: (r: JsonRpcResponse) => out.push(r),
writeErr: (m: string) => err.push(m),
};
}
const okHandler: RequestHandler = async (method) => ({ method });
describe("processLine — request path", () => {
it("emits a response for a request with id", async () => {
const c = collector();
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }),
okHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(1);
expect(c.out[0]).toEqual({
jsonrpc: "2.0",
id: 1,
result: { method: "initialize" },
});
});
it("emits an error response when the handler throws on a request", async () => {
const c = collector();
const throwingHandler: RequestHandler = async () => {
throw new Error("boom");
};
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }),
throwingHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBe(7);
expect(c.out[0].error?.code).toBe(-32603);
expect(c.out[0].error?.message).toBe("boom");
});
});
describe("processLine — notification path (#129)", () => {
it("does NOT emit a response for a notification (no id field)", async () => {
const c = collector();
const handlerCalled = vi.fn(async () => ({ shouldNotEscape: true }));
await processLine(
JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}),
handlerCalled,
c.writeOut,
c.writeErr,
);
expect(handlerCalled).toHaveBeenCalledOnce();
expect(c.out).toHaveLength(0);
expect(c.err).toHaveLength(0);
});
it("does NOT emit a response for a notification with id: null", async () => {
const c = collector();
await processLine(
JSON.stringify({
jsonrpc: "2.0",
id: null,
method: "notifications/cancelled",
}),
okHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(0);
});
it("logs to stderr but does NOT emit a response when a notification handler throws", async () => {
const c = collector();
const throwingHandler: RequestHandler = async () => {
throw new Error("notification crash");
};
await processLine(
JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}),
throwingHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(0);
expect(c.err).toHaveLength(1);
expect(c.err[0]).toContain("notification handler error");
expect(c.err[0]).toContain("notification crash");
});
});
describe("processLine — malformed input", () => {
it("emits a parse error with id: null for invalid JSON", async () => {
const c = collector();
await processLine("not-json", okHandler, c.writeOut, c.writeErr);
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBeNull();
expect(c.out[0].error?.code).toBe(-32700);
expect(c.out[0].error?.message).toBe("Parse error");
});
it("ignores empty / whitespace-only lines", async () => {
const c = collector();
await processLine("", okHandler, c.writeOut, c.writeErr);
await processLine(" \t ", okHandler, c.writeOut, c.writeErr);
expect(c.out).toHaveLength(0);
expect(c.err).toHaveLength(0);
});
it("emits an Invalid Request error when a request has an id but no jsonrpc", async () => {
const c = collector();
await processLine(
JSON.stringify({ id: 1, method: "tools/list" }),
okHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBe(1);
expect(c.out[0].error?.code).toBe(-32600);
});
it("silently drops a malformed message that has no id (treated as notification)", async () => {
const c = collector();
await processLine(
JSON.stringify({ method: "broken" }),
okHandler,
c.writeOut,
c.writeErr,
);
// No jsonrpc field, no id — drop without responding.
expect(c.out).toHaveLength(0);
});
it("silently drops a malformed message with a non-primitive id (can't safely echo)", async () => {
const c = collector();
await processLine(
JSON.stringify({ id: { nested: true }, method: "broken" }),
okHandler,
c.writeOut,
c.writeErr,
);
// Malformed shape + non-primitive id — can't echo id back, drop silently.
expect(c.out).toHaveLength(0);
});
});
describe("processLine — id type validation (JSON-RPC §4)", () => {
it("rejects a request whose id is an object with -32600 and id: null", async () => {
const c = collector();
const handlerCalled = vi.fn(okHandler);
await processLine(
JSON.stringify({
jsonrpc: "2.0",
id: { bogus: true },
method: "tools/list",
}),
handlerCalled,
c.writeOut,
c.writeErr,
);
expect(handlerCalled).not.toHaveBeenCalled();
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBeNull();
expect(c.out[0].error?.code).toBe(-32600);
expect(c.out[0].error?.message).toContain("id must be");
});
it("rejects a request whose id is an array", async () => {
const c = collector();
const handlerCalled = vi.fn(okHandler);
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: [1, 2], method: "tools/list" }),
handlerCalled,
c.writeOut,
c.writeErr,
);
expect(handlerCalled).not.toHaveBeenCalled();
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBeNull();
expect(c.out[0].error?.code).toBe(-32600);
});
it("rejects a request whose id is a boolean", async () => {
const c = collector();
const handlerCalled = vi.fn(okHandler);
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: true, method: "tools/list" }),
handlerCalled,
c.writeOut,
c.writeErr,
);
expect(handlerCalled).not.toHaveBeenCalled();
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBeNull();
expect(c.out[0].error?.code).toBe(-32600);
});
it("accepts a request with string id", async () => {
const c = collector();
await processLine(
JSON.stringify({ jsonrpc: "2.0", id: "abc-123", method: "ping" }),
okHandler,
c.writeOut,
c.writeErr,
);
expect(c.out).toHaveLength(1);
expect(c.out[0].id).toBe("abc-123");
expect(c.out[0].result).toEqual({ method: "ping" });
});
});
describe("stdio framing", () => {
it("parses Content-Length framed MCP messages split across chunks", () => {
const messages: string[] = [];
const parser = createMessageParser((message) => messages.push(message));
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" });
const framed = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
parser.push(framed.slice(0, 12));
parser.push(framed.slice(12));
expect(messages).toEqual([body]);
expect(parser.isFramed()).toBe(true);
});
it("parses newline-delimited JSON for existing clients", () => {
const messages: string[] = [];
const parser = createMessageParser((message) => messages.push(message));
const first = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
const second = JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" });
parser.push(`${first}\n${second}\n`);
expect(messages).toEqual([first, second]);
expect(parser.isFramed()).toBe(false);
});
it("formats responses with Content-Length framing when requested", () => {
const response: JsonRpcResponse = {
jsonrpc: "2.0",
id: 1,
result: { ok: true },
};
const formatted = formatResponse(response, true);
expect(Array.isArray(formatted)).toBe(true);
if (!Array.isArray(formatted)) throw new Error("expected framed response");
const header = formatted[0].toString("ascii");
const body = formatted[1].toString("utf8");
expect(header).toBe(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`);
expect(JSON.parse(body)).toEqual(response);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
// /memories and /export must support count + pagination so the
// viewer and `agentmemory status` work on large corpora (8K+ memories)
// without timing out at the iii engine boundary.
describe("memories + export pagination (#544)", () => {
const api = readFileSync("src/triggers/api.ts", "utf-8");
it("api::memories accepts count=true and returns total + latestCount", () => {
expect(api).toMatch(/req\.query_params\?\.\["count"\]\s*===\s*"true"/);
// count must report the SAME scope as the list path (#554 follow-up).
expect(api).toMatch(/total:\s*filtered\.length/);
expect(api).toMatch(/latestCount:\s*filtered\.filter/);
});
it("api::memories accepts limit + offset query params", () => {
expect(api).toMatch(/query_params\?\.\["limit"\]/);
expect(api).toMatch(/query_params\?\.\["offset"\]/);
expect(api).toMatch(/filtered\.slice\(offset/);
expect(api).toMatch(/total:\s*filtered\.length/);
});
it("api::memories caps limit at 5000 to bound response size", () => {
expect(api).toMatch(/Math\.min\(parsedLimit,\s*5000\)/);
});
it("api::export passes through maxSessions + offset query params", () => {
expect(api).toMatch(/query_params\?\.\["maxSessions"\]/);
expect(api).toMatch(/query_params\?\.\["offset"\]/);
// The payload object is named `payload` in our handler; assert it is
// forwarded to mem::export rather than the previous empty object.
expect(api).toMatch(
/sdk\.trigger\(\{\s*function_id:\s*"mem::export",\s*payload,/m,
);
});
it("viewer dashboard caps memories?latest fetch with limit", () => {
const viewer = readFileSync("src/viewer/index.html", "utf-8");
expect(viewer).toMatch(/memories\?latest=true&limit=500/);
expect(viewer).toMatch(/memories\?latest=true&limit=2000/);
});
});
+755
View File
@@ -0,0 +1,755 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerMeshFunction } from "../src/functions/mesh.js";
import type {
MeshPeer,
Memory,
Action,
SemanticMemory,
ProceduralMemory,
MemoryRelation,
GraphNode,
GraphEdge,
} from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
describe("Mesh Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
vi.clearAllMocks();
registerMeshFunction(sdk as never, kv as never);
});
describe("mesh-register", () => {
it("registers a valid peer", async () => {
const result = (await sdk.trigger("mem::mesh-register", {
url: "https://peer1.example.com",
name: "peer-1",
sharedScopes: ["memories"],
})) as { success: boolean; peer: MeshPeer };
expect(result.success).toBe(true);
expect(result.peer.url).toBe("https://peer1.example.com");
expect(result.peer.name).toBe("peer-1");
expect(result.peer.status).toBe("disconnected");
expect(result.peer.sharedScopes).toEqual(["memories"]);
expect(result.peer.id).toMatch(/^peer_/);
const peers = await kv.list<MeshPeer>("mem:mesh");
expect(peers.length).toBe(1);
});
it("uses expanded default sharedScopes when not provided", async () => {
const result = (await sdk.trigger("mem::mesh-register", {
url: "https://peer2.example.com",
name: "peer-2",
})) as { success: boolean; peer: MeshPeer };
expect(result.success).toBe(true);
expect(result.peer.sharedScopes).toEqual([
"memories",
"actions",
"semantic",
"procedural",
"relations",
"graph:nodes",
"graph:edges",
]);
});
it("stores syncFilter when provided", async () => {
const result = (await sdk.trigger("mem::mesh-register", {
url: "https://peer3.example.com",
name: "peer-3",
syncFilter: { project: "/my/project" },
})) as { success: boolean; peer: MeshPeer };
expect(result.success).toBe(true);
expect(result.peer.syncFilter).toEqual({ project: "/my/project" });
});
it("returns error when url is missing", async () => {
const result = (await sdk.trigger("mem::mesh-register", {
name: "peer-1",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("url and name are required");
});
it("returns error when name is missing", async () => {
const result = (await sdk.trigger("mem::mesh-register", {
url: "https://peer1.example.com",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("url and name are required");
});
it("returns error for duplicate url", async () => {
await sdk.trigger("mem::mesh-register", {
url: "https://peer1.example.com",
name: "peer-1",
});
const result = (await sdk.trigger("mem::mesh-register", {
url: "https://peer1.example.com",
name: "peer-1-duplicate",
})) as { success: boolean; error: string; peerId: string };
expect(result.success).toBe(false);
expect(result.error).toContain("peer already registered");
expect(result.peerId).toBeDefined();
});
});
describe("mesh-list", () => {
it("returns empty list when no peers registered", async () => {
const result = (await sdk.trigger("mem::mesh-list", {})) as {
success: boolean;
peers: MeshPeer[];
};
expect(result.success).toBe(true);
expect(result.peers).toEqual([]);
});
it("returns all registered peers", async () => {
await sdk.trigger("mem::mesh-register", {
url: "https://peer1.example.com",
name: "peer-1",
});
await sdk.trigger("mem::mesh-register", {
url: "https://peer2.example.com",
name: "peer-2",
});
const result = (await sdk.trigger("mem::mesh-list", {})) as {
success: boolean;
peers: MeshPeer[];
};
expect(result.success).toBe(true);
expect(result.peers.length).toBe(2);
expect(result.peers.map((p) => p.name).sort()).toEqual(["peer-1", "peer-2"]);
});
});
describe("mesh-sync", () => {
it("requires a configured shared secret", async () => {
const regResult = (await sdk.trigger("mem::mesh-register", {
url: "https://peer1.example.com",
name: "peer-1",
})) as { success: boolean; peer: MeshPeer };
const result = (await sdk.trigger("mem::mesh-sync", {
peerId: regResult.peer.id,
direction: "push",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("AGENTMEMORY_SECRET");
});
it("sends authorization headers to peers when syncing", async () => {
const authedSdk = mockSdk();
const authedKv = mockKV();
registerMeshFunction(authedSdk as never, authedKv as never, "mesh-secret");
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ accepted: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const regResult = (await authedSdk.trigger("mem::mesh-register", {
url: "https://peer2.example.com",
name: "peer-2",
})) as { success: boolean; peer: MeshPeer };
const result = (await authedSdk.trigger("mem::mesh-sync", {
peerId: regResult.peer.id,
direction: "push",
})) as { success: boolean; results: Array<{ errors: string[] }> };
expect(result.success).toBe(true);
expect(result.results[0].errors).toEqual([]);
expect(fetchMock).toHaveBeenCalledWith(
"https://peer2.example.com/agentmemory/mesh/receive",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer mesh-secret",
}),
}),
);
vi.unstubAllGlobals();
});
});
describe("mesh-receive", () => {
it("accepts new memories", async () => {
const mem: Memory = {
id: "mem_1",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "pattern",
title: "Test memory",
content: "Test content",
concepts: ["test"],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
};
const result = (await sdk.trigger("mem::mesh-receive", {
memories: [mem],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<Memory>("mem:memories", "mem_1");
expect(stored).toBeDefined();
expect(stored!.title).toBe("Test memory");
});
it("accepts newer memory over existing (last-write-wins)", async () => {
const older: Memory = {
id: "mem_1",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "pattern",
title: "Old title",
content: "Old content",
concepts: [],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: true,
};
await kv.set("mem:memories", "mem_1", older);
const newer: Memory = {
...older,
updatedAt: "2026-03-02T00:00:00Z",
title: "New title",
content: "New content",
version: 2,
};
const result = (await sdk.trigger("mem::mesh-receive", {
memories: [newer],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<Memory>("mem:memories", "mem_1");
expect(stored!.title).toBe("New title");
});
it("rejects older memory than existing", async () => {
const existing: Memory = {
id: "mem_1",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-02T00:00:00Z",
type: "pattern",
title: "Existing title",
content: "Existing content",
concepts: [],
files: [],
sessionIds: [],
strength: 5,
version: 2,
isLatest: true,
};
await kv.set("mem:memories", "mem_1", existing);
const older: Memory = {
...existing,
updatedAt: "2026-03-01T00:00:00Z",
title: "Old title",
version: 1,
};
const result = (await sdk.trigger("mem::mesh-receive", {
memories: [older],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(0);
const stored = await kv.get<Memory>("mem:memories", "mem_1");
expect(stored!.title).toBe("Existing title");
});
it("skips memory entries with missing id", async () => {
const result = (await sdk.trigger("mem::mesh-receive", {
memories: [
{ updatedAt: "2026-03-01T00:00:00Z", title: "No ID" } as unknown as Memory,
],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(0);
});
it("skips memory entries with invalid date", async () => {
const result = (await sdk.trigger("mem::mesh-receive", {
memories: [
{
id: "mem_bad_date",
updatedAt: "not-a-date",
title: "Bad date",
} as unknown as Memory,
],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(0);
});
it("accepts new actions", async () => {
const action: Action = {
id: "act_1",
title: "Fix bug",
description: "Fix the login bug",
status: "pending",
priority: 1,
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
createdBy: "agent-1",
tags: ["bug"],
sourceObservationIds: [],
sourceMemoryIds: [],
};
const result = (await sdk.trigger("mem::mesh-receive", {
actions: [action],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<Action>("mem:actions", "act_1");
expect(stored).toBeDefined();
expect(stored!.title).toBe("Fix bug");
});
it("accepts newer action over existing (last-write-wins)", async () => {
const older: Action = {
id: "act_1",
title: "Old action",
description: "Old desc",
status: "pending",
priority: 1,
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
createdBy: "agent-1",
tags: [],
sourceObservationIds: [],
sourceMemoryIds: [],
};
await kv.set("mem:actions", "act_1", older);
const newer: Action = {
...older,
updatedAt: "2026-03-02T00:00:00Z",
title: "Updated action",
status: "done",
};
const result = (await sdk.trigger("mem::mesh-receive", {
actions: [newer],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<Action>("mem:actions", "act_1");
expect(stored!.title).toBe("Updated action");
expect(stored!.status).toBe("done");
});
it("rejects older action than existing", async () => {
const existing: Action = {
id: "act_1",
title: "Current action",
description: "Current desc",
status: "active",
priority: 1,
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-02T00:00:00Z",
createdBy: "agent-1",
tags: [],
sourceObservationIds: [],
sourceMemoryIds: [],
};
await kv.set("mem:actions", "act_1", existing);
const older: Action = {
...existing,
updatedAt: "2026-03-01T00:00:00Z",
title: "Stale action",
};
const result = (await sdk.trigger("mem::mesh-receive", {
actions: [older],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(0);
const stored = await kv.get<Action>("mem:actions", "act_1");
expect(stored!.title).toBe("Current action");
});
it("skips action entries with missing id", async () => {
const result = (await sdk.trigger("mem::mesh-receive", {
actions: [
{ updatedAt: "2026-03-01T00:00:00Z", title: "No ID" } as unknown as Action,
],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(0);
});
it("skips action entries with invalid date", async () => {
const result = (await sdk.trigger("mem::mesh-receive", {
actions: [
{
id: "act_bad_date",
updatedAt: "invalid-date-string",
title: "Bad date",
} as unknown as Action,
],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(0);
});
it("accepts both memories and actions in one call", async () => {
const mem: Memory = {
id: "mem_combo",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "fact",
title: "Combo memory",
content: "Content",
concepts: [],
files: [],
sessionIds: [],
strength: 3,
version: 1,
isLatest: true,
};
const action: Action = {
id: "act_combo",
title: "Combo action",
description: "Desc",
status: "pending",
priority: 2,
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
createdBy: "agent-1",
tags: [],
sourceObservationIds: [],
sourceMemoryIds: [],
};
const result = (await sdk.trigger("mem::mesh-receive", {
memories: [mem],
actions: [action],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(2);
});
it("returns zero accepted for empty arrays", async () => {
const result = (await sdk.trigger("mem::mesh-receive", {
memories: [],
actions: [],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(0);
});
});
describe("mesh-remove", () => {
it("removes a registered peer", async () => {
const regResult = (await sdk.trigger("mem::mesh-register", {
url: "https://peer1.example.com",
name: "peer-1",
})) as { success: boolean; peer: MeshPeer };
const result = (await sdk.trigger("mem::mesh-remove", {
peerId: regResult.peer.id,
})) as { success: boolean };
expect(result.success).toBe(true);
const peers = await kv.list<MeshPeer>("mem:mesh");
expect(peers.length).toBe(0);
});
it("returns error when peerId is missing", async () => {
const result = (await sdk.trigger("mem::mesh-remove", {})) as {
success: boolean;
error: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain("peerId is required");
});
it("succeeds silently for non-existent peerId", async () => {
const result = (await sdk.trigger("mem::mesh-remove", {
peerId: "peer_nonexistent",
})) as { success: boolean };
expect(result.success).toBe(true);
});
});
describe("mesh-receive expanded scopes", () => {
it("accepts semantic memories", async () => {
const sem: SemanticMemory = {
id: "sem_1",
fact: "React uses JSX",
confidence: 0.9,
sourceSessionIds: ["ses_1"],
sourceMemoryIds: ["mem_1"],
accessCount: 1,
lastAccessedAt: "2026-03-01T00:00:00Z",
strength: 7,
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
};
const result = (await sdk.trigger("mem::mesh-receive", {
semantic: [sem],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<SemanticMemory>("mem:semantic", "sem_1");
expect(stored).toBeDefined();
expect(stored!.fact).toBe("React uses JSX");
});
it("accepts procedural memories", async () => {
const proc: ProceduralMemory = {
id: "proc_1",
name: "Deploy to prod",
steps: ["build", "test", "deploy"],
triggerCondition: "on merge to main",
frequency: 5,
sourceSessionIds: ["ses_1"],
strength: 8,
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
};
const result = (await sdk.trigger("mem::mesh-receive", {
procedural: [proc],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<ProceduralMemory>("mem:procedural", "proc_1");
expect(stored!.name).toBe("Deploy to prod");
});
it("accepts graph nodes", async () => {
const node: GraphNode = {
id: "gn_1",
type: "concept",
name: "typescript",
properties: {},
sourceObservationIds: ["obs_1"],
createdAt: "2026-03-01T00:00:00Z",
};
const result = (await sdk.trigger("mem::mesh-receive", {
graphNodes: [node],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<GraphNode>("mem:graph:nodes", "gn_1");
expect(stored!.name).toBe("typescript");
});
it("accepts graph edges", async () => {
const edge: GraphEdge = {
id: "ge_1",
type: "uses",
sourceNodeId: "gn_1",
targetNodeId: "gn_2",
weight: 1,
sourceObservationIds: ["obs_1"],
createdAt: "2026-03-01T00:00:00Z",
};
const result = (await sdk.trigger("mem::mesh-receive", {
graphEdges: [edge],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<GraphEdge>("mem:graph:edges", "ge_1");
expect(stored!.type).toBe("uses");
});
it("accepts relations", async () => {
const rel: MemoryRelation = {
type: "supersedes",
sourceId: "mem_2",
targetId: "mem_1",
createdAt: "2026-03-01T00:00:00Z",
confidence: 0.95,
};
const relWithId = { ...rel, id: "rel_1" } as MemoryRelation & { id: string };
const result = (await sdk.trigger("mem::mesh-receive", {
relations: [relWithId],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
});
it("accepts all scope types in one call", async () => {
const mem: Memory = {
id: "mem_all",
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
type: "fact",
title: "All scopes test",
content: "Content",
concepts: [],
files: [],
sessionIds: [],
strength: 5,
version: 1,
isLatest: true,
};
const sem: SemanticMemory = {
id: "sem_all",
fact: "Test",
confidence: 0.5,
sourceSessionIds: [],
sourceMemoryIds: [],
accessCount: 0,
lastAccessedAt: "2026-03-01T00:00:00Z",
strength: 5,
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
};
const node: GraphNode = {
id: "gn_all",
type: "file",
name: "test.ts",
properties: {},
sourceObservationIds: [],
createdAt: "2026-03-01T00:00:00Z",
};
const result = (await sdk.trigger("mem::mesh-receive", {
memories: [mem],
semantic: [sem],
graphNodes: [node],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(3);
});
it("applies LWW for semantic memories", async () => {
const older: SemanticMemory = {
id: "sem_lww",
fact: "Old fact",
confidence: 0.5,
sourceSessionIds: [],
sourceMemoryIds: [],
accessCount: 1,
lastAccessedAt: "2026-03-01T00:00:00Z",
strength: 5,
createdAt: "2026-03-01T00:00:00Z",
updatedAt: "2026-03-01T00:00:00Z",
};
await kv.set("mem:semantic", "sem_lww", older);
const newer: SemanticMemory = {
...older,
fact: "New fact",
updatedAt: "2026-03-02T00:00:00Z",
};
const result = (await sdk.trigger("mem::mesh-receive", {
semantic: [newer],
})) as { success: boolean; accepted: number };
expect(result.success).toBe(true);
expect(result.accepted).toBe(1);
const stored = await kv.get<SemanticMemory>("mem:semantic", "sem_lww");
expect(stored!.fact).toBe("New fact");
});
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it, afterEach } from "vitest";
import { MinimaxProvider } from "../src/providers/minimax.js";
describe("MinimaxProvider — base URL resolution (#285)", () => {
const originalEnv = process.env["MINIMAX_BASE_URL"];
afterEach(() => {
if (originalEnv === undefined) {
delete process.env["MINIMAX_BASE_URL"];
} else {
process.env["MINIMAX_BASE_URL"] = originalEnv;
}
});
it("defaults to https://api.minimax.io/anthropic (not the legacy minimaxi.com host)", () => {
delete process.env["MINIMAX_BASE_URL"];
const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800);
expect((provider as unknown as { baseUrl: string }).baseUrl).toBe(
"https://api.minimax.io/anthropic",
);
});
it("honors MINIMAX_BASE_URL via getEnvVar (merged ~/.agentmemory/.env + process.env)", () => {
process.env["MINIMAX_BASE_URL"] = "https://custom.example.com/anthropic";
const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800);
expect((provider as unknown as { baseUrl: string }).baseUrl).toBe(
"https://custom.example.com/anthropic",
);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { loadConfig } from "../src/config";
const PORT_ENVS = [
"III_REST_PORT",
"III_STREAM_PORT",
"III_STREAMS_PORT",
"III_ENGINE_PORT",
"III_ENGINE_URL",
] as const;
describe("multi-instance port auto-derive (#750)", () => {
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const k of PORT_ENVS) {
saved[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
for (const k of PORT_ENVS) {
if (saved[k] === undefined) {
delete process.env[k];
} else {
process.env[k] = saved[k];
}
}
});
it("default REST anchor yields canonical 3111/3112/49134 quartet", () => {
const cfg = loadConfig();
expect(cfg.restPort).toBe(3111);
expect(cfg.streamsPort).toBe(3112);
expect(cfg.engineUrl).toBe("ws://localhost:49134");
});
it("relocating REST drags streams + engine with it", () => {
process.env["III_REST_PORT"] = "3211";
const cfg = loadConfig();
expect(cfg.restPort).toBe(3211);
expect(cfg.streamsPort).toBe(3212);
expect(cfg.engineUrl).toBe("ws://localhost:49234");
});
it("instance N=2 block (3311) lands on 3312 + 49334", () => {
process.env["III_REST_PORT"] = "3311";
const cfg = loadConfig();
expect(cfg.restPort).toBe(3311);
expect(cfg.streamsPort).toBe(3312);
expect(cfg.engineUrl).toBe("ws://localhost:49334");
});
it("explicit III_STREAM_PORT pins streams without affecting REST/engine", () => {
process.env["III_REST_PORT"] = "3211";
process.env["III_STREAM_PORT"] = "9999";
const cfg = loadConfig();
expect(cfg.restPort).toBe(3211);
expect(cfg.streamsPort).toBe(9999);
expect(cfg.engineUrl).toBe("ws://localhost:49234");
});
it("legacy III_STREAMS_PORT still honored", () => {
process.env["III_STREAMS_PORT"] = "9000";
const cfg = loadConfig();
expect(cfg.streamsPort).toBe(9000);
});
it("explicit III_ENGINE_PORT pins engine without affecting REST/streams", () => {
process.env["III_REST_PORT"] = "3211";
process.env["III_ENGINE_PORT"] = "55555";
const cfg = loadConfig();
expect(cfg.restPort).toBe(3211);
expect(cfg.streamsPort).toBe(3212);
expect(cfg.engineUrl).toBe("ws://localhost:55555");
});
it("legacy III_ENGINE_URL overrides derivation entirely", () => {
process.env["III_REST_PORT"] = "3211";
process.env["III_ENGINE_URL"] = "ws://remote-host:49999";
const cfg = loadConfig();
expect(cfg.engineUrl).toBe("ws://remote-host:49999");
});
});
+381
View File
@@ -0,0 +1,381 @@
import { describe, it, expect, vi, afterAll, beforeEach } from "vitest";
import { existsSync, rmSync } from "node:fs";
vi.mock("iii-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("iii-sdk")>();
return {
...actual,
getContext: () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
}),
};
});
vi.mock("../src/functions/search.js", () => ({
getSearchIndex: () => ({
add: vi.fn(),
}),
vectorIndexAddGuarded: vi.fn().mockResolvedValue(false),
}));
const mockTrigger = vi.fn().mockResolvedValue(undefined);
const mockSdk = { trigger: mockTrigger } as any;
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
if (!store.has(scope)) return [];
return Array.from(store.get(scope)!.values()) as T[];
},
getStore: () => store,
};
}
const kv = mockKV() as any;
import { registerObserveFunction } from "../src/functions/observe.js";
import { registerCompressFunction } from "../src/functions/compress.js";
import type { RawObservation, CompressedObservation, MemoryProvider } from "../src/types.js";
const VALID_COMPRESS_XML = `<type>image</type>
<title>Screenshot of Red Dot</title>
<subtitle>Test image observation</subtitle>
<facts><fact>Image shows a single red pixel on white background</fact></facts>
<narrative>A vision model described a screenshot showing a red dot on a white background</narrative>
<concepts><concept>testing</concept><concept>screenshot</concept></concepts>
<files></files>
<importance>5</importance>`;
describe("End-to-End Multimodal Flow", () => {
let savedImagePath: string | undefined;
afterAll(() => {
if (savedImagePath && existsSync(savedImagePath)) {
rmSync(savedImagePath);
console.log(`Cleanup: Removed test image at ${savedImagePath}`);
}
});
beforeEach(() => {
mockTrigger.mockClear();
});
it("Step 1: Agent image should be successfully saved to hard drive", async () => {
let observeCallback: any = null;
const sdkMocker = { ...mockSdk, registerFunction: vi.fn((id, cb) => { if (id === "mem::observe") observeCallback = cb; }) };
registerObserveFunction(sdkMocker, kv);
const fakeIncomingData = {
hookType: "post_tool_use",
sessionId: "test-session",
timestamp: new Date().toISOString(),
data: {
tool_name: "screenshot",
tool_output: {
image_data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg=="
}
}
};
const res = await observeCallback(fakeIncomingData);
expect(res.observationId).toBeDefined();
const obsList = await kv.list("mem:obs:test-session");
expect(obsList.length).toBe(1);
const raw = obsList[0] as RawObservation;
expect(raw.modality).toBe("mixed");
expect(raw.imageData).toBeDefined();
expect(typeof raw.imageData).toBe("string");
expect(existsSync(raw.imageData!)).toBe(true);
savedImagePath = raw.imageData;
const deltaCalls = mockTrigger.mock.calls.filter(
(c: any[]) => c[0]?.function_id === "mem::disk-size-delta"
);
expect(deltaCalls.length).toBe(1);
expect(deltaCalls[0][0].payload.deltaBytes).toBeGreaterThan(0);
});
it("Step 2 & 3: mem::compress should call the vision model and store compressed observation in KV", async () => {
const mockProvider: MemoryProvider = {
name: "mock-vision",
compress: async (_systemPrompt, userPrompt) => {
expect(userPrompt).toContain("TEST_VISION_RESULT: I see a red dot");
return VALID_COMPRESS_XML;
},
summarize: async () => "",
describeImage: async (_base64, _mimeType, _prompt) => {
return "TEST_VISION_RESULT: I see a red dot";
},
};
let compressCallback: any = null;
const sdkMocker = {
...mockSdk,
registerFunction: vi.fn((id, cb) => {
if (id === "mem::compress") compressCallback = cb;
}),
};
registerCompressFunction(sdkMocker, kv, mockProvider);
expect(compressCallback).not.toBeNull();
const rawObsList = await kv.list("mem:obs:test-session");
const raw = rawObsList[0] as RawObservation;
expect(raw.modality).toBeDefined();
expect(raw.imageData).toBe(savedImagePath);
const result = await compressCallback({
observationId: raw.id,
sessionId: raw.sessionId,
raw,
});
expect(result.success).toBe(true);
expect(result.compressed).toBeDefined();
const compressed = result.compressed as CompressedObservation;
expect(compressed.imageDescription).toBe("TEST_VISION_RESULT: I see a red dot");
expect(compressed.imageRef).toBe(savedImagePath);
expect(compressed.modality).toBe("mixed");
expect(compressed.title).toBe("Screenshot of Red Dot");
expect(compressed.narrative).toContain("red dot");
const stored = await kv.get("mem:obs:test-session", raw.id!) as CompressedObservation | null;
expect(stored).not.toBeNull();
expect(stored!.imageDescription).toBe("TEST_VISION_RESULT: I see a red dot");
expect(stored!.imageRef).toBe(savedImagePath);
});
});
describe("Disk Size Manager", () => {
it("should increment disk size and trigger cleanup when over quota", async () => {
const localKv = mockKV() as any;
const localTrigger = vi.fn().mockResolvedValue(undefined);
const localSdk = { trigger: localTrigger } as any;
let managerCallback: any = null;
const sdkMocker = {
...localSdk,
registerFunction: vi.fn((id: string, cb: any) => {
if (id === "mem::disk-size-delta") managerCallback = cb;
}),
};
const { registerDiskSizeManager } = await import("../src/functions/disk-size-manager.js");
registerDiskSizeManager(sdkMocker, localKv);
expect(managerCallback).not.toBeNull();
const res1 = await managerCallback({ deltaBytes: 1000 });
expect(res1.success).toBe(true);
expect(res1.currentTotal).toBe(1000);
const res2 = await managerCallback({ deltaBytes: 2000 });
expect(res2.success).toBe(true);
expect(res2.currentTotal).toBe(3000);
expect(localTrigger).not.toHaveBeenCalled();
});
it("should trigger cleanup when total exceeds max bytes", async () => {
const originalEnv = process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES;
try {
process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES = "5000";
const localKv = mockKV() as any;
const localTrigger = vi.fn().mockResolvedValue(undefined);
const localSdk = { trigger: localTrigger } as any;
let managerCallback: any = null;
const sdkMocker = {
...localSdk,
registerFunction: vi.fn((id: string, cb: any) => {
if (id === "mem::disk-size-delta") managerCallback = cb;
}),
};
const { registerDiskSizeManager } = await import("../src/functions/disk-size-manager.js");
registerDiskSizeManager(sdkMocker, localKv);
await managerCallback({ deltaBytes: 3000 });
expect(localTrigger).not.toHaveBeenCalled();
await managerCallback({ deltaBytes: 3000 });
expect(localTrigger).toHaveBeenCalledWith(
expect.objectContaining({
function_id: "mem::image-quota-cleanup",
payload: {},
}),
);
} finally {
if (originalEnv === undefined) {
delete process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES;
} else {
process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES = originalEnv;
}
}
});
it("should clamp negative totals to zero", async () => {
const localKv = mockKV() as any;
const localSdk = { trigger: vi.fn().mockResolvedValue(undefined) } as any;
let managerCallback: any = null;
const sdkMocker = {
...localSdk,
registerFunction: vi.fn((id: string, cb: any) => {
if (id === "mem::disk-size-delta") managerCallback = cb;
}),
};
const { registerDiskSizeManager } = await import("../src/functions/disk-size-manager.js");
registerDiskSizeManager(sdkMocker, localKv);
const res = await managerCallback({ deltaBytes: -99999 });
expect(res.currentTotal).toBe(0);
});
it("should reject invalid deltaBytes", async () => {
const localKv = mockKV() as any;
const localSdk = { trigger: vi.fn().mockResolvedValue(undefined) } as any;
let managerCallback: any = null;
const sdkMocker = {
...localSdk,
registerFunction: vi.fn((id: string, cb: any) => {
if (id === "mem::disk-size-delta") managerCallback = cb;
}),
};
const { registerDiskSizeManager } = await import("../src/functions/disk-size-manager.js");
registerDiskSizeManager(sdkMocker, localKv);
const res = await managerCallback({ deltaBytes: "not-a-number" });
expect(res.success).toBe(false);
});
});
describe("Image Refs", () => {
it("should increment and decrement ref counts correctly with deletion parity", async () => {
const localKv = mockKV() as any;
const localTrigger = vi.fn().mockResolvedValue(undefined);
const localSdk = { trigger: localTrigger } as any;
const { saveImageToDisk } = await import("../src/utils/image-store.js");
const { incrementImageRef, decrementImageRef, getImageRefCount } = await import("../src/functions/image-refs.js");
const result = await saveImageToDisk(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg=="
);
const testFile = result.filePath;
expect(existsSync(testFile)).toBe(true);
// Initial state
expect(await getImageRefCount(localKv, testFile)).toBe(0);
// Increment to 1
await incrementImageRef(localKv, testFile);
expect(await getImageRefCount(localKv, testFile)).toBe(1);
// Increment to 2 (shared image)
await incrementImageRef(localKv, testFile);
expect(await getImageRefCount(localKv, testFile)).toBe(2);
// Decrement from 2 to 1
await decrementImageRef(localKv, localSdk, testFile);
expect(await getImageRefCount(localKv, testFile)).toBe(1);
// (c) shared image with refcount >= 2 is NOT deleted when one decrements
expect(existsSync(testFile)).toBe(true);
expect(localTrigger).not.toHaveBeenCalled();
// (a) decrementing to zero triggers image file deletion and negative delta
await decrementImageRef(localKv, localSdk, testFile);
expect(await getImageRefCount(localKv, testFile)).toBe(0);
expect(existsSync(testFile)).toBe(false);
const deltaCalls = localTrigger.mock.calls.filter(
(c: any[]) => c[0]?.function_id === "mem::disk-size-delta"
);
expect(deltaCalls.length).toBe(1);
expect(deltaCalls[0][0].payload.deltaBytes).toBeLessThan(0);
// (b) decrementing an already-zero/unknown ref is a no-op
localTrigger.mockClear();
await decrementImageRef(localKv, localSdk, "/fake/unknown/path.png");
expect(await getImageRefCount(localKv, "/fake/unknown/path.png")).toBe(0);
const noOpDeltaCalls = localTrigger.mock.calls.filter(
(c: any[]) => c[0]?.function_id === "mem::disk-size-delta"
);
expect(noOpDeltaCalls.length).toBe(0);
});
});
describe("Image Store", () => {
let testFilePath: string | undefined;
afterAll(() => {
if (testFilePath && existsSync(testFilePath)) {
rmSync(testFilePath);
}
});
it("saveImageToDisk should return filePath and bytesWritten", async () => {
const { saveImageToDisk } = await import("../src/utils/image-store.js");
const result = await saveImageToDisk(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg=="
);
expect(result.filePath).toBeDefined();
expect(typeof result.filePath).toBe("string");
expect(result.filePath.length).toBeGreaterThan(0);
expect(result.bytesWritten).toBeGreaterThan(0);
expect(existsSync(result.filePath)).toBe(true);
testFilePath = result.filePath;
});
it("deleteImage should return deletedBytes after successful deletion", async () => {
const { deleteImage } = await import("../src/utils/image-store.js");
expect(testFilePath).toBeDefined();
expect(existsSync(testFilePath!)).toBe(true);
const result = await deleteImage(testFilePath!);
expect(result.deletedBytes).toBeGreaterThan(0);
expect(existsSync(testFilePath!)).toBe(false);
testFilePath = undefined;
});
it("deleteImage should return 0 for non-existent files", async () => {
const { deleteImage } = await import("../src/utils/image-store.js");
const result = await deleteImage("/non/existent/file.png");
expect(result.deletedBytes).toBe(0);
});
it("saveImageToDisk should return empty for empty input", async () => {
const { saveImageToDisk } = await import("../src/utils/image-store.js");
const result = await saveImageToDisk("");
expect(result.filePath).toBe("");
expect(result.bytesWritten).toBe(0);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
store,
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
update: async (scope: string, key: string, updates: Array<{ path: string; value: unknown }>) => {
const m = store.get(scope);
if (!m) return;
const v = (m.get(key) as Record<string, unknown>) ?? {};
for (const u of updates) v[u.path] = u.value;
m.set(key, v);
},
delete: async (scope: string, key: string) => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const m = store.get(scope);
return m ? (Array.from(m.values()) as T[]) : [];
},
};
}
function mockSdk() {
const fns = new Map<string, Function>();
return {
fns,
registerFunction: (
idOrOpts: string | { id: string },
fn: Function,
) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
fns.set(id, fn);
},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown; action?: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = fns.get(id);
if (fn) return fn(payload);
return null;
},
};
}
describe("observe implicit session create (#638)", () => {
beforeEach(() => {
vi.resetModules();
});
it("creates the session on first observe when project+cwd present and session record missing", async () => {
const { registerObserveFunction } = await import("../src/functions/observe.js");
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
const result = (await sdk.trigger("mem::observe", {
sessionId: "ses_opencode_abc",
project: "/home/user/myrepo",
cwd: "/home/user/myrepo",
hookType: "prompt_submit",
timestamp: new Date().toISOString(),
data: { prompt: "ship the helm chart" },
})) as { observationId: string };
expect(result.observationId).toBeTruthy();
const sessionScope = kv.store.get("mem:sessions");
expect(sessionScope).toBeTruthy();
const session = sessionScope!.get("ses_opencode_abc") as Record<string, unknown>;
expect(session).toBeTruthy();
expect(session.id).toBe("ses_opencode_abc");
expect(session.project).toBe("/home/user/myrepo");
expect(session.cwd).toBe("/home/user/myrepo");
expect(session.status).toBe("active");
expect(session.observationCount).toBe(1);
expect(session.firstPrompt).toBe("ship the helm chart");
});
it("does not implicit-create when project+cwd missing (test-payload back-compat)", async () => {
const { registerObserveFunction } = await import("../src/functions/observe.js");
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
await sdk.trigger("mem::observe", {
sessionId: "ses_no_project",
hookType: "post_tool_use",
timestamp: new Date().toISOString(),
data: { tool_name: "Read", tool_input: { file_path: "x.ts" } },
});
const sessionScope = kv.store.get("mem:sessions");
// Either no scope at all, or no entry for this session
expect(sessionScope?.get("ses_no_project")).toBeUndefined();
});
it("does not overwrite an existing session when one already exists", async () => {
const { registerObserveFunction } = await import("../src/functions/observe.js");
const sdk = mockSdk();
const kv = mockKV();
registerObserveFunction(sdk as never, kv as never);
await kv.set("mem:sessions", "ses_existing", {
id: "ses_existing",
project: "/orig/project",
cwd: "/orig/cwd",
startedAt: "2026-01-01T00:00:00Z",
status: "active",
observationCount: 7,
firstPrompt: "original first prompt",
});
await sdk.trigger("mem::observe", {
sessionId: "ses_existing",
project: "/different/project",
cwd: "/different/cwd",
hookType: "post_tool_use",
timestamp: new Date().toISOString(),
data: { tool_name: "Read" },
});
const session = kv.store.get("mem:sessions")!.get("ses_existing") as Record<string, unknown>;
// Original project + firstPrompt preserved
expect(session.project).toBe("/orig/project");
expect(session.firstPrompt).toBe("original first prompt");
// Counter bumped, updatedAt refreshed
expect(session.observationCount).toBe(8);
expect(session.updatedAt).toBeTruthy();
});
});
+433
View File
@@ -0,0 +1,433 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const writtenFiles = new Map<string, string>();
const createdDirs = new Set<string>();
vi.mock("node:fs/promises", () => ({
mkdir: vi.fn(async (dir: string) => {
createdDirs.add(dir);
}),
writeFile: vi.fn(async (path: string, content: string) => {
writtenFiles.set(path, content);
}),
}));
import { registerObsidianExportFunction } from "../src/functions/obsidian-export.js";
import type { Memory, Lesson, Crystal, Session } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeMemory(id: string): Memory {
return {
id,
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
type: "pattern",
title: `Memory ${id}`,
content: `Content for ${id}`,
concepts: ["testing"],
files: ["src/test.ts"],
sessionIds: ["ses_1"],
strength: 7,
version: 1,
isLatest: true,
};
}
function makeLesson(id: string): Lesson {
return {
id,
content: `Lesson ${id}`,
context: "Test context",
confidence: 0.8,
reinforcements: 2,
source: "manual",
sourceIds: [],
project: "/test",
tags: ["testing"],
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
decayRate: 0.05,
};
}
function makeCrystal(id: string): Crystal {
return {
id,
narrative: `Crystal narrative ${id}`,
keyOutcomes: ["Outcome 1"],
filesAffected: ["src/test.ts"],
lessons: ["Learned something"],
sourceActionIds: ["act_1"],
sessionId: "ses_1",
project: "/test",
createdAt: "2026-04-01T00:00:00Z",
};
}
function makeSession(id: string): Session {
return {
id,
project: "/test",
cwd: "/Users/test/project",
startedAt: "2026-04-01T00:00:00Z",
endedAt: "2026-04-01T01:00:00Z",
status: "completed",
observationCount: 15,
};
}
describe("Obsidian Export", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
const exportRoot = "/tmp/agentmemory-export-root";
beforeEach(() => {
process.env.AGENTMEMORY_EXPORT_ROOT = exportRoot;
sdk = mockSdk();
kv = mockKV();
writtenFiles.clear();
createdDirs.clear();
registerObsidianExportFunction(sdk as never, kv as never);
});
it("creates directories and MOC.md with empty data", async () => {
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
vaultDir: string;
};
expect(result.success).toBe(true);
expect(result.exported.memories).toBe(0);
expect(result.exported.lessons).toBe(0);
expect(result.exported.crystals).toBe(0);
expect(result.exported.sessions).toBe(0);
expect(createdDirs.size).toBe(4);
const mocPath = [...writtenFiles.keys()].find((k) => k.endsWith("MOC.md"));
expect(mocPath).toBeDefined();
const moc = writtenFiles.get(mocPath!);
expect(moc).toContain("# agentmemory vault");
expect(moc).toContain("## Memories (0)");
});
it("exports memories with YAML frontmatter", async () => {
const mem = makeMemory("mem_001");
await kv.set("mem:memories", mem.id, mem);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
exported: Record<string, number>;
};
expect(result.exported.memories).toBe(1);
const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_001.md"),
);
expect(memFile).toBeDefined();
const content = memFile![1];
expect(content).toContain("---");
expect(content).toContain('id: "mem_001"');
expect(content).toContain('type: "pattern"');
expect(content).toContain("# Memory mem_001");
expect(content).toContain("#testing");
});
it("exports lessons with confidence and source", async () => {
const lesson = makeLesson("lsn_001");
await kv.set("mem:lessons", lesson.id, lesson);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
exported: Record<string, number>;
};
expect(result.exported.lessons).toBe(1);
const lsnFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("lessons/lsn_001.md"),
);
expect(lsnFile).toBeDefined();
const content = lsnFile![1];
expect(content).toContain("confidence: 0.8");
expect(content).toContain("reinforcements: 2");
expect(content).toContain('source: "manual"');
});
it("exports crystals with wikilinks to source actions", async () => {
const crystal = makeCrystal("crys_001");
await kv.set("mem:crystals", crystal.id, crystal);
await sdk.trigger("mem::obsidian-export", {});
const crysFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("crystals/crys_001.md"),
);
expect(crysFile).toBeDefined();
expect(crysFile![1]).toContain("[[act_1]]");
expect(crysFile![1]).toContain("Outcome 1");
});
it("respects types filter", async () => {
await kv.set("mem:memories", "mem_001", makeMemory("mem_001"));
await kv.set("mem:lessons", "lsn_001", makeLesson("lsn_001"));
const result = (await sdk.trigger("mem::obsidian-export", {
types: ["lessons"],
})) as { exported: Record<string, number> };
expect(result.exported.lessons).toBe(1);
expect(result.exported.memories).toBe(0);
});
it("respects custom vaultDir", async () => {
await sdk.trigger("mem::obsidian-export", {
vaultDir: "/tmp/agentmemory-export-root/test-vault",
});
const hasCustomPath = [...createdDirs].some((d) =>
d.startsWith("/tmp/agentmemory-export-root/test-vault"),
);
expect(hasCustomPath).toBe(true);
});
it("rejects vaultDir outside the export root", async () => {
const result = (await sdk.trigger("mem::obsidian-export", {
vaultDir: "/tmp/outside-root",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain(exportRoot);
});
it("skips deleted lessons", async () => {
const lesson = makeLesson("lsn_deleted");
(lesson as any).deleted = true;
await kv.set("mem:lessons", lesson.id, lesson);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
exported: Record<string, number>;
};
expect(result.exported.lessons).toBe(0);
});
it("skips non-latest memories", async () => {
const mem = makeMemory("mem_old");
mem.isLatest = false;
await kv.set("mem:memories", mem.id, mem);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
exported: Record<string, number>;
};
expect(result.exported.memories).toBe(0);
});
it("MOC groups entries by type", async () => {
await kv.set("mem:memories", "mem_001", makeMemory("mem_001"));
await kv.set("mem:lessons", "lsn_001", makeLesson("lsn_001"));
await kv.set("mem:crystals", "crys_001", makeCrystal("crys_001"));
await kv.set("mem:sessions", "ses_001", makeSession("ses_001"));
await sdk.trigger("mem::obsidian-export", {});
const mocPath = [...writtenFiles.keys()].find((k) => k.endsWith("MOC.md"));
const moc = writtenFiles.get(mocPath!)!;
expect(moc).toContain("## Memories (1)");
expect(moc).toContain("## Lessons (1)");
expect(moc).toContain("## Crystals (1)");
expect(moc).toContain("## Sessions (1)");
expect(moc).toContain("[[memories/mem_001|");
expect(moc).toContain("[[lessons/lsn_001|");
expect(moc).toContain("[[crystals/crys_001|");
expect(moc).toContain("[[sessions/ses_001|");
});
it("returns undefined errors on success", async () => {
await kv.set("mem:memories", "mem_001", makeMemory("mem_001"));
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
errors?: unknown[];
};
expect(result.success).toBe(true);
expect(result.errors).toBeUndefined();
});
// #729: any record missing an id used to crash `sanitize(undefined.id)`
// outside the per-record try, escaping the handler entirely and
// returning HTTP 500 `{"error":"[object Object]"}` with zero files
// written. The hardened loops filter id-less records and the outer
// try/catch keeps thrown errors from ever reaching the HTTP serializer.
it("skips records that are missing an id and keeps exporting the rest", async () => {
await kv.set("mem:memories", "orphan-memory", { ...makeMemory("mem_missing"), id: undefined } as any);
await kv.set("mem:lessons", "orphan-lesson", { ...makeLesson("lsn_missing"), id: undefined } as any);
await kv.set("mem:crystals", "orphan-crystal", { ...makeCrystal("crys_missing"), id: undefined } as any);
await kv.set("mem:sessions", "orphan-session", { ...makeSession("ses_missing"), id: undefined } as any);
await kv.set("mem:sessions", "valid-session", makeSession("ses_valid"));
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
errors?: unknown[];
};
expect(result.success).toBe(true);
expect(result.exported.memories).toBe(0);
expect(result.exported.lessons).toBe(0);
expect(result.exported.crystals).toBe(0);
expect(result.exported.sessions).toBe(1);
expect(result.errors).toBeUndefined();
expect([...writtenFiles.keys()].some((path) => path.includes("undefined.md"))).toBe(false);
expect([...writtenFiles.keys()].some((path) => path.includes("sessions/ses_valid.md"))).toBe(true);
});
it("tolerates malformed startedAt timestamps when sorting sessions", async () => {
await kv.set("mem:sessions", "ses_recent", { ...makeSession("ses_recent"), startedAt: "2026-04-02T00:00:00Z" });
await kv.set("mem:sessions", "ses_bad", { ...makeSession("ses_bad"), startedAt: "not-a-date" } as any);
await kv.set("mem:sessions", "ses_undef", { ...makeSession("ses_undef"), startedAt: undefined } as any);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
};
expect(result.success).toBe(true);
expect(result.exported.sessions).toBe(3);
});
it("exports memories whose optional array fields are missing or null", async () => {
const incomplete = {
...makeMemory("mem_incomplete"),
concepts: undefined,
files: null,
relatedIds: null,
supersedes: undefined,
} as any;
await kv.set("mem:memories", incomplete.id, incomplete);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
};
expect(result.success).toBe(true);
expect(result.exported.memories).toBe(1);
const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_incomplete.md"),
);
expect(memFile).toBeDefined();
const content = memFile![1];
expect(content).toContain("# Memory mem_incomplete");
expect(content).not.toContain("## Related");
expect(content).not.toContain("## Supersedes");
});
it("falls back to the id when title / content / narrative are missing", async () => {
await kv.set("mem:memories", "mem_no_title", {
...makeMemory("mem_no_title"),
title: undefined,
content: undefined,
} as any);
await kv.set("mem:lessons", "lsn_no_content", {
...makeLesson("lsn_no_content"),
content: undefined,
} as any);
await kv.set("mem:crystals", "crys_no_narr", {
...makeCrystal("crys_no_narr"),
narrative: undefined,
} as any);
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
exported: Record<string, number>;
};
expect(result.success).toBe(true);
expect(result.exported.memories).toBe(1);
expect(result.exported.lessons).toBe(1);
expect(result.exported.crystals).toBe(1);
const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_no_title.md"),
);
expect(memFile![1]).toContain("# mem_no_title");
const lsnFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("lessons/lsn_no_content.md"),
);
expect(lsnFile![1]).toContain("# Lesson: lsn_no_content");
const crysFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("crystals/crys_no_narr.md"),
);
expect(crysFile![1]).toContain("# Crystal: crys_no_narr");
});
it("never throws out to the engine — returns {success: false, error: <string>} on internal failure", async () => {
// Force mkdir to throw to simulate an unexpected runtime error so we
// can assert the outer try/catch turns it into a serializable error.
const fsModule = await import("node:fs/promises");
const original = fsModule.mkdir;
(fsModule.mkdir as any) = vi.fn(async () => {
throw new TypeError("simulated disk failure");
});
try {
const result = (await sdk.trigger("mem::obsidian-export", {})) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(typeof result.error).toBe("string");
expect(result.error).toContain("simulated disk failure");
} finally {
(fsModule.mkdir as any) = original;
}
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { buildAgentOptions, getInitialAgentValues } from "../src/cli/onboarding.js";
describe("first-run onboarding", () => {
it("offers GitHub Copilot CLI as a native setup target", () => {
const options = buildAgentOptions();
expect(options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "copilot-cli",
label: expect.stringContaining("GitHub Copilot CLI"),
hint: "native plugin",
}),
]),
);
});
it("selects GitHub Copilot CLI by default when running inside Copilot CLI", () => {
expect(getInitialAgentValues({ COPILOT_CLI: "1" })).toEqual(["copilot-cli"]);
expect(getInitialAgentValues({ COPILOT_AGENT_SESSION_ID: "session" })).toEqual(["copilot-cli"]);
});
it("keeps Claude Code as the default outside known agent environments", () => {
expect(getInitialAgentValues({})).toEqual(["claude-code"]);
});
});
+304
View File
@@ -0,0 +1,304 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
buildAuthHeaders,
buildChatUrl,
buildEmbeddingUrl,
detectAzure,
normalizeBaseUrl,
} from "../src/providers/_openai-shared.js";
import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js";
describe("_openai-shared — detectAzure", () => {
it("detects standard Azure resource hostname", () => {
expect(
detectAzure(
"https://myresource.openai.azure.com/openai/deployments/mydeploy",
),
).toBe(true);
});
it("does not flag api.openai.com", () => {
expect(detectAzure("https://api.openai.com")).toBe(false);
});
it("does not flag DeepSeek / SiliconFlow / Ollama / vLLM", () => {
expect(detectAzure("https://api.deepseek.com/v1")).toBe(false);
expect(detectAzure("https://api.siliconflow.cn")).toBe(false);
expect(detectAzure("http://localhost:11434/v1")).toBe(false);
expect(detectAzure("http://localhost:8000/v1")).toBe(false);
});
it("returns false for malformed URLs", () => {
expect(detectAzure("not-a-url")).toBe(false);
expect(detectAzure("")).toBe(false);
});
});
describe("_openai-shared — buildChatUrl", () => {
it("appends /v1/chat/completions for standard OpenAI", () => {
expect(buildChatUrl("https://api.openai.com", false, "2024-08-01-preview")).toBe(
"https://api.openai.com/v1/chat/completions",
);
});
it("appends /chat/completions + api-version for Azure", () => {
const url = buildChatUrl(
"https://myresource.openai.azure.com/openai/deployments/mydeploy",
true,
"2024-08-01-preview",
);
expect(url).toBe(
"https://myresource.openai.azure.com/openai/deployments/mydeploy/chat/completions?api-version=2024-08-01-preview",
);
});
it("URL-encodes the api-version", () => {
const url = buildChatUrl(
"https://r.openai.azure.com/openai/deployments/d",
true,
"preview/with/slashes",
);
expect(url).toContain("api-version=preview%2Fwith%2Fslashes");
});
it("preserves pre-existing query params on the base URL (CodeRabbit catch)", () => {
// A corporate proxy or diagnostics endpoint might already carry
// query parameters on the base URL. String-concat would have
// interpolated the route path into the query string; URL-API
// composition keeps the query intact and adds api-version
// alongside.
const url = buildChatUrl(
"https://proxy.example.com/openai/deployments/d?tenant=acme",
true,
"2024-08-01-preview",
);
const parsed = new URL(url);
expect(parsed.pathname).toBe("/openai/deployments/d/chat/completions");
expect(parsed.searchParams.get("tenant")).toBe("acme");
expect(parsed.searchParams.get("api-version")).toBe("2024-08-01-preview");
});
it("strips trailing slashes from base path before joining route", () => {
const url = buildChatUrl(
"https://r.openai.azure.com/openai/deployments/d/",
true,
"2024-08-01-preview",
);
expect(new URL(url).pathname).toBe("/openai/deployments/d/chat/completions");
});
it("routes through /openai/v1 when the base URL has no /deployments/ segment (Azure v1 GA)", () => {
// Azure shipped a v1 URL pattern that mirrors the OpenAI shape:
// /openai/v1/chat/completions, deployment passed in the body as
// `model`. No api-version query param.
const url = buildChatUrl(
"https://r.openai.azure.com",
true,
"2024-08-01-preview", // ignored on v1
);
const parsed = new URL(url);
expect(parsed.pathname).toBe("/openai/v1/chat/completions");
expect(parsed.searchParams.get("api-version")).toBeNull();
});
it("strips a trailing /openai or /openai/v1 prefix when composing v1 URLs", () => {
// Users may pre-configure OPENAI_BASE_URL with the /openai/v1
// suffix already present. We should not double it.
const fromOpenai = buildChatUrl(
"https://r.openai.azure.com/openai",
true,
"ignored",
);
expect(new URL(fromOpenai).pathname).toBe("/openai/v1/chat/completions");
const fromV1 = buildChatUrl(
"https://r.openai.azure.com/openai/v1",
true,
"ignored",
);
expect(new URL(fromV1).pathname).toBe("/openai/v1/chat/completions");
});
});
describe("_openai-shared — buildEmbeddingUrl", () => {
it("appends /v1/embeddings for standard OpenAI", () => {
expect(
buildEmbeddingUrl("https://api.openai.com", false, "2024-08-01-preview"),
).toBe("https://api.openai.com/v1/embeddings");
});
it("appends /embeddings + api-version for Azure legacy (no /v1/ prefix)", () => {
const url = buildEmbeddingUrl(
"https://r.openai.azure.com/openai/deployments/embed-deploy",
true,
"2024-08-01-preview",
);
expect(url).toBe(
"https://r.openai.azure.com/openai/deployments/embed-deploy/embeddings?api-version=2024-08-01-preview",
);
});
it("routes through /openai/v1/embeddings on Azure v1 (no api-version)", () => {
const url = buildEmbeddingUrl(
"https://r.openai.azure.com",
true,
"2024-08-01-preview", // ignored on v1
);
const parsed = new URL(url);
expect(parsed.pathname).toBe("/openai/v1/embeddings");
expect(parsed.searchParams.get("api-version")).toBeNull();
});
});
describe("_openai-shared — non-OpenAI base URLs (#628, #646)", () => {
it("does not double /v1 when base URL already ends with /v1 (DeepSeek shape, #628)", () => {
expect(
buildChatUrl("https://api.deepseek.com/v1", false, "2024-08-01-preview"),
).toBe("https://api.deepseek.com/v1/chat/completions");
expect(
buildEmbeddingUrl("https://api.deepseek.com/v1", false, "2024-08-01-preview"),
).toBe("https://api.deepseek.com/v1/embeddings");
});
it("does not inject /v1 when provider uses non-OpenAI version segment (Zhipu /api/paas/v4, #646)", () => {
expect(
buildChatUrl(
"https://open.bigmodel.cn/api/paas/v4",
false,
"2024-08-01-preview",
),
).toBe("https://open.bigmodel.cn/api/paas/v4/chat/completions");
expect(
buildEmbeddingUrl(
"https://open.bigmodel.cn/api/paas/v4",
false,
"2024-08-01-preview",
),
).toBe("https://open.bigmodel.cn/api/paas/v4/embeddings");
});
it("tolerates trailing slash on already-versioned base", () => {
expect(
buildChatUrl("https://api.deepseek.com/v1/", false, "2024-08-01-preview"),
).toBe("https://api.deepseek.com/v1/chat/completions");
});
it("handles localhost OpenAI-compatible servers with explicit /v1", () => {
expect(
buildChatUrl("http://localhost:11434/v1", false, "2024-08-01-preview"),
).toBe("http://localhost:11434/v1/chat/completions");
expect(
buildChatUrl("http://localhost:8000/v1", false, "2024-08-01-preview"),
).toBe("http://localhost:8000/v1/chat/completions");
});
});
describe("_openai-shared — buildAuthHeaders", () => {
it("emits Authorization: Bearer for standard OpenAI", () => {
expect(buildAuthHeaders("sk-test", false)).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer sk-test",
});
});
it("emits api-key header for Azure", () => {
expect(buildAuthHeaders("azure-key", true)).toEqual({
"Content-Type": "application/json",
"api-key": "azure-key",
});
});
});
describe("_openai-shared — normalizeBaseUrl", () => {
it("returns default when no value passed", () => {
expect(normalizeBaseUrl(undefined)).toBe("https://api.openai.com");
expect(normalizeBaseUrl("")).toBe("https://api.openai.com");
});
it("strips trailing slashes", () => {
expect(normalizeBaseUrl("https://api.deepseek.com/v1///")).toBe(
"https://api.deepseek.com/v1",
);
});
it("returns explicit values unchanged otherwise", () => {
expect(normalizeBaseUrl("https://api.deepseek.com/v1")).toBe(
"https://api.deepseek.com/v1",
);
});
});
// ─────────────────────────────────────────────────────────────
// OpenAIEmbeddingProvider — Azure transport (#371)
// Verifies the embedding path now uses the shared Azure helpers:
// hits /embeddings (not /v1/embeddings), includes api-version, uses
// api-key header instead of Authorization: Bearer.
// ─────────────────────────────────────────────────────────────
describe("OpenAIEmbeddingProvider — Azure auto-detection (#371)", () => {
const ORIGINAL_BASE = process.env["OPENAI_BASE_URL"];
const ORIGINAL_VERSION = process.env["OPENAI_API_VERSION"];
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
if (ORIGINAL_BASE === undefined) delete process.env["OPENAI_BASE_URL"];
else process.env["OPENAI_BASE_URL"] = ORIGINAL_BASE;
if (ORIGINAL_VERSION === undefined) delete process.env["OPENAI_API_VERSION"];
else process.env["OPENAI_API_VERSION"] = ORIGINAL_VERSION;
vi.restoreAllMocks();
});
it("uses Azure shape when OPENAI_BASE_URL points at *.openai.azure.com", async () => {
process.env["OPENAI_BASE_URL"] =
"https://myres.openai.azure.com/openai/deployments/embed-d";
process.env["OPENAI_API_VERSION"] = "2024-08-01-preview";
let capturedUrl = "";
let capturedHeaders = new Headers();
vi.spyOn(globalThis, "fetch").mockImplementation(
async (url: string | URL | Request, init?: RequestInit) => {
capturedUrl = String(url);
capturedHeaders = new Headers(init?.headers);
return new Response(
JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }),
{ status: 200 },
);
},
);
const provider = new OpenAIEmbeddingProvider("azure-key");
await provider.embedBatch(["hello"]);
expect(capturedUrl).toBe(
"https://myres.openai.azure.com/openai/deployments/embed-d/embeddings?api-version=2024-08-01-preview",
);
expect(capturedHeaders.get("api-key")).toBe("azure-key");
expect(capturedHeaders.get("Authorization")).toBeNull();
});
it("uses standard shape when OPENAI_BASE_URL points at api.openai.com", async () => {
process.env["OPENAI_BASE_URL"] = "https://api.openai.com";
let capturedUrl = "";
let capturedHeaders = new Headers();
vi.spyOn(globalThis, "fetch").mockImplementation(
async (url: string | URL | Request, init?: RequestInit) => {
capturedUrl = String(url);
capturedHeaders = new Headers(init?.headers);
return new Response(
JSON.stringify({ data: [{ embedding: [0.4, 0.5, 0.6] }] }),
{ status: 200 },
);
},
);
const provider = new OpenAIEmbeddingProvider("sk-test");
await provider.embedBatch(["hello"]);
expect(capturedUrl).toBe("https://api.openai.com/v1/embeddings");
expect(capturedHeaders.get("Authorization")).toBe("Bearer sk-test");
expect(capturedHeaders.get("api-key")).toBeNull();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
type Capability = {
promptBuilder?: (params: {
availableTools: Set<string>;
}) => string[] | undefined;
};
type RegisterFn = (capability: Capability) => void;
interface FakeApi {
registerMemoryCapability: RegisterFn;
on: ReturnType<typeof vi.fn>;
pluginConfig: Record<string, unknown>;
logger: { warn: ReturnType<typeof vi.fn> };
}
function makeApi(overrides: Partial<FakeApi> = {}): FakeApi {
return {
registerMemoryCapability: vi.fn(),
on: vi.fn(),
pluginConfig: { base_url: "http://localhost:3111" },
logger: { warn: vi.fn() },
...overrides,
};
}
describe("openclaw plugin — memory capability registration (closes #286 follow-up)", () => {
it("calls api.registerMemoryCapability with a promptBuilder when the host supports it", async () => {
const mod = await import("../integrations/openclaw/plugin.mjs");
const plugin = (mod as unknown as { default: { register(api: FakeApi): void } }).default;
const api = makeApi();
plugin.register(api);
expect(api.registerMemoryCapability).toHaveBeenCalledTimes(1);
const capability = (api.registerMemoryCapability as ReturnType<typeof vi.fn>).mock.calls[0][0] as Capability;
expect(typeof capability.promptBuilder).toBe("function");
const lines = capability.promptBuilder?.({ availableTools: new Set() });
expect(Array.isArray(lines)).toBe(true);
expect((lines as string[]).join(" ")).toMatch(/agentmemory/i);
});
it("still registers hooks and tolerates older OpenClaw builds without registerMemoryCapability", async () => {
const mod = await import("../integrations/openclaw/plugin.mjs");
const plugin = (mod as unknown as { default: { register(api: FakeApi): void } }).default;
const api = makeApi({ registerMemoryCapability: undefined as unknown as RegisterFn });
expect(() => plugin.register(api)).not.toThrow();
expect(api.on).toHaveBeenCalled();
const events = (api.on as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]);
expect(events).toContain("before_agent_start");
expect(events).toContain("agent_end");
});
it("promptBuilder returns lines that mention the configured base_url", async () => {
const mod = await import("../integrations/openclaw/plugin.mjs");
const plugin = (mod as unknown as { default: { register(api: FakeApi): void } }).default;
const api = makeApi({ pluginConfig: { base_url: "http://memory.internal:9999" } });
plugin.register(api);
const capability = (api.registerMemoryCapability as ReturnType<typeof vi.fn>).mock.calls[0][0] as Capability;
const lines = capability.promptBuilder?.({ availableTools: new Set() }) ?? [];
expect(lines.join("\n")).toMatch(/memory\.internal:9999/);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
// OpenCode plugin needs zero-config memory injection. Plugin
// already wires experimental.chat.system.transform; this PR threads
// the /session/start context through a cache so injection happens
// without a second /context fetch and is documented as the
// SessionStart-equivalent behaviour.
describe("OpenCode plugin auto-context injection (#431)", () => {
const plugin = readFileSync(
"plugin/opencode/agentmemory-capture.ts",
"utf-8",
);
it("captures context returned by POST /session/start", () => {
expect(plugin).toMatch(/startContextCache\s*=\s*new Map<string,\s*string>/);
expect(plugin).toMatch(
/postJson\(["']\/session\/start["']/,
);
// Snapshot `activeSessionId` into a local before the await so the cached
// context binds to the session that opened it, not a later one.
expect(plugin).toMatch(
/const\s+sessionId\s*=\s*activeSessionId[\s\S]*?startContextCache\.set\(sessionId/,
);
});
it("chat.system.transform reads cached context first, falls back to /context", () => {
expect(plugin).toMatch(/startContextCache\.get\(sid\)/);
expect(plugin).toMatch(/postJson\(["']\/context["']/);
expect(plugin).toMatch(/startContextCache\.delete\(sid\)/);
});
it("session.deleted clears the cache to avoid stale entries", () => {
const deletedBlock = plugin.slice(plugin.indexOf("session.deleted"));
expect(deletedBlock).toMatch(/startContextCache\.delete\(sid\)/);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ORIGINAL_HOME = process.env["HOME"];
const ORIGINAL_USERPROFILE = process.env["USERPROFILE"];
let sandboxHome: string;
async function freshPrefs() {
vi.resetModules();
return await import("../src/cli/preferences.js");
}
describe("cli preferences", () => {
beforeEach(() => {
sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-prefs-"));
process.env["HOME"] = sandboxHome;
process.env["USERPROFILE"] = sandboxHome;
});
afterEach(() => {
if (ORIGINAL_HOME === undefined) delete process.env["HOME"];
else process.env["HOME"] = ORIGINAL_HOME;
if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"];
else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE;
rmSync(sandboxHome, { recursive: true, force: true });
});
it("returns defaults when no preferences file exists", async () => {
const { readPrefs } = await freshPrefs();
const p = readPrefs();
expect(p.schemaVersion).toBe(1);
expect(p.lastAgent).toBeNull();
expect(p.lastAgents).toEqual([]);
expect(p.lastProvider).toBeNull();
expect(p.skipSplash).toBe(false);
expect(p.skipNpxHint).toBe(false);
expect(p.firstRunAt).toBeNull();
});
it("isFirstRun is true when no preferences file exists", async () => {
const { isFirstRun } = await freshPrefs();
expect(isFirstRun()).toBe(true);
});
it("writePrefs persists values and merges with existing keys", async () => {
const { writePrefs, readPrefs, prefsPath } = await freshPrefs();
writePrefs({ lastAgent: "claude-code", lastAgents: ["claude-code", "cursor"] });
let p = readPrefs();
expect(p.lastAgent).toBe("claude-code");
expect(p.lastAgents).toEqual(["claude-code", "cursor"]);
expect(p.lastProvider).toBeNull();
writePrefs({ lastProvider: "anthropic", skipSplash: true });
p = readPrefs();
expect(p.lastAgent).toBe("claude-code");
expect(p.lastProvider).toBe("anthropic");
expect(p.skipSplash).toBe(true);
const raw = JSON.parse(readFileSync(prefsPath(), "utf-8"));
expect(raw.schemaVersion).toBe(1);
expect(raw.lastAgents).toEqual(["claude-code", "cursor"]);
});
it("isFirstRun flips to false after firstRunAt is recorded", async () => {
const { writePrefs, isFirstRun } = await freshPrefs();
writePrefs({ firstRunAt: new Date().toISOString() });
expect(isFirstRun()).toBe(false);
});
it("readPrefs falls back to defaults when the file is corrupt", async () => {
const { readPrefs, prefsDir, prefsPath } = await freshPrefs();
mkdirSync(prefsDir(), { recursive: true });
writeFileSync(prefsPath(), "{not json", "utf-8");
const p = readPrefs();
expect(p.lastAgent).toBeNull();
expect(p.schemaVersion).toBe(1);
});
it("readPrefs forces schemaVersion to 1 even when the file lies", async () => {
const { readPrefs, prefsDir, prefsPath } = await freshPrefs();
mkdirSync(prefsDir(), { recursive: true });
writeFileSync(
prefsPath(),
JSON.stringify({ schemaVersion: 99, lastAgent: "cursor" }),
"utf-8",
);
const p = readPrefs();
expect(p.schemaVersion).toBe(1);
expect(p.lastAgent).toBe("cursor");
});
it("resetPrefs removes the file", async () => {
const { writePrefs, resetPrefs, isFirstRun, prefsPath } = await freshPrefs();
writePrefs({ firstRunAt: new Date().toISOString() });
expect(isFirstRun()).toBe(false);
resetPrefs();
expect(() => readFileSync(prefsPath())).toThrow();
expect(isFirstRun()).toBe(true);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { stripPrivateData } from "../src/functions/privacy.js";
describe("stripPrivateData", () => {
it("strips private tags", () => {
expect(stripPrivateData("hello <private>secret</private> world")).toBe(
"hello [REDACTED] world",
);
});
it("strips private tags case-insensitive", () => {
expect(stripPrivateData("<Private>data</Private>")).toBe("[REDACTED]");
});
it("strips API keys", () => {
expect(stripPrivateData("api_key=sk-ant-1234567890abcdefghij")).toBe(
"[REDACTED_SECRET]",
);
});
it("strips GitHub PATs", () => {
expect(
stripPrivateData("token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh"),
).toBe("[REDACTED_SECRET]");
});
it("strips standalone GitHub PATs", () => {
expect(
stripPrivateData("found ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij here"),
).toBe("found [REDACTED_SECRET] here");
});
it("strips Slack tokens", () => {
expect(stripPrivateData("xoxb-123456-789012-abcdef")).toBe(
"[REDACTED_SECRET]",
);
});
it("strips AWS access keys", () => {
expect(stripPrivateData("key=AKIAIOSFODNN7EXAMPLE")).toBe(
"key=[REDACTED_SECRET]",
);
});
it("strips JWT tokens", () => {
expect(
stripPrivateData(
"eyJhbGciOiJIUzI1.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpM",
),
).toBe("[REDACTED_SECRET]");
});
it("strips sk- prefixed keys", () => {
expect(stripPrivateData("sk-1234567890abcdefghijklmnopqr")).toBe(
"[REDACTED_SECRET]",
);
});
it("strips OpenAI project keys", () => {
expect(
stripPrivateData("sk-proj-1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"),
).toBe("[REDACTED_SECRET]");
});
it("strips GitHub fine-grained service tokens", () => {
expect(
stripPrivateData("ghs_1234567890abcdefghijklmnopqrstuvwxyzAB"),
).toBe("[REDACTED_SECRET]");
});
it("strips bearer tokens", () => {
expect(
stripPrivateData(
"Authorization: Bearer sk-proj-1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
),
).toBe("Authorization: [REDACTED_SECRET]");
});
it("handles multiple secrets in one string", () => {
const input =
"sk-abcdefghijklmnopqrstuv and ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
const result = stripPrivateData(input);
expect(result).not.toContain("sk-");
expect(result).not.toContain("ghp_");
});
it("does not strip short strings", () => {
expect(stripPrivateData("api_key=short")).toBe("api_key=short");
});
it("returns empty string unchanged", () => {
expect(stripPrivateData("")).toBe("");
});
it("handles no secrets gracefully", () => {
expect(stripPrivateData("normal text without secrets")).toBe(
"normal text without secrets",
);
});
it("works correctly on consecutive calls (no regex statefulness)", () => {
const input = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabc";
expect(stripPrivateData(input)).toBe("[REDACTED_SECRET]");
expect(stripPrivateData(input)).toBe("[REDACTED_SECRET]");
expect(stripPrivateData(input)).toBe("[REDACTED_SECRET]");
});
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerProfileFunction } from "../src/functions/profile.js";
import type {
CompressedObservation,
Session,
ProjectProfile,
} from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
describe("Profile Function", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(async () => {
sdk = mockSdk();
kv = mockKV();
registerProfileFunction(sdk as never, kv as never);
const session: Session = {
id: "ses_1",
project: "my-project",
cwd: "/tmp/my-project",
startedAt: "2026-02-01T00:00:00Z",
status: "completed",
observationCount: 3,
};
await kv.set("mem:sessions", "ses_1", session);
const obs1: CompressedObservation = {
id: "obs_1",
sessionId: "ses_1",
timestamp: "2026-02-01T10:00:00Z",
type: "file_edit",
title: "Edit auth module",
facts: [],
narrative: "Auth changes",
concepts: ["typescript", "authentication"],
files: ["/project/src/auth.ts", "/project/src/middleware.ts"],
importance: 8,
};
const obs2: CompressedObservation = {
id: "obs_2",
sessionId: "ses_1",
timestamp: "2026-02-01T11:00:00Z",
type: "file_edit",
title: "Update database",
facts: [],
narrative: "DB changes",
concepts: ["typescript", "database"],
files: ["/project/src/db.ts"],
importance: 6,
};
const obs3: CompressedObservation = {
id: "obs_3",
sessionId: "ses_1",
timestamp: "2026-02-01T12:00:00Z",
type: "error",
title: "Connection timeout",
facts: [],
narrative: "Error occurred",
concepts: ["error"],
files: ["/project/src/db.ts"],
importance: 4,
};
await kv.set("mem:obs:ses_1", "obs_1", obs1);
await kv.set("mem:obs:ses_1", "obs_2", obs2);
await kv.set("mem:obs:ses_1", "obs_3", obs3);
});
it("generates profile with topConcepts sorted by frequency", async () => {
const result = (await sdk.trigger("mem::profile", {
project: "my-project",
})) as { profile: ProjectProfile; cached: boolean };
expect(result.cached).toBe(false);
expect(result.profile.topConcepts[0].concept).toBe("typescript");
expect(result.profile.topConcepts[0].frequency).toBe(2);
});
it("generates profile with topFiles sorted by frequency", async () => {
const result = (await sdk.trigger("mem::profile", {
project: "my-project",
})) as { profile: ProjectProfile };
expect(result.profile.topFiles[0].file).toBe("/project/src/db.ts");
expect(result.profile.topFiles[0].frequency).toBe(2);
});
it("extracts conventions from file patterns", async () => {
const result = (await sdk.trigger("mem::profile", {
project: "my-project",
})) as { profile: ProjectProfile };
expect(result.profile.conventions).toContain("TypeScript project");
expect(result.profile.conventions).toContain(
"Standard src/ directory structure",
);
});
it("returns cached profile if fresh", async () => {
await sdk.trigger("mem::profile", { project: "my-project" });
const result = (await sdk.trigger("mem::profile", {
project: "my-project",
})) as { profile: ProjectProfile; cached: boolean };
expect(result.cached).toBe(true);
});
it("returns null profile for unknown project", async () => {
const result = (await sdk.trigger("mem::profile", {
project: "nonexistent",
})) as { profile: null; reason: string };
expect(result.profile).toBeNull();
expect(result.reason).toBe("no_sessions");
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, vi } from "vitest";
import type { MemoryProvider } from "../src/types.js";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, fn: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, fn);
},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (fn) return fn(payload);
return null;
},
};
}
describe("QueryExpansion", () => {
it("imports without errors", async () => {
const mod = await import("../src/functions/query-expansion.js");
expect(mod.registerQueryExpansionFunction).toBeDefined();
expect(mod.extractEntitiesFromQuery).toBeDefined();
});
it("extracts entities from capitalized words", async () => {
const { extractEntitiesFromQuery } = await import(
"../src/functions/query-expansion.js"
);
const entities = extractEntitiesFromQuery(
'What happened with React and the Vue migration?',
);
expect(entities).toContain("React");
expect(entities).toContain("Vue");
expect(entities).not.toContain("What");
});
it("extracts quoted entities", async () => {
const { extractEntitiesFromQuery } = await import(
"../src/functions/query-expansion.js"
);
const entities = extractEntitiesFromQuery(
'Find memories about "auth middleware" changes',
);
expect(entities).toContain("auth middleware");
});
it("expands queries via LLM", async () => {
const { registerQueryExpansionFunction } = await import(
"../src/functions/query-expansion.js"
);
const response = `<expansion>
<reformulations>
<query>Authentication middleware modifications</query>
<query>JWT token validation changes</query>
<query>Security layer updates</query>
</reformulations>
<temporal>
<query>Auth changes in the past 7 days</query>
</temporal>
<entities>
<entity>auth middleware</entity>
<entity>JWT</entity>
</entities>
</expansion>`;
const provider: MemoryProvider = {
name: "test",
compress: vi.fn().mockResolvedValue(response),
summarize: vi.fn().mockResolvedValue(response),
};
const sdk = mockSdk();
registerQueryExpansionFunction(sdk as never, provider);
const result = (await sdk.trigger("mem::expand-query", {
query: "What changed in auth?",
})) as { success: boolean; expansion: any };
expect(result.success).toBe(true);
expect(result.expansion.original).toBe("What changed in auth?");
expect(result.expansion.reformulations.length).toBe(3);
expect(result.expansion.entityExtractions).toContain("auth middleware");
expect(result.expansion.temporalConcretizations.length).toBe(1);
});
it("returns empty expansion on LLM failure", async () => {
const { registerQueryExpansionFunction } = await import(
"../src/functions/query-expansion.js"
);
const provider: MemoryProvider = {
name: "test",
compress: vi.fn().mockRejectedValue(new Error("LLM down")),
summarize: vi.fn().mockRejectedValue(new Error("LLM down")),
};
const sdk = mockSdk();
registerQueryExpansionFunction(sdk as never, provider);
const result = (await sdk.trigger("mem::expand-query", {
query: "test query",
})) as { success: boolean; expansion: any };
expect(result.success).toBe(true);
expect(result.expansion.original).toBe("test query");
expect(result.expansion.reformulations).toEqual([]);
});
it("respects maxReformulations limit", async () => {
const { registerQueryExpansionFunction } = await import(
"../src/functions/query-expansion.js"
);
const response = `<expansion>
<reformulations>
<query>Query A</query>
<query>Query B</query>
<query>Query C</query>
<query>Query D</query>
<query>Query E</query>
<query>Query F</query>
</reformulations>
<temporal></temporal>
<entities></entities>
</expansion>`;
const provider: MemoryProvider = {
name: "test",
compress: vi.fn().mockResolvedValue(response),
summarize: vi.fn().mockResolvedValue(response),
};
const sdk = mockSdk();
registerQueryExpansionFunction(sdk as never, provider);
const result = (await sdk.trigger("mem::expand-query", {
query: "test",
maxReformulations: 3,
})) as { success: boolean; expansion: any };
expect(result.expansion.reformulations.length).toBe(3);
});
});
+352
View File
@@ -0,0 +1,352 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerReflectFunctions } from "../src/functions/reflect.js";
import type { Insight, GraphNode, GraphEdge, SemanticMemory, Lesson, Crystal } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
function makeConceptNode(name: string): GraphNode {
return {
id: `node_${name}`,
type: "concept",
name,
properties: {},
sourceObservationIds: [],
createdAt: "2026-04-01T00:00:00Z",
};
}
function makeEdge(src: string, tgt: string): GraphEdge {
return {
id: `edge_${src}_${tgt}`,
type: "related_to",
sourceNodeId: `node_${src}`,
targetNodeId: `node_${tgt}`,
weight: 1,
sourceObservationIds: [],
createdAt: "2026-04-01T00:00:00Z",
};
}
function makeSemantic(fact: string, id?: string): SemanticMemory {
return {
id: id || `sem_${fact.slice(0, 8)}`,
fact,
confidence: 0.8,
sourceSessionIds: [],
sourceMemoryIds: [],
accessCount: 1,
lastAccessedAt: "2026-04-01T00:00:00Z",
strength: 0.8,
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
};
}
function makeLesson(content: string, tags: string[]): Lesson {
return {
id: `lsn_${content.slice(0, 8)}`,
content,
context: "",
confidence: 0.7,
reinforcements: 0,
source: "manual",
sourceIds: [],
tags,
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
decayRate: 0.05,
};
}
function makeCrystal(narrative: string, lessons: string[]): Crystal {
return {
id: `crys_${narrative.slice(0, 8)}`,
narrative,
keyOutcomes: [],
filesAffected: [],
lessons,
sourceActionIds: [],
createdAt: "2026-04-01T00:00:00Z",
};
}
const XML_RESPONSE = `<insights>
<insight confidence="0.85" title="Defense in Depth">
Security requires layered protection: input validation, safe APIs, and deny-lists together.
</insight>
<insight confidence="0.7" title="Testing at Boundaries">
Focus test effort on system boundaries where trust transitions occur.
</insight>
</insights>`;
describe("Reflect", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
let provider: { name: string; compress: ReturnType<typeof vi.fn>; summarize: ReturnType<typeof vi.fn> };
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
provider = {
name: "test",
compress: vi.fn(),
summarize: vi.fn().mockResolvedValue(XML_RESPONSE),
};
registerReflectFunctions(sdk as never, kv as never, provider as never);
});
describe("mem::reflect", () => {
it("returns empty when no graph nodes or memories exist", async () => {
const result = (await sdk.trigger("mem::reflect", {})) as {
success: boolean;
newInsights: number;
clustersProcessed: number;
};
expect(result.success).toBe(true);
expect(result.newInsights).toBe(0);
expect(result.clustersProcessed).toBe(0);
});
it("synthesizes insights from graph concept clusters", async () => {
await kv.set("mem:graph:nodes", "node_security", makeConceptNode("security"));
await kv.set("mem:graph:nodes", "node_validation", makeConceptNode("validation"));
await kv.set("mem:graph:nodes", "node_testing", makeConceptNode("testing"));
await kv.set("mem:graph:edges", "edge_1", makeEdge("security", "validation"));
await kv.set("mem:graph:edges", "edge_2", makeEdge("security", "testing"));
await kv.set("mem:semantic", "sem_1", makeSemantic("Always validate security inputs"));
await kv.set("mem:semantic", "sem_2", makeSemantic("Testing improves security coverage"));
await kv.set("mem:semantic", "sem_3", makeSemantic("Validation prevents injection attacks"));
await kv.set("mem:lessons", "lsn_1", makeLesson("Use execFile for security", ["security"]));
const result = (await sdk.trigger("mem::reflect", {})) as {
success: boolean;
newInsights: number;
};
expect(result.success).toBe(true);
expect(result.newInsights).toBe(2);
expect(provider.summarize).toHaveBeenCalled();
const insights = await kv.list<Insight>("mem:insights");
expect(insights.length).toBe(2);
expect(insights[0].title).toBeTruthy();
expect(insights[0].sourceConceptCluster.length).toBeGreaterThan(0);
});
it("skips clusters with fewer than 3 supporting items", async () => {
await kv.set("mem:graph:nodes", "node_sparse", makeConceptNode("sparse"));
await kv.set("mem:graph:nodes", "node_topic", makeConceptNode("topic"));
await kv.set("mem:graph:edges", "edge_1", makeEdge("sparse", "topic"));
await kv.set("mem:semantic", "sem_1", makeSemantic("One sparse fact"));
const result = (await sdk.trigger("mem::reflect", {})) as {
clustersSkipped: number;
newInsights: number;
};
expect(result.clustersSkipped).toBe(1);
expect(result.newInsights).toBe(0);
expect(provider.summarize).not.toHaveBeenCalled();
});
it("deduplicates insights by fingerprint", async () => {
await kv.set("mem:graph:nodes", "node_security", makeConceptNode("security"));
await kv.set("mem:graph:nodes", "node_validation", makeConceptNode("validation"));
await kv.set("mem:graph:edges", "edge_1", makeEdge("security", "validation"));
await kv.set("mem:semantic", "sem_1", makeSemantic("Always validate security inputs"));
await kv.set("mem:semantic", "sem_2", makeSemantic("Testing improves security coverage"));
await kv.set("mem:semantic", "sem_3", makeSemantic("Validation prevents injection"));
await sdk.trigger("mem::reflect", {});
const first = await kv.list<Insight>("mem:insights");
expect(first.length).toBe(2);
const result = (await sdk.trigger("mem::reflect", {})) as {
reinforced: number;
newInsights: number;
};
expect(result.reinforced).toBe(2);
expect(result.newInsights).toBe(0);
const after = await kv.list<Insight>("mem:insights");
expect(after.length).toBe(2);
expect(after[0].reinforcements).toBe(1);
});
it("falls back to Jaccard grouping when graph is empty", async () => {
await kv.set("mem:semantic", "sem_1", makeSemantic("security validation is important"));
await kv.set("mem:semantic", "sem_2", makeSemantic("security testing prevents bugs"));
await kv.set("mem:semantic", "sem_3", makeSemantic("validation testing framework"));
await kv.set("mem:lessons", "lsn_1", makeLesson("Use security headers", ["security", "validation"]));
const result = (await sdk.trigger("mem::reflect", {})) as {
success: boolean;
usedFallback: boolean;
};
expect(result.success).toBe(true);
expect(result.usedFallback).toBe(true);
});
it("handles LLM failure gracefully", async () => {
provider.summarize.mockRejectedValue(new Error("LLM timeout"));
await kv.set("mem:graph:nodes", "node_a", makeConceptNode("concept_a"));
await kv.set("mem:graph:nodes", "node_b", makeConceptNode("concept_b"));
await kv.set("mem:graph:edges", "edge_1", makeEdge("concept_a", "concept_b"));
await kv.set("mem:semantic", "sem_1", makeSemantic("fact about concept_a"));
await kv.set("mem:semantic", "sem_2", makeSemantic("fact about concept_b"));
await kv.set("mem:semantic", "sem_3", makeSemantic("concept_a and concept_b together"));
const result = (await sdk.trigger("mem::reflect", {})) as {
success: boolean;
newInsights: number;
};
expect(result.success).toBe(true);
expect(result.newInsights).toBe(0);
});
});
describe("mem::insight-list", () => {
beforeEach(async () => {
const now = new Date().toISOString();
await kv.set("mem:insights", "ins_1", {
id: "ins_1", title: "Insight A", content: "Content A", confidence: 0.9,
reinforcements: 2, sourceConceptCluster: ["security"], sourceMemoryIds: [],
sourceLessonIds: [], sourceCrystalIds: [], project: "/app",
tags: ["security"], createdAt: now, updatedAt: now, decayRate: 0.05,
});
await kv.set("mem:insights", "ins_2", {
id: "ins_2", title: "Insight B", content: "Content B", confidence: 0.4,
reinforcements: 0, sourceConceptCluster: ["testing"], sourceMemoryIds: [],
sourceLessonIds: [], sourceCrystalIds: [], project: "/other",
tags: ["testing"], createdAt: now, updatedAt: now, decayRate: 0.05,
});
});
it("lists all non-deleted insights sorted by confidence", async () => {
const result = (await sdk.trigger("mem::insight-list", {})) as { insights: Insight[] };
expect(result.insights.length).toBe(2);
expect(result.insights[0].confidence).toBe(0.9);
});
it("filters by project", async () => {
const result = (await sdk.trigger("mem::insight-list", { project: "/app" })) as { insights: Insight[] };
expect(result.insights.length).toBe(1);
});
it("filters by minConfidence", async () => {
const result = (await sdk.trigger("mem::insight-list", { minConfidence: 0.5 })) as { insights: Insight[] };
expect(result.insights.length).toBe(1);
});
});
describe("mem::insight-search", () => {
beforeEach(async () => {
const now = new Date().toISOString();
await kv.set("mem:insights", "ins_1", {
id: "ins_1", title: "Defense in Depth", content: "Security requires layered protection",
confidence: 0.85, reinforcements: 1, sourceConceptCluster: ["security"],
sourceMemoryIds: [], sourceLessonIds: [], sourceCrystalIds: [],
tags: ["security"], createdAt: now, updatedAt: now, decayRate: 0.05,
});
});
it("finds insights matching query", async () => {
const result = (await sdk.trigger("mem::insight-search", {
query: "security layered protection",
})) as { insights: Array<Insight & { score: number }> };
expect(result.insights.length).toBe(1);
expect(result.insights[0].title).toBe("Defense in Depth");
});
it("rejects empty query", async () => {
const result = (await sdk.trigger("mem::insight-search", { query: "" })) as { success: boolean };
expect(result.success).toBe(false);
});
});
describe("mem::insight-decay-sweep", () => {
it("decays old insights incrementally", async () => {
await kv.set("mem:insights", "ins_old", {
id: "ins_old", title: "Old", content: "Old insight", confidence: 0.8,
reinforcements: 1, sourceConceptCluster: [], sourceMemoryIds: [],
sourceLessonIds: [], sourceCrystalIds: [], tags: [],
createdAt: new Date(Date.now() - 21 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 21 * 86400000).toISOString(),
decayRate: 0.05,
});
const result = (await sdk.trigger("mem::insight-decay-sweep", {})) as { decayed: number };
expect(result.decayed).toBe(1);
const after = await kv.get<Insight>("mem:insights", "ins_old");
expect(after!.confidence).toBeLessThan(0.8);
expect(after!.lastDecayedAt).toBeDefined();
});
it("soft-deletes low-confidence unreinforced insights", async () => {
await kv.set("mem:insights", "ins_weak", {
id: "ins_weak", title: "Weak", content: "Weak insight", confidence: 0.12,
reinforcements: 0, sourceConceptCluster: [], sourceMemoryIds: [],
sourceLessonIds: [], sourceCrystalIds: [], tags: [],
createdAt: new Date(Date.now() - 21 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 21 * 86400000).toISOString(),
decayRate: 0.05,
});
const result = (await sdk.trigger("mem::insight-decay-sweep", {})) as { softDeleted: number };
expect(result.softDeleted).toBe(1);
const after = await kv.get<Insight>("mem:insights", "ins_weak");
expect(after!.deleted).toBe(true);
});
});
});
+199
View File
@@ -0,0 +1,199 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerRelationsFunction } from "../src/functions/relations.js";
import type { Memory } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
getFunction: (id: string) => functions.get(id),
};
}
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: "mem_1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "pattern",
title: "Test memory",
content: "This is a test memory",
concepts: ["test"],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
...overrides,
};
}
describe("Relations Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerRelationsFunction(sdk as never, kv as never);
});
describe("mem::relate", () => {
it("creates a relation between two memories", async () => {
const mem1 = makeMemory({ id: "mem_1" });
const mem2 = makeMemory({ id: "mem_2" });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
const result = await sdk.trigger("mem::relate", {
sourceId: "mem_1",
targetId: "mem_2",
type: "related",
});
expect((result as { success: boolean }).success).toBe(true);
const updated1 = await kv.get<Memory>("mem:memories", "mem_1");
const updated2 = await kv.get<Memory>("mem:memories", "mem_2");
expect(updated1!.relatedIds).toContain("mem_2");
expect(updated2!.relatedIds).toContain("mem_1");
});
it("returns error when source memory not found", async () => {
const mem2 = makeMemory({ id: "mem_2" });
await kv.set("mem:memories", "mem_2", mem2);
const result = await sdk.trigger("mem::relate", {
sourceId: "mem_missing",
targetId: "mem_2",
type: "related",
});
expect((result as { success: boolean }).success).toBe(false);
});
it("does not duplicate relatedIds on repeated calls", async () => {
const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2"] });
const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1"] });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
await sdk.trigger("mem::relate", {
sourceId: "mem_1",
targetId: "mem_2",
type: "related",
});
const updated1 = await kv.get<Memory>("mem:memories", "mem_1");
expect(updated1!.relatedIds!.filter((id) => id === "mem_2").length).toBe(1);
});
});
describe("mem::evolve", () => {
it("marks old memory as not latest and creates new version", async () => {
const original = makeMemory({ id: "mem_old", version: 1 });
await kv.set("mem:memories", "mem_old", original);
const result = (await sdk.trigger("mem::evolve", {
memoryId: "mem_old",
newContent: "Updated content",
newTitle: "Updated title",
})) as { success: boolean; memory: Memory; previousId: string };
expect(result.success).toBe(true);
expect(result.memory.version).toBe(2);
expect(result.memory.content).toBe("Updated content");
expect(result.memory.title).toBe("Updated title");
expect(result.memory.parentId).toBe("mem_old");
expect(result.memory.isLatest).toBe(true);
const old = await kv.get<Memory>("mem:memories", "mem_old");
expect(old!.isLatest).toBe(false);
});
it("returns error when memory not found", async () => {
const result = await sdk.trigger("mem::evolve", {
memoryId: "mem_missing",
newContent: "Updated content",
});
expect((result as { success: boolean }).success).toBe(false);
});
});
describe("mem::get-related", () => {
it("retrieves related memories within 1 hop", async () => {
const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2"] });
const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1", "mem_3"] });
const mem3 = makeMemory({ id: "mem_3", relatedIds: ["mem_2"] });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
await kv.set("mem:memories", "mem_3", mem3);
const result = (await sdk.trigger("mem::get-related", {
memoryId: "mem_1",
maxHops: 1,
})) as { results: Array<{ memory: Memory; hop: number }> };
expect(result.results.length).toBe(1);
expect(result.results[0].memory.id).toBe("mem_2");
expect(result.results[0].hop).toBe(1);
});
it("retrieves related memories within 2 hops", async () => {
const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2"] });
const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1", "mem_3"] });
const mem3 = makeMemory({ id: "mem_3", relatedIds: ["mem_2"] });
await kv.set("mem:memories", "mem_1", mem1);
await kv.set("mem:memories", "mem_2", mem2);
await kv.set("mem:memories", "mem_3", mem3);
const result = (await sdk.trigger("mem::get-related", {
memoryId: "mem_1",
maxHops: 2,
})) as { results: Array<{ memory: Memory; hop: number }> };
expect(result.results.length).toBe(2);
const ids = result.results.map((r) => r.memory.id);
expect(ids).toContain("mem_2");
expect(ids).toContain("mem_3");
});
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { SearchIndex } from "../src/state/search-index.js";
import type { CompressedObservation, Memory } from "../src/types.js";
// Mirrors the helper used by remember.ts and rebuildIndex(). Kept inline
// here rather than exporting from src/ so the test asserts the contract,
// not the implementation.
function memoryAsIndexable(memory: Memory): CompressedObservation {
return {
id: memory.id,
sessionId: memory.sessionIds[0] ?? "memory",
timestamp: memory.createdAt,
type: "decision",
title: memory.title,
facts: [memory.content],
narrative: memory.content,
concepts: memory.concepts,
files: memory.files,
importance: memory.strength,
};
}
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: "mem_test_001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "fact",
title: "BM25 test memory",
content: "BM25 search returns this memory by keyword match",
concepts: ["bm25", "search", "test"],
files: [],
sessionIds: [],
strength: 7,
version: 1,
isLatest: true,
...overrides,
};
}
describe("SearchIndex.has()", () => {
it("returns false for unknown ids", () => {
expect(new SearchIndex().has("mem_unknown")).toBe(false);
});
it("returns true after add()", () => {
const idx = new SearchIndex();
idx.add(memoryAsIndexable(makeMemory()));
expect(idx.has("mem_test_001")).toBe(true);
});
});
describe("memory indexing into SearchIndex (closes #257)", () => {
it("makes a saved memory findable by keyword search", () => {
const idx = new SearchIndex();
idx.add(memoryAsIndexable(makeMemory({
id: "mem_user_001",
title: "JWT middleware uses jose for Edge compatibility",
content: "Chose jose over jsonwebtoken because Cloudflare Workers don't ship Node crypto",
concepts: ["auth", "jose", "edge"],
})));
const hits = idx.search("jose middleware", 5);
expect(hits).toHaveLength(1);
expect(hits[0].obsId).toBe("mem_user_001");
});
it("returns the memory when the issue's reproduction query is run", () => {
// From issue #257: user saved a memory containing 'BM25 test'
// keywords and the search returned empty — recall failure.
const idx = new SearchIndex();
idx.add(memoryAsIndexable(makeMemory({
id: "mem_moy3u6ua_8c6962b668e7",
title: "BM25 test",
content: "Confirmed BM25 indexing works for memories saved via memory_save",
concepts: [],
})));
const hits = idx.search("BM25 test", 5);
expect(hits.length).toBeGreaterThan(0);
expect(hits[0].obsId).toBe("mem_moy3u6ua_8c6962b668e7");
});
it("matches concepts as well as title and content", () => {
const idx = new SearchIndex();
idx.add(memoryAsIndexable(makeMemory({
id: "mem_concept_001",
title: "Generic title",
content: "Generic content",
concepts: ["unique-concept-marker"],
})));
const hits = idx.search("unique-concept-marker", 5);
expect(hits.length).toBeGreaterThan(0);
expect(hits[0].obsId).toBe("mem_concept_001");
});
});
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../src/state/keyed-mutex.js", () => ({
withKeyedLock: <T>(_key: string, fn: () => Promise<T>) => fn(),
}));
import { registerRememberFunction } from "../src/functions/remember.js";
import {
getSearchIndex,
setIndexPersistence,
} from "../src/functions/search.js";
import { memoryToObservation } from "../src/state/memory-utils.js";
import type { Memory } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (id: string, handler: Function) => {
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (input: { function_id: string; payload: unknown }) => {
const fn = functions.get(input.function_id);
if (!fn) throw new Error(`unknown fn ${input.function_id}`);
return fn(input.payload);
},
};
}
describe("mem::forget audit coverage (issue #125)", () => {
it("emits a single audit row when a memory is forgotten", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
await kv.set("mem:memories", "mem_a", { id: "mem_a", content: "x" });
const result = await sdk.trigger({
function_id: "mem::forget",
payload: { memoryId: "mem_a" },
});
expect((result as { deleted: number }).deleted).toBe(1);
const auditRows = await kv.list<{
operation: string;
functionId: string;
targetIds: string[];
details: Record<string, unknown>;
}>("mem:audit");
expect(auditRows).toHaveLength(1);
const [row] = auditRows;
expect(row.operation).toBe("forget");
expect(row.functionId).toBe("mem::forget");
expect(row.targetIds).toEqual(["mem_a"]);
expect(row.details.memoriesDeleted).toBe(1);
expect(row.details.observationsDeleted).toBe(0);
expect(row.details.sessionDeleted).toBe(false);
});
it("emits one batched audit row when an entire session is forgotten", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
await kv.set("mem:sessions", "sess_1", { id: "sess_1" });
await kv.set("mem:summaries", "sess_1", { id: "sess_1" });
await kv.set("mem:obs:sess_1", "obs_a", { id: "obs_a" });
await kv.set("mem:obs:sess_1", "obs_b", { id: "obs_b" });
await sdk.trigger({
function_id: "mem::forget",
payload: { sessionId: "sess_1" },
});
const auditRows = await kv.list<{
targetIds: string[];
details: Record<string, unknown>;
}>("mem:audit");
expect(auditRows).toHaveLength(1);
const [row] = auditRows;
expect([...row.targetIds].sort()).toEqual(["obs_a", "obs_b"]);
expect(row.details.memoriesDeleted).toBe(0);
expect(row.details.observationsDeleted).toBe(2);
expect(row.details.sessionDeleted).toBe(true);
expect(row.details.deleted).toBe(4);
});
it("does not emit an audit row when nothing is deleted", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
await sdk.trigger({
function_id: "mem::forget",
payload: { sessionId: undefined, memoryId: undefined },
});
const auditRows = await kv.list("mem:audit");
expect(auditRows).toHaveLength(0);
});
});
// Delete paths must tear down the BM25 index entry and synchronously
// flush the persisted snapshot. Without this, a deleted memory keeps
// occupying limit-capped search result slots, and an in-memory remove
// would be lost if the process exits before the debounced save fires.
describe("mem::forget search-index cleanup", () => {
function makeMemory(id: string): Memory {
return {
id,
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
type: "fact",
title: `title ${id}`,
content: `content ${id}`,
concepts: [],
files: [],
sessionIds: ["ses_1"],
strength: 5,
version: 1,
isLatest: true,
};
}
beforeEach(() => {
getSearchIndex().clear();
setIndexPersistence(null);
});
afterEach(() => {
setIndexPersistence(null);
});
it("removes a forgotten memory from the BM25 index", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const mem = makeMemory("mem_a");
await kv.set("mem:memories", mem.id, mem);
getSearchIndex().add(memoryToObservation(mem));
expect(getSearchIndex().has("mem_a")).toBe(true);
await sdk.trigger({
function_id: "mem::forget",
payload: { memoryId: "mem_a" },
});
expect(getSearchIndex().has("mem_a")).toBe(false);
});
it("removes forgotten observations from the BM25 index", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
await kv.set("mem:obs:ses_1", "obs_a", { id: "obs_a" });
await kv.set("mem:obs:ses_1", "obs_b", { id: "obs_b" });
getSearchIndex().add(memoryToObservation(makeMemory("obs_a")));
getSearchIndex().add(memoryToObservation(makeMemory("obs_b")));
await sdk.trigger({
function_id: "mem::forget",
payload: { sessionId: "ses_1", observationIds: ["obs_a"] },
});
expect(getSearchIndex().has("obs_a")).toBe(false);
expect(getSearchIndex().has("obs_b")).toBe(true);
});
it("flushes persistence immediately when a memory is forgotten", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) };
setIndexPersistence(persistence);
await kv.set("mem:memories", "mem_a", makeMemory("mem_a"));
await sdk.trigger({
function_id: "mem::forget",
payload: { memoryId: "mem_a" },
});
expect(persistence.save).toHaveBeenCalled();
});
});
+258
View File
@@ -0,0 +1,258 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../src/state/keyed-mutex.js", () => ({
withKeyedLock: <T>(_key: string, fn: () => Promise<T>) => fn(),
}));
vi.mock("iii-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("iii-sdk")>();
return {
...actual,
TriggerAction: {
...actual.TriggerAction,
Void: vi.fn(() => ({ type: "void" })),
},
};
});
import { vi } from "vitest";
import { registerRememberFunction } from "../src/functions/remember.js";
import { getSearchIndex, setIndexPersistence } from "../src/functions/search.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (id: string, handler: Function) => {
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (input: { function_id: string; payload: unknown; action?: unknown }) => {
const fn = functions.get(input.function_id);
if (!fn) return {};
return fn(input.payload);
},
};
}
describe("mem::remember — project field stamping", () => {
beforeEach(() => {
getSearchIndex().clear();
setIndexPersistence(null);
});
afterEach(() => {
setIndexPersistence(null);
});
it("persists project on the saved memory when provided", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "express-jwt requires trimmed Bearer token",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
},
}) as { success: boolean; memory: { id: string; project?: string } };
expect(result.success).toBe(true);
expect(result.memory.project).toBe("api");
const stored = await kv.get<{ project?: string }>("mem:memories", result.memory.id);
expect(stored?.project).toBe("api");
});
it("leaves project undefined when not provided (backward-compat)", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = await sdk.trigger({
function_id: "mem::remember",
payload: { content: "some unscoped memory" },
}) as { success: boolean; memory: { id: string; project?: string } };
expect(result.success).toBe(true);
expect(result.memory.project).toBeUndefined();
});
it("trims whitespace from the project value", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = await sdk.trigger({
function_id: "mem::remember",
payload: { content: "padded project name", project: " api " },
}) as { success: boolean; memory: { project?: string } };
expect(result.memory.project).toBe("api");
});
it("treats a blank project string the same as no project", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const result = await sdk.trigger({
function_id: "mem::remember",
payload: { content: "blank project string", project: " " },
}) as { success: boolean; memory: { project?: string } };
expect(result.memory.project).toBeUndefined();
});
});
describe("mem::remember — cross-project dedup isolation", () => {
beforeEach(() => {
getSearchIndex().clear();
setIndexPersistence(null);
});
afterEach(() => {
setIndexPersistence(null);
});
it("does not supersede a memory from a different project even when content is similar", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
// Save a memory in project "api"
const first = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "always use express-jwt middleware for token validation in this project",
type: "pattern",
project: "api",
},
}) as { memory: { id: string; isLatest: boolean; project?: string } };
// Save a near-identical memory in project "web" — should NOT supersede the api one
const second = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "always use express-jwt middleware for token validation in this project",
type: "pattern",
project: "web",
},
}) as { memory: { id: string; supersedes: string[]; project?: string } };
expect(second.memory.project).toBe("web");
expect(second.memory.supersedes).toHaveLength(0);
// The api memory must still be isLatest
const apiMemory = await kv.get<{ isLatest: boolean }>("mem:memories", first.memory.id);
expect(apiMemory?.isLatest).toBe(true);
});
it("still supersedes within the same project when content is similar", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const first = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "always use express-jwt middleware for token validation in this project",
type: "pattern",
project: "api",
},
}) as { memory: { id: string } };
const second = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "always use express-jwt middleware for token validation in this project",
type: "pattern",
project: "api",
},
}) as { memory: { supersedes: string[] } };
expect(second.memory.supersedes).toContain(first.memory.id);
const original = await kv.get<{ isLatest: boolean }>("mem:memories", first.memory.id);
expect(original?.isLatest).toBe(false);
});
it("allows an unscoped memory to be superseded by a scoped one (legacy compat)", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
// Existing legacy memory with no project
const legacy = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "always use express-jwt middleware for token validation in this project",
type: "pattern",
},
}) as { memory: { id: string } };
// New scoped memory — should supersede the legacy unscoped one
const scoped = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "always use express-jwt middleware for token validation in this project",
type: "pattern",
project: "api",
},
}) as { memory: { supersedes: string[] } };
expect(scoped.memory.supersedes).toContain(legacy.memory.id);
});
it("allows a scoped memory to be superseded by an unscoped one (legacy compat)", async () => {
const sdk = mockSdk();
const kv = mockKV();
registerRememberFunction(sdk as never, kv as never);
const scoped = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "always use express-jwt middleware for token validation in this project",
type: "pattern",
project: "api",
},
}) as { memory: { id: string } };
// Unscoped write — should still supersede since one side has no project
const unscoped = await sdk.trigger({
function_id: "mem::remember",
payload: {
content: "always use express-jwt middleware for token validation in this project",
type: "pattern",
},
}) as { memory: { supersedes: string[] } };
expect(unscoped.memory.supersedes).toContain(scoped.memory.id);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerReplayFunctions } from "../src/functions/replay.js";
import { KV } from "../src/state/schema.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
const setCalls: Array<{ scope: string; key: string | undefined; value: any }> = [];
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, value: T): Promise<T> => {
setCalls.push({ scope, key, value });
if (!store.has(scope)) store.set(scope, new Map());
// Mirror the engine: a state::set with key=undefined fails. We
// surface this via setCalls so the test can assert key !== undefined.
if (key === undefined) {
throw new Error("missing field `key`");
}
store.get(scope)!.set(key, value);
return value;
},
delete: async (scope: string, key: string) => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> =>
Array.from(store.get(scope)?.values() ?? []) as T[],
getSetCalls: () => setCalls,
};
}
function mockSdk(kv: ReturnType<typeof mockKV>) {
const fns = new Map<string, Function>();
return {
registerFunction: (id: string, handler: Function) => fns.set(id, handler),
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload?: unknown },
data?: unknown,
) => {
const id =
typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload =
typeof idOrInput === "string" ? data : (idOrInput as any).payload;
const fn = fns.get(id);
if (!fn) return { success: true };
return fn(payload);
},
_kv: kv,
} as any;
}
describe("import-jsonl re-key on parsed.sessionId (#775)", () => {
let tmpRoot: string;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "replay-import-key-"));
});
function writeFixture(sessionId: string, ts = "2026-04-17T10:00:00.000Z") {
const dir = join(tmpRoot, "proj");
rmSync(dir, { recursive: true, force: true });
require("node:fs").mkdirSync(dir, { recursive: true });
const lines = [
JSON.stringify({
type: "user",
uuid: "u1",
sessionId,
timestamp: ts,
cwd: tmpRoot,
message: {
role: "user",
content: [{ type: "text", text: "hello" }],
},
}),
JSON.stringify({
type: "assistant",
uuid: "a1",
sessionId,
timestamp: ts,
message: {
role: "assistant",
content: [{ type: "text", text: "world" }],
},
}),
];
writeFileSync(join(dir, `${sessionId}.jsonl`), lines.join("\n") + "\n");
}
it("re-imports a session whose stored row is missing the `id` field without aborting the batch", async () => {
writeFixture("sess-no-id");
const kv = mockKV();
const sdk = mockSdk(kv);
registerReplayFunctions(sdk, kv as never);
// Seed an existing session row that is MISSING `id` — the
// pre-fix code would re-key on `existing.id` (undefined) and
// throw `missing field \`key\``, aborting the whole import.
await kv.set(KV.sessions, "sess-no-id", {
project: "proj",
cwd: tmpRoot,
startedAt: "2026-04-17T09:00:00Z",
endedAt: "2026-04-17T09:30:00Z",
status: "completed",
observationCount: 2,
tags: [],
});
const result = (await sdk.trigger("mem::replay::import-jsonl", {
path: tmpRoot,
})) as { success: boolean; imported?: number; error?: string };
expect(result.success).toBe(true);
expect(result.imported).toBe(1);
const undefinedKeyWrites = kv
.getSetCalls()
.filter((c) => c.scope === KV.sessions && c.key === undefined);
expect(undefinedKeyWrites.length).toBe(0);
const sessionWrites = kv
.getSetCalls()
.filter((c) => c.scope === KV.sessions && c.key === "sess-no-id");
expect(sessionWrites.length).toBeGreaterThan(0);
// The handler also backfills the missing id field so future reads
// are well-formed.
expect((sessionWrites.at(-1)!.value as any).id).toBe("sess-no-id");
});
it("fresh import (no existing row) still writes session keyed by parsed.sessionId", async () => {
writeFixture("sess-fresh");
const kv = mockKV();
const sdk = mockSdk(kv);
registerReplayFunctions(sdk, kv as never);
const result = (await sdk.trigger("mem::replay::import-jsonl", {
path: tmpRoot,
})) as { success: boolean; imported?: number };
expect(result.success).toBe(true);
expect(result.imported).toBe(1);
const sessionWrites = kv
.getSetCalls()
.filter((c) => c.scope === KV.sessions && c.key === "sess-fresh");
expect(sessionWrites.length).toBe(1);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { isSensitive } from "../src/functions/replay.js";
describe("isSensitive path guard", () => {
it("blocks .env and common secret filenames", () => {
expect(isSensitive("/Users/x/project/.env")).toBe(true);
expect(isSensitive("/Users/x/project/.env.local")).toBe(true);
expect(isSensitive("/tmp/credentials.json")).toBe(true);
expect(isSensitive("/home/alice/.ssh/id_rsa")).toBe(true);
expect(isSensitive("/srv/app/secret.key")).toBe(true);
expect(isSensitive("/srv/app/access_token.txt")).toBe(true);
expect(isSensitive("/srv/app/private_key.pem")).toBe(true);
});
it("does not false-positive on project names containing substrings", () => {
expect(isSensitive("/Users/dev/jsonwebtoken-demo/transcript.jsonl")).toBe(false);
expect(isSensitive("/repos/secrethandshake-lib/a.jsonl")).toBe(false);
expect(isSensitive("/opt/tokeniser/out.jsonl")).toBe(false);
expect(isSensitive("/Users/alice/.claude/projects/myapp/abc.jsonl")).toBe(false);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parseJsonlText } from "../src/replay/jsonl-parser.js";
import { projectTimeline } from "../src/replay/timeline.js";
const fx = (name: string) =>
readFileSync(join(__dirname, "fixtures/jsonl", name), "utf-8");
describe("parseJsonlText", () => {
it("parses basic user/assistant exchange", () => {
const out = parseJsonlText(fx("basic.jsonl"));
expect(out.sessionId).toBe("sess-basic");
expect(out.project).toBe("project");
expect(out.cwd).toBe("/Users/alice/project");
expect(out.observations).toHaveLength(2);
expect(out.observations[0].hookType).toBe("prompt_submit");
expect(out.observations[0].userPrompt).toBe("Fix the login bug");
expect(out.observations[1].hookType).toBe("stop");
expect(out.observations[1].assistantResponse).toBe("Looking into it now.");
});
it("parses tool_use + tool_result pairs", () => {
const out = parseJsonlText(fx("tool-use.jsonl"));
expect(out.sessionId).toBe("sess-tool");
const kinds = out.observations.map((o) => o.hookType);
expect(kinds).toEqual([
"prompt_submit",
"pre_tool_use",
"post_tool_use",
"stop",
]);
const toolCall = out.observations[1];
expect(toolCall.toolName).toBe("Bash");
expect((toolCall.toolInput as { command: string }).command).toBe("ls");
const toolResult = out.observations[2];
expect(toolResult.toolOutput).toBe("README.md\nsrc\n");
});
it("tolerates malformed lines and marks tool errors", () => {
const out = parseJsonlText(fx("errors.jsonl"));
const errObs = out.observations.find((o) => o.hookType === "post_tool_failure");
expect(errObs).toBeDefined();
expect(errObs?.toolOutput).toBe("exit 1");
});
it("falls back to generated sessionId when missing", () => {
const text = JSON.stringify({
type: "user",
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
});
const out = parseJsonlText(text);
expect(out.sessionId).toMatch(/^sess_/);
});
it("returns empty observations for blank input", () => {
const out = parseJsonlText("");
expect(out.observations).toHaveLength(0);
});
it("prefers the file's sessionId over the fallback", () => {
const text = [
JSON.stringify({
type: "user",
sessionId: "real-session-from-file",
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
}),
].join("\n");
const out = parseJsonlText(text, "fallback-should-be-ignored");
expect(out.sessionId).toBe("real-session-from-file");
for (const obs of out.observations) {
expect(obs.sessionId).toBe("real-session-from-file");
}
});
it("returns the same sessionId across repeated parses of one file", () => {
const text = JSON.stringify({
type: "user",
sessionId: "stable-id",
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
});
const a = parseJsonlText(text, "fb-1");
const b = parseJsonlText(text, "fb-2");
expect(a.sessionId).toBe("stable-id");
expect(b.sessionId).toBe("stable-id");
});
it("uses the fallback only when the file has no sessionId", () => {
const text = JSON.stringify({
type: "user",
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
});
const out = parseJsonlText(text, "fb-used");
expect(out.sessionId).toBe("fb-used");
});
});
describe("projectTimeline", () => {
it("preserves ordering and computes offsets from real timestamps", () => {
const parsed = parseJsonlText(fx("tool-use.jsonl"));
const tl = projectTimeline(parsed.observations);
expect(tl.eventCount).toBe(4);
expect(tl.events[0].kind).toBe("prompt");
expect(tl.events[1].kind).toBe("tool_call");
expect(tl.events[2].kind).toBe("tool_result");
expect(tl.events[3].kind).toBe("response");
expect(tl.events[0].offsetMs).toBe(0);
expect(tl.events[3].offsetMs).toBeGreaterThan(0);
});
it("synthesizes pacing when all timestamps identical", () => {
const parsed = parseJsonlText(fx("basic.jsonl"));
for (const obs of parsed.observations) obs.timestamp = "2026-04-17T10:00:00.000Z";
const tl = projectTimeline(parsed.observations);
expect(tl.events[0].offsetMs).toBe(0);
expect(tl.events[1].offsetMs).toBeGreaterThanOrEqual(300);
});
it("returns empty timeline for no observations", () => {
const tl = projectTimeline([]);
expect(tl.eventCount).toBe(0);
expect(tl.totalDurationMs).toBe(0);
expect(tl.events).toHaveLength(0);
});
it("marks errored tool results as tool_error kind", () => {
const parsed = parseJsonlText(fx("errors.jsonl"));
const tl = projectTimeline(parsed.observations);
expect(tl.events.some((e) => e.kind === "tool_error")).toBe(true);
});
it("uses one shared fallback timestamp when metadata missing", () => {
const text = JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
});
const out = parseJsonlText(text);
expect(out.startedAt).toBe(out.endedAt);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("@xenova/transformers", () => {
throw new Error("not installed");
});
import { rerank, isRerankerAvailable } from "../src/state/reranker.js";
describe("reranker", () => {
it("returns results unchanged when @xenova/transformers is unavailable", async () => {
const results = [
{
observation: {
id: "o1",
title: "First",
narrative: "First result",
},
bm25Score: 0.5,
vectorScore: 0.6,
graphScore: 0,
combinedScore: 0.8,
sessionId: "s1",
},
{
observation: {
id: "o2",
title: "Second",
narrative: "Second result",
},
bm25Score: 0.3,
vectorScore: 0.4,
graphScore: 0,
combinedScore: 0.5,
sessionId: "s1",
},
] as any;
const reranked = await rerank("test query", results);
expect(reranked).toEqual(results);
});
it("isRerankerAvailable returns false when not loaded", () => {
expect(isRerankerAvailable()).toBe(false);
});
it("handles single result gracefully", async () => {
const results = [
{
observation: { id: "o1", title: "Only" },
combinedScore: 1.0,
},
] as any;
const reranked = await rerank("query", results);
expect(reranked).toHaveLength(1);
});
it("handles empty results", async () => {
const reranked = await rerank("query", []);
expect(reranked).toHaveLength(0);
});
});
+315
View File
@@ -0,0 +1,315 @@
import { describe, it, expect, vi } from "vitest";
import type { Memory, SemanticMemory } from "../src/types.js";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function mockKV(
memories: Memory[] = [],
semanticMems: SemanticMemory[] = [],
) {
const store = new Map<string, Map<string, unknown>>();
const memMap = new Map<string, unknown>();
for (const m of memories) memMap.set(m.id, m);
store.set("mem:memories", memMap);
const semMap = new Map<string, unknown>();
for (const s of semanticMems) semMap.set(s.id, s);
store.set("mem:semantic", semMap);
store.set("mem:retention", new Map());
store.set("mem:access", new Map());
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string) => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const m = store.get(scope);
return m ? (Array.from(m.values()) as T[]) : [];
},
};
}
function mockSdk() {
const fns = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, fn: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
fns.set(id, fn);
},
trigger: async (
input: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const functionId =
typeof input === "string" ? input : input.function_id;
const payload = typeof input === "string" ? data : input.payload;
const fn = fns.get(functionId);
if (fn) return fn(payload);
return null;
},
};
}
function makeMemory(id: string, daysOld = 30): Memory {
const created = new Date(
Date.now() - daysOld * 86_400_000,
).toISOString();
return {
id,
createdAt: created,
updatedAt: created,
type: "fact",
title: `Memory ${id}`,
content: `Content ${id}`,
concepts: [],
files: [],
sessionIds: ["ses_1"],
strength: 1,
version: 1,
isLatest: true,
};
}
function makeSemantic(
id: string,
daysOld: number,
accessCount = 0,
): SemanticMemory {
const created = new Date(
Date.now() - daysOld * 86_400_000,
).toISOString();
return {
id,
fact: `Fact ${id}`,
confidence: 0.8,
sourceSessionIds: ["ses_1"],
sourceMemoryIds: [],
accessCount,
lastAccessedAt: created,
strength: 0.8,
createdAt: created,
updatedAt: created,
};
}
describe("RetentionScoring with access log (issue #119)", () => {
it("episodic memories with recorded reads get higher reinforcementBoost than untouched ones", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const { recordAccess } = await import(
"../src/functions/access-tracker.js"
);
const memories = [
makeMemory("mem_hot", 30),
makeMemory("mem_cold", 30),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
// Simulate 5 agent reads of mem_hot in the past 24h
const now = Date.now();
for (let i = 0; i < 5; i++) {
await recordAccess(kv as never, "mem_hot", now - i * 60_000);
}
const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any;
const hot = result.scores.find((s: any) => s.memoryId === "mem_hot");
const cold = result.scores.find((s: any) => s.memoryId === "mem_cold");
expect(hot.accessCount).toBe(5);
expect(cold.accessCount).toBe(0);
expect(hot.reinforcementBoost).toBeGreaterThan(0);
expect(cold.reinforcementBoost).toBe(0);
expect(hot.score).toBeGreaterThan(cold.score);
});
it("recent reads contribute more to reinforcement than ancient reads", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const { recordAccess } = await import(
"../src/functions/access-tracker.js"
);
const memories = [
makeMemory("mem_recent_read", 60),
makeMemory("mem_old_read", 60),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
const now = Date.now();
// mem_recent_read: 1 access yesterday
await recordAccess(kv as never, "mem_recent_read", now - 86_400_000);
// mem_old_read: 1 access 60 days ago
await recordAccess(kv as never, "mem_old_read", now - 60 * 86_400_000);
const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any;
const recent = result.scores.find(
(s: any) => s.memoryId === "mem_recent_read",
);
const old = result.scores.find(
(s: any) => s.memoryId === "mem_old_read",
);
expect(recent.reinforcementBoost).toBeGreaterThan(old.reinforcementBoost);
});
it("backwards-compat: semantic memories with only legacy lastAccessedAt still score", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
// Pre-0.8.3 data: semantic memory has lastAccessedAt set by the
// consolidation pipeline, but no entry in mem:access. The merge in
// retention.ts must inject lastAccessedAt into accessTimestamps so
// the boost is non-zero. Compare against an identical sem with NO
// lastAccessedAt to prove the merge actually contributes.
const semWith = makeSemantic("sem_with_legacy", 30, 3);
semWith.lastAccessedAt = new Date(Date.now() - 86_400_000).toISOString();
const semWithout = makeSemantic("sem_without_legacy", 30, 3);
semWithout.lastAccessedAt = "";
const sdk = mockSdk();
const kv = mockKV([], [semWith, semWithout]);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any;
const withEntry = result.scores.find(
(s: any) => s.memoryId === "sem_with_legacy",
);
const withoutEntry = result.scores.find(
(s: any) => s.memoryId === "sem_without_legacy",
);
expect(withEntry.accessCount).toBe(3);
expect(withEntry.reinforcementBoost).toBeGreaterThan(0);
expect(withoutEntry.reinforcementBoost).toBe(0);
// The merged legacy timestamp must produce a meaningful delta.
expect(withEntry.reinforcementBoost).toBeGreaterThan(
withoutEntry.reinforcementBoost + 0.1,
);
});
it("corrupted lastAccessedAt does not propagate NaN into the score", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sem = makeSemantic("sem_corrupt", 30, 1);
sem.lastAccessedAt = "<script>alert(1)</script>";
const sdk = mockSdk();
const kv = mockKV([], [sem]);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any;
const entry = result.scores.find((s: any) => s.memoryId === "sem_corrupt");
expect(Number.isFinite(entry.score)).toBe(true);
expect(Number.isFinite(entry.reinforcementBoost)).toBe(true);
});
it("retention scoring normalizes malformed mem:access rows", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("mem_corrupt", 10),
makeMemory("mem_clean", 10),
];
const sdk = mockSdk();
const kv = mockKV(memories);
// Seed the access namespace directly with garbage rows.
await kv.set("mem:access", "mem_corrupt", {
memoryId: "mem_corrupt",
count: "not-a-number" as unknown as number,
lastAt: 42 as unknown as string,
recent: [NaN, "bad" as unknown as number, 5_000, Infinity, -1_000],
});
await kv.set("mem:access", "mem_clean", {
memoryId: "mem_clean",
count: -7,
lastAt: "",
recent: Array.from({ length: 50 }, (_, i) => i * 1000),
});
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any;
const corrupt = result.scores.find(
(s: any) => s.memoryId === "mem_corrupt",
);
const clean = result.scores.find((s: any) => s.memoryId === "mem_clean");
expect(Number.isFinite(corrupt.score)).toBe(true);
expect(Number.isFinite(corrupt.reinforcementBoost)).toBe(true);
expect(corrupt.accessCount).toBeGreaterThanOrEqual(0);
expect(Number.isFinite(clean.score)).toBe(true);
// recent[] was 50 entries; normalization should have capped at 20.
expect(clean.accessCount).toBeGreaterThanOrEqual(20);
});
it("retention scoring survives kv.list(mem:access) failures", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [makeMemory("mem_resilient", 10)];
const sdk = mockSdk();
const kv = mockKV(memories);
const realList = kv.list.bind(kv);
kv.list = (async (scope: string) => {
if (scope === "mem:access") throw new Error("namespace missing");
return realList(scope);
}) as never;
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any;
expect(result.success).toBe(true);
const entry = result.scores.find(
(s: any) => s.memoryId === "mem_resilient",
);
expect(entry.accessCount).toBe(0);
});
it("fresh access log overrides legacy single-sample for semantic memories", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const { recordAccess } = await import(
"../src/functions/access-tracker.js"
);
const sem = makeSemantic("sem_active", 30, 1);
const sdk = mockSdk();
const kv = mockKV([], [sem]);
registerRetentionFunctions(sdk as never, kv as never);
const now = Date.now();
for (let i = 0; i < 10; i++) {
await recordAccess(kv as never, "sem_active", now - i * 30_000);
}
const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any;
const entry = result.scores.find(
(s: any) => s.memoryId === "sem_active",
);
// effectiveCount = max(log=10, sem.accessCount=1) = 10
expect(entry.accessCount).toBe(10);
});
});
+566
View File
@@ -0,0 +1,566 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
getSearchIndex,
setIndexPersistence,
} from "../src/functions/search.js";
import { memoryToObservation } from "../src/state/memory-utils.js";
import type { Memory, SemanticMemory } from "../src/types.js";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function mockKV(
memories: Memory[] = [],
semanticMems: SemanticMemory[] = [],
) {
const store = new Map<string, Map<string, unknown>>();
const memMap = new Map<string, unknown>();
for (const m of memories) memMap.set(m.id, m);
store.set("mem:memories", memMap);
const semMap = new Map<string, unknown>();
for (const s of semanticMems) semMap.set(s.id, s);
store.set("mem:semantic", semMap);
store.set("mem:retention", new Map());
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string, fn: Function) => {
if (typeof idOrOpts !== "string") {
throw new Error("registerFunction expects string function id");
}
functions.set(idOrOpts, fn);
},
trigger: async (input: { function_id: string; payload: unknown }) => {
if (typeof input === "string") {
throw new Error("legacy trigger signature is not supported in tests");
}
const fn = functions.get(input.function_id);
if (fn) return fn(input.payload);
return null;
},
};
}
function makeMemory(
id: string,
type: Memory["type"],
daysOld: number,
): Memory {
const created = new Date(
Date.now() - daysOld * 24 * 60 * 60 * 1000,
).toISOString();
return {
id,
createdAt: created,
updatedAt: created,
type,
title: `Memory ${id}`,
content: `Content of memory ${id}`,
concepts: [],
files: [],
sessionIds: ["ses_1"],
strength: 1,
version: 1,
isLatest: true,
};
}
function makeSemanticMemory(
id: string,
daysOld: number,
accessCount = 0,
): SemanticMemory {
const created = new Date(
Date.now() - daysOld * 24 * 60 * 60 * 1000,
).toISOString();
return {
id,
fact: `Fact ${id}`,
confidence: 0.8,
sourceSessionIds: ["ses_1"],
sourceMemoryIds: [],
accessCount,
lastAccessedAt: created,
strength: 0.8,
createdAt: created,
updatedAt: created,
};
}
describe("RetentionScoring", () => {
it("imports without errors", async () => {
const mod = await import("../src/functions/retention.js");
expect(mod.registerRetentionFunctions).toBeDefined();
});
it("computes retention scores for all memories", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("mem_recent", "architecture", 1),
makeMemory("mem_old", "fact", 365),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as {
success: boolean;
total: number;
tiers: any;
scores: any[];
};
expect(result.success).toBe(true);
expect(result.total).toBe(2);
expect(result.scores.length).toBe(2);
const recentScore = result.scores.find(
(s: any) => s.memoryId === "mem_recent",
);
const oldScore = result.scores.find(
(s: any) => s.memoryId === "mem_old",
);
expect(recentScore!.score).toBeGreaterThan(oldScore!.score);
});
it("higher-type memories get higher salience", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("mem_arch", "architecture", 30),
makeMemory("mem_fact", "fact", 30),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as any;
const archScore = result.scores.find(
(s: any) => s.memoryId === "mem_arch",
);
const factScore = result.scores.find(
(s: any) => s.memoryId === "mem_fact",
);
expect(archScore.salience).toBeGreaterThan(factScore.salience);
});
it("classifies memories into tiers", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("hot1", "architecture", 1),
makeMemory("hot2", "preference", 3),
makeMemory("warm1", "pattern", 60),
makeMemory("cold1", "fact", 300),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as any;
expect(result.tiers.hot + result.tiers.warm + result.tiers.cold + result.tiers.evictable).toBe(4);
});
it("dry-run eviction shows candidates without deleting", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("mem_keep", "architecture", 1),
makeMemory("mem_evict", "fact", 500),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
const dryResult = (await sdk.trigger({
function_id: "mem::retention-evict",
payload: {
threshold: 0.5,
dryRun: true,
},
})) as any;
expect(dryResult.dryRun).toBe(true);
expect(dryResult.wouldEvict).toBeGreaterThanOrEqual(0);
const remaining = await kv.list("mem:memories");
expect(remaining.length).toBe(2);
});
it("includes semantic memories in scoring", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const semanticMems = [
makeSemanticMemory("sem_1", 10, 5),
makeSemanticMemory("sem_2", 200, 0),
];
const sdk = mockSdk();
const kv = mockKV([], semanticMems);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as any;
expect(result.total).toBe(2);
const sem1 = result.scores.find((s: any) => s.memoryId === "sem_1");
const sem2 = result.scores.find((s: any) => s.memoryId === "sem_2");
expect(sem1.score).toBeGreaterThan(sem2.score);
});
it("scores tag rows with their source scope (#124)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sdk = mockSdk();
const kv = mockKV(
[makeMemory("mem_ep", "fact", 10)],
[makeSemanticMemory("sem_sem", 10, 2)],
);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as any;
const ep = result.scores.find((s: any) => s.memoryId === "mem_ep");
const sem = result.scores.find((s: any) => s.memoryId === "sem_sem");
expect(ep.source).toBe("episodic");
expect(sem.source).toBe("semantic");
// Also assert the source discriminator is persisted to mem:retention,
// not just present in the transient response payload — the eviction
// loop reads back from stored rows, so a regression in kv.set or
// serialization would still pass the in-memory check above.
const [epStored, semStored] = await Promise.all([
kv.get("mem:retention", "mem_ep"),
kv.get("mem:retention", "sem_sem"),
]);
expect(epStored).toMatchObject({ source: "episodic" });
expect(semStored).toMatchObject({ source: "semantic" });
});
it("mem::retention-evict deletes semantic memories from mem:semantic, not mem:memories (#124)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
// Both are 500 days old with zero access → both will score below
// the default cold threshold. Before #124 the loop silently called
// kv.delete(mem:memories, <semantic-id>) which was a no-op, leaving
// the semantic row in mem:semantic forever.
const sdk = mockSdk();
const kv = mockKV(
[makeMemory("mem_evict", "fact", 500)],
[makeSemanticMemory("sem_evict", 500, 0)],
);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
const result = (await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.9 },
})) as any;
expect(result.evicted).toBe(2);
expect(result.evictedEpisodic).toBe(1);
expect(result.evictedSemantic).toBe(1);
const remainingEp = await kv.list("mem:memories");
const remainingSem = await kv.list("mem:semantic");
expect(remainingEp).toHaveLength(0);
expect(remainingSem).toHaveLength(0);
// Retention score rows also cleaned up for both.
const remainingScores = await kv.list("mem:retention");
expect(remainingScores).toHaveLength(0);
});
it("mem::retention-evict emits a single batched audit record on success (#124, audit policy)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sdk = mockSdk();
const kv = mockKV(
[makeMemory("mem_a", "fact", 500), makeMemory("mem_b", "fact", 500)],
[makeSemanticMemory("sem_c", 500, 0)],
);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.9 },
});
// Retention-score ALSO emits an audit row (one per rescore, also
// required by the repo audit-coverage policy), so filter the audit
// log down to just the retention-evict entry we're asserting on.
const allEntries = await kv.list<{
operation: string;
functionId: string;
targetIds: string[];
details: Record<string, unknown>;
}>("mem:audit");
const evictEntries = allEntries.filter(
(e) => e.functionId === "mem::retention-evict",
);
expect(evictEntries).toHaveLength(1);
const [entry] = evictEntries;
expect(entry.operation).toBe("delete");
expect([...entry.targetIds].sort()).toEqual(["mem_a", "mem_b", "sem_c"]);
expect(entry.details.evicted).toBe(3);
expect(entry.details.evictedEpisodic).toBe(2);
expect(entry.details.evictedSemantic).toBe(1);
});
it("mem::retention-evict skips audit when evicted=0 (no spurious audit rows)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sdk = mockSdk();
// Memory is 1 day old → score will be high → nothing falls below
// the strict 0.99 threshold → evict=0 → no evict audit row.
// Retention-score itself still writes one audit row per sweep,
// which is the expected behavior (zero-eviction != zero-rescore),
// so we filter the audit log down to just the evict entries.
const kv = mockKV([makeMemory("mem_keep", "architecture", 1)]);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.0001 },
});
const allEntries = await kv.list<{ functionId: string }>("mem:audit");
const evictEntries = allEntries.filter(
(e) => e.functionId === "mem::retention-evict",
);
expect(evictEntries).toHaveLength(0);
});
it("mem::retention-score emits a batched audit row per rescore (#124, audit policy)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sdk = mockSdk();
const kv = mockKV(
[makeMemory("mem_a", "fact", 10), makeMemory("mem_b", "fact", 10)],
[makeSemanticMemory("sem_c", 10, 2)],
);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
const allEntries = await kv.list<{
operation: string;
functionId: string;
targetIds: string[];
details: Record<string, unknown>;
}>("mem:audit");
const scoreEntries = allEntries.filter(
(e) => e.functionId === "mem::retention-score",
);
expect(scoreEntries).toHaveLength(1);
const [entry] = scoreEntries;
expect(entry.operation).toBe("retention_score");
// targetIds is intentionally empty — a mature store can have 1000+
// memory ids per rescore and flooding the audit log would be worse
// than recording just the summary counts.
expect(entry.targetIds).toEqual([]);
expect(entry.details.total).toBe(3);
expect(entry.details.episodic).toBe(2);
expect(entry.details.semantic).toBe(1);
});
it("mem::retention-evict probes namespaces for legacy semantic rows (backwards-compat, #124)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
// The actual nasty case from CodeRabbit's review: a pre-0.8.10
// store that had a semantic memory scored by the old code path.
// The retention row has NO source field and the memory lives in
// mem:semantic. If the eviction path blindly defaults missing
// source to episodic, it no-ops the delete and strands the
// semantic row forever — which is the exact bug #124 is about.
const sdk = mockSdk();
const kv = mockKV([], [makeSemanticMemory("sem_legacy", 500, 0)]);
registerRetentionFunctions(sdk as never, kv as never);
await kv.set("mem:retention", "sem_legacy", {
memoryId: "sem_legacy",
// No `source` field — simulates a row written by 0.8.9 or earlier.
score: 0.01,
salience: 0,
temporalDecay: 0,
reinforcementBoost: 0,
lastAccessed: new Date().toISOString(),
accessCount: 0,
});
const result = (await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.5 },
})) as any;
expect(result.evicted).toBe(1);
expect(result.evictedSemantic).toBe(1);
expect(result.evictedEpisodic).toBe(0);
// Most important assertion: the semantic row is GONE from
// mem:semantic. Before the probe fix, this assertion failed
// because the delete targeted mem:memories.
const remainingSem = await kv.list("mem:semantic");
expect(remainingSem).toHaveLength(0);
});
it("mem::retention-evict routes pre-0.8.10 episodic rows with missing source to mem:memories (#124)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
// Simulate a store that was scored on 0.8.9 or earlier: retention
// rows exist but they have no `source` field. The new eviction
// loop must still route those to mem:memories so users don't get
// stuck with un-evictable episodic rows after upgrading.
const sdk = mockSdk();
const kv = mockKV([makeMemory("mem_old", "fact", 500)]);
registerRetentionFunctions(sdk as never, kv as never);
// Directly plant a legacy-shape retention score (no `source` key).
await kv.set("mem:retention", "mem_old", {
memoryId: "mem_old",
score: 0.01,
salience: 0,
temporalDecay: 0,
reinforcementBoost: 0,
lastAccessed: new Date().toISOString(),
accessCount: 0,
});
const result = (await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.5 },
})) as any;
expect(result.evicted).toBe(1);
expect(result.evictedEpisodic).toBe(1);
expect(result.evictedSemantic).toBe(0);
const remaining = await kv.list("mem:memories");
expect(remaining).toHaveLength(0);
});
describe("search-index cleanup on eviction", () => {
beforeEach(() => {
getSearchIndex().clear();
setIndexPersistence(null);
});
afterEach(() => {
setIndexPersistence(null);
});
it("removes evicted memories from the BM25 index and flushes persistence", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) };
setIndexPersistence(persistence);
const evictee = makeMemory("mem_evict", "fact", 500);
const sdk = mockSdk();
const kv = mockKV([evictee]);
registerRetentionFunctions(sdk as never, kv as never);
getSearchIndex().add(memoryToObservation(evictee));
expect(getSearchIndex().has("mem_evict")).toBe(true);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.9 },
});
expect(getSearchIndex().has("mem_evict")).toBe(false);
expect(persistence.save).toHaveBeenCalled();
});
it("does not flush persistence when nothing is evicted", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) };
setIndexPersistence(persistence);
const sdk = mockSdk();
const kv = mockKV([makeMemory("mem_fresh", "architecture", 1)]);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.01 },
});
expect(persistence.save).not.toHaveBeenCalled();
});
});
});
+498
View File
@@ -0,0 +1,498 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerRoutinesFunction } from "../src/functions/routines.js";
import type { Action, Routine, RoutineRun } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
describe("Routines Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
registerRoutinesFunction(sdk as never, kv as never);
});
describe("mem::routine-create", () => {
it("creates a routine with valid data", async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "Deploy Pipeline",
description: "Standard deploy steps",
steps: [
{ title: "Build", description: "Run build", actionTemplate: {}, dependsOn: [] },
{ title: "Test", description: "Run tests", actionTemplate: {}, dependsOn: [0] },
],
tags: ["deploy", "ci"],
})) as { success: boolean; routine: Routine };
expect(result.success).toBe(true);
expect(result.routine.id).toMatch(/^rtn_/);
expect(result.routine.name).toBe("Deploy Pipeline");
expect(result.routine.description).toBe("Standard deploy steps");
expect(result.routine.steps.length).toBe(2);
expect(result.routine.tags).toEqual(["deploy", "ci"]);
expect(result.routine.createdAt).toBeDefined();
expect(result.routine.updatedAt).toBeDefined();
expect(result.routine.frozen).toBe(true);
});
it("returns error when name is missing", async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "",
steps: [{ title: "Step 1", description: "", actionTemplate: {}, dependsOn: [] }],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("name and steps are required");
});
it("returns error when steps array is empty", async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "Empty Routine",
steps: [],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("name and steps are required");
});
it("returns error when a step has no title", async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "Bad Steps",
steps: [
{ title: "Good Step", description: "ok", actionTemplate: {}, dependsOn: [] },
{ title: " ", description: "no title", actionTemplate: {}, dependsOn: [] },
],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("step 1 must have a title");
});
it("assigns correct order to steps", async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "Ordered Routine",
steps: [
{ title: "First", description: "", actionTemplate: {}, dependsOn: [] },
{ title: "Second", description: "", actionTemplate: {}, dependsOn: [] },
{ title: "Third", description: "", actionTemplate: {}, dependsOn: [] },
],
})) as { success: boolean; routine: Routine };
expect(result.success).toBe(true);
expect(result.routine.steps[0].order).toBe(0);
expect(result.routine.steps[1].order).toBe(1);
expect(result.routine.steps[2].order).toBe(2);
});
it("preserves explicit order values", async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "Custom Order",
steps: [
{ order: 10, title: "First", description: "", actionTemplate: {}, dependsOn: [] },
{ order: 20, title: "Second", description: "", actionTemplate: {}, dependsOn: [] },
],
})) as { success: boolean; routine: Routine };
expect(result.success).toBe(true);
expect(result.routine.steps[0].order).toBe(10);
expect(result.routine.steps[1].order).toBe(20);
});
it("defaults frozen to true when not specified", async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "Default Frozen",
steps: [{ title: "Step", description: "", actionTemplate: {}, dependsOn: [] }],
})) as { success: boolean; routine: Routine };
expect(result.success).toBe(true);
expect(result.routine.frozen).toBe(true);
});
it("respects frozen=false when explicitly set", async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "Unfrozen",
steps: [{ title: "Step", description: "", actionTemplate: {}, dependsOn: [] }],
frozen: false,
})) as { success: boolean; routine: Routine };
expect(result.success).toBe(true);
expect(result.routine.frozen).toBe(false);
});
});
describe("mem::routine-list", () => {
beforeEach(async () => {
await sdk.trigger("mem::routine-create", {
name: "Routine A",
steps: [{ title: "S1", description: "", actionTemplate: {}, dependsOn: [] }],
tags: ["deploy"],
frozen: true,
});
await sdk.trigger("mem::routine-create", {
name: "Routine B",
steps: [{ title: "S1", description: "", actionTemplate: {}, dependsOn: [] }],
tags: ["test", "ci"],
frozen: false,
});
await sdk.trigger("mem::routine-create", {
name: "Routine C",
steps: [{ title: "S1", description: "", actionTemplate: {}, dependsOn: [] }],
tags: ["deploy", "ci"],
frozen: true,
});
});
it("lists all routines", async () => {
const result = (await sdk.trigger("mem::routine-list", {})) as {
success: boolean;
routines: Routine[];
};
expect(result.success).toBe(true);
expect(result.routines.length).toBe(3);
});
it("filters by frozen=true", async () => {
const result = (await sdk.trigger("mem::routine-list", {
frozen: true,
})) as { success: boolean; routines: Routine[] };
expect(result.success).toBe(true);
expect(result.routines.length).toBe(2);
expect(result.routines.every((r) => r.frozen === true)).toBe(true);
});
it("filters by frozen=false", async () => {
const result = (await sdk.trigger("mem::routine-list", {
frozen: false,
})) as { success: boolean; routines: Routine[] };
expect(result.success).toBe(true);
expect(result.routines.length).toBe(1);
expect(result.routines[0].name).toBe("Routine B");
});
it("filters by tags", async () => {
const result = (await sdk.trigger("mem::routine-list", {
tags: ["deploy"],
})) as { success: boolean; routines: Routine[] };
expect(result.success).toBe(true);
expect(result.routines.length).toBe(2);
const names = result.routines.map((r) => r.name);
expect(names).toContain("Routine A");
expect(names).toContain("Routine C");
});
it("filters by tags with multiple matches", async () => {
const result = (await sdk.trigger("mem::routine-list", {
tags: ["ci"],
})) as { success: boolean; routines: Routine[] };
expect(result.success).toBe(true);
expect(result.routines.length).toBe(2);
const names = result.routines.map((r) => r.name);
expect(names).toContain("Routine B");
expect(names).toContain("Routine C");
});
});
describe("mem::routine-run", () => {
let routineId: string;
beforeEach(async () => {
const result = (await sdk.trigger("mem::routine-create", {
name: "Test Pipeline",
steps: [
{ order: 0, title: "Build", description: "Build step", actionTemplate: { priority: 3 }, dependsOn: [] },
{ order: 1, title: "Test", description: "Test step", actionTemplate: { priority: 5 }, dependsOn: [0] },
{ order: 2, title: "Deploy", description: "Deploy step", actionTemplate: {}, dependsOn: [0, 1] },
],
})) as { success: boolean; routine: Routine };
routineId = result.routine.id;
});
it("creates actions for each step", async () => {
const result = (await sdk.trigger("mem::routine-run", {
routineId,
initiatedBy: "user-1",
})) as { success: boolean; run: RoutineRun; actionsCreated: number };
expect(result.success).toBe(true);
expect(result.actionsCreated).toBe(3);
expect(result.run.actionIds.length).toBe(3);
expect(result.run.status).toBe("running");
expect(result.run.initiatedBy).toBe("user-1");
const actions = await kv.list<Action>("mem:actions");
expect(actions.length).toBe(3);
const titles = actions.map((a) => a.title);
expect(titles).toContain("Build");
expect(titles).toContain("Test");
expect(titles).toContain("Deploy");
});
it("creates dependency edges between steps", async () => {
const result = (await sdk.trigger("mem::routine-run", {
routineId,
})) as { success: boolean; run: RoutineRun; actionsCreated: number };
expect(result.success).toBe(true);
const edges = await kv.list<{
id: string;
type: string;
sourceActionId: string;
targetActionId: string;
}>("mem:action-edges");
expect(edges.length).toBe(3);
expect(edges.every((e) => e.type === "requires")).toBe(true);
});
it("creates routine run tracking object", async () => {
const result = (await sdk.trigger("mem::routine-run", {
routineId,
initiatedBy: "agent-x",
})) as { success: boolean; run: RoutineRun };
expect(result.run.id).toMatch(/^run_/);
expect(result.run.routineId).toBe(routineId);
expect(result.run.status).toBe("running");
expect(result.run.startedAt).toBeDefined();
expect(result.run.actionIds.length).toBe(3);
expect(result.run.initiatedBy).toBe("agent-x");
const stored = await kv.get<RoutineRun>("mem:routine-runs", result.run.id);
expect(stored).not.toBeNull();
expect(stored!.routineId).toBe(routineId);
});
it("preserves priority 0 via nullish coalescing", async () => {
const createResult = (await sdk.trigger("mem::routine-create", {
name: "Zero Priority",
steps: [
{ order: 0, title: "Step Zero", description: "", actionTemplate: { priority: 0 }, dependsOn: [] },
],
})) as { success: boolean; routine: Routine };
const runResult = (await sdk.trigger("mem::routine-run", {
routineId: createResult.routine.id,
})) as { success: boolean; run: RoutineRun };
const actionId = runResult.run.actionIds[0];
const action = await kv.get<Action>("mem:actions", actionId);
expect(action!.priority).toBe(0);
});
it("tags actions with routine id", async () => {
const result = (await sdk.trigger("mem::routine-run", {
routineId,
})) as { success: boolean; run: RoutineRun };
for (const actionId of result.run.actionIds) {
const action = await kv.get<Action>("mem:actions", actionId);
expect(action!.tags).toContain(`routine:${routineId}`);
}
});
it("returns error when routineId is missing", async () => {
const result = (await sdk.trigger("mem::routine-run", {
routineId: "",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("routineId is required");
});
it("returns error when routine is not found", async () => {
const result = (await sdk.trigger("mem::routine-run", {
routineId: "rtn_nonexistent",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("routine not found");
});
});
describe("mem::routine-status", () => {
let runId: string;
let actionIds: string[];
beforeEach(async () => {
const createResult = (await sdk.trigger("mem::routine-create", {
name: "Status Test",
steps: [
{ order: 0, title: "Step A", description: "", actionTemplate: {}, dependsOn: [] },
{ order: 1, title: "Step B", description: "", actionTemplate: {}, dependsOn: [] },
{ order: 2, title: "Step C", description: "", actionTemplate: {}, dependsOn: [] },
],
})) as { success: boolean; routine: Routine };
const runResult = (await sdk.trigger("mem::routine-run", {
routineId: createResult.routine.id,
})) as { success: boolean; run: RoutineRun };
runId = runResult.run.id;
actionIds = runResult.run.actionIds;
});
it("reports running status when actions are in progress", async () => {
const result = (await sdk.trigger("mem::routine-status", {
runId,
})) as { success: boolean; run: RoutineRun; progress: { total: number; pending: number } };
expect(result.success).toBe(true);
expect(result.run.status).toBe("running");
expect(result.progress.total).toBe(3);
expect(result.progress.pending).toBe(3);
});
it("marks run completed when all actions are done", async () => {
for (const actionId of actionIds) {
const action = await kv.get<Action>("mem:actions", actionId);
action!.status = "done";
await kv.set("mem:actions", actionId, action);
}
const result = (await sdk.trigger("mem::routine-status", {
runId,
})) as { success: boolean; run: RoutineRun; progress: { done: number; total: number } };
expect(result.success).toBe(true);
expect(result.run.status).toBe("completed");
expect(result.run.completedAt).toBeDefined();
expect(result.progress.done).toBe(3);
});
it("marks run failed when any action is cancelled", async () => {
const action = await kv.get<Action>("mem:actions", actionIds[0]);
action!.status = "cancelled";
await kv.set("mem:actions", actionIds[0], action);
const result = (await sdk.trigger("mem::routine-status", {
runId,
})) as { success: boolean; run: RoutineRun };
expect(result.success).toBe(true);
expect(result.run.status).toBe("failed");
});
it("marks run failed when any action is cancelled (mixed statuses)", async () => {
const action = await kv.get<Action>("mem:actions", actionIds[1]);
(action as Action).status = "done";
await kv.set("mem:actions", actionIds[1], action);
const action2 = await kv.get<Action>("mem:actions", actionIds[2]);
(action2 as Action).status = "cancelled";
await kv.set("mem:actions", actionIds[2], action2);
const result = (await sdk.trigger("mem::routine-status", {
runId,
})) as { success: boolean; run: RoutineRun };
expect(result.success).toBe(true);
expect(result.run.status).toBe("failed");
});
it("returns error when runId is missing", async () => {
const result = (await sdk.trigger("mem::routine-status", {
runId: "",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("runId is required");
});
it("returns error when run is not found", async () => {
const result = (await sdk.trigger("mem::routine-status", {
runId: "run_nonexistent",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("run not found");
});
});
describe("mem::routine-freeze", () => {
it("freezes a routine", async () => {
const createResult = (await sdk.trigger("mem::routine-create", {
name: "Unfreeze Me",
steps: [{ title: "Step", description: "", actionTemplate: {}, dependsOn: [] }],
frozen: false,
})) as { success: boolean; routine: Routine };
expect(createResult.routine.frozen).toBe(false);
const result = (await sdk.trigger("mem::routine-freeze", {
routineId: createResult.routine.id,
})) as { success: boolean; routine: Routine };
expect(result.success).toBe(true);
expect(result.routine.frozen).toBe(true);
expect(result.routine.updatedAt).toBeDefined();
});
it("returns error when routineId is missing", async () => {
const result = (await sdk.trigger("mem::routine-freeze", {
routineId: "",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("routineId is required");
});
it("returns error when routine is not found", async () => {
const result = (await sdk.trigger("mem::routine-freeze", {
routineId: "rtn_nonexistent",
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toBe("routine not found");
});
});
});

Some files were not shown because too many files have changed in this diff Show More