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.
271 lines
7.4 KiB
TypeScript
271 lines
7.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { MemoryClient, add, search, get, getAll, del } = vi.hoisted(() => {
|
|
const add = vi.fn();
|
|
const search = vi.fn();
|
|
const get = vi.fn();
|
|
const getAll = vi.fn();
|
|
const del = vi.fn();
|
|
const MemoryClient = vi.fn(
|
|
class {
|
|
add = add;
|
|
search = search;
|
|
get = get;
|
|
getAll = getAll;
|
|
delete = del;
|
|
},
|
|
);
|
|
return { MemoryClient, add, search, get, getAll, del };
|
|
});
|
|
|
|
vi.mock("mem0ai", () => ({
|
|
default: MemoryClient,
|
|
}));
|
|
|
|
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({
|
|
id: "12",
|
|
memory: "tea",
|
|
});
|
|
expect(
|
|
parseSearchHit({ memory_id: "m1", data: { memory: "nested" } }),
|
|
).toEqual({ id: "m1", memory: "nested" });
|
|
expect(parseSearchHit({ id: "m2", text: "plain" })).toEqual({
|
|
id: "m2",
|
|
memory: "plain",
|
|
});
|
|
expect(parseSearchHit({ id: "", memory: "x" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseMemoryRecord", () => {
|
|
it("requires id and userId", () => {
|
|
expect(
|
|
parseMemoryRecord({
|
|
id: "m1",
|
|
user_id: "scope_abc",
|
|
metadata: { source: "eve" },
|
|
}),
|
|
).toEqual({
|
|
id: "m1",
|
|
userId: "scope_abc",
|
|
metadata: { source: "eve" },
|
|
});
|
|
expect(parseMemoryRecord({ id: "m1" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolveApiKey", () => {
|
|
it("trims string keys and rejects empty values", async () => {
|
|
await expect(resolveApiKey(" token ")).resolves.toBe("token");
|
|
await expect(resolveApiKey(" ")).rejects.toThrow(/empty/);
|
|
});
|
|
|
|
it("resolves async getters", async () => {
|
|
await expect(resolveApiKey(async () => " secret ")).resolves.toBe("secret");
|
|
await expect(resolveApiKey(async () => "")).rejects.toThrow(/empty/);
|
|
});
|
|
});
|
|
|
|
describe("createLazyStore", () => {
|
|
it("retries after a failed factory call", async () => {
|
|
let calls = 0;
|
|
const load = createLazyStore(async () => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
throw new Error("unavailable");
|
|
}
|
|
return { name: "store" } as never;
|
|
});
|
|
|
|
await expect(load()).rejects.toThrow("unavailable");
|
|
await expect(load()).resolves.toEqual({ name: "store" });
|
|
expect(calls).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("createMem0Store", () => {
|
|
beforeEach(() => {
|
|
add.mockReset();
|
|
search.mockReset();
|
|
get.mockReset();
|
|
getAll.mockReset();
|
|
del.mockReset();
|
|
MemoryClient.mockClear();
|
|
});
|
|
|
|
it("constructs the client with a resolved key and host", async () => {
|
|
const store = await createMem0Store({
|
|
apiKey: async () => " m0-live ",
|
|
host: "https://api.mem0.ai",
|
|
});
|
|
expect(store).toBeTruthy();
|
|
expect(MemoryClient).toHaveBeenCalledWith({
|
|
apiKey: "m0-live",
|
|
host: "https://api.mem0.ai",
|
|
});
|
|
});
|
|
|
|
it("forwards add, search, and metadata filters", async () => {
|
|
search.mockResolvedValue({
|
|
results: [{ id: "m1", memory: "likes tea" }, { id: 2, memory: "" }],
|
|
});
|
|
getAll.mockResolvedValue({
|
|
results: [{ id: "m9", userId: "scope_abc", metadata: { operation_id: "op_1" } }],
|
|
});
|
|
add.mockResolvedValue({ event_id: "evt_1", status: "SUCCEEDED" });
|
|
|
|
const store = await createMem0Store({
|
|
apiKey: "m0-test",
|
|
host: "https://api.mem0.ai",
|
|
});
|
|
|
|
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,
|
|
threshold: 0.5,
|
|
rerank: true,
|
|
});
|
|
const existing = await store.listByMetadata({
|
|
userId: "scope_abc",
|
|
metadata: { operation_id: "op_1" },
|
|
});
|
|
|
|
expect(add).toHaveBeenCalledWith(
|
|
[{ role: "user", content: "I like tea" }],
|
|
{
|
|
userId: "scope_abc",
|
|
infer: false,
|
|
metadata: { source: "eve", operation_id: "op_1" },
|
|
},
|
|
);
|
|
expect(search).toHaveBeenCalledWith("tea", {
|
|
filters: { user_id: "scope_abc" },
|
|
topK: 3,
|
|
threshold: 0.5,
|
|
rerank: true,
|
|
});
|
|
expect(found.results).toEqual([{ id: "m1", memory: "likes tea" }]);
|
|
expect(existing).toEqual([
|
|
{
|
|
id: "m9",
|
|
userId: "scope_abc",
|
|
metadata: { operation_id: "op_1" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
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",
|
|
host: "https://api.mem0.ai",
|
|
});
|
|
search.mockResolvedValueOnce([{ id: "m1", memory: "tea" }]);
|
|
await expect(
|
|
store.search("tea", {
|
|
userId: "scope_abc",
|
|
topK: 5,
|
|
threshold: 0.1,
|
|
rerank: false,
|
|
}),
|
|
).resolves.toEqual({ results: [{ id: "m1", memory: "tea" }] });
|
|
|
|
search.mockResolvedValueOnce({ memories: "nope" });
|
|
await expect(
|
|
store.search("tea", {
|
|
userId: "scope_abc",
|
|
topK: 5,
|
|
threshold: 0.1,
|
|
rerank: false,
|
|
}),
|
|
).rejects.toThrow(/unexpected response shape/);
|
|
});
|
|
|
|
it("deletes only when the memory belongs to the scope", async () => {
|
|
const store = await createMem0Store({
|
|
apiKey: "m0-test",
|
|
host: "https://api.mem0.ai",
|
|
});
|
|
|
|
get.mockResolvedValueOnce({ id: "m1", userId: "scope_abc" });
|
|
await store.delete("m1", "scope_abc");
|
|
expect(del).toHaveBeenCalledWith("m1");
|
|
|
|
get.mockResolvedValueOnce({ id: "m2", user_id: "other" });
|
|
await expect(store.delete("m2", "scope_abc")).rejects.toThrow(/Memory not found/);
|
|
expect(del).toHaveBeenCalledTimes(1);
|
|
|
|
get.mockRejectedValueOnce(new Error("Memory not found"));
|
|
await expect(store.delete("missing", "scope_abc")).rejects.toThrow(
|
|
/Memory not found/,
|
|
);
|
|
});
|
|
});
|