From 01cbdcb8e4de17052260b464ec719f70487f89e0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:18:50 -0500 Subject: [PATCH] fix(ai): batch Anthropic parallel tool results (#41948) Co-authored-by: Aiden Cline --- .../ai/src/protocols/anthropic-messages.ts | 5 +- .../test/provider/anthropic-messages.test.ts | 89 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 7539e54d092..78c76be5e43 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -573,7 +573,10 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( cache_control: cacheControl(breakpoints, part.cache), }) } - messages.push({ role: "user", content }) + const previous = messages.at(-1) + if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result")) + messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] } + else messages.push({ role: "user", content }) } return messages diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index ab1047f2c92..d8792c3d9df 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -271,6 +271,47 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("batches parallel tool results into one Anthropic user message", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.user("Check both cities."), + Message.assistant([ + { type: "text", text: "I'll check both." }, + ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }), + ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }), + ]), + Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }), + Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }), + ], + cache: "none", + }), + ) + + expect(prepared.body.messages).toMatchObject([ + { role: "user", content: [{ type: "text", text: "Check both cities." }] }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll check both." }, + { type: "tool_use", id: "call_paris", name: "weather", input: { city: "Paris" } }, + { type: "tool_use", id: "call_london", name: "weather", input: { city: "London" } }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' }, + { type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' }, + ], + }, + ]) + expect(prepared.body.messages).toHaveLength(3) + }), + ) + it.effect("keeps tools and sends tool_choice none", () => Effect.gen(function* () { const prepared = yield* compileRequest( @@ -915,6 +956,54 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("assembles and persists multiple tool calls from one Anthropic response", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "call_paris", name: "weather", input: {} }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "call_london", name: "weather", input: {} }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"city":"London"}' }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 2 } }, + { type: "message_stop" }, + ), + ), + ), + ) + + expect(response.toolCalls).toMatchObject([ + { id: "call_paris", name: "weather", input: { city: "Paris" } }, + { id: "call_london", name: "weather", input: { city: "London" } }, + ]) + expect(response.message.content).toMatchObject([ + { type: "tool-call", id: "call_paris", name: "weather", input: { city: "Paris" } }, + { type: "tool-call", id: "call_london", name: "weather", input: { city: "London" } }, + ]) + expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" }) + }), + ) + it.effect("keeps malformed server tool input terminal", () => Effect.gen(function* () { const body = sseEvents(