Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 45 additions & 19 deletions integration-tests/context-fidelity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ import type { FakeResponse, HistoryTurn } from '@google/gemini-cli-core';
describe('Context Management Fidelity E2E', () => {
let rig: TestRig;

function generateRandomString(length: number): string {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(
Math.floor(Math.random() * characters.length),
);
}
return result;
}

beforeEach(() => {
rig = new TestRig();
});
Expand Down Expand Up @@ -52,7 +64,7 @@ describe('Context Management Fidelity E2E', () => {

const countTokensResponse: FakeResponse = {
method: 'countTokens',
response: { totalTokens: 50000 },
response: { totalTokens: 1000 },
};

const streamResponse = (text: string): FakeResponse => ({
Expand Down Expand Up @@ -87,7 +99,6 @@ describe('Context Management Fidelity E2E', () => {
},
});

const massivePayload = 'X'.repeat(50000);
const traceDir = path.join(rig.testDir!, 'traces');
fs.mkdirSync(traceDir, { recursive: true });
const traceLog = path.join(traceDir, 'trace.log');
Expand All @@ -105,24 +116,35 @@ describe('Context Management Fidelity E2E', () => {
streamResponse('Ack 3'),
streamResponse('Ack 4'),
streamResponse('Ack 5'),
streamResponse('Ack 6'),
streamResponse('Ack 7'),
streamResponse('Ack 8'),
streamResponse('Ack 9'),
streamResponse('Ack 10'),
streamResponse('Ack 11'),
streamResponse('Ack 12'),
];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}

// Turn 1: Initial massive payload to put pressure
await rig.run({
args: [
'--debug',
'--fake-responses-non-strict',
setupResponses('resp1.json', runMocks),
],
stdin: 'Turn 1: ' + massivePayload,
env: commonEnv,
});
// Turns 1-10: Build up history
for (let i = 1; i <= 10; i++) {
await rig.run({
args: [
'--debug',
i === 1 ? '' : '--resume',
i === 1 ? '' : 'latest',
'--fake-responses-non-strict',
setupResponses(`resp_init_${i}.json`, runMocks),
].filter(Boolean),
stdin: `Turn ${i}: ` + generateRandomString(900),
env: commonEnv,
});
}

// Turn 2: Another turn, resuming Turn 1
// Turn 11: Penultimate turn
await rig.run({
args: [
'--debug',
Expand All @@ -131,11 +153,11 @@ describe('Context Management Fidelity E2E', () => {
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 2: ' + massivePayload,
stdin: 'Turn 11: ' + generateRandomString(900),
env: commonEnv,
});

// Turn 3: Third turn to force GC, resuming Turn 2
// Turn 12: Breach threshold and force GC
await rig.run({
args: [
'--debug',
Expand All @@ -144,7 +166,7 @@ describe('Context Management Fidelity E2E', () => {
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
],
stdin: 'Turn 3: ' + massivePayload,
stdin: 'Turn 12: ' + generateRandomString(900),
env: commonEnv,
});

Expand Down Expand Up @@ -214,12 +236,16 @@ describe('Context Management Fidelity E2E', () => {

// Most importantly, synthetic IDs (like summaries) must be stable.
const syntheticTurns = contextBeforeExit!.filter(
(t: HistoryTurn) => t.id && t.id.length === 32,
); // deriveStableId produces 32-char hex
(t: HistoryTurn) =>
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
(t.id && t.id.length === 32),
);
expect(syntheticTurns.length).toBeGreaterThan(0);

const syntheticTurnsAfter = contextAfterResume!.filter(
(t: HistoryTurn) => t.id && t.id.length === 32,
(t: HistoryTurn) =>
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
(t.id && t.id.length === 32),
);
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
syntheticTurns.length,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/context/config/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ export const stressTestProfile: ContextProfile = {
name: 'Stress Test',
config: {
budget: {
retainedTokens: 4000,
maxTokens: 10000,
retainedTokens: 1500,
maxTokens: 5000,
},
processorOptions: {
ToolMasking: {
Expand Down
27 changes: 14 additions & 13 deletions packages/core/src/context/contextManager.barrier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createSyntheticHistory,
createMockContextConfig,
setupContextComponentTest,
deriveStableId,
} from './testing/contextTestUtils.js';

describe('ContextManager Sync Pressure Barrier Tests', () => {
Expand All @@ -32,10 +33,14 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
);

// 2. Add System Prompt (Episode 0 - Protected)
const envId = deriveStableId(['environment-context']);
chatHistory.set([
{
id: 'h1',
content: { role: 'user', parts: [{ text: 'System prompt' }] },
id: envId,
content: {
role: 'user',
parts: [{ text: '<session_context>\nSystem prompt' }],
},
},
{
id: 'h2',
Expand Down Expand Up @@ -74,8 +79,10 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {

expect(projection.length).toBeLessThan(rawHistoryLength);

// Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation
// Verify Episode 0 (System) was PRESERVED because it is pinned Turn 0.
expect(projection[0].id).toBe(envId);
expect(projection[0].content.role).toBe('user');

const projectionString = JSON.stringify(projection);
expect(projectionString).toContain('User turn 17');
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
Expand All @@ -86,19 +93,13 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
);

// Verify the latest turn is perfectly preserved at the back
// Note: The HistoryHardener appends a "Please continue." user turn if we end on model,
// so we look at the turns before the sentinel.
const lastSentinel = contentNodes[contentNodes.length - 1].content;
const lastModel = contentNodes[contentNodes.length - 2].content;
const lastUser = contentNodes[contentNodes.length - 3].content;
const lastModel = contentNodes[contentNodes.length - 1].content;
const lastUser = contentNodes[contentNodes.length - 2].content;

expect(lastSentinel.role).toBe('user');
expect(lastSentinel.parts![0].text).toBe('Please continue.');
expect(lastModel.role).toBe('model');
expect(lastModel.parts![0].text).toBe('Final answer.');

expect(lastUser.role).toBe('user');
expect(lastUser.parts![0].text).toBe('Final question.');

expect(lastModel.role).toBe('model');
expect(lastModel.parts![0].text).toBe('Final answer.');
});
});
151 changes: 151 additions & 0 deletions packages/core/src/context/contextManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { ContextManager } from './contextManager.js';
import type { ContextProfile } from './config/profiles.js';
import type { ContextEnvironment } from './pipeline/environment.js';
import type { ContextTracer } from './tracer.js';
import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
import type {
AgentChatHistory,
HistoryTurn,
} from '../core/agentChatHistory.js';
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
import { createMockEnvironment } from './testing/contextTestUtils.js';

describe('ContextManager', () => {
let mockSidecar: ContextProfile;
let mockEnv: ContextEnvironment;
let mockTracer: ContextTracer;
let mockOrchestrator: PipelineOrchestrator;
let mockChatHistory: AgentChatHistory;
let mockAdvancedTokenCalculator: AdvancedTokenCalculator;

beforeEach(() => {
vi.resetAllMocks();

mockSidecar = {
name: 'test-profile',
config: { budget: { retainedTokens: 1000, maxTokens: 2000 } },
buildPipelines: vi.fn().mockReturnValue([]),
buildAsyncPipelines: vi.fn().mockReturnValue([]),
} as unknown as ContextProfile;

mockEnv = createMockEnvironment();
mockTracer = mockEnv.tracer;

mockOrchestrator = {
setNodeProvider: vi.fn(),
waitForPipelines: vi.fn().mockResolvedValue(undefined),
executeTriggerSync: vi
.fn()
.mockImplementation(async (trigger, nodes) => nodes),
shutdown: vi.fn(),
} as unknown as PipelineOrchestrator;

mockChatHistory = {
all: vi.fn().mockReturnValue([]),
last: vi.fn(),
getById: vi.fn(),
getTurnById: vi.fn(),
getTurnsByIds: vi.fn(),
getNeighboringTurns: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
get: vi.fn().mockReturnValue([]),
setHistory: vi.fn(),
getHistoryTurns: vi.fn().mockReturnValue([]),
getRawHistory: vi.fn().mockReturnValue([]),
addTurn: vi.fn(),
updateTurn: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
clear: vi.fn(),
subscribe: vi.fn(),
} as unknown as AgentChatHistory;

mockAdvancedTokenCalculator = {
getRawBaseUnits: vi.fn().mockReturnValue(0),
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
calculateTokensAndBaseUnits: vi
.fn()
.mockReturnValue({ tokens: 0, baseUnits: 0 }),
} as unknown as AdvancedTokenCalculator;
});

it('renderHistory should process pendingRequest via the new_message pipeline', async () => {
const contextManager = new ContextManager(
mockSidecar,
mockEnv,
mockTracer,
mockOrchestrator,
mockChatHistory,
mockAdvancedTokenCalculator,
);

const largeToolOutput = 'a'.repeat(10000);
const pendingRequest: HistoryTurn = {
id: 'pending-turn-1',
content: {
role: 'user',
parts: [
{
functionResponse: {
name: 'run_shell_command',
response: {
output: largeToolOutput,
},
},
},
],
},
};

await contextManager.renderHistory(pendingRequest);

expect(mockOrchestrator.executeTriggerSync).toHaveBeenCalledExactlyOnceWith(
'new_message',
expect.any(Array),
expect.any(Set),
);

// Check that the node passed to the orchestrator corresponds to our pendingRequest
const call = (mockOrchestrator.executeTriggerSync as unknown as Mock).mock
.calls[0];
const passedNodes = call[1];
const passedNodeIds = call[2];

expect(passedNodes).toHaveLength(1);
expect(passedNodes[0].type).toBe('TOOL_EXECUTION');
expect(passedNodes[0].payload.functionResponse.response.output).toBe(
largeToolOutput,
);
expect(passedNodeIds.has(passedNodes[0].id)).toBe(true);
});

it('renderHistory should exclude pendingRequest from the result (late binding)', async () => {
const contextManager = new ContextManager(
mockSidecar,
mockEnv,
mockTracer,
mockOrchestrator,
mockChatHistory,
mockAdvancedTokenCalculator,
);

const pendingRequest: HistoryTurn = {
id: 'pending-turn-1',
content: { role: 'user', parts: [{ text: 'Active prompt' }] },
};

const { history, apiHistory } =
await contextManager.renderHistory(pendingRequest);

// Should be empty because mockChatHistory has no historical turns
expect(history).toHaveLength(0);
expect(apiHistory).toHaveLength(0);
});
});
Loading
Loading