/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AgentHistoryProvider } from './agentHistoryProvider.js'; import { estimateTokenCountSync } from '../utils/tokenCalculation.js'; vi.mock('../utils/tokenCalculation.js', () => ({ estimateTokenCountSync: vi.fn(), ASCII_TOKENS_PER_CHAR: 0.25, NON_ASCII_TOKENS_PER_CHAR: 1.3, })); import type { Content, GenerateContentResponse, Part } from '@google/genai'; import type { Config } from '../config/config.js'; import type { BaseLlmClient } from '../core/baseLlmClient.js'; import type { AgentHistoryProviderConfig, ContextManagementConfig, } from './types.js'; import { TEXT_TRUNCATION_PREFIX, TOOL_TRUNCATION_PREFIX, truncateProportionally, } from './truncation.js'; describe('AgentHistoryProvider', () => { let config: Config; let provider: AgentHistoryProvider; let providerConfig: AgentHistoryProviderConfig; let generateContentMock: ReturnType; beforeEach(() => { config = { isExperimentalAgentHistoryTruncationEnabled: vi .fn() .mockReturnValue(false), getContextManagementConfig: vi.fn().mockReturnValue(false), getBaseLlmClient: vi.fn(), } as unknown as Config; // By default, messages are small vi.mocked(estimateTokenCountSync).mockImplementation( (parts: Part[]) => parts.length * 100, ); generateContentMock = vi.fn().mockResolvedValue({ candidates: [{ content: { parts: [{ text: 'Mock intent summary' }] } }], } as unknown as GenerateContentResponse); config.getBaseLlmClient = vi.fn().mockReturnValue({ generateContent: generateContentMock, } as unknown as BaseLlmClient); providerConfig = { maxTokens: 60000, retainedTokens: 40000, normalMessageTokens: 2500, maximumMessageTokens: 10000, normalizationHeadRatio: 0.2, }; provider = new AgentHistoryProvider(providerConfig, config); }); const createMockHistory = (count: number): Content[] => Array.from({ length: count }).map((_, i) => ({ role: i % 2 === 0 ? 'user' : 'model', parts: [{ text: `Message ${i}` }], })); it('should return history unchanged if length is under threshold', async () => { const history = createMockHistory(20); // Threshold is 30 const result = await provider.manageHistory(history); expect(result).toBe(history); expect(result.length).toBe(20); }); it('should truncate when total tokens exceed budget, preserving structural integrity', async () => { providerConfig.maxTokens = 60000; providerConfig.retainedTokens = 60000; vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({ enabled: false, } as unknown as ContextManagementConfig); // Make each message cost 4000 tokens vi.mocked(estimateTokenCountSync).mockImplementation( (parts: Part[]) => parts.length * 4000, ); const history = createMockHistory(35); // 35 * 4000 = 140,000 total tokens > maxTokens const result = await provider.manageHistory(history); expect(result.length).toBe(15); // Budget = 60000. Each message costs 4000. 60000 / 4000 = 15. }); it('should call summarizer and prepend summary', async () => { providerConfig.maxTokens = 60000; providerConfig.retainedTokens = 60000; vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({ enabled: true, } as unknown as ContextManagementConfig); vi.mocked(estimateTokenCountSync).mockImplementation( (parts: Part[]) => parts.length * 4000, ); const history = createMockHistory(35); const result = await provider.manageHistory(history); expect(generateContentMock).toHaveBeenCalled(); expect(result.length).toBe(15); expect(result[0].role).toBe('user'); expect(result[0].parts![0].text).toContain(''); expect(result[0].parts![0].text).toContain('Mock intent summary'); }); it('should handle summarizer failures gracefully', async () => { providerConfig.maxTokens = 60000; providerConfig.retainedTokens = 60000; vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({ enabled: true, } as unknown as ContextManagementConfig); vi.mocked(estimateTokenCountSync).mockImplementation( (parts: Part[]) => parts.length * 4000, ); generateContentMock.mockRejectedValue(new Error('API Error')); const history = createMockHistory(35); const result = await provider.manageHistory(history); expect(generateContentMock).toHaveBeenCalled(); expect(result.length).toBe(15); // Should fallback to fallback text expect(result[0].parts![0].text).toContain( '[System Note: Conversation History Truncated]', ); }); it('should pass the contextual bridge to the summarizer', async () => { vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({ enabled: true, } as unknown as ContextManagementConfig); // Max tokens 30 means if total tokens > 30, it WILL truncate. providerConfig.maxTokens = 30; // budget 20 tokens means it will keep 2 messages if they are 10 each. providerConfig.retainedTokens = 20; vi.mocked(estimateTokenCountSync).mockImplementation( (parts: Part[]) => parts.length * 10, ); const history: Content[] = [ { role: 'user', parts: [{ text: 'Old Message' }] }, { role: 'model', parts: [{ text: 'Old Response' }] }, { role: 'user', parts: [{ text: 'Keep 1' }] }, { role: 'user', parts: [{ text: 'Keep 2' }] }, ]; await provider.manageHistory(history); expect(generateContentMock).toHaveBeenCalled(); const callArgs = generateContentMock.mock.calls[0][0]; const prompt = callArgs.contents[0].parts[0].text; expect(prompt).toContain('ACTIVE BRIDGE (LOOKAHEAD):'); expect(prompt).toContain('Keep 1'); expect(prompt).toContain('Keep 2'); }); it('should detect a previous summary in the truncated head', async () => { vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({ enabled: true, } as unknown as ContextManagementConfig); providerConfig.maxTokens = 20; providerConfig.retainedTokens = 10; vi.mocked(estimateTokenCountSync).mockImplementation( (parts: Part[]) => parts.length * 10, ); const history: Content[] = [ { role: 'user', parts: [{ text: 'Previous Mandate' }], }, { role: 'model', parts: [{ text: 'Work' }] }, { role: 'user', parts: [{ text: 'New Work' }] }, ]; await provider.manageHistory(history); expect(generateContentMock).toHaveBeenCalled(); const callArgs = generateContentMock.mock.calls[0][0]; const prompt = callArgs.contents[0].parts[0].text; expect(prompt).toContain('1. **Previous Summary:**'); expect(prompt).toContain('PREVIOUS SUMMARY AND TRUNCATED HISTORY:'); }); it('should include the Action Path (necklace of function names) in the prompt', async () => { vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({ enabled: true, } as unknown as ContextManagementConfig); providerConfig.maxTokens = 20; providerConfig.retainedTokens = 10; vi.mocked(estimateTokenCountSync).mockImplementation( (parts: Part[]) => parts.length * 10, ); const history: Content[] = [ { role: 'model', parts: [ { functionCall: { name: 'tool_a', args: {} } }, { functionCall: { name: 'tool_b', args: {} } }, ], }, { role: 'user', parts: [{ text: 'Keep' }] }, ]; await provider.manageHistory(history); expect(generateContentMock).toHaveBeenCalled(); const callArgs = generateContentMock.mock.calls[0][0]; const prompt = callArgs.contents[0].parts[0].text; expect(prompt).toContain('The Action Path:'); expect(prompt).toContain('tool_a → tool_b'); }); describe('Tiered Normalization Logic', () => { it('normalizes large messages incrementally: newest and exit-grace', async () => { providerConfig.retainedTokens = 30000; providerConfig.maximumMessageTokens = 10000; providerConfig.normalMessageTokens = 2500; // History of 35 messages. // Index 34: Newest (Grace Zone) -> Target 10000 tokens (~40000 chars) // Index 19: Exit Grace (35-1-15=19) -> Target 2500 tokens (~10000 chars) // Index 10: Archived -> Should NOT be normalized in this turn (Incremental optimization) const history = createMockHistory(35); const hugeText = 'H'.repeat(100000); history[34] = { role: 'user', parts: [{ text: hugeText }] }; history[19] = { role: 'model', parts: [{ text: hugeText }] }; history[10] = { role: 'user', parts: [{ text: hugeText }] }; // Mock token count to trigger normalization (100k chars = 25k tokens @ 4 chars/token) vi.mocked(estimateTokenCountSync).mockImplementation((parts: Part[]) => { if (!parts?.[0]) return 10; const text = parts[0].text || ''; if (text.startsWith('H')) return 25000; return 10; }); const result = await provider.manageHistory(history); // 1. Newest message (index 34) normalized to ~40000 chars const normalizedLast = result[34].parts![0].text!; expect(normalizedLast).toContain(TEXT_TRUNCATION_PREFIX); expect(normalizedLast.length).toBeLessThan(50000); expect(normalizedLast.length).toBeGreaterThan(30000); // 2. Exit grace message (index 19) normalized to ~10000 chars const normalizedArchived = result[19].parts![0].text!; expect(normalizedArchived).toContain(TEXT_TRUNCATION_PREFIX); expect(normalizedArchived.length).toBeLessThan(15000); expect(normalizedArchived.length).toBeGreaterThan(8000); // 3. Archived message (index 10) IS touched and normalized to ~10000 chars const normalizedPastArchived = result[10].parts![0].text!; expect(normalizedPastArchived).toContain(TEXT_TRUNCATION_PREFIX); expect(normalizedPastArchived.length).toBeLessThan(15000); expect(normalizedPastArchived.length).toBeGreaterThan(8000); }); it('normalize function responses correctly by targeting large string values', async () => { providerConfig.maximumMessageTokens = 1000; const hugeValue = 'O'.repeat(5000); const history: Content[] = [ { role: 'user', parts: [ { functionResponse: { name: 'test_tool', id: '1', response: { stdout: hugeValue, stderr: 'small error', exitCode: 0, }, }, }, ], }, ]; vi.mocked(estimateTokenCountSync).mockImplementation( (parts: readonly Part[]) => { if (parts?.[0]?.functionResponse) return 5000; return 10; }, ); const result = await provider.manageHistory(history); const fr = result[0].parts![0].functionResponse!; const resp = fr.response as Record; // stdout should be truncated expect(resp['stdout']).toContain(TOOL_TRUNCATION_PREFIX); expect((resp['stdout'] as string).length).toBeLessThan(hugeValue.length); // stderr and exitCode should be PRESERVED (JSON integrity) expect(resp['stderr']).toBe('small error'); expect(resp['exitCode']).toBe(0); // Schema should be intact expect(fr.name).toBe('test_tool'); expect(fr.id).toBe('1'); }); }); describe('truncateProportionally', () => { it('returns original string if under target chars', () => { const str = 'A'.repeat(50); expect(truncateProportionally(str, 100, TEXT_TRUNCATION_PREFIX)).toBe( str, ); }); it('truncates proportionally with prefix and ellipsis', () => { const str = 'A'.repeat(500) + 'B'.repeat(500); // 1000 chars const target = 100; const result = truncateProportionally( str, target, TEXT_TRUNCATION_PREFIX, ); expect(result.startsWith(TEXT_TRUNCATION_PREFIX)).toBe(true); expect(result).toContain('\n...\n'); // The prefix and ellipsis take up some space // It should keep ~20% head and ~80% tail of the *available* space const ellipsis = '\n...\n'; const overhead = TEXT_TRUNCATION_PREFIX.length + ellipsis.length + 1; // +1 for the newline after prefix const availableChars = Math.max(0, target - overhead); const expectedHeadChars = Math.floor(availableChars * 0.2); const expectedTailChars = availableChars - expectedHeadChars; // Extract parts around the ellipsis const parts = result.split(ellipsis); expect(parts.length).toBe(2); // Remove prefix + newline from the first part to check head length const actualHead = parts[0].replace(TEXT_TRUNCATION_PREFIX + '\n', ''); const actualTail = parts[1]; expect(actualHead.length).toBe(expectedHeadChars); expect(actualTail.length).toBe(expectedTailChars); }); it('handles very small targets gracefully by just returning prefix', () => { const str = 'A'.repeat(100); const result = truncateProportionally(str, 10, TEXT_TRUNCATION_PREFIX); expect(result).toBe(TEXT_TRUNCATION_PREFIX); }); }); describe('Multi-part Proportional Normalization', () => { it('distributes token budget proportionally across multiple large parts', async () => { providerConfig.maximumMessageTokens = 2500; // Small limit to trigger normalization on last msg const history = createMockHistory(35); // Make newest message (index 34) have two large parts // Part 1: 10000 chars (~2500 tokens at 4 chars/token) // Part 2: 30000 chars (~7500 tokens at 4 chars/token) // Total tokens = 10000. Target = 2500. Ratio = 0.25. const part1Text = 'A'.repeat(10000); const part2Text = 'B'.repeat(30000); history[34] = { role: 'user', parts: [{ text: part1Text }, { text: part2Text }], }; vi.mocked(estimateTokenCountSync).mockImplementation( (parts: readonly Part[]) => { if (!parts || parts.length === 0) return 0; let tokens = 0; for (const p of parts) { if (p.text?.startsWith('A')) tokens += 2500; else if (p.text?.startsWith('B')) tokens += 7500; else tokens += 10; } return tokens; }, ); const result = await provider.manageHistory(history); const normalizedMsg = result[34]; expect(normalizedMsg.parts!.length).toBe(2); const p1 = normalizedMsg.parts![0].text!; const p2 = normalizedMsg.parts![1].text!; expect(p1).toContain(TEXT_TRUNCATION_PREFIX); expect(p2).toContain(TEXT_TRUNCATION_PREFIX); // Part 1: Target chars ~ 2500 * 0.25 * 4 = 2500 // Part 2: Target chars ~ 7500 * 0.25 * 4 = 7500 expect(p1.length).toBeLessThan(3500); expect(p2.length).toBeLessThan(9000); expect(p1.length).toBeLessThan(p2.length); }); it('preserves small parts while truncating large parts in the same message', async () => { providerConfig.maximumMessageTokens = 2500; const history = createMockHistory(35); const smallText = 'Hello I am small'; const hugeText = 'B'.repeat(40000); // 10000 tokens history[34] = { role: 'user', parts: [{ text: smallText }, { text: hugeText }], }; vi.mocked(estimateTokenCountSync).mockImplementation( (parts: readonly Part[]) => { if (!parts || parts.length === 0) return 0; let tokens = 0; for (const p of parts) { if (p.text === smallText) tokens += 10; else if (p.text?.startsWith('B')) tokens += 10000; else tokens += 10; } return tokens; }, ); const result = await provider.manageHistory(history); const normalizedMsg = result[34]; expect(normalizedMsg.parts!.length).toBe(2); const p1 = normalizedMsg.parts![0].text!; const p2 = normalizedMsg.parts![1].text!; // Small part should be preserved expect(p1).toBe(smallText); // Huge part should be truncated expect(p2).toContain(TEXT_TRUNCATION_PREFIX); // Target tokens for huge part = ~2500 * (10000/10010) = ~2500 // Target chars = ~10000 expect(p2.length).toBeLessThan(12000); }); }); });