chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+2
@@ -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."}]}}
|
||||
Vendored
+4
@@ -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}]}}
|
||||
Vendored
+4
@@ -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."}]}}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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*\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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\}/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user