/** * OSS Memory unit tests — get, update, delete, deleteAll, getAll, search, history. * Content-based LLM mock. Tests verify real behavior, not mock echoes. */ /// import { Memory } from "../src/memory"; import type { MemoryItem, SearchResult } from "../src/types"; import { logger } from "../src/utils/logger"; jest.setTimeout(30000); // Mock Google modules to prevent @google/genai crash in CI jest.mock("../src/embeddings/google", () => ({ GoogleEmbedder: jest.fn(), })); jest.mock("../src/llms/google", () => ({ GoogleLLM: jest.fn(), })); jest.mock("../src/llms/openai", () => ({ OpenAILLM: jest.fn().mockImplementation(() => ({ generateResponse: jest .fn() .mockImplementation( (messages: Array<{ role: string; content: string }>) => { // V3 pipeline: single LLM call with additive extraction prompt. // Extract the user input from the prompt to produce unique memories. const userMsg = messages.find((m) => m.role === "user"); const content = userMsg?.content ?? ""; // Pull the text between "## New Messages" and the next "##" const newMsgMatch = content.match( /## New Messages\n([\s\S]*?)(?=\n##|$)/, ); const extracted = newMsgMatch ? newMsgMatch[1].trim() : "stored fact"; return JSON.stringify({ memory: [ { id: "0", text: extracted, attributed_to: "user", }, ], }); }, ), })), })); const mockEmbedding = new Array(1536).fill(0.1); jest.mock("../src/embeddings/openai", () => ({ OpenAIEmbedder: jest.fn().mockImplementation(() => ({ embed: jest.fn().mockResolvedValue(mockEmbedding), embedBatch: jest .fn() .mockImplementation((texts: string[]) => Promise.resolve(texts.map(() => mockEmbedding)), ), embeddingDims: 1536, })), })); function createMemory(): Memory { return new Memory({ version: "v1.1", embedder: { provider: "openai", config: { apiKey: "test-key", model: "text-embedding-3-small" }, }, vectorStore: { provider: "memory", config: { collectionName: `test-crud-${Date.now()}-${Math.random()}`, dimension: 1536, dbPath: ":memory:", }, }, llm: { provider: "openai", config: { apiKey: "test-key", model: "gpt-5-mini" }, }, historyDbPath: ":memory:", }); } // ─── get() ─────────────────────────────────────────────── describe("Memory - get()", () => { let memory: Memory; const userId = `get_test_${Date.now()}`; beforeAll(async () => { memory = createMemory(); }); afterAll(async () => { await memory.reset(); }); test("returns the memory matching the ID from add()", async () => { const addResult: SearchResult = await memory.add("I love AI", { userId, }); const id = addResult.results[0].id; const item: MemoryItem | null = await memory.get(id); expect(item).not.toBeNull(); expect(item!.id).toBe(id); }); test("returns a string for the memory field", async () => { const addResult: SearchResult = await memory.add("Testing get", { userId, }); const item: MemoryItem | null = await memory.get(addResult.results[0].id); expect(typeof item!.memory).toBe("string"); }); test("returns null for non-existent ID", async () => { const item = await memory.get("nonexistent-uuid-12345"); expect(item).toBeNull(); }); test("returns hash and createdAt on stored memory", async () => { const addResult: SearchResult = await memory.add("Hash test", { userId, }); const item: MemoryItem | null = await memory.get(addResult.results[0].id); expect(typeof item!.hash).toBe("string"); expect(item!.createdAt).toBeDefined(); expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date"); }); test("does not duplicate the entity id inside metadata", async () => { const addResult: SearchResult = await memory.add("Metadata leak test", { userId, }); const item: any = await memory.get(addResult.results[0].id); expect(item.user_id).toBe(userId); expect(item.metadata).not.toHaveProperty("user_id"); expect(item.metadata).not.toHaveProperty("userId"); }); }); // ─── update() ──────────────────────────────────────────── describe("Memory - update()", () => { let memory: Memory; const userId = `update_test_${Date.now()}`; beforeAll(async () => { memory = createMemory(); }); afterAll(async () => { await memory.reset(); }); // Use infer: false for update tests — bypasses LLM, gives us a stable ID test("returns success message", async () => { const addResult: SearchResult = await memory.add("Original", { userId, infer: false, }); const id = addResult.results[0].id; const result = await memory.update(id, { text: "Updated" }); expect(result.message).toBe("Memory updated successfully!"); }); test("persists the updated text", async () => { const addResult: SearchResult = await memory.add("Before update", { userId, infer: false, }); const id = addResult.results[0].id; await memory.update(id, { text: "After update" }); const item: MemoryItem | null = await memory.get(id); expect(item!.memory).toBe("After update"); }); test("preserves createdAt and sets updatedAt", async () => { const addResult: SearchResult = await memory.add("Timestamp test", { userId, infer: false, }); const id = addResult.results[0].id; const before: MemoryItem | null = await memory.get(id); const originalCreatedAt = before!.createdAt; await memory.update(id, { text: "New text" }); const after: MemoryItem | null = await memory.get(id); expect(after!.createdAt).toBe(originalCreatedAt); expect(after!.updatedAt).toBeDefined(); }); test("updates the hash", async () => { const addResult: SearchResult = await memory.add("Hash change", { userId, infer: false, }); const id = addResult.results[0].id; const before: MemoryItem | null = await memory.get(id); await memory.update(id, { text: "Completely different text" }); const after: MemoryItem | null = await memory.get(id); expect(after!.hash).not.toBe(before!.hash); }); test("preserves custom metadata fields after update", async () => { const addResult: SearchResult = await memory.add("Original text", { userId, metadata: { category: "hobbies", priority: "high" }, infer: false, }); const id = addResult.results[0].id; await memory.update(id, { text: "Updated text" }); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe("Updated text"); expect(after!.metadata).toEqual( expect.objectContaining({ category: "hobbies", priority: "high" }), ); }); // Regression: metadata passed to update() must never overwrite a memory's // identity fields (issues #6277 / #6278). test("metadata cannot overwrite identity fields (tenant isolation)", async () => { const tenantA = `tenant_a_${Date.now()}`; const tenantB = `tenant_b_${Date.now()}`; const addResult: SearchResult = await memory.add("Tenant A secret", { userId: tenantA, infer: false, }); const id = addResult.results[0].id; // Caller metadata attempts to re-scope the memory to tenant B. await memory.update(id, { text: "Updated text", metadata: { user_id: tenantB, agent_id: "attacker", run_id: "attacker" }, }); // The memory must remain in tenant A's scope... const aList: SearchResult = await memory.getAll({ filters: { user_id: tenantA }, }); expect(aList.results.map((m) => m.id)).toContain(id); // ...and must never leak into tenant B's scope. const bList: SearchResult = await memory.getAll({ filters: { user_id: tenantB }, }); expect(bList.results.map((m) => m.id)).not.toContain(id); }); // A memory created with only some identity fields (e.g. run_id only) must not // let update() metadata inject a brand-new identity key. The default vector // store promotes camelCase aliases to snake_case on read, so both casings must be covered. test("metadata cannot inject an identity field on update (snake_case or camelCase)", async () => { const runId = `run_only_${Date.now()}`; const attacker = `attacker_${Date.now()}`; const addResult: SearchResult = await memory.add("Run-scoped secret", { runId, infer: false, }); const id = addResult.results[0].id; // Memory has no user_id/agent_id/actor_id — metadata tries to inject them, // in both snake_case and camelCase. await memory.update(id, { text: "Updated text", metadata: { user_id: attacker, agent_id: attacker, actor_id: attacker, userId: attacker, agentId: attacker, }, }); // Still reachable under its original run_id scope... const runList: SearchResult = await memory.getAll({ filters: { run_id: runId }, }); expect(runList.results.map((m) => m.id)).toContain(id); // ...and the injected identity scopes must not resolve to it. const userList: SearchResult = await memory.getAll({ filters: { user_id: attacker }, }); expect(userList.results.map((m) => m.id)).not.toContain(id); const agentList: SearchResult = await memory.getAll({ filters: { agent_id: attacker }, }); expect(agentList.results.map((m) => m.id)).not.toContain(id); }); }); // ─── update() options: text / data / metadata / expirationDate ─── describe("Memory - update() options", () => { let memory: Memory; let warnSpy: jest.SpyInstance; const userId = `update_options_${Date.now()}`; beforeAll(async () => { memory = createMemory(); }); beforeEach(() => { warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); afterAll(async () => { await memory.reset(); }); async function seed(text: string): Promise { const addResult: SearchResult = await memory.add(text, { userId, infer: false, }); return addResult.results[0].id; } test("accepts an options object with text, without warning", async () => { const id = await seed("Options before"); await memory.update(id, { text: "Options after" }); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe("Options after"); expect(warnSpy).not.toHaveBeenCalled(); }); test("accepts a bare text string, as on main", async () => { const id = await seed("Bare before"); await memory.update(id, "Bare after"); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe("Bare after"); }); test("accepts the deprecated data alias and warns", async () => { const id = await seed("Alias before"); await memory.update(id, { data: "Alias after" }); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe("Alias after"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("deprecated")); }); // An empty `text` is content, so it must beat `data`. Guards against `||=`, // which would treat "" as absent and store the `data` value instead. test("text wins over data, even when text is empty", async () => { const id = await seed("Both before"); await memory.update(id, { text: "", data: "From data" }); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe(""); }); // Same trap on the guard: "" must not read as "no field provided". test("an empty bare string is content, not a missing argument", async () => { const id = await seed("Empty bare"); await memory.update(id, ""); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe(""); }); test("updates metadata without touching the stored text", async () => { const id = await seed("Metadata only"); await memory.update(id, { metadata: { category: "solo" } }); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe("Metadata only"); expect(after!.metadata).toEqual( expect.objectContaining({ category: "solo" }), ); }); test("sets an expiration date without touching the stored text", async () => { const id = await seed("Expiry only"); await memory.update(id, { expirationDate: "2099-12-31" }); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe("Expiry only"); expect(after!.metadata).toEqual( expect.objectContaining({ expiration_date: "2099-12-31" }), ); }); // Python raises on `data=None` / `metadata=None`, so loose `== null` is the // right check for both. `{}` is a real value and must not raise. test.each([{}, { data: null }, { metadata: null }])( "throws when %p provides nothing updatable", async (options) => { const id = await seed("Nothing to update"); await expect(memory.update(id, options as any)).rejects.toThrow( "At least one of text, metadata, or expirationDate must be provided.", ); expect(warnSpy).not.toHaveBeenCalled(); }, ); test("accepts empty metadata as an updatable field", async () => { const id = await seed("Empty metadata"); await expect(memory.update(id, { metadata: {} })).resolves.toEqual({ message: "Memory updated successfully!", }); }); }); // ─── expiration date parsing ───────────────────────────── describe("Memory - expiration date parsing", () => { let memory: Memory; const userId = `expiry_parse_test_${Date.now()}`; beforeAll(async () => { memory = createMemory(); }); afterAll(async () => { await memory.reset(); }); // `new Date(...)` accepts all of these; Python's date.fromisoformat rejects // them. The first two die to the format regex, the last two only to the // UTC component round-trip: they match YYYY-MM-DD but are not real days. const rejected = [ "12/31/2099", // also shifts a day west of UTC "2099-12-31T23:00:00", "2099-02-30", // rolls over to 2099-03-02 "2100-02-29", // 2100 is not a leap year ]; test.each(rejected)("add() rejects %p", async (value) => { await expect( memory.add("Bad expiry", { userId, infer: false, expirationDate: value }), ).rejects.toThrow("YYYY-MM-DD"); }); // add() and update() share normalizeExpirationDate(); this checks the wiring. test("update() rejects a malformed expiration date", async () => { const addResult: SearchResult = await memory.add("Good", { userId, infer: false, }); await expect( memory.update(addResult.results[0].id, { expirationDate: "12/31/2099" }), ).rejects.toThrow("YYYY-MM-DD"); }); // "2096-02-29" is a real leap day: it must survive the component check. test.each(["2099-12-31", "2096-02-29"])( "stores %p verbatim", async (value) => { const addResult: SearchResult = await memory.add("Good expiry", { userId, infer: false, expirationDate: value, }); const item: MemoryItem | null = await memory.get(addResult.results[0].id); expect(item!.metadata!.expiration_date).toBe(value); }, ); }); // ─── expired memories are hidden on read ───────────────── describe("Memory - expired memories", () => { let memory: Memory; const userId = `expired_test_${Date.now()}`; const today = new Date().toISOString().slice(0, 10); beforeAll(async () => { memory = createMemory(); }); afterAll(async () => { await memory.reset(); }); async function seed(text: string, expirationDate?: string): Promise { const addResult: SearchResult = await memory.add(text, { userId, infer: false, ...(expirationDate ? { expirationDate } : {}), }); return addResult.results[0].id; } async function seedLiveAndDead(scopedUser: string): Promise { await memory.add("Live memory", { userId: scopedUser, infer: false }); await memory.add("Dead memory", { userId: scopedUser, infer: false, expirationDate: "2020-01-01", }); } test("getAll() hides expired memories unless showExpired", async () => { const scopedUser = `${userId}_getall`; await seedLiveAndDead(scopedUser); const filters = { user_id: scopedUser }; const hidden: SearchResult = await memory.getAll({ filters }); expect(hidden.results.map((r) => r.memory)).toEqual(["Live memory"]); const shown: SearchResult = await memory.getAll({ filters, showExpired: true, }); expect(shown.results.map((r) => r.memory).sort()).toEqual([ "Dead memory", "Live memory", ]); }); test("search() hides expired memories unless showExpired", async () => { const scopedUser = `${userId}_search`; await seedLiveAndDead(scopedUser); const filters = { user_id: scopedUser }; const hidden: SearchResult = await memory.search("memory", { filters }); expect(hidden.results.map((r) => r.memory)).toEqual(["Live memory"]); const shown: SearchResult = await memory.search("memory", { filters, showExpired: true, }); expect(shown.results.map((r) => r.memory).sort()).toEqual([ "Dead memory", "Live memory", ]); }); test("a memory expiring today is not yet expired", async () => { const scopedUser = `${userId}_today`; await memory.add("Expires today", { userId: scopedUser, infer: false, expirationDate: today, }); const result: SearchResult = await memory.getAll({ filters: { user_id: scopedUser }, }); expect(result.results).toHaveLength(1); }); test("get() still returns an expired memory by ID", async () => { const id = await seed("Fetch by id", "2020-01-01"); const item: MemoryItem | null = await memory.get(id); expect(item).not.toBeNull(); expect(item!.memory).toBe("Fetch by id"); }); test("clearing the expiration date makes a memory visible again", async () => { const scopedUser = `${userId}_revive`; const addResult: SearchResult = await memory.add("Revived", { userId: scopedUser, infer: false, expirationDate: "2020-01-01", }); const id = addResult.results[0].id; const before: SearchResult = await memory.getAll({ filters: { user_id: scopedUser }, }); expect(before.results).toHaveLength(0); await memory.update(id, { expirationDate: null }); const after: SearchResult = await memory.getAll({ filters: { user_id: scopedUser }, }); expect(after.results.map((r) => r.memory)).toEqual(["Revived"]); }); test("getAll() still fills topK when expired memories are present", async () => { const scopedUser = `${userId}_topk`; for (let i = 0; i < 3; i++) { await memory.add(`Dead ${i}`, { userId: scopedUser, infer: false, expirationDate: "2020-01-01", }); } for (let i = 0; i < 3; i++) { await memory.add(`Live ${i}`, { userId: scopedUser, infer: false }); } const result: SearchResult = await memory.getAll({ filters: { user_id: scopedUser }, topK: 3, }); expect(result.results).toHaveLength(3); expect(result.results.every((r) => r.memory!.startsWith("Live"))).toBe( true, ); }); }); // ─── delete() ──────────────────────────────────────────── describe("Memory - delete()", () => { let memory: Memory; const userId = `delete_test_${Date.now()}`; beforeAll(async () => { memory = createMemory(); }); afterAll(async () => { await memory.reset(); }); test("returns success message", async () => { const addResult: SearchResult = await memory.add("Delete me", { userId, infer: false, }); const result = await memory.delete(addResult.results[0].id); expect(result.message).toBe("Memory deleted successfully!"); }); test("get() returns null after deletion", async () => { const addResult: SearchResult = await memory.add("Temporary", { userId, infer: false, }); const id = addResult.results[0].id; await memory.delete(id); expect(await memory.get(id)).toBeNull(); }); }); // ─── deleteAll() ───────────────────────────────────────── describe("Memory - deleteAll()", () => { let memory: Memory; const userId = `deleteall_test_${Date.now()}`; beforeAll(async () => { memory = createMemory(); }); afterAll(async () => { await memory.reset(); }); test("removes all memories for the user and returns success", async () => { await memory.add("Fact A", { userId }); await memory.add("Fact B", { userId }); const result = await memory.deleteAll({ userId }); expect(result.message).toBe("Memories deleted successfully!"); const remaining: SearchResult = await memory.getAll({ filters: { user_id: userId }, }); expect(remaining.results).toHaveLength(0); }); test("throws when no filter is provided", async () => { await expect(memory.deleteAll({} as any)).rejects.toThrow( "At least one filter is required to delete all memories", ); }); test("deletes past the vector store default page limit", async () => { const mem = createMemory(); const internals = mem as any; await internals._ensureInitialized(); const pagedUserId = `deleteall_paged_${Date.now()}`; const total = 150; await internals.vectorStore.insert( Array.from({ length: total }, () => new Array(1536).fill(0.1)), Array.from({ length: total }, (_, index) => `paged-${index}`), Array.from({ length: total }, (_, index) => ({ data: `Fact ${index}`, hash: `hash-${index}`, user_id: pagedUserId, createdAt: new Date().toISOString(), })), ); const [before] = await internals.vectorStore.list( { user_id: pagedUserId }, total * 2, ); expect(before).toHaveLength(total); const result = await mem.deleteAll({ userId: pagedUserId }); expect(result.message).toBe("Memories deleted successfully!"); const [after] = await internals.vectorStore.list( { user_id: pagedUserId }, total * 2, ); expect(after).toHaveLength(0); }, 60000); test("stops when the vector store keeps returning the same batch", async () => { const mem = createMemory(); const internals = mem as any; await internals._ensureInitialized(); const stale = [ { id: "stale-1", payload: { data: "x", user_id: "u1" } }, { id: "stale-2", payload: { data: "x", user_id: "u1" } }, ]; const listSpy = jest .spyOn(internals.vectorStore, "list") .mockResolvedValueOnce([stale, stale.length]) .mockResolvedValueOnce([stale, stale.length]) .mockImplementation(async () => { throw new Error("deleteAll should have stopped after a repeated batch"); }); const deleteSpy = jest .spyOn(internals, "deleteMemory") .mockResolvedValue("stale-1"); const result = await mem.deleteAll({ userId: "u1" }); expect(result.message).toBe("Memories deleted successfully!"); expect(listSpy).toHaveBeenCalledTimes(2); expect(deleteSpy).toHaveBeenCalledTimes(2); }); }); // ─── getAll() ──────────────────────────────────────────── describe("Memory - getAll()", () => { let memory: Memory; const userId = `getall_test_${Date.now()}`; beforeAll(async () => { memory = createMemory(); }); afterAll(async () => { await memory.reset(); }); test("returns all stored memories for the user", async () => { await memory.add("First", { userId }); await memory.add("Second", { userId }); const result: SearchResult = await memory.getAll({ filters: { user_id: userId }, }); expect(Array.isArray(result.results)).toBe(true); expect(result.results.length).toBeGreaterThanOrEqual(2); }); test("each result has id and memory fields", async () => { const result: SearchResult = await memory.getAll({ filters: { user_id: userId }, }); for (const item of result.results) { expect(item.id).toBeDefined(); expect(typeof item.memory).toBe("string"); } }); test("returns empty array when no memories exist", async () => { const result: SearchResult = await memory.getAll({ filters: { user_id: "no_such_user" }, }); expect(result.results).toHaveLength(0); }); }); // ─── search() ──────────────────────────────────────────── describe("Memory - search()", () => { let memory: Memory; const userId = `search_test_${Date.now()}`; beforeAll(async () => { memory = createMemory(); await memory.add("I love TypeScript", { userId }); }); afterAll(async () => { await memory.reset(); }); test("returns SearchResult with results array", async () => { const result: SearchResult = await memory.search("TypeScript", { filters: { user_id: userId }, }); expect(Array.isArray(result.results)).toBe(true); }); test("returns results with score field", async () => { const result: SearchResult = await memory.search("content", { filters: { user_id: userId }, }); if (result.results.length > 0) { expect(typeof result.results[0].score).toBe("number"); } }); test("throws when no userId/agentId/runId provided", async () => { await expect(memory.search("query", {} as any)).rejects.toThrow( "filters must contain at least one of: user_id, agent_id, run_id", ); }); test("returns empty results for user with no memories", async () => { const result: SearchResult = await memory.search("query", { filters: { user_id: "empty_user" }, }); expect(result.results).toHaveLength(0); }); }); // ─── attributedTo (#5666) ──────────────────────────────── describe("Memory - attributedTo round-trip (#5666)", () => { let memory: Memory; const userId = `attributed_test_${Date.now()}`; let id: string; beforeAll(async () => { memory = createMemory(); // The mocked LLM tags every extracted fact with attributed_to: "user". const addResult: SearchResult = await memory.add("I love AI", { userId }); id = addResult.results[0].id; }); afterAll(async () => { await memory.reset(); }); test("get() surfaces attributedTo", async () => { const item: MemoryItem | null = await memory.get(id); expect(item!.attributedTo).toBe("user"); }); test("getAll() surfaces attributedTo", async () => { const result: SearchResult = await memory.getAll({ filters: { user_id: userId }, }); expect(result.results[0].attributedTo).toBe("user"); }); test("search() surfaces attributedTo", async () => { const result: SearchResult = await memory.search("AI", { filters: { user_id: userId }, }); expect(result.results.length).toBeGreaterThan(0); expect(result.results[0].attributedTo).toBe("user"); }); }); // ─── history() ─────────────────────────────────────────── describe("Memory - history()", () => { let memory: Memory; const userId = `history_test_${Date.now()}`; beforeAll(async () => { memory = createMemory(); }); afterAll(async () => { await memory.reset(); }); test("records ADD event after add()", async () => { const addResult: SearchResult = await memory.add("New fact", { userId, }); const history = await memory.history(addResult.results[0].id); expect(Array.isArray(history)).toBe(true); expect(history.length).toBeGreaterThan(0); }); test("records additional entry after update()", async () => { const addResult: SearchResult = await memory.add("Before", { userId, }); const id = addResult.results[0].id; await memory.update(id, { text: "After" }); const history = await memory.history(id); expect(history.length).toBeGreaterThanOrEqual(2); }); test("returns empty array for non-existent memory ID", async () => { const history = await memory.history("nonexistent-id"); expect(history).toHaveLength(0); }); });