From b170ad7830a718a4964d6f77889cd2aca170582c Mon Sep 17 00:00:00 2001 From: kripu77 Date: Thu, 2 Jul 2026 16:10:47 +1000 Subject: [PATCH] fix (ai): serialize parallel tool results in tool-call order --- .changeset/tool-result-call-order.md | 5 + .../to-response-messages.test.ts | 277 ++++++++++++++++++ .../src/generate-text/to-response-messages.ts | 27 ++ 3 files changed, 309 insertions(+) create mode 100644 .changeset/tool-result-call-order.md diff --git a/.changeset/tool-result-call-order.md b/.changeset/tool-result-call-order.md new file mode 100644 index 000000000000..1fcf76d5623c --- /dev/null +++ b/.changeset/tool-result-call-order.md @@ -0,0 +1,5 @@ +--- +'ai': patch +--- + +fix (ai): serialize parallel tool results in tool-call order for deterministic prompt caching diff --git a/packages/ai/src/generate-text/to-response-messages.test.ts b/packages/ai/src/generate-text/to-response-messages.test.ts index d18de47007ea..bd03462b62d6 100644 --- a/packages/ai/src/generate-text/to-response-messages.test.ts +++ b/packages/ai/src/generate-text/to-response-messages.test.ts @@ -1507,4 +1507,281 @@ describe('toResponseMessages', () => { ] `); }); + + describe('parallel tool result ordering', () => { + const weatherTool = tool({ + description: 'Get weather information', + inputSchema: z.object({ city: z.string() }), + }); + + it('should order tool results by tool-call order, not completion order', async () => { + const result = await toResponseMessages({ + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + }, + { + type: 'tool-call', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + }, + // completion order: call-2 finished first + { + type: 'tool-result', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + output: '14C and rainy', + }, + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + output: '25C and sunny', + }, + ], + tools: { weather: weatherTool }, + }); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "input": { + "city": "Tokyo", + }, + "providerExecuted": undefined, + "providerOptions": undefined, + "toolCallId": "call-1", + "toolName": "weather", + "type": "tool-call", + }, + { + "input": { + "city": "London", + }, + "providerExecuted": undefined, + "providerOptions": undefined, + "toolCallId": "call-2", + "toolName": "weather", + "type": "tool-call", + }, + ], + "role": "assistant", + }, + { + "content": [ + { + "output": { + "type": "text", + "value": "25C and sunny", + }, + "toolCallId": "call-1", + "toolName": "weather", + "type": "tool-result", + }, + { + "output": { + "type": "text", + "value": "14C and rainy", + }, + "toolCallId": "call-2", + "toolName": "weather", + "type": "tool-result", + }, + ], + "role": "tool", + }, + ] + `); + }); + + it('should keep tool results unchanged when they already match tool-call order', async () => { + const result = await toResponseMessages({ + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + }, + { + type: 'tool-call', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + }, + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + output: '25C and sunny', + }, + { + type: 'tool-result', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + output: '14C and rainy', + }, + ], + tools: { weather: weatherTool }, + }); + + const toolMessage = result.find(message => message.role === 'tool'); + expect( + toolMessage?.content.map(part => + part.type === 'tool-result' ? part.toolCallId : part.type, + ), + ).toEqual(['call-1', 'call-2']); + }); + + it('should order tool errors together with tool results by tool-call order', async () => { + const result = await toResponseMessages({ + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + }, + { + type: 'tool-call', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + }, + // completion order: call-2 errored first + { + type: 'tool-error', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + error: 'City not found', + }, + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + output: '25C and sunny', + }, + ], + tools: { weather: weatherTool }, + }); + + const toolMessage = result.find(message => message.role === 'tool'); + expect( + toolMessage?.content.map(part => + part.type === 'tool-result' ? part.toolCallId : part.type, + ), + ).toEqual(['call-1', 'call-2']); + }); + + it('should sort tool results without a matching tool call last, tie-broken by id', async () => { + const result = await toResponseMessages({ + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + }, + { + type: 'tool-result', + toolCallId: 'call-3', + toolName: 'weather', + input: { city: 'Paris' }, + output: '18C and cloudy', + }, + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + output: '25C and sunny', + }, + { + type: 'tool-result', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + output: '14C and rainy', + }, + ], + tools: { weather: weatherTool }, + }); + + const toolMessage = result.find(message => message.role === 'tool'); + expect( + toolMessage?.content.map(part => + part.type === 'tool-result' ? part.toolCallId : part.type, + ), + ).toEqual(['call-1', 'call-2', 'call-3']); + }); + + it('should not reorder the tool message when approval responses are present', async () => { + const result = await toResponseMessages({ + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + }, + { + type: 'tool-call', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + }, + { + type: 'tool-approval-request', + approvalId: 'approval-1', + toolCall: { + type: 'tool-call', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + }, + }, + // completion order: call-2's denial resolved first + { + type: 'tool-approval-response', + approvalId: 'approval-1', + approved: false, + reason: 'User denied access', + toolCall: { + type: 'tool-call', + toolCallId: 'call-2', + toolName: 'weather', + input: { city: 'London' }, + }, + }, + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'weather', + input: { city: 'Tokyo' }, + output: '25C and sunny', + }, + ], + tools: { weather: weatherTool }, + }); + + const toolMessage = result.find(message => message.role === 'tool'); + expect( + toolMessage?.content.map(part => + part.type === 'tool-result' ? part.toolCallId : part.type, + ), + ).toEqual(['tool-approval-response', 'call-2', 'call-1']); + }); + }); }); diff --git a/packages/ai/src/generate-text/to-response-messages.ts b/packages/ai/src/generate-text/to-response-messages.ts index 656c179462f3..23c9aa69b0a1 100644 --- a/packages/ai/src/generate-text/to-response-messages.ts +++ b/packages/ai/src/generate-text/to-response-messages.ts @@ -201,6 +201,33 @@ export async function toResponseMessages({ }); } + // Sort tool results into tool-call order: parallel results arrive in + // nondeterministic completion order, which breaks byte-sensitive provider + // prompt caching (#16567). Skipped when approval responses are present, + // since their ordering is position-dependent. + if ( + toolResultContent.length > 1 && + toolResultContent.every(part => part.type === 'tool-result') + ) { + const toolCallRank = new Map( + inputContent + .filter(part => part.type === 'tool-call') + .map((part, index) => [part.toolCallId, index]), + ); + toolResultContent.sort((a, b) => { + const aRank = toolCallRank.get(a.toolCallId); + const bRank = toolCallRank.get(b.toolCallId); + if (aRank != null && bRank != null) return aRank - bRank; + if (aRank != null) return -1; + if (bRank != null) return 1; + return a.toolCallId < b.toolCallId + ? -1 + : a.toolCallId > b.toolCallId + ? 1 + : 0; + }); + } + if (toolResultContent.length > 0) { responseMessages.push({ role: 'tool',