Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bc234de
fix(core): filter out thought parts from request history to prevent l…
amelidev Jun 15, 2026
92dc783
fix(core): strip thoughts from scrubbed history turns and add unit tests
amelidev Jun 16, 2026
2d0b4e6
Update packages/core/src/utils/historyHardening.ts
amelidev Jun 16, 2026
e948639
Merge branch 'main' into b_470967254
amelidev Jun 16, 2026
d4307ff
Update packages/core/src/utils/historyHardening.ts
amelidev Jun 16, 2026
cef5800
Update packages/core/src/utils/historyHardening.ts
amelidev Jun 16, 2026
f80df54
Merge branch 'main' into b_470967254
amelidev Jun 22, 2026
729f215
Merge branch 'main' into b_470967254
galdawave Jun 23, 2026
dffbb33
refactor(core): address PR feedback on history hardening allocations …
amelidev Jun 24, 2026
84aadfa
Merge branch 'main' into b_470967254
amelidev Jun 25, 2026
26fc9e4
Update packages/core/src/utils/historyHardening.ts
amelidev Jun 25, 2026
a868d97
fix(core): filter out empty content turns in scrubContents
amelidev Jun 25, 2026
3cfe893
Merge branch 'main' into b_470967254
amelidev Jun 25, 2026
0fa93d8
refactor(core): cast Part to ThoughtPart in isInternalThought for str…
amelidev Jun 25, 2026
98ff56f
Merge branch 'main' into b_470967254
DavidAPierce Jun 26, 2026
2006d25
fix(core): coalesce adjacent same-role turns in scrubContents
amelidev Jun 29, 2026
c1e4e6c
Update packages/core/src/utils/historyHardening.ts
amelidev Jun 30, 2026
e5f9a0f
Apply suggestions from code review
amelidev Jun 30, 2026
e9751e4
Merge branch 'main' into b_470967254
DavidAPierce Jul 1, 2026
4342c23
Merge branch 'main' into b_470967254
DavidAPierce Jul 6, 2026
1cbe049
Merge branch 'main' into b_470967254
DavidAPierce Jul 6, 2026
858710d
Merge branch 'main' into b_470967254
DavidAPierce Jul 6, 2026
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
30 changes: 30 additions & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ThinkingLevel,
type Content,
type GenerateContentResponse,
type Part,
} from '@google/genai';
import type { ContentGenerator } from '../core/contentGenerator.js';
import {
Expand Down Expand Up @@ -2253,6 +2254,35 @@ describe('GeminiChat', () => {
});
});

describe('thought leakage in getHistoryTurns', () => {
it('should completely filter out thought parts from getHistoryTurns when context management is enabled', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(true);

chat.setHistory([
{
role: 'user',
parts: [{ text: 'hello' }],
},
{
role: 'model',
Comment thread
amelidev marked this conversation as resolved.
parts: [
{ text: 'internal monologue', thought: true } as unknown as Part,
{ text: 'actual conversational response' },
],
},
]);

const turns = chat.getHistoryTurns(true);

expect(turns).toHaveLength(2);
const modelTurn = turns[1];
expect(modelTurn.content.parts).toHaveLength(1);
expect(modelTurn.content.parts![0]).toEqual({
text: 'actual conversational response',
});
});
});

describe('ensureActiveLoopHasThoughtSignatures', () => {
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
const chat = new GeminiChat(mockConfig, '', [], []);
Expand Down
205 changes: 204 additions & 1 deletion packages/core/src/utils/historyHardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { describe, it, expect } from 'vitest';
import {
hardenHistory,
SYNTHETIC_THOUGHT_SIGNATURE,
scrubContents,
scrubHistory,
} from './historyHardening.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
import { deriveStableId } from './cryptoUtils.js';
import type { Part } from '@google/genai';
import type { Part, Content } from '@google/genai';

describe('hardenHistory', () => {
it('should return an empty array if input is empty', () => {
Expand Down Expand Up @@ -375,4 +377,205 @@ describe('hardenHistory', () => {
expect(hardened[0].content.parts![0]).not.toHaveProperty('extraProp');
expect(hardened[0].content.parts![0]).toHaveProperty('text', 'hello');
});

it('should completely filter out thought parts from the scrubbed history', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'User prompt' }],
},
},
{
id: '2',
content: {
role: 'model',
parts: [
{
text: 'Previous model thought...',
thought: true,
} as unknown as Part,
{ text: 'Actual conversational text response' },
],
},
},
{
id: '3',
content: {
role: 'user',
parts: [{ text: 'User follow-up prompt' }],
},
},
];

const hardened = hardenHistory(history);
// Model turn (Turn 2, index 1 in hardened) should only contain the actual conversational text part
const modelTurn = hardened[1];
expect(modelTurn.content.parts).toHaveLength(1);
expect(modelTurn.content.parts![0]).toHaveProperty(
'text',
'Actual conversational text response',
);
expect(modelTurn.content.parts![0]).not.toHaveProperty('thought');
});

it('should remove the entire turn if it only contained thought parts and is now empty', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'User prompt' }],
},
},
{
id: '2',
content: {
role: 'model',
parts: [
{
text: 'Model is just thinking internally...',
thought: true,
} as unknown as Part,
],
},
},
{
id: '3',
content: {
role: 'user',
parts: [{ text: 'User follow-up prompt' }],
},
},
];

const hardened = hardenHistory(history);
// After scrubbing, Turn 2 should have 0 parts.
// The history mapping filters out empty turns, so the total turns should coalesce and reduce to 1 coalesced user turn.
// Let's inspect the hardened array:
// User prompt (Turn 1) + User follow-up prompt (Turn 3) will be coalesced into 1 User turn.
expect(hardened).toHaveLength(1);
expect(hardened[0].content.role).toBe('user');
expect(hardened[0].content.parts).toEqual([
{ text: 'User prompt' },
{ text: 'User follow-up prompt' },
]);
});
});

describe('scrubContents', () => {
it('should scrub non-standard fields from parts', () => {
const contents: Content[] = [
{
role: 'user',
parts: [{ text: 'Hello', customField: 'ignored' } as unknown as Part],
},
];
const scrubbed = scrubContents(contents);
expect(scrubbed).toEqual([
{
role: 'user',
parts: [{ text: 'Hello' }],
},
]);
});

it('should filter out internal thought parts', () => {
const contents: Content[] = [
{
role: 'model',
parts: [
{ text: 'thought', thought: true } as unknown as Part,
{ text: 'response' },
],
},
];
const scrubbed = scrubContents(contents);
expect(scrubbed).toEqual([
{
role: 'model',
parts: [{ text: 'response' }],
},
]);
});

it('should completely filter out Content objects that have no parts left after thought scrubbing and coalesce adjacent turns of the same role', () => {
const contents: Content[] = [
{
role: 'user',
parts: [{ text: 'Hello' }],
},
{
role: 'model',
parts: [{ text: 'thought', thought: true } as unknown as Part],
},
{
role: 'user',
parts: [{ text: 'How are you?' }],
},
];
const scrubbed = scrubContents(contents);
expect(scrubbed).toEqual([
{
role: 'user',
parts: [{ text: 'Hello' }, { text: 'How are you?' }],
},
]);
});

it('should coalesce adjacent turns of the same role when no filtration occurs', () => {
const contents: Content[] = [
{
role: 'user',
parts: [{ text: 'Part 1' }],
},
{
role: 'user',
parts: [{ text: 'Part 2' }],
},
];
const scrubbed = scrubContents(contents);
expect(scrubbed).toEqual([
{
role: 'user',
parts: [{ text: 'Part 1' }, { text: 'Part 2' }],
},
]);
});
});

describe('scrubHistory', () => {
it('should scrub non-standard fields and filter empty turns in history', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'Hello', customField: 'ignored' } as unknown as Part],
},
},
{
id: '2',
content: {
role: 'model',
parts: [{ text: 'thought', thought: true } as unknown as Part],
},
},
{
id: '3',
content: {
role: 'user',
parts: [{ text: 'World' }],
},
},
];

const scrubbed = scrubHistory(history);
expect(scrubbed.length).toBe(1); // Since user turns are coalesced (Turn 1 + Turn 3) and Turn 2 is removed because it has 0 parts
expect(scrubbed[0].content.parts).toEqual([
{ text: 'Hello' },
{ text: 'World' },
]);
});
});
Loading
Loading