From afaee41c67b77950d3995c85465c327fbeb1b47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hamb=C3=BCchen?= Date: Sun, 16 Aug 2026 15:56:47 +0200 Subject: [PATCH 1/2] Move `tool_result` blocks to the front. Fixes #1259 Assisted-By: Diagnosed and fixed using Claude Opus 5 in Zoo Code, human review. --- src/core/task/Task.ts | 13 ++++++- src/core/task/validateToolResultIds.ts | 52 +++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b728e43b9a..fcc9d8819d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -132,7 +132,7 @@ import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHist import { MessageQueueService } from "../message-queue/MessageQueueService" import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval" import { MessageManager } from "../message-manager" -import { validateAndFixToolResultIds } from "./validateToolResultIds" +import { hoistToolResultsToFront, validateAndFixToolResultIds } from "./validateToolResultIds" import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" import { prepareApiConversationMessage } from "./apiConversationHistory" import { shouldAddUserMessageToHistory } from "./messageCounting" @@ -4668,9 +4668,18 @@ export class Task extends EventEmitter implements TaskLike { // Default path for regular messages (no embedded reasoning) if (msg.role) { + const content = + msg.role === "user" && Array.isArray(msg.content) + ? // Fix already-broken persisted tasks affected by bug + // https://github.com/Zoo-Code-Org/Zoo-Code/issues/1259 + // that were created before the `hoistToolResultsToFront()` fix was introduced, + // by calling it here. + hoistToolResultsToFront(msg.content as Anthropic.Messages.ContentBlockParam[]) + : (msg.content as Anthropic.Messages.ContentBlockParam[] | string) + cleanConversationHistory.push({ role: msg.role, - content: msg.content as Anthropic.Messages.ContentBlockParam[] | string, + content, }) } } diff --git a/src/core/task/validateToolResultIds.ts b/src/core/task/validateToolResultIds.ts index a966d429ed..162fff4350 100644 --- a/src/core/task/validateToolResultIds.ts +++ b/src/core/task/validateToolResultIds.ts @@ -33,6 +33,50 @@ export class MissingToolResultError extends Error { } } +/** + * Moves all `tool_result` blocks to the front of a user message's content, preserving + * their relative order and the relative order of every other block. + * + * Anthropic documents this as a hard requirement: + * > In the user message containing tool results, the tool_result blocks must come + * > FIRST in the content array. Any text must come AFTER all tool results. + * https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls#handling-results-from-client-tools + * + * In practice only the *leading contiguous run* of `tool_result` blocks is recognized, so + * any non-`tool_result` block (an image returned by a tool, approval feedback, a queued + * user message, environment details, ...) that lands between two results truncates that + * run and orphans every result after it, producing: + * + * "`tool_use` ids were found without `tool_result` blocks immediately after: . + * Each `tool_use` block must have a corresponding `tool_result` block in the next message." + * + * This is reachable with parallel tool calls, because each tool appends its result and then + * its image blocks, so the second tool's result ends up after the first tool's images. + * Once the malformed message is persisted the task rejects every subsequent request, so the + * hoist runs before the message is written to the API conversation history. + * + * See: https://github.com/Zoo-Code-Org/Zoo-Code/issues/1259 + */ +export function hoistToolResultsToFront( + content: Anthropic.Messages.ContentBlockParam[], +): Anthropic.Messages.ContentBlockParam[] { + const firstNonToolResultIndex = content.findIndex((block) => block.type !== "tool_result") + + // Already well-formed when there is no trailing content, or when no tool_result + // appears after the first non-tool_result block. + if ( + firstNonToolResultIndex === -1 || + !content.slice(firstNonToolResultIndex + 1).some((block) => block.type === "tool_result") + ) { + return content + } + + const toolResults = content.filter((block) => block.type === "tool_result") + const otherBlocks = content.filter((block) => block.type !== "tool_result") + + return [...toolResults, ...otherBlocks] +} + /** * Validates and fixes tool_result IDs in a user message against the previous assistant message. * @@ -42,6 +86,7 @@ export class MissingToolResultError extends Error { * - Message editing scenarios * - Resume/delegation scenarios * - Missing tool_result blocks for tool_use calls + * - tool_result blocks interleaved with other block types * * @param userMessage - The user message being added to history * @param apiConversationHistory - The conversation history to find the previous assistant message from @@ -99,12 +144,15 @@ export function validateAndFixToolResultIds( return true }) + // Hoist tool_results ahead of any interleaved image/text blocks. + const hoistedContent = hoistToolResultsToFront(deduplicatedContent) + userMessage = { ...userMessage, - content: deduplicatedContent, + content: hoistedContent, } - toolResults = deduplicatedContent.filter( + toolResults = hoistedContent.filter( (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", ) From 7b9f729647b8fa25b6b2ca756c327025509edd0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hamb=C3=BCchen?= Date: Sun, 16 Aug 2026 16:08:24 +0200 Subject: [PATCH 2/2] Add tests for `hoistToolResultsToFront()` Assisted-By: Done using Claude Opus 5 in Zoo Code. --- .../__tests__/validateToolResultIds.spec.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/src/core/task/__tests__/validateToolResultIds.spec.ts b/src/core/task/__tests__/validateToolResultIds.spec.ts index 0926e899aa..f63411d1b3 100644 --- a/src/core/task/__tests__/validateToolResultIds.spec.ts +++ b/src/core/task/__tests__/validateToolResultIds.spec.ts @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { TelemetryService } from "@roo-code/telemetry" import { validateAndFixToolResultIds, + hoistToolResultsToFront, ToolResultIdMismatchError, MissingToolResultError, } from "../validateToolResultIds" @@ -994,4 +995,187 @@ describe("validateAndFixToolResultIds", () => { expect(TelemetryService.instance.captureException).not.toHaveBeenCalled() }) }) + + // Anthropic requires that tool_result blocks come FIRST in the user message content array; + // see the doc comment on `hoistToolResultsToFront()`. + describe("when tool_results are interleaved with other block types", () => { + it("should hoist a tool_result that follows an image block from a parallel tool call", () => { + const assistantMessage: Anthropic.MessageParam = { + role: "assistant", + content: [ + { type: "text", text: "The visual confirms it: ..." }, + { + type: "tool_use", + id: "tooluse_UDLZ6mSXfpiIeVAHk5NnIR", + name: "execute_command", + input: { command: "WS=/..." }, + }, + { + type: "tool_use", + id: "tooluse_NudlJpcjeQemU5wudkIYMA", + name: "execute_command", + input: { command: "git diff --stat -- python/" }, + }, + ], + } + + const userMessage: Anthropic.MessageParam = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tooluse_UDLZ6mSXfpiIeVAHk5NnIR", + content: "Exit code: 0", + }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "iVBOR" }, + }, + { + type: "tool_result", + tool_use_id: "tooluse_NudlJpcjeQemU5wudkIYMA", + content: "Exit code: 0", + }, + { type: "text", text: "Something wrong with the tool use?" }, + { type: "text", text: "..." }, + ], + } + + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) + const content = result.content as Anthropic.Messages.ContentBlockParam[] + + // Both tool_results must form one contiguous leading run. + expect(content.map((block) => block.type)).toEqual(["tool_result", "tool_result", "image", "text", "text"]) + // IDs and content are preserved, and no synthetic "interrupted" result is invented. + expect((content[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tooluse_UDLZ6mSXfpiIeVAHk5NnIR") + expect((content[1] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tooluse_NudlJpcjeQemU5wudkIYMA") + expect((content[1] as Anthropic.ToolResultBlockParam).content).toBe("Exit code: 0") + // Telemetry should not fire: nothing is missing or mismatched, only misordered. + expect(TelemetryService.instance.captureException).not.toHaveBeenCalled() + }) + + it("should hoist tool_results that follow a text block", () => { + const assistantMessage: Anthropic.MessageParam = { + role: "assistant", + content: [ + { type: "tool_use", id: "tool-1", name: "read_file", input: {} }, + { type: "tool_use", id: "tool-2", name: "read_file", input: {} }, + ], + } + + const userMessage: Anthropic.MessageParam = { + role: "user", + content: [ + { type: "text", text: "Here are the results:" }, + { type: "tool_result", tool_use_id: "tool-1", content: "A" }, + { type: "tool_result", tool_use_id: "tool-2", content: "B" }, + ], + } + + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) + const content = result.content as Anthropic.Messages.ContentBlockParam[] + + expect(content.map((block) => block.type)).toEqual(["tool_result", "tool_result", "text"]) + expect((content[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-1") + expect((content[1] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-2") + }) + + it("should deduplicate and hoist without mismatching IDs by position", () => { + // The duplicate must be dropped BEFORE positional ID correction, otherwise the + // interleaved ordering could cause a valid result to be reassigned the wrong ID. + const assistantMessage: Anthropic.MessageParam = { + role: "assistant", + content: [ + { type: "tool_use", id: "tool-1", name: "read_file", input: {} }, + { type: "tool_use", id: "tool-2", name: "read_file", input: {} }, + ], + } + + const userMessage: Anthropic.MessageParam = { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tool-1", content: "A" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "x" } }, + { type: "tool_result", tool_use_id: "tool-1", content: "duplicate" }, + { type: "tool_result", tool_use_id: "tool-2", content: "B" }, + ], + } + + const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) + const content = result.content as Anthropic.Messages.ContentBlockParam[] + + expect(content.map((block) => block.type)).toEqual(["tool_result", "tool_result", "image"]) + expect((content[0] as Anthropic.ToolResultBlockParam).content).toBe("A") + expect((content[1] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-2") + expect((content[1] as Anthropic.ToolResultBlockParam).content).toBe("B") + }) + }) +}) + +describe("hoistToolResultsToFront", () => { + it("returns the same array reference when tool_results are already contiguous at the front", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { type: "tool_result", tool_use_id: "tool-1", content: "A" }, + { type: "tool_result", tool_use_id: "tool-2", content: "B" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "x" } }, + { type: "text", text: "env" }, + ] + + expect(hoistToolResultsToFront(content)).toBe(content) + }) + + it("returns the same array reference when there are no tool_result blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { type: "text", text: "hello" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "x" } }, + ] + + expect(hoistToolResultsToFront(content)).toBe(content) + }) + + it("returns the same array reference for an empty array", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [] + expect(hoistToolResultsToFront(content)).toBe(content) + }) + + it("returns the same array reference when every block is a tool_result", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { type: "tool_result", tool_use_id: "tool-1", content: "A" }, + { type: "tool_result", tool_use_id: "tool-2", content: "B" }, + ] + + expect(hoistToolResultsToFront(content)).toBe(content) + }) + + it("preserves the relative order of both tool_results and other blocks", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { type: "text", text: "first" }, + { type: "tool_result", tool_use_id: "tool-1", content: "A" }, + { type: "text", text: "second" }, + { type: "tool_result", tool_use_id: "tool-2", content: "B" }, + { type: "text", text: "third" }, + ] + + const result = hoistToolResultsToFront(content) + + expect(result).toEqual([ + { type: "tool_result", tool_use_id: "tool-1", content: "A" }, + { type: "tool_result", tool_use_id: "tool-2", content: "B" }, + { type: "text", text: "first" }, + { type: "text", text: "second" }, + { type: "text", text: "third" }, + ]) + }) + + it("does not mutate the input array", () => { + const content: Anthropic.Messages.ContentBlockParam[] = [ + { type: "text", text: "first" }, + { type: "tool_result", tool_use_id: "tool-1", content: "A" }, + ] + const snapshot = [...content] + + hoistToolResultsToFront(content) + + expect(content).toEqual(snapshot) + }) })