From 7e668f0c0ab811ff78853ac2608df65c27ab4b57 Mon Sep 17 00:00:00 2001 From: citizen204 Date: Sat, 22 Aug 2026 19:00:33 +0930 Subject: [PATCH 1/3] fix(ai-gemini): dedupe functionResponse parts by id, not name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parallel calls to the same tool share a functionResponse.name but have distinct ids (msg.toolCallId, already set at both construction sites). mergeConsecutiveSameRoleMessages deduped by name, so the second response to a repeated same-tool call was dropped, leaving Gemini with fewer response parts than call parts on the next request: 400 INVALID_ARGUMENT: Please ensure that the number of function response parts is equal to the number of function call parts of the function call turn. Key the dedup on functionResponse.id instead — it still collapses a genuine duplicate tool result (same id twice), and now also preserves both responses when the model fires the same tool twice in one turn. Fixes #894 --- packages/ai-gemini/src/adapters/text.ts | 16 +++-- .../ai-gemini/tests/gemini-adapter.test.ts | 67 +++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/ai-gemini/src/adapters/text.ts b/packages/ai-gemini/src/adapters/text.ts index c94f87a7cc..7345409eda 100644 --- a/packages/ai-gemini/src/adapters/text.ts +++ b/packages/ai-gemini/src/adapters/text.ts @@ -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, @@ -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() + const seenFunctionResponseIds = new Set() 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 }) diff --git a/packages/ai-gemini/tests/gemini-adapter.test.ts b/packages/ai-gemini/tests/gemini-adapter.test.ts index c3779ce969..edaaee1bbb 100644 --- a/packages/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/ai-gemini/tests/gemini-adapter.test.ts @@ -712,6 +712,73 @@ 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' From 61c97959f3e7f7b36fcdf0707366030bd0b14038 Mon Sep 17 00:00:00 2001 From: citizen204 Date: Sat, 22 Aug 2026 19:01:56 +0930 Subject: [PATCH 2/3] chore: add changeset for #894 fix --- .changeset/gemini-parallel-tool-dedup.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gemini-parallel-tool-dedup.md diff --git a/.changeset/gemini-parallel-tool-dedup.md b/.changeset/gemini-parallel-tool-dedup.md new file mode 100644 index 0000000000..7a15906b32 --- /dev/null +++ b/.changeset/gemini-parallel-tool-dedup.md @@ -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. From b10e1fb8e31cd0da40abdd4c2bbeef6cd3fffab1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:35:13 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- packages/ai-gemini/tests/gemini-adapter.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ai-gemini/tests/gemini-adapter.test.ts b/packages/ai-gemini/tests/gemini-adapter.test.ts index edaaee1bbb..6e8f4543a8 100644 --- a/packages/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/ai-gemini/tests/gemini-adapter.test.ts @@ -746,12 +746,18 @@ describe('GeminiAdapter through AI', () => { { id: 'call_1', type: 'function', - function: { name: 'lookupCurrency', arguments: '{"query":"USD"}' }, + function: { + name: 'lookupCurrency', + arguments: '{"query":"USD"}', + }, }, { id: 'call_2', type: 'function', - function: { name: 'lookupCurrency', arguments: '{"query":"EUR"}' }, + function: { + name: 'lookupCurrency', + arguments: '{"query":"EUR"}', + }, }, ], },