fix: unify tool block handling across legacy and AI SDK formats

This commit is contained in:
daniel-lxs 2026-02-11 15:45:48 -05:00
parent dcb33c47ad
commit 2161f6092b
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
6 changed files with 427 additions and 99 deletions

View file

@ -242,6 +242,53 @@ describe("injectSyntheticToolResults", () => {
// Both tool_uses have matching tool_results, no injection needed
expect(result).toEqual(messages)
})
it("should support AI SDK tool-call/tool-result blocks", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "tool-1",
toolName: "read_file",
input: { path: "test.ts" },
},
] as any,
ts: 2,
},
{
role: "user",
content: [{ type: "tool-result", toolCallId: "tool-1", output: "file contents" }] as any,
ts: 3,
},
]
const result = injectSyntheticToolResults(messages)
expect(result).toEqual(messages)
})
it("should inject synthetic tool_result for orphan AI SDK tool-call", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "Hello", ts: 1 },
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "tool-orphan", toolName: "attempt_completion", input: {} },
] as any,
ts: 2,
},
]
const result = injectSyntheticToolResults(messages)
expect(result).toHaveLength(3)
const content = result[2].content as any[]
expect(content).toHaveLength(1)
expect(content[0].type).toBe("tool_result")
expect(content[0].tool_use_id).toBe("tool-orphan")
})
})
describe("getMessagesSinceLastSummary", () => {
@ -585,6 +632,58 @@ describe("getEffectiveApiHistory", () => {
expect(userContent[0].type).toBe("text")
expect(userContent[0].text).toBe("User added some text")
})
it("should keep AI SDK tool-result blocks that have matching tool-call after summary", () => {
const condenseId = "cond-1"
const messages: ApiMessage[] = [
{
role: "user",
content: [{ type: "text", text: "Summary content" }],
isSummary: true,
condenseId,
},
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "tool-valid", toolName: "read_file", input: {} }] as any,
},
{
role: "user",
content: [{ type: "tool-result", toolCallId: "tool-valid", output: "ok" }] as any,
},
]
const result = getEffectiveApiHistory(messages)
expect(result).toHaveLength(3)
expect((result[2].content as any[])[0].toolCallId).toBe("tool-valid")
})
it("should filter orphan AI SDK tool-result blocks after fresh start condensation", () => {
const condenseId = "cond-1"
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "tool-orphan", toolName: "attempt_completion", input: {} },
] as any,
condenseParent: condenseId,
},
{
role: "user",
content: [{ type: "text", text: "Summary content" }],
isSummary: true,
condenseId,
},
{
role: "user",
content: [{ type: "tool-result", toolCallId: "tool-orphan", output: "Rejected by user" }] as any,
},
]
const result = getEffectiveApiHistory(messages)
expect(result).toHaveLength(1)
expect(result[0].isSummary).toBe(true)
})
})
describe("cleanupAfterTruncation", () => {
@ -1505,6 +1604,31 @@ describe("convertToolBlocksToText", () => {
expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]")
expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts")
})
it("should convert AI SDK tool-call and tool-result blocks to text blocks", () => {
const content = [
{
type: "tool-call",
toolCallId: "tool-1",
toolName: "read_file",
input: { path: "a.ts" },
},
{
type: "tool-result",
toolCallId: "tool-1",
output: [{ type: "text", text: "contents of a.ts" }],
},
] as any
const result = convertToolBlocksToText(content)
expect(Array.isArray(result)).toBe(true)
const resultArray = result as Anthropic.Messages.ContentBlockParam[]
expect(resultArray).toHaveLength(2)
expect((resultArray[0] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]")
expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]")
expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts")
})
})
describe("transformMessagesForCondensing", () => {

View file

@ -10,18 +10,55 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import {
type ToolResultLikeBlock,
type ToolUseLikeBlock,
getToolResultLikeId,
getToolResultLikePayload,
getToolUseLikeId,
getToolUseLikeName,
isToolResultLikeBlock,
isToolUseLikeBlock,
stringifyUnknown,
} from "../task/toolBlockFormat"
import { generateFoldedFileContext } from "./foldedFileContext"
export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext"
function toolResultPayloadToText(payload: unknown): string {
if (typeof payload === "string") {
return payload
}
if (Array.isArray(payload)) {
return payload
.map((contentBlock: unknown) => {
if (!contentBlock || typeof contentBlock !== "object") {
return stringifyUnknown(contentBlock)
}
const block = contentBlock as { type?: string; text?: string; value?: unknown }
if (block.type === "text") {
return typeof block.text === "string" ? block.text : stringifyUnknown(block.value)
}
if (block.type === "image") {
return "[Image]"
}
return `[${block.type ?? "unknown"}]`
})
.join("\n")
}
return stringifyUnknown(payload)
}
/**
* Converts a tool_use block to a text representation.
* This allows the conversation to be summarized without requiring the tools parameter.
*/
export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): string {
export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam | ToolUseLikeBlock): string {
const toolName = getToolUseLikeName(block as ToolUseLikeBlock)
const toolInput = (block as ToolUseLikeBlock).input
let input: string
if (typeof block.input === "object" && block.input !== null) {
input = Object.entries(block.input)
if (typeof toolInput === "object" && toolInput !== null) {
input = Object.entries(toolInput)
.map(([key, value]) => {
const formattedValue =
typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value)
@ -29,35 +66,21 @@ export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): stri
})
.join("\n")
} else {
input = String(block.input)
input = String(toolInput)
}
return `[Tool Use: ${block.name}]\n${input}`
return `[Tool Use: ${toolName}]\n${input}`
}
/**
* Converts a tool_result block to a text representation.
* This allows the conversation to be summarized without requiring the tools parameter.
*/
export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam): string {
const errorSuffix = block.is_error ? " (Error)" : ""
if (typeof block.content === "string") {
return `[Tool Result${errorSuffix}]\n${block.content}`
} else if (Array.isArray(block.content)) {
const contentText = block.content
.map((contentBlock) => {
if (contentBlock.type === "text") {
return contentBlock.text
}
if (contentBlock.type === "image") {
return "[Image]"
}
// Handle any other content block types
return `[${(contentBlock as { type: string }).type}]`
})
.join("\n")
return `[Tool Result${errorSuffix}]\n${contentText}`
}
return `[Tool Result${errorSuffix}]`
export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam | ToolResultLikeBlock): string {
const isError = (block as ToolResultLikeBlock).is_error || (block as ToolResultLikeBlock).isError
const errorSuffix = isError ? " (Error)" : ""
const payload = getToolResultLikePayload(block as ToolResultLikeBlock)
const text = toolResultPayloadToText(payload)
return text ? `[Tool Result${errorSuffix}]\n${text}` : `[Tool Result${errorSuffix}]`
}
/**
@ -76,13 +99,13 @@ export function convertToolBlocksToText(
}
return content.map((block) => {
if (block.type === "tool_use") {
if (isToolUseLikeBlock(block)) {
return {
type: "text" as const,
text: toolUseToText(block),
}
}
if (block.type === "tool_result") {
if (isToolResultLikeBlock(block)) {
return {
type: "text" as const,
text: toolResultToText(block),
@ -140,15 +163,21 @@ export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[]
for (const msg of messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use") {
toolCallIds.add(block.id)
if (isToolUseLikeBlock(block)) {
const id = getToolUseLikeId(block)
if (id) {
toolCallIds.add(id)
}
}
}
}
if (msg.role === "user" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_result") {
toolResultIds.add(block.tool_use_id)
if (isToolResultLikeBlock(block)) {
const id = getToolResultLikeId(block)
if (id) {
toolResultIds.add(id)
}
}
}
}
@ -559,8 +588,11 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
for (const msg of messagesFromSummary) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) {
toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id)
if (isToolUseLikeBlock(block)) {
const id = getToolUseLikeId(block)
if (id) {
toolUseIds.add(id)
}
}
}
}
@ -571,8 +603,9 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
.map((msg) => {
if (msg.role === "user" && Array.isArray(msg.content)) {
const filteredContent = msg.content.filter((block) => {
if (block.type === "tool_result") {
return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id)
if (isToolResultLikeBlock(block)) {
const id = getToolResultLikeId(block)
return id ? toolUseIds.has(id) : false
}
return true
})

View file

@ -134,12 +134,25 @@ import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
import { MessageManager } from "../message-manager"
import { validateAndFixToolResultIds } from "./validateToolResultIds"
import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
import {
type ToolResultLikeBlock,
getToolResultLikeId,
getToolResultLikePayload,
getToolUseLikeId,
isToolResultLikeBlock,
isToolUseLikeBlock,
stringifyUnknown,
} from "./toolBlockFormat"
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
function toolResultPayloadToString(block: ToolResultLikeBlock): string {
return stringifyUnknown(getToolResultLikePayload(block))
}
export interface TaskOptions extends CreateTaskOptions {
provider: ClineProvider
apiConfiguration: ProviderSettings
@ -1168,10 +1181,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
messageToAdd = {
...message,
content: message.content.map((block) =>
block.type === "tool_result"
isToolResultLikeBlock(block)
? {
type: "text" as const,
text: `Tool result:\n${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`,
text: `Tool result:\n${toolResultPayloadToString(block)}`,
}
: block,
),
@ -2279,17 +2292,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const content = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text", text: lastMessage.content }]
const hasToolUse = content.some((block) => block.type === "tool_use")
const hasToolUse = content.some(isToolUseLikeBlock)
if (hasToolUse) {
const toolUseBlocks = content.filter(
(block) => block.type === "tool_use",
) as Anthropic.Messages.ToolUseBlock[]
const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({
type: "tool_result",
tool_use_id: block.id,
content: "Task was interrupted before this tool call could be completed.",
}))
const toolResponses: Anthropic.ToolResultBlockParam[] = content
.map((block) => (isToolUseLikeBlock(block) ? getToolUseLikeId(block) : undefined))
.filter((id): id is string => Boolean(id))
.map((toolUseId) => ({
type: "tool_result",
tool_use_id: toolUseId,
content: "Task was interrupted before this tool call could be completed.",
}))
modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes
modifiedOldUserContent = [...toolResponses]
} else {
@ -2308,22 +2321,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
? previousAssistantMessage.content
: [{ type: "text", text: previousAssistantMessage.content }]
const toolUseBlocks = assistantContent.filter(
(block) => block.type === "tool_use",
) as Anthropic.Messages.ToolUseBlock[]
const toolUseIds = assistantContent
.map((block) => (isToolUseLikeBlock(block) ? getToolUseLikeId(block) : undefined))
.filter((id): id is string => Boolean(id))
if (toolUseBlocks.length > 0) {
const existingToolResults = existingUserContent.filter(
(block) => block.type === "tool_result",
) as Anthropic.ToolResultBlockParam[]
if (toolUseIds.length > 0) {
const existingToolResultIds = new Set(
existingUserContent
.map((block) => (isToolResultLikeBlock(block) ? getToolResultLikeId(block) : undefined))
.filter((id): id is string => Boolean(id)),
)
const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks
.filter(
(toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id),
)
.map((toolUse) => ({
const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseIds
.filter((toolUseId) => !existingToolResultIds.has(toolUseId))
.map((toolUseId) => ({
type: "tool_result",
tool_use_id: toolUse.id,
tool_use_id: toolUseId,
content: "Task was interrupted before this tool call could be completed.",
}))

View file

@ -231,6 +231,58 @@ describe("validateAndFixToolResultIds", () => {
})
})
describe("AI SDK tool-call/tool-result format", () => {
it("should keep matching toolCallId unchanged", () => {
const assistantMessage: Anthropic.MessageParam = {
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: {} }] as any,
}
const userMessage: Anthropic.MessageParam = {
role: "user",
content: [{ type: "tool-result", toolCallId: "call-1", output: "ok" }] as any,
}
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
expect(result).toEqual(userMessage)
})
it("should fix mismatched toolCallId by position", () => {
const assistantMessage: Anthropic.MessageParam = {
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call-correct", toolName: "read_file", input: {} }] as any,
}
const userMessage: Anthropic.MessageParam = {
role: "user",
content: [{ type: "tool-result", toolCallId: "call-wrong", output: "ok" }] as any,
}
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
const resultContent = result.content as any[]
expect(resultContent[0].type).toBe("tool-result")
expect(resultContent[0].toolCallId).toBe("call-correct")
})
it("should inject missing tool-result in AI SDK format when assistant used tool-call", () => {
const assistantMessage: Anthropic.MessageParam = {
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call-1", toolName: "read_file", input: {} }] as any,
}
const userMessage: Anthropic.MessageParam = {
role: "user",
content: [{ type: "text", text: "No tool result yet" }],
}
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
const resultContent = result.content as any[]
expect(resultContent[0].type).toBe("tool-result")
expect(resultContent[0].toolCallId).toBe("call-1")
expect(resultContent[0].output).toBe("Tool execution was interrupted before completion.")
})
})
describe("when user message has non-tool_result content", () => {
it("should preserve text blocks alongside tool_result blocks", () => {
const assistantMessage: Anthropic.MessageParam = {

View file

@ -0,0 +1,67 @@
export type ToolUseLikeBlock = {
type: "tool_use" | "tool-call"
id?: string
toolCallId?: string
name?: string
toolName?: string
input?: unknown
}
export type ToolResultLikeBlock = {
type: "tool_result" | "tool-result"
tool_use_id?: string
toolCallId?: string
content?: unknown
output?: unknown
is_error?: boolean
isError?: boolean
}
export function isToolUseLikeBlock(block: unknown): block is ToolUseLikeBlock {
if (!block || typeof block !== "object") {
return false
}
const maybeTool = block as ToolUseLikeBlock
return maybeTool.type === "tool_use" || maybeTool.type === "tool-call"
}
export function getToolUseLikeId(block: ToolUseLikeBlock): string | undefined {
return block.type === "tool_use" ? block.id : block.toolCallId
}
export function getToolUseLikeName(block: ToolUseLikeBlock): string {
return block.type === "tool_use" ? (block.name ?? "unknown_tool") : (block.toolName ?? "unknown_tool")
}
export function isToolResultLikeBlock(block: unknown): block is ToolResultLikeBlock {
if (!block || typeof block !== "object") {
return false
}
const maybeToolResult = block as ToolResultLikeBlock
return maybeToolResult.type === "tool_result" || maybeToolResult.type === "tool-result"
}
export function getToolResultLikeId(block: ToolResultLikeBlock): string | undefined {
return block.type === "tool_result" ? block.tool_use_id : block.toolCallId
}
export function getToolResultLikePayload(block: ToolResultLikeBlock): unknown {
return block.type === "tool_result" ? block.content : block.output
}
export function stringifyUnknown(value: unknown): string {
if (typeof value === "string") {
return value
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value)
}
if (value === null || value === undefined) {
return ""
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}

View file

@ -1,6 +1,9 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { TelemetryService } from "@roo-code/telemetry"
import { findLastIndex } from "../../shared/array"
import { getToolResultLikeId, getToolUseLikeId, isToolResultLikeBlock, isToolUseLikeBlock } from "./toolBlockFormat"
type AnyContentBlock = Anthropic.Messages.ContentBlockParam | (Record<string, unknown> & { type: string })
/**
* Custom error class for tool result ID mismatches.
@ -51,8 +54,10 @@ export function validateAndFixToolResultIds(
userMessage: Anthropic.MessageParam,
apiConversationHistory: Anthropic.MessageParam[],
): Anthropic.MessageParam {
const userContent = userMessage.content as AnyContentBlock[]
// Only process user messages with array content
if (userMessage.role !== "user" || !Array.isArray(userMessage.content)) {
if (userMessage.role !== "user" || !Array.isArray(userContent)) {
return userMessage
}
@ -70,7 +75,7 @@ export function validateAndFixToolResultIds(
return userMessage
}
const toolUseBlocks = assistantContent.filter((block): block is Anthropic.ToolUseBlock => block.type === "tool_use")
const toolUseBlocks = (assistantContent as AnyContentBlock[]).filter(isToolUseLikeBlock)
// No tool_use blocks to match against - no validation needed
if (toolUseBlocks.length === 0) {
@ -78,9 +83,7 @@ export function validateAndFixToolResultIds(
}
// Find tool_result blocks in the user message
let toolResults = userMessage.content.filter(
(block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result",
)
let toolResults = userContent.filter(isToolResultLikeBlock)
// Deduplicate tool_result blocks to prevent API protocol violations (GitHub #10465)
// This serves as a safety net for any potential race conditions that could generate
@ -88,39 +91,49 @@ export function validateAndFixToolResultIds(
// creating duplicate results) has been fixed in presentAssistantMessage.ts, but this
// deduplication remains as a defensive measure for unknown edge cases.
const seenToolResultIds = new Set<string>()
const deduplicatedContent = userMessage.content.filter((block) => {
if (block.type !== "tool_result") {
const deduplicatedContent = userContent.filter((block) => {
if (!isToolResultLikeBlock(block)) {
return true
}
if (seenToolResultIds.has(block.tool_use_id)) {
const id = getToolResultLikeId(block)
if (!id) {
return false
}
if (seenToolResultIds.has(id)) {
return false // Duplicate - filter out
}
seenToolResultIds.add(block.tool_use_id)
seenToolResultIds.add(id)
return true
})
userMessage = {
...userMessage,
content: deduplicatedContent,
content: deduplicatedContent as Anthropic.Messages.ContentBlockParam[],
}
toolResults = deduplicatedContent.filter(
(block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result",
)
toolResults = deduplicatedContent.filter(isToolResultLikeBlock)
// Build a set of valid tool_use IDs
const validToolUseIds = new Set(toolUseBlocks.map((block) => block.id))
const validToolUseIds = new Set(
toolUseBlocks.map((block) => getToolUseLikeId(block)).filter((id): id is string => Boolean(id)),
)
// Build a set of existing tool_result IDs
const existingToolResultIds = new Set(toolResults.map((r) => r.tool_use_id))
const existingToolResultIds = new Set(
toolResults.map((result) => getToolResultLikeId(result)).filter((id): id is string => Boolean(id)),
)
// Check for missing tool_results (tool_use IDs that don't have corresponding tool_results)
const missingToolUseIds = toolUseBlocks
.filter((toolUse) => !existingToolResultIds.has(toolUse.id))
.map((toolUse) => toolUse.id)
.map((block) => getToolUseLikeId(block))
.filter((id): id is string => Boolean(id))
.filter((id) => !existingToolResultIds.has(id))
// Check if any tool_result has an invalid ID
const hasInvalidIds = toolResults.some((result) => !validToolUseIds.has(result.tool_use_id))
const hasInvalidIds = toolResults.some((result) => {
const id = getToolResultLikeId(result)
return !id || !validToolUseIds.has(id)
})
// If no missing tool_results and no invalid IDs, no changes needed
if (missingToolUseIds.length === 0 && !hasInvalidIds) {
@ -128,8 +141,12 @@ export function validateAndFixToolResultIds(
}
// We have issues - need to fix them
const toolResultIdList = toolResults.map((r) => r.tool_use_id)
const toolUseIdList = toolUseBlocks.map((b) => b.id)
const toolResultIdList = toolResults
.map((result) => getToolResultLikeId(result))
.filter((id): id is string => Boolean(id))
const toolUseIdList = toolUseBlocks
.map((toolUse) => getToolUseLikeId(toolUse))
.filter((id): id is string => Boolean(id))
// Report missing tool_results to PostHog error tracking
if (missingToolUseIds.length > 0 && TelemetryService.hasInstance()) {
@ -167,34 +184,44 @@ export function validateAndFixToolResultIds(
// Match tool_results to tool_uses by position and fix incorrect IDs
const usedToolUseIds = new Set<string>()
const contentArray = userMessage.content as Anthropic.Messages.ContentBlockParam[]
const contentArray = userMessage.content as AnyContentBlock[]
const correctedContent = contentArray
.map((block: Anthropic.Messages.ContentBlockParam) => {
if (block.type !== "tool_result") {
.map((block: AnyContentBlock) => {
if (!isToolResultLikeBlock(block)) {
return block
}
const currentId = getToolResultLikeId(block)
// If the ID is already valid and not yet used, keep it
if (validToolUseIds.has(block.tool_use_id) && !usedToolUseIds.has(block.tool_use_id)) {
usedToolUseIds.add(block.tool_use_id)
if (currentId && validToolUseIds.has(currentId) && !usedToolUseIds.has(currentId)) {
usedToolUseIds.add(currentId)
return block
}
// Find which tool_result index this block is by comparing references.
// This correctly handles duplicate tool_use_ids - we find the actual block's
// position among all tool_results, not the first block with a matching ID.
const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam)
const toolResultIndex = toolResults.indexOf(block)
// Try to match by position - only fix if there's a corresponding tool_use
if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) {
const correctId = toolUseBlocks[toolResultIndex].id
const correctId = getToolUseLikeId(toolUseBlocks[toolResultIndex])
if (!correctId) {
return null
}
// Only use this ID if it hasn't been used yet
if (!usedToolUseIds.has(correctId)) {
usedToolUseIds.add(correctId)
if (block.type === "tool_result") {
return {
...block,
tool_use_id: correctId,
}
}
return {
...block,
tool_use_id: correctId,
toolCallId: correctId,
}
}
}
@ -207,21 +234,33 @@ export function validateAndFixToolResultIds(
// Add missing tool_result blocks for any tool_use that doesn't have one
const coveredToolUseIds = new Set(
correctedContent
.filter(
(b: Anthropic.Messages.ContentBlockParam): b is Anthropic.ToolResultBlockParam =>
b.type === "tool_result",
)
.map((r: Anthropic.ToolResultBlockParam) => r.tool_use_id),
.filter(isToolResultLikeBlock)
.map((result) => getToolResultLikeId(result))
.filter((id): id is string => Boolean(id)),
)
const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.id))
const stillMissingToolUseIds = toolUseBlocks
.map((toolUse) => getToolUseLikeId(toolUse))
.filter((id): id is string => Boolean(id))
.filter((id) => !coveredToolUseIds.has(id))
// Build final content: add missing tool_results at the beginning if any
const missingToolResults: Anthropic.ToolResultBlockParam[] = stillMissingToolUseIds.map((toolUse) => ({
type: "tool_result" as const,
tool_use_id: toolUse.id,
content: "Tool execution was interrupted before completion.",
}))
const prefersAiSdkToolResultFormat =
toolResults.some((result) => result.type === "tool-result") ||
toolUseBlocks.some((toolUse) => toolUse.type === "tool-call")
const missingToolResults = stillMissingToolUseIds.map((toolUseId) =>
prefersAiSdkToolResultFormat
? ({
type: "tool-result" as const,
toolCallId: toolUseId,
output: "Tool execution was interrupted before completion.",
} satisfies AnyContentBlock)
: ({
type: "tool_result" as const,
tool_use_id: toolUseId,
content: "Tool execution was interrupted before completion.",
} satisfies AnyContentBlock),
)
// Insert missing tool_results at the beginning of the content array
// This ensures they come before any text blocks that may summarize the results
@ -229,6 +268,6 @@ export function validateAndFixToolResultIds(
return {
...userMessage,
content: finalContent,
content: finalContent as Anthropic.Messages.ContentBlockParam[],
}
}