fix: reorder tool_result blocks to match tool_use order (fixes #11804)

When the Anthropic API returns multiple tool_use blocks, tool_result blocks
must appear in the same order. The existing validateAndFixToolResultIds()
already handled missing/duplicate results but did not reorder them.

This adds a reorderToolResults() helper that sorts tool_result blocks by
their corresponding tool_use index while keeping non-tool-result blocks
(text, image) in their original positions.
This commit is contained in:
Roo Code 2026-03-01 15:20:54 +00:00
parent 193053110b
commit 2bd2c13790
2 changed files with 211 additions and 15 deletions

View file

@ -611,11 +611,10 @@ describe("validateAndFixToolResultIds", () => {
const resultContent = result.content as Anthropic.ToolResultBlockParam[]
// Should now have 2 tool_results: one fixed and one added for the missing tool_use
expect(resultContent.length).toBe(2)
// The missing tool_result is prepended
expect(resultContent[0].tool_use_id).toBe("tool-2")
expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.")
// The original is fixed
expect(resultContent[1].tool_use_id).toBe("tool-1")
// Reordered to match tool_use order: tool-1 first (fixed from wrong-1), tool-2 second (injected)
expect(resultContent[0].tool_use_id).toBe("tool-1")
expect(resultContent[1].tool_use_id).toBe("tool-2")
expect(resultContent[1].content).toBe("Tool execution was interrupted before completion.")
})
})
@ -736,12 +735,11 @@ describe("validateAndFixToolResultIds", () => {
expect(Array.isArray(result.content)).toBe(true)
const resultContent = result.content as Anthropic.ToolResultBlockParam[]
expect(resultContent.length).toBe(2)
// Missing tool_result for tool-2 should be prepended
expect(resultContent[0].tool_use_id).toBe("tool-2")
expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.")
// Existing tool_result should be preserved
expect(resultContent[1].tool_use_id).toBe("tool-1")
expect(resultContent[1].content).toBe("Content for tool 1")
// Reordered to match tool_use order: tool-1 first (existing), tool-2 second (injected)
expect(resultContent[0].tool_use_id).toBe("tool-1")
expect(resultContent[0].content).toBe("Content for tool 1")
expect(resultContent[1].tool_use_id).toBe("tool-2")
expect(resultContent[1].content).toBe("Tool execution was interrupted before completion.")
})
it("should handle empty user content array by adding all missing tool_results", () => {
@ -994,4 +992,116 @@ describe("validateAndFixToolResultIds", () => {
expect(TelemetryService.instance.captureException).not.toHaveBeenCalled()
})
})
describe("tool_result reordering to match tool_use order", () => {
it("should reorder out-of-order tool_results to match tool_use order", () => {
const assistantMessage: Anthropic.MessageParam = {
role: "assistant",
content: [
{ type: "tool_use", id: "tool-A", name: "read_file", input: { path: "a.txt" } },
{ type: "tool_use", id: "tool-B", name: "read_file", input: { path: "b.txt" } },
{ type: "tool_use", id: "tool-C", name: "read_file", input: { path: "c.txt" } },
],
}
// tool_results arrive in reverse order (C, B, A) instead of (A, B, C)
const userMessage: Anthropic.MessageParam = {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tool-C", content: "Result C" },
{ type: "tool_result", tool_use_id: "tool-B", content: "Result B" },
{ type: "tool_result", tool_use_id: "tool-A", content: "Result A" },
],
}
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
const content = result.content as Anthropic.ToolResultBlockParam[]
expect(content[0].tool_use_id).toBe("tool-A")
expect(content[1].tool_use_id).toBe("tool-B")
expect(content[2].tool_use_id).toBe("tool-C")
})
it("should keep non-tool-result blocks in their original positions when reordering", () => {
const assistantMessage: Anthropic.MessageParam = {
role: "assistant",
content: [
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "a.txt" } },
{ type: "tool_use", id: "tool-2", name: "write_file", input: { path: "b.txt" } },
],
}
// tool_results are reversed, with a text block in between
const userMessage: Anthropic.MessageParam = {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tool-2", content: "Result 2" },
{ type: "text", text: "Here are the results" },
{ type: "tool_result", tool_use_id: "tool-1", content: "Result 1" },
],
}
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
const content = result.content as Anthropic.Messages.ContentBlockParam[]
// tool_results should be reordered, but text block stays at index 1
expect(content[0]).toEqual({ type: "tool_result", tool_use_id: "tool-1", content: "Result 1" })
expect(content[1]).toEqual({ type: "text", text: "Here are the results" })
expect(content[2]).toEqual({ type: "tool_result", tool_use_id: "tool-2", content: "Result 2" })
})
it("should not modify content when tool_results are already in correct order", () => {
const assistantMessage: Anthropic.MessageParam = {
role: "assistant",
content: [
{ type: "tool_use", id: "tool-X", name: "read_file", input: { path: "x.txt" } },
{ type: "tool_use", id: "tool-Y", name: "read_file", input: { path: "y.txt" } },
],
}
const userMessage: Anthropic.MessageParam = {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tool-X", content: "Result X" },
{ type: "tool_result", tool_use_id: "tool-Y", content: "Result Y" },
],
}
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
expect(result).toEqual(userMessage)
})
it("should reorder tool_results even when mixed with missing result injection", () => {
const assistantMessage: Anthropic.MessageParam = {
role: "assistant",
content: [
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "a.txt" } },
{ type: "tool_use", id: "tool-2", name: "write_file", input: { path: "b.txt" } },
{ type: "tool_use", id: "tool-3", name: "list_files", input: { path: "." } },
],
}
// Only tool-3 and tool-1 have results (out of order), tool-2 is missing
const userMessage: Anthropic.MessageParam = {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tool-3", content: "Result 3" },
{ type: "tool_result", tool_use_id: "tool-1", content: "Result 1" },
],
}
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
const toolResults = (result.content as Anthropic.Messages.ContentBlockParam[]).filter(
(b): b is Anthropic.ToolResultBlockParam => b.type === "tool_result",
)
// Should have 3 tool_results (including injected one for tool-2)
expect(toolResults).toHaveLength(3)
// They should be in tool_use order: tool-1, tool-2, tool-3
expect(toolResults[0].tool_use_id).toBe("tool-1")
expect(toolResults[1].tool_use_id).toBe("tool-2")
expect(toolResults[2].tool_use_id).toBe("tool-3")
})
})
})

View file

@ -122,8 +122,16 @@ export function validateAndFixToolResultIds(
// Check if any tool_result has an invalid ID
const hasInvalidIds = toolResults.some((result) => !validToolUseIds.has(result.tool_use_id))
// If no missing tool_results and no invalid IDs, no changes needed
// If no missing tool_results and no invalid IDs, check if reordering is needed
if (missingToolUseIds.length === 0 && !hasInvalidIds) {
// Reorder tool_result blocks to match tool_use order (required by Anthropic API)
const reordered = reorderToolResults(
userMessage.content as Anthropic.Messages.ContentBlockParam[],
toolUseBlocks,
)
if (reordered) {
return { ...userMessage, content: reordered }
}
return userMessage
}
@ -223,12 +231,90 @@ export function validateAndFixToolResultIds(
content: "Tool execution was interrupted before completion.",
}))
// Insert missing tool_results at the beginning of the content array
// This ensures they come before any text blocks that may summarize the results
const finalContent = missingToolResults.length > 0 ? [...missingToolResults, ...correctedContent] : correctedContent
// Combine missing tool_results with corrected content
const combinedContent =
missingToolResults.length > 0 ? [...missingToolResults, ...correctedContent] : correctedContent
// Reorder tool_result blocks to match the tool_use order (required by Anthropic API).
// This handles the case where tool results were appended in completion order rather
// than the original tool_use order.
const finalContent = reorderToolResults(combinedContent, toolUseBlocks) ?? combinedContent
return {
...userMessage,
content: finalContent,
}
}
/**
* Reorders tool_result blocks within a content array to match the order of
* their corresponding tool_use blocks from the assistant message.
*
* Non-tool-result blocks (text, image, etc.) remain in their original
* positions relative to the tool_result blocks -- only tool_results are
* reordered among themselves.
*
* Returns `null` if the tool_results are already in the correct order
* (no reordering needed).
*/
function reorderToolResults(
content: Anthropic.Messages.ContentBlockParam[],
toolUseBlocks: Anthropic.ToolUseBlock[],
): Anthropic.Messages.ContentBlockParam[] | null {
if (toolUseBlocks.length === 0) {
return null
}
// Build an order map: tool_use_id -> position index
const orderMap = new Map<string, number>()
toolUseBlocks.forEach((block, index) => {
orderMap.set(block.id, index)
})
// Separate tool_result blocks from non-tool-result blocks, preserving indices
const toolResultEntries: { index: number; block: Anthropic.ToolResultBlockParam }[] = []
const nonToolResultEntries: { index: number; block: Anthropic.Messages.ContentBlockParam }[] = []
content.forEach((block, index) => {
if (block.type === "tool_result") {
toolResultEntries.push({ index, block: block as Anthropic.ToolResultBlockParam })
} else {
nonToolResultEntries.push({ index, block })
}
})
if (toolResultEntries.length <= 1) {
return null // Nothing to reorder
}
// Sort tool_result blocks by their corresponding tool_use order
const sortedToolResults = [...toolResultEntries].sort((a, b) => {
const orderA = orderMap.get(a.block.tool_use_id) ?? Number.MAX_SAFE_INTEGER
const orderB = orderMap.get(b.block.tool_use_id) ?? Number.MAX_SAFE_INTEGER
return orderA - orderB
})
// Check if already in correct order
const alreadyOrdered = sortedToolResults.every((entry, i) => entry === toolResultEntries[i])
if (alreadyOrdered) {
return null
}
// Reconstruct the array: place sorted tool_results into the original
// tool_result positions, keeping non-tool-result blocks where they were.
const result: Anthropic.Messages.ContentBlockParam[] = new Array(content.length)
// First, place non-tool-result blocks back at their original indices
for (const entry of nonToolResultEntries) {
result[entry.index] = entry.block
}
// Then, place sorted tool_results into the slots that were originally
// occupied by tool_result blocks (preserving relative position of non-tool blocks)
const toolResultSlots = toolResultEntries.map((e) => e.index)
sortedToolResults.forEach((entry, i) => {
result[toolResultSlots[i]] = entry.block
})
return result
}