/** * Integration-lite test for {@link runMemoryFlush}: scripted model + * in-memory backend stub. Verifies end-to-end that the flush phase * flips, tool_calls execute, the backend sees the writes, and the * rich result object is populated. */ import { AIMessage } from '@langchain/core/messages'; import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { runMemoryFlush } from '../memoryFlushPhase'; import type { MemoryAppendInput, MemoryBackend, MemoryScope, } from '@/memory/types'; import { MEMORY_APPEND_TOOL_NAME, MEMORY_PHASE_FLUSHING, MEMORY_PHASE_NORMAL, SILENT_REPLY_TOKEN, } from '@/memory/constants'; /** Minimal in-memory backend — just records appends. */ function makeBackend(): MemoryBackend & { writes: MemoryAppendInput[] } { const writes: MemoryAppendInput[] = []; return { kind: 'vector', writes, async search() { return []; }, async get() { return null; }, async append(_scope: MemoryScope, input: MemoryAppendInput) { writes.push(input); }, async health() { return { ok: true, backend: 'vector' }; }, }; } /** * Scripted model masquerading as a BaseChatModel. We only need * `invoke` and `bindTools`; the memoryFlushPhase code accesses those * through bindToolsIfSupported and runFlushLoop only. */ function scriptedModel(script: AIMessage[]) { let i = 0; const model = { bindTools() { return model; }, async invoke() { const next = script[i] ?? new AIMessage({ content: SILENT_REPLY_TOKEN }); i += 1; return next; }, }; return model as unknown as BaseChatModel; } describe('runMemoryFlush', () => { it('flips phase, runs the loop, and writes notes through the backend', async () => { const backend = makeBackend(); const phases: string[] = []; const setPhase = ( p: typeof MEMORY_PHASE_NORMAL | typeof MEMORY_PHASE_FLUSHING ) => { phases.push(p); }; const model = scriptedModel([ new AIMessage({ content: '', tool_calls: [ { name: MEMORY_APPEND_TOOL_NAME, args: { path: 'memory/user/preferences.md', content: 'User is Varun; prefers clean agent separation.', }, id: 'c1', }, ], }), new AIMessage({ content: SILENT_REPLY_TOKEN }), ]); const result = await runMemoryFlush({ model, memory: { backend, scope: { agentId: 'orchestrator', userId: 'user-1' }, }, conversationSummary: 'Varun explained his preferences...', setPhase, }); expect(result.ran).toBe(true); expect(result.iterations).toBe(2); expect(result.appendsAttempted).toBe(1); expect(result.appendsSucceeded).toBe(1); expect(result.silentReply).toBe(true); expect(result.toolErrors).toEqual([]); expect(backend.writes).toHaveLength(1); expect(backend.writes[0].path).toBe('memory/user/preferences.md'); expect(phases[0]).toBe(MEMORY_PHASE_FLUSHING); expect(phases[phases.length - 1]).toBe(MEMORY_PHASE_NORMAL); }); it('returns ran=false when flush is disabled', async () => { const backend = makeBackend(); const result = await runMemoryFlush({ model: scriptedModel([]), memory: { backend, scope: { agentId: 'a', userId: 'u' }, flush: { enabled: false }, }, conversationSummary: 'x', setPhase: () => {}, }); expect(result.ran).toBe(false); expect(backend.writes).toHaveLength(0); }); it('restores phase even when the model throws', async () => { const backend = makeBackend(); const phases: string[] = []; const brokenModel = { bindTools() { return brokenModel; }, async invoke() { throw new Error('boom'); }, } as unknown as BaseChatModel; const result = await runMemoryFlush({ model: brokenModel, memory: { backend, scope: { agentId: 'a', userId: 'u' } }, conversationSummary: 'x', setPhase: (p) => phases.push(p), }); expect(result.ran).toBe(false); expect(result.error).toMatch(/boom/); expect(phases[phases.length - 1]).toBe(MEMORY_PHASE_NORMAL); }); });