From eb10a60d48ef5ee2037fe514de43156084f355a6 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:57:08 +0800 Subject: [PATCH] fix(ai-gemini): dedup functionResponse by id, not name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GeminiTextAdapter.mergeConsecutiveSameRoleMessages` deduplicated `functionResponse` parts by tool name. When Gemini returned two or more parallel calls to the same tool in one turn, the second matching response was filtered out, so the follow-up request 400'd: 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. Each `functionResponse` is already built with a unique `id` (the originating `toolCallId`), so keying the dedup on `id` keeps parallel calls distinct (different ids → both kept) while still collapsing a genuine duplicate (same id → second one dropped). The pre-existing comment already described the key as "tool call ID" — this brings the implementation in line with that comment. Fixes #894. --- ...gemini-parallel-function-response-dedup.md | 20 +++ packages/ai-gemini/src/adapters/text.ts | 17 +- .../tests/parallel-function-response.test.ts | 168 ++++++++++++++++++ 3 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 .changeset/ai-gemini-parallel-function-response-dedup.md create mode 100644 packages/ai-gemini/tests/parallel-function-response.test.ts diff --git a/.changeset/ai-gemini-parallel-function-response-dedup.md b/.changeset/ai-gemini-parallel-function-response-dedup.md new file mode 100644 index 000000000..086696129 --- /dev/null +++ b/.changeset/ai-gemini-parallel-function-response-dedup.md @@ -0,0 +1,20 @@ +--- +"@tanstack/ai-gemini": patch +--- + +Fix parallel functionResponse dedup dropping same-tool calls (#894) + +`GeminiTextAdapter.mergeConsecutiveSameRoleMessages` deduplicated +`functionResponse` parts by tool **name** instead of by **id**. When Gemini +returned two or more parallel calls to the same tool in one turn, the second +matching response was filtered out, so the follow-up request failed with: + +> 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. + +Each `functionResponse` is already built with a unique `id` (the originating +`toolCallId`), so keying the dedup on `id` keeps parallel calls distinct +(different ids → both kept) while still collapsing a genuine duplicate +(same id → second one dropped). The pre-existing comment already described +the key as "tool call ID" — this brings the implementation in line with +that comment. diff --git a/packages/ai-gemini/src/adapters/text.ts b/packages/ai-gemini/src/adapters/text.ts index 1b7587bb6..cd634c911 100644 --- a/packages/ai-gemini/src/adapters/text.ts +++ b/packages/ai-gemini/src/adapters/text.ts @@ -818,7 +818,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, @@ -849,16 +849,21 @@ export class GeminiTextAdapter< } } - // Deduplicate functionResponse parts with the same name (tool call ID) + // Deduplicate functionResponse parts with the same id (tool call ID) for (const msg of merged) { if (!msg.parts) continue - const seenFunctionResponseNames = new Set() + // Each `functionResponse` carries a unique `id` (set to the originating + // `toolCallId` above), so keying on `id` keeps parallel calls to the same + // tool distinct (different ids → both kept) while still collapsing a + // genuine duplicate (same id → second one dropped). Keying on `.name` + // would drop the second of two parallel calls to the same tool — #894. + 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/parallel-function-response.test.ts b/packages/ai-gemini/tests/parallel-function-response.test.ts new file mode 100644 index 000000000..bc7d1d4a8 --- /dev/null +++ b/packages/ai-gemini/tests/parallel-function-response.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { GeminiTextAdapter } from '../src/adapters/text' +import type { ModelMessage } from '@tanstack/ai' +import type { Content } from '@google/genai' + +// `formatMessages` is `private` on the adapter (TS2341 blocks subclass access, +// so the Probe pattern used in ai-bedrock/tests for `protected` hooks doesn't +// apply here). Cast through `unknown` to a minimal shape — keeps the test +// type-safe without widening the adapter's public API surface just to make a +// private method testable. +type WithFormatMessages = { + formatMessages: (messages: Array) => Array +} + +const adapter = new GeminiTextAdapter( + { apiKey: 'not-used' }, + 'gemini-2.5-flash-lite', +) + +const format = (messages: Array): Array => + (adapter as unknown as WithFormatMessages).formatMessages(messages) + +function functionResponseIds(out: Array): Array { + // `Content.parts` is `Part[] | undefined`; once narrowed past the `?? []` + // each `Part` is a discriminated union keyed by which optional field it + // carries. Filtering on `'functionResponse' in p` is enough to land us on + // the right arm at runtime; cast through to read the id. + return out + .flatMap((c) => c.parts ?? []) + .filter((p) => 'functionResponse' in p) + .map( + (p) => + (p as { functionResponse?: { id?: string } }).functionResponse?.id ?? + '', + ) +} + +describe('GeminiTextAdapter — parallel functionResponse dedup (#894)', () => { + it('keeps both responses when two parallel calls share a tool name but have distinct ids', () => { + // Repro straight from the issue: same-tool parallel calls, distinct ids. + // Pre-fix the dedup keyed on `.name` and dropped the second response, + // making the next Gemini request 400 with "function response/call part + // count mismatch". + const messages: Array = [ + { role: 'user', content: 'log 50 USD and 30 EUR' }, + { + role: 'assistant', + content: '', + 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: 'USD -> US Dollar' }, + { role: 'tool', toolCallId: 'call_2', content: 'EUR -> Euro' }, + ] + + const out = format(messages) + const ids = functionResponseIds(out) + + expect(ids).toEqual(['call_1', 'call_2']) + }) + + it('drops a genuine duplicate functionResponse (same id reappears)', () => { + // The dedup still has to collapse a real duplicate — e.g. a caller that + // re-sends the same toolCallId's result. Keying on `.id` keeps this + // guarantee intact while letting same-name parallel calls through. + const messages: Array = [ + { role: 'user', content: 'lookup USD' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookupCurrency', arguments: '{"query":"USD"}' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_1', content: 'USD -> US Dollar' }, + // Accidental re-send of the same toolCallId. + { role: 'tool', toolCallId: 'call_1', content: 'USD -> US Dollar' }, + ] + + const out = format(messages) + const ids = functionResponseIds(out) + + expect(ids).toEqual(['call_1']) + }) + + it('keeps both responses when the parallel calls use different tool names', () => { + // Different names AND different ids — both old and new code keep these, + // so this is a regression guard against an overzealous "dedup by id" fix + // that might accidentally drop by name as well. + const messages: Array = [ + { role: 'user', content: 'lookup USD and log it' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookupCurrency', arguments: '{"query":"USD"}' }, + }, + { + id: 'call_2', + type: 'function', + function: { name: 'logMessage', arguments: '{"msg":"hi"}' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_1', content: 'USD -> US Dollar' }, + { role: 'tool', toolCallId: 'call_2', content: 'logged' }, + ] + + const out = format(messages) + const ids = functionResponseIds(out) + + expect(ids).toEqual(['call_1', 'call_2']) + }) + + it('keeps all three responses when three parallel calls share a tool name', () => { + // Stress the dedup with 3 same-name parallel calls — pre-fix this would + // collapse to a single response, after the fix all three survive. + const messages: Array = [ + { role: 'user', content: 'log USD EUR GBP' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookupCurrency', arguments: '{"query":"USD"}' }, + }, + { + id: 'call_2', + type: 'function', + function: { name: 'lookupCurrency', arguments: '{"query":"EUR"}' }, + }, + { + id: 'call_3', + type: 'function', + function: { name: 'lookupCurrency', arguments: '{"query":"GBP"}' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_1', content: 'USD -> US Dollar' }, + { role: 'tool', toolCallId: 'call_2', content: 'EUR -> Euro' }, + { role: 'tool', toolCallId: 'call_3', content: 'GBP -> Pound Sterling' }, + ] + + const out = format(messages) + const ids = functionResponseIds(out) + + expect(ids).toEqual(['call_1', 'call_2', 'call_3']) + }) +})