fix(eve): scope forget, persist capture idempotency, and fail closed
Review findings: delete was not partitioned by scope.key, capture replay after restart could double-write, and Mem0 errors were swallowed as empty recall. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
1d83bb0835
commit
10d6717068
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
MemoryCompactionCompletedContext,
|
||||
MemoryToolsContext,
|
||||
MemoryTurnCompletedContext,
|
||||
MemoryTurnStartedContext,
|
||||
@@ -19,6 +20,15 @@ function completedContext(
|
||||
return memoryContext(input) as unknown as MemoryTurnCompletedContext;
|
||||
}
|
||||
|
||||
function compactionContext(
|
||||
input?: Parameters<typeof memoryContext>[0],
|
||||
): MemoryCompactionCompletedContext {
|
||||
return memoryContext({
|
||||
...input,
|
||||
turn: input?.turn === undefined ? null : input.turn,
|
||||
}) as unknown as MemoryCompactionCompletedContext;
|
||||
}
|
||||
|
||||
function toolsContext(
|
||||
input?: Parameters<typeof memoryContext>[0],
|
||||
): MemoryToolsContext {
|
||||
@@ -32,10 +42,20 @@ async function runTool(
|
||||
return tool.execute(input as never, {} as never);
|
||||
}
|
||||
|
||||
function requireCapture(
|
||||
provider: ReturnType<typeof mem0Provider>,
|
||||
): NonNullable<NonNullable<typeof provider.capture>["turn.completed"]> {
|
||||
const capture = provider.capture?.["turn.completed"];
|
||||
if (!capture) {
|
||||
throw new Error("expected turn.completed");
|
||||
}
|
||||
return capture;
|
||||
}
|
||||
|
||||
describe("mem0Provider", () => {
|
||||
it("recalls memories for the locked scope on turn start", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const provider = mem0Provider({ apiKey: "m0-test", store });
|
||||
const provider = mem0Provider({ store });
|
||||
const result = await provider.recall["turn.started"](
|
||||
startedContext({
|
||||
turnInput: [{ role: "user", content: "What do I drink?" }],
|
||||
@@ -43,31 +63,110 @@ describe("mem0Provider", () => {
|
||||
);
|
||||
|
||||
expect(store.searched).toEqual([
|
||||
{ query: "What do I drink?", userId: "scope_abc" },
|
||||
{
|
||||
query: "What do I drink?",
|
||||
userId: "scope_abc",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
messages: [{ id: "mem_1", content: "User likes tea" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fail the turn when search throws", async () => {
|
||||
it("forwards recall search options", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const provider = mem0Provider({
|
||||
store,
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
});
|
||||
await provider.recall["turn.started"](
|
||||
startedContext({
|
||||
turnInput: [{ role: "user", content: "tea" }],
|
||||
}),
|
||||
);
|
||||
expect(store.searched[0]).toMatchObject({
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips search when autoSearch is disabled", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const provider = mem0Provider({ store, autoSearch: { enabled: false } });
|
||||
await expect(
|
||||
provider.recall["turn.started"](startedContext()),
|
||||
).resolves.toBeNull();
|
||||
expect(store.searched).toEqual([]);
|
||||
});
|
||||
|
||||
it("recalls after compaction when turn is null", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const provider = mem0Provider({ store });
|
||||
const recallAfterCompaction = provider.recall["compaction.completed"];
|
||||
if (!recallAfterCompaction) {
|
||||
throw new Error("expected compaction.completed");
|
||||
}
|
||||
const result = await recallAfterCompaction(
|
||||
compactionContext({
|
||||
messages: [
|
||||
{ role: "user", content: "What do I drink?" },
|
||||
{ role: "assistant", content: "tea" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.searched).toEqual([
|
||||
{
|
||||
query: "What do I drink?",
|
||||
userId: "scope_abc",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
messages: [{ id: "mem_1", content: "User likes tea" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails the turn when search throws", async () => {
|
||||
const store = createFakeStore();
|
||||
store.search = async () => {
|
||||
throw new Error("mem0 down");
|
||||
};
|
||||
const provider = mem0Provider({ apiKey: "m0-test", store });
|
||||
const provider = mem0Provider({ store });
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
provider.recall["turn.started"](startedContext()),
|
||||
).resolves.toBeNull();
|
||||
try {
|
||||
await expect(
|
||||
provider.recall["turn.started"](startedContext()),
|
||||
).rejects.toThrow("mem0 down");
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
error.mockRestore();
|
||||
it("rethrows when recall is aborted", async () => {
|
||||
const store = createFakeStore();
|
||||
store.search = async () => {
|
||||
throw new Error("cancelled");
|
||||
};
|
||||
const provider = mem0Provider({ store });
|
||||
|
||||
await expect(
|
||||
provider.recall["turn.started"](startedContext({ aborted: true })),
|
||||
).rejects.toThrow("cancelled");
|
||||
});
|
||||
|
||||
it("captures a completed turn once per operation id", async () => {
|
||||
const store = createFakeStore();
|
||||
const provider = mem0Provider({ apiKey: "m0-test", store });
|
||||
const provider = mem0Provider({ store });
|
||||
const capture = provider.capture?.["turn.completed"];
|
||||
expect(capture).toBeTypeOf("function");
|
||||
|
||||
@@ -76,12 +175,42 @@ describe("mem0Provider", () => {
|
||||
await capture!(context);
|
||||
|
||||
expect(store.added).toHaveLength(1);
|
||||
expect(store.added[0]?.userId).toBe("scope_abc");
|
||||
expect(store.added[0]).toMatchObject({
|
||||
userId: "scope_abc",
|
||||
infer: true,
|
||||
metadata: {
|
||||
source: "eve",
|
||||
operation_id: "op_replay",
|
||||
session_id: "sess_1",
|
||||
slot: "mem0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not recapture after process restart when operation metadata exists", async () => {
|
||||
const store = createFakeStore();
|
||||
const first = requireCapture(mem0Provider({ store }));
|
||||
await first(completedContext({ operationId: "op_replay" }));
|
||||
|
||||
const restarted = requireCapture(mem0Provider({ store }));
|
||||
await restarted(completedContext({ operationId: "op_replay" }));
|
||||
|
||||
expect(store.added).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("surfaces capture store failures", async () => {
|
||||
const store = createFakeStore();
|
||||
store.add = async () => {
|
||||
throw new Error("write failed");
|
||||
};
|
||||
|
||||
await expect(
|
||||
requireCapture(mem0Provider({ store }))(completedContext()),
|
||||
).rejects.toThrow("write failed");
|
||||
});
|
||||
|
||||
it("omits capture when it is disabled", () => {
|
||||
const provider = mem0Provider({
|
||||
apiKey: "m0-test",
|
||||
store: createFakeStore(),
|
||||
capture: { enabled: false },
|
||||
});
|
||||
@@ -90,7 +219,13 @@ describe("mem0Provider", () => {
|
||||
|
||||
it("binds tools to the locked scope", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "likes tea" }]);
|
||||
const provider = mem0Provider({ apiKey: "m0-test", store });
|
||||
const provider = mem0Provider({
|
||||
store,
|
||||
topK: 3,
|
||||
threshold: 0.4,
|
||||
rerank: true,
|
||||
infer: false,
|
||||
});
|
||||
const tools = await provider.tools!(toolsContext());
|
||||
if (!tools) {
|
||||
throw new Error("expected tools");
|
||||
@@ -114,8 +249,52 @@ describe("mem0Provider", () => {
|
||||
});
|
||||
expect(remember).toEqual({ saved: true });
|
||||
expect(forget).toEqual({ deleted: true });
|
||||
expect(store.searched[0]?.userId).toBe("scope_abc");
|
||||
expect(store.added[0]?.userId).toBe("scope_abc");
|
||||
expect(store.searched[0]).toMatchObject({
|
||||
userId: "scope_abc",
|
||||
topK: 3,
|
||||
threshold: 0.4,
|
||||
rerank: true,
|
||||
});
|
||||
expect(store.added[0]).toMatchObject({
|
||||
userId: "scope_abc",
|
||||
infer: false,
|
||||
metadata: { source: "eve-tool" },
|
||||
messages: [{ role: "user", content: "Allergic to peanuts" }],
|
||||
});
|
||||
expect(store.deleted).toEqual(["mem_1"]);
|
||||
});
|
||||
|
||||
it("refuses to forget a memory from another scope", async () => {
|
||||
const store = createFakeStore([
|
||||
{ id: "mem_other", memory: "secret", userId: "scope_other" },
|
||||
]);
|
||||
const provider = mem0Provider({ store });
|
||||
const tools = await provider.tools!(toolsContext());
|
||||
const forgetTool = tools?.forget;
|
||||
if (!forgetTool) {
|
||||
throw new Error("expected forget tool");
|
||||
}
|
||||
|
||||
await expect(runTool(forgetTool, { id: "mem_other" })).rejects.toThrow(
|
||||
/Memory not found/,
|
||||
);
|
||||
expect(store.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces tool store failures", async () => {
|
||||
const store = createFakeStore();
|
||||
store.add = async () => {
|
||||
throw new Error("add failed");
|
||||
};
|
||||
const provider = mem0Provider({ store });
|
||||
const tools = await provider.tools!(toolsContext());
|
||||
const rememberTool = tools?.remember;
|
||||
if (!rememberTool) {
|
||||
throw new Error("expected remember tool");
|
||||
}
|
||||
|
||||
await expect(
|
||||
runTool(rememberTool, { text: "remember this" }),
|
||||
).rejects.toThrow("add failed");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user