Skip to content

Commit 80d2690

Browse files
authored
fix(core): Fix chat corruption bug in context manager. (#26534)
1 parent e039fcd commit 80d2690

8 files changed

Lines changed: 428 additions & 63 deletions

File tree

packages/core/src/context/contextManager.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,8 @@ export class ContextManager {
5858
);
5959

6060
this.eventBus.onPristineHistoryUpdated((event) => {
61-
const newIds = new Set(event.nodes.map((n) => n.id));
62-
const addedNodes = event.nodes.filter((n) => event.newNodes.has(n.id));
63-
64-
// Prune any pristine nodes that were dropped from the upstream history
65-
this.buffer = this.buffer.prunePristineNodes(newIds);
66-
67-
if (addedNodes.length > 0) {
68-
this.buffer = this.buffer.appendPristineNodes(addedNodes);
69-
}
61+
// Sync the entire pristine history chronologically
62+
this.buffer = this.buffer.syncPristineHistory(event.nodes);
7063

7164
this.evaluateTriggers(event.newNodes);
7265
});
@@ -254,13 +247,17 @@ export class ContextManager {
254247
await this.orchestrator.waitForPipelines();
255248

256249
let nodes = this.buffer.nodes;
250+
const previewNodeIds = new Set<string>();
257251

258252
// If we have a pending request, we need to build a 'preview' graph for this render.
259253
if (pendingRequest) {
260254
const previewNodes = this.env.graphMapper.applyEvent({
261255
type: 'PUSH',
262256
payload: [pendingRequest],
263257
});
258+
for (const n of previewNodes) {
259+
previewNodeIds.add(n.id);
260+
}
264261
nodes = [...nodes, ...previewNodes];
265262
}
266263

@@ -296,6 +293,7 @@ export class ContextManager {
296293
this.env,
297294
protectionReasons,
298295
headerTokens,
296+
previewNodeIds,
299297
);
300298

301299
// Structural validation in debug mode
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi } from 'vitest';
8+
import { render } from './render.js';
9+
import type { ConcreteNode } from './types.js';
10+
import { NodeType } from './types.js';
11+
import type { ContextEnvironment } from '../pipeline/environment.js';
12+
import type { ContextTracer } from '../tracer.js';
13+
import type { ContextProfile } from '../config/profiles.js';
14+
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
15+
import type { Part } from '@google/genai';
16+
17+
describe('render', () => {
18+
it('should filter out previewNodeIds', async () => {
19+
const mockNodes: ConcreteNode[] = [
20+
{
21+
id: '1',
22+
type: NodeType.USER_PROMPT,
23+
payload: {} as Part,
24+
} as unknown as ConcreteNode,
25+
{
26+
id: '2',
27+
type: NodeType.AGENT_THOUGHT,
28+
payload: {} as Part,
29+
} as unknown as ConcreteNode,
30+
{
31+
id: 'preview-1',
32+
type: NodeType.USER_PROMPT,
33+
payload: {} as Part,
34+
} as unknown as ConcreteNode,
35+
];
36+
const previewNodeIds = new Set(['preview-1']);
37+
38+
const orchestrator = {} as PipelineOrchestrator;
39+
const sidecar = { config: {} } as ContextProfile; // No budget
40+
const env = {
41+
graphMapper: {
42+
fromGraph: vi.fn((nodes: readonly ConcreteNode[]) =>
43+
nodes.map((n) => ({ text: n.id })),
44+
),
45+
},
46+
} as unknown as ContextEnvironment;
47+
const tracer = {
48+
logEvent: vi.fn(),
49+
} as unknown as ContextTracer;
50+
51+
const result = await render(
52+
mockNodes,
53+
orchestrator,
54+
sidecar,
55+
tracer,
56+
env,
57+
new Map(),
58+
0,
59+
previewNodeIds,
60+
);
61+
62+
expect(result.history).toEqual([{ text: '1' }, { text: '2' }]);
63+
});
64+
});

packages/core/src/context/graph/render.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ export async function render(
2323
env: ContextEnvironment,
2424
protectionReasons: Map<string, string> = new Map(),
2525
headerTokens: number = 0,
26+
previewNodeIds: ReadonlySet<string> = new Set(),
2627
): Promise<{ history: Content[]; didApplyManagement: boolean }> {
2728
if (!sidecar.config.budget) {
28-
const contents = env.graphMapper.fromGraph(nodes);
29+
const visibleNodes = nodes.filter((n) => !previewNodeIds.has(n.id));
30+
const contents = env.graphMapper.fromGraph(visibleNodes);
2931
tracer.logEvent('Render', 'Render Context to LLM (No Budget)', {
3032
renderedContext: contents,
3133
});
@@ -61,13 +63,13 @@ export async function render(
6163
'Render',
6264
`View is within maxTokens (${currentTokens} <= ${maxTokens}). Returning view.`,
6365
);
64-
const contents = env.graphMapper.fromGraph(nodes);
66+
const visibleNodes = nodes.filter((n) => !previewNodeIds.has(n.id));
67+
const contents = env.graphMapper.fromGraph(visibleNodes);
6568
tracer.logEvent('Render', 'Render Context for LLM', {
6669
renderedContext: contents,
6770
});
6871
return { history: contents, didApplyManagement: false };
6972
}
70-
7173
const targetDelta = currentTokens - sidecar.config.budget.retainedTokens;
7274
tracer.logEvent(
7375
'Render',
@@ -103,7 +105,9 @@ export async function render(
103105
}
104106
}
105107

106-
const visibleNodes = processedNodes.filter((n) => !skipList.has(n.id));
108+
const visibleNodes = processedNodes.filter(
109+
(n) => !skipList.has(n.id) && !previewNodeIds.has(n.id),
110+
);
107111

108112
const contents = env.graphMapper.fromGraph(visibleNodes);
109113
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { ContextGraphBuilder } from './toGraph.js';
9+
import type { Content } from '@google/genai';
10+
import type { BaseConcreteNode } from './types.js';
11+
12+
describe('ContextGraphBuilder', () => {
13+
describe('toGraph', () => {
14+
it('should skip legacy <session_context> headers even if they appear later in the history', () => {
15+
const history: Content[] = [
16+
{ role: 'user', parts: [{ text: 'Message 1' }] },
17+
{ role: 'model', parts: [{ text: 'Reply 1' }] },
18+
{
19+
role: 'user',
20+
parts: [
21+
{
22+
text: '<session_context>\nThis is the Gemini CLI\nSome context...',
23+
},
24+
],
25+
},
26+
{ role: 'user', parts: [{ text: 'Message 2' }] },
27+
];
28+
29+
const builder = new ContextGraphBuilder();
30+
const nodes = builder.processHistory(history);
31+
32+
// We expect the first two messages and the last one to be present
33+
// The session context message should be filtered out
34+
expect(nodes.length).toBe(3);
35+
expect((nodes[0] as BaseConcreteNode).payload.text).toBe('Message 1');
36+
expect((nodes[1] as BaseConcreteNode).payload.text).toBe('Reply 1');
37+
expect((nodes[2] as BaseConcreteNode).payload.text).toBe('Message 2');
38+
});
39+
});
40+
});

packages/core/src/context/graph/toGraph.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,13 @@ export class ContextGraphBuilder {
149149
const msg = history[turnIdx];
150150
if (!msg.parts) continue;
151151

152-
// Defensive: Skip legacy environment header if it's the first turn.
152+
// Defensive: Skip legacy environment header regardless of where it appears.
153153
// We now manage this as an orthogonal late-addition header.
154-
if (turnIdx === 0 && msg.role === 'user' && msg.parts.length === 1) {
154+
if (msg.role === 'user' && msg.parts.length === 1) {
155155
const text = msg.parts[0].text;
156156
if (
157157
text?.startsWith('<session_context>') &&
158-
text?.includes('This is the Gemini CLI.')
158+
text?.includes('This is the Gemini CLI')
159159
) {
160160
debugLogger.log(
161161
'[ContextGraphBuilder] Skipping legacy environment header turn from graph.',

packages/core/src/context/pipeline/contextWorkingBuffer.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,180 @@ describe('ContextWorkingBufferImpl', () => {
196196
// It should root to itself
197197
expect(buffer.getPristineNodes('injected1')).toEqual([injected]);
198198
});
199+
200+
describe('syncPristineHistory', () => {
201+
it('should append newly discovered pristine nodes to the end of the buffer', () => {
202+
const p1 = createDummyNode(
203+
'ep1',
204+
NodeType.USER_PROMPT,
205+
10,
206+
undefined,
207+
'p1',
208+
);
209+
let buffer = ContextWorkingBufferImpl.initialize([p1]);
210+
211+
const p2 = createDummyNode(
212+
'ep1',
213+
NodeType.AGENT_THOUGHT,
214+
10,
215+
undefined,
216+
'p2',
217+
);
218+
const p3 = createDummyNode(
219+
'ep1',
220+
NodeType.USER_PROMPT,
221+
10,
222+
undefined,
223+
'p3',
224+
);
225+
226+
buffer = buffer.syncPristineHistory([p1, p2, p3]);
227+
228+
expect(buffer.nodes.map((n) => n.id)).toEqual(['p1', 'p2', 'p3']);
229+
expect(buffer.getPristineNodes('p3')).toEqual([p3]);
230+
});
231+
232+
it('should drop working nodes if their pristine root is dropped from authoritative history', () => {
233+
const p1 = createDummyNode(
234+
'ep1',
235+
NodeType.USER_PROMPT,
236+
10,
237+
undefined,
238+
'p1',
239+
);
240+
const p2 = createDummyNode(
241+
'ep1',
242+
NodeType.AGENT_THOUGHT,
243+
10,
244+
undefined,
245+
'p2',
246+
);
247+
let buffer = ContextWorkingBufferImpl.initialize([p1, p2]);
248+
249+
// Mutate p2 into m2
250+
const m2 = createDummyNode(
251+
'ep1',
252+
NodeType.AGENT_THOUGHT,
253+
5,
254+
undefined,
255+
'm2',
256+
);
257+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
258+
(m2 as any).replacesId = 'p2';
259+
buffer = buffer.applyProcessorResult('Masking', [p2], [m2]);
260+
261+
expect(buffer.nodes.map((n) => n.id)).toEqual(['p1', 'm2']);
262+
263+
// Upstream graph drops p2 entirely
264+
buffer = buffer.syncPristineHistory([p1]);
265+
266+
// m2 should be gone because its root p2 is gone
267+
expect(buffer.nodes.map((n) => n.id)).toEqual(['p1']);
268+
});
269+
270+
it('should correctly weave summarized and mutated nodes into their chronological spots when new nodes arrive', () => {
271+
// Step 1: Initial state
272+
const p1 = createDummyNode(
273+
'ep1',
274+
NodeType.USER_PROMPT,
275+
10,
276+
undefined,
277+
'p1',
278+
);
279+
const p2 = createDummyNode(
280+
'ep1',
281+
NodeType.AGENT_THOUGHT,
282+
10,
283+
undefined,
284+
'p2',
285+
);
286+
const p3 = createDummyNode(
287+
'ep1',
288+
NodeType.USER_PROMPT,
289+
10,
290+
undefined,
291+
'p3',
292+
);
293+
let buffer = ContextWorkingBufferImpl.initialize([p1, p2, p3]);
294+
295+
// Step 2: Mutate p2 into m2
296+
const m2 = createDummyNode(
297+
'ep1',
298+
NodeType.AGENT_THOUGHT,
299+
5,
300+
undefined,
301+
'm2',
302+
);
303+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
304+
(m2 as any).replacesId = 'p2';
305+
buffer = buffer.applyProcessorResult('Masking', [p2], [m2]);
306+
307+
expect(buffer.nodes.map((n) => n.id)).toEqual(['p1', 'm2', 'p3']);
308+
309+
// Step 3: Upstream adds new nodes (p4, p5)
310+
const p4 = createDummyNode(
311+
'ep1',
312+
NodeType.AGENT_THOUGHT,
313+
10,
314+
undefined,
315+
'p4',
316+
);
317+
const p5 = createDummyNode(
318+
'ep1',
319+
NodeType.USER_PROMPT,
320+
10,
321+
undefined,
322+
'p5',
323+
);
324+
325+
buffer = buffer.syncPristineHistory([p1, p2, p3, p4, p5]);
326+
327+
// The working buffer should re-order to match the authoritative pristine history (p1, p2, p3, p4, p5)
328+
// but retain the mutated state (m2 instead of p2).
329+
// So expected order: p1, m2, p3, p4, p5
330+
expect(buffer.nodes.map((n) => n.id)).toEqual([
331+
'p1',
332+
'm2',
333+
'p3',
334+
'p4',
335+
'p5',
336+
]);
337+
});
338+
it('should drop a non-pristine node if ANY of its multiple pristine roots are dropped from authoritative history', () => {
339+
const p1 = createDummyNode(
340+
'ep1',
341+
NodeType.USER_PROMPT,
342+
10,
343+
undefined,
344+
'p1',
345+
);
346+
const p2 = createDummyNode(
347+
'ep1',
348+
NodeType.AGENT_THOUGHT,
349+
10,
350+
undefined,
351+
'p2',
352+
);
353+
let buffer = ContextWorkingBufferImpl.initialize([p1, p2]);
354+
355+
const s1 = createDummyNode(
356+
'ep1',
357+
NodeType.ROLLING_SUMMARY,
358+
5,
359+
undefined,
360+
's1',
361+
);
362+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
363+
(s1 as any).abstractsIds = ['p1', 'p2'];
364+
buffer = buffer.applyProcessorResult('Summarizer', [p1, p2], [s1]);
365+
366+
expect(buffer.nodes.map((n) => n.id)).toEqual(['s1']);
367+
368+
// Upstream graph drops p1 but keeps p2
369+
buffer = buffer.syncPristineHistory([p2]);
370+
371+
// s1 should be gone because one of its roots (p1) is gone
372+
expect(buffer.nodes.map((n) => n.id)).toEqual(['p2']);
373+
});
374+
});
199375
});

0 commit comments

Comments
 (0)