/** * nonBlockingSummarization.test.ts * * Tests that the Graph's summarization pipeline is fully non-blocking. * The core invariant: summarizeCallback is NEVER awaited in the hot path. * Instead, the graph uses cached/persisted summaries and fires background updates. */ import { HumanMessage, AIMessage, SystemMessage, BaseMessage, } from '@langchain/core/messages'; import type { TokenCounter } from '@/types/run'; import { createPruneMessages } from '@/messages/prune'; /** * Simple token counter: ~1 token per 4 characters */ const simpleTokenCounter: TokenCounter = (msg: BaseMessage): number => { const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); return Math.ceil(content.length / 4); }; /** * Build a conversation that exceeds the token budget to trigger pruning. * Each message is ~100 tokens (400 chars). */ function buildLargeConversation(messageCount: number): BaseMessage[] { const messages: BaseMessage[] = [ new SystemMessage('You are a helpful assistant.'), ]; for (let i = 0; i < messageCount; i++) { const longText = `Message ${i}: ${'x'.repeat(380)}`; if (i % 2 === 0) { messages.push(new HumanMessage(longText)); } else { messages.push(new AIMessage(longText)); } } return messages; } // ============================================================================ // Non-blocking summarization behavior // ============================================================================ describe('Non-blocking summarization in Graph pruning', () => { /** * Simulates the Graph.ts summarization decision logic (lines 1439-1492). * This is extracted to test the branching behavior without needing a full Graph. */ function simulateGraphSummarization(opts: { messagesToRefine: BaseMessage[]; cachedRunSummary: string | null; persistedSummary: string | null; summarizeCallback: (msgs: BaseMessage[]) => Promise; }): { summary: string | undefined; callbackCalled: boolean; wasBlocking: boolean; } { const { messagesToRefine, cachedRunSummary, persistedSummary, summarizeCallback, } = opts; let summary: string | undefined; let callbackCalled = false; let wasBlocking = false; if (messagesToRefine.length > 0 && summarizeCallback) { if (cachedRunSummary != null) { // Case 1: Reuse cached summary summary = cachedRunSummary; callbackCalled = true; wasBlocking = false; // Fire background update (non-blocking) summarizeCallback(messagesToRefine).catch(() => {}); } else if (persistedSummary != null && persistedSummary !== '') { // Case 2: Use persisted summary as fallback summary = persistedSummary; callbackCalled = true; wasBlocking = false; summarizeCallback(messagesToRefine).catch(() => {}); } else { // Case 3: No summary exists — skip injection, fire background generation summary = undefined; callbackCalled = true; wasBlocking = false; summarizeCallback(messagesToRefine).catch(() => {}); } } return { summary, callbackCalled, wasBlocking }; } it('Case 1: uses cached run summary without blocking', () => { const callback = jest.fn().mockResolvedValue('updated summary'); const result = simulateGraphSummarization({ messagesToRefine: [new HumanMessage('old msg')], cachedRunSummary: 'existing cached summary', persistedSummary: null, summarizeCallback: callback, }); expect(result.summary).toBe('existing cached summary'); expect(result.wasBlocking).toBe(false); expect(callback).toHaveBeenCalledTimes(1); }); it('Case 2: uses persisted summary as fallback without blocking', () => { const callback = jest.fn().mockResolvedValue('updated summary'); const result = simulateGraphSummarization({ messagesToRefine: [new HumanMessage('old msg')], cachedRunSummary: null, persistedSummary: 'persisted from last turn', summarizeCallback: callback, }); expect(result.summary).toBe('persisted from last turn'); expect(result.wasBlocking).toBe(false); expect(callback).toHaveBeenCalledTimes(1); }); it('Case 3: no summary available — skips injection, fires background', () => { const callback = jest.fn().mockResolvedValue('new summary'); const result = simulateGraphSummarization({ messagesToRefine: [new HumanMessage('old msg')], cachedRunSummary: null, persistedSummary: null, summarizeCallback: callback, }); expect(result.summary).toBeUndefined(); expect(result.wasBlocking).toBe(false); expect(callback).toHaveBeenCalledTimes(1); }); it('does not call callback when no messages were refined', () => { const callback = jest.fn().mockResolvedValue('summary'); const result = simulateGraphSummarization({ messagesToRefine: [], cachedRunSummary: null, persistedSummary: null, summarizeCallback: callback, }); expect(result.summary).toBeUndefined(); expect(callback).not.toHaveBeenCalled(); }); it('handles callback failure gracefully', async () => { const callback = jest.fn().mockRejectedValue(new Error('LLM timeout')); const result = simulateGraphSummarization({ messagesToRefine: [new HumanMessage('msg')], cachedRunSummary: 'cached', persistedSummary: null, summarizeCallback: callback, }); // Should still use cached summary expect(result.summary).toBe('cached'); // Wait for background promise to settle await new Promise((r) => setTimeout(r, 10)); expect(callback).toHaveBeenCalledTimes(1); }); it('background callback updates cache for subsequent iterations', async () => { let cachedSummary: string | null = null; const callback = jest.fn().mockImplementation(async () => { // Simulate LLM call delay await new Promise((r) => setTimeout(r, 50)); const updated = 'background-updated summary'; cachedSummary = updated; return updated; }); // First call: no cache simulateGraphSummarization({ messagesToRefine: [new HumanMessage('msg')], cachedRunSummary: null, persistedSummary: 'stale persisted', summarizeCallback: callback, }); // Initially still using persisted expect(cachedSummary).toBeNull(); // Wait for background to complete await new Promise((r) => setTimeout(r, 100)); expect(cachedSummary).toBe('background-updated summary'); // Second call: now has cached summary const result2 = simulateGraphSummarization({ messagesToRefine: [new HumanMessage('msg2')], cachedRunSummary: cachedSummary, persistedSummary: null, summarizeCallback: callback, }); expect(result2.summary).toBe('background-updated summary'); }); }); // ============================================================================ // Pruning integration with summary injection // ============================================================================ describe('Pruning + summary injection flow', () => { it('pruneMessages produces messagesToRefine when context exceeds budget', () => { // Each message ~100 tokens (400 chars). 40 messages = ~4000 tokens. // maxTokens = 200 forces heavy pruning. const messages = buildLargeConversation(40); const maxTokens = 200; const prune = createPruneMessages({ startIndex: 0, provider: 'anthropic' as any, tokenCounter: simpleTokenCounter, maxTokens, indexTokenCountMap: {}, }); const { context, messagesToRefine } = prune({ messages }); expect(messagesToRefine.length).toBeGreaterThan(0); expect(context.length).toBeLessThan(messages.length); }); it('summary is injected after system message when available', () => { const messages: BaseMessage[] = [ new SystemMessage('System prompt'), new HumanMessage('Recent question'), ]; const summaryText = 'User discussed project deadlines and budget'; const summaryMsg = new SystemMessage( `[Conversation Summary]\n${summaryText}` ); // Insert after system message const systemIdx = messages[0]?.getType() === 'system' ? 1 : 0; const result = [ ...messages.slice(0, systemIdx), summaryMsg, ...messages.slice(systemIdx), ]; expect(result.length).toBe(3); expect(result[0].getType()).toBe('system'); // Original system expect(result[1].content as string).toContain('[Conversation Summary]'); expect(result[2].getType()).toBe('human'); // Recent question preserved }); }); // ============================================================================ // Multi-turn simulation // ============================================================================ describe('Multi-turn conversation with rolling summaries', () => { it('simulates 5 turns with persisted summary handoff', async () => { let persistedSummary: string | null = null; const summaryUpdates: string[] = []; const callback = jest .fn() .mockImplementation(async (msgs: BaseMessage[]) => { const msgCount = msgs.length; const summary = `Summary of ${msgCount} messages (turn ${summaryUpdates.length + 1})`; summaryUpdates.push(summary); persistedSummary = summary; return summary; }); // Simulate 5 conversation turns for (let turn = 0; turn < 5; turn++) { const messages = buildLargeConversation(10); const prune = createPruneMessages({ startIndex: 0, provider: 'anthropic' as any, tokenCounter: simpleTokenCounter, maxTokens: 300, indexTokenCountMap: {}, }); const { messagesToRefine } = prune({ messages }); if (messagesToRefine.length > 0) { // Simulate Graph behavior: use persisted, fire background const cachedSummary = persistedSummary; if (cachedSummary) { // Non-blocking: use existing summary expect(cachedSummary).toBeDefined(); } // Fire background update callback(messagesToRefine).catch(() => {}); await new Promise((r) => setTimeout(r, 10)); } } // All turns should have fired background updates expect(summaryUpdates.length).toBeGreaterThanOrEqual(4); expect(persistedSummary).toContain('Summary of'); }); });