Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 121 additions & 11 deletions src/core/task/__tests__/validateToolResultIds.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
})
})

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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")
})
})
})
94 changes: 90 additions & 4 deletions src/core/task/validateToolResultIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Loading