From baaae9eeaa8a908802354d88719a9af16066a1b3 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Tue, 20 Oct 2026 22:00:42 +0200 Subject: [PATCH 1/2] fix(task): normalize tool_use/tool_result pairing in outgoing requests Anthropic rejects requests where a tool_result is not directly after its tool_use, is preceded by other content, or has no matching tool_use. Shape the history sent to the API so results come first in tool_use order, interrupted calls get a synthetic error result, and orphan results are dropped, on every path rather than only after a summary. Fixes #2098 Co-Authored-By: Claude Sonnet 5.5 --- src/core/task/Task.ts | 3 +- .../normalizeToolResultPairing.spec.ts | 81 +++++++++++++++++++ src/core/task/normalizeToolResultPairing.ts | 79 ++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/core/task/__tests__/normalizeToolResultPairing.spec.ts create mode 100644 src/core/task/normalizeToolResultPairing.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ebb362f7eb..d046b34f1b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -140,6 +140,7 @@ import { type AutoDenyDetail, AutoApprovalHandler, checkAutoApproval } from "../ import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" +import { normalizeToolResultPairing } from "./normalizeToolResultPairing" import { prepareApiConversationMessage } from "./apiConversationHistory" import { shouldAddUserMessageToHistory } from "./messageCounting" import { type TaskExecutionContext } from "./providerHandoff" @@ -5845,7 +5846,7 @@ export class Task extends EventEmitter implements TaskLike { // For API only: merge consecutive user messages (excludes summary messages per // mergeConsecutiveApiMessages implementation) without mutating stored history. const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) - const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) + const messagesWithoutImages = maybeRemoveImageBlocks(normalizeToolResultPairing(mergedForApi), this.api) const cleanConversationHistory = this.buildCleanConversationHistory( messagesWithoutImages as ApiMessage[], requestModelInfo, diff --git a/src/core/task/__tests__/normalizeToolResultPairing.spec.ts b/src/core/task/__tests__/normalizeToolResultPairing.spec.ts new file mode 100644 index 0000000000..e6f6dc9922 --- /dev/null +++ b/src/core/task/__tests__/normalizeToolResultPairing.spec.ts @@ -0,0 +1,81 @@ +import type { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiMessage } from "../../task-persistence" +import { normalizeToolResultPairing } from "../normalizeToolResultPairing" + +type Block = Anthropic.Messages.ContentBlockParam + +const use = (id: string): Block => ({ type: "tool_use", id, name: "read_file", input: {} }) +const result = (id: string, content = `result ${id}`): Block => ({ type: "tool_result", tool_use_id: id, content }) +const text = (value: string): Block => ({ type: "text", text: value }) +const image: Block = { type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } } +const assistant = (...content: Block[]): ApiMessage => ({ role: "assistant", content }) +const user = (...content: Block[]): ApiMessage => ({ role: "user", content }) + +describe("normalizeToolResultPairing", () => { + it("moves interleaved tool results ahead of other content", () => { + const input = [ + user(text("go")), + assistant(text("ok"), use("A"), use("B")), + user(result("A"), image, result("B")), + ] + + expect(normalizeToolResultPairing(input)[2].content).toEqual([result("A"), result("B"), image]) + }) + + it("orders results by tool_use order and drops duplicates", () => { + const input = [assistant(use("A"), use("B")), user(result("B"), result("A"), result("A", "dup"))] + + expect(normalizeToolResultPairing(input)[1].content).toEqual([result("A"), result("B")]) + }) + + it("adds a synthetic error result for an interrupted tool_use", () => { + const input = [assistant(use("A"), use("B")), user(result("A"), text("next"))] + + expect(normalizeToolResultPairing(input)[1].content).toEqual([ + result("A"), + { type: "tool_result", tool_use_id: "B", content: "Tool execution was interrupted before completion." }, + text("next"), + ]) + }) + + it("inserts a user turn when an assistant turn is followed by another assistant turn", () => { + const out = normalizeToolResultPairing([assistant(use("A")), assistant(text("again"))]) + + expect(out.map((m) => m.role)).toEqual(["assistant", "user", "assistant"]) + expect(out[1].content).toMatchObject([{ type: "tool_result", tool_use_id: "A" }]) + }) + + it.each([ + { name: "result for an unknown id", messages: [assistant(use("A")), user(result("A"), result("X"))] }, + { + name: "result after a text-only assistant turn", + messages: [assistant(text("hi")), user(result("X"), text("q"))], + }, + ])("drops orphan tool results: $name", ({ messages }) => { + const ids = normalizeToolResultPairing(messages) + .flatMap((m) => (Array.isArray(m.content) ? m.content : [])) + .flatMap((b) => (b.type === "tool_result" ? [b.tool_use_id] : [])) + + expect(ids).not.toContain("X") + }) + + it("removes a user message that only held orphan results", () => { + expect(normalizeToolResultPairing([assistant(text("hi")), user(result("X"))])).toHaveLength(1) + }) + + it("leaves a trailing assistant tool_use and well-formed history unchanged", () => { + const wellFormed = [user(text("go")), assistant(use("A")), user(result("A"), text("done")), assistant(use("B"))] + + expect(normalizeToolResultPairing(wellFormed)).toEqual(wellFormed) + }) + + it("does not mutate its input", () => { + const input = [assistant(use("A")), user(image, result("A"))] + const snapshot = structuredClone(input) + + normalizeToolResultPairing(input) + + expect(input).toEqual(snapshot) + }) +}) diff --git a/src/core/task/normalizeToolResultPairing.ts b/src/core/task/normalizeToolResultPairing.ts new file mode 100644 index 0000000000..60e4701c69 --- /dev/null +++ b/src/core/task/normalizeToolResultPairing.ts @@ -0,0 +1,79 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiMessage } from "../task-persistence" + +type Block = Anthropic.Messages.ContentBlockParam +type ToolResultBlock = Anthropic.Messages.ToolResultBlockParam + +const INTERRUPTED_RESULT = "Tool execution was interrupted before completion." + +function toBlocks(content: ApiMessage["content"]): Block[] { + if (Array.isArray(content)) return content as Block[] + if (content === undefined || content === null || content === "") return [] + return [{ type: "text", text: String(content) }] +} + +function toolUseIdsOf(message: ApiMessage): string[] { + if (message.role !== "assistant" || !Array.isArray(message.content)) return [] + return (message.content as Block[]).flatMap((block) => (block.type === "tool_use" ? [block.id] : [])) +} + +/** + * Shapes outgoing history so tool results satisfy Anthropic's adjacency rule: every + * `tool_use` in an assistant message is answered by a `tool_result` in the very next + * user message, `tool_result` blocks come first, and no `tool_result` refers to a + * `tool_use` outside the immediately preceding assistant message. + * + * - Results are moved ahead of other content, in `tool_use` order (duplicates dropped). + * - Results without a matching `tool_use` are dropped. + * - Interrupted calls get a synthetic error result. + * + * Used for API request shaping only; stored history is not mutated. A trailing assistant + * message is left alone because its results have not been produced yet. + */ +export function normalizeToolResultPairing(messages: ApiMessage[]): ApiMessage[] { + const out: ApiMessage[] = [] + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + + if (message.role === "assistant") { + out.push(message) + const toolUseIds = toolUseIdsOf(message) + if (toolUseIds.length === 0 || i === messages.length - 1) continue + + const next = messages[i + 1] + const nextBlocks = next.role === "user" ? toBlocks(next.content) : [] + const resultsById = new Map() + for (const block of nextBlocks) { + if (block.type === "tool_result" && !resultsById.has(block.tool_use_id)) { + resultsById.set(block.tool_use_id, block) + } + } + + const results: ToolResultBlock[] = toolUseIds.map( + (id) => resultsById.get(id) ?? { type: "tool_result", tool_use_id: id, content: INTERRUPTED_RESULT }, + ) + + if (next.role === "user") { + const rest = nextBlocks.filter((block) => block.type !== "tool_result") + out.push({ ...next, content: [...results, ...rest] }) + i++ + } else { + // Another assistant message follows: answer the calls with a user turn of their own. + out.push({ role: "user", content: results }) + } + continue + } + + // A user message not directly after a tool_use turn cannot carry tool results. + if (!Array.isArray(message.content) || !message.content.some((block) => block.type === "tool_result")) { + out.push(message) + continue + } + const kept = (message.content as Block[]).filter((block) => block.type !== "tool_result") + if (kept.length > 0) out.push({ ...message, content: kept }) + } + + return out +} From 9a1d02c1db8138ffed61034ce595a097b2b74db0 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Tue, 20 Oct 2026 22:04:03 +0200 Subject: [PATCH 2/2] fix(task): queue tool_results ahead of images from parallel tool calls pushToolResult appended each tool's images right after its result, so parallel calls produced [result A, image, result B], which Anthropic rejects. Insert new results after the last existing result instead. Co-Authored-By: Claude Sonnet 5.5 --- src/core/task/Task.ts | 8 +++++-- src/core/task/__tests__/Task.spec.ts | 31 +++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d046b34f1b..96e4d8c9f1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -575,7 +575,8 @@ export class Task extends EventEmitter implements TaskLike { } /** - * Push a tool_result block to userMessageContent, preventing duplicates. + * Push a tool_result block to userMessageContent after any existing tool_results + * (ahead of images and text), preventing duplicates. * Duplicate tool_use_ids cause API errors. * * @param toolResult - The tool_result block to add @@ -592,7 +593,10 @@ export class Task extends EventEmitter implements TaskLike { ) return false } - this.userMessageContent.push(toolResult) + // Keep results contiguous at the front: with parallel tool calls, an earlier tool's + // images or text are already queued, and Anthropic needs every tool_result first. + const lastResultIndex = findLastIndex(this.userMessageContent, (block) => block.type === "tool_result") + this.userMessageContent.splice(lastResultIndex + 1, 0, toolResult) return true } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 3066613263..0f1d40c8c1 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -8171,6 +8171,30 @@ describe("pushToolResultToUserContent", () => { warnSpy.mockRestore() }) + it("keeps tool_results ahead of images queued by earlier parallel tool calls", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const image: Anthropic.ImageBlockParam = { + type: "image", + source: { type: "base64", media_type: "image/png", data: "AAAA" }, + } + const result = (id: string): Anthropic.ToolResultBlockParam => ({ + type: "tool_result", + tool_use_id: id, + content: id, + }) + + task.pushToolResultToUserContent(result("A")) + task.userMessageContent.push(image) + task.pushToolResultToUserContent(result("B")) + + expect(task.userMessageContent).toEqual([result("A"), result("B"), image]) + }) + it("should allow different tool_use_ids to be added", () => { const task = new Task({ provider: mockProvider, @@ -8247,8 +8271,9 @@ describe("pushToolResultToUserContent", () => { expect(added).toBe(true) expect(task.userMessageContent).toHaveLength(3) - expect(task.userMessageContent[0].type).toBe("text") - expect(task.userMessageContent[1].type).toBe("image") - expect(task.userMessageContent[2]).toEqual(toolResult) + // Tool results lead; other content keeps its relative order after them. + expect(task.userMessageContent[0]).toEqual(toolResult) + expect(task.userMessageContent[1].type).toBe("text") + expect(task.userMessageContent[2].type).toBe("image") }) })