Skip to content

Commit 86e7a15

Browse files
sarbojitranaclaude
andcommitted
fix(core): preserve functionCall thoughtSignature when stripping thought parts
Fixes #28604 Root cause: PR #28509 added stripThoughts() to getHistoryTurns() for Gemini 2.x/modern models when context management is disabled. It unconditionally drops every history part with thought:true. For turns where the API attached the required thoughtSignature to that thought part rather than to the sibling functionCall part, stripping it left the functionCall with no signature at all. The existing repair (ensureActiveLoopHasThoughtSignatures) only patches the current active loop, not older completed turns already sitting in history (e.g. a prior activate_skill call), so those turns replay on every subsequent request with a signature-less functionCall and the API rejects them with 400 INVALID_ARGUMENT "Function call is missing a thought_signature in functionCall parts." Fix: stripThoughts() now injects the existing SYNTHETIC_THOUGHT_SIGNATURE onto a model turn's first functionCall part if stripping removed its only source of a signature and it doesn't already have one. This mirrors the equivalent repair pairToolsAndEnforceSignatures() already performs for the context-management-enabled path in historyHardening.ts, so both code paths now uphold the same invariant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d55e366 commit 86e7a15

2 files changed

Lines changed: 93 additions & 1 deletion

File tree

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2387,6 +2387,75 @@ describe('GeminiChat', () => {
23872387
expect(turns[0].content.parts![0].text).toBe('Question 1');
23882388
expect(turns[0].content.parts![1].text).toBe('Question 2');
23892389
});
2390+
2391+
it('should inject a synthetic thoughtSignature onto a functionCall left signature-less after stripping a thought part that carried it (regression test for #28604)', () => {
2392+
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
2393+
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
2394+
2395+
chat.setHistory([
2396+
{ role: 'user', parts: [{ text: 'activate the skill' }] },
2397+
{
2398+
role: 'model',
2399+
parts: [
2400+
{
2401+
text: 'internal monologue',
2402+
thought: true,
2403+
thoughtSignature: 'real-sig-from-api',
2404+
} as unknown as Part,
2405+
{
2406+
functionCall: { name: 'activate_skill', args: {} },
2407+
},
2408+
],
2409+
},
2410+
{
2411+
role: 'user',
2412+
parts: [
2413+
{ functionResponse: { name: 'activate_skill', response: {} } },
2414+
],
2415+
},
2416+
]);
2417+
2418+
const turns = chat.getHistoryTurns(true);
2419+
2420+
const modelTurn = turns[1];
2421+
expect(modelTurn.content.parts).toHaveLength(1);
2422+
expect(modelTurn.content.parts![0].functionCall?.name).toBe(
2423+
'activate_skill',
2424+
);
2425+
expect(modelTurn.content.parts![0].thoughtSignature).toBe(
2426+
SYNTHETIC_THOUGHT_SIGNATURE,
2427+
);
2428+
});
2429+
2430+
it('should leave an existing thoughtSignature on a functionCall untouched when stripping thoughts', () => {
2431+
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
2432+
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
2433+
2434+
chat.setHistory([
2435+
{ role: 'user', parts: [{ text: 'activate the skill' }] },
2436+
{
2437+
role: 'model',
2438+
parts: [
2439+
{
2440+
text: 'internal monologue',
2441+
thought: true,
2442+
thoughtSignature: 'real-sig-from-api',
2443+
} as unknown as Part,
2444+
{
2445+
functionCall: { name: 'activate_skill', args: {} },
2446+
thoughtSignature: 'existing-sig-on-call',
2447+
},
2448+
],
2449+
},
2450+
]);
2451+
2452+
const turns = chat.getHistoryTurns(true);
2453+
2454+
const modelTurn = turns[1];
2455+
expect(modelTurn.content.parts![0].thoughtSignature).toBe(
2456+
'existing-sig-on-call',
2457+
);
2458+
});
23902459
});
23912460

23922461
describe('ensureActiveLoopHasThoughtSignatures', () => {

packages/core/src/core/geminiChat.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1533,11 +1533,34 @@ export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
15331533
if (!hasThought) return turn;
15341534

15351535
const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought);
1536+
1537+
// The thoughtSignature the API requires on the first functionCall of a
1538+
// model turn is sometimes only carried by the thought part we just
1539+
// removed, not by the functionCall part itself. Without it, replaying
1540+
// this turn in a later request gets rejected with a 400 "missing
1541+
// thought_signature" error, so inject a synthetic one if needed.
1542+
let patchedFirstCall = false;
1543+
const finalParts =
1544+
turn.content.role === 'model'
1545+
? nonThoughtParts.map((p) => {
1546+
if (!patchedFirstCall && p.functionCall) {
1547+
patchedFirstCall = true;
1548+
if (!p.thoughtSignature) {
1549+
return {
1550+
...p,
1551+
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
1552+
};
1553+
}
1554+
}
1555+
return p;
1556+
})
1557+
: nonThoughtParts;
1558+
15361559
return {
15371560
...turn,
15381561
content: {
15391562
...turn.content,
1540-
parts: nonThoughtParts,
1563+
parts: finalParts,
15411564
},
15421565
};
15431566
})

0 commit comments

Comments
 (0)