Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/gemini-parallel-tool-dedup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-gemini': patch
---

Fix `mergeConsecutiveSameRoleMessages` deduplicating `functionResponse` parts by `name` instead of `id`. Two parallel calls to the same tool in one turn share a `name` but have distinct ids, so the second response was silently dropped, leaving Gemini with fewer response parts than call parts on the next request (`400 INVALID_ARGUMENT: ... number of function response parts is equal to the number of function call parts`). Deduping by `id` still collapses a genuine duplicate tool result while preserving both responses for same-tool parallel calls.
16 changes: 10 additions & 6 deletions packages/ai-gemini/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ export class GeminiTextAdapter<
* user messages in multi-turn conversations.
*
* Also filters out empty model messages (e.g., from a previous failed request)
* and deduplicates functionResponse parts with the same name (tool call ID).
* and deduplicates functionResponse parts with the same id (tool call ID).
*/
private mergeConsecutiveSameRoleMessages(
messages: Array<Content>,
Expand Down Expand Up @@ -790,16 +790,20 @@ export class GeminiTextAdapter<
}
}

// Deduplicate functionResponse parts with the same name (tool call ID)
// Deduplicate functionResponse parts with the same id (tool call ID).
// Two parallel calls to the *same* tool share a `name` but have distinct
// `id`s — keying on `name` dropped every response but the first for
// same-tool parallel calls, leaving Gemini with fewer response parts
// than call parts and a 400 on the next request.
for (const msg of merged) {
if (!msg.parts) continue
const seenFunctionResponseNames = new Set<string>()
const seenFunctionResponseIds = new Set<string>()
msg.parts = msg.parts.filter((part) => {
if ('functionResponse' in part && part.functionResponse?.name) {
if (seenFunctionResponseNames.has(part.functionResponse.name)) {
if ('functionResponse' in part && part.functionResponse?.id) {
if (seenFunctionResponseIds.has(part.functionResponse.id)) {
return false
}
seenFunctionResponseNames.add(part.functionResponse.name)
seenFunctionResponseIds.add(part.functionResponse.id)
}
return true
})
Expand Down
73 changes: 73 additions & 0 deletions packages/ai-gemini/tests/gemini-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,79 @@ describe('GeminiAdapter through AI', () => {
expect(textParts[0].text).toBe("what's a good electric guitar?")
})

it('preserves both functionResponse parts when two parallel calls hit the same tool', async () => {
const streamChunks = [
{
candidates: [
{
content: {
parts: [{ text: '50 USD and 30 EUR logged' }],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 15,
},
},
]

mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks))

const adapter = createTextAdapter()

for await (const _ of chat({
adapter,
messages: [
{ role: 'user', content: 'log 50 USD and 30 EUR' },
{
role: 'assistant',
content: null,
toolCalls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'lookupCurrency',
arguments: '{"query":"USD"}',
},
},
{
id: 'call_2',
type: 'function',
function: {
name: 'lookupCurrency',
arguments: '{"query":"EUR"}',
},
},
],
},
{ role: 'tool', toolCallId: 'call_1', content: '{"rate":1}' },
{ role: 'tool', toolCallId: 'call_2', content: '{"rate":0.9}' },
],
tools: [weatherTool],
})) {
/* consume */
}

const [payload] = mocks.generateContentStreamSpy.mock.calls[0]!
const lastMsg = payload.contents[payload.contents.length - 1]
const functionResponses = lastMsg.parts.filter(
(p: any) => p.functionResponse,
)

// Two parallel calls to the SAME tool ("lookupCurrency" twice) share a
// `name` but have distinct `id`s. Deduping by name (the old behavior)
// dropped one response, leaving Gemini with fewer response parts than
// call parts and a 400 on the next request.
expect(functionResponses).toHaveLength(2)
expect(
functionResponses.map((p: any) => p.functionResponse.id).sort(),
).toEqual(['call_1', 'call_2'])
})

it('reads Part-level thoughtSignature from Gemini 3.x streaming response', async () => {
const thoughtSig = 'base64-encoded-thought-signature-xyz'

Expand Down
Loading
Loading