Repository navigation
fix(ai-mistral): keep parallel tool calls with the same index apart #1925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@tanstack/ai-mistral': patch | ||
| --- | ||
|
|
||
| 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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<AdapterYieldChunk> = [] | ||
| 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<AdapterYieldChunk> = [] | ||
| 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([ | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: TanStack/ai
Length of output: 1691
Script executed:
Repository: TanStack/ai
Length of output: 16810
Script executed:
Repository: TanStack/ai
Length of output: 328
Document the changed Mistral tool-call behavior.
CONTRIBUTING.mdrequires adocs/update for any new or changed user-facing behavior. It does not exempt bug fixes. This fix changes the caller-visible result by keeping parallel tool calls separate. Add a brief note to the Mistral adapter documentation.