| |
| |
| |
| |
| |
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| import { ContextCompressionService } from './contextCompressionService.js'; |
| import type { Config } from '../config/config.js'; |
| import type { Content } from '@google/genai'; |
| import * as fsSync from 'node:fs'; |
|
|
| vi.mock('node:fs/promises', () => ({ |
| readFile: vi.fn(), |
| writeFile: vi.fn(), |
| })); |
|
|
| vi.mock('node:fs', async (importOriginal) => { |
| const actual = await importOriginal<typeof import('node:fs')>(); |
| return { |
| ...actual, |
| existsSync: vi.fn(), |
| }; |
| }); |
|
|
| describe('ContextCompressionService', () => { |
| let mockConfig: Partial<Config>; |
| let service: ContextCompressionService; |
| const generateContentMock: ReturnType<typeof vi.fn> = vi.fn(); |
| const generateJsonMock: ReturnType<typeof vi.fn> = vi.fn(); |
|
|
| beforeEach(() => { |
| mockConfig = { |
| storage: { |
| getProjectTempDir: vi.fn().mockReturnValue('/mock/temp/dir'), |
| }, |
| isContextManagementEnabled: vi.fn().mockResolvedValue(true), |
| getBaseLlmClient: vi.fn().mockReturnValue({ |
| generateContent: generateContentMock, |
| generateJson: generateJsonMock, |
| }), |
| } as unknown as Config; |
|
|
| vi.mocked(fsSync.existsSync).mockReturnValue(false); |
|
|
| service = new ContextCompressionService(mockConfig as Config); |
| }); |
|
|
| afterEach(() => { |
| vi.clearAllMocks(); |
| }); |
|
|
| describe('compressHistory', () => { |
| it('bypasses compression if feature flag is false', async () => { |
| mockConfig.isContextManagementEnabled = vi.fn().mockResolvedValue(false); |
| const history: Content[] = [{ role: 'user', parts: [{ text: 'hello' }] }]; |
|
|
| const res = await service.compressHistory(history, 'test prompt'); |
| expect(res).toStrictEqual(history); |
| }); |
|
|
| it('protects files that were read within the RECENT_TURNS_PROTECTED window', async () => { |
| const history: Content[] = [ |
| |
| { |
| role: 'model', |
| parts: [ |
| { |
| functionCall: { |
| name: 'read_file', |
| args: { filepath: 'src/app.ts' }, |
| }, |
| }, |
| ], |
| }, |
| { |
| role: 'user', |
| parts: [ |
| { |
| functionResponse: { |
| name: 'read_file', |
| response: { |
| output: '--- src/app.ts ---\nLine 1\nLine 2\nLine 3', |
| }, |
| }, |
| }, |
| ], |
| }, |
|
|
| |
| { role: 'model', parts: [{ text: 'res 1' }] }, |
| { role: 'user', parts: [{ text: 'res 2' }] }, |
|
|
| |
| { role: 'model', parts: [{ text: 'res 3' }] }, |
| { role: 'user', parts: [{ text: 'res 4' }] }, |
|
|
| |
| |
| { |
| role: 'model', |
| parts: [ |
| { |
| functionCall: { |
| name: 'read_file', |
| args: { filepath: 'src/app.ts' }, |
| }, |
| }, |
| ], |
| }, |
| { |
| role: 'user', |
| parts: [ |
| { |
| functionResponse: { |
| name: 'read_file', |
| response: { |
| output: '--- src/app.ts ---\nLine 1\nLine 2\nLine 3', |
| }, |
| }, |
| }, |
| ], |
| }, |
| ]; |
|
|
| const res = await service.compressHistory(history, 'test prompt'); |
|
|
| |
| |
| const compressedOutput = |
| res[1].parts![0].functionResponse!.response!['output']; |
| expect(compressedOutput).toBe( |
| '--- src/app.ts ---\nLine 1\nLine 2\nLine 3', |
| ); |
| |
| expect(generateContentMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it('compresses files read outside the protected window', async () => { |
| const history: Content[] = [ |
| |
| { |
| role: 'model', |
| parts: [ |
| { |
| functionCall: { |
| name: 'read_file', |
| args: { filepath: 'src/old.ts' }, |
| }, |
| }, |
| ], |
| }, |
| |
| { |
| role: 'user', |
| parts: [ |
| { |
| functionResponse: { |
| name: 'read_file', |
| response: { |
| output: '--- src/old.ts ---\nLine 1\nLine 2\nLine 3\nLine 4', |
| }, |
| }, |
| }, |
| ], |
| }, |
| |
| { role: 'model', parts: [{ text: 'msg 2' }] }, |
| { role: 'user', parts: [{ text: 'res 2' }] }, |
| { role: 'model', parts: [{ text: 'msg 3' }] }, |
| { role: 'user', parts: [{ text: 'res 3' }] }, |
| { role: 'model', parts: [{ text: 'msg 4' }] }, |
| { role: 'user', parts: [{ text: 'res 4' }] }, |
| ]; |
|
|
| |
| generateJsonMock.mockResolvedValueOnce({ |
| 'src/old.ts': { |
| level: 'PARTIAL', |
| start_line: 2, |
| end_line: 3, |
| }, |
| }); |
|
|
| const res = await service.compressHistory(history, 'test prompt'); |
| const compressedOutput = |
| res[1].parts![0].functionResponse!.response!['output']; |
|
|
| expect(compressedOutput).toContain('[Showing lines 2–3 of 4 in old.ts.'); |
| expect(compressedOutput).toContain('2 | Line 2'); |
| expect(compressedOutput).toContain('3 | Line 3'); |
| }); |
|
|
| it('returns SUMMARY and hits cache on subsequent requests', async () => { |
| const history1: Content[] = [ |
| { |
| role: 'model', |
| parts: [ |
| { |
| functionCall: { |
| name: 'read_file', |
| args: { filepath: 'src/index.ts' }, |
| }, |
| }, |
| ], |
| }, |
| { |
| role: 'user', |
| parts: [ |
| { |
| functionResponse: { |
| name: 'read_file', |
| response: { |
| output: `--- src/index.ts ---\nVery long content here...`, |
| }, |
| }, |
| }, |
| ], |
| }, |
| { role: 'model', parts: [{ text: 'p1' }] }, |
| { role: 'user', parts: [{ text: 'p2' }] }, |
| { role: 'model', parts: [{ text: 'p3' }] }, |
| { role: 'user', parts: [{ text: 'p4' }] }, |
| { role: 'model', parts: [{ text: 'p5' }] }, |
| { role: 'user', parts: [{ text: 'p6' }] }, |
| ]; |
|
|
| |
| generateJsonMock.mockResolvedValueOnce({ |
| 'src/index.ts': { level: 'SUMMARY' }, |
| }); |
| |
| generateContentMock.mockResolvedValueOnce({ |
| candidates: [ |
| { content: { parts: [{ text: 'This is a cached summary.' }] } }, |
| ], |
| }); |
|
|
| await service.compressHistory(history1, 'test query'); |
| expect(generateJsonMock).toHaveBeenCalledTimes(1); |
| expect(generateContentMock).toHaveBeenCalledTimes(1); |
|
|
| |
| const history2: Content[] = [ |
| ...history1, |
| { role: 'model', parts: [{ text: 'p7' }] }, |
| { role: 'user', parts: [{ text: 'p8' }] }, |
| ]; |
|
|
| |
| generateJsonMock.mockResolvedValueOnce({ |
| 'src/index.ts': { level: 'SUMMARY' }, |
| }); |
|
|
| const res = await service.compressHistory(history2, 'new query'); |
|
|
| |
| expect(generateJsonMock).toHaveBeenCalledTimes(1); |
| expect(generateContentMock).toHaveBeenCalledTimes(1); |
|
|
| const compressedOutput = |
| res[1].parts![0].functionResponse!.response!['output']; |
| expect(compressedOutput).toContain('This is a cached summary.'); |
| }); |
| it('returns unmodified history if structural validation fails', async () => { |
| |
| const brokenHistory: Content[] = [ |
| { |
| role: 'model', |
| parts: [ |
| { |
| functionCall: { |
| name: 'read_file', |
| args: { filepath: 'src/index.ts' }, |
| }, |
| }, |
| ], |
| }, |
| |
| { role: 'model', parts: [{ text: 'Wait, I am a model again.' }] }, |
| { role: 'user', parts: [{ text: 'This is invalid.' }] }, |
| { role: 'model', parts: [{ text: 'Yep.' }] }, |
| { role: 'user', parts: [{ text: 'Padding.' }] }, |
| { role: 'model', parts: [{ text: 'Padding.' }] }, |
| ]; |
|
|
| const res = await service.compressHistory(brokenHistory, 'test query'); |
|
|
| |
| expect(res).toBe(brokenHistory); |
| }); |
| }); |
| }); |
|
|