Files
gemini-cli/packages/core/src/context/contextCompressionService.test.ts
T
2026-04-02 16:22:04 +00:00

289 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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', () => ({
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[] = [
// Turn 0 & 1 (Old)
{
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',
},
},
},
],
},
// Padding (Turns 2 & 3)
{ role: 'model', parts: [{ text: 'res 1' }] },
{ role: 'user', parts: [{ text: 'res 2' }] },
// Padding (Turns 4 & 5)
{ role: 'model', parts: [{ text: 'res 3' }] },
{ role: 'user', parts: [{ text: 'res 4' }] },
// Recent Turn (Turn 6 & 7, inside window, cutoff is Math.max(0, 8 - 4) = 4)
// Here the model explicitly reads the file again
{
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');
// Because src/app.ts was re-read recently (index 6 is >= 4), the OLD response at index 1 is PROTECTED.
// It should NOT be compressed.
const compressedOutput =
res[1].parts![0].functionResponse!.response!['output'];
expect(compressedOutput).toBe(
'--- src/app.ts ---\nLine 1\nLine 2\nLine 3',
);
// Verify generateContentMock wasn't called because it bypassed the LLM routing
expect(generateContentMock).not.toHaveBeenCalled();
});
it('compresses files read outside the protected window', async () => {
const history: Content[] = [
// Turn 0: The original function call to read the file
{
role: 'model',
parts: [
{
functionCall: {
name: 'read_file',
args: { filepath: 'src/old.ts' },
},
},
],
},
// Turn 1: The tool output response
{
role: 'user',
parts: [
{
functionResponse: {
name: 'read_file',
response: {
output: '--- src/old.ts ---\nLine 1\nLine 2\nLine 3\nLine 4',
},
},
},
],
},
// Padding turns to push it out of the recent window
{ 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' }] },
];
// Mock the routing request to return PARTIAL
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 23 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' }] },
];
// 1st request: routing says SUMMARY
generateJsonMock.mockResolvedValueOnce({
'src/index.ts': { level: 'SUMMARY' },
});
// 2nd request: the actual summarization call
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);
// Time passes, we get a new query. The file is still old.
const history2: Content[] = [
...history1,
{ role: 'model', parts: [{ text: 'p7' }] },
{ role: 'user', parts: [{ text: 'p8' }] },
];
// 3rd request: routing says SUMMARY again.
generateJsonMock.mockResolvedValueOnce({
'src/index.ts': { level: 'SUMMARY' },
});
const res = await service.compressHistory(history2, 'new query');
// It should NOT make a 3rd fetch call for routing, since content has not changed and state is cached.
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 () => {
// Creating a broken history where functionCall is NOT followed by user functionResponse
const brokenHistory: Content[] = [
{
role: 'model',
parts: [
{
functionCall: {
name: 'read_file',
args: { filepath: 'src/index.ts' },
},
},
],
},
// Missing user functionResponse!
{ 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');
// Because it's broken, it should return the exact same array by reference.
expect(res).toBe(brokenHistory);
});
});
});