/** * Unit tests for the memory tool family. * Exercise the read path, the phase gate on append, and the isolation * closure — all through the LangChain tool interface, against a mock backend. */ import { MockMemoryBackend } from '@/memory/__tests__/mockBackend'; import { buildMemoryTools } from '../index'; import { MEMORY_APPEND_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_PHASE_FLUSHING, MEMORY_PHASE_NORMAL, MEMORY_SEARCH_TOOL_NAME, } from '@/memory/constants'; import type { MemoryPhase } from '@/memory/types'; describe('buildMemoryTools', () => { const scope = { agentId: 'sales', userId: 'alice' }; async function callTool(t: unknown, args: Record) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (t as any).invoke(args) as Promise; } it('exposes memory_search and memory_get in readOnly mode', () => { const backend = new MockMemoryBackend(); const tools = buildMemoryTools({ backend, scope, readOnly: true }); const names = tools.map((t) => (t as { name: string }).name); expect(names).toEqual([MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME]); }); it('attaches memory_append when not readOnly', () => { const backend = new MockMemoryBackend(); let phase: MemoryPhase = MEMORY_PHASE_NORMAL; const tools = buildMemoryTools({ backend, scope, getPhase: () => phase, }); const names = tools.map((t) => (t as { name: string }).name); expect(names).toContain(MEMORY_APPEND_TOOL_NAME); }); it('memory_search returns hits from the agent workspace only', async () => { const backend = new MockMemoryBackend(); await backend.append(scope, { path: 'memory/agent/playbook.md', content: 'chose Postgres over Mongo for vector search', }); await backend.append( { agentId: 'support', userId: 'alice' }, { path: 'memory/agent/playbook.md', content: 'unrelated support note' } ); const [search] = buildMemoryTools({ backend, scope, readOnly: true }); const result = JSON.parse(await callTool(search, { query: 'Postgres' })); expect(result.results).toHaveLength(1); expect(result.results[0].content).toContain('Postgres'); }); it('different user of same agent sees shared workspace', async () => { const backend = new MockMemoryBackend(); // Alice writes in her session. await backend.append( { agentId: 'sales', userId: 'alice' }, { path: 'memory/agent/playbook.md', content: 'Alice learned pricing tiers', } ); // Bob searches in his session — same agent, shared workspace. const [search] = buildMemoryTools({ backend, scope: { agentId: 'sales', userId: 'bob' }, readOnly: true, }); const result = JSON.parse(await callTool(search, { query: 'pricing' })); expect(result.results).toHaveLength(1); expect(result.results[0].content).toContain('pricing'); }); it('memory_append rejects outside memory_flushing phase', async () => { const backend = new MockMemoryBackend(); const tools = buildMemoryTools({ backend, scope, getPhase: () => MEMORY_PHASE_NORMAL, }); const append = tools.find( (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME )!; const result = JSON.parse( await callTool(append, { path: 'memory/agent/playbook.md', content: 'hello', }) ); expect(result.ok).toBe(false); expect(result.error).toMatch(/reflection/); expect(backend.allRows()).toHaveLength(0); }); it('memory_append persists during memory_flushing phase', async () => { const backend = new MockMemoryBackend(); const phase: MemoryPhase = MEMORY_PHASE_FLUSHING; const tools = buildMemoryTools({ backend, scope, getPhase: () => phase, }); const append = tools.find( (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME )!; const result = JSON.parse( await callTool(append, { path: 'memory/agent/playbook.md', content: 'I decided to use pgvector.', }) ); expect(result.ok).toBe(true); expect(backend.allRows()).toHaveLength(1); // Agent-tier row — scoping user_id is NULL, latest writer recorded // as provenance via lastUserId. expect(backend.allRows()[0].userId).toBeNull(); expect(backend.allRows()[0].lastUserId).toBe('alice'); }); it('memory_append merges multiple notes into one row per path', async () => { const backend = new MockMemoryBackend(); const tools = buildMemoryTools({ backend, scope, getPhase: () => MEMORY_PHASE_FLUSHING, }); const append = tools.find( (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME )!; await callTool(append, { path: 'memory/agent/playbook.md', content: 'first note', }); await callTool(append, { path: 'memory/agent/playbook.md', content: 'second note', }); const rows = backend.allRows(); expect(rows).toHaveLength(1); expect(rows[0].content).toContain('first note'); expect(rows[0].content).toContain('second note'); }); it('memory_append rejects paths outside memory/', async () => { const backend = new MockMemoryBackend(); const tools = buildMemoryTools({ backend, scope, getPhase: () => MEMORY_PHASE_FLUSHING, }); const append = tools.find( (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME )!; const result = JSON.parse( await callTool(append, { path: 'secret.md', content: 'escape', }) ); expect(result.ok).toBe(false); expect(result.error).toMatch(/memory\//); }); it('memory_append rejects non-whitelisted paths even with memory/ prefix', async () => { const backend = new MockMemoryBackend(); const tools = buildMemoryTools({ backend, scope, getPhase: () => MEMORY_PHASE_FLUSHING, }); const append = tools.find( (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME )!; const result = JSON.parse( await callTool(append, { path: 'memory/random-notes.md', content: 'escape', }) ); expect(result.ok).toBe(false); expect(result.error).toMatch(/whitelist/i); }); it('tool args cannot inject agent_id into scope', async () => { const backend = new MockMemoryBackend(); await backend.append( { agentId: 'victim', userId: 'alice' }, { path: 'memory/agent/playbook.md', content: 'secret data' } ); const [search] = buildMemoryTools({ backend, scope, readOnly: true }); // Try to pass a forged agent_id — Zod strips it, closure wins. const result = JSON.parse( // eslint-disable-next-line @typescript-eslint/no-explicit-any await callTool(search, { query: 'secret', agent_id: 'victim' } as any) ); expect(result.results).toHaveLength(0); }); });