Skip to content

Commit 16609f7

Browse files
jherrclaude
andcommitted
fix(ai-gemini): remove redundant text part from functionResponse messages (#436)
Tool result messages were emitting both a text part and a functionResponse part, causing 400 errors on newer Gemini models that reject mixed parts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e832506 commit 16609f7

3 files changed

Lines changed: 18 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai-gemini': patch
3+
---
4+
5+
Fix 400 error when sending tool results to Gemini API by removing redundant text part from functionResponse messages. Newer models (gemini-3.1-flash-lite, gemma-4) reject messages that mix text and functionResponse parts.

packages/typescript/ai-gemini/src/adapters/text.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ export class GeminiTextAdapter<
562562
for (const contentPart of msg.content) {
563563
parts.push(this.convertContentPartToGemini(contentPart))
564564
}
565-
} else if (msg.content) {
565+
} else if (msg.content && msg.role !== 'tool') {
566566
parts.push({ text: msg.content })
567567
}
568568

packages/typescript/ai-gemini/tests/gemini-adapter.test.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -394,12 +394,17 @@ describe('GeminiAdapter through AI', () => {
394394
expect(payload.contents[1].role).toBe('model')
395395
expect(payload.contents[2].role).toBe('user')
396396

397-
// Last user message should contain both functionResponse and text
397+
// Last user message should contain functionResponse (no redundant text part
398+
// for the tool result) and the follow-up user text
398399
const lastParts = payload.contents[2].parts
399400
const hasFunctionResponse = lastParts.some((p: any) => p.functionResponse)
400-
const hasText = lastParts.some((p: any) => p.text === 'What about Paris?')
401+
const hasFollowUp = lastParts.some((p: any) => p.text === 'What about Paris?')
402+
const hasToolResultText = lastParts.some(
403+
(p: any) => p.text === '{"temp":72}',
404+
)
401405
expect(hasFunctionResponse).toBe(true)
402-
expect(hasText).toBe(true)
406+
expect(hasFollowUp).toBe(true)
407+
expect(hasToolResultText).toBe(false)
403408
})
404409

405410
it('handles full multi-turn with duplicate tool results and empty model message', async () => {
@@ -487,15 +492,16 @@ describe('GeminiAdapter through AI', () => {
487492
expect(payload.contents).toHaveLength(3)
488493

489494
// Last user should have deduplicated functionResponses + follow-up text
495+
// (no redundant text parts for tool results)
490496
const lastParts = payload.contents[2].parts
491497
const functionResponses = lastParts.filter((p: any) => p.functionResponse)
492498
// 2 unique tool call IDs, not 3 (duplicate removed)
493499
expect(functionResponses).toHaveLength(2)
494500

495-
const textParts = lastParts.filter(
496-
(p: any) => p.text === "what's a good electric guitar?",
497-
)
501+
const textParts = lastParts.filter((p: any) => p.text)
502+
// Only the follow-up user message text, no tool result text parts
498503
expect(textParts).toHaveLength(1)
504+
expect(textParts[0].text).toBe("what's a good electric guitar?")
499505
})
500506

501507
it('preserves thoughtSignature in functionCall parts when sending history back to Gemini', async () => {

0 commit comments

Comments
 (0)