mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 05:24:34 -07:00
289 lines
9.0 KiB
TypeScript
289 lines
9.0 KiB
TypeScript
|
|
/**
|
|||
|
|
* @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 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' }] },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// 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);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|