/** * Unit tests for the agentic flush loop. * * Scope: pure loop mechanics — model invocation, tool dispatch, * ToolMessage feedback, iteration cap, NO_REPLY detection, error capture. * Does NOT touch the real pgvector backend or memory_append — every tool * is a stub so the test stays hermetic. */ import { AIMessage, HumanMessage } from '@langchain/core/messages'; import { tool } from '@langchain/core/tools'; import { z } from 'zod'; import { runFlushLoop, extractText, parseToolResult, type InvokableModel, } from '../flushLoop'; import { MEMORY_APPEND_TOOL_NAME, SILENT_REPLY_TOKEN, } from '@/memory/constants'; /** * Build a stubbed LangChain tool with a controllable response * function, so individual test cases can simulate success / schema * error / backend error. */ function makeAppendStub( respond: (args: { path: string; content: string }) => string ) { return tool( async (args) => respond(args as { path: string; content: string }), { name: MEMORY_APPEND_TOOL_NAME, description: 'stub memory_append', schema: z.object({ path: z.string(), content: z.string() }), } ); } /** Scripted model — returns pre-canned AIMessages in order. */ function scriptedModel(script: AIMessage[]): InvokableModel { let i = 0; return { async invoke() { const next = script[i] ?? new AIMessage({ content: 'done' }); i += 1; return next; }, }; } describe('runFlushLoop', () => { it('stops immediately if the first response has no tool_calls', async () => { const model = scriptedModel([ new AIMessage({ content: 'nothing to store' }), ]); const result = await runFlushLoop({ model, tools: [], initialMessages: [new HumanMessage('flush')], }); expect(result.iterations).toBe(1); expect(result.appendsAttempted).toBe(0); expect(result.appendsSucceeded).toBe(0); expect(result.toolErrors).toEqual([]); expect(result.silentReply).toBe(false); expect(result.finalText).toBe('nothing to store'); }); it('detects NO_REPLY silent reply', async () => { const model = scriptedModel([ new AIMessage({ content: SILENT_REPLY_TOKEN }), ]); const result = await runFlushLoop({ model, tools: [], initialMessages: [new HumanMessage('flush')], }); expect(result.silentReply).toBe(true); expect(result.finalText).toBe(SILENT_REPLY_TOKEN); }); it('executes memory_append tool_calls and counts successes', async () => { const appendTool = makeAppendStub(({ path }) => JSON.stringify({ ok: true, path }) ); const model = scriptedModel([ new AIMessage({ content: '', tool_calls: [ { name: MEMORY_APPEND_TOOL_NAME, args: { path: 'memory/2026-04-13.md', content: 'user prefers X' }, id: 'c1', }, { name: MEMORY_APPEND_TOOL_NAME, args: { path: 'memory/2026-04-13.md', content: 'tool schema failed', }, id: 'c2', }, ], }), new AIMessage({ content: SILENT_REPLY_TOKEN }), ]); const result = await runFlushLoop({ model, tools: [appendTool], initialMessages: [new HumanMessage('flush')], }); expect(result.iterations).toBe(2); expect(result.appendsAttempted).toBe(2); expect(result.appendsSucceeded).toBe(2); expect(result.silentReply).toBe(true); expect(result.toolErrors).toEqual([]); }); it('captures tool errors and feeds them back as ToolMessages', async () => { const appendTool = makeAppendStub(() => JSON.stringify({ ok: false, error: 'path must start with memory/' }) ); const model = scriptedModel([ new AIMessage({ content: '', tool_calls: [ { name: MEMORY_APPEND_TOOL_NAME, args: { path: 'invalid.md', content: 'x' }, id: 'bad1', }, ], }), new AIMessage({ content: 'gave up' }), ]); const result = await runFlushLoop({ model, tools: [appendTool], initialMessages: [new HumanMessage('flush')], }); expect(result.appendsAttempted).toBe(1); expect(result.appendsSucceeded).toBe(0); expect(result.toolErrors).toHaveLength(1); expect(result.toolErrors[0].error).toMatch(/path must start with memory/); // The ToolMessage should be in the transcript so the next invoke // would see it. const hasToolMsg = result.messages.some((m) => m._getType() === 'tool'); expect(hasToolMsg).toBe(true); }); it('records tool_not_found when the model calls an unknown tool', async () => { const model = scriptedModel([ new AIMessage({ content: '', tool_calls: [{ name: 'mystery_tool', args: {}, id: 'm1' }], }), new AIMessage({ content: 'stopped' }), ]); const result = await runFlushLoop({ model, tools: [], initialMessages: [new HumanMessage('flush')], }); expect(result.toolErrors).toHaveLength(1); expect(result.toolErrors[0].error).toMatch(/tool_not_found/); }); it('stops at maxIterations and flags hitIterationCap', async () => { // A model that keeps emitting tool_calls forever. const appendTool = makeAppendStub(() => JSON.stringify({ ok: true, path: 'memory/x.md' }) ); const model: InvokableModel = { async invoke() { return new AIMessage({ content: '', tool_calls: [ { name: MEMORY_APPEND_TOOL_NAME, args: { path: 'memory/x.md', content: 'loop' }, id: 'loop', }, ], }); }, }; const result = await runFlushLoop({ model, tools: [appendTool], initialMessages: [new HumanMessage('flush')], maxIterations: 3, }); expect(result.iterations).toBe(3); expect(result.hitIterationCap).toBe(true); expect(result.appendsAttempted).toBe(3); expect(result.appendsSucceeded).toBe(3); }); it('surfaces thrown tool errors without aborting the loop', async () => { const appendTool = makeAppendStub(() => { throw new Error('backend down'); }); const model = scriptedModel([ new AIMessage({ content: '', tool_calls: [ { name: MEMORY_APPEND_TOOL_NAME, args: { path: 'memory/x.md', content: 'x' }, id: 't1', }, ], }), new AIMessage({ content: 'done' }), ]); const result = await runFlushLoop({ model, tools: [appendTool], initialMessages: [new HumanMessage('flush')], }); expect(result.appendsAttempted).toBe(1); expect(result.appendsSucceeded).toBe(0); expect(result.toolErrors[0].error).toMatch(/backend down/); expect(result.iterations).toBe(2); }); }); describe('extractText', () => { it('returns string content directly', () => { expect(extractText(new AIMessage({ content: 'hi' }))).toBe('hi'); }); it('joins array content blocks', () => { const ai = new AIMessage({ content: [ { type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }, ] as unknown as string, }); expect(extractText(ai)).toBe('hello world'); }); }); describe('parseToolResult', () => { it('parses well-formed JSON with ok field', () => { expect(parseToolResult('{"ok":true,"path":"memory/x.md"}')).toEqual({ ok: true, path: 'memory/x.md', }); }); it('parses error JSON', () => { expect(parseToolResult('{"ok":false,"error":"bad"}')).toEqual({ ok: false, error: 'bad', }); }); it('treats non-JSON as opaque success', () => { expect(parseToolResult('hello')).toEqual({ ok: true }); }); });