Skip to content

Commit 27a3da3

Browse files
amelidevgemini-code-assist[bot]galdawaveDavidAPierce
authored
fix(core): strip thoughts from scrubbed history turns and resolve thought leakage (#27971)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com> Co-authored-by: David Pierce <davidapierce@google.com>
1 parent 15a9429 commit 27a3da3

3 files changed

Lines changed: 335 additions & 15 deletions

File tree

packages/core/src/core/geminiChat.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ThinkingLevel,
1111
type Content,
1212
type GenerateContentResponse,
13+
type Part,
1314
} from '@google/genai';
1415
import type { ContentGenerator } from '../core/contentGenerator.js';
1516
import {
@@ -2253,6 +2254,35 @@ describe('GeminiChat', () => {
22532254
});
22542255
});
22552256

2257+
describe('thought leakage in getHistoryTurns', () => {
2258+
it('should completely filter out thought parts from getHistoryTurns when context management is enabled', () => {
2259+
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(true);
2260+
2261+
chat.setHistory([
2262+
{
2263+
role: 'user',
2264+
parts: [{ text: 'hello' }],
2265+
},
2266+
{
2267+
role: 'model',
2268+
parts: [
2269+
{ text: 'internal monologue', thought: true } as unknown as Part,
2270+
{ text: 'actual conversational response' },
2271+
],
2272+
},
2273+
]);
2274+
2275+
const turns = chat.getHistoryTurns(true);
2276+
2277+
expect(turns).toHaveLength(2);
2278+
const modelTurn = turns[1];
2279+
expect(modelTurn.content.parts).toHaveLength(1);
2280+
expect(modelTurn.content.parts![0]).toEqual({
2281+
text: 'actual conversational response',
2282+
});
2283+
});
2284+
});
2285+
22562286
describe('ensureActiveLoopHasThoughtSignatures', () => {
22572287
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
22582288
const chat = new GeminiChat(mockConfig, '', [], []);

packages/core/src/utils/historyHardening.test.ts

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import { describe, it, expect } from 'vitest';
88
import {
99
hardenHistory,
1010
SYNTHETIC_THOUGHT_SIGNATURE,
11+
scrubContents,
12+
scrubHistory,
1113
} from './historyHardening.js';
1214
import type { HistoryTurn } from '../core/agentChatHistory.js';
1315
import { deriveStableId } from './cryptoUtils.js';
14-
import type { Part } from '@google/genai';
16+
import type { Part, Content } from '@google/genai';
1517

1618
describe('hardenHistory', () => {
1719
it('should return an empty array if input is empty', () => {
@@ -375,4 +377,205 @@ describe('hardenHistory', () => {
375377
expect(hardened[0].content.parts![0]).not.toHaveProperty('extraProp');
376378
expect(hardened[0].content.parts![0]).toHaveProperty('text', 'hello');
377379
});
380+
381+
it('should completely filter out thought parts from the scrubbed history', () => {
382+
const history: HistoryTurn[] = [
383+
{
384+
id: '1',
385+
content: {
386+
role: 'user',
387+
parts: [{ text: 'User prompt' }],
388+
},
389+
},
390+
{
391+
id: '2',
392+
content: {
393+
role: 'model',
394+
parts: [
395+
{
396+
text: 'Previous model thought...',
397+
thought: true,
398+
} as unknown as Part,
399+
{ text: 'Actual conversational text response' },
400+
],
401+
},
402+
},
403+
{
404+
id: '3',
405+
content: {
406+
role: 'user',
407+
parts: [{ text: 'User follow-up prompt' }],
408+
},
409+
},
410+
];
411+
412+
const hardened = hardenHistory(history);
413+
// Model turn (Turn 2, index 1 in hardened) should only contain the actual conversational text part
414+
const modelTurn = hardened[1];
415+
expect(modelTurn.content.parts).toHaveLength(1);
416+
expect(modelTurn.content.parts![0]).toHaveProperty(
417+
'text',
418+
'Actual conversational text response',
419+
);
420+
expect(modelTurn.content.parts![0]).not.toHaveProperty('thought');
421+
});
422+
423+
it('should remove the entire turn if it only contained thought parts and is now empty', () => {
424+
const history: HistoryTurn[] = [
425+
{
426+
id: '1',
427+
content: {
428+
role: 'user',
429+
parts: [{ text: 'User prompt' }],
430+
},
431+
},
432+
{
433+
id: '2',
434+
content: {
435+
role: 'model',
436+
parts: [
437+
{
438+
text: 'Model is just thinking internally...',
439+
thought: true,
440+
} as unknown as Part,
441+
],
442+
},
443+
},
444+
{
445+
id: '3',
446+
content: {
447+
role: 'user',
448+
parts: [{ text: 'User follow-up prompt' }],
449+
},
450+
},
451+
];
452+
453+
const hardened = hardenHistory(history);
454+
// After scrubbing, Turn 2 should have 0 parts.
455+
// The history mapping filters out empty turns, so the total turns should coalesce and reduce to 1 coalesced user turn.
456+
// Let's inspect the hardened array:
457+
// User prompt (Turn 1) + User follow-up prompt (Turn 3) will be coalesced into 1 User turn.
458+
expect(hardened).toHaveLength(1);
459+
expect(hardened[0].content.role).toBe('user');
460+
expect(hardened[0].content.parts).toEqual([
461+
{ text: 'User prompt' },
462+
{ text: 'User follow-up prompt' },
463+
]);
464+
});
465+
});
466+
467+
describe('scrubContents', () => {
468+
it('should scrub non-standard fields from parts', () => {
469+
const contents: Content[] = [
470+
{
471+
role: 'user',
472+
parts: [{ text: 'Hello', customField: 'ignored' } as unknown as Part],
473+
},
474+
];
475+
const scrubbed = scrubContents(contents);
476+
expect(scrubbed).toEqual([
477+
{
478+
role: 'user',
479+
parts: [{ text: 'Hello' }],
480+
},
481+
]);
482+
});
483+
484+
it('should filter out internal thought parts', () => {
485+
const contents: Content[] = [
486+
{
487+
role: 'model',
488+
parts: [
489+
{ text: 'thought', thought: true } as unknown as Part,
490+
{ text: 'response' },
491+
],
492+
},
493+
];
494+
const scrubbed = scrubContents(contents);
495+
expect(scrubbed).toEqual([
496+
{
497+
role: 'model',
498+
parts: [{ text: 'response' }],
499+
},
500+
]);
501+
});
502+
503+
it('should completely filter out Content objects that have no parts left after thought scrubbing and coalesce adjacent turns of the same role', () => {
504+
const contents: Content[] = [
505+
{
506+
role: 'user',
507+
parts: [{ text: 'Hello' }],
508+
},
509+
{
510+
role: 'model',
511+
parts: [{ text: 'thought', thought: true } as unknown as Part],
512+
},
513+
{
514+
role: 'user',
515+
parts: [{ text: 'How are you?' }],
516+
},
517+
];
518+
const scrubbed = scrubContents(contents);
519+
expect(scrubbed).toEqual([
520+
{
521+
role: 'user',
522+
parts: [{ text: 'Hello' }, { text: 'How are you?' }],
523+
},
524+
]);
525+
});
526+
527+
it('should coalesce adjacent turns of the same role when no filtration occurs', () => {
528+
const contents: Content[] = [
529+
{
530+
role: 'user',
531+
parts: [{ text: 'Part 1' }],
532+
},
533+
{
534+
role: 'user',
535+
parts: [{ text: 'Part 2' }],
536+
},
537+
];
538+
const scrubbed = scrubContents(contents);
539+
expect(scrubbed).toEqual([
540+
{
541+
role: 'user',
542+
parts: [{ text: 'Part 1' }, { text: 'Part 2' }],
543+
},
544+
]);
545+
});
546+
});
547+
548+
describe('scrubHistory', () => {
549+
it('should scrub non-standard fields and filter empty turns in history', () => {
550+
const history: HistoryTurn[] = [
551+
{
552+
id: '1',
553+
content: {
554+
role: 'user',
555+
parts: [{ text: 'Hello', customField: 'ignored' } as unknown as Part],
556+
},
557+
},
558+
{
559+
id: '2',
560+
content: {
561+
role: 'model',
562+
parts: [{ text: 'thought', thought: true } as unknown as Part],
563+
},
564+
},
565+
{
566+
id: '3',
567+
content: {
568+
role: 'user',
569+
parts: [{ text: 'World' }],
570+
},
571+
},
572+
];
573+
574+
const scrubbed = scrubHistory(history);
575+
expect(scrubbed.length).toBe(1); // Since user turns are coalesced (Turn 1 + Turn 3) and Turn 2 is removed because it has 0 parts
576+
expect(scrubbed[0].content.parts).toEqual([
577+
{ text: 'Hello' },
578+
{ text: 'World' },
579+
]);
580+
});
378581
});

0 commit comments

Comments
 (0)