/** * End-to-End tests for context management across subagents, handoffs, and chaining. * * These tests exercise the full lifecycle of context management scenarios that * span multiple agents and turns, validating that context is preserved, compacted, * and transferred correctly across all agent execution patterns. * * Run with: * npx jest --no-coverage --forceExit src/graphs/contextManagement.e2e.test.ts */ import { HumanMessage, AIMessage, SystemMessage, ToolMessage, BaseMessage, } from '@langchain/core/messages'; import type { TokenCounter } from '@/types/run'; import { summarize, createEmergencySummary, formatMessagesForSummary, buildFullSummaryPrompt, } from '@/messages/summarize'; import { getContextUtilization } from '@/messages/prune'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Simple token counter: 1 token ≈ 4 chars */ const simpleTokenCounter: TokenCounter = (msg: BaseMessage): number => { const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); return Math.ceil(content.length / 4); }; /** Create a realistic conversation with N turns */ function buildConversation(turns: number, charPerMessage = 200): BaseMessage[] { const messages: BaseMessage[] = []; for (let i = 0; i < turns; i++) { messages.push( new HumanMessage({ content: `User message ${i}: ${'x'.repeat(charPerMessage)}`, }) ); messages.push( new AIMessage({ content: `AI response ${i}: ${'y'.repeat(charPerMessage)}`, }) ); } return messages; } /** Simulates a summarize callback that returns a structured summary */ const mockSummarizeCallback = async ( prompt: string, _maxTokens: number ): Promise => { return `[Summary] Conversation covered ${prompt.length} chars of content. Key topics discussed. Active context preserved.`; }; /** Simulates a summarize callback that always fails */ const failingSummarizeCallback = async (): Promise => { throw new Error('LLM unavailable'); }; // ============================================================================ // 1. Subagent Result Truncation E2E // ============================================================================ describe('Subagent result truncation E2E', () => { it('truncated subagent result fits within parent context budget', () => { // Simulate: subagent produces 50K chars, parent has 10K token budget const subagentResult = 'Finding: '.repeat(5000) + 'Conclusion reached.'; const parentMaxTokens = 10000; const maxResultChars = 8192 * 4; // 8192 tokens * 4 chars/token = 32768 // Truncation logic (mirrors TaskTool.truncateResult) const truncationNotice = '\n\n[... sub-agent output truncated — middle section omitted to fit parent context ...]\n\n'; const available = maxResultChars - truncationNotice.length; const headSize = Math.floor(available * 0.6); const tailSize = available - headSize; const truncated = subagentResult.substring(0, headSize) + truncationNotice + subagentResult.substring(subagentResult.length - tailSize); // Result fits in parent budget (32768 chars ≈ 8192 tokens) const resultTokens = Math.ceil(truncated.length / 4); expect(resultTokens).toBeLessThanOrEqual(parentMaxTokens); expect(truncated).toContain('Finding:'); expect(truncated).toContain('Conclusion reached.'); expect(truncated).toContain('sub-agent output truncated'); }); it('multiple subagent results combined still fit parent context', () => { // Simulate 3 parallel subagent tasks each returning large results const maxResultCharsPerSubagent = 8192; const results = [ 'A'.repeat(maxResultCharsPerSubagent), 'B'.repeat(maxResultCharsPerSubagent), 'C'.repeat(maxResultCharsPerSubagent), ]; // Parent context: system + messages + 3 tool results const systemTokens = 500; const conversationTokens = 2000; const totalResultTokens = results.reduce( (sum, r) => sum + Math.ceil(r.length / 4), 0 ); const totalUsed = systemTokens + conversationTokens + totalResultTokens; // With 3 subagents at 8192 chars each (≈2048 tokens each), total ≈ 6144 + 2500 = 8644 tokens // Fits within a 16K context window expect(totalUsed).toBeLessThan(16000); }); it('subagent result truncation preserves actionable content', () => { // Simulate a subagent that found code, data, and conclusions const codeBlock = '```typescript\nfunction process() { return 42; }\n```\n'; const dataSection = 'Data: ' + JSON.stringify({ metric: 99.5, status: 'healthy' }) + '\n'; const middlePadding = 'Analysis details '.repeat(3000); // ~51K chars const conclusion = '\nFinal answer: The system is operating within normal parameters.'; const fullResult = codeBlock + dataSection + middlePadding + conclusion; const maxChars = 2000; // Truncation keeps head (60%) and tail (40%) const notice = '\n\n[... sub-agent output truncated — middle section omitted to fit parent context ...]\n\n'; const available = maxChars - notice.length; const headSize = Math.floor(available * 0.6); const tailSize = available - headSize; const truncated = fullResult.substring(0, headSize) + notice + fullResult.substring(fullResult.length - tailSize); // Head should contain the code and data (actionable) expect(truncated).toContain('function process()'); expect(truncated).toContain('metric'); // Tail should contain the conclusion expect(truncated).toContain('Final answer'); }); }); // ============================================================================ // 2. Agent Handoff with Context Compaction E2E // ============================================================================ describe('Agent handoff with context compaction E2E', () => { it('compacts context before handoff when sender exceeds receiver budget', async () => { // Agent A has 200K context, Agent B has 32K context const senderMessages = buildConversation(50, 500); // 50 turns, ~500 chars each ≈ lots of tokens const receiverMaxTokens = 8000; // 32K chars // Calculate sender's context tokens const senderTokens = senderMessages.reduce( (sum, m) => sum + simpleTokenCounter(m), 0 ); // Check if compaction needed (>70% of receiver budget) const compactionThreshold = receiverMaxTokens * 0.7; expect(senderTokens).toBeGreaterThan(compactionThreshold); // Compact: summarize + keep last 3 messages const result = await summarize(senderMessages, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, summaryBudget: Math.floor(receiverMaxTokens * 0.2), }); expect(result.tier).toBe('full'); expect(result.summary).toBeTruthy(); // Build compacted messages: system summary + last 3 messages const compactedMessages = [ new SystemMessage({ content: `[Handoff Briefing]\n${result.summary}` }), ...senderMessages.slice(-3), ]; // Compacted context fits receiver budget const compactedTokens = compactedMessages.reduce( (sum, m) => sum + simpleTokenCounter(m), 0 ); expect(compactedTokens).toBeLessThan(receiverMaxTokens); }); it('preserves critical context through handoff compaction', async () => { // First message has the user's original intent const messages: BaseMessage[] = [ new HumanMessage({ content: 'Create a quarterly revenue report for Q4 2025 with charts and analysis', }), new AIMessage({ content: 'I will analyze the revenue data.' }), // ... many intermediate messages ... ...buildConversation(20, 300), // Last messages have current progress new HumanMessage({ content: 'Now add the competitor comparison section', }), new AIMessage({ content: 'Adding competitor analysis from the market data...', }), ]; // Emergency summary should capture first user intent and last state const emergency = createEmergencySummary(messages); expect(emergency).toContain('quarterly revenue report'); expect(emergency).toContain('competitor'); // Full summary should also capture it const result = await summarize(messages, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, }); expect(result.summary.length).toBeGreaterThan(0); expect(result.messagesCompacted).toBe(messages.length); }); it('handles handoff to agent with smaller context gracefully', async () => { // Simulate large context being handed to a mini agent const largeContext = buildConversation(100, 400); // Very large conversation const miniAgentBudget = 4000; // Very small budget (~16K chars) // First try full summarize const result = await summarize(largeContext, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, summaryBudget: Math.floor(miniAgentBudget * 0.2), }); // Should produce a summary expect(result.summary.length).toBeGreaterThan(0); // Build handoff context const handoffMessages = [ new SystemMessage({ content: `[Handoff Briefing]\n${result.summary}` }), ...largeContext.slice(-2), // Keep last exchange ]; const handoffTokens = handoffMessages.reduce( (sum, m) => sum + simpleTokenCounter(m), 0 ); // Should fit within mini agent budget with room for its own responses expect(handoffTokens).toBeLessThan(miniAgentBudget * 0.5); }); it('generates error ToolMessage for invalid handoff destination', () => { const agentContexts = new Map([ ['research-agent', { name: 'Research Agent', maxContextTokens: 200000 }], ['writing-agent', { name: 'Writing Agent', maxContextTokens: 32000 }], ]); const invalidDest = 'coding-agent'; expect(agentContexts.has(invalidDest)).toBe(false); // Build error message for self-correction const availableAgents = Array.from(agentContexts.keys()).join(', '); const errorMsg = new ToolMessage({ content: `Agent "${invalidDest}" does not exist. Available agents: ${availableAgents}. Please choose a valid agent.`, tool_call_id: 'handoff_call_123', }); expect(errorMsg.content).toContain('coding-agent'); expect(errorMsg.content).toContain('research-agent'); expect(errorMsg.content).toContain('writing-agent'); }); }); // ============================================================================ // 3. Agent Chaining with Rolling Summaries // ============================================================================ describe('Agent chaining with rolling summaries', () => { it('maintains context across 3-agent chain via rolling summaries', async () => { // Agent A: Research phase const agentAMessages: BaseMessage[] = [ new HumanMessage({ content: 'Research the impact of AI on healthcare' }), new AIMessage({ content: 'Found 15 papers on AI diagnostics, drug discovery, and telemedicine.', }), new HumanMessage({ content: 'Focus on drug discovery findings' }), new AIMessage({ content: 'Drug discovery: AI reduces development time by 40%. Key compounds identified.', }), ]; // Summarize Agent A context before passing to Agent B const summaryA = await summarize(agentAMessages, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, }); expect(summaryA.tier).toBe('full'); // Agent B: Analysis phase — receives Agent A's summary const agentBMessages: BaseMessage[] = [ new SystemMessage({ content: `[Previous Agent Summary]\n${summaryA.summary}`, }), new HumanMessage({ content: 'Analyze the drug discovery data and create visualizations', }), new AIMessage({ content: 'Created 3 charts showing AI impact on drug development timelines.', }), ]; // Summarize Agent B context (including Agent A's summary) before passing to Agent C const summaryB = await summarize(agentBMessages, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, }); expect(summaryB.tier).toBe('full'); // Agent B's summary should reference Agent A's work expect(summaryB.messagesCompacted).toBe(agentBMessages.length); // Agent C: Report generation — receives accumulated summaries const agentCMessages: BaseMessage[] = [ new SystemMessage({ content: `[Chain Summary]\n${summaryB.summary}` }), new HumanMessage({ content: 'Generate the final report with all findings and visualizations', }), ]; // Agent C has enough context to generate the report const cTokens = agentCMessages.reduce( (sum, m) => sum + simpleTokenCounter(m), 0 ); expect(cTokens).toBeLessThan(8000); // Well within any model's budget }); it('chain summary grows but stays within budget', async () => { // Simulate a 5-agent chain where each adds to the rolling summary let rollingSummary = ''; for (let i = 0; i < 5; i++) { const agentMessages: BaseMessage[] = [ ...(rollingSummary ? [ new SystemMessage({ content: `[Chain Summary]\n${rollingSummary}`, }), ] : []), new HumanMessage({ content: `Agent ${i} task: process step ${i}` }), new AIMessage({ content: `Agent ${i} completed step ${i}. ${'Result data '.repeat(50)}`, }), ]; const result = await summarize(agentMessages, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, summaryBudget: 500, }); rollingSummary = result.summary; } // After 5 agents, rolling summary should still be manageable const summaryTokens = Math.ceil(rollingSummary.length / 4); expect(summaryTokens).toBeLessThan(1000); // Summary stays compact expect(rollingSummary.length).toBeGreaterThan(0); }); it('emergency summary preserves chain context when LLM fails', async () => { // Agent chain where LLM fails mid-chain const chainMessages: BaseMessage[] = [ new SystemMessage({ content: '[Chain Summary] Previous agents researched AI healthcare.', }), new HumanMessage({ content: 'Continue the analysis with cost projections', }), new AIMessage({ content: 'Cost analysis shows 30% reduction possible.' }), new ToolMessage({ content: 'spreadsheet data loaded', tool_call_id: 'tool_1', }), new HumanMessage({ content: 'Add regulatory compliance section' }), new AIMessage({ content: 'Regulatory review completed for FDA pathways.', }), ]; // LLM unavailable — falls to emergency const result = await summarize(chainMessages, failingSummarizeCallback, { tokenCounter: simpleTokenCounter, }); expect(result.tier).toBe('emergency'); expect(result.summary).toContain('cost projections'); // First user msg expect(result.summary).toContain('FDA'); // Last AI msg expect(result.summary).toContain('Messages compacted: 6'); // Total count }); }); // ============================================================================ // 4. Continuation Exhaustion and Retry // ============================================================================ describe('Continuation exhaustion and retry', () => { it('detects max_tokens finish reason requiring continuation', () => { const finishReasons = ['length', 'max_tokens', 'end_turn', 'stop', null]; const needsContinuation = finishReasons.filter( (r) => r === 'length' || r === 'max_tokens' ); expect(needsContinuation).toEqual(['length', 'max_tokens']); }); it('enforces maxContinuations limit', () => { const maxContinuations = 5; let continuationCount = 0; const results: string[] = []; // Simulate continuation loop while (continuationCount < maxContinuations) { const finishReason = 'max_tokens'; // Always hits limit if (finishReason !== 'max_tokens' && finishReason !== 'length') { break; } continuationCount++; results.push(`Continuation ${continuationCount}`); } expect(continuationCount).toBe(maxContinuations); expect(results.length).toBe(maxContinuations); }); it('appends truncation notice when all continuations exhausted', () => { const maxContinuations = 5; const continuationCount = 5; const lastFinishReason = 'max_tokens'; const isExhausted = continuationCount >= maxContinuations && lastFinishReason === 'max_tokens'; expect(isExhausted).toBe(true); // Truncation notice appended to last content const truncationNotice = '\n\n[Note: Response was truncated due to length. Ask me to continue if you need the rest.]'; const lastContent = 'Some AI response that was cut off'; const withNotice = lastContent + truncationNotice; expect(withNotice).toContain('truncated due to length'); expect(withNotice).toContain('continue'); }); it('retry-once logic: retries after 2s delay then gives up', async () => { let attempts = 0; let retried = false; const simulateContinuation = async (): Promise => { attempts++; if (attempts === 1) { throw new Error('Temporary failure'); } return 'Recovered content'; }; // First attempt fails, retry once try { await simulateContinuation(); } catch { if (!retried) { retried = true; // In real code: await new Promise(r => setTimeout(r, 2000)); const result = await simulateContinuation(); expect(result).toBe('Recovered content'); } } expect(attempts).toBe(2); expect(retried).toBe(true); }); it('saves partial response with interruption notice on final failure', () => { const partialResponse = 'Here is the beginning of the analysis...'; const interruptionNotice = '\n\n[Response interrupted: An error occurred while generating this response. The content above may be incomplete.]'; const finalContent = partialResponse + interruptionNotice; expect(finalContent).toContain('interrupted'); expect(finalContent).toContain('beginning of the analysis'); }); it('context compaction enables additional continuations', async () => { // After 5 continuations, compact context to free space for more const longConversation = buildConversation(30, 400); // 30 turns // Before compaction: high utilization const totalTokens = longConversation.reduce( (sum, m) => sum + simpleTokenCounter(m), 0 ); const maxTokens = totalTokens + 2000; // Just barely fits const utilBefore = getContextUtilization( Object.fromEntries( longConversation.map((_, i) => [ String(i), simpleTokenCounter(longConversation[i]), ]) ), 500, maxTokens ); expect(utilBefore).toBeGreaterThan(80); // After compaction const result = await summarize(longConversation, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, }); const compactedMessages = [ new SystemMessage({ content: `[Conversation Summary]\n${result.summary}`, }), ...longConversation.slice(-3), ]; const compactedTokens = compactedMessages.reduce( (sum, m) => sum + simpleTokenCounter(m), 0 ); // After compaction, plenty of room for more continuations expect(compactedTokens).toBeLessThan(maxTokens * 0.5); }); }); // ============================================================================ // 5. Pre-invocation Utilization Gate // ============================================================================ describe('Pre-invocation utilization gate', () => { it('emits warning at 70-85% utilization', () => { const maxContextTokens = 10000; const currentTokens = 7500; // 75% const utilization = (currentTokens / maxContextTokens) * 100; const events: Array<{ type: string; level: string }> = []; if (utilization > 95) { events.push({ type: 'ON_CONTEXT_PRESSURE', level: 'emergency' }); } else if (utilization > 85) { events.push({ type: 'ON_CONTEXT_PRESSURE', level: 'critical' }); } else if (utilization > 70) { events.push({ type: 'ON_CONTEXT_PRESSURE', level: 'warning' }); } expect(events).toHaveLength(1); expect(events[0].level).toBe('warning'); }); it('triggers proactive compaction at 85-95% utilization', async () => { const maxContextTokens = 10000; const currentTokens = 9000; // 90% const utilization = (currentTokens / maxContextTokens) * 100; expect(utilization).toBeGreaterThan(85); expect(utilization).toBeLessThan(95); // Should trigger proactive compaction const messages = buildConversation(20, 200); const result = await summarize(messages, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, }); expect(result.summary.length).toBeGreaterThan(0); // After compaction, utilization should drop significantly const compactedTokens = Math.ceil(result.summary.length / 4) + 500; // summary + last few messages const newUtilization = (compactedTokens / maxContextTokens) * 100; expect(newUtilization).toBeLessThan(50); }); it('triggers emergency compaction at >95% utilization', async () => { const maxContextTokens = 10000; const currentTokens = 9600; // 96% const utilization = (currentTokens / maxContextTokens) * 100; expect(utilization).toBeGreaterThan(95); // Emergency: no LLM call, pure extraction const messages = buildConversation(30, 300); const emergency = createEmergencySummary(messages); expect(emergency).toBeTruthy(); expect(emergency.length).toBeLessThan(2000); // Emergency summaries are compact }); it('does NOT inject token budget hints at any utilization level', () => { // Token budget hints were removed to prevent LLM voluntary bail-out. // Context overflow is handled mechanically by pruning + auto-continuation. // See: docs/context-overflow-architecture.md const utilizationLevels = [50, 70, 85, 95, 101]; for (const utilization of utilizationLevels) { const messages = buildConversation(10, 200); // No message should contain raw token numbers or budget percentages for (const msg of messages) { const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); expect(content).not.toMatch(/CONTEXT BUDGET/); expect(content).not.toMatch(/\d+ of \d+ tokens/); } } }); it('post-prune note does not contain token numbers', () => { // After pruning, a context note is injected but it must not // expose any token counts or budget percentages to the LLM const { buildPostPruneNote } = require('@/utils/contextPressure'); const noteWithSummary = buildPostPruneNote(10, true); const noteWithout = buildPostPruneNote(10, false); for (const note of [noteWithSummary, noteWithout]) { expect(note).not.toBeNull(); expect(note).not.toMatch(/\d+%/); expect(note).not.toMatch(/\d+ of \d+ tokens/); expect(note).not.toMatch(/BUDGET/i); expect(note).toContain('task'); } }); }); // ============================================================================ // 6. Emergency Context Preservation (First+Last Pair) // ============================================================================ describe('Emergency context preservation', () => { it('preserves first user message and last AI message from 50-turn conversation', () => { const messages = buildConversation(50, 200); const emergency = createEmergencySummary(messages); // Should contain first user intent expect(emergency).toContain('User message 0'); // Should contain last AI state expect(emergency).toContain('AI response 49'); // Should report correct count expect(emergency).toContain('Messages compacted: 100'); }); it('captures tool names in emergency summary', () => { const messages: BaseMessage[] = [ new HumanMessage({ content: 'Search for revenue data' }), new ToolMessage({ content: 'Found 5 documents', tool_call_id: 'tc_1', name: 'file_search', }), new ToolMessage({ content: 'Web results for Q4', tool_call_id: 'tc_2', name: 'web_search', }), new ToolMessage({ content: 'Code executed successfully', tool_call_id: 'tc_3', name: 'code_execution', }), new AIMessage({ content: 'Analysis complete' }), ]; const emergency = createEmergencySummary(messages); expect(emergency).toContain('file_search'); expect(emergency).toContain('web_search'); expect(emergency).toContain('code_execution'); }); it('emergency summary never throws even with malformed messages', () => { const weirdMessages: BaseMessage[] = [ new HumanMessage({ content: '' }), // Empty // eslint-disable-next-line @typescript-eslint/no-explicit-any new AIMessage({ content: [{ type: 'text', text: '' }] as any }), // Complex empty new SystemMessage({ content: 'system' }), ]; expect(() => createEmergencySummary(weirdMessages)).not.toThrow(); const result = createEmergencySummary(weirdMessages); expect(typeof result).toBe('string'); }); it('preserves context even when all intermediate messages are removed', async () => { const messages: BaseMessage[] = [ new HumanMessage({ content: 'CRITICAL: Build the authentication system for the app', }), ...buildConversation(40, 300), // 40 turns of intermediate work new HumanMessage({ content: 'Now deploy to production' }), new AIMessage({ content: 'Deploying the authentication system to AWS ECS...', }), ]; const emergency = createEmergencySummary(messages); // First user message preserved expect(emergency).toContain('authentication system'); // Last AI response preserved expect(emergency).toContain('Deploying'); }); }); // ============================================================================ // 7. Multi-Agent Context Handoff Scenarios // ============================================================================ describe('Multi-agent context handoff scenarios', () => { it('research agent → writing agent handoff preserves findings', async () => { const researchMessages: BaseMessage[] = [ new HumanMessage({ content: 'Research competitors: Acme Corp, Beta Inc, Gamma Ltd', }), new ToolMessage({ content: 'Acme Corp: $50M revenue, 200 employees', tool_call_id: 'ws_1', name: 'web_search', }), new ToolMessage({ content: 'Beta Inc: $30M revenue, 150 employees', tool_call_id: 'ws_2', name: 'web_search', }), new AIMessage({ content: 'Found competitive data: Acme leads with $50M, Beta at $30M, Gamma data unavailable.', }), ]; // Summarize for handoff const result = await summarize(researchMessages, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, }); expect(result.tier).toBe('full'); expect(result.messagesCompacted).toBe(4); // Writing agent receives the summary const writingContext = [ new SystemMessage({ content: `[Handoff from Research Agent]\n${result.summary}\n\nYou are the Writing Agent. Create a report based on the research above.`, }), ]; const writingTokens = writingContext.reduce( (sum, m) => sum + simpleTokenCounter(m), 0 ); expect(writingTokens).toBeLessThan(2000); }); it('handles cascade handoff: A → B → C → A (circular)', async () => { // A → B handoff const summaryAB = await summarize( buildConversation(5, 200), mockSummarizeCallback, { tokenCounter: simpleTokenCounter, } ); // B → C handoff const summaryBC = await summarize( [ new SystemMessage({ content: `[From A]\n${summaryAB.summary}` }), ...buildConversation(3, 200), ], mockSummarizeCallback, { tokenCounter: simpleTokenCounter } ); // C → A handoff (back to original) const summaryCA = await summarize( [ new SystemMessage({ content: `[From B via A]\n${summaryBC.summary}` }), ...buildConversation(3, 200), ], mockSummarizeCallback, { tokenCounter: simpleTokenCounter } ); // All summaries should exist and be compact expect(summaryAB.summary.length).toBeGreaterThan(0); expect(summaryBC.summary.length).toBeGreaterThan(0); expect(summaryCA.summary.length).toBeGreaterThan(0); // Final summary shouldn't have exploded in size const finalTokens = Math.ceil(summaryCA.summary.length / 4); expect(finalTokens).toBeLessThan(1000); }); it('multi-agent workflow state is included in full summary prompt', () => { const messages: BaseMessage[] = [ new HumanMessage({ content: 'Start multi-agent research' }), new AIMessage({ content: 'Delegating to specialized agents.' }), ]; const formatted = formatMessagesForSummary(messages); const prompt = buildFullSummaryPrompt(formatted, { isMultiAgent: true, agentWorkflowState: { currentAgentId: 'analysis-agent', agentChain: ['research-agent', 'analysis-agent'], pendingAgents: ['writing-agent'], }, }); expect(prompt).toContain('analysis-agent'); expect(prompt).toContain('research-agent'); expect(prompt).toContain('writing-agent'); expect(prompt).toContain('Agent Workflow State'); }); }); // ============================================================================ // 8. summaryModel Dedicated Cheap Model Usage // ============================================================================ describe('summaryModel dedicated cheap model usage', () => { it('summarize passes maxOutputTokens to callback for model configuration', async () => { let capturedMaxTokens: number | undefined; const trackingCallback = async ( prompt: string, maxTokens: number ): Promise => { capturedMaxTokens = maxTokens; return '[Summary] Tracked callback.'; }; await summarize(buildConversation(5, 200), trackingCallback, { tokenCounter: simpleTokenCounter, maxOutputTokens: 2048, }); expect(capturedMaxTokens).toBe(2048); }); it('Tier 2 uses lower maxTokens (512) than Tier 1', async () => { const capturedMaxTokens: number[] = []; let callCount = 0; const tieredCallback = async ( prompt: string, maxTokens: number ): Promise => { capturedMaxTokens.push(maxTokens); callCount++; if (callCount === 1) { throw new Error('Tier 1 failed'); // Force fallback to Tier 2 } return '[Simple Summary]'; }; await summarize(buildConversation(5, 200), tieredCallback, { tokenCounter: simpleTokenCounter, maxOutputTokens: 1024, }); // Tier 1 tried with 1024, Tier 2 tried with 512 expect(capturedMaxTokens[0]).toBe(1024); expect(capturedMaxTokens[1]).toBe(512); }); it('summaryModel config follows titleModel pattern', () => { // Verify the config structure matches expectations const endpointConfig = { titleModel: 'us.amazon.nova-micro-v1:0', summaryModel: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', modelDisplayLabel: 'Test', }; // summaryModel should be a different, cheaper model than the main model expect(endpointConfig.summaryModel).not.toBe(endpointConfig.titleModel); expect(endpointConfig.summaryModel).toContain('haiku'); // Cheap model }); it('falls back through provider defaults when no summaryModel configured', () => { const providers = [ { name: 'bedrock', expected: 'claude-3-haiku-20240307' }, { name: 'anthropic', expected: 'claude-3-haiku-20240307' }, { name: 'openAI', expected: 'gpt-4o-mini' }, { name: 'google', expected: 'gemini-2.0-flash' }, { name: 'unknown', expected: 'claude-3-haiku-20240307' }, ]; for (const { name, expected } of providers) { let summaryModel: string; if (name === 'bedrock' || name === 'anthropic') { summaryModel = 'claude-3-haiku-20240307'; } else if (name === 'openAI' || name === 'azureOpenAI') { summaryModel = 'gpt-4o-mini'; } else if (name === 'google') { summaryModel = 'gemini-2.0-flash'; } else { summaryModel = 'claude-3-haiku-20240307'; } expect(summaryModel).toBe(expected); } }); }); // ============================================================================ // 9. Config Wiring Validation // ============================================================================ describe('Config wiring validation', () => { it('contextManagement config has all required fields', () => { const config = { compactMode: 'auto', recoveryMode: 'summarize', continuationRetries: 1, toolResultBudgetPct: 0.3, subagentMaxResultTokens: 8192, chainRollingSummary: false, fileReadDedup: true, compactionAudit: true, }; // All fields present expect(config.compactMode).toBeDefined(); expect(config.recoveryMode).toBeDefined(); expect(config.continuationRetries).toBeDefined(); expect(config.toolResultBudgetPct).toBeDefined(); expect(config.subagentMaxResultTokens).toBeDefined(); expect(config.chainRollingSummary).toBeDefined(); expect(config.fileReadDedup).toBeDefined(); expect(config.compactionAudit).toBeDefined(); }); it('subagentMaxResultTokens converts to chars correctly', () => { const tokenValues = [4096, 8192, 16384, 32768]; for (const tokens of tokenValues) { const chars = tokens * 4; expect(chars).toBe(tokens * 4); // Chars should be reasonable (not exceeding 200K chars) expect(chars).toBeLessThan(200000); } }); it('compactMode values are valid', () => { const validModes = ['auto', 'summarize', 'prune']; for (const mode of validModes) { expect(['auto', 'summarize', 'prune']).toContain(mode); } }); it('recoveryMode values are valid', () => { const validModes = ['summarize', 'prune', 'emergency']; for (const mode of validModes) { expect(['summarize', 'prune', 'emergency']).toContain(mode); } }); }); // ============================================================================ // 10. Stress Tests: Large Conversations // ============================================================================ describe('Stress tests with large conversations', () => { it('handles 200-turn conversation summarization', async () => { const messages = buildConversation(200, 100); // 400 messages expect(messages.length).toBe(400); const result = await summarize(messages, mockSummarizeCallback, { tokenCounter: simpleTokenCounter, }); expect(result.summary.length).toBeGreaterThan(0); expect(result.messagesCompacted).toBe(400); }); it('emergency summary from 500-turn conversation is still compact', () => { const messages = buildConversation(500, 50); // 1000 messages const emergency = createEmergencySummary(messages); // Emergency summary should be compact regardless of input size expect(emergency.length).toBeLessThan(3000); expect(emergency).toContain('Messages compacted: 1000'); }); it('getContextUtilization handles 1000-message token map', () => { const map: Record = {}; for (let i = 0; i < 1000; i++) { map[String(i)] = 100; } const result = getContextUtilization(map, 500, 200000); // 1000 * 100 + 500 = 100500 / 200000 = 50.25% expect(result).toBeCloseTo(50.25, 1); }); });