Skip to content

Commit e4e67e2

Browse files
committed
fix: merge approval feedback into tool result instead of pushing duplicate (ROO-410)
When users approve tool execution with feedback text, the feedback was being pushed as a separate tool_result before the actual tool executed and pushed its own result. This created duplicate tool_results with the same tool_use_id, violating the Anthropic API protocol (GitHub #10465). Changes: - Add approvalFeedback storage to hold feedback until tool completes - Modify pushToolResult to merge stored feedback into the tool's actual result - Update askApproval to store feedback instead of pushing as separate result - Apply fix to both MCP tools and regular tools, native and XML protocols - Update validateToolResultIds comments to clarify deduplication is now safety net
1 parent 43f7ce0 commit e4e67e2

File tree

2 files changed

+77
-12
lines changed

2 files changed

+77
-12
lines changed

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,10 @@ export async function presentAssistantMessage(cline: Task) {
145145
const toolCallId = mcpBlock.id
146146
const toolProtocol = TOOL_PROTOCOL.NATIVE // MCP tools in native mode always use native protocol
147147

148-
const pushToolResult = (content: ToolResponse) => {
148+
// Store approval feedback to merge into tool result (GitHub #10465)
149+
let approvalFeedback: { text: string; images?: string[] } | undefined
150+
151+
const pushToolResult = (content: ToolResponse, feedbackImages?: string[]) => {
149152
if (hasToolResult) {
150153
console.warn(
151154
`[presentAssistantMessage] Skipping duplicate tool_result for mcp_tool_use: ${toolCallId}`,
@@ -166,6 +169,18 @@ export async function presentAssistantMessage(cline: Task) {
166169
"(tool did not return anything)"
167170
}
168171

172+
// Merge approval feedback into tool result (GitHub #10465)
173+
if (approvalFeedback) {
174+
const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text, toolProtocol)
175+
resultContent = `${feedbackText}\n\n${resultContent}`
176+
177+
// Add feedback images to the image blocks
178+
if (approvalFeedback.images) {
179+
const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images)
180+
imageBlocks = [...feedbackImageBlocks, ...imageBlocks]
181+
}
182+
}
183+
169184
if (toolCallId) {
170185
cline.pushToolResultToUserContent({
171186
type: "tool_result",
@@ -214,11 +229,12 @@ export async function presentAssistantMessage(cline: Task) {
214229
return false
215230
}
216231

232+
// Store approval feedback to be merged into tool result (GitHub #10465)
233+
// Don't push it as a separate tool_result here - that would create duplicates.
234+
// The tool will call pushToolResult, which will merge the feedback into the actual result.
217235
if (text) {
218236
await cline.say("user_feedback", text, images)
219-
pushToolResult(
220-
formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text, toolProtocol), images),
221-
)
237+
approvalFeedback = { text, images }
222238
}
223239

224240
return true
@@ -501,6 +517,9 @@ export async function presentAssistantMessage(cline: Task) {
501517
// Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS)
502518
const isMultipleNativeToolCallsEnabled = false
503519

520+
// Store approval feedback to merge into tool result (GitHub #10465)
521+
let approvalFeedback: { text: string; images?: string[] } | undefined
522+
504523
const pushToolResult = (content: ToolResponse) => {
505524
if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
506525
// For native protocol, only allow ONE tool_result per tool call
@@ -529,6 +548,21 @@ export async function presentAssistantMessage(cline: Task) {
529548
"(tool did not return anything)"
530549
}
531550

551+
// Merge approval feedback into tool result (GitHub #10465)
552+
if (approvalFeedback) {
553+
const feedbackText = formatResponse.toolApprovedWithFeedback(
554+
approvalFeedback.text,
555+
toolProtocol,
556+
)
557+
resultContent = `${feedbackText}\n\n${resultContent}`
558+
559+
// Add feedback images to the image blocks
560+
if (approvalFeedback.images) {
561+
const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images)
562+
imageBlocks = [...feedbackImageBlocks, ...imageBlocks]
563+
}
564+
}
565+
532566
// Add tool_result with text content only
533567
cline.pushToolResultToUserContent({
534568
type: "tool_result",
@@ -544,15 +578,44 @@ export async function presentAssistantMessage(cline: Task) {
544578
hasToolResult = true
545579
} else {
546580
// For XML protocol, add as text blocks (legacy behavior)
581+
let resultContent: string
582+
583+
if (typeof content === "string") {
584+
resultContent = content || "(tool did not return anything)"
585+
} else {
586+
const textBlocks = content.filter((item) => item.type === "text")
587+
resultContent =
588+
textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
589+
"(tool did not return anything)"
590+
}
591+
592+
// Merge approval feedback into tool result (GitHub #10465)
593+
if (approvalFeedback) {
594+
const feedbackText = formatResponse.toolApprovedWithFeedback(
595+
approvalFeedback.text,
596+
toolProtocol,
597+
)
598+
resultContent = `${feedbackText}\n\n${resultContent}`
599+
}
600+
547601
cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
548602

549603
if (typeof content === "string") {
550604
cline.userMessageContent.push({
551605
type: "text",
552-
text: content || "(tool did not return anything)",
606+
text: resultContent,
553607
})
554608
} else {
555-
cline.userMessageContent.push(...content)
609+
// Add text content with merged feedback
610+
cline.userMessageContent.push({
611+
type: "text",
612+
text: resultContent,
613+
})
614+
// Add any images from the tool result
615+
const imageBlocks = content.filter((item) => item.type === "image")
616+
if (imageBlocks.length > 0) {
617+
cline.userMessageContent.push(...imageBlocks)
618+
}
556619
}
557620
}
558621

@@ -603,12 +666,12 @@ export async function presentAssistantMessage(cline: Task) {
603666
return false
604667
}
605668

606-
// Handle yesButtonClicked with text.
669+
// Store approval feedback to be merged into tool result (GitHub #10465)
670+
// Don't push it as a separate tool_result here - that would create duplicates.
671+
// The tool will call pushToolResult, which will merge the feedback into the actual result.
607672
if (text) {
608673
await cline.say("user_feedback", text, images)
609-
pushToolResult(
610-
formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text, toolProtocol), images),
611-
)
674+
approvalFeedback = { text, images }
612675
}
613676

614677
return true

src/core/task/validateToolResultIds.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ export function validateAndFixToolResultIds(
8383
)
8484

8585
// Deduplicate tool_result blocks to prevent API protocol violations (GitHub #10465)
86-
// Terminal fallback race conditions can generate duplicate tool_results with the same tool_use_id.
87-
// Filter out duplicates before validation since Set-based checks below would miss them.
86+
// This serves as a safety net for any potential race conditions that could generate
87+
// duplicate tool_results with the same tool_use_id. The root cause (approval feedback
88+
// creating duplicate results) has been fixed in presentAssistantMessage.ts, but this
89+
// deduplication remains as a defensive measure for unknown edge cases.
8890
const seenToolResultIds = new Set<string>()
8991
const deduplicatedContent = userMessage.content.filter((block) => {
9092
if (block.type !== "tool_result") {

0 commit comments

Comments
 (0)