From c695d11548cafe81ca76c66bd832b5dfc5909cfd Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Mon, 26 Oct 2026 22:53:24 -0400 Subject: [PATCH 1/2] fix(ai-mistral): keep parallel tool calls with the same index apart Mistral can stream each parallel tool call whole with index 0 (the SDK defaults a missing index to 0), so the adapter put them all in slot 0: the name was overwritten and the arguments joined into invalid JSON, and only one call was emitted. A delta whose id differs from the one already in its slot now starts a new call; a repeated id goes back to its slot. Co-Authored-By: Claude Sonnet 5.5 --- .../mistral-parallel-tool-calls-index.md | 5 + packages/ai-mistral/src/adapters/text.ts | 19 ++- .../ai-mistral/tests/mistral-adapter.test.ts | 129 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 .changeset/mistral-parallel-tool-calls-index.md diff --git a/.changeset/mistral-parallel-tool-calls-index.md b/.changeset/mistral-parallel-tool-calls-index.md new file mode 100644 index 0000000000..d733ff69af --- /dev/null +++ b/.changeset/mistral-parallel-tool-calls-index.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-mistral': patch +--- + +Parallel tool calls that Mistral streams whole, all with `index` 0, are no longer merged into one call with a combined name and invalid JSON arguments. A tool call delta whose id differs from the one already in its slot now starts a new call. diff --git a/packages/ai-mistral/src/adapters/text.ts b/packages/ai-mistral/src/adapters/text.ts index 652bc4ca3f..59d25e39f7 100644 --- a/packages/ai-mistral/src/adapters/text.ts +++ b/packages/ai-mistral/src/adapters/text.ts @@ -656,7 +656,24 @@ export class MistralTextAdapter< if (deltaToolCalls) { for (const [i, toolCallDelta] of deltaToolCalls.entries()) { - const index = toolCallDelta.index ?? i + let index = toolCallDelta.index ?? i + // Mistral can send each parallel call whole with the same `index` + // (the SDK fills a missing one with 0), so an id that differs from + // the one already in the slot starts a new call. A repeated id goes + // back to its own slot. + if (toolCallDelta.id) { + const occupant = toolCallsInProgress.get(index) + if (occupant?.id && occupant.id !== toolCallDelta.id) { + const sameId = [...toolCallsInProgress].find( + ([, call]) => call.id === toolCallDelta.id, + ) + if (sameId) { + index = sameId[0] + } else { + while (toolCallsInProgress.has(index)) index += 1 + } + } + } let toolCall = toolCallsInProgress.get(index) if (!toolCall) { diff --git a/packages/ai-mistral/tests/mistral-adapter.test.ts b/packages/ai-mistral/tests/mistral-adapter.test.ts index 7a9e06d447..fabb4c2b07 100644 --- a/packages/ai-mistral/tests/mistral-adapter.test.ts +++ b/packages/ai-mistral/tests/mistral-adapter.test.ts @@ -1088,6 +1088,135 @@ describe('Mistral AG-UI event emission', () => { expect(caught?.message).toMatch(/lookup_weather/) }) + it('keeps parallel tool calls apart when they all arrive with index 0', async () => { + setupMockStream([ + { + id: 'cmpl-parallel', + model: 'mistral-large-latest', + choices: [ + { + index: 0, + delta: { + toolCalls: [ + { + index: 0, + id: 'tc_1', + function: { + name: 'lookup_weather', + arguments: '{"location": "Paris"}', + }, + }, + { + index: 0, + id: 'tc_2', + function: { + name: 'lookup_weather', + arguments: '{"location": "Rome"}', + }, + }, + ], + }, + finishReason: null, + }, + ], + }, + { + id: 'cmpl-parallel', + model: 'mistral-large-latest', + choices: [{ index: 0, delta: {}, finishReason: 'tool_calls' }], + }, + ]) + + const adapter = createMistralText('mistral-large-latest', 'test-api-key') + const chunks: Array = [] + for await (const chunk of adapter.chatStream( + chatOpts({ + model: 'mistral-large-latest', + messages: [{ role: 'user', content: 'Weather in Paris and Rome?' }], + tools: [weatherTool], + }), + )) { + chunks.push(chunk) + } + + const starts = chunks.filter((c) => c.type === 'TOOL_CALL_START') + expect(starts.map((c) => (c as { toolCallId: string }).toolCallId)).toEqual( + ['tc_1', 'tc_2'], + ) + const ends = chunks.filter((c) => c.type === 'TOOL_CALL_END') + expect(ends).toHaveLength(2) + expect(ends[0]).toMatchObject({ + toolCallId: 'tc_1', + args: '{"location": "Paris"}', + input: { location: 'Paris' }, + }) + expect(ends[1]).toMatchObject({ + toolCallId: 'tc_2', + args: '{"location": "Rome"}', + input: { location: 'Rome' }, + }) + }) + + it('keeps accumulating index-0 argument fragments that carry no id into one call', async () => { + setupMockStream([ + { + id: 'cmpl-frag', + model: 'mistral-large-latest', + choices: [ + { + index: 0, + delta: { + toolCalls: [ + { + index: 0, + id: 'tc_frag', + function: { + name: 'lookup_weather', + arguments: '{"location": ', + }, + }, + ], + }, + finishReason: null, + }, + ], + }, + { + id: 'cmpl-frag', + model: 'mistral-large-latest', + choices: [ + { + index: 0, + delta: { + toolCalls: [{ index: 0, function: { arguments: '"Paris"}' } }], + }, + finishReason: 'tool_calls', + }, + ], + }, + ]) + + const adapter = createMistralText('mistral-large-latest', 'test-api-key') + const chunks: Array = [] + for await (const chunk of adapter.chatStream( + chatOpts({ + model: 'mistral-large-latest', + messages: [{ role: 'user', content: 'Weather in Paris?' }], + tools: [weatherTool], + }), + )) { + chunks.push(chunk) + } + + expect(chunks.filter((c) => c.type === 'TOOL_CALL_START')).toHaveLength(1) + const ends = chunks.filter((c) => c.type === 'TOOL_CALL_END') + expect(ends).toHaveLength(1) + expect(ends[0]).toMatchObject({ + toolCallId: 'tc_frag', + args: '{"location": "Paris"}', + }) + }) + it('puts the raw argument string on TOOL_CALL_END', async () => { setupMockStream([ { From 8174d688fe780bf5d9d1dac5b4dfd46201b7e8b5 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Mon, 26 Oct 2026 22:56:28 -0400 Subject: [PATCH 2/2] docs(changeset): describe the Mistral index fix without claiming observed API behavior Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mistral-parallel-tool-calls-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mistral-parallel-tool-calls-index.md b/.changeset/mistral-parallel-tool-calls-index.md index d733ff69af..6397163ca7 100644 --- a/.changeset/mistral-parallel-tool-calls-index.md +++ b/.changeset/mistral-parallel-tool-calls-index.md @@ -2,4 +2,4 @@ '@tanstack/ai-mistral': patch --- -Parallel tool calls that Mistral streams whole, all with `index` 0, are no longer merged into one call with a combined name and invalid JSON arguments. A tool call delta whose id differs from the one already in its slot now starts a new call. +Parallel tool calls that arrive whole with the same `index` (the Mistral SDK fills a missing `index` with 0) are no longer merged into one call with a combined name and invalid JSON arguments. A tool call delta whose id differs from the one already in its slot now starts a new call.