Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
28 changes: 2 additions & 26 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2253,6 +2253,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.
Resolved
parts: [
{ text: 'internal monologue', thought: true },
{ 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
85 changes: 85 additions & 0 deletions packages/core/src/utils/historyHardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,4 +375,89 @@ 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' },
]);
});
});
43 changes: 37 additions & 6 deletions packages/core/src/utils/historyHardening.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ export function hardenHistory(

const sentinels = { ...DEFAULT_SENTINELS, ...options.sentinels };

// Pass 0: Strip internal thoughts and remove empty turns
const processed = stripThoughts(history);

// Pass 1: Initial Coalesce & Empty Turn Removal
let coalesced = coalesce(history);
let coalesced = coalesce(processed);

// Pass 2: Tool Pairing & Signatures (The semantic layer)
coalesced = pairToolsAndEnforceSignatures(coalesced, sentinels);
Expand All @@ -62,6 +65,28 @@ export function hardenHistory(
return final;
}

/**
* Removes parts that represent thoughts (where part.thought === true)
* and filters out turns that become empty as a result.
*/
function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
return history
.map((turn) => {
if (!turn.content.parts) return turn;
const nonThoughtParts = turn.content.parts.filter(
(p) => !(p && typeof p === 'object' && 'thought' in p && p.thought),
);
return {
id: turn.id,
content: {
...turn.content,
parts: nonThoughtParts,
},
Comment thread
amelidev marked this conversation as resolved.
Resolved
};
})
.filter((turn) => turn.content.parts && turn.content.parts.length > 0);
}
Comment thread
amelidev marked this conversation as resolved.
Resolved

/**
* Combines adjacent turns with the same role and removes empty turns.
*/
Expand Down Expand Up @@ -344,20 +369,26 @@ function enforceRoleConstraints(
* This ensures compatibility with strict APIs (like Vertex AI) that reject unknown fields.
*/
export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] {
return history.map((turn) => ({
const scrubbed = history.map((turn) => ({
id: turn.id,
content: scrubContents([turn.content])[0],
}));
Comment thread
amelidev marked this conversation as resolved.
Resolved
return coalesce(scrubbed);
}

/**
* Deep-scrubs an array of Content objects to remove non-standard properties.
*/
export function scrubContents(contents: Content[]): Content[] {
return contents.map((content) => ({
role: content.role,
parts: (content.parts || []).map((p) => scrubPart(p)),
}));
return contents.map((content) => {
const nonThoughtParts = (content.parts ?? []).filter(
(p) => !(p && typeof p === 'object' && 'thought' in p && p.thought),
);
Comment thread
amelidev marked this conversation as resolved.
Resolved
return {
role: content.role,
parts: nonThoughtParts.map((p) => scrubPart(p)),
};
});
Comment thread
amelidev marked this conversation as resolved.
Resolved
}

interface ThoughtPart extends Part {
Expand Down
Loading