From 58c1a5d4e0777d69e604d2180a2d22bb416500be Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 4 Dec 2025 13:21:14 -0500 Subject: [PATCH 1/2] fix: preserve tool_use_id when editing tool_result messages When editing a user_feedback message that corresponds to a tool_result in the API conversation history, we now edit the message in place rather than creating a new one. This preserves the tool_use_id, preventing the mismatch between tool_call and tool_result that was causing errors with native tool protocols. Changes: - Add resumeAfterMessageEdit() method to Task.ts that properly resets state and resumes the task loop without creating a new user message - Update webviewMessageHandler.ts to detect tool_result messages and edit them in place, preserving the tool_use_id while updating the content - Handle the case where timestamps don't match between clineMessages and apiConversationHistory by using position-based matching --- src/core/task/Task.ts | 91 +++++++++++++++ src/core/webview/webviewMessageHandler.ts | 136 +++++++++++++++++++++- 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3043b3c9462..f8c081623ef 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1989,6 +1989,97 @@ export class Task extends EventEmitter implements TaskLike { return child } + /** + * Resume task after an in-place message edit without creating a new user message. + * Used when editing tool_result messages to preserve tool_use_id. + * + * This method: + * - Clears any pending ask states and promises + * - Resets abort, streaming, and tool execution flags + * - Ensures next API call includes full context + * - Immediately continues task loop using existing history + */ + public async resumeAfterMessageEdit(): Promise { + // Clear any ask states and pending ask promises + this.idleAsk = undefined + this.resumableAsk = undefined + this.interactiveAsk = undefined + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + + // Reset abort and streaming state + this.abort = false + this.abandoned = false + this.abortReason = undefined + this.didFinishAbortingStream = false + this.isStreaming = false + this.isWaitingForFirstChunk = false + + // Reset tool execution flags - critical for preventing: + // - "Response interrupted by a tool use result" injected message + // - Tool state from previous execution affecting the new request + this.didRejectTool = false + this.didAlreadyUseTool = false + this.didToolFailInCurrentTurn = false + this.didCompleteReadingStream = false + this.userMessageContentReady = false + this.presentAssistantMessageLocked = false + this.presentAssistantMessageHasPendingUpdates = false + + // Clear any pending user message content (we're resuming, not adding new) + this.userMessageContent = [] + this.assistantMessageContent = [] + + // Ensure next API call includes full context + this.skipPrevResponseIdOnce = true + + // Reset consecutive mistake count for fresh start + this.consecutiveMistakeCount = 0 + + // Reset lastMessageTs to prevent any pending asks from throwing "ignored" errors + // when the first say() in the new task loop updates the timestamp + this.lastMessageTs = undefined + + // Mark as active + this.emit(RooCodeEventName.TaskActive, this.taskId) + + // Add fresh environment details to the last user message + const environmentDetails = await getEnvironmentDetails(this, true) + let lastUserMsgIndex = -1 + for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) { + if (this.apiConversationHistory[i].role === "user") { + lastUserMsgIndex = i + break + } + } + if (lastUserMsgIndex >= 0) { + const lastUserMsg = this.apiConversationHistory[lastUserMsgIndex] + if (Array.isArray(lastUserMsg.content)) { + // Remove any existing environment_details blocks before adding fresh ones + const contentWithoutEnvDetails = lastUserMsg.content.filter( + (block: Anthropic.Messages.ContentBlockParam) => { + if (block.type === "text" && typeof block.text === "string") { + const isEnvironmentDetailsBlock = + block.text.trim().startsWith("") && + block.text.trim().endsWith("") + return !isEnvironmentDetailsBlock + } + return true + }, + ) + // Add fresh environment details + lastUserMsg.content = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] + } + } + + // Save the updated history + await this.saveApiConversationHistory() + + // Continue task loop with empty array - signals no new user content needed + await this.initiateTaskLoop([]) + } + /** * Resume parent task after delegation completion without showing resume ask. * Used in metadata-driven subtask flow. diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c1c8e6aa204..4222c62ad99 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -407,7 +407,141 @@ export const webviewMessageHandler = async ( } } - // For non-checkpoint edits, remove the ORIGINAL user message being edited and all subsequent messages + // Check if this is a tool_result message in the API history + // A tool_result message is a user message containing tool_result blocks + // We need to edit it in place to preserve the tool_use_id + // + // IMPORTANT: clineMessages and apiConversationHistory have DIFFERENT timestamps + // for the same logical message. We can't rely on timestamp matching. + // Instead, if the clineMessage is user_feedback (responding to a tool like attempt_completion), + // we find the corresponding API message by looking for user messages with tool_result blocks. + let apiMessageIndex = apiConversationHistoryIndex + let apiMessage: ApiMessage | undefined = + apiConversationHistoryIndex !== -1 + ? currentCline.apiConversationHistory[apiConversationHistoryIndex] + : undefined + + // If timestamp lookup failed, but target is user_feedback, search for tool_result by position + if (apiMessageIndex === -1 && targetMessage.say === "user_feedback") { + // Find position of this user_feedback in the clineMessages sequence + // Count how many user_feedback messages come before this one + let userFeedbackPosition = 0 + for (let i = 0; i < messageIndex; i++) { + if (currentCline.clineMessages[i].say === "user_feedback") { + userFeedbackPosition++ + } + } + + // Find the corresponding tool_result user message in API history + // Count tool_result user messages until we reach the same position + let toolResultCount = 0 + for (let i = 0; i < currentCline.apiConversationHistory.length; i++) { + const msg = currentCline.apiConversationHistory[i] + if ( + msg.role === "user" && + Array.isArray(msg.content) && + msg.content.some((block: any) => block.type === "tool_result") + ) { + if (toolResultCount === userFeedbackPosition) { + apiMessageIndex = i + apiMessage = msg + break + } + toolResultCount++ + } + } + } + + if (apiMessage && Array.isArray(apiMessage.content)) { + } + + const isToolResultMessage = + apiMessage?.role === "user" && + Array.isArray(apiMessage.content) && + apiMessage.content.some((block: any) => block.type === "tool_result") + + if (isToolResultMessage && apiMessageIndex !== -1 && apiMessage) { + // IN-PLACE EDIT: Preserve tool_use_id by editing content directly + // This is critical for native tool protocol - creating a new message would pick up the wrong tool_use_id + const apiMessageContent = apiMessage.content as any[] + + // Update tool_result content while preserving tool_use_id + for (const block of apiMessageContent) { + if (block.type === "tool_result") { + // Update the content - preserve images if they exist + if (images && images.length > 0) { + // Create content array with text and images + const newContent: any[] = [{ type: "text", text: editedContent }] + for (const image of images) { + newContent.push({ + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: image.replace(/^data:image\/\w+;base64,/, ""), + }, + }) + } + block.content = newContent + } else { + block.content = editedContent + } + } + } + + // Also update the corresponding clineMessage + if (targetMessage) { + targetMessage.text = editedContent + targetMessage.images = images + } + + // Delete only messages AFTER the edited one (not including it) + const deleteFromClineIndex = messageIndex + 1 + const deleteFromApiIndex = apiMessageIndex + 1 + + // Store checkpoints from messages that will be preserved (including the edited message) + const preservedCheckpoints = new Map() + for (let i = 0; i <= messageIndex; i++) { + const msg = currentCline.clineMessages[i] + if (msg?.checkpoint && msg.ts) { + preservedCheckpoints.set(msg.ts, msg.checkpoint) + } + } + + // Delete only subsequent messages + if (deleteFromClineIndex < currentCline.clineMessages.length) { + await currentCline.overwriteClineMessages(currentCline.clineMessages.slice(0, deleteFromClineIndex)) + } + if (deleteFromApiIndex < currentCline.apiConversationHistory.length) { + await currentCline.overwriteApiConversationHistory( + currentCline.apiConversationHistory.slice(0, deleteFromApiIndex), + ) + } + + // Restore checkpoint associations + for (const [ts, checkpoint] of preservedCheckpoints) { + const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts) + if (msgIndex !== -1) { + currentCline.clineMessages[msgIndex].checkpoint = checkpoint + } + } + + // Save changes + await saveTaskMessages({ + messages: currentCline.clineMessages, + taskId: currentCline.taskId, + globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, + }) + + // Update UI + await provider.postStateToWebview() + + // Resume the task loop - the edited message is already in history with preserved tool_use_id + await currentCline.resumeAfterMessageEdit() + return + } + + // Remove the ORIGINAL user message being edited and all subsequent messages // Determine the correct starting index to delete from (prefer the last preceding user_feedback message) let deleteFromMessageIndex = messageIndex let deleteFromApiIndex = apiConversationHistoryIndex From fb3c2be954f099163eefd5ea6e2089f97419709b Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 4 Dec 2025 13:38:25 -0500 Subject: [PATCH 2/2] fix: address PR review feedback - Remove empty debug if-block (leftover code) - Only update first tool_result block to avoid overwriting multiple blocks with identical content - Add cleanup of orphaned condenseParent/truncationParent references using cleanupAfterTruncation --- src/core/webview/webviewMessageHandler.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4222c62ad99..47b4ca967e5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -452,9 +452,6 @@ export const webviewMessageHandler = async ( } } - if (apiMessage && Array.isArray(apiMessage.content)) { - } - const isToolResultMessage = apiMessage?.role === "user" && Array.isArray(apiMessage.content) && @@ -465,7 +462,9 @@ export const webviewMessageHandler = async ( // This is critical for native tool protocol - creating a new message would pick up the wrong tool_use_id const apiMessageContent = apiMessage.content as any[] - // Update tool_result content while preserving tool_use_id + // Update only the first tool_result content while preserving tool_use_id + // Important: If multiple tool_result blocks exist, we only update the first one + // to avoid overwriting unrelated tool results with the same content for (const block of apiMessageContent) { if (block.type === "tool_result") { // Update the content - preserve images if they exist @@ -486,6 +485,8 @@ export const webviewMessageHandler = async ( } else { block.content = editedContent } + // Only update the first tool_result block, then exit the loop + break } } @@ -508,14 +509,18 @@ export const webviewMessageHandler = async ( } } - // Delete only subsequent messages + // Delete only subsequent messages and clean up orphaned references + // This mirrors the cleanup logic in removeMessagesThisAndSubsequent if (deleteFromClineIndex < currentCline.clineMessages.length) { await currentCline.overwriteClineMessages(currentCline.clineMessages.slice(0, deleteFromClineIndex)) } if (deleteFromApiIndex < currentCline.apiConversationHistory.length) { - await currentCline.overwriteApiConversationHistory( - currentCline.apiConversationHistory.slice(0, deleteFromApiIndex), - ) + // Truncate API history and clean up orphaned condenseParent/truncationParent references + // This ensures messages that reference now-deleted summaries or truncation markers + // don't have dangling references + const truncatedApiHistory = currentCline.apiConversationHistory.slice(0, deleteFromApiIndex) + const cleanedApiHistory = cleanupAfterTruncation(truncatedApiHistory) + await currentCline.overwriteApiConversationHistory(cleanedApiHistory) } // Restore checkpoint associations