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
@@ -8,8 +8,9 @@ describe("captureCompletedTurn", () => {
|
||||
const captured = await captureCompletedTurn({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
infer: true,
|
||||
metadata: { source: "eve" },
|
||||
operationId: "op_1",
|
||||
infer: false,
|
||||
metadata: { source: "eve", operation_id: "op_1" },
|
||||
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
@@ -21,6 +22,8 @@ describe("captureCompletedTurn", () => {
|
||||
expect(store.added).toEqual([
|
||||
{
|
||||
userId: "scope_abc",
|
||||
infer: false,
|
||||
metadata: { source: "eve", operation_id: "op_1" },
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "Got it." },
|
||||
@@ -34,12 +37,38 @@ describe("captureCompletedTurn", () => {
|
||||
const captured = await captureCompletedTurn({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
operationId: "op_1",
|
||||
infer: true,
|
||||
metadata: {},
|
||||
metadata: { operation_id: "op_1" },
|
||||
turnInput: [],
|
||||
messages: [],
|
||||
});
|
||||
expect(captured).toBe(false);
|
||||
expect(store.added).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips add when the operation id was already captured", async () => {
|
||||
const store = createFakeStore([
|
||||
{
|
||||
id: "mem_existing",
|
||||
memory: "already stored",
|
||||
metadata: { operation_id: "op_replay" },
|
||||
},
|
||||
]);
|
||||
const captured = await captureCompletedTurn({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
operationId: "op_replay",
|
||||
infer: true,
|
||||
metadata: { operation_id: "op_replay" },
|
||||
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "Got it." },
|
||||
],
|
||||
});
|
||||
|
||||
expect(captured).toBe(false);
|
||||
expect(store.added).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
errorMessage,
|
||||
isNotFoundError,
|
||||
isSetupError,
|
||||
logProviderError,
|
||||
} from "../src/errors.js";
|
||||
|
||||
describe("errors", () => {
|
||||
it("classifies setup and not-found errors", () => {
|
||||
expect(isSetupError(new Error("Mem0 API key cannot be empty"))).toBe(true);
|
||||
expect(isSetupError(Object.assign(new Error("denied"), { name: "AuthenticationError" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSetupError(new Error("mem0 down"))).toBe(false);
|
||||
expect(isNotFoundError(new Error("Memory not found"))).toBe(true);
|
||||
expect(isNotFoundError(new Error("HTTP 404"))).toBe(true);
|
||||
expect(errorMessage("x")).toBe("Unknown Mem0 error");
|
||||
});
|
||||
|
||||
it("logs the original error object", () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const failure = Object.assign(new Error("HTTP 401"), {
|
||||
name: "AuthenticationError",
|
||||
});
|
||||
try {
|
||||
logProviderError("recall failed", failure, { sessionId: "sess_1" });
|
||||
expect(error).toHaveBeenCalledWith("[@mem0/eve] recall failed", {
|
||||
error: failure,
|
||||
name: "AuthenticationError",
|
||||
message: "HTTP 401",
|
||||
sessionId: "sess_1",
|
||||
});
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,86 @@
|
||||
import type { MemoryMessage, MemoryStore, SearchHit } from "../src/store.js";
|
||||
import type {
|
||||
AddMemoryInput,
|
||||
MemoryMessage,
|
||||
MemoryRecord,
|
||||
MemoryStore,
|
||||
SearchHit,
|
||||
SearchMemoryInput,
|
||||
} from "../src/store.js";
|
||||
|
||||
export function createFakeStore(hits: SearchHit[] = []): MemoryStore & {
|
||||
added: Array<{ messages: readonly MemoryMessage[]; userId: string }>;
|
||||
searched: Array<{ query: string; userId: string }>;
|
||||
export type FakeHit = SearchHit & {
|
||||
readonly userId?: string;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type FakeAdd = {
|
||||
messages: readonly MemoryMessage[];
|
||||
userId: string;
|
||||
infer: boolean;
|
||||
metadata: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type FakeSearch = SearchMemoryInput & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function createFakeStore(hits: FakeHit[] = []): MemoryStore & {
|
||||
added: FakeAdd[];
|
||||
searched: FakeSearch[];
|
||||
deleted: string[];
|
||||
records: MemoryRecord[];
|
||||
} {
|
||||
const added: Array<{ messages: readonly MemoryMessage[]; userId: string }> = [];
|
||||
const searched: Array<{ query: string; userId: string }> = [];
|
||||
const added: FakeAdd[] = [];
|
||||
const searched: FakeSearch[] = [];
|
||||
const deleted: string[] = [];
|
||||
const records: MemoryRecord[] = hits.map((hit) => ({
|
||||
id: hit.id,
|
||||
userId: hit.userId ?? "scope_abc",
|
||||
metadata: hit.metadata ?? {},
|
||||
}));
|
||||
|
||||
return {
|
||||
added,
|
||||
searched,
|
||||
deleted,
|
||||
records,
|
||||
async add(messages, input) {
|
||||
added.push({ messages, userId: input.userId });
|
||||
return { eventId: "evt_1" };
|
||||
added.push({
|
||||
messages,
|
||||
userId: input.userId,
|
||||
infer: input.infer,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
records.push({
|
||||
id: `mem_added_${added.length}`,
|
||||
userId: input.userId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
},
|
||||
async search(query, input) {
|
||||
searched.push({ query, userId: input.filters.user_id });
|
||||
return { results: hits };
|
||||
searched.push({ query, ...input });
|
||||
return {
|
||||
results: hits.filter((hit) => (hit.userId ?? "scope_abc") === input.userId),
|
||||
};
|
||||
},
|
||||
async delete(memoryId) {
|
||||
async get(memoryId) {
|
||||
return records.find((record) => record.id === memoryId) ?? null;
|
||||
},
|
||||
async listByMetadata({ userId, metadata }) {
|
||||
return records.filter((record) => {
|
||||
if (record.userId !== userId) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(metadata).every(
|
||||
([key, value]) => record.metadata[key] === value,
|
||||
);
|
||||
});
|
||||
},
|
||||
async delete(memoryId, userId) {
|
||||
const record = records.find((item) => item.id === memoryId);
|
||||
if (!record || record.userId !== userId) {
|
||||
throw new Error("Memory not found");
|
||||
}
|
||||
deleted.push(memoryId);
|
||||
return { message: "ok" };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -31,6 +88,14 @@ export function createFakeStore(hits: SearchHit[] = []): MemoryStore & {
|
||||
export function memoryContext(input?: {
|
||||
scopeKey?: string;
|
||||
operationId?: string;
|
||||
aborted?: boolean;
|
||||
turn?:
|
||||
| {
|
||||
id?: string;
|
||||
sequence?: number;
|
||||
input: Array<{ role: string; content: unknown }>;
|
||||
}
|
||||
| null;
|
||||
turnInput?: Array<{ role: string; content: unknown }>;
|
||||
messages?: Array<{ role: string; content: unknown }>;
|
||||
}): {
|
||||
@@ -46,10 +111,21 @@ export function memoryContext(input?: {
|
||||
id: string;
|
||||
sequence: number;
|
||||
input: Array<{ role: string; content: unknown }>;
|
||||
};
|
||||
} | null;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
if (input?.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
const turnInput =
|
||||
input?.turn === null
|
||||
? undefined
|
||||
: (input?.turn?.input ??
|
||||
input?.turnInput ?? [{ role: "user", content: "I am vegetarian" }]);
|
||||
|
||||
return {
|
||||
abortSignal: new AbortController().signal,
|
||||
abortSignal: controller.signal,
|
||||
operationId: input?.operationId ?? "op_1",
|
||||
session: { id: "sess_1" },
|
||||
memory: {
|
||||
@@ -64,10 +140,13 @@ export function memoryContext(input?: {
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "I will remember that." },
|
||||
],
|
||||
turn: {
|
||||
id: "turn_1",
|
||||
sequence: 1,
|
||||
input: input?.turnInput ?? [{ role: "user", content: "I am vegetarian" }],
|
||||
},
|
||||
turn:
|
||||
input?.turn === null
|
||||
? null
|
||||
: {
|
||||
id: input?.turn?.id ?? "turn_1",
|
||||
sequence: input?.turn?.sequence ?? 1,
|
||||
input: turnInput ?? [{ role: "user", content: "I am vegetarian" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,4 +29,19 @@ describe("createIdempotencyGate", () => {
|
||||
await once("op_1", work);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("evicts the oldest id after the cap", async () => {
|
||||
const once = createIdempotencyGate(2);
|
||||
let calls = 0;
|
||||
const work = async () => {
|
||||
calls += 1;
|
||||
};
|
||||
|
||||
await once("op_1", work);
|
||||
await once("op_2", work);
|
||||
await once("op_3", work);
|
||||
await once("op_1", work);
|
||||
|
||||
expect(calls).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ describe("extractText", () => {
|
||||
).toBe("I like\ntea");
|
||||
});
|
||||
|
||||
it("reads a single text object", () => {
|
||||
expect(extractText({ type: "text", text: " solo " })).toBe("solo");
|
||||
});
|
||||
|
||||
it("ignores non-text parts", () => {
|
||||
expect(extractText([{ type: "image", url: "x" }])).toBe("");
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import { createFakeStore } from "./helpers.js";
|
||||
|
||||
describe("resolveOptions", () => {
|
||||
it("applies defaults", () => {
|
||||
expect(resolveOptions({ apiKey: "m0-test" })).toMatchObject({
|
||||
expect(resolveOptions({ apiKey: "m0-test" })).toEqual({
|
||||
apiKey: "m0-test",
|
||||
host: "https://api.mem0.ai",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
@@ -12,6 +13,7 @@ describe("resolveOptions", () => {
|
||||
infer: true,
|
||||
autoSearch: { enabled: true },
|
||||
capture: { enabled: true },
|
||||
metadata: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +21,56 @@ describe("resolveOptions", () => {
|
||||
expect(() => resolveOptions({ apiKey: " " })).toThrow(/empty/);
|
||||
});
|
||||
|
||||
it("requires an api key when no store is provided", () => {
|
||||
expect(() => resolveOptions({} as never)).toThrow(/apiKey is required/);
|
||||
});
|
||||
|
||||
it("allows a store without an api key", () => {
|
||||
const store = createFakeStore();
|
||||
expect(resolveOptions({ store }).store).toBe(store);
|
||||
});
|
||||
|
||||
it("does not invoke a function api key at resolve time", () => {
|
||||
let calls = 0;
|
||||
const apiKey = () => {
|
||||
calls += 1;
|
||||
return "";
|
||||
};
|
||||
expect(() => resolveOptions({ apiKey })).not.toThrow();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an invalid topK", () => {
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", topK: 0 })).toThrow(/topK/);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", topK: 101 })).toThrow(/topK/);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", topK: 1.5 })).toThrow(/topK/);
|
||||
});
|
||||
|
||||
it("rejects an invalid threshold", () => {
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", threshold: -0.1 })).toThrow(
|
||||
/threshold/,
|
||||
);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", threshold: 1.1 })).toThrow(
|
||||
/threshold/,
|
||||
);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", threshold: Number.NaN })).toThrow(
|
||||
/threshold/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-http host", () => {
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", host: "localhost" })).toThrow(
|
||||
/host/,
|
||||
);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", host: "ftp://mem0.ai" })).toThrow(
|
||||
/host/,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a blank host as the default", () => {
|
||||
expect(resolveOptions({ apiKey: "m0-test", host: " " }).host).toBe(
|
||||
"https://api.mem0.ai",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an injected store", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,12 @@ import { formatRecallMessages, recallMemories } from "../src/recall.js";
|
||||
import { createFakeStore } from "./helpers.js";
|
||||
|
||||
describe("formatRecallMessages", () => {
|
||||
it("drops empty memories", () => {
|
||||
it("drops empty memories and empty ids", () => {
|
||||
expect(
|
||||
formatRecallMessages([
|
||||
{ id: "1", memory: " likes tea " },
|
||||
{ id: "2", memory: " " },
|
||||
{ id: " ", memory: "kept text" },
|
||||
]),
|
||||
).toEqual([{ id: "1", content: "likes tea" }]);
|
||||
});
|
||||
@@ -20,13 +21,19 @@ describe("recallMemories", () => {
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
query: "What does the user drink?",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
});
|
||||
|
||||
expect(store.searched).toEqual([
|
||||
{ query: "What does the user drink?", userId: "scope_abc" },
|
||||
{
|
||||
query: "What does the user drink?",
|
||||
userId: "scope_abc",
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
messages: [{ id: "mem_1", content: "User likes tea" }],
|
||||
@@ -47,4 +54,18 @@ describe("recallMemories", () => {
|
||||
).resolves.toBeNull();
|
||||
expect(store.searched).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns null when search has no usable hits", async () => {
|
||||
const store = createFakeStore([{ id: " ", memory: " " }]);
|
||||
await expect(
|
||||
recallMemories({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
query: "tea",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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,
|
||||
parseMemoryRecord,
|
||||
parseSearchHit,
|
||||
resolveApiKey,
|
||||
} from "../src/store.js";
|
||||
|
||||
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({ eventId: "evt_1" });
|
||||
|
||||
const store = await createMem0Store({
|
||||
apiKey: "m0-test",
|
||||
host: "https://api.mem0.ai",
|
||||
});
|
||||
|
||||
await store.add([{ role: "user", content: "I like tea" }], {
|
||||
userId: "scope_abc",
|
||||
infer: false,
|
||||
metadata: { source: "eve", operation_id: "op_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("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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user