Skip to content

Commit 741b268

Browse files
authored
fix: prevent duplicate tool_result blocks causing API errors (#10497)
1 parent 503f402 commit 741b268

6 files changed

Lines changed: 276 additions & 16 deletions

File tree

src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
7777
say: vi.fn().mockResolvedValue(undefined),
7878
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
7979
}
80+
81+
// Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
82+
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
83+
const existingResult = mockTask.userMessageContent.find(
84+
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
85+
)
86+
if (existingResult) {
87+
return false
88+
}
89+
mockTask.userMessageContent.push(toolResult)
90+
return true
91+
})
8092
})
8193

8294
describe("Custom tool usage recording", () => {

src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
6060
say: vi.fn().mockResolvedValue(undefined),
6161
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
6262
}
63+
64+
// Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
65+
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
66+
const existingResult = mockTask.userMessageContent.find(
67+
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
68+
)
69+
if (existingResult) {
70+
return false
71+
}
72+
mockTask.userMessageContent.push(toolResult)
73+
return true
74+
})
6375
})
6476

6577
it("should preserve images in tool_result for native protocol", async () => {

src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
5959
say: vi.fn().mockResolvedValue(undefined),
6060
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
6161
}
62+
63+
// Add pushToolResultToUserContent method after mockTask is created so 'this' binds correctly
64+
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
65+
const existingResult = mockTask.userMessageContent.find(
66+
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
67+
)
68+
if (existingResult) {
69+
return false
70+
}
71+
mockTask.userMessageContent.push(toolResult)
72+
return true
73+
})
6274
})
6375

6476
it("should return error for unknown tool in native protocol", async () => {

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,12 @@ export async function presentAssistantMessage(cline: Task) {
115115
: `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.`
116116

117117
if (toolCallId) {
118-
cline.userMessageContent.push({
118+
cline.pushToolResultToUserContent({
119119
type: "tool_result",
120120
tool_use_id: toolCallId,
121121
content: errorMessage,
122122
is_error: true,
123-
} as Anthropic.ToolResultBlockParam)
123+
})
124124
}
125125
break
126126
}
@@ -130,12 +130,12 @@ export async function presentAssistantMessage(cline: Task) {
130130
const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.`
131131

132132
if (toolCallId) {
133-
cline.userMessageContent.push({
133+
cline.pushToolResultToUserContent({
134134
type: "tool_result",
135135
tool_use_id: toolCallId,
136136
content: errorMessage,
137137
is_error: true,
138-
} as Anthropic.ToolResultBlockParam)
138+
})
139139
}
140140
break
141141
}
@@ -167,11 +167,11 @@ export async function presentAssistantMessage(cline: Task) {
167167
}
168168

169169
if (toolCallId) {
170-
cline.userMessageContent.push({
170+
cline.pushToolResultToUserContent({
171171
type: "tool_result",
172172
tool_use_id: toolCallId,
173173
content: resultContent,
174-
} as Anthropic.ToolResultBlockParam)
174+
})
175175

176176
if (imageBlocks.length > 0) {
177177
cline.userMessageContent.push(...imageBlocks)
@@ -446,12 +446,12 @@ export async function presentAssistantMessage(cline: Task) {
446446

447447
if (toolCallId) {
448448
// Native protocol: MUST send tool_result for every tool_use
449-
cline.userMessageContent.push({
449+
cline.pushToolResultToUserContent({
450450
type: "tool_result",
451451
tool_use_id: toolCallId,
452452
content: errorMessage,
453453
is_error: true,
454-
} as Anthropic.ToolResultBlockParam)
454+
})
455455
} else {
456456
// XML protocol: send as text
457457
cline.userMessageContent.push({
@@ -471,12 +471,12 @@ export async function presentAssistantMessage(cline: Task) {
471471

472472
if (toolCallId) {
473473
// Native protocol: MUST send tool_result for every tool_use
474-
cline.userMessageContent.push({
474+
cline.pushToolResultToUserContent({
475475
type: "tool_result",
476476
tool_use_id: toolCallId,
477477
content: errorMessage,
478478
is_error: true,
479-
} as Anthropic.ToolResultBlockParam)
479+
})
480480
} else {
481481
// XML protocol: send as text
482482
cline.userMessageContent.push({
@@ -530,11 +530,11 @@ export async function presentAssistantMessage(cline: Task) {
530530
}
531531

532532
// Add tool_result with text content only
533-
cline.userMessageContent.push({
533+
cline.pushToolResultToUserContent({
534534
type: "tool_result",
535535
tool_use_id: toolCallId,
536536
content: resultContent,
537-
} as Anthropic.ToolResultBlockParam)
537+
})
538538

539539
// Add image blocks separately after tool_result
540540
if (imageBlocks.length > 0) {
@@ -735,12 +735,12 @@ export async function presentAssistantMessage(cline: Task) {
735735

736736
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
737737
// For native protocol, push tool_result directly without setting didAlreadyUseTool
738-
cline.userMessageContent.push({
738+
cline.pushToolResultToUserContent({
739739
type: "tool_result",
740740
tool_use_id: toolCallId,
741741
content: typeof errorContent === "string" ? errorContent : "(validation error)",
742742
is_error: true,
743-
} as Anthropic.ToolResultBlockParam)
743+
})
744744
} else {
745745
// For XML protocol, use the standard pushToolResult
746746
pushToolResult(errorContent)
@@ -1110,12 +1110,12 @@ export async function presentAssistantMessage(cline: Task) {
11101110
// Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
11111111
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
11121112
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
1113-
cline.userMessageContent.push({
1113+
cline.pushToolResultToUserContent({
11141114
type: "tool_result",
11151115
tool_use_id: toolCallId,
11161116
content: formatResponse.toolError(errorMessage, toolProtocol),
11171117
is_error: true,
1118-
} as Anthropic.ToolResultBlockParam)
1118+
})
11191119
} else {
11201120
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
11211121
}

src/core/task/Task.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,28 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
337337
presentAssistantMessageHasPendingUpdates = false
338338
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = []
339339
userMessageContentReady = false
340+
341+
/**
342+
* Push a tool_result block to userMessageContent, preventing duplicates.
343+
* This is critical for native tool protocol where duplicate tool_use_ids cause API errors.
344+
*
345+
* @param toolResult - The tool_result block to add
346+
* @returns true if added, false if duplicate was skipped
347+
*/
348+
public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean {
349+
const existingResult = this.userMessageContent.find(
350+
(block): block is Anthropic.ToolResultBlockParam =>
351+
block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
352+
)
353+
if (existingResult) {
354+
console.warn(
355+
`[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`,
356+
)
357+
return false
358+
}
359+
this.userMessageContent.push(toolResult)
360+
return true
361+
}
340362
didRejectTool = false
341363
didAlreadyUseTool = false
342364
didToolFailInCurrentTurn = false

0 commit comments

Comments
 (0)