fix(eve): address review — async-write status, reasoning filter, turn-bounded capture, best-effort dedup

Review by @kartik-mem0. Root cause across all four: Mem0's hosted add with
infer:true is async and returns a PENDING event before the memory is visible.

- store.ts/tools.ts: add() returns AddResult{status,eventId} via parseAddResult;
  the v3 add path returns {status:"PENDING", event_id}, so remember reports
  {status:"queued", eventId} for async writes and {status:"saved"} only when
  resolved. FAILED/unknown status maps to completed, not queued.
- messages.ts: drop typed non-text parts (reasoning no longer leaks into memory);
  bound the captured assistant reply to the current turn and collect all of its
  text segments (text->tool->text) instead of a stale prior answer.
- capture.ts + README + eve.mdx: document operationId dedup as best-effort
  (lookup+add is not atomic; pending/concurrent windows can double-write);
  removed the "restart replays cannot write twice" claim.

Tests: 68 pass (+15). Added reasoning, tool-only/cross-turn, multi-segment,
pending/concurrent capture replay, and PENDING/FAILED add-result cases.
Typecheck + build clean.
This commit is contained in:
Himanshu-Sangshetti
2026-09-12 00:36:29 +09:00
parent 10d6717068
commit 75e9efa261
11 changed files with 327 additions and 22 deletions
+67
View File
@@ -47,6 +47,73 @@ describe("captureCompletedTurn", () => {
expect(store.added).toEqual([]);
});
it("dedupes a replay once the prior write is visible", async () => {
const store = createFakeStore();
const args = {
store,
scopeKey: "scope_abc",
operationId: "op_dedup",
infer: true,
metadata: { source: "eve", operation_id: "op_dedup" },
turnInput: [{ role: "user", content: "I am vegetarian" }],
messages: [
{ role: "user", content: "I am vegetarian" },
{ role: "assistant", content: "Got it." },
],
};
// First write is immediately visible (completed), so the replay is deduped.
expect(await captureCompletedTurn(args)).toBe(true);
expect(await captureCompletedTurn(args)).toBe(false);
expect(store.added).toHaveLength(1);
});
it("cannot dedupe while the prior write is still pending (best-effort)", async () => {
// Documents the known limitation: with infer:true the first write is a
// PENDING event whose memory is not yet visible, so the metadata lookup
// finds nothing and a restart replay writes the same operation again.
const store = createFakeStore([], { pendingWrites: true });
const args = {
store,
scopeKey: "scope_abc",
operationId: "op_pending",
infer: true,
metadata: { source: "eve", operation_id: "op_pending" },
turnInput: [{ role: "user", content: "I am vegetarian" }],
messages: [
{ role: "user", content: "I am vegetarian" },
{ role: "assistant", content: "Got it." },
],
};
expect(await captureCompletedTurn(args)).toBe(true);
expect(await captureCompletedTurn(args)).toBe(true);
expect(store.added).toHaveLength(2);
});
it("does not dedupe concurrent replays that both pass the lookup (best-effort)", async () => {
// Two workers on separate provider instances both clear the metadata check
// before either write lands, so both capture the same operation.
const store = createFakeStore([], { pendingWrites: true });
const args = {
store,
scopeKey: "scope_abc",
operationId: "op_concurrent",
infer: true,
metadata: { source: "eve", operation_id: "op_concurrent" },
turnInput: [{ role: "user", content: "I am vegetarian" }],
messages: [
{ role: "user", content: "I am vegetarian" },
{ role: "assistant", content: "Got it." },
],
};
const [first, second] = await Promise.all([
captureCompletedTurn(args),
captureCompletedTurn(args),
]);
expect(first).toBe(true);
expect(second).toBe(true);
expect(store.added).toHaveLength(2);
});
it("skips add when the operation id was already captured", async () => {
const store = createFakeStore([
{
+11 -1
View File
@@ -23,7 +23,10 @@ export type FakeSearch = SearchMemoryInput & {
query: string;
};
export function createFakeStore(hits: FakeHit[] = []): MemoryStore & {
export function createFakeStore(
hits: FakeHit[] = [],
options: { pendingWrites?: boolean } = {},
): MemoryStore & {
added: FakeAdd[];
searched: FakeSearch[];
deleted: string[];
@@ -50,11 +53,18 @@ export function createFakeStore(hits: FakeHit[] = []): MemoryStore & {
infer: input.infer,
metadata: input.metadata,
});
// Model Mem0's async extraction: a pending write is accepted but its
// memory is not yet visible to listByMetadata/get, so it cannot dedupe a
// replay. A resolved write becomes immediately visible.
if (options.pendingWrites) {
return { status: "queued" as const, eventId: `evt_${added.length}` };
}
records.push({
id: `mem_added_${added.length}`,
userId: input.userId,
metadata: input.metadata,
});
return { status: "completed" as const };
},
async search(query, input) {
searched.push({ query, ...input });
+63
View File
@@ -27,6 +27,19 @@ describe("extractText", () => {
it("ignores non-text parts", () => {
expect(extractText([{ type: "image", url: "x" }])).toBe("");
});
it("ignores reasoning parts and keeps only the final text", () => {
expect(
extractText([
{ type: "reasoning", text: "speculation" },
{ type: "text", text: "Noted." },
]),
).toBe("Noted.");
});
it("still reads an untyped text object", () => {
expect(extractText({ text: " bare " })).toBe("bare");
});
});
describe("lastUserText", () => {
@@ -87,4 +100,54 @@ describe("completedTurnMessages", () => {
}),
).toEqual([]);
});
it("does not attach a previous turn's answer to a tool-only turn", () => {
// Older Q&A, then a new user message whose only assistant output is a
// tool call (no text). The stale "older reply" must not be captured.
expect(
completedTurnMessages({
turnInput: [{ role: "user", content: "new question" }],
messages: [
{ role: "user", content: "older question" },
{ role: "assistant", content: "older reply" },
{ role: "user", content: "new question" },
{ role: "assistant", content: [{ type: "tool-call", id: "t1" }] },
],
}),
).toEqual([{ role: "user", content: "new question" }]);
});
it("captures all assistant text in a text/tool-call/text turn", () => {
expect(
completedTurnMessages({
turnInput: [{ role: "user", content: "Q" }],
messages: [
{ role: "user", content: "Q" },
{ role: "assistant", content: "part A" },
{ role: "assistant", content: [{ type: "tool-call", id: "t1" }] },
{ role: "assistant", content: "part B" },
],
}),
).toEqual([
{ role: "user", content: "Q" },
{ role: "assistant", content: "part A\npart B" },
]);
});
it("captures this turn's assistant reply, not an earlier one", () => {
expect(
completedTurnMessages({
turnInput: [{ role: "user", content: "new question" }],
messages: [
{ role: "user", content: "older question" },
{ role: "assistant", content: "older reply" },
{ role: "user", content: "new question" },
{ role: "assistant", content: "new reply" },
],
}),
).toEqual([
{ role: "user", content: "new question" },
{ role: "assistant", content: "new reply" },
]);
});
});
+13 -1
View File
@@ -247,7 +247,7 @@ describe("mem0Provider", () => {
expect(search).toEqual({
memories: [{ id: "mem_1", memory: "likes tea" }],
});
expect(remember).toEqual({ saved: true });
expect(remember).toEqual({ status: "saved" });
expect(forget).toEqual({ deleted: true });
expect(store.searched[0]).toMatchObject({
userId: "scope_abc",
@@ -264,6 +264,18 @@ describe("mem0Provider", () => {
expect(store.deleted).toEqual(["mem_1"]);
});
it("reports remember as queued when the write is still pending", async () => {
const store = createFakeStore([], { pendingWrites: true });
const provider = mem0Provider({ store, infer: true });
const tools = await provider.tools!(toolsContext());
const rememberTool = tools?.remember;
if (!rememberTool) {
throw new Error("expected remember tool");
}
const remember = await runTool(rememberTool, { text: "Allergic to peanuts" });
expect(remember).toEqual({ status: "queued", eventId: "evt_1" });
});
it("refuses to forget a memory from another scope", async () => {
const store = createFakeStore([
{ id: "mem_other", memory: "secret", userId: "scope_other" },
+55 -2
View File
@@ -25,11 +25,48 @@ vi.mock("mem0ai", () => ({
import {
createLazyStore,
createMem0Store,
parseAddResult,
parseMemoryRecord,
parseSearchHit,
resolveApiKey,
} from "../src/store.js";
describe("parseAddResult", () => {
it("reports a pending event as queued", () => {
expect(parseAddResult({ event_id: "evt_1", status: "PENDING" })).toEqual({
status: "queued",
eventId: "evt_1",
});
});
it("reports a succeeded event as completed", () => {
expect(parseAddResult({ event_id: "evt_1", status: "SUCCEEDED" })).toEqual({
status: "completed",
eventId: "evt_1",
});
});
it("treats a memory-results array as completed", () => {
expect(parseAddResult([{ id: "m1", memory: "tea" }])).toEqual({
status: "completed",
});
});
it("does not report a failed event as queued", () => {
expect(parseAddResult({ event_id: "evt_1", status: "FAILED" })).toEqual({
status: "completed",
eventId: "evt_1",
});
});
it("does not report an event with no status as queued", () => {
expect(parseAddResult({ event_id: "evt_1" })).toEqual({
status: "completed",
eventId: "evt_1",
});
});
});
describe("parseSearchHit", () => {
it("accepts id, memory_id, nested text, and numeric ids", () => {
expect(parseSearchHit({ id: 12, memory: "tea" })).toEqual({
@@ -122,18 +159,19 @@ describe("createMem0Store", () => {
getAll.mockResolvedValue({
results: [{ id: "m9", userId: "scope_abc", metadata: { operation_id: "op_1" } }],
});
add.mockResolvedValue({ eventId: "evt_1" });
add.mockResolvedValue({ event_id: "evt_1", status: "SUCCEEDED" });
const store = await createMem0Store({
apiKey: "m0-test",
host: "https://api.mem0.ai",
});
await store.add([{ role: "user", content: "I like tea" }], {
const addResult = await store.add([{ role: "user", content: "I like tea" }], {
userId: "scope_abc",
infer: false,
metadata: { source: "eve", operation_id: "op_1" },
});
expect(addResult).toEqual({ status: "completed", eventId: "evt_1" });
const found = await store.search("tea", {
userId: "scope_abc",
topK: 3,
@@ -169,6 +207,21 @@ describe("createMem0Store", () => {
]);
});
it("surfaces a pending platform write as queued", async () => {
const store = await createMem0Store({
apiKey: "m0-test",
host: "https://api.mem0.ai",
});
add.mockResolvedValueOnce({ event_id: "evt_9", status: "PENDING" });
await expect(
store.add([{ role: "user", content: "I like tea" }], {
userId: "scope_abc",
infer: true,
metadata: { source: "eve" },
}),
).resolves.toEqual({ status: "queued", eventId: "evt_9" });
});
it("accepts a bare search array and rejects unknown envelopes", async () => {
const store = await createMem0Store({
apiKey: "m0-test",