/** * gapFeatures.test.ts * * Integration tests for the four LibreChat gap features: * 1. Tool Discovery Caching * 2. SummarizationConfig (trigger types, initialSummary) * 3. EMA Pruning Calibration * 4. Message Deduplication * * These tests verify the features work together in the Graph pipeline * without breaking existing functionality. */ import { HumanMessage, AIMessage, AIMessageChunk, SystemMessage, ToolMessage, BaseMessage, } from '@langchain/core/messages'; import type { TokenCounter } from '@/types/run'; import type { SummarizationConfig } from '@/types/graph'; import { createPruneMessages } from '@/messages/prune'; import { deduplicateSystemMessages } from '@/messages/dedup'; import { ToolDiscoveryCache } from '@/utils/toolDiscoveryCache'; import { createPruneCalibration, updatePruneCalibration, applyCalibration, } from '@/utils/pruneCalibration'; import { Constants } from '@/common'; const simpleTokenCounter: TokenCounter = (msg: BaseMessage): number => { const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); return Math.ceil(content.length / 4); }; function buildConversation( messageCount: number, charsPerMsg = 400 ): BaseMessage[] { const messages: BaseMessage[] = [ new SystemMessage('You are a helpful assistant.'), ]; for (let i = 0; i < messageCount; i++) { const text = `Message ${i}: ${'x'.repeat(charsPerMsg - 15)}`; messages.push(i % 2 === 0 ? new HumanMessage(text) : new AIMessage(text)); } return messages; } // ============================================================================ // 1. Tool Discovery Caching Integration // ============================================================================ describe('Tool Discovery Caching — Integration', () => { it('caches tool discoveries across multiple pruning iterations', () => { const cache = new ToolDiscoveryCache(); const messages: BaseMessage[] = [ new SystemMessage('System prompt'), new HumanMessage('Find a tool'), new AIMessageChunk({ content: 'Searching', tool_calls: [ { id: 'tc_1', name: Constants.TOOL_SEARCH, args: { query: 'web' } }, ], }), new ToolMessage({ content: 'Found tools', tool_call_id: 'tc_1', name: Constants.TOOL_SEARCH, artifact: { tool_references: [{ tool_name: 'web_search' }] }, }), ]; // Iteration 1: discovers web_search const disc1 = cache.getNewDiscoveries(messages); expect(disc1).toEqual(['web_search']); // Iteration 2: same messages, no new discoveries (cached) const disc2 = cache.getNewDiscoveries(messages); expect(disc2).toEqual([]); // Iteration 3: new tool search added messages.push( new AIMessageChunk({ content: 'More tools', tool_calls: [ { id: 'tc_2', name: Constants.TOOL_SEARCH, args: { query: 'code' } }, ], }), new ToolMessage({ content: 'Found more', tool_call_id: 'tc_2', name: Constants.TOOL_SEARCH, artifact: { tool_references: [ { tool_name: 'code_exec' }, { tool_name: 'web_search' }, ], }, }) ); // Only code_exec is new (web_search already cached) const disc3 = cache.getNewDiscoveries(messages); expect(disc3).toEqual(['code_exec']); expect(cache.size).toBe(2); }); it('seed + incremental discovery simulates cross-turn caching', () => { const cache = new ToolDiscoveryCache(); // Seed from prior turn's discoveries cache.seed(['tool_a', 'tool_b']); const messages: BaseMessage[] = [ new HumanMessage('Use tool_a and find tool_c'), new AIMessageChunk({ content: 'Searching', tool_calls: [{ id: 'tc_1', name: Constants.TOOL_SEARCH, args: {} }], }), new ToolMessage({ content: 'Found', tool_call_id: 'tc_1', name: Constants.TOOL_SEARCH, artifact: { tool_references: [{ tool_name: 'tool_a' }, { tool_name: 'tool_c' }], }, }), ]; const newDisc = cache.getNewDiscoveries(messages); // tool_a is seeded, only tool_c is new expect(newDisc).toEqual(['tool_c']); expect(cache.getAllDiscoveredTools()).toEqual( expect.arrayContaining(['tool_a', 'tool_b', 'tool_c']) ); }); }); // ============================================================================ // 2. SummarizationConfig Integration // ============================================================================ describe('SummarizationConfig — Trigger Logic', () => { /** * Simulates the Graph's shouldTriggerSummarization logic. */ function shouldTriggerSummarization( prunedMessageCount: number, maxContextTokens: number, indexTokenCountMap: Record, instructionTokens: number, config?: SummarizationConfig ): boolean { if (prunedMessageCount === 0) return false; if (!config || !config.triggerType) return true; const threshold = config.triggerThreshold; switch (config.triggerType) { case 'contextPercentage': { if (maxContextTokens <= 0) return true; const effectiveThreshold = threshold ?? 80; let totalTokens = instructionTokens; for (const key in indexTokenCountMap) { totalTokens += indexTokenCountMap[key] ?? 0; } const utilization = (totalTokens / maxContextTokens) * 100; return utilization >= effectiveThreshold; } case 'messageCount': { const effectiveThreshold = threshold ?? 5; return prunedMessageCount >= effectiveThreshold; } case 'tokenThreshold': { if (threshold == null) return true; let totalTokens = instructionTokens; for (const key in indexTokenCountMap) { totalTokens += indexTokenCountMap[key] ?? 0; } return totalTokens >= threshold; } default: return true; } } it('no config = always triggers (backward compatible)', () => { expect(shouldTriggerSummarization(3, 10000, {}, 100)).toBe(true); }); it('contextPercentage: triggers at 80% utilization', () => { const tokenMap = { '0': 4000, '1': 3500, '2': 500 }; // Total = 100 + 8000 = 8100, 8100/10000 = 81% > 80% expect( shouldTriggerSummarization(2, 10000, tokenMap, 100, { triggerType: 'contextPercentage', triggerThreshold: 80, }) ).toBe(true); }); it('contextPercentage: does NOT trigger below threshold', () => { const tokenMap = { '0': 2000, '1': 1000 }; // Total = 100 + 3000 = 3100, 3100/10000 = 31% < 80% expect( shouldTriggerSummarization(2, 10000, tokenMap, 100, { triggerType: 'contextPercentage', triggerThreshold: 80, }) ).toBe(false); }); it('messageCount: triggers when enough messages pruned', () => { expect( shouldTriggerSummarization(5, 10000, {}, 100, { triggerType: 'messageCount', triggerThreshold: 5, }) ).toBe(true); expect( shouldTriggerSummarization(3, 10000, {}, 100, { triggerType: 'messageCount', triggerThreshold: 5, }) ).toBe(false); }); it('tokenThreshold: triggers when total tokens exceed threshold', () => { const tokenMap = { '0': 5000, '1': 3000 }; expect( shouldTriggerSummarization(2, 10000, tokenMap, 100, { triggerType: 'tokenThreshold', triggerThreshold: 8000, }) ).toBe(true); expect( shouldTriggerSummarization(2, 10000, tokenMap, 100, { triggerType: 'tokenThreshold', triggerThreshold: 9000, }) ).toBe(false); }); it('never triggers with 0 pruned messages', () => { expect(shouldTriggerSummarization(0, 10000, {}, 100)).toBe(false); expect( shouldTriggerSummarization(0, 10000, {}, 100, { triggerType: 'messageCount', triggerThreshold: 1, }) ).toBe(false); }); it('initialSummary provides cross-run seeding', () => { const config: SummarizationConfig = { initialSummary: 'This agent helps with data analysis tasks.', }; // Simulate the Graph logic: when no cached/persisted summary exists, // initialSummary is used as fallback let summary: string | undefined; const cachedRunSummary: string | null = null; const persistedSummary: string | null = null; if (cachedRunSummary != null) { summary = cachedRunSummary; } else if (persistedSummary != null && persistedSummary !== '') { summary = persistedSummary; } else if (config.initialSummary != null && config.initialSummary !== '') { summary = config.initialSummary; } expect(summary).toBe('This agent helps with data analysis tasks.'); }); }); // ============================================================================ // 3. EMA Pruning Calibration Integration // ============================================================================ describe('EMA Pruning Calibration — Integration', () => { it('adjusts pruning budget across iterations', () => { let calibration = createPruneCalibration(); const rawBudget = 10000; // Iteration 1: no calibration data → raw budget expect(applyCalibration(rawBudget, calibration)).toBe(10000); // Simulate: our counter estimates 8000 tokens but API says 10000 // (we're under-counting → need to prune more aggressively) calibration = updatePruneCalibration(calibration, 10000, 8000); const adjusted = applyCalibration(rawBudget, calibration); expect(adjusted).toBeLessThan(10000); // More aggressive pruning }); it('full pruning cycle with calibration', () => { let calibration = createPruneCalibration(); const messages = buildConversation(40); // Iteration 1: uncalibrated const maxTokens1 = applyCalibration(200, calibration); const prune1 = createPruneMessages({ startIndex: 0, provider: 'anthropic' as any, tokenCounter: simpleTokenCounter, maxTokens: maxTokens1, indexTokenCountMap: {}, }); const result1 = prune1({ messages }); expect(result1.messagesToRefine.length).toBeGreaterThan(0); // Simulate API returning higher token count than our estimate calibration = updatePruneCalibration(calibration, 250, 200); // Iteration 2: calibrated (should use adjusted budget) const maxTokens2 = applyCalibration(200, calibration); expect(maxTokens2).not.toBe(200); // Budget adjusted // Multiple iterations should converge for (let i = 0; i < 5; i++) { calibration = updatePruneCalibration(calibration, 250, 200); } const finalBudget = applyCalibration(200, calibration); // Should stabilize around 200 * (200/250) ≈ 160 expect(finalBudget).toBeLessThan(200); expect(finalBudget).toBeGreaterThan(100); }); }); // ============================================================================ // 4. Message Deduplication Integration // ============================================================================ describe('Message Deduplication — Integration', () => { it('deduplicates post-prune notes from multiple iterations', () => { const postPruneNote = 'Note: Earlier messages have been compressed.'; const messages: BaseMessage[] = [ new SystemMessage('Main system prompt'), new SystemMessage('[Conversation Summary]\nPrior context'), new SystemMessage(postPruneNote), // Iteration 1 post-prune note new HumanMessage('Q1'), new AIMessage('A1'), new SystemMessage(postPruneNote), // Iteration 2 duplicate new HumanMessage('Q2'), new AIMessage('A2'), new SystemMessage(postPruneNote), // Iteration 3 duplicate ]; const { messages: deduped, removedCount } = deduplicateSystemMessages(messages); expect(removedCount).toBe(2); expect(deduped).toHaveLength(7); // Non-system messages all preserved const humanMsgs = deduped.filter((m) => m.getType() === 'human'); expect(humanMsgs).toHaveLength(2); }); it('preserves unique system messages including summary', () => { const messages: BaseMessage[] = [ new SystemMessage('Main prompt'), new SystemMessage('[Conversation Summary]\nVersion 1'), new HumanMessage('Q'), new SystemMessage('[Conversation Summary]\nVersion 2 - updated'), ]; const { messages: deduped, removedCount } = deduplicateSystemMessages(messages); expect(removedCount).toBe(0); expect(deduped).toHaveLength(4); }); it('works with pruning + dedup pipeline', () => { // Simulate: prune messages, inject summary, then dedup const allMessages = buildConversation(20); // Step 1: Prune const prune = createPruneMessages({ startIndex: 0, provider: 'anthropic' as any, tokenCounter: simpleTokenCounter, maxTokens: 300, indexTokenCountMap: {}, }); const { context, messagesToRefine } = prune({ messages: allMessages }); expect(messagesToRefine.length).toBeGreaterThan(0); // Step 2: Inject summary const summaryMsg = new SystemMessage( '[Conversation Summary]\nUser discussed tasks' ); const systemIdx = context[0]?.getType() === 'system' ? 1 : 0; let withSummary = [ ...context.slice(0, systemIdx), summaryMsg, ...context.slice(systemIdx), ]; // Simulate adding post-prune note withSummary = [...withSummary, new SystemMessage('Context was compressed')]; // Step 3: Dedup (should not remove anything since all unique) const { messages: final, removedCount } = deduplicateSystemMessages(withSummary); expect(removedCount).toBe(0); expect(final.length).toBe(withSummary.length); }); }); // ============================================================================ // Combined Integration // ============================================================================ describe('All Features Combined — Full Pipeline', () => { it('simulates 3-turn conversation with all features active', async () => { const toolCache = new ToolDiscoveryCache(); let calibration = createPruneCalibration(); let persistedSummary: string | null = null; const sumConfig: SummarizationConfig = { triggerType: 'contextPercentage', triggerThreshold: 50, reserveRatio: 0.3, }; const callback = jest .fn() .mockImplementation(async (msgs: BaseMessage[]) => { const summary = `Summary of ${msgs.length} messages`; persistedSummary = summary; return summary; }); for (let turn = 0; turn < 3; turn++) { // Build conversation that exceeds budget const messages = buildConversation(15); // Tool discovery (turn 1 has tool search results) if (turn === 0) { messages.push( new AIMessageChunk({ content: 'Searching', tool_calls: [ { id: `tc_${turn}`, name: Constants.TOOL_SEARCH, args: {} }, ], }), new ToolMessage({ content: 'Found', tool_call_id: `tc_${turn}`, name: Constants.TOOL_SEARCH, artifact: { tool_references: [{ tool_name: 'web_search' }] }, }) ); } const discoveries = toolCache.getNewDiscoveries(messages); if (turn === 0) { expect(discoveries).toEqual(['web_search']); } else { expect(discoveries).toEqual([]); } // Prune with calibration const maxTokens = applyCalibration(300, calibration); const prune = createPruneMessages({ startIndex: 0, provider: 'anthropic' as any, tokenCounter: simpleTokenCounter, maxTokens, indexTokenCountMap: {}, }); const { context, messagesToRefine } = prune({ messages }); let assembled = [...context]; // Inject summary if available if (persistedSummary && messagesToRefine.length > 0) { const summaryMsg = new SystemMessage( `[Conversation Summary]\n${persistedSummary}` ); const sysIdx = assembled[0]?.getType() === 'system' ? 1 : 0; assembled = [ ...assembled.slice(0, sysIdx), summaryMsg, ...assembled.slice(sysIdx), ]; } // Fire background summary if (messagesToRefine.length > 0) { callback(messagesToRefine).catch(() => {}); await new Promise((r) => setTimeout(r, 10)); } // Dedup const { messages: deduped } = deduplicateSystemMessages(assembled); expect(deduped.length).toBeLessThanOrEqual(assembled.length); // Update calibration (simulated API response) calibration = updatePruneCalibration( calibration, maxTokens + 50, maxTokens ); } // Verify state after 3 turns expect(toolCache.size).toBe(1); expect(toolCache.has('web_search')).toBe(true); expect(calibration.iterations).toBe(3); expect(persistedSummary).toContain('Summary of'); expect(callback).toHaveBeenCalled(); }); }); // =========================================================================== // Proactive Summarization — Context Pressure // =========================================================================== import { getContextUtilization } from '@/messages/prune'; import { PROACTIVE_SUMMARY_THRESHOLD } from '@/common/constants'; describe('Proactive Summarization — Context Pressure', () => { it('triggers proactive summary at 80% utilization BEFORE pruning', () => { // Simulate context at 82% utilization const maxContextTokens = 200_000; const indexTokenCountMap: Record = {}; // Build messages that fill ~82% of context const msgsNeeded = 40; const tokensPerMsg = Math.floor((maxContextTokens * 0.82) / msgsNeeded); for (let i = 0; i < msgsNeeded; i++) { indexTokenCountMap[String(i)] = tokensPerMsg; } const utilization = getContextUtilization( indexTokenCountMap, 0, maxContextTokens ); const threshold = PROACTIVE_SUMMARY_THRESHOLD * 100; // 80 expect(utilization).toBeGreaterThanOrEqual(threshold); // At 82%, proactive summary should fire // But pruning should NOT have happened yet (context < 90% safety factor) const effectiveBudget = Math.floor(maxContextTokens * 0.9); // CONTEXT_SAFETY_FACTOR const totalTokens = Object.values(indexTokenCountMap).reduce( (s, v) => (s ?? 0) + (v ?? 0), 0 ) as number; expect(totalTokens).toBeLessThan(effectiveBudget); }); it('does NOT trigger proactive summary below 80%', () => { const maxContextTokens = 200_000; const indexTokenCountMap: Record = {}; // Fill to 50% utilization const msgsNeeded = 20; const tokensPerMsg = Math.floor((maxContextTokens * 0.5) / msgsNeeded); for (let i = 0; i < msgsNeeded; i++) { indexTokenCountMap[String(i)] = tokensPerMsg; } const utilization = getContextUtilization( indexTokenCountMap, 0, maxContextTokens ); expect(utilization).toBeLessThan(PROACTIVE_SUMMARY_THRESHOLD * 100); }); it('selects only older messages for proactive summarization (keeps recent turns)', () => { const messages: BaseMessage[] = [ new SystemMessage('System prompt'), ...Array.from({ length: 20 }, (_, i) => i % 2 === 0 ? new HumanMessage(`User message ${i}`) : new AIMessage(`AI response ${i}`) ), ]; // Simulate the selection logic from Graph.ts proactive summarization const recentTurnCount = Math.max(4, Math.floor(messages.length * 0.3)); const oldMessages = messages.slice( 1, // skip system message Math.max(1, messages.length - recentTurnCount) ); // Recent 30% (~6 messages) preserved, older messages selected for summary expect(oldMessages.length).toBeLessThan(messages.length); expect(oldMessages.length).toBeGreaterThan(0); // System message not included expect(oldMessages[0].getType()).not.toBe('system'); // Last messages of conversation not included (recent turns preserved) const lastOldIndex = messages.indexOf(oldMessages[oldMessages.length - 1]); expect(lastOldIndex).toBeLessThan(messages.length - recentTurnCount); }); it('never blocks — proactive summary is always fire-and-forget', async () => { let resolveCallback: ((v: string) => void) | undefined; const slowCallback = jest.fn( () => new Promise((resolve) => { resolveCallback = resolve; }) ); // Simulate proactive summary fire-and-forget const summaryPromise = slowCallback().then((updated) => { return updated; }); // Main flow continues immediately — callback hasn't resolved yet expect(slowCallback).toHaveBeenCalledTimes(1); // Later, callback resolves (simulating Nova Micro responding) resolveCallback!('Proactive summary result'); const result = await summaryPromise; expect(result).toBe('Proactive summary result'); }); it('at 100%+ utilization, uses existing summary without throwing', () => { const maxContextTokens = 200_000; const cachedSummary = 'Previously generated summary of the conversation'; // Context is at 105% (over budget) const indexTokenCountMap: Record = { '0': 210_000, // system + everything }; const utilization = getContextUtilization( indexTokenCountMap, 0, maxContextTokens ); expect(utilization).toBeGreaterThan(100); // Even at 100%+, we use the existing cached summary — no error thrown expect(cachedSummary).toBeTruthy(); // Compaction builds a windowed view — no messages deleted, no throwing }); }); // =========================================================================== // Context Compaction (Copilot-style: never delete messages) // =========================================================================== import { applyCalibration as _applyCalibration } from '@/utils/pruneCalibration'; import { COMPACTION_RECENT_ROUNDS } from '@/common/constants'; import { buildFileManifestBlock, FILE_MANIFEST_PREFIX, } from '@/utils/fileManifest'; import type { FileManifestEntry } from '@/types/graph'; describe('Context Compaction — Windowed View (no message deletion)', () => { /** * Simulates the compaction logic from Graph.ts without the full Graph instance. * Mirrors the two modes: * A) No summary → fill budget with as many recent messages as fit * B) Summary exists → keep last COMPACTION_RECENT_ROUNDS rounds only */ function buildWindowedView(opts: { messages: BaseMessage[]; indexTokenCountMap: Record; maxTokens: number; summary?: string; tokenCounter: TokenCounter; fileManifest?: FileManifestEntry[]; }) { const { messages, indexTokenCountMap, maxTokens, summary, tokenCounter, fileManifest, } = opts; const systemMsg = messages[0]?.getType() === 'system' ? messages[0] : null; const systemTokens = systemMsg != null ? (indexTokenCountMap[0] ?? 0) : 0; const summaryMsg = summary ? new SystemMessage(`[Conversation Summary]\n${summary}`) : null; const summaryTokens = summaryMsg != null ? tokenCounter(summaryMsg) : 0; const recentBudget = maxTokens - systemTokens - summaryTokens - 3; const contentStart = systemMsg != null ? 1 : 0; let usedTokens = 0; let windowStart = messages.length; if (!summary) { // Mode A: No summary — fill budget for (let i = messages.length - 1; i >= contentStart; i--) { const msgTokens = indexTokenCountMap[i] ?? 0; if (usedTokens + msgTokens > recentBudget) break; usedTokens += msgTokens; windowStart = i; } } else { // Mode B: Summary exists — keep last N rounds let roundsSeen = 0; for (let i = messages.length - 1; i >= contentStart; i--) { const msgType = messages[i]?.getType(); const msgTokens = indexTokenCountMap[i] ?? 0; if (usedTokens + msgTokens > recentBudget) break; usedTokens += msgTokens; windowStart = i; if (msgType === 'human') { roundsSeen++; if (roundsSeen >= COMPACTION_RECENT_ROUNDS) break; } } } // Don't split tool-call / tool-result pairs while ( windowStart > contentStart && messages[windowStart]?.getType() === 'tool' ) { windowStart--; usedTokens += indexTokenCountMap[windowStart] ?? 0; } const recentMessages = messages.slice(windowStart); const compactedMessages = messages.slice(contentStart, windowStart); const view: BaseMessage[] = []; if (systemMsg) view.push(systemMsg); if (summaryMsg) view.push(summaryMsg); // Inject file manifest when files exist and messages are being compacted let fileManifestMsg: SystemMessage | null = null; if ( fileManifest && fileManifest.length > 0 && compactedMessages.length > 0 ) { const manifestBlock = buildFileManifestBlock(fileManifest); if (manifestBlock) { fileManifestMsg = new SystemMessage(manifestBlock); view.push(fileManifestMsg); } } view.push(...recentMessages); return { view, compactedMessages, recentMessages, usedTokens, fileManifestMsg, }; } it('builds a windowed view without deleting any messages', () => { const messages = buildConversation(20, 400); // system + 20 content msgs const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } const { view, compactedMessages, recentMessages } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 500, // small budget forces windowing tokenCounter: simpleTokenCounter, }); // View is smaller than original expect(view.length).toBeLessThan(messages.length); // But original messages array is untouched expect(messages.length).toBe(21); // system + 20 // Compacted + recent = all non-system messages expect(compactedMessages.length + recentMessages.length).toBe(20); // View starts with system message expect(view[0].getType()).toBe('system'); }); it('injects summary message covering compacted (windowed-out) messages', () => { const messages = buildConversation(20, 400); const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } const summary = 'Summary of earlier conversation turns'; const { view, compactedMessages } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 600, summary, tokenCounter: simpleTokenCounter, }); // Summary is injected after system message expect(view[1].content).toContain('[Conversation Summary]'); expect(view[1].content).toContain(summary); // There should be compacted messages behind the summary expect(compactedMessages.length).toBeGreaterThan(0); // Original array is unchanged expect(messages.length).toBe(21); }); it('includes all messages when budget is large enough (no compaction)', () => { const messages = buildConversation(5, 100); // small conversation const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } const { view, compactedMessages } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 100_000, // huge budget tokenCounter: simpleTokenCounter, }); // All messages fit — no compaction expect(view.length).toBe(messages.length); expect(compactedMessages.length).toBe(0); }); it('does not split tool-call / tool-result pairs at window boundary', () => { const messages: BaseMessage[] = [ new SystemMessage('System'), new HumanMessage('old question'), new AIMessage('old answer'), new HumanMessage('question about tool'), new AIMessageChunk({ content: 'Let me search', tool_calls: [{ id: 'tc_1', name: 'web_search', args: {} }], }), new ToolMessage({ content: 'Search results', tool_call_id: 'tc_1', name: 'web_search', }), new AIMessage('Based on the search results...'), new HumanMessage('latest question'), new AIMessage('latest answer'), ]; const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } // Budget that would naturally cut between the AI tool-call and ToolMessage // Force the window to start at the ToolMessage by making budget tight const toolMsgIdx = 5; // ToolMessage index let budgetUpToTool = 3; // priming tokens for (let i = toolMsgIdx; i < messages.length; i++) { budgetUpToTool += indexTokenCountMap[i] ?? 0; } // Budget includes ToolMessage but NOT the AI tool-call before it // The algorithm should walk back to include the AI message too const tightBudget = budgetUpToTool + (indexTokenCountMap[0] ?? 0) + 5; const { view } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: tightBudget, tokenCounter: simpleTokenCounter, }); // Verify no ToolMessage appears without its preceding AI message for (let i = 0; i < view.length; i++) { if (view[i].getType() === 'tool' && i > 0) { // The message before a ToolMessage should be an AI message (the tool caller) // or another ToolMessage (multi-tool scenario), or system const prevType = view[i - 1].getType(); expect(['ai', 'tool', 'system']).toContain(prevType); } } }); it('with summary, recent messages use remaining budget after summary tokens', () => { const messages = buildConversation(20, 400); const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } // Large summary eats into the budget const largeSummary = 'S'.repeat(1000); // ~250 tokens const { view: viewWithSummary, recentMessages: recentWithSummary } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 800, summary: largeSummary, tokenCounter: simpleTokenCounter, }); // Without summary — more recent messages fit const { recentMessages: recentWithout } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 800, tokenCounter: simpleTokenCounter, }); // Summary takes budget, so fewer recent messages fit expect(recentWithSummary.length).toBeLessThan(recentWithout.length); }); it('with summary, limits window to last 2 rounds (not budget-filling)', () => { // 20 messages = 10 rounds. With summary, should only keep last 2 rounds (4 msgs). const messages: BaseMessage[] = [new SystemMessage('System prompt')]; for (let i = 0; i < 20; i++) { messages.push( i % 2 === 0 ? new HumanMessage(`User question ${i / 2}`) : new AIMessage(`AI answer ${(i - 1) / 2}`) ); } const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } const { view, recentMessages, compactedMessages } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 100_000, // huge budget — would fit everything summary: 'Summary of earlier conversation', tokenCounter: simpleTokenCounter, }); // Despite huge budget, only last 2 rounds kept (4 content msgs: H+A+H+A) // Plus possible trailing messages in the last round expect(recentMessages.length).toBeLessThanOrEqual(5); // 2 rounds + maybe 1 trailing expect(recentMessages.length).toBeGreaterThanOrEqual(4); // at least 2 full rounds // Most messages are compacted behind the summary expect(compactedMessages.length).toBeGreaterThan(10); // View = system + summary + recent window expect(view[0].getType()).toBe('system'); expect(view[1].content).toContain('[Conversation Summary]'); }); it('without summary, fills budget (no round limit)', () => { const messages = buildConversation(20, 100); // small messages const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } const { recentMessages: withoutSummary } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 100_000, // huge budget tokenCounter: simpleTokenCounter, // no summary → mode A }); const { recentMessages: withSummary } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 100_000, summary: 'Summary exists', tokenCounter: simpleTokenCounter, }); // Without summary: all messages included (budget-filling mode) expect(withoutSummary.length).toBe(20); // all content messages // With summary: only last 2 rounds expect(withSummary.length).toBeLessThan(withoutSummary.length); }); it('original messages array is never mutated', () => { const messages = buildConversation(15, 400); const originalLength = messages.length; const originalFirstContent = messages[0].content; const originalLastContent = messages[messages.length - 1].content; const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } // Run compaction multiple times for (let i = 0; i < 5; i++) { buildWindowedView({ messages, indexTokenCountMap, maxTokens: 300, summary: `Summary iteration ${i}`, tokenCounter: simpleTokenCounter, }); } // Original array unchanged after 5 compaction runs expect(messages.length).toBe(originalLength); expect(messages[0].content).toBe(originalFirstContent); expect(messages[messages.length - 1].content).toBe(originalLastContent); }); // ── File Manifest in Windowed View ───────────────────────────────────── it('injects file manifest block when files exist and messages are compacted', () => { const messages = buildConversation(20, 400); const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } const manifest: FileManifestEntry[] = [ { fileId: 'f1', filename: 'report.pdf', contentId: 'abc123' }, { fileId: 'f2', filename: 'data.csv', contentId: 'def456', source: 'local', }, ]; const { view, compactedMessages, fileManifestMsg } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 600, summary: 'Summary of earlier turns', tokenCounter: simpleTokenCounter, fileManifest: manifest, }); // File manifest is injected expect(fileManifestMsg).not.toBeNull(); expect(compactedMessages.length).toBeGreaterThan(0); // Manifest message contains file names and content IDs const manifestContent = fileManifestMsg!.content as string; expect(manifestContent).toContain(FILE_MANIFEST_PREFIX); expect(manifestContent).toContain('report.pdf'); expect(manifestContent).toContain('abc123'); expect(manifestContent).toContain('data.csv'); expect(manifestContent).toContain('def456'); // View order: [system] + [summary] + [file manifest] + [recent messages] expect(view[0].getType()).toBe('system'); expect(view[1].content as string).toContain('[Conversation Summary]'); expect(view[2].content as string).toContain(FILE_MANIFEST_PREFIX); // Recent messages follow expect(view.length).toBeGreaterThan(3); }); it('does NOT inject file manifest when no messages are compacted', () => { const messages = buildConversation(4, 100); // small conversation const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } const manifest: FileManifestEntry[] = [ { fileId: 'f1', filename: 'file.txt', contentId: 'aaa' }, ]; const { compactedMessages, fileManifestMsg } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 100_000, // everything fits tokenCounter: simpleTokenCounter, fileManifest: manifest, }); // No compaction happened, so no manifest injected expect(compactedMessages.length).toBe(0); expect(fileManifestMsg).toBeNull(); }); it('does NOT inject file manifest when manifest is empty', () => { const messages = buildConversation(20, 400); const indexTokenCountMap: Record = {}; for (let i = 0; i < messages.length; i++) { indexTokenCountMap[i] = simpleTokenCounter(messages[i]); } const { fileManifestMsg } = buildWindowedView({ messages, indexTokenCountMap, maxTokens: 600, summary: 'Summary', tokenCounter: simpleTokenCounter, fileManifest: [], }); expect(fileManifestMsg).toBeNull(); }); }); // =========================================================================== // File Manifest Utility — Unit Tests // =========================================================================== describe('buildFileManifestBlock', () => { it('returns empty string for undefined manifest', () => { expect(buildFileManifestBlock(undefined)).toBe(''); }); it('returns empty string for empty manifest', () => { expect(buildFileManifestBlock([])).toBe(''); }); it('builds block with file names and content IDs', () => { const manifest: FileManifestEntry[] = [ { fileId: 'f1', filename: 'report.pdf', contentId: 'abc123' }, { fileId: 'f2', filename: 'notes.md', contentId: 'def456' }, ]; const block = buildFileManifestBlock(manifest); expect(block).toContain(FILE_MANIFEST_PREFIX); expect(block).toContain('report.pdf'); expect(block).toContain('(content_id: abc123)'); expect(block).toContain('notes.md'); expect(block).toContain('(content_id: def456)'); expect(block).toContain('file_search or content_tool read'); }); it('includes source when provided', () => { const manifest: FileManifestEntry[] = [ { fileId: 'f1', filename: 'doc.pdf', source: 'sharepoint' }, ]; const block = buildFileManifestBlock(manifest); expect(block).toContain('[sharepoint]'); }); it('handles entries without contentId or source', () => { const manifest: FileManifestEntry[] = [ { fileId: 'f1', filename: 'image.png' }, ]; const block = buildFileManifestBlock(manifest); expect(block).toContain('- image.png'); // No per-file content_id or source brackets in the file line expect(block).not.toContain('(content_id:'); expect(block).not.toContain('[local]'); }); });