From 1d56de6fc20c292c0323f3f1e9d24486b97312d3 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 28 Oct 2026 07:49:20 -0400 Subject: [PATCH 1/3] fix(ai-ollama): keep parallel same-name tool calls apart when ids are missing Co-Authored-By: Claude Sonnet 5.5 --- .changeset/ollama-parallel-tool-call-ids.md | 5 +++ packages/ai-ollama/src/adapters/text.ts | 4 +- packages/ai-ollama/tests/text-adapter.test.ts | 45 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/ollama-parallel-tool-call-ids.md diff --git a/.changeset/ollama-parallel-tool-call-ids.md b/.changeset/ollama-parallel-tool-call-ids.md new file mode 100644 index 0000000000..a326d44012 --- /dev/null +++ b/.changeset/ollama-parallel-tool-call-ids.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-ollama': patch +--- + +Keep parallel calls to the same tool apart when Ollama sends no tool-call id. The fallback id was `_`, so two calls to one tool in the same millisecond shared an id and the second call was dropped. The fallback id now also carries the call's index. diff --git a/packages/ai-ollama/src/adapters/text.ts b/packages/ai-ollama/src/adapters/text.ts index 67a456aba6..2b16553bb8 100644 --- a/packages/ai-ollama/src/adapters/text.ts +++ b/packages/ai-ollama/src/adapters/text.ts @@ -332,8 +332,10 @@ export class OllamaTextAdapter extends BaseTextAdapter< id: string function: { index: number } } + // Ollama sends no id: tell parallel calls to the same tool apart by index const toolCallId = - actualToolCall.id || `${actualToolCall.function.name}_${Date.now()}` + actualToolCall.id || + `${actualToolCall.function.name}_${Date.now()}_${actualToolCall.function.index ?? toolCallsEmitted.size}` const events: Array = [] // Emit TOOL_CALL_START if not already emitted for this tool call diff --git a/packages/ai-ollama/tests/text-adapter.test.ts b/packages/ai-ollama/tests/text-adapter.test.ts index 6ed3ff47c1..ade4888e63 100644 --- a/packages/ai-ollama/tests/text-adapter.test.ts +++ b/packages/ai-ollama/tests/text-adapter.test.ts @@ -280,6 +280,51 @@ describe('OllamaTextAdapter.chatStream (tool calls)', () => { expect(startChunk!.toolCallId).toMatch(/^search_\d+/) }) + it('keeps two parallel calls to the same tool apart when Ollama omits ids', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + try { + chatMock.mockResolvedValueOnce( + asyncIterable([ + { + message: { + role: 'assistant', + content: '', + tool_calls: [ + { function: { name: 'search', arguments: { q: 'cats' } } }, + { function: { name: 'search', arguments: { q: 'dogs' } } }, + ], + }, + done: true, + done_reason: 'stop', + }, + ]), + ) + + const chunks = await collectStream( + createOllamaChat('llama3.2').chatStream({ + threadId: 'thread-1', + logger: testLogger, + model: 'llama3.2', + messages: [{ role: 'user', content: 'find cats and dogs' }], + tools: [searchTool], + }), + ) + + const starts = chunks.filter((c) => c.type === 'TOOL_CALL_START') as Array<{ + toolCallId: string + }> + const ends = chunks.filter((c) => c.type === 'TOOL_CALL_END') as Array<{ + toolCallId: string + args: string + }> + expect(starts).toHaveLength(2) + expect(new Set(starts.map((s) => s.toolCallId)).size).toBe(2) + expect(ends.map((e) => e.args)).toEqual(['{"q":"cats"}', '{"q":"dogs"}']) + } finally { + vi.restoreAllMocks() + } + }) + async function toolCallEnd(args: unknown) { chatMock.mockResolvedValueOnce( asyncIterable([ From 274814177b55c64a27f0b7032d2d8746428f23ad Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:43:16 +1100 Subject: [PATCH 2/3] fix(ai-ollama): use generateId for the fallback tool-call id Reuse the existing generateId helper instead of a hand-built name_Date.now()_index id. Type id/index as optional to match the wire. Add an Ollama E2E case for parallel calls to the same tool, and fix the changeset wording (the second call was merged, not dropped). --- .changeset/ollama-parallel-tool-call-ids.md | 2 +- packages/ai-ollama/src/adapters/text.ts | 8 +- packages/ai-ollama/tests/text-adapter.test.ts | 73 +++++++++---------- .../tool-calling/parallel-same-tool.json | 31 ++++++++ testing/e2e/src/lib/feature-support.ts | 1 + testing/e2e/tests/parallel-tool-calls.spec.ts | 22 ++++++ 6 files changed, 92 insertions(+), 45 deletions(-) create mode 100644 testing/e2e/fixtures/tool-calling/parallel-same-tool.json diff --git a/.changeset/ollama-parallel-tool-call-ids.md b/.changeset/ollama-parallel-tool-call-ids.md index a326d44012..4e19f02cc7 100644 --- a/.changeset/ollama-parallel-tool-call-ids.md +++ b/.changeset/ollama-parallel-tool-call-ids.md @@ -2,4 +2,4 @@ '@tanstack/ai-ollama': patch --- -Keep parallel calls to the same tool apart when Ollama sends no tool-call id. The fallback id was `_`, so two calls to one tool in the same millisecond shared an id and the second call was dropped. The fallback id now also carries the call's index. +Keep parallel calls to the same tool apart when Ollama sends no tool-call id. The fallback id was `_`, so two calls to one tool in the same millisecond shared an id and the second call was merged into the first instead of running. The fallback id is now unique per call. diff --git a/packages/ai-ollama/src/adapters/text.ts b/packages/ai-ollama/src/adapters/text.ts index 2b16553bb8..3fd3e764f6 100644 --- a/packages/ai-ollama/src/adapters/text.ts +++ b/packages/ai-ollama/src/adapters/text.ts @@ -329,13 +329,11 @@ export class OllamaTextAdapter extends BaseTextAdapter< const handleToolCall = (toolCall: ToolCall): Array => { const actualToolCall = toolCall as ToolCall & { - id: string - function: { index: number } + id?: string + function: { index?: number } } - // Ollama sends no id: tell parallel calls to the same tool apart by index const toolCallId = - actualToolCall.id || - `${actualToolCall.function.name}_${Date.now()}_${actualToolCall.function.index ?? toolCallsEmitted.size}` + actualToolCall.id || generateId(actualToolCall.function.name) const events: Array = [] // Emit TOOL_CALL_START if not already emitted for this tool call diff --git a/packages/ai-ollama/tests/text-adapter.test.ts b/packages/ai-ollama/tests/text-adapter.test.ts index ade4888e63..3516969e7b 100644 --- a/packages/ai-ollama/tests/text-adapter.test.ts +++ b/packages/ai-ollama/tests/text-adapter.test.ts @@ -54,6 +54,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllEnvs() + vi.restoreAllMocks() }) describe('OllamaTextAdapter construction', () => { @@ -277,52 +278,46 @@ describe('OllamaTextAdapter.chatStream (tool calls)', () => { const startChunk = chunks.find((c) => c.type === 'TOOL_CALL_START') as | { toolCallId: string; toolCallName: string } | undefined - expect(startChunk!.toolCallId).toMatch(/^search_\d+/) + expect(startChunk!.toolCallId).toMatch(/^search-\d+-/) }) it('keeps two parallel calls to the same tool apart when Ollama omits ids', async () => { + // Same millisecond for both calls: the old `name_Date.now()` id collided here vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) - try { - chatMock.mockResolvedValueOnce( - asyncIterable([ - { - message: { - role: 'assistant', - content: '', - tool_calls: [ - { function: { name: 'search', arguments: { q: 'cats' } } }, - { function: { name: 'search', arguments: { q: 'dogs' } } }, - ], - }, - done: true, - done_reason: 'stop', + chatMock.mockResolvedValueOnce( + asyncIterable([ + { + message: { + role: 'assistant', + content: '', + tool_calls: [ + { function: { name: 'search', arguments: { q: 'cats' } } }, + { function: { name: 'search', arguments: { q: 'dogs' } } }, + ], }, - ]), - ) + done: true, + done_reason: 'stop', + }, + ]), + ) - const chunks = await collectStream( - createOllamaChat('llama3.2').chatStream({ - threadId: 'thread-1', - logger: testLogger, - model: 'llama3.2', - messages: [{ role: 'user', content: 'find cats and dogs' }], - tools: [searchTool], - }), - ) + const chunks = await collectStream( + createOllamaChat('llama3.2').chatStream({ + threadId: 'thread-1', + logger: testLogger, + model: 'llama3.2', + messages: [{ role: 'user', content: 'find cats and dogs' }], + tools: [searchTool], + }), + ) - const starts = chunks.filter((c) => c.type === 'TOOL_CALL_START') as Array<{ - toolCallId: string - }> - const ends = chunks.filter((c) => c.type === 'TOOL_CALL_END') as Array<{ - toolCallId: string - args: string - }> - expect(starts).toHaveLength(2) - expect(new Set(starts.map((s) => s.toolCallId)).size).toBe(2) - expect(ends.map((e) => e.args)).toEqual(['{"q":"cats"}', '{"q":"dogs"}']) - } finally { - vi.restoreAllMocks() - } + const startIds = chunks.flatMap((c) => + c.type === 'TOOL_CALL_START' ? [c.toolCallId] : [], + ) + const ends = chunks.flatMap((c) => (c.type === 'TOOL_CALL_END' ? [c] : [])) + expect(new Set(startIds).size).toBe(2) + expect(ends.map((e) => e.toolCallId)).toEqual(startIds) + expect(ends.map((e) => e.args)).toEqual(['{"q":"cats"}', '{"q":"dogs"}']) }) async function toolCallEnd(args: unknown) { diff --git a/testing/e2e/fixtures/tool-calling/parallel-same-tool.json b/testing/e2e/fixtures/tool-calling/parallel-same-tool.json new file mode 100644 index 0000000000..7e3381f4f8 --- /dev/null +++ b/testing/e2e/fixtures/tool-calling/parallel-same-tool.json @@ -0,0 +1,31 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[parallel-same] compare guitars 1 and 2, then 3 and 4", + "sequenceIndex": 0 + }, + "response": { + "toolCalls": [ + { + "name": "compareGuitars", + "arguments": "{\"guitarIds\":[1,2]}" + }, + { + "name": "compareGuitars", + "arguments": "{\"guitarIds\":[3,4]}" + } + ] + } + }, + { + "match": { + "userMessage": "[parallel-same] compare guitars 1 and 2, then 3 and 4", + "sequenceIndex": 1 + }, + "response": { + "content": "Both comparisons are done. The Stratocaster is the cheapest of the first pair." + } + } + ] +} diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 8bf6f8fa98..7c28351025 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -154,6 +154,7 @@ export const matrix: Record> = { 'cloudflare', 'neon', 'neon-anthropic', + 'ollama', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format // Vertex uses the same Gemini stream, so it is excluded for the same reason. diff --git a/testing/e2e/tests/parallel-tool-calls.spec.ts b/testing/e2e/tests/parallel-tool-calls.spec.ts index aa20742a0c..02cfa5c728 100644 --- a/testing/e2e/tests/parallel-tool-calls.spec.ts +++ b/testing/e2e/tests/parallel-tool-calls.spec.ts @@ -33,5 +33,27 @@ for (const provider of providersFor('parallel-tool-calls')) { // Wait for text response after tool execution await waitForAssistantText(page, 'Stratocaster') }) + + // Ollama sends no tool-call ids, so this guards the fallback id + test('keeps parallel calls to the same tool apart', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto( + featureUrl(provider, 'parallel-tool-calls', testId, aimockPort), + ) + + await sendMessage( + page, + '[parallel-same] compare guitars 1 and 2, then 3 and 4', + ) + await waitForResponse(page) + + const toolNames = (await getToolCalls(page)).map((t) => t.name) + expect(toolNames).toEqual(['compareGuitars', 'compareGuitars']) + + await waitForAssistantText(page, 'Both comparisons are done') + }) }) } From 3425ed7740f083a8380999cf6420dfc0fa83bd7c Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:02:29 +1100 Subject: [PATCH 3/3] test(e2e): count only compareGuitars calls in the same-tool parallel case --- testing/e2e/tests/parallel-tool-calls.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing/e2e/tests/parallel-tool-calls.spec.ts b/testing/e2e/tests/parallel-tool-calls.spec.ts index 02cfa5c728..f67b7748ea 100644 --- a/testing/e2e/tests/parallel-tool-calls.spec.ts +++ b/testing/e2e/tests/parallel-tool-calls.spec.ts @@ -51,7 +51,9 @@ for (const provider of providersFor('parallel-tool-calls')) { await waitForResponse(page) const toolNames = (await getToolCalls(page)).map((t) => t.name) - expect(toolNames).toEqual(['compareGuitars', 'compareGuitars']) + expect( + toolNames.filter((name) => name === 'compareGuitars'), + ).toHaveLength(2) await waitForAssistantText(page, 'Both comparisons are done') })