Skip to content

Commit 055e0f6

Browse files
authored
fix(context): Fix snapshot recovery across sessions. (#26939)
1 parent 9d01958 commit 055e0f6

57 files changed

Lines changed: 3142 additions & 750 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8+
import { TestRig } from './test-helper.js';
9+
import * as path from 'node:path';
10+
import * as fs from 'node:fs';
11+
import { FinishReason, GenerateContentResponse } from '@google/genai';
12+
import type { FakeResponse, HistoryTurn } from '@google/gemini-cli-core';
13+
14+
describe('Context Management Fidelity E2E', () => {
15+
let rig: TestRig;
16+
17+
beforeEach(() => {
18+
rig = new TestRig();
19+
});
20+
21+
afterEach(async () => await rig.cleanup());
22+
23+
it('should reproduce the exact context working buffer on resume', async () => {
24+
// Mock responses to trigger GC (summarization)
25+
const snapshotResponse: FakeResponse = {
26+
method: 'generateContent',
27+
response: {
28+
candidates: [
29+
{
30+
content: {
31+
parts: [
32+
{
33+
text: JSON.stringify({
34+
new_facts: ['GC Triggered.'],
35+
new_constraints: [],
36+
new_tasks: [],
37+
resolved_task_ids: [],
38+
obsolete_fact_indices: [],
39+
obsolete_constraint_indices: [],
40+
chronological_summary: 'Snapshot created.',
41+
}),
42+
},
43+
],
44+
role: 'model',
45+
},
46+
finishReason: FinishReason.STOP,
47+
index: 0,
48+
},
49+
],
50+
} as unknown as GenerateContentResponse,
51+
};
52+
53+
const countTokensResponse: FakeResponse = {
54+
method: 'countTokens',
55+
response: { totalTokens: 50000 },
56+
};
57+
58+
const streamResponse = (text: string): FakeResponse => ({
59+
method: 'generateContentStream',
60+
response: [
61+
{
62+
candidates: [
63+
{
64+
content: { parts: [{ text }], role: 'model' },
65+
finishReason: FinishReason.STOP,
66+
index: 0,
67+
},
68+
],
69+
},
70+
] as unknown as GenerateContentResponse[],
71+
});
72+
73+
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
74+
const filePath = path.join(rig.testDir!, fileName);
75+
fs.writeFileSync(
76+
filePath,
77+
mocks.map((m) => JSON.stringify(m)).join('\n'),
78+
);
79+
return filePath;
80+
};
81+
82+
await rig.setup('context-fidelity', {
83+
settings: {
84+
experimental: {
85+
stressTestProfile: true, // Lowers thresholds to trigger GC easily
86+
},
87+
},
88+
});
89+
90+
const massivePayload = 'X'.repeat(50000);
91+
const traceDir = path.join(rig.testDir!, 'traces');
92+
fs.mkdirSync(traceDir, { recursive: true });
93+
const traceLog = path.join(traceDir, 'trace.log');
94+
95+
const commonEnv = {
96+
GEMINI_API_KEY: 'mock-key',
97+
GEMINI_CONTEXT_TRACE_DIR: traceDir,
98+
GEMINI_CONTEXT_TRACE_ENABLED: 'true',
99+
GEMINI_DEBUG_LOG_FILE: path.join(rig.testDir!, 'debug.log'),
100+
};
101+
102+
const runMocks: FakeResponse[] = [
103+
streamResponse('Ack 1'),
104+
streamResponse('Ack 2'),
105+
streamResponse('Ack 3'),
106+
streamResponse('Ack 4'),
107+
streamResponse('Ack 5'),
108+
];
109+
for (let i = 0; i < 50; i++) {
110+
runMocks.push(snapshotResponse);
111+
runMocks.push(countTokensResponse);
112+
}
113+
114+
// Turn 1: Initial massive payload to put pressure
115+
await rig.run({
116+
args: [
117+
'--debug',
118+
'--fake-responses-non-strict',
119+
setupResponses('resp1.json', runMocks),
120+
],
121+
stdin: 'Turn 1: ' + massivePayload,
122+
env: commonEnv,
123+
});
124+
125+
// Turn 2: Another turn, resuming Turn 1
126+
await rig.run({
127+
args: [
128+
'--debug',
129+
'--resume',
130+
'latest',
131+
'--fake-responses-non-strict',
132+
setupResponses('resp2.json', runMocks),
133+
],
134+
stdin: 'Turn 2: ' + massivePayload,
135+
env: commonEnv,
136+
});
137+
138+
// Turn 3: Third turn to force GC, resuming Turn 2
139+
await rig.run({
140+
args: [
141+
'--debug',
142+
'--resume',
143+
'latest',
144+
'--fake-responses-non-strict',
145+
setupResponses('resp3.json', runMocks),
146+
],
147+
stdin: 'Turn 3: ' + massivePayload,
148+
env: commonEnv,
149+
});
150+
151+
// Extract the rendered context asset from the log
152+
const getRenderedContext = (logContent: string): HistoryTurn[] | null => {
153+
const lines = logContent.split('\n');
154+
const renderLines = lines.filter(
155+
(l) =>
156+
l.includes('[Render] Render Sanitized Context for LLM') ||
157+
l.includes('[Render] Render Context for LLM'),
158+
);
159+
if (renderLines.length === 0) return null;
160+
161+
const lastRender = renderLines[renderLines.length - 1];
162+
const detailsMatch = lastRender.match(/\| Details: (.*)$/);
163+
if (!detailsMatch) return null;
164+
165+
const details = JSON.parse(detailsMatch[1]);
166+
const assetInfo =
167+
details.renderedContextSanitized || details.renderedContext;
168+
if (assetInfo && assetInfo.$asset) {
169+
const assetPath = path.join(traceDir, 'assets', assetInfo.$asset);
170+
return JSON.parse(fs.readFileSync(assetPath, 'utf-8'));
171+
}
172+
return assetInfo;
173+
};
174+
175+
const log1 = fs.readFileSync(traceLog, 'utf-8');
176+
const contextBeforeExit = getRenderedContext(log1);
177+
expect(contextBeforeExit).toBeDefined();
178+
console.log(
179+
'Context Before Exit (First 2 turns):',
180+
JSON.stringify(contextBeforeExit!.slice(0, 2), null, 2),
181+
);
182+
183+
// Turn 4: Resume and run a small command
184+
await rig.run({
185+
args: [
186+
'--debug',
187+
'--resume',
188+
'latest',
189+
'--fake-responses-non-strict',
190+
setupResponses('resp4.json', runMocks),
191+
'continue',
192+
],
193+
env: commonEnv,
194+
});
195+
196+
const log2 = fs.readFileSync(traceLog, 'utf-8');
197+
const contextAfterResume = getRenderedContext(log2);
198+
expect(contextAfterResume).toBeDefined();
199+
console.log(
200+
'Context After Resume (First 2 turns):',
201+
JSON.stringify(contextAfterResume!.slice(0, 2), null, 2),
202+
);
203+
204+
expect(contextAfterResume!.length).toBeGreaterThanOrEqual(
205+
contextBeforeExit!.length,
206+
);
207+
208+
for (let i = 0; i < contextBeforeExit!.length; i++) {
209+
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
210+
expect(contextAfterResume![i].content).toEqual(
211+
contextBeforeExit![i].content,
212+
);
213+
}
214+
215+
// Most importantly, synthetic IDs (like summaries) must be stable.
216+
const syntheticTurns = contextBeforeExit!.filter(
217+
(t: HistoryTurn) => t.id && t.id.length === 32,
218+
); // deriveStableId produces 32-char hex
219+
expect(syntheticTurns.length).toBeGreaterThan(0);
220+
221+
const syntheticTurnsAfter = contextAfterResume!.filter(
222+
(t: HistoryTurn) => t.id && t.id.length === 32,
223+
);
224+
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
225+
syntheticTurns.length,
226+
);
227+
228+
// Check if the first synthetic turn is identical
229+
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
230+
expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content);
231+
});
232+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8+
import { TestRig } from './test-helper.js';
9+
import * as path from 'node:path';
10+
import * as fs from 'node:fs';
11+
import { FinishReason, GenerateContentResponse } from '@google/genai';
12+
import type { FakeResponse } from '@google/gemini-cli-core';
13+
14+
describe('Context Management Resume E2E', () => {
15+
let rig: TestRig;
16+
17+
beforeEach(() => {
18+
rig = new TestRig();
19+
});
20+
21+
afterEach(async () => await rig.cleanup());
22+
23+
it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => {
24+
const snapshotResponse: FakeResponse = {
25+
method: 'generateContent',
26+
response: {
27+
candidates: [
28+
{
29+
content: {
30+
parts: [
31+
{
32+
text: JSON.stringify({
33+
new_facts: ['GC Triggered.'],
34+
new_constraints: [],
35+
new_tasks: [],
36+
resolved_task_ids: [],
37+
obsolete_fact_indices: [],
38+
obsolete_constraint_indices: [],
39+
chronological_summary: 'Snapshot created.',
40+
}),
41+
},
42+
],
43+
role: 'model',
44+
},
45+
finishReason: FinishReason.STOP,
46+
index: 0,
47+
},
48+
],
49+
} as unknown as GenerateContentResponse,
50+
};
51+
52+
const countTokensResponse: FakeResponse = {
53+
method: 'countTokens',
54+
response: { totalTokens: 50000 },
55+
};
56+
57+
const streamResponse = (text: string): FakeResponse => ({
58+
method: 'generateContentStream',
59+
response: [
60+
{
61+
candidates: [
62+
{
63+
content: { parts: [{ text }], role: 'model' },
64+
finishReason: FinishReason.STOP,
65+
index: 0,
66+
},
67+
],
68+
},
69+
] as unknown as GenerateContentResponse[],
70+
});
71+
72+
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
73+
const filePath = path.join(rig.testDir!, fileName);
74+
fs.writeFileSync(
75+
filePath,
76+
mocks.map((m) => JSON.stringify(m)).join('\n'),
77+
);
78+
return filePath;
79+
};
80+
81+
await rig.setup('resume-gc-snapshot', {
82+
settings: {
83+
experimental: {
84+
stressTestProfile: true,
85+
},
86+
},
87+
});
88+
89+
const massivePayload = 'X'.repeat(40000);
90+
const logFile = path.join(rig.testDir!, 'debug.log');
91+
const traceDir = path.join(rig.testDir!, 'traces');
92+
fs.mkdirSync(traceDir, { recursive: true });
93+
const traceLog = path.join(traceDir, 'trace.log');
94+
95+
const commonEnv = {
96+
GEMINI_API_KEY: 'mock-key',
97+
GEMINI_DEBUG_LOG_FILE: logFile,
98+
GEMINI_CONTEXT_TRACE_DIR: traceDir,
99+
};
100+
101+
// Provide a massive pool of responses to prevent exhaustion
102+
const runMocks: FakeResponse[] = [streamResponse('Acknowledged block.')];
103+
for (let i = 0; i < 50; i++) {
104+
runMocks.push(snapshotResponse);
105+
runMocks.push(countTokensResponse);
106+
}
107+
108+
// Use stdin for the massive payload to avoid ENAMETOOLONG on Windows
109+
await rig.run({
110+
args: [
111+
'--debug',
112+
'--fake-responses-non-strict',
113+
setupResponses('resp1.json', runMocks),
114+
],
115+
stdin: 'Turn 1: ' + massivePayload,
116+
env: commonEnv,
117+
});
118+
119+
await rig.run({
120+
args: [
121+
'--debug',
122+
'--resume',
123+
'latest',
124+
'--fake-responses-non-strict',
125+
setupResponses('resp2.json', runMocks),
126+
],
127+
stdin: 'Turn 2: ' + massivePayload,
128+
env: commonEnv,
129+
});
130+
131+
const result3 = await rig.run({
132+
args: [
133+
'--debug',
134+
'--resume',
135+
'latest',
136+
'--fake-responses-non-strict',
137+
setupResponses('resp3.json', runMocks),
138+
'continue',
139+
],
140+
env: commonEnv,
141+
});
142+
143+
expect(result3).toContain('Acknowledged block');
144+
145+
const traces = fs.readFileSync(traceLog, 'utf-8');
146+
expect(traces).toContain('Hitting Synchronous Pressure Barrier');
147+
expect(traces).toContain('GC Triggered.');
148+
});
149+
});

0 commit comments

Comments
 (0)