From 993cf7a6539aff66d485645ba43d26c919538fde Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Sun, 20 Sep 2026 10:51:22 +0900 Subject: [PATCH 01/51] fix(tools): process queued messages when terminal command finishes --- src/core/tools/ExecuteCommandTool.ts | 2 + .../__tests__/executeCommandTool.spec.ts | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 8383d9a4e1..9ff31a5adc 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -203,6 +203,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } pushToolResult(result) + task.processQueuedMessages() } catch (error: unknown) { // Invalidate pending ask from first execution to prevent race condition task.supersedePendingAsk() @@ -222,6 +223,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } pushToolResult(result) + task.processQueuedMessages() } else { // Command was submitted but shell integration lost track of it — show warning. await task.say("shell_integration_warning") diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index a856b180ca..e56adb443e 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -88,6 +88,7 @@ describe("executeCommandTool", () => { recordToolUsage: vitest.fn().mockReturnValue({} as ToolUsage), recordToolError: vitest.fn(), supersedePendingAsk: vitest.fn(), + processQueuedMessages: vitest.fn(), providerRef: { deref: vitest.fn().mockResolvedValue({ contextProxy: { @@ -497,6 +498,74 @@ describe("executeCommandTool", () => { }) }) + describe("Queued message processing", () => { + it("processes queued messages after the command completes", async () => { + mockToolUse.params.command = "echo test" + mockToolUse.nativeArgs = { command: "echo test" } + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalled() + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + }) + + it("processes queued messages after the execa fallback retry completes", async () => { + const shellError = new executeCommandModule.ShellIntegrationError("startup failed", false) + const failedProcess = Object.assign(Promise.reject(shellError), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + const successfulProcess = Object.assign(Promise.resolve(), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + const successfulTerminalProcess = successfulProcess as unknown as RooTerminalProcess + + vitest + .mocked(TerminalRegistry.getOrCreateTerminal) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockReturnValue(failedProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockImplementation((_command: string, callbacks: RooTerminalCallbacks) => { + void callbacks.onCompleted?.("", successfulTerminalProcess) + callbacks.onShellExecutionComplete?.({ exitCode: 0 }, successfulTerminalProcess) + return successfulProcess + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalled() + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + }) + + it("does not process queued messages when the user rejects the command", async () => { + mockAskApproval.mockResolvedValue(false) + mockToolUse.params.command = "echo test" + mockToolUse.nativeArgs = { command: "echo test" } + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + }) + }) + describe("Command execution timeout configuration", () => { it("should include timeout parameter in ExecuteCommandOptions", () => { // This test verifies that the timeout configuration is properly typed From 2d080018fc0fd3f5512ea611ec21eeda38d5b0a0 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Sun, 20 Sep 2026 17:32:07 +0900 Subject: [PATCH 02/51] fix(tools): gate queued-message drain on actual command submission --- src/core/tools/ExecuteCommandTool.ts | 27 ++++++++++++----- .../__tests__/executeCommandTool.spec.ts | 30 ++++++++++++++++++- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 9ff31a5adc..18a2a5e250 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -196,14 +196,19 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } try { - const [rejected, result] = await executeCommandInTerminal(task, options) + const [rejected, result, commandSubmitted] = await executeCommandInTerminal(task, options) if (rejected) { task.didRejectTool = true } pushToolResult(result) - task.processQueuedMessages() + // Only drain queued messages when the command actually ran + // (early validation failures end the turn without an execution, + // matching file tools' error-path behavior). + if (commandSubmitted) { + task.processQueuedMessages() + } } catch (error: unknown) { // Invalidate pending ask from first execution to prevent race condition task.supersedePendingAsk() @@ -213,7 +218,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { const status: CommandExecutionStatus = { executionId, status: "fallback" } postCommandExecutionStatus(provider, status) - const [rejected, result] = await executeCommandInTerminal(task, { + const [rejected, result, commandSubmitted] = await executeCommandInTerminal(task, { ...options, terminalShellIntegrationDisabled: true, }) @@ -223,7 +228,9 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } pushToolResult(result) - task.processQueuedMessages() + if (commandSubmitted) { + task.processQueuedMessages() + } } else { // Command was submitted but shell integration lost track of it — show warning. await task.say("shell_integration_warning") @@ -270,7 +277,7 @@ export async function executeCommandInTerminal( commandExecutionTimeout = 0, agentTimeout = 0, }: ExecuteCommandOptions, -): Promise<[boolean, ToolResponse]> { +): Promise<[boolean, ToolResponse, boolean]> { // Convert milliseconds back to seconds for display purposes. const commandExecutionTimeoutSeconds = commandExecutionTimeout / 1000 let workingDir: string @@ -286,7 +293,8 @@ export async function executeCommandInTerminal( try { await fs.access(workingDir) } catch (error) { - return [false, `Working directory '${workingDir}' does not exist.`] + // The command never ran (working directory validation failed). + return [false, `Working directory '${workingDir}' does not exist.`, false] } let runInBackground = false @@ -523,6 +531,8 @@ export async function executeCommandInTerminal( return [ false, `The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`, + + true, ] } throw error @@ -558,7 +568,7 @@ export async function executeCommandInTerminal( // Use persisted output format when output was truncated and spilled to disk if (persistedResult?.truncated) { - return [false, formatPersistedOutput(persistedResult, exitDetails, currentWorkingDir)] + return [false, formatPersistedOutput(persistedResult, exitDetails, currentWorkingDir), true] } // Use inline format for small outputs (original behavior with exit status). @@ -573,6 +583,7 @@ export async function executeCommandInTerminal( return [ false, `Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`, + true, ] } else { return [ @@ -582,6 +593,8 @@ export async function executeCommandInTerminal( result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n", "You will be updated on the terminal status and new output in the future.", ].join("\n"), + + true, ] } } diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index e56adb443e..05f74df972 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -2,6 +2,7 @@ import type { ToolUsage } from "@roo-code/types" import * as vscode from "vscode" +import fs from "fs/promises" import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" @@ -73,7 +74,9 @@ describe("executeCommandTool", () => { vitest.useRealTimers() // Spy on executeCommandInTerminal and mock its return value - vitest.spyOn(executeCommandModule, "executeCommandInTerminal").mockResolvedValue([false, "Command executed"]) + vitest + .spyOn(executeCommandModule, "executeCommandInTerminal") + .mockResolvedValue([false, "Command executed", true]) // Create mock implementations with eslint directives to handle the type issues mockCline = { @@ -511,6 +514,10 @@ describe("executeCommandTool", () => { expect(mockPushToolResult).toHaveBeenCalled() expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + // The tool result must be published before queued messages are processed. + expect(mockPushToolResult.mock.invocationCallOrder[0]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[0], + ) }) it("processes queued messages after the execa fallback retry completes", async () => { @@ -548,6 +555,9 @@ describe("executeCommandTool", () => { expect(mockPushToolResult).toHaveBeenCalled() expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + expect(mockPushToolResult.mock.invocationCallOrder[0]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[0], + ) }) it("does not process queued messages when the user rejects the command", async () => { @@ -564,6 +574,24 @@ describe("executeCommandTool", () => { expect(mockPushToolResult).not.toHaveBeenCalled() expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() }) + + it("does not process queued messages when the working directory does not exist", async () => { + mockToolUse.params.command = "echo test" + mockToolUse.params.cwd = "/nonexistent/working/dir" + mockToolUse.nativeArgs = { command: "echo test", cwd: "/nonexistent/working/dir" } + vitest.mocked(fs.access).mockRejectedValueOnce(new Error("ENOENT")) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Working directory '/nonexistent/working/dir' does not exist.", + ) + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + }) }) describe("Command execution timeout configuration", () => { From 59d20fcd9683a357a9147dd7e509bb5648cb4d31 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Sun, 20 Sep 2026 18:18:22 +0900 Subject: [PATCH 03/51] test(tools): cover execa retry working-directory validation gate --- .../__tests__/executeCommandTool.spec.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 05f74df972..dc7f8592d3 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -592,6 +592,35 @@ describe("executeCommandTool", () => { ) expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() }) + + it("does not drain when the execa fallback retry hits a working directory failure", async () => { + mockToolUse.params.command = "echo test" + mockToolUse.params.cwd = "/nonexistent/working/dir" + mockToolUse.nativeArgs = { command: "echo test", cwd: "/nonexistent/working/dir" } + // First attempt passes validation but fails shell integration startup + // (retryable); the retry then fails the working directory check. + vitest.mocked(fs.access).mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("ENOENT")) + const shellError = new executeCommandModule.ShellIntegrationError("startup failed", false) + const failedProcess = Object.assign(Promise.reject(shellError), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValueOnce({ + runCommand: vitest.fn().mockReturnValue(failedProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Working directory '/nonexistent/working/dir' does not exist.", + ) + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + }) }) describe("Command execution timeout configuration", () => { From 4765b9215cbeb27c10a46ba6cc69fd306ab19663 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Wed, 23 Sep 2026 06:41:50 +0900 Subject: [PATCH 04/51] fix(task): make queued-message drain awaitable and failure-aware --- packages/types/src/task.ts | 2 +- src/core/task/Task.ts | 54 ++++++++++++++++++---------- src/core/tools/ApplyDiffTool.ts | 12 +++++-- src/core/tools/ApplyPatchTool.ts | 12 +++++-- src/core/tools/EditFileTool.ts | 4 ++- src/core/tools/EditTool.ts | 4 ++- src/core/tools/ExecuteCommandTool.ts | 8 +++-- src/core/tools/SearchReplaceTool.ts | 4 ++- src/core/tools/WriteToFileTool.ts | 4 ++- 9 files changed, 71 insertions(+), 33 deletions(-) diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index cac34cc6f2..57894d3e7c 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -127,7 +127,7 @@ export interface TaskLike { approveAsk(options?: { text?: string; images?: string[] }): void denyAsk(options?: { text?: string; images?: string[] }): void - submitUserMessage(text: string, images?: string[], mode?: string, providerProfile?: string): Promise + submitUserMessage(text: string, images?: string[], mode?: string, providerProfile?: string): Promise abortTask(): Promise } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 55798437c3..9cda31fb2c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1800,18 +1800,25 @@ export class Task extends EventEmitter implements TaskLike { this.api = buildApiHandler(this.apiConfiguration) } + /** + * Submit a user message through the ask-response channel. + * + * @returns true when the message was handed to the ask-response channel; + * false when there was nothing to submit or the handoff failed (the failure + * is logged either way). Callers draining a durable queue must check this. + */ public async submitUserMessage( text: string, images?: string[], mode?: string, providerProfile?: string, - ): Promise { + ): Promise { try { text = (text ?? "").trim() images = images ?? [] if (text.length === 0 && images.length === 0) { - return + return false } const provider = this.providerRef.deref() @@ -1840,11 +1847,14 @@ export class Task extends EventEmitter implements TaskLike { // This avoids a race condition where the webview's message state hasn't // hydrated yet, causing it to interpret the message as a new task request. this.handleWebviewAskResponse("messageResponse", text, images) + return true } else { console.error("[Task#submitUserMessage] Provider reference lost") + return false } } catch (error) { console.error("[Task#submitUserMessage] Failed to submit user message:", error) + return false } } @@ -2003,7 +2013,7 @@ export class Task extends EventEmitter implements TaskLike { ) // Process any queued messages after condensing completes - this.processQueuedMessages() + await this.processQueuedMessages() } async say( @@ -5409,26 +5419,32 @@ export class Task extends EventEmitter implements TaskLike { } /** - * Process any queued messages by dequeuing and submitting them. - * This ensures that queued user messages are sent when appropriate, - * preventing them from getting stuck in the queue. + * Process the next queued message by claiming and submitting it. * - * @param context - Context string for logging (e.g., the calling tool name) + * The message is claimed — not dequeued — before submission and is only + * removed after the submission handoff succeeds. When submission fails, the + * claim is released so the message stays queued for a later drain, and the + * failure propagates to the caller instead of being logged and dropped. + * + * @returns Promise resolving to true when a queued message was submitted + * and durably removed; false when the queue was empty. */ - public processQueuedMessages(): void { + public async processQueuedMessages(): Promise { + const queued = this.messageQueueService.claimNextMessage() + if (!queued) { + return false + } try { - if (!this.messageQueueService.isEmpty()) { - const queued = this.messageQueueService.dequeueMessage() - if (queued) { - setTimeout(() => { - this.submitUserMessage(queued.text, queued.images).catch((err) => - console.error(`[Task] Failed to submit queued message:`, err), - ) - }, 0) - } + const submitted = await this.submitUserMessage(queued.text, queued.images) + if (!submitted) { + throw new Error(`[Task] Failed to submit queued message ${queued.id}`) } - } catch (e) { - console.error(`[Task] Queue processing error:`, e) + } catch (error) { + // Release the claim so a later drain can retry the message. + this.messageQueueService.releaseMessage(queued.id) + throw error } + this.messageQueueService.removeMessage(queued.id) + return true } } diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 3b664b3bd2..3bd3dd818b 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -216,7 +216,9 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { if (!didApprove) { await task.diffViewProvider.revertChanges() - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[ApplyDiffTool] Failed to process queued messages:", error) + }) return } @@ -257,14 +259,18 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { this.resetPartialState() // Process any queued messages after file edit completes - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[ApplyDiffTool] Failed to process queued messages:", error) + }) return } catch (error) { await handleError("applying diff", error as Error) await task.diffViewProvider.reset() this.resetPartialState() - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[ApplyDiffTool] Failed to process queued messages:", error) + }) return } } diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 56b2bf8909..9bec4c609c 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -226,7 +226,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) pushToolResult(message) await task.diffViewProvider.reset() - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[ApplyPatchTool] Failed to process queued messages:", error) + }) } private async handleDeleteFile( @@ -283,7 +285,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[ApplyPatchTool] Failed to process queued messages:", error) + }) } private async handleUpdateFile( @@ -446,7 +450,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) pushToolResult(message) await task.diffViewProvider.reset() - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[ApplyPatchTool] Failed to process queued messages:", error) + }) } override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index a7301e2ac9..1f6eb188a4 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -467,7 +467,9 @@ export class EditFileTool extends BaseTool<"edit_file"> { this.resetPartialState() // Process any queued messages after file edit completes - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[EditFileTool] Failed to process queued messages:", error) + }) } catch (error) { if (relPathForErrorHandling) { await finalizePartialToolAskIfNeeded(relPathForErrorHandling) diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 2ae8bf4ed0..29eb9a91ff 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -233,7 +233,9 @@ export class EditTool extends BaseTool<"edit"> { this.resetPartialState() // Process any queued messages after file edit completes - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[EditTool] Failed to process queued messages:", error) + }) } catch (error) { await handleError("edit", error as Error) await task.diffViewProvider.reset() diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 18a2a5e250..6014aeec60 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -205,9 +205,11 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { pushToolResult(result) // Only drain queued messages when the command actually ran // (early validation failures end the turn without an execution, - // matching file tools' error-path behavior). + // matching file tools' error-path behavior). The drain is awaited + // so a failed queued-message submission propagates instead of + // being dropped after the tool result was already published. if (commandSubmitted) { - task.processQueuedMessages() + await task.processQueuedMessages() } } catch (error: unknown) { // Invalidate pending ask from first execution to prevent race condition @@ -229,7 +231,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { pushToolResult(result) if (commandSubmitted) { - task.processQueuedMessages() + await task.processQueuedMessages() } } else { // Command was submitted but shell integration lost track of it — show warning. diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index e29b124010..2ba1dc7aa4 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -229,7 +229,9 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { this.resetPartialState() // Process any queued messages after file edit completes - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[SearchReplaceTool] Failed to process queued messages:", error) + }) } catch (error) { await handleError("search and replace", error as Error) await task.diffViewProvider.reset() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index ae026b4b86..1ca930315d 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -182,7 +182,9 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { await task.diffViewProvider.reset() this.resetPartialState() - task.processQueuedMessages() + void task.processQueuedMessages().catch((error) => { + console.error("[WriteToFileTool] Failed to process queued messages:", error) + }) return } catch (error) { From 8ca6ec859b87bb0ac939ac5ea28ce9cd4eb3568e Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Wed, 23 Sep 2026 06:42:35 +0900 Subject: [PATCH 05/51] test(tools): cover interrupted and timeout queue drains --- src/core/task/__tests__/Task.spec.ts | 87 ++++++++++++++----- .../__tests__/applyPatchTool.execute.spec.ts | 2 +- .../tools/__tests__/executeCommand.spec.ts | 6 +- .../__tests__/executeCommandTool.spec.ts | 72 +++++++++++++++ 4 files changed, 140 insertions(+), 27 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index c35acf864d..fc14194cf0 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1187,7 +1187,7 @@ describe("Cline", () => { info: ctxModelInfo, }) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) task.apiConversationHistory = [{ role: "user", content: [{ type: "text", text: "x" }], ts: Date.now() }] const getSystemPromptSpy = vi @@ -1283,7 +1283,7 @@ describe("Cline", () => { condenseId: "condense-id", }) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) await expect(task.condenseContext()).resolves.toBeUndefined() @@ -4352,7 +4352,7 @@ describe("Cline", () => { await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) // The wait never settles on its own; only the bound expires it. Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) task.apiConversationHistory = [ @@ -4400,7 +4400,7 @@ describe("Cline", () => { }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.abandoned = true // Only an unstarted prompt build attributes the skip to the entry @@ -4431,7 +4431,7 @@ describe("Cline", () => { await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, @@ -4487,7 +4487,7 @@ describe("Cline", () => { await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, @@ -4540,7 +4540,7 @@ describe("Cline", () => { await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, @@ -4587,7 +4587,7 @@ describe("Cline", () => { await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, @@ -5068,7 +5068,7 @@ describe("Cline", () => { releasePrompt = () => resolve("mock system prompt") }) vi.mocked(SYSTEM_PROMPT).mockReturnValueOnce(promptGate) - vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() @@ -5780,19 +5780,13 @@ describe("Queued message processing after condense", () => { // Make condense fast + deterministic vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("system") - const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) // Queue a message during condensing task.messageQueueService.addMessage("queued text", ["img1.png"]) - // Use fake timers to capture setTimeout(0) in processQueuedMessages - vi.useFakeTimers() await task.condenseContext() - // Flush the microtask that submits the queued message - vi.runAllTimers() - vi.useRealTimers() - expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"]) expect(task.messageQueueService.isEmpty()).toBe(true) }) @@ -5817,31 +5811,76 @@ describe("Queued message processing after condense", () => { vi.spyOn(getTaskTestAccess(taskA), "getSystemPrompt").mockResolvedValue("system") vi.spyOn(getTaskTestAccess(taskB), "getSystemPrompt").mockResolvedValue("system") - const spyA = vi.spyOn(taskA, "submitUserMessage").mockResolvedValue(undefined) - const spyB = vi.spyOn(taskB, "submitUserMessage").mockResolvedValue(undefined) + const spyA = vi.spyOn(taskA, "submitUserMessage").mockResolvedValue(true) + const spyB = vi.spyOn(taskB, "submitUserMessage").mockResolvedValue(true) taskA.messageQueueService.addMessage("A message") taskB.messageQueueService.addMessage("B message") // Condense in task A should only drain A's queue - vi.useFakeTimers() await taskA.condenseContext() - vi.runAllTimers() - vi.useRealTimers() expect(spyA).toHaveBeenCalledWith("A message", undefined) expect(spyB).not.toHaveBeenCalled() expect(taskB.messageQueueService.isEmpty()).toBe(false) // Now condense in task B should drain B's queue - vi.useFakeTimers() await taskB.condenseContext() - vi.runAllTimers() - vi.useRealTimers() expect(spyB).toHaveBeenCalledWith("B message", undefined) expect(taskB.messageQueueService.isEmpty()).toBe(true) }) + + describe("processQueuedMessages drain semantics", () => { + const createQueueTask = () => + new Task({ + provider: createProvider(), + apiConfiguration: apiConfig, + task: "initial task", + startTask: false, + }) + + it("submits and durably removes the next queued message", async () => { + const task = createQueueTask() + const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) + task.messageQueueService.addMessage("queued text", ["img1.png"]) + + await expect(task.processQueuedMessages()).resolves.toBe(true) + + expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"]) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("resolves false when the queue is empty", async () => { + const task = createQueueTask() + const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) + + await expect(task.processQueuedMessages()).resolves.toBe(false) + + expect(submitSpy).not.toHaveBeenCalled() + }) + + it("releases the queued message and propagates when submission fails", async () => { + const task = createQueueTask() + vi.spyOn(task, "submitUserMessage").mockResolvedValue(false) + task.messageQueueService.addMessage("retry me") + + await expect(task.processQueuedMessages()).rejects.toThrow("Failed to submit queued message") + + // The claim is released, so the message stays queued for a later drain. + expect(task.messageQueueService.claimNextMessage()?.text).toBe("retry me") + }) + + it("releases the queued message and propagates when submission throws", async () => { + const task = createQueueTask() + vi.spyOn(task, "submitUserMessage").mockRejectedValue(new Error("emit failed")) + task.messageQueueService.addMessage("retry me too") + + await expect(task.processQueuedMessages()).rejects.toThrow("emit failed") + + expect(task.messageQueueService.claimNextMessage()?.text).toBe("retry me too") + }) + }) }) describe("Telemetry installments (idle/shutdown flush)", () => { diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 72ffb112bc..afe47b8f92 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -61,7 +61,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { isWriteProtected: vi.fn().mockReturnValue(false), } as unknown as Task["rooProtectedController"], say: vi.fn().mockResolvedValue(undefined), - processQueuedMessages: vi.fn(), + processQueuedMessages: vi.fn().mockResolvedValue(true), didEditFile: false, } diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index 7146fa930b..4ac6b07471 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -356,9 +356,10 @@ describe("executeCommand", () => { commandExecutionTimeout: 1_000, }) await vitest.advanceTimersByTimeAsync(1_000) - const [rejected, result] = await executionPromise + const [rejected, result, commandSubmitted] = await executionPromise expect(rejected).toBe(false) + expect(commandSubmitted).toBe(true) expect(result).toContain("terminated after exceeding") expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( expect.objectContaining({ @@ -444,10 +445,11 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommandInTerminal(mockTask, options) + const [rejected, result, commandSubmitted] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) + expect(commandSubmitted).toBe(true) expect(result).toContain("Process terminated by signal SIGINT") expect(result).toContain("within working directory '/test/project'") }) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index dc7f8592d3..94b5b5ff81 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -560,6 +560,78 @@ describe("executeCommandTool", () => { ) }) + it("processes queued messages once after a command terminated by an interruption", async () => { + mockToolUse.params.command = "long-running-command" + mockToolUse.nativeArgs = { command: "long-running-command" } + + vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValue({ + runCommand: vitest.fn().mockImplementation((_command: string, callbacks: RooTerminalCallbacks) => { + const interruptedProcess = Object.assign(Promise.resolve(), { + continue: vitest.fn(), + abort: vitest.fn(), + }) as unknown as RooTerminalProcess + void callbacks.onCompleted?.("Command interrupted", interruptedProcess) + callbacks.onShellExecutionComplete?.( + { exitCode: undefined, signalName: "SIGINT", coreDumpPossible: false }, + interruptedProcess, + ) + return interruptedProcess + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Process terminated by signal SIGINT"), + ) + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + // The tool result must be published before queued messages are processed. + expect(mockPushToolResult.mock.invocationCallOrder[0]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[0], + ) + }) + + it("processes queued messages once after a user-configured execution timeout", async () => { + mockToolUse.params.command = "sleep 10" + mockToolUse.nativeArgs = { command: "sleep 10" } + // 0.1s user timeout keeps the test fast while exercising the real abort path. + vitest.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vitest + .fn() + .mockImplementation((key: string, defaultValue: unknown) => + key === "commandExecutionTimeout" ? 0.1 : defaultValue, + ), + } as unknown as vscode.WorkspaceConfiguration) + + const pendingProcess = Object.assign(new Promise(() => {}), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValue({ + runCommand: vitest.fn().mockReturnValue(pendingProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("terminated after exceeding a user-configured"), + ) + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + expect(mockPushToolResult.mock.invocationCallOrder[0]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[0], + ) + }) + it("does not process queued messages when the user rejects the command", async () => { mockAskApproval.mockResolvedValue(false) mockToolUse.params.command = "echo test" From 33aaa1e91d354bb038c06638436e561713c9eb48 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 27 Sep 2026 22:24:22 +0900 Subject: [PATCH 06/51] test(tools): resolve processQueuedMessages mocks so success paths exercise real drain What: give the stale vi.fn() processQueuedMessages mocks in editFileTool, editTool, and searchReplaceTool specs mockResolvedValue(undefined). Why: the tools now chain .catch() on the drain promise, so the undefined return threw TypeError inside the tool try block and routed every success-path test through handleError, masking real drain regressions. --- src/core/tools/__tests__/editFileTool.spec.ts | 2 +- src/core/tools/__tests__/editTool.spec.ts | 2 +- src/core/tools/__tests__/searchReplaceTool.spec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 1ff8d52a8d..71a390b2e9 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -149,7 +149,7 @@ describe("editFileTool", () => { mockTask.ask = vi.fn().mockResolvedValue(undefined) mockTask.recordToolError = vi.fn() mockTask.recordToolUsage = vi.fn() - mockTask.processQueuedMessages = vi.fn() + mockTask.processQueuedMessages = vi.fn().mockResolvedValue(undefined) mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") mockAskApproval = vi.fn().mockResolvedValue(true) diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index a5f665b9e5..acd61dc5de 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -145,7 +145,7 @@ describe("editTool", () => { mockTask.ask = vi.fn().mockResolvedValue(undefined) mockTask.recordToolError = vi.fn() mockTask.recordToolUsage = vi.fn() - mockTask.processQueuedMessages = vi.fn() + mockTask.processQueuedMessages = vi.fn().mockResolvedValue(undefined) mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") mockAskApproval = vi.fn().mockResolvedValue(true) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 5cf10790d4..c3e4b3ad41 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -147,7 +147,7 @@ describe("searchReplaceTool", () => { mockCline.ask = vi.fn().mockResolvedValue(undefined) mockCline.recordToolError = vi.fn() mockCline.recordToolUsage = vi.fn() - mockCline.processQueuedMessages = vi.fn() + mockCline.processQueuedMessages = vi.fn().mockResolvedValue(undefined) mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") mockAskApproval = vi.fn().mockResolvedValue(true) From fd9d4227d615266f3dd953fdd2a268690e55dcb9 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 27 Sep 2026 23:20:06 +0900 Subject: [PATCH 07/51] fix(tools): separate command drain failures from shell-integration handling What: ExecuteCommandTool now drains queued messages outside the execution try/catch, so a drain rejection after a published result is logged and the message stays claimed-released for a later drain instead of emitting shell_integration_warning and pushing a second, contradictory command-failure result; the execa fallback drain gets the same separation. The onCompleted callback now also drains messages queued while an agent-timeout background command was running, after publishing the final command_output update. Why: Addresses CodeRabbit review 5330483500 on PR #1711 (two Major items): queued-message submission failures were misattributed as shell integration errors, and feedback queued during a background run stayed queued until an unrelated drain path ran. Impact: No data loss on drain failure (message stays queued and redrainable); the model no longer receives contradictory results after a successful command; background command completion now flushes feedback queued during the run. --- src/core/tools/ExecuteCommandTool.ts | 38 +++-- .../__tests__/executeCommandTool.spec.ts | 144 ++++++++++++++++++ 2 files changed, 173 insertions(+), 9 deletions(-) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 6014aeec60..e72098d5e5 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -195,6 +195,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { agentTimeout, } + let shouldDrainQueuedMessages = false try { const [rejected, result, commandSubmitted] = await executeCommandInTerminal(task, options) @@ -205,12 +206,8 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { pushToolResult(result) // Only drain queued messages when the command actually ran // (early validation failures end the turn without an execution, - // matching file tools' error-path behavior). The drain is awaited - // so a failed queued-message submission propagates instead of - // being dropped after the tool result was already published. - if (commandSubmitted) { - await task.processQueuedMessages() - } + // matching file tools' error-path behavior). + shouldDrainQueuedMessages = commandSubmitted } catch (error: unknown) { // Invalidate pending ask from first execution to prevent race condition task.supersedePendingAsk() @@ -230,9 +227,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } pushToolResult(result) - if (commandSubmitted) { - await task.processQueuedMessages() - } + shouldDrainQueuedMessages = commandSubmitted } else { // Command was submitted but shell integration lost track of it — show warning. await task.say("shell_integration_warning") @@ -247,6 +242,19 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } } + // The drain runs outside the execution catch above and is awaited so a + // failed queued-message submission is observable. A drain failure is + // logged and the message stays claimed-released for a later drain, so + // it must never be misattributed as a shell-integration error after + // the tool result was already published. + if (shouldDrainQueuedMessages) { + try { + await task.processQueuedMessages() + } catch (error) { + console.error("[ExecuteCommandTool] Failed to process queued messages:", error) + } + } + return } catch (error) { await handleError("executing command", error as Error) @@ -446,6 +454,18 @@ export async function executeCommandInTerminal( // errors here are UI-only and must not surface to the tool result. commandOutputSayChain .then(() => queueCommandOutputMessage(result, false, true)) + .then(() => { + // The tool returned a "still running" result and drained when the + // agent timeout moved the command to the background; process + // messages queued since then so they are not held until an + // unrelated path drains them. + if (!runInBackground) { + return + } + return task.processQueuedMessages().catch((error) => { + console.error("[ExecuteCommandTool] Failed to process queued messages:", error) + }) + }) .catch((error) => { console.error("[ExecuteCommandTool] Failed to flush final command_output:", error) }) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 94b5b5ff81..da8a18ec4f 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -665,6 +665,110 @@ describe("executeCommandTool", () => { expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() }) + it("logs a queued-message drain failure after a successful result without a shell-integration warning or a second result", async () => { + mockToolUse.params.command = "echo test" + mockToolUse.nativeArgs = { command: "echo test" } + const successfulProcess = Object.assign(Promise.resolve(), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + const successfulTerminalProcess = successfulProcess as unknown as RooTerminalProcess + vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValue({ + runCommand: vitest.fn().mockImplementation((_command: string, callbacks: RooTerminalCallbacks) => { + void callbacks.onCompleted?.("", successfulTerminalProcess) + callbacks.onShellExecutionComplete?.({ exitCode: 0 }, successfulTerminalProcess) + return successfulProcess + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + const drainError = new Error("queued submission failed") + mockCline.processQueuedMessages.mockRejectedValueOnce(drainError) + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) + try { + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + // The successful result is published exactly once: the drain failure + // must not emit a shell-integration warning or push a contradictory + // command-failure result on top of it. + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Command executed in terminal")) + expect(mockCline.say).not.toHaveBeenCalledWith("shell_integration_warning") + expect(mockHandleError).not.toHaveBeenCalled() + // The failure is logged, not swallowed silently. + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ExecuteCommandTool] Failed to process queued messages:", + drainError, + ) + + // The message stays queued for a later drain: the next command's + // drain still runs. + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(2) + expect(mockPushToolResult).toHaveBeenCalledTimes(2) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("logs a queued-message drain failure after a successful execa fallback retry without a shell-integration warning or a second result", async () => { + const shellError = new executeCommandModule.ShellIntegrationError("startup failed", false) + const failedProcess = Object.assign(Promise.reject(shellError), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + const successfulProcess = Object.assign(Promise.resolve(), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + const successfulTerminalProcess = successfulProcess as unknown as RooTerminalProcess + + vitest + .mocked(TerminalRegistry.getOrCreateTerminal) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockReturnValue(failedProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockImplementation((_command: string, callbacks: RooTerminalCallbacks) => { + void callbacks.onCompleted?.("", successfulTerminalProcess) + callbacks.onShellExecutionComplete?.({ exitCode: 0 }, successfulTerminalProcess) + return successfulProcess + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + const drainError = new Error("queued submission failed") + mockCline.processQueuedMessages.mockRejectedValueOnce(drainError) + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) + try { + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Command executed in terminal")) + expect(mockCline.say).not.toHaveBeenCalledWith("shell_integration_warning") + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ExecuteCommandTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + it("does not drain when the execa fallback retry hits a working directory failure", async () => { mockToolUse.params.command = "echo test" mockToolUse.params.cwd = "/nonexistent/working/dir" @@ -897,5 +1001,45 @@ describe("executeCommandTool", () => { expect(mockPushToolResult).toHaveBeenCalled() expect(mockPushToolResult.mock.calls[0][0]).toContain("still running") }) + + it("drains messages queued during a background run after publishing the final command_output update", async () => { + vitest.useFakeTimers() + mockCline.processQueuedMessages.mockResolvedValue(true) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("npm run dev", 2) + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("server starting...\n", proc) + + // The agent timeout moves the command to the background; the tool + // returns its "still running" result and drains immediately. + await vitest.advanceTimersByTimeAsync(2_000) + await handlePromise + + expect(mockPushToolResult.mock.calls[0][0]).toContain("still running") + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + + // When the background command later completes, the messages queued + // since the immediate drain are processed after the final + // non-partial command_output update is published. + await callbacks.onCompleted!("server exited\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + await vitest.advanceTimersByTimeAsync(100) + + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(2) + + const finalOutputCallIndex = mockCline.say.mock.calls.findIndex( + (call: unknown[]) => call[0] === "command_output" && call[3] === false, + ) + expect(finalOutputCallIndex).not.toBe(-1) + expect(mockCline.say.mock.invocationCallOrder[finalOutputCallIndex]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[1], + ) + }) }) }) From 6903902c6e7342b574e5a16d4737830386adf9b4 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Mon, 28 Sep 2026 00:15:35 +0900 Subject: [PATCH 08/51] fix(tools): gate background-completion drain on tool result publication What: ExecuteCommandTool.execute now creates a per-invocation promise that resolves at each pushToolResult site (success, execa retry, and shell-integration failure paths) and passes it to executeCommandInTerminal as toolResultPublished; the onCompleted background drain awaits it before calling processQueuedMessages. Why: a background command can finish during the 50 ms settle delay, while the tool result is still pending. The fire-and-forget completion chain then drained queued messages before pushToolResult ran, submitting queued user messages ahead of the current tool result. The promise gate makes the ordering structural instead of dependent on timer or microtask scheduling. Impact: drain errors stay log-only (no shell_integration_warning, no second failure result), claim-release/redrainable semantics are unchanged, and non-background behavior is identical. Direct callers of executeCommandInTerminal get a resolved default and drain as before. --- src/core/tools/ExecuteCommandTool.ts | 40 +++++++++++--- .../__tests__/executeCommandTool.spec.ts | 52 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index e72098d5e5..b9ac464eea 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -186,6 +186,21 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { // Convert agent-specified timeout from seconds to milliseconds const agentTimeout = resolveAgentTimeoutMs(timeoutSeconds) + // The background-completion drain inside executeCommandInTerminal + // must not run before this command's tool result is published: a + // background command can finish during the settle delay while the + // result is still pending. Resolve at each pushToolResult site so + // the drain waits on a structural signal, not on timer or + // microtask ordering. + let resolveToolResultPublished: (() => void) | undefined + const toolResultPublished = new Promise((resolve) => { + resolveToolResultPublished = resolve + }) + const publishToolResult = (result: ToolResponse): void => { + pushToolResult(result) + resolveToolResultPublished?.() + } + const options: ExecuteCommandOptions = { executionId, command: canonicalCommand, @@ -193,6 +208,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { terminalShellIntegrationDisabled, commandExecutionTimeout, agentTimeout, + toolResultPublished, } let shouldDrainQueuedMessages = false @@ -203,7 +219,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { task.didRejectTool = true } - pushToolResult(result) + publishToolResult(result) // Only drain queued messages when the command actually ran // (early validation failures end the turn without an execution, // matching file tools' error-path behavior). @@ -226,18 +242,18 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { task.didRejectTool = true } - pushToolResult(result) + publishToolResult(result) shouldDrainQueuedMessages = commandSubmitted } else { // Command was submitted but shell integration lost track of it — show warning. await task.say("shell_integration_warning") if (error instanceof ShellIntegrationError) { - pushToolResult( + publishToolResult( "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", ) } else { - pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) + publishToolResult(`Command failed to execute in terminal due to a shell integration error.`) } } } @@ -275,6 +291,12 @@ export type ExecuteCommandOptions = { terminalShellIntegrationDisabled?: boolean commandExecutionTimeout?: number agentTimeout?: number + /** + * Resolves once the tool result for this command has been published. + * The background-completion drain awaits this before processing queued + * messages so they are never submitted ahead of the current tool result. + */ + toolResultPublished?: Promise } export async function executeCommandInTerminal( @@ -286,6 +308,8 @@ export async function executeCommandInTerminal( terminalShellIntegrationDisabled = true, commandExecutionTimeout = 0, agentTimeout = 0, + // Direct callers without a tool-result lifecycle drain immediately. + toolResultPublished = Promise.resolve(), }: ExecuteCommandOptions, ): Promise<[boolean, ToolResponse, boolean]> { // Convert milliseconds back to seconds for display purposes. @@ -454,14 +478,18 @@ export async function executeCommandInTerminal( // errors here are UI-only and must not surface to the tool result. commandOutputSayChain .then(() => queueCommandOutputMessage(result, false, true)) - .then(() => { + .then(async () => { // The tool returned a "still running" result and drained when the // agent timeout moved the command to the background; process // messages queued since then so they are not held until an - // unrelated path drains them. + // unrelated path drains them. The tool result for this command + // must be published first: completion can land inside the + // settle delay while the result is still pending, so wait on + // the per-invocation signal instead of timer ordering. if (!runInBackground) { return } + await toolResultPublished return task.processQueuedMessages().catch((error) => { console.error("[ExecuteCommandTool] Failed to process queued messages:", error) }) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index da8a18ec4f..aa116b0a05 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -1041,5 +1041,57 @@ describe("executeCommandTool", () => { mockCline.processQueuedMessages.mock.invocationCallOrder[1], ) }) + + it("publishes the tool result before draining when a background command completes during the settle delay", async () => { + vitest.useFakeTimers() + mockCline.processQueuedMessages.mockResolvedValue(true) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("npm run dev", 2) + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("server starting...\n", proc) + + // The agent timeout moves the command to the background. Advance in + // small steps and stop as soon as the transition happens: the tool + // then sits in the 50 ms settle delay with the tool result still + // pending publication. + for (let i = 0; terminal.proc.continue.mock.calls.length === 0 && i < 200; i++) { + await vitest.advanceTimersByTimeAsync(25) + } + expect(terminal.proc.continue).toHaveBeenCalled() + expect(mockPushToolResult).not.toHaveBeenCalled() + + // Completion lands inside the settle delay, while the tool result + // is still pending publication. + await callbacks.onCompleted!("server exited\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + expect(mockPushToolResult).not.toHaveBeenCalled() + // The background-completion drain is gated on the tool result. + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + + await vitest.advanceTimersByTimeAsync(100) + await handlePromise + await vitest.advanceTimersByTimeAsync(0) + + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + // Completion already landed, so the tool returns the completed + // result rather than a "still running" one. + expect(mockPushToolResult.mock.calls[0][0]).toContain("Command executed in terminal") + expect(mockPushToolResult.mock.calls[0][0]).toContain("Exit code: 0") + // The immediate post-result drain plus the background-completion drain. + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(2) + // Queued messages must never be processed before the tool result. + expect(mockPushToolResult.mock.invocationCallOrder[0]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[0], + ) + expect(mockPushToolResult.mock.invocationCallOrder[0]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[1], + ) + }) }) }) From cefd73546c6fa61b466ec03f43a0008834c4caeb Mon Sep 17 00:00:00 2001 From: myk1yt Date: Mon, 28 Sep 2026 01:40:03 +0900 Subject: [PATCH 09/51] test(tools): cover queued-message drain rejection paths for patch coverage What: add rejection-path tests asserting each file-edit tool logs its processQueuedMessages failure via console.error while leaving the tool result unchanged; extend the WriteToFileTool fixture with a resolving processQueuedMessages mock; add a dedicated ApplyDiffTool spec covering all three drain sites (rejection by user, success, and error paths); cover ExecuteCommandTool's submitted-shell-integration warning without drain, the generic non-shell-integration warning, the background completion drain failure, and the persisted truncated-output result; assert submitUserMessage's boolean contract (true on handoff, false for empty input, lost provider, and thrown handoff) and its nullish-text coercion. Why: the codecov/patch check on PR #1711 reported 64.15% (19 missing lines) against the 80% target. Success-path tests never executed the .catch callback bodies added to the fire-and-forget drains, ApplyDiffTool had no spec at all, and the WriteToFileTool fixture never defined processQueuedMessages, routing success tests through the outer error handler instead of the new drain call. Impact: all patch lines in the eight touched files are now covered by focused lowest-layer tests (60/60 countable added lines locally, 100%); tool results, drain ordering guarantees, and log-only failure semantics are asserted unchanged. No source changes; suppressions file untouched. --- src/core/task/__tests__/Task.spec.ts | 91 +++++++++ .../tools/__tests__/applyDiffTool.spec.ts | 191 ++++++++++++++++++ .../__tests__/applyPatchTool.execute.spec.ts | 183 +++++++++++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 21 ++ src/core/tools/__tests__/editTool.spec.ts | 21 ++ .../__tests__/executeCommandTool.spec.ts | 185 +++++++++++++++++ .../tools/__tests__/searchReplaceTool.spec.ts | 21 ++ .../tools/__tests__/writeToFileTool.spec.ts | 22 ++ 8 files changed, 735 insertions(+) create mode 100644 src/core/tools/__tests__/applyDiffTool.spec.ts diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index fc14194cf0..05a74290b5 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2627,6 +2627,97 @@ describe("Cline", () => { // Restore console.error consoleErrorSpy.mockRestore() }) + + it("returns true when the message is handed to the ask-response channel", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + const submitted = await task.submitUserMessage("test message", ["image1.png"]) + + expect(submitted).toBe(true) + }) + + it("returns false when there is nothing to submit", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse") + + // Empty text without images. + await expect(task.submitUserMessage("", [])).resolves.toBe(false) + // Whitespace-only text without images. + await expect(task.submitUserMessage(" ", [])).resolves.toBe(false) + + expect(handleResponseSpy).not.toHaveBeenCalled() + }) + + it("returns false when the provider reference is lost", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + writable: false, + configurable: true, + }) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(task.submitUserMessage("test message")).resolves.toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#submitUserMessage] Provider reference lost") + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("returns false when the submission handoff throws", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => { + throw new Error("emit failed") + }) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(task.submitUserMessage("test message")).resolves.toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#submitUserMessage] Failed to submit user message:", + expect.any(Error), + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("coerces a nullish text into an images-only submission", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + // Runtime callers outside the type system can pass a nullish text; + // the guard coerces it so an images-only message still submits. + const submitted = await task.submitUserMessage(undefined as unknown as string, ["image1.png"]) + + expect(submitted).toBe(true) + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "", ["image1.png"]) + }) }) }) diff --git a/src/core/tools/__tests__/applyDiffTool.spec.ts b/src/core/tools/__tests__/applyDiffTool.spec.ts new file mode 100644 index 0000000000..7d4aaa5cb9 --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.spec.ts @@ -0,0 +1,191 @@ +// npx vitest run core/tools/__tests__/applyDiffTool.spec.ts + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import type { Task } from "../../task/Task" +import { ApplyDiffTool } from "../ApplyDiffTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + }, +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +describe("ApplyDiffTool.execute - queued message drain failures", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyDiffTool + let mockTask: Pick< + Task, + | "cwd" + | "api" + | "consecutiveMistakeCount" + | "consecutiveMistakeCountForApplyDiff" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "sayAndCreateMissingParamError" + | "processQueuedMessages" + | "didEditFile" + | "providerRef" + | "diffViewProvider" + | "diffStrategy" + | "fileContextTracker" + > + let mockProcessQueuedMessages: MockedFunction<() => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + mockProcessQueuedMessages = vi.fn().mockResolvedValue(true) + + mockTask = { + cwd: "/workspace/project", + api: { + getModel: vi.fn().mockReturnValue({ id: "claude-3" }), + } as unknown as Task["api"], + consecutiveMistakeCount: 0, + consecutiveMistakeCountForApplyDiff: new Map(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing param error"), + processQueuedMessages: mockProcessQueuedMessages, + didEditFile: false, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }), + } as unknown as Task["providerRef"], + diffViewProvider: { + editType: undefined, + isEditing: false, + originalContent: "", + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + revertChanges: vi.fn().mockResolvedValue(undefined), + saveChanges: vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: null, + finalContent: "final content", + }), + saveDirectly: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Tool result message"), + } as unknown as Task["diffViewProvider"], + diffStrategy: { + applyDiff: vi.fn().mockResolvedValue({ success: true, content: "updated file content" }), + } as unknown as Task["diffStrategy"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyDiffTool() + }) + + it("logs a drain failure when the user rejects the diff", async () => { + mockAskApproval.mockResolvedValue(false) + const drainError = new Error("queued submission failed") + mockProcessQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await tool.execute({ path: "src/existing.ts", diff: "updated file content" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockTask.diffViewProvider.revertChanges).toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ApplyDiffTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("logs a drain failure after a successful diff edit without changing the tool result", async () => { + const drainError = new Error("queued submission failed") + mockProcessQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await tool.execute({ path: "src/existing.ts", diff: "updated file content" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result message") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ApplyDiffTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("logs a drain failure when the edit fails after the diff view reset", async () => { + const saveError = new Error("save failed") + ;( + mockTask.diffViewProvider.saveChanges as MockedFunction<(...args: unknown[]) => Promise> + ).mockRejectedValue(saveError) + const drainError = new Error("queued submission failed") + mockProcessQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await tool.execute({ path: "src/existing.ts", diff: "updated file content" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockHandleError).toHaveBeenCalledWith("applying diff", saveError) + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ApplyDiffTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) +}) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index afe47b8f92..5608d52c27 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -2,6 +2,8 @@ import type { MockedFunction } from "vitest" +import fs from "fs/promises" + import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import type { Task } from "../../task/Task" @@ -94,3 +96,184 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolError).not.toHaveBeenCalled() }) }) + +describe("ApplyPatchTool.execute - queued message drain failures", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction + const mockedReadFile = fs.readFile as MockedFunction + + let tool: ApplyPatchTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "recordToolUsage" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "providerRef" + | "diffViewProvider" + | "fileContextTracker" + > + let mockProcessQueuedMessages: MockedFunction<() => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + mockedReadFile.mockResolvedValue("original file content\n") + mockProcessQueuedMessages = vi.fn().mockResolvedValue(true) + + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: mockProcessQueuedMessages, + didEditFile: false, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }), + } as unknown as Task["providerRef"], + diffViewProvider: { + editType: undefined, + isEditing: false, + originalContent: "", + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + revertChanges: vi.fn().mockResolvedValue(undefined), + saveChanges: vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: null, + finalContent: "final content", + }), + saveDirectly: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Tool result message"), + } as unknown as Task["diffViewProvider"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyPatchTool() + }) + + it("logs a drain failure after deleting a file without changing the tool result", async () => { + const drainError = new Error("queued submission failed") + mockProcessQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const patch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** End Patch` + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ApplyPatchTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("logs a drain failure after writing a new file without changing the tool result", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + const drainError = new Error("queued submission failed") + mockProcessQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const patch = `*** Begin Patch +*** Add File: src/created.ts ++created content +*** End Patch` + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result message") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ApplyPatchTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("logs a drain failure after updating a file without changing the tool result", async () => { + const drainError = new Error("queued submission failed") + mockProcessQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const patch = `*** Begin Patch +*** Update File: src/existing.ts +@@ +-original file content ++updated file content +*** End Patch` + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result message") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ApplyPatchTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) +}) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 71a390b2e9..c578be586f 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -677,6 +677,27 @@ describe("editFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() }) + + it("logs a queued-message drain failure after a successful edit without changing the tool result", async () => { + const drainError = new Error("queued submission failed") + mockTask.processQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const result = await executeEditFileTool() + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[EditFileTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe("file tracking", () => { diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index acd61dc5de..29cf6d4a46 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -415,6 +415,27 @@ describe("editTool", () => { expect(mockHandleError).toHaveBeenCalledWith("edit", expect.any(Error)) expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() }) + + it("logs a queued-message drain failure after a successful edit without changing the tool result", async () => { + const drainError = new Error("queued submission failed") + mockTask.processQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const result = await executeEditTool() + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[EditTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe("file tracking", () => { diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index aa116b0a05..3d77c11a01 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -44,6 +44,40 @@ vitest.mock("../../../integrations/terminal/TerminalRegistry", () => ({ }, })) +const mockInterceptorInstances: Array<{ write: ReturnType; finalize: ReturnType }> = + vitest.hoisted(() => []) + +vitest.mock("../../../integrations/terminal/OutputInterceptor", () => ({ + // vitest 4 mocks used with `new` must be function/class implementations. + OutputInterceptor: class { + write = vitest.fn() + finalize = vitest.fn().mockResolvedValue({ truncated: false }) + constructor(..._args: unknown[]) { + mockInterceptorInstances.push(this) + } + }, +})) + +vitest.mock("../../../utils/storage", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getTaskDirectoryPath: vitest.fn().mockResolvedValue("/test/storage/task-1/command-output"), + } +}) + +vitest.mock("@roo-code/telemetry", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + TelemetryService: class { + static instance = { + captureShellIntegrationError: vitest.fn(), + } + }, + } +}) + vitest.mock("../../task/Task") vitest.mock("../../prompts/responses") @@ -491,6 +525,30 @@ describe("executeCommandTool", () => { expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(false) }) + it("warns without draining when the terminal fails with a non-shell-integration error", async () => { + vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValueOnce({ + runCommand: vitest.fn().mockImplementation(() => { + throw new Error("terminal process failed to start") + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockCline.say).toHaveBeenCalledWith("shell_integration_warning") + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledWith( + "Command failed to execute in terminal due to a shell integration error.", + ) + // The command never ran, so queued messages must not be drained. + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + }) + it("selects the Execa fallback provider for cmd.exe shell integration", () => { vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(true) @@ -1093,5 +1151,132 @@ describe("executeCommandTool", () => { mockCline.processQueuedMessages.mock.invocationCallOrder[1], ) }) + + it("warns without draining when a submitted command loses shell integration", async () => { + vitest.useFakeTimers() + mockCline.providerRef.deref.mockResolvedValue({ + contextProxy: { getValue: vitest.fn().mockReturnValue(false) }, + getState: vitest.fn().mockResolvedValue({ terminalShellIntegrationDisabled: false }), + postMessageToWebview: vitest.fn().mockResolvedValue(undefined), + }) + vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(false) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("Write-Output hello") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + expect(terminal.provider).toBe("vscode") + callbacks.onShellExecutionStarted!(1234, proc) + // The command was submitted, but shell integration cannot report + // its completion, so the retry path must not run. + callbacks.onNoShellIntegration!({ message: "exit code unknown", commandSubmitted: true }, proc) + terminal.resolveProcess() + await vitest.advanceTimersByTimeAsync(100) + await handlePromise + + expect(mockCline.say).toHaveBeenCalledWith("shell_integration_warning") + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledWith( + "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", + ) + // A submitted command that loses shell integration did complete its + // execution path, but the tool must not drain queued messages here. + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("logs a background-completion drain failure after the final command_output update", async () => { + vitest.useFakeTimers() + const drainError = new Error("queued submission failed") + mockCline.processQueuedMessages.mockResolvedValueOnce(true).mockRejectedValueOnce(drainError) + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) + try { + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("npm run dev", 2) + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("server starting...\n", proc) + + // The agent timeout moves the command to the background; the tool + // returns its "still running" result and drains immediately. + await vitest.advanceTimersByTimeAsync(2_000) + await handlePromise + + expect(mockPushToolResult.mock.calls[0][0]).toContain("still running") + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + + // When the background command later completes, the drain failure + // is logged and never surfaces as a second tool result. + await callbacks.onCompleted!("server exited\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + await vitest.advanceTimersByTimeAsync(100) + + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(2) + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ExecuteCommandTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("returns the persisted output format when the interceptor reports truncated output", async () => { + vitest.useFakeTimers() + mockCline.providerRef.deref.mockResolvedValue({ + context: { globalStorageUri: { fsPath: "/test/storage" } }, + contextProxy: { getValue: vitest.fn().mockReturnValue(false) }, + getState: vitest.fn().mockResolvedValue({ + terminalOutputLineLimit: 500, + terminalOutputCharacterLimit: 100000, + terminalShellIntegrationDisabled: true, + }), + postMessageToWebview: vitest.fn().mockResolvedValue(undefined), + }) + const persisted = { + truncated: true, + totalBytes: 200_000, + artifactPath: "/test/storage/task-1/command-output/exec-1.txt", + preview: "head\n...[omitted middle content]...\ntail", + } + const terminal = await setupControllableTerminal() + // Other tests (DCG approval flows) also construct interceptors, so + // index relative to the instances that exist before this command. + const interceptorIndex = mockInterceptorInstances.length + + const handlePromise = handleCommand("cat big.log") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + expect(mockInterceptorInstances.length).toBe(interceptorIndex + 1) + mockInterceptorInstances[interceptorIndex].finalize.mockResolvedValue(persisted) + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("line\n", proc) + await callbacks.onCompleted!("line\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + terminal.resolveProcess() + await vitest.advanceTimersByTimeAsync(100) + await handlePromise + + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Command executed in '/test/workspace'. Exit code: 0") + expect(result).toContain("Output (195.3KB) persisted. Artifact ID: exec-1.txt") + expect(result).toContain(persisted.preview) + expect(result).toContain("Use read_command_output tool to view full output if needed.") + }) }) }) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index c3e4b3ad41..6c00abdad9 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -386,6 +386,27 @@ describe("searchReplaceTool", () => { expect(mockHandleError).toHaveBeenCalledWith("search and replace", expect.any(Error)) expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() }) + + it("logs a queued-message drain failure after a successful replace without changing the tool result", async () => { + const drainError = new Error("queued submission failed") + mockCline.processQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const result = await executeSearchReplaceTool() + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[SearchReplaceTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe("file tracking", () => { diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 52a7e3c052..43f46cd003 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -187,6 +187,7 @@ describe("writeToFileTool", () => { mockCline.say = vi.fn().mockResolvedValue(undefined) mockCline.ask = vi.fn().mockResolvedValue(undefined) mockCline.recordToolError = vi.fn() + mockCline.processQueuedMessages = vi.fn().mockResolvedValue(undefined) mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") mockAskApproval = vi.fn().mockResolvedValue(true) @@ -392,6 +393,27 @@ describe("writeToFileTool", () => { // Should process normally without issues expect(mockCline.consecutiveMistakeCount).toBe(0) }) + + it("logs a queued-message drain failure after a successful write without changing the tool result", async () => { + const drainError = new Error("queued submission failed") + mockCline.processQueuedMessages.mockRejectedValue(drainError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + const result = await executeWriteFileTool({}, { fileExists: false }) + + // Flush the fire-and-forget drain promise so its rejection is logged. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[WriteToFileTool] Failed to process queued messages:", + drainError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe("partial block handling", () => { From 5183a1e0bbe90d9b2979d0df791f9564c6b6af8f Mon Sep 17 00:00:00 2001 From: myk1yt Date: Mon, 28 Sep 2026 02:07:33 +0900 Subject: [PATCH 10/51] fix(task): serialize queued-message drains per task What: Task.processQueuedMessages now chains each drain behind the previous drain's completion via a per-task promise chain, and the claim/submit/remove body moved into a private claimAndSubmitNextQueuedMessage that runs only when the chain reaches it. The chain survives rejections, so a failed drain still propagates to its own caller but never blocks later drains. Also folds CodeRabbit review 5331226947 on cefd73546: - ExecuteCommandTool routes generic execution errors (e.g. runCommand throwing at terminal start) to the tool's ordinary handleError path instead of the shell-integration warning + result; only ShellIntegrationError keeps the warning path, and the now-unreachable generic warning result is removed. - submitUserMessage return-value test also asserts the handleWebviewAskResponse handoff args. - ApplyDiffTool rejection test asserts revertChanges ran exactly once and no tool result was published. - The background-completion drain-failure test asserts the final non-partial command_output update precedes the second drain via the shared invocation-order idiom. Why: the background-completion drain in ExecuteCommandTool's onCompleted callback and the post-result drain at the end of execute() can both run in the same window after a background command finishes. Each drain can claim a different queued message, but handleWebviewAskResponse stores a single askResponse/askResponseText/askResponseImages slot, so the second submission can overwrite the first response before the pending ask consumes it while both messages have already been removed from the queue. Serializing at the Task level closes the interleaving for both entry paths at once, since all drains funnel through processQueuedMessages. Impact: claim-release on submission failure, redrainability of failed messages, log-only drain error handling in the tools, and the toolResultPublished result-before-drain gate are unchanged; a blocked submission now head-of-line blocks later drains by design (the handoff itself is synchronous and error-guarded, so the blocked window is bounded). Generic terminal-start failures now surface as ordinary tool errors instead of a misleading shell-integration warning. Regression test queues two messages, blocks the first submission mid-handoff, and asserts the second message is neither claimed nor submitted until the first response resolves, then both deliver in order; a second test pins that a rejected drain does not poison the chain. Mutation-verified: removing the chain fails the serialization test (second message submitted early). --- src/core/task/Task.ts | 25 ++++++++- src/core/task/__tests__/Task.spec.ts | 52 ++++++++++++++++++- src/core/tools/ExecuteCommandTool.ts | 19 +++---- .../tools/__tests__/applyDiffTool.spec.ts | 3 +- .../__tests__/executeCommandTool.spec.ts | 27 +++++++--- 5 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9cda31fb2c..34665c59c0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -412,6 +412,12 @@ export class Task extends EventEmitter implements TaskLike { // Message Queue Service public readonly messageQueueService: MessageQueueService private messageQueueStateChangedHandler: (() => void) | undefined + // Serializes queued-message drains: a drain claims and submits only after + // the previous drain's submission handoff finished, so two concurrent + // drains cannot submit different messages into the single ask-response + // slot (which would drop the earlier response after both messages were + // already removed from the queue). + private queuedMessageDrainChain: Promise = Promise.resolve() // Streaming isWaitingForFirstChunk = false @@ -5426,10 +5432,27 @@ export class Task extends EventEmitter implements TaskLike { * claim is released so the message stays queued for a later drain, and the * failure propagates to the caller instead of being logged and dropped. * + * Drains are serialized per task: each run claims only after the previous + * run's submission handoff completed, so the background-completion drain + * and the post-result drain cannot interleave two different messages into + * the single pending ask-response. A rejected drain does not block later + * drains. + * * @returns Promise resolving to true when a queued message was submitted * and durably removed; false when the queue was empty. */ - public async processQueuedMessages(): Promise { + public processQueuedMessages(): Promise { + const run = this.queuedMessageDrainChain.then(() => this.claimAndSubmitNextQueuedMessage()) + // A rejected drain must not poison the chain for later drains; the + // caller still receives this run's outcome through the returned promise. + this.queuedMessageDrainChain = run.then( + () => {}, + () => {}, + ) + return run + } + + private async claimAndSubmitNextQueuedMessage(): Promise { const queued = this.messageQueueService.claimNextMessage() if (!queued) { return false diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 05a74290b5..d68903c1fd 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2635,11 +2635,12 @@ describe("Cline", () => { task: "initial task", startTask: false, }) - vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) const submitted = await task.submitUserMessage("test message", ["image1.png"]) expect(submitted).toBe(true) + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "test message", ["image1.png"]) }) it("returns false when there is nothing to submit", async () => { @@ -5971,6 +5972,55 @@ describe("Queued message processing after condense", () => { expect(task.messageQueueService.claimNextMessage()?.text).toBe("retry me too") }) + + it("serializes concurrent drains so a blocked submission holds the next message queued", async () => { + const task = createQueueTask() + task.messageQueueService.addMessage("first") + task.messageQueueService.addMessage("second") + + // Block the first submission mid-handoff so its response stays pending. + let releaseFirstSubmission!: () => void + const firstSubmission = new Promise((resolve) => { + releaseFirstSubmission = () => resolve(true) + }) + const submitSpy = vi + .spyOn(task, "submitUserMessage") + .mockReturnValueOnce(firstSubmission) + .mockResolvedValue(true) + + const firstDrain = task.processQueuedMessages() + const secondDrain = task.processQueuedMessages() + + // The first drain reaches its blocked submission; the serialized + // second drain must not claim or submit the next message yet. + await Promise.resolve() + expect(submitSpy).toHaveBeenCalledTimes(1) + expect(submitSpy).toHaveBeenCalledWith("first", undefined) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["first", "second"]) + + releaseFirstSubmission() + await expect(firstDrain).resolves.toBe(true) + await expect(secondDrain).resolves.toBe(true) + + // Both messages are delivered in queue order. + expect(submitSpy).toHaveBeenCalledTimes(2) + expect(submitSpy).toHaveBeenNthCalledWith(2, "second", undefined) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("does not let a failed drain block later drains", async () => { + const task = createQueueTask() + vi.spyOn(task, "submitUserMessage").mockRejectedValueOnce(new Error("emit failed")).mockResolvedValue(true) + task.messageQueueService.addMessage("retry me") + task.messageQueueService.addMessage("still deliverable") + + await expect(task.processQueuedMessages()).rejects.toThrow("emit failed") + // The failed claim is released; the next drain retries it in order. + await expect(task.processQueuedMessages()).resolves.toBe(true) + await expect(task.processQueuedMessages()).resolves.toBe(true) + + expect(task.messageQueueService.isEmpty()).toBe(true) + }) }) }) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index b9ac464eea..99113ffda6 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -244,17 +244,18 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { publishToolResult(result) shouldDrainQueuedMessages = commandSubmitted - } else { + } else if (error instanceof ShellIntegrationError) { // Command was submitted but shell integration lost track of it — show warning. await task.say("shell_integration_warning") - - if (error instanceof ShellIntegrationError) { - publishToolResult( - "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", - ) - } else { - publishToolResult(`Command failed to execute in terminal due to a shell integration error.`) - } + publishToolResult( + "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", + ) + } else { + // Ordinary execution error (e.g. the terminal failed to start) — + // not a shell-integration failure, so it must not emit the + // shell-integration warning; surface it through the tool's + // error path instead. + throw error } } diff --git a/src/core/tools/__tests__/applyDiffTool.spec.ts b/src/core/tools/__tests__/applyDiffTool.spec.ts index 7d4aaa5cb9..cb93ff5b3c 100644 --- a/src/core/tools/__tests__/applyDiffTool.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.spec.ts @@ -123,7 +123,8 @@ describe("ApplyDiffTool.execute - queued message drain failures", () => { // Flush the fire-and-forget drain promise so its rejection is logged. await new Promise((resolve) => setTimeout(resolve, 0)) - expect(mockTask.diffViewProvider.revertChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).not.toHaveBeenCalled() expect(mockHandleError).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( "[ApplyDiffTool] Failed to process queued messages:", diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 3d77c11a01..92a7fcdb72 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -525,10 +525,11 @@ describe("executeCommandTool", () => { expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(false) }) - it("warns without draining when the terminal fails with a non-shell-integration error", async () => { + it("routes a generic terminal-start error to the execution error path without draining", async () => { + const runError = new Error("terminal process failed to start") vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValueOnce({ runCommand: vitest.fn().mockImplementation(() => { - throw new Error("terminal process failed to start") + throw runError }), getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), } as never) @@ -539,14 +540,15 @@ describe("executeCommandTool", () => { pushToolResult: mockPushToolResult as unknown as PushToolResult, }) - expect(mockCline.say).toHaveBeenCalledWith("shell_integration_warning") - expect(mockPushToolResult).toHaveBeenCalledTimes(1) - expect(mockPushToolResult).toHaveBeenCalledWith( - "Command failed to execute in terminal due to a shell integration error.", - ) + // A generic terminal failure is an ordinary execution error, not a + // shell-integration failure: the shell-integration warning and its + // dedicated result must not appear (see the ShellIntegrationError + // test for that distinct path). + expect(mockHandleError).toHaveBeenCalledWith("executing command", runError) + expect(mockCline.say).not.toHaveBeenCalledWith("shell_integration_warning") + expect(mockPushToolResult).not.toHaveBeenCalled() // The command never ran, so queued messages must not be drained. expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() - expect(mockHandleError).not.toHaveBeenCalled() }) it("selects the Execa fallback provider for cmd.exe shell integration", () => { @@ -1220,6 +1222,15 @@ describe("executeCommandTool", () => { await vitest.advanceTimersByTimeAsync(100) expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(2) + // The final non-partial command_output update is published before + // the background-completion drain runs. + const finalOutputCallIndex = mockCline.say.mock.calls.findIndex( + (call: unknown[]) => call[0] === "command_output" && call[3] === false, + ) + expect(finalOutputCallIndex).not.toBe(-1) + expect(mockCline.say.mock.invocationCallOrder[finalOutputCallIndex]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[1], + ) expect(mockPushToolResult).toHaveBeenCalledTimes(1) expect(mockHandleError).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( From f2981f6518ee8c49c77a0b6ddbb29b77d7818be0 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Mon, 28 Sep 2026 06:23:09 +0900 Subject: [PATCH 11/51] fix(task): retain queued messages until an ask consumes them What: claimAndSubmitNextQueuedMessage releases the claim after a successful submitUserMessage instead of removing the message, so a drained message stays queued until Task.ask consumes it. The drain snapshots the posted response (text trimmed and images defaulted exactly as submitUserMessage normalizes them); the ask returning that response from the pending slot (a blocked-ask interception) removes the message there, while an overwritten unconsumed submission (user click, auto-approval) or a claim-path consumption leaves it queued for a later ask. Removal is now tied to consumption in every path: pending-slot interception, the ask claim path, or persistQueuedFeedbackAndAcknowledge for durable-ack resolutions. Why: removal used to fire at submit-return (and at dequeue time before this PR's redesign), so a message submitted between command completion and the next conversational ask was dropped from the queue while its response could still be discarded unconsumed: the next ask() resets the pending ask-response slot at its start, so if no ask was blocked at submit time the feedback vanished entirely even though the user had already sent and seen it. Conversely, with unconditional retention a drain whose submission intercepts a blocked ask would deliver twice (the interception, then a later claim-path consumption); matching the returned response against the submission snapshot distinguishes interception (consumption -> remove, exactly-once) from an unconsumed overwrite (retain). The snapshot must mirror submitUserMessage's trim/images normalization or the match fails for queued messages saved with surrounding whitespace (editQueuedMessage saves untrimmed), which would reopen the double delivery for that input class. Impact: the drain-chain serialization, claim-release on failure, and redrainability are unchanged; a queued message may be re-submitted by successive drains before an ask consumes it (idempotent: the submit handoff overwrites the pending slot with the same content), and a retained message suppresses the interactive/resumable/idle status timers until consumed because the queue no longer reads empty. Existing drain and condense tests updated to assert retain-then-consume; new tests pin real-submit retention, delivery through the next conversational ask, interception removal for a blocked ask (exactly-once, including padded untrimmed text), retention when a user response overwrites the submission before consumption, and direct content comparisons for the exported queuedImagesEqual helper. Mutation-verified: restoring remove-on-submit fails the retention assertions, disabling interception-removal fails the blocked-ask exactly-once test, and removing the snapshot trim fails the padded-text interception test. --- src/core/task/Task.ts | 57 +++++++++++- src/core/task/__tests__/Task.spec.ts | 69 +++++++++++++- .../ask-queued-message-drain.spec.ts | 91 ++++++++++++++++++- 3 files changed, 207 insertions(+), 10 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 34665c59c0..3d39f04b01 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -181,6 +181,10 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio return { response: "messageResponse", requiresDurableAck: type === "completion_result" } } +export function queuedImagesEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]) +} + 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 @@ -418,6 +422,13 @@ export class Task extends EventEmitter implements TaskLike { // slot (which would drop the earlier response after both messages were // already removed from the queue). private queuedMessageDrainChain: Promise = Promise.resolve() + // Snapshot of the last drain-submitted queued message. A successful submit + // only posts into the pending ask-response slot; the message stays queued + // until an ask actually consumes that response. The consuming ask matches + // the returned response against this snapshot to decide whether the + // submission was consumed (interception → remove) or overwritten + // unconsumed (retain for a later ask). + private pendingSubmittedQueuedMessage: { id: string; text: string; images: string[] } | undefined // Streaming isWaitingForFirstChunk = false @@ -1701,6 +1712,27 @@ export class Task extends EventEmitter implements TaskLike { images: this.askResponseImages, queuedMessageId, } + // Tie a drain-submitted queued message to actual consumption. The ask + // consumed the submission only if it returned the posted response from + // the pending slot: the claim path is excluded (durable flows carry + // queuedMessageId for later persistence; non-durable flows already + // removed the message inline). + const pendingSubmitted = this.pendingSubmittedQueuedMessage + if (pendingSubmitted) { + const consumedViaPendingSlot = + result.queuedMessageId === undefined && + result.response === "messageResponse" && + result.text === pendingSubmitted.text && + queuedImagesEqual(result.images ?? [], pendingSubmitted.images) + if (consumedViaPendingSlot) { + this.messageQueueService.removeMessage(pendingSubmitted.id) + this.pendingSubmittedQueuedMessage = undefined + } else if (!this.messageQueueService.messages.some((message) => message.id === pendingSubmitted.id)) { + // The message was consumed via the ask claim path or discarded + // by an existing path; the tracker is stale, so clear it. + this.pendingSubmittedQueuedMessage = undefined + } + } this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined @@ -5427,8 +5459,14 @@ export class Task extends EventEmitter implements TaskLike { /** * Process the next queued message by claiming and submitting it. * - * The message is claimed — not dequeued — before submission and is only - * removed after the submission handoff succeeds. When submission fails, the + * The message is claimed — not dequeued — before submission, and the claim + * is released after the submission handoff succeeds so the message STAYS + * queued: removal is tied to ask-consumption (Task.ask claims queued + * messages and removes them via handleQueuedAskResponse, or hands them to + * persistQueuedFeedbackAndAcknowledge), not to submit-return. A message + * submitted between command completion and the next conversational ask is + * therefore retained until an ask actually consumes it, instead of being + * dropped if the turn fails or restarts first. When submission fails, the * claim is released so the message stays queued for a later drain, and the * failure propagates to the caller instead of being logged and dropped. * @@ -5439,7 +5477,7 @@ export class Task extends EventEmitter implements TaskLike { * drains. * * @returns Promise resolving to true when a queued message was submitted - * and durably removed; false when the queue was empty. + * (and remains queued until consumed); false when the queue was empty. */ public processQueuedMessages(): Promise { const run = this.queuedMessageDrainChain.then(() => this.claimAndSubmitNextQueuedMessage()) @@ -5467,7 +5505,18 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueService.releaseMessage(queued.id) throw error } - this.messageQueueService.removeMessage(queued.id) + // Submission succeeded, but the message is only removed once an ask + // consumes it. Snapshot the posted response so the consuming ask can + // tell interception (consumption → remove) from an unconsumed + // overwrite (retain); release the claim so the ask path can claim it. + // The snapshot mirrors submitUserMessage's normalization (trim, images + // default) so the interception match compares post-trim values. + this.pendingSubmittedQueuedMessage = { + id: queued.id, + text: queued.text.trim(), + images: queued.images ?? [], + } + this.messageQueueService.releaseMessage(queued.id) return true } } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index d68903c1fd..8f1d934d8a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -48,6 +48,7 @@ type TaskTestAccess = { getFilesReadByRooSafely: (context: string) => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise resetAssistantMessagePersistence: () => void + checkpointSave: (force?: boolean, suppressMessage?: boolean) => Promise buildCleanConversationHistory: ( messages: ApiMessage[], requestModelInfo: ModelInfo, @@ -5880,6 +5881,11 @@ describe("Queued message processing after condense", () => { await task.condenseContext() expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"]) + // Submission does not remove: the message stays queued until an ask + // consumes it. + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued text"]) + const result = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(result.text).toBe("queued text") expect(task.messageQueueService.isEmpty()).toBe(true) }) @@ -5920,7 +5926,16 @@ describe("Queued message processing after condense", () => { await taskB.condenseContext() expect(spyB).toHaveBeenCalledWith("B message", undefined) + // Drains submit but do not remove; each task retains its own message + // until an ask consumes it. + expect(taskA.messageQueueService.messages.map((message) => message.text)).toEqual(["A message"]) + expect(taskB.messageQueueService.messages.map((message) => message.text)).toEqual(["B message"]) + + const resultB = await taskB.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(resultB.text).toBe("B message") expect(taskB.messageQueueService.isEmpty()).toBe(true) + // Consuming B's message must not touch A's queue. + expect(taskA.messageQueueService.messages.map((message) => message.text)).toEqual(["A message"]) }) describe("processQueuedMessages drain semantics", () => { @@ -5932,14 +5947,39 @@ describe("Queued message processing after condense", () => { startTask: false, }) - it("submits and durably removes the next queued message", async () => { + it("submits the next queued message and retains it until an ask consumes it", async () => { const task = createQueueTask() - const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(true) + vi.spyOn(getTaskTestAccess(task), "checkpointSave").mockResolvedValue(undefined) task.messageQueueService.addMessage("queued text", ["img1.png"]) await expect(task.processQueuedMessages()).resolves.toBe(true) - expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"]) + // The real submit succeeded, but removal is tied to ask-consumption: + // the message stays queued until an ask claims it. + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued text"]) + + const result = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + + expect(result).toMatchObject({ response: "yesButtonClicked", text: "queued text", images: ["img1.png"] }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("delivers queued feedback through the next conversational ask instead of losing it", async () => { + const task = createQueueTask() + vi.spyOn(getTaskTestAccess(task), "checkpointSave").mockResolvedValue(undefined) + task.messageQueueService.addMessage("one more change") + + // Command completion drain: the real submit succeeds and the webview + // shows the feedback, but no ask has consumed the response yet. + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["one more change"]) + + // The next conversational ask discards the unconsumed pending response + // at its start; the retained queued message must survive that discard + // and be consumed by the ask instead of being dropped. + const result = await task.ask("followup", "Anything else?", false) + + expect(result).toMatchObject({ response: "messageResponse", text: "one more change" }) expect(task.messageQueueService.isEmpty()).toBe(true) }) @@ -5975,6 +6015,7 @@ describe("Queued message processing after condense", () => { it("serializes concurrent drains so a blocked submission holds the next message queued", async () => { const task = createQueueTask() + vi.spyOn(getTaskTestAccess(task), "checkpointSave").mockResolvedValue(undefined) task.messageQueueService.addMessage("first") task.messageQueueService.addMessage("second") @@ -6002,14 +6043,23 @@ describe("Queued message processing after condense", () => { await expect(firstDrain).resolves.toBe(true) await expect(secondDrain).resolves.toBe(true) - // Both messages are delivered in queue order. + // The second drain waited for the first, then re-claimed the + // retained head message (submit is idempotent for the same content); + // the next queued message was still not submitted early. expect(submitSpy).toHaveBeenCalledTimes(2) - expect(submitSpy).toHaveBeenNthCalledWith(2, "second", undefined) + expect(submitSpy).toHaveBeenNthCalledWith(2, "first", undefined) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["first", "second"]) + + const firstResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(firstResult).toMatchObject({ response: "yesButtonClicked", text: "first" }) + const secondResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(secondResult).toMatchObject({ response: "yesButtonClicked", text: "second" }) expect(task.messageQueueService.isEmpty()).toBe(true) }) it("does not let a failed drain block later drains", async () => { const task = createQueueTask() + vi.spyOn(getTaskTestAccess(task), "checkpointSave").mockResolvedValue(undefined) vi.spyOn(task, "submitUserMessage").mockRejectedValueOnce(new Error("emit failed")).mockResolvedValue(true) task.messageQueueService.addMessage("retry me") task.messageQueueService.addMessage("still deliverable") @@ -6019,6 +6069,15 @@ describe("Queued message processing after condense", () => { await expect(task.processQueuedMessages()).resolves.toBe(true) await expect(task.processQueuedMessages()).resolves.toBe(true) + // The retried submissions stay queued until asks consume them in order. + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual([ + "retry me", + "still deliverable", + ]) + const firstResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(firstResult).toMatchObject({ response: "yesButtonClicked", text: "retry me" }) + const secondResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(secondResult).toMatchObject({ response: "yesButtonClicked", text: "still deliverable" }) expect(task.messageQueueService.isEmpty()).toBe(true) }) }) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index b137130174..cc1e926da0 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -1,4 +1,4 @@ -import { Task } from "../Task" +import { queuedImagesEqual, Task } from "../Task" type QueueTaskTestAccess = { say: Task["say"] @@ -6,6 +6,7 @@ type QueueTaskTestAccess = { addToClineMessages: () => Promise lastMessageTs?: number abort: boolean + queuedMessageDrainChain: Promise } const getQueueTaskTestAccess = (task: Task) => task as unknown as QueueTaskTestAccess @@ -13,6 +14,15 @@ const getQueueTaskTestAccess = (task: Task) => task as unknown as QueueTaskTestA // Keep this test focused: if a queued message arrives while Task.ask() is blocked, // it should be consumed and used to fulfill the ask. +describe("queuedImagesEqual", () => { + it("compares image arrays by content", () => { + expect(queuedImagesEqual([], [])).toBe(true) + expect(queuedImagesEqual(["a.png", "b.png"], ["a.png", "b.png"])).toBe(true) + expect(queuedImagesEqual(["a.png"], ["b.png"])).toBe(false) + expect(queuedImagesEqual(["a.png"], ["a.png", "b.png"])).toBe(false) + }) +}) + describe("Task.ask queued message drain", () => { function createTask(provider?: { getState: () => Promise> }) { const task = Object.create(Task.prototype) as Task @@ -24,6 +34,9 @@ describe("Task.ask queued message drain", () => { ;(task as any).lastMessageTs = undefined return import("../../message-queue/MessageQueueService").then(({ MessageQueueService }) => { ;(task as any).messageQueueService = new MessageQueueService() + // Object.create skips field initializers; the drain chain must exist + // for processQueuedMessages to schedule behind it. + getQueueTaskTestAccess(task).queuedMessageDrainChain = Promise.resolve() ;(task as any).addToClineMessages = vi.fn(async () => {}) ;(task as any).saveClineMessages = vi.fn(async () => {}) ;(task as any).updateClineMessage = vi.fn(async () => {}) @@ -48,6 +61,82 @@ describe("Task.ask queued message drain", () => { expect(result.text).toBe("picked answer") }) + it("removes a drained padded message when its submission intercepts a blocked ask", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + // editQueuedMessage saves untrimmed text; submitUserMessage trims before + // posting, so the interception match must compare post-trim values. + task.messageQueueService.addMessage(" padded correction ") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + + expect(result).toMatchObject({ response: "messageResponse", text: "padded correction" }) + expect(task.messageQueueService.isEmpty()).toBe(true) + + setTimeout(() => task.approveAsk(), 0) + const nextResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(nextResult).toMatchObject({ response: "yesButtonClicked", text: undefined }) + }) + + it("removes a drained message when its submission intercepts a blocked ask", async () => { + const task = await createTask({ getState: async () => ({}) }) + + // Park a tool ask in the real pWaitFor: no auto-approval and nothing + // queued at ask start, so it blocks. + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + // Let the ask reach its pWaitFor before the drain runs. + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Background-completion style drain while the ask is blocked: the real + // submit posts the message into the pending ask-response slot. + task.messageQueueService.addMessage("queued correction") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + + // Interception: the blocked tool ask is answered with the submitted + // message (the claim path would have answered yesButtonClicked). + expect(result).toMatchObject({ response: "messageResponse", text: "queued correction" }) + // Interception is consumption: the message is removed, not retained for + // a second delivery at the next ask. + expect(task.messageQueueService.isEmpty()).toBe(true) + + setTimeout(() => task.approveAsk(), 0) + const nextResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(nextResult).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("retains a drained message when a user response overwrites it before consumption", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + // The drain posts the message into the pending slot, but the user + // answers the blocked ask directly before any ask consumed it. + task.messageQueueService.addMessage("queued correction") + await task.processQueuedMessages() + setTimeout(() => task.approveAsk(), 0) + + const result = await askPromise + + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + // The overwritten submission was not consumed, so the message stays + // queued for a later ask instead of being removed or lost. + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued correction"]) + + const nextResult = await task.ask("followup", "Q?", false) + expect(nextResult).toMatchObject({ response: "messageResponse", text: "queued correction" }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + it("does not consume queued messages for command_output asks", async () => { const task = await createTask() From 6bc900ede17c08556dfda6deadd8b4c326fa9e6f Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 01:40:39 +0900 Subject: [PATCH 12/51] fix(task): consume queued messages by identity with durable acks What: Track the queued message ID on the ask-response slot (set by drain submissions, cleared by direct responses) and match that ID in Task.ask instead of response text/images. A consumed interception hands the queued message ID to the caller and the queue entry is removed only after persistQueuedFeedbackAndAcknowledge saves the feedback durably; a failed or thrown write releases the message back to the queue. addToClineMessages/say propagate the save result instead of swallowing it, and drains skip re-posting a message already pending consumption so a second drain cannot overwrite a distinct pending response. Ask consumers (follow-up tool, resume path) ack queued answers through the durable path. Why: A direct response with identical text/images could consume a queued message it did not answer; a double drain could overwrite a pending response and lose it; and removing the queue entry before the history write meant a failed save lost the message from both the queue and the conversation history. Impact: Exactly-once delivery is preserved (interception counts as consumption, between-turns submissions stay queued) with removal tied to durable persistence; failed saves re-queue the message for redelivery. --- src/core/task/Task.ts | 164 ++++++++++++------ .../task/__tests__/Task.persistence.spec.ts | 6 +- src/core/task/__tests__/Task.spec.ts | 75 +++++--- .../task/__tests__/ask-allowlist-cwd.spec.ts | 2 +- .../ask-queued-message-drain.spec.ts | 164 ++++++++++++++++-- .../task/__tests__/grace-retry-errors.spec.ts | 8 +- src/core/tools/AskFollowupQuestionTool.ts | 13 +- .../__tests__/CodebaseSearchTool.spec.ts | 2 +- .../CodebaseSearchTool.workspace.spec.ts | 2 +- .../__tests__/askFollowupQuestionTool.spec.ts | 43 ++++- 10 files changed, 372 insertions(+), 107 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3d39f04b01..1971de283f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -181,10 +181,6 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio return { response: "messageResponse", requiresDurableAck: type === "completion_result" } } -export function queuedImagesEqual(a: string[], b: string[]): boolean { - return a.length === b.length && a.every((value, index) => value === b[index]) -} - 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 @@ -373,6 +369,11 @@ export class Task extends EventEmitter implements TaskLike { private askResponse?: ClineAskResponse private askResponseText?: string private askResponseImages?: string[] + // ID of the queued message that produced the current ask-response slot + // value (set only when a queued-message drain submitted it). Direct + // responses clear it, so Task.ask can tie consumption to message identity + // instead of matching response text/images. + private askResponseQueuedMessageId?: string public lastMessageTs?: number private autoApprovalTimeoutRef?: NodeJS.Timeout @@ -422,13 +423,13 @@ export class Task extends EventEmitter implements TaskLike { // slot (which would drop the earlier response after both messages were // already removed from the queue). private queuedMessageDrainChain: Promise = Promise.resolve() - // Snapshot of the last drain-submitted queued message. A successful submit - // only posts into the pending ask-response slot; the message stays queued - // until an ask actually consumes that response. The consuming ask matches - // the returned response against this snapshot to decide whether the - // submission was consumed (interception → remove) or overwritten - // unconsumed (retain for a later ask). - private pendingSubmittedQueuedMessage: { id: string; text: string; images: string[] } | undefined + // ID of the last drain-submitted queued message. A successful submit only + // posts into the pending ask-response slot (which records this ID); the + // message stays queued until an ask consumes that response. The consuming + // ask matches the slot's recorded ID against this one to decide whether the + // submission was consumed (hand the ID to the caller for a durable ack) or + // overwritten unconsumed (retain for a later ask). + private pendingSubmittedQueuedMessageId: string | undefined // Streaming isWaitingForFirstChunk = false @@ -962,7 +963,15 @@ export class Task extends EventEmitter implements TaskLike { text?: string, images?: string[], ): Promise { - await this.say("user_feedback", text ?? "", images) + try { + await this.say("user_feedback", text ?? "", images) + } catch (error) { + // A failed write must not leave the message claimed: release it so a + // later drain can redeliver it. (No-op when the drain path already + // released the claim.) + this.messageQueueService.releaseMessage(messageId) + throw error + } for (let attempt = 0; attempt <= QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length; attempt++) { if (this.abort) { this.messageQueueService.releaseMessage(messageId) @@ -1303,7 +1312,8 @@ export class Task extends EventEmitter implements TaskLike { return readTaskMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) } - private async addToClineMessages(message: ClineMessage) { + /** Appends a message, posts it, and persists. @returns whether the history save succeeded. */ + private async addToClineMessages(message: ClineMessage): Promise { message.messageId ??= crypto.randomUUID() this.clineMessages.push(message) const provider = this.providerRef.deref() @@ -1323,7 +1333,7 @@ export class Task extends EventEmitter implements TaskLike { } } this.emit(RooCodeEventName.Message, { action: "created", message }) - await this.saveClineMessages() + const saved = await this.saveClineMessages() const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() @@ -1335,6 +1345,8 @@ export class Task extends EventEmitter implements TaskLike { // Track that this message has been synced to cloud this.cloudSyncedMessageTimestamps.add(message.ts) } + + return saved } /** @@ -1530,6 +1542,7 @@ export class Task extends EventEmitter implements TaskLike { this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined // Bug for the history books: // In the webview we use the ts as the chatrow key for the @@ -1563,6 +1576,7 @@ export class Task extends EventEmitter implements TaskLike { this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ @@ -1581,6 +1595,7 @@ export class Task extends EventEmitter implements TaskLike { this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ @@ -1706,36 +1721,44 @@ export class Task extends EventEmitter implements TaskLike { throw new AskIgnoredError("superseded") } - const result = { - response: this.askResponse!, - text: this.askResponseText, - images: this.askResponseImages, - queuedMessageId, - } - // Tie a drain-submitted queued message to actual consumption. The ask - // consumed the submission only if it returned the posted response from - // the pending slot: the claim path is excluded (durable flows carry - // queuedMessageId for later persistence; non-durable flows already - // removed the message inline). - const pendingSubmitted = this.pendingSubmittedQueuedMessage - if (pendingSubmitted) { + // Tie a drain-submitted queued message to actual consumption by identity: + // the ask consumed the submission only if the pending slot still carries + // that message's ID. A direct response clears the slot ID even when its + // text/images are identical, so it cannot consume the queue entry. The + // claim path is excluded (durable flows already carry queuedMessageId; + // non-durable flows removed the message inline). + if (this.pendingSubmittedQueuedMessageId) { const consumedViaPendingSlot = - result.queuedMessageId === undefined && - result.response === "messageResponse" && - result.text === pendingSubmitted.text && - queuedImagesEqual(result.images ?? [], pendingSubmitted.images) + queuedMessageId === undefined && + this.askResponseQueuedMessageId === this.pendingSubmittedQueuedMessageId if (consumedViaPendingSlot) { - this.messageQueueService.removeMessage(pendingSubmitted.id) - this.pendingSubmittedQueuedMessage = undefined - } else if (!this.messageQueueService.messages.some((message) => message.id === pendingSubmitted.id)) { + // Hand the ID to the caller instead of removing inline: the queue + // entry is deleted only after the feedback is durably saved + // (persistQueuedFeedbackAndAcknowledge), so a failed history write + // cannot lose a message that was already dequeued. + queuedMessageId = this.pendingSubmittedQueuedMessageId + this.pendingSubmittedQueuedMessageId = undefined + } else if ( + !this.messageQueueService.messages.some( + (message) => message.id === this.pendingSubmittedQueuedMessageId, + ) + ) { // The message was consumed via the ask claim path or discarded // by an existing path; the tracker is stale, so clear it. - this.pendingSubmittedQueuedMessage = undefined + this.pendingSubmittedQueuedMessageId = undefined } } + + const result = { + response: this.askResponse!, + text: this.askResponseText, + images: this.askResponseImages, + queuedMessageId, + } this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined // Cancel the timeouts if they are still running. timeouts.forEach((timeout) => clearTimeout(timeout)) @@ -1752,13 +1775,19 @@ export class Task extends EventEmitter implements TaskLike { return result } - handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + handleWebviewAskResponse( + askResponse: ClineAskResponse, + text?: string, + images?: string[], + sourceQueuedMessageId?: string, + ) { // Clear any pending auto-approval timeout when user responds this.cancelAutoApprovalTimeout() this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images + this.askResponseQueuedMessageId = sourceQueuedMessageId // Create a checkpoint whenever the user sends a message. // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. @@ -1841,6 +1870,10 @@ export class Task extends EventEmitter implements TaskLike { /** * Submit a user message through the ask-response channel. * + * @param sourceQueuedMessageId - ID of the durable queue message being + * submitted, when this submission drains the queue. Recorded on the + * ask-response slot so the consuming ask can tie consumption to message + * identity; direct (non-queue) submissions leave it undefined. * @returns true when the message was handed to the ask-response channel; * false when there was nothing to submit or the handoff failed (the failure * is logged either way). Callers draining a durable queue must check this. @@ -1850,6 +1883,7 @@ export class Task extends EventEmitter implements TaskLike { images?: string[], mode?: string, providerProfile?: string, + sourceQueuedMessageId?: string, ): Promise { try { text = (text ?? "").trim() @@ -1884,7 +1918,7 @@ export class Task extends EventEmitter implements TaskLike { // Handle the message directly instead of routing through the webview. // This avoids a race condition where the webview's message state hasn't // hydrated yet, causing it to interpret the message as a new task request. - this.handleWebviewAskResponse("messageResponse", text, images) + this.handleWebviewAskResponse("messageResponse", text, images, sourceQueuedMessageId) return true } else { console.error("[Task#submitUserMessage] Provider reference lost") @@ -2066,7 +2100,7 @@ export class Task extends EventEmitter implements TaskLike { } = {}, contextCondense?: ContextCondense, contextTruncation?: ContextTruncation, - ): Promise { + ): Promise { if (this.abort) { throw new Error(`[RooCode#say] task ${this.taskId}.${this.instanceId} aborted`) } @@ -2091,6 +2125,7 @@ export class Task extends EventEmitter implements TaskLike { this.updateClineMessage(lastMessage).catch((error) => { console.error("[Task#say] updateClineMessage failed:", error) }) + return true } else { // This is a new partial message, so add it with partial state. const sayTs = Date.now() @@ -2099,7 +2134,7 @@ export class Task extends EventEmitter implements TaskLike { this.lastMessageTs = sayTs } - await this.addToClineMessages({ + return this.addToClineMessages({ ts: sayTs, type: "say", say: type, @@ -2126,7 +2161,7 @@ export class Task extends EventEmitter implements TaskLike { // Instead of streaming partialMessage events, we do a save // and post like normal to persist to disk. - await this.saveClineMessages() + const saved = await this.saveClineMessages() // More performant than an entire `postStateToWebview`. // Fire-and-forget: see updateClineMessage call above for the @@ -2134,6 +2169,7 @@ export class Task extends EventEmitter implements TaskLike { this.updateClineMessage(lastMessage).catch((error) => { console.error("[Task#say] updateClineMessage failed:", error) }) + return saved } else { // This is a new and complete message, so add it like normal. const sayTs = Date.now() @@ -2142,7 +2178,7 @@ export class Task extends EventEmitter implements TaskLike { this.lastMessageTs = sayTs } - await this.addToClineMessages({ + return this.addToClineMessages({ ts: sayTs, type: "say", say: type, @@ -2165,7 +2201,7 @@ export class Task extends EventEmitter implements TaskLike { this.lastMessageTs = sayTs } - await this.addToClineMessages({ + return this.addToClineMessages({ ts: sayTs, type: "say", say: type, @@ -2459,13 +2495,22 @@ export class Task extends EventEmitter implements TaskLike { this.isInitialized = true - const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`. + const { response, text, images, queuedMessageId } = await this.ask(askType) // Calls `postStateToWebview`. let responseText: string | undefined let responseImages: string[] | undefined if (response === "messageResponse") { - await this.say("user_feedback", text, images) + if (queuedMessageId) { + const persisted = await this.persistQueuedFeedbackAndAcknowledge(queuedMessageId, text, images) + if (!persisted) { + throw new Error( + `[Task#resumeTaskFromHistory] Failed to persist queued feedback ${queuedMessageId}`, + ) + } + } else { + await this.say("user_feedback", text, images) + } responseText = text responseImages = images } @@ -5473,8 +5518,11 @@ export class Task extends EventEmitter implements TaskLike { * Drains are serialized per task: each run claims only after the previous * run's submission handoff completed, so the background-completion drain * and the post-result drain cannot interleave two different messages into - * the single pending ask-response. A rejected drain does not block later - * drains. + * the single pending ask-response. A drain whose claimed message is still + * pending consumption (already submitted, not yet observed by an ask) does + * not re-post it; it releases the claim and resolves true, leaving the + * message queued so a distinct pending response cannot be overwritten. A + * rejected drain does not block later drains. * * @returns Promise resolving to true when a queued message was submitted * (and remains queued until consumed); false when the queue was empty. @@ -5495,8 +5543,16 @@ export class Task extends EventEmitter implements TaskLike { if (!queued) { return false } + // An earlier drain already submitted this message and no ask has + // consumed it yet: re-posting would overwrite a distinct pending + // response and lose it, so release the claim and keep the message + // queued for the ask. + if (this.pendingSubmittedQueuedMessageId === queued.id) { + this.messageQueueService.releaseMessage(queued.id) + return true + } try { - const submitted = await this.submitUserMessage(queued.text, queued.images) + const submitted = await this.submitUserMessage(queued.text, queued.images, undefined, undefined, queued.id) if (!submitted) { throw new Error(`[Task] Failed to submit queued message ${queued.id}`) } @@ -5506,16 +5562,10 @@ export class Task extends EventEmitter implements TaskLike { throw error } // Submission succeeded, but the message is only removed once an ask - // consumes it. Snapshot the posted response so the consuming ask can - // tell interception (consumption → remove) from an unconsumed - // overwrite (retain); release the claim so the ask path can claim it. - // The snapshot mirrors submitUserMessage's normalization (trim, images - // default) so the interception match compares post-trim values. - this.pendingSubmittedQueuedMessage = { - id: queued.id, - text: queued.text.trim(), - images: queued.images ?? [], - } + // consumes it. Track the ID so the consuming ask can tell interception + // (consumption → durable ack) from an unconsumed overwrite (retain), + // and release the claim so the ask path can claim it. + this.pendingSubmittedQueuedMessageId = queued.id this.messageQueueService.releaseMessage(queued.id) return true } diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 8d3314a9a6..c64da4652c 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -523,7 +523,7 @@ describe("Task persistence", () => { let saving: Promise | undefined try { - vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(task, "say").mockResolvedValue(true) vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked", text: "", images: [] }) vi.spyOn(task, "emitFinalTokenUsageUpdate").mockImplementation(() => undefined) vi.spyOn(task, "flushTelemetryInstallment").mockImplementation(() => undefined) @@ -618,7 +618,7 @@ describe("Task persistence", () => { toolDescription: vi.fn(), toolCallId: completionCallId, } - vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(task, "say").mockResolvedValue(true) vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked", text: "", images: [] }) vi.spyOn(task, "emitFinalTokenUsageUpdate").mockImplementation(() => undefined) vi.spyOn(task, "flushTelemetryInstallment").mockImplementation(() => undefined) @@ -688,7 +688,7 @@ describe("Task persistence", () => { toolDescription: vi.fn(), toolCallId: completionCallId, } - vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(task, "say").mockResolvedValue(true) vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked", text: "", images: [] }) vi.spyOn(task, "emitFinalTokenUsageUpdate").mockImplementation(() => undefined) vi.spyOn(task, "flushTelemetryInstallment").mockImplementation(() => undefined) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 8f1d934d8a..444f469dcd 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1361,7 +1361,7 @@ describe("Cline", () => { startTask: false, }) - const saySpy = vi.spyOn(cline, "say").mockResolvedValue(undefined) + const saySpy = vi.spyOn(cline, "say").mockResolvedValue(true) // relPath provided -> the "...WithPath" message branch. const withPath = await cline.sayAndCreateMissingParamError("read_file", "path", "src/foo.ts") @@ -2484,7 +2484,12 @@ describe("Cline", () => { await task.submitUserMessage("test message", ["image1.png"]) // Verify handleWebviewAskResponse was called directly (not webview) - expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "test message", ["image1.png"]) + expect(handleResponseSpy).toHaveBeenCalledWith( + "messageResponse", + "test message", + ["image1.png"], + undefined, + ) // Should NOT route through webview anymore expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) @@ -2579,7 +2584,7 @@ describe("Cline", () => { task.clineMessages = [] await task.submitUserMessage("new task", ["image1.png"]) - expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "new task", ["image1.png"]) + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "new task", ["image1.png"], undefined) // Clear mock handleResponseSpy.mockClear() @@ -2595,7 +2600,12 @@ describe("Cline", () => { ] await task.submitUserMessage("follow-up message", ["image2.png"]) - expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "follow-up message", ["image2.png"]) + expect(handleResponseSpy).toHaveBeenCalledWith( + "messageResponse", + "follow-up message", + ["image2.png"], + undefined, + ) }) it("should handle undefined provider gracefully", async () => { @@ -2641,7 +2651,12 @@ describe("Cline", () => { const submitted = await task.submitUserMessage("test message", ["image1.png"]) expect(submitted).toBe(true) - expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "test message", ["image1.png"]) + expect(handleResponseSpy).toHaveBeenCalledWith( + "messageResponse", + "test message", + ["image1.png"], + undefined, + ) }) it("returns false when there is nothing to submit", async () => { @@ -2718,7 +2733,7 @@ describe("Cline", () => { const submitted = await task.submitUserMessage(undefined as unknown as string, ["image1.png"]) expect(submitted).toBe(true) - expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "", ["image1.png"]) + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "", ["image1.png"], undefined) }) }) }) @@ -2807,7 +2822,7 @@ describe("Cline", () => { ask: "resume_task" as const, } - await expect(taskAccess.addToClineMessages(message)).resolves.toBeUndefined() + await expect(taskAccess.addToClineMessages(message)).resolves.toBe(true) expect(consoleErrorSpy).toHaveBeenCalledWith( "[Task#addToClineMessages] postStateToWebviewThrottled failed:", @@ -3911,7 +3926,7 @@ describe("Cline", () => { totalTokensOut: 0, contextTokens: 0, }) - vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(task, "say").mockResolvedValue(true) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] @@ -4537,7 +4552,7 @@ describe("Cline", () => { // The early return this guards never reaches say; the spy only keeps // the aborted task's post-overwrite say from throwing before the // summarize/overwrite assertions can report a regression. - vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(task, "say").mockResolvedValue(true) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) // The summarizeConversation module mock is never cleared, so pin the // call count this condense starts from. @@ -4589,7 +4604,7 @@ describe("Cline", () => { // The early return this guards never reaches say; the spy only keeps // the aborted task's post-overwrite say from throwing before the // overwrite assertion can report the regression. - const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(true) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) // The summarizeConversation module mock is never cleared, so pin the // call count this condense starts from. @@ -4639,7 +4654,7 @@ describe("Cline", () => { { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") - vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(task, "say").mockResolvedValue(true) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) // Suspend inside the collector so the abort lands while the // summarization request cannot have started yet. @@ -4686,7 +4701,7 @@ describe("Cline", () => { { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") - vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(task, "say").mockResolvedValue(true) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) let releaseFilesRead!: (value: string[] | undefined) => void const filesReadGate = new Promise((resolve) => { @@ -5362,7 +5377,7 @@ describe("Cline", () => { expect(task.clineMessages).toEqual([]) await pendingPostState }) - const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(true) vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ enabledToolCount: 0, enabledServerCount: 0, @@ -5880,7 +5895,13 @@ describe("Queued message processing after condense", () => { await task.condenseContext() - expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"]) + expect(submitSpy).toHaveBeenCalledWith( + "queued text", + ["img1.png"], + undefined, + undefined, + task.messageQueueService.messages[0]?.id, + ) // Submission does not remove: the message stays queued until an ask // consumes it. expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued text"]) @@ -5918,14 +5939,26 @@ describe("Queued message processing after condense", () => { // Condense in task A should only drain A's queue await taskA.condenseContext() - expect(spyA).toHaveBeenCalledWith("A message", undefined) + expect(spyA).toHaveBeenCalledWith( + "A message", + undefined, + undefined, + undefined, + taskA.messageQueueService.messages[0]?.id, + ) expect(spyB).not.toHaveBeenCalled() expect(taskB.messageQueueService.isEmpty()).toBe(false) // Now condense in task B should drain B's queue await taskB.condenseContext() - expect(spyB).toHaveBeenCalledWith("B message", undefined) + expect(spyB).toHaveBeenCalledWith( + "B message", + undefined, + undefined, + undefined, + taskB.messageQueueService.messages[0]?.id, + ) // Drains submit but do not remove; each task retains its own message // until an ask consumes it. expect(taskA.messageQueueService.messages.map((message) => message.text)).toEqual(["A message"]) @@ -6036,18 +6069,16 @@ describe("Queued message processing after condense", () => { // second drain must not claim or submit the next message yet. await Promise.resolve() expect(submitSpy).toHaveBeenCalledTimes(1) - expect(submitSpy).toHaveBeenCalledWith("first", undefined) expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["first", "second"]) releaseFirstSubmission() await expect(firstDrain).resolves.toBe(true) await expect(secondDrain).resolves.toBe(true) - // The second drain waited for the first, then re-claimed the - // retained head message (submit is idempotent for the same content); - // the next queued message was still not submitted early. - expect(submitSpy).toHaveBeenCalledTimes(2) - expect(submitSpy).toHaveBeenNthCalledWith(2, "first", undefined) + // The second drain saw "first" still pending consumption and did not + // re-post it (a re-post could overwrite a distinct pending response); + // both messages stay queued until asks consume them in order. + expect(submitSpy).toHaveBeenCalledTimes(1) expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["first", "second"]) const firstResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) diff --git a/src/core/task/__tests__/ask-allowlist-cwd.spec.ts b/src/core/task/__tests__/ask-allowlist-cwd.spec.ts index 3b3682f969..3b130c21d8 100644 --- a/src/core/task/__tests__/ask-allowlist-cwd.spec.ts +++ b/src/core/task/__tests__/ask-allowlist-cwd.spec.ts @@ -29,7 +29,7 @@ function buildTask(provider: ProviderStub, taskCwd: string) { task["askResponseText"] = undefined task["askResponseImages"] = undefined task["lastMessageTs"] = undefined - task["addToClineMessages"] = vi.fn(async () => {}) + task["addToClineMessages"] = vi.fn(async () => true) task["saveClineMessages"] = vi.fn(async () => true) task["updateClineMessage"] = vi.fn(async () => {}) task["cancelAutoApprovalTimeout"] = vi.fn(() => {}) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index cc1e926da0..5fb0048440 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -1,4 +1,4 @@ -import { queuedImagesEqual, Task } from "../Task" +import { Task } from "../Task" type QueueTaskTestAccess = { say: Task["say"] @@ -14,15 +14,6 @@ const getQueueTaskTestAccess = (task: Task) => task as unknown as QueueTaskTestA // Keep this test focused: if a queued message arrives while Task.ask() is blocked, // it should be consumed and used to fulfill the ask. -describe("queuedImagesEqual", () => { - it("compares image arrays by content", () => { - expect(queuedImagesEqual([], [])).toBe(true) - expect(queuedImagesEqual(["a.png", "b.png"], ["a.png", "b.png"])).toBe(true) - expect(queuedImagesEqual(["a.png"], ["b.png"])).toBe(false) - expect(queuedImagesEqual(["a.png"], ["a.png", "b.png"])).toBe(false) - }) -}) - describe("Task.ask queued message drain", () => { function createTask(provider?: { getState: () => Promise> }) { const task = Object.create(Task.prototype) as Task @@ -61,14 +52,14 @@ describe("Task.ask queued message drain", () => { expect(result.text).toBe("picked answer") }) - it("removes a drained padded message when its submission intercepts a blocked ask", async () => { + it("acks a drained padded message through the consuming ask", async () => { const task = await createTask({ getState: async () => ({}) }) const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) await new Promise((resolve) => setTimeout(resolve, 150)) // editQueuedMessage saves untrimmed text; submitUserMessage trims before - // posting, so the interception match must compare post-trim values. + // posting, so the drained submission is the trimmed text. task.messageQueueService.addMessage(" padded correction ") const drain = task.processQueuedMessages() @@ -76,6 +67,15 @@ describe("Task.ask queued message drain", () => { await drain expect(result).toMatchObject({ response: "messageResponse", text: "padded correction" }) + // Interception is consumption, but removal is deferred to the durable + // ack: the entry stays queued until the history write succeeds. + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + expect(task.messageQueueService.isEmpty()).toBe(false) + + getQueueTaskTestAccess(task).saveClineMessages = vi.fn(async () => true) + await expect( + task.persistQueuedFeedbackAndAcknowledge(result.queuedMessageId!, result.text, result.images), + ).resolves.toBe(true) expect(task.messageQueueService.isEmpty()).toBe(true) setTimeout(() => task.approveAsk(), 0) @@ -83,7 +83,7 @@ describe("Task.ask queued message drain", () => { expect(nextResult).toMatchObject({ response: "yesButtonClicked", text: undefined }) }) - it("removes a drained message when its submission intercepts a blocked ask", async () => { + it("acks an intercepted drained message through the consuming ask", async () => { const task = await createTask({ getState: async () => ({}) }) // Park a tool ask in the real pWaitFor: no auto-approval and nothing @@ -103,8 +103,14 @@ describe("Task.ask queued message drain", () => { // Interception: the blocked tool ask is answered with the submitted // message (the claim path would have answered yesButtonClicked). expect(result).toMatchObject({ response: "messageResponse", text: "queued correction" }) - // Interception is consumption: the message is removed, not retained for - // a second delivery at the next ask. + // The entry stays queued until the consuming ask's durable ack removes it. + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + expect(task.messageQueueService.isEmpty()).toBe(false) + + getQueueTaskTestAccess(task).saveClineMessages = vi.fn(async () => true) + await expect( + task.persistQueuedFeedbackAndAcknowledge(result.queuedMessageId!, result.text, result.images), + ).resolves.toBe(true) expect(task.messageQueueService.isEmpty()).toBe(true) setTimeout(() => task.approveAsk(), 0) @@ -137,6 +143,134 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.isEmpty()).toBe(true) }) + it("does not consume a drained message when a direct response has identical text and images", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("same words", ["img.png"]) + await task.processQueuedMessages() + + // A direct user response with the exact same trimmed text and images + // lands before the ask observes the pending slot; identity, not + // content, decides consumption. + task.handleWebviewAskResponse("messageResponse", "same words", ["img.png"]) + + const result = await askPromise + + expect(result).toMatchObject({ response: "messageResponse", text: "same words", images: ["img.png"] }) + expect(result.queuedMessageId).toBeUndefined() + // The direct response did not consume the queue entry. + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["same words"]) + }) + + it("does not resubmit a drained message that is still pending consumption", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("queued correction") + await task.processQueuedMessages() + + // A direct response lands before the ask observes the pending slot. + task.handleWebviewAskResponse("yesButtonClicked") + + // The second drain (background-completion + post-result orchestration) + // must not re-post the retained message over the direct response. + const secondDrain = task.processQueuedMessages() + + const result = await askPromise + await secondDrain + + expect(submitSpy).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued correction"]) + + // The retained message is still deliverable to a later ask. + const nextResult = await task.ask("followup", "Q?", false) + expect(nextResult).toMatchObject({ response: "messageResponse", text: "queued correction" }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("retains an intercepted drained message until its history write succeeds", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("Keep this correction") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + + expect(result).toMatchObject({ response: "messageResponse", text: "Keep this correction" }) + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + + // A failed history write must keep the message queued; the ack retries + // and removes the entry only after the save succeeds. + const taskAccess = getQueueTaskTestAccess(task) + taskAccess.say = vi.fn().mockResolvedValue(true) + taskAccess.saveClineMessages = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) + + vi.useFakeTimers() + try { + const persistence = task.persistQueuedFeedbackAndAcknowledge( + result.queuedMessageId!, + result.text, + result.images, + ) + await vi.advanceTimersByTimeAsync(0) + expect(task.messageQueueService.isEmpty()).toBe(false) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["Keep this correction"]) + + await vi.advanceTimersByTimeAsync(250) + await expect(persistence).resolves.toBe(true) + expect(task.messageQueueService.isEmpty()).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("re-queues an intercepted drained message when its history write keeps failing", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("Do not lose me") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + + const taskAccess = getQueueTaskTestAccess(task) + taskAccess.say = vi.fn().mockResolvedValue(true) + taskAccess.saveClineMessages = vi.fn().mockResolvedValue(false) + + vi.useFakeTimers() + try { + const persistence = task.persistQueuedFeedbackAndAcknowledge( + result.queuedMessageId!, + result.text, + result.images, + ) + await vi.runAllTimersAsync() + + await expect(persistence).resolves.toBe(false) + expect(taskAccess.saveClineMessages).toHaveBeenCalledTimes(4) + // The message is released back to the queue for a later drain. + expect(task.messageQueueService.messages).toHaveLength(1) + expect(task.messageQueueService.claimNextMessage()?.text).toBe("Do not lose me") + } finally { + vi.useRealTimers() + } + }) + it("does not consume queued messages for command_output asks", async () => { const task = await createTask() diff --git a/src/core/task/__tests__/grace-retry-errors.spec.ts b/src/core/task/__tests__/grace-retry-errors.spec.ts index 3f72924f21..7fff4c8424 100644 --- a/src/core/task/__tests__/grace-retry-errors.spec.ts +++ b/src/core/task/__tests__/grace-retry-errors.spec.ts @@ -289,7 +289,7 @@ describe("Grace Retry Error Handling", () => { startTask: false, }) - const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(true) // Simulate first empty response - should NOT show error task.consecutiveNoAssistantMessagesCount = 0 @@ -313,7 +313,7 @@ describe("Grace Retry Error Handling", () => { startTask: false, }) - const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(true) // Simulate second consecutive empty response task.consecutiveNoAssistantMessagesCount = 1 @@ -337,7 +337,7 @@ describe("Grace Retry Error Handling", () => { startTask: false, }) - const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(true) // Simulate third consecutive empty response task.consecutiveNoAssistantMessagesCount = 2 @@ -409,7 +409,7 @@ describe("Grace Retry Error Handling", () => { startTask: false, }) - const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(true) // Simulate the error condition (2 consecutive failures) task.consecutiveNoAssistantMessagesCount = 2 diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index 3baea7ed52..3371321e78 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -70,9 +70,18 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { } task.consecutiveMistakeCount = 0 - const { text, images } = await task.ask("followup", JSON.stringify(follow_up_json), false) + const { text, images, queuedMessageId } = await task.ask("followup", JSON.stringify(follow_up_json), false) const safeText = text ?? "" - await task.say("user_feedback", safeText, images) + if (queuedMessageId) { + // The answer came from the durable queue: persist it and remove the + // queue entry only after the history write succeeds. + const persisted = await task.persistQueuedFeedbackAndAcknowledge(queuedMessageId, text, images) + if (!persisted) { + throw new Error(`Failed to persist queued follow-up feedback ${queuedMessageId}`) + } + } else { + await task.say("user_feedback", safeText, images) + } pushToolResult(formatResponse.toolResult(`\n${safeText}\n`, images)) } catch (error) { await handleError("asking question", error as Error) diff --git a/src/core/tools/__tests__/CodebaseSearchTool.spec.ts b/src/core/tools/__tests__/CodebaseSearchTool.spec.ts index 4b0a6ed01d..2afccba8fd 100644 --- a/src/core/tools/__tests__/CodebaseSearchTool.spec.ts +++ b/src/core/tools/__tests__/CodebaseSearchTool.spec.ts @@ -51,7 +51,7 @@ describe("CodebaseSearchTool", () => { sayAndCreateMissingParamError: vi .fn() .mockResolvedValue("missing query"), - say: vi.fn().mockResolvedValue(undefined), + say: vi.fn().mockResolvedValue(true), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } task = taskStub as Task diff --git a/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts b/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts index 5cfabb4c43..8700261222 100644 --- a/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts +++ b/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts @@ -74,7 +74,7 @@ describe("CodebaseSearchTool workspace selection", () => { cwd: second.uri.fsPath, providerRef: new WeakRef(provider), consecutiveMistakeCount: 0, - say: vi.fn().mockResolvedValue(undefined), + say: vi.fn().mockResolvedValue(true), } task = taskStub as Task callbacks = { diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index 9f081279ca..b9772d3368 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -23,7 +23,8 @@ describe("AskFollowupQuestionTool", () => { didToolFailInCurrentTurn: false, sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), ask: vi.fn().mockResolvedValue({ text: "User answer", images: [] }), - say: vi.fn().mockResolvedValue(undefined), + say: vi.fn().mockResolvedValue(true), + persistQueuedFeedbackAndAcknowledge: vi.fn().mockResolvedValue(true), } as unknown as Task mockCallbacks = { @@ -261,6 +262,46 @@ describe("AskFollowupQuestionTool", () => { expect(mockCallbacks.handleError).toHaveBeenCalledWith("asking question", error) }) + // ===== Queued answer ack tests ===== + + it("should persist and ack a queued answer instead of saying it", async () => { + const params = { question: "Which approach?", follow_up: [{ text: "Approach 1" }] } + vi.mocked(mockTask.ask).mockResolvedValue({ + response: "messageResponse", + text: "queued words", + images: [], + queuedMessageId: "queued-1", + }) + + await tool.execute(params, mockTask, mockCallbacks) + + expect(mockTask.persistQueuedFeedbackAndAcknowledge).toHaveBeenCalledWith("queued-1", "queued words", []) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + formatResponse.toolResult("\nqueued words\n", []), + ) + expect(mockCallbacks.handleError).not.toHaveBeenCalled() + }) + + it("should call handleError without pushing a tool result when the queued ack fails", async () => { + const params = { question: "Which approach?", follow_up: [{ text: "Approach 1" }] } + vi.mocked(mockTask.ask).mockResolvedValue({ + response: "messageResponse", + text: "queued words", + images: [], + queuedMessageId: "queued-1", + }) + vi.mocked(mockTask.persistQueuedFeedbackAndAcknowledge).mockResolvedValue(false) + + await tool.execute(params, mockTask, mockCallbacks) + + expect(mockCallbacks.handleError).toHaveBeenCalledWith( + "asking question", + new Error("Failed to persist queued follow-up feedback queued-1"), + ) + expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() + }) + // ===== handlePartial tests ===== it("should show question during partial streaming via handlePartial", async () => { From 14125eea25f68ff4020b0a4fa7b07e0d646c0077 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 01:41:18 +0900 Subject: [PATCH 13/51] test(tools): assert drain runs once after persisted output results What: Extend the truncated persisted-output completion test to assert processQueuedMessages() is called exactly once and after pushToolResult(). Why: The persisted-output branch returns commandSubmitted: true, enabling the post-result drain, but the focused test verified only the formatted result, leaving the orchestration drain unasserted. Impact: Locks the post-result queued-message drain ordering for the persisted-output completion path. --- src/core/tools/__tests__/executeCommandTool.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 92a7fcdb72..262fe3beb4 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -1288,6 +1288,12 @@ describe("executeCommandTool", () => { expect(result).toContain("Output (195.3KB) persisted. Artifact ID: exec-1.txt") expect(result).toContain(persisted.preview) expect(result).toContain("Use read_command_output tool to view full output if needed.") + // Truncated persisted output reports commandSubmitted: true, so the + // post-result drain runs exactly once, after the tool result. + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + expect(mockPushToolResult.mock.invocationCallOrder[0]).toBeLessThan( + mockCline.processQueuedMessages.mock.invocationCallOrder[0], + ) }) }) }) From 4860a9cae8d0efc98c6c579795dee030715387ca Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 02:20:19 +0900 Subject: [PATCH 14/51] fix(task): ack intercepted queued messages at every consuming ask What: Every Task.ask consumer now resolves a consumed queued message. Consumers that persist returned feedback route it through the new sayUserFeedbackAndAckQueued helper (durable ack: the queue entry is removed only after the feedback's history write succeeds); consumers that only inspect the button response drop it via discardConsumedQueuedMessage (removal without inventing a history write). Covered: ReadFileTool (batch, single, and per-file loop approvals), the mcp_tool_use askApproval and the tool-repetition ask in presentAssistantMessage, mistake_limit_reached, both api_req_failed gates, the auto-approval-limit callback, and EditFileTool's partial-row finalization ask. Why: Deferring interception removal to a durable ack left non-acking consumers with the message still queued, so a later drain or claim redelivered identical text into history and model input (at-least-once). The ask boundary cannot know whether a consumer will persist the text, so each consumer now declares its own resolution. Impact: Interception is delivered exactly once for every consumer while removal stays tied to a successful durable write (or none where no write happens); between-turns submissions stay retained and failed saves still re-queue the message. --- .../presentAssistantMessage.ts | 10 +-- src/core/task/Task.ts | 62 +++++++++++++++++-- .../ask-queued-message-drain.spec.ts | 55 ++++++++++++++++ src/core/tools/EditFileTool.ts | 5 +- src/core/tools/ReadFileTool.ts | 22 ++++--- src/core/tools/__tests__/editFileTool.spec.ts | 1 + src/core/tools/__tests__/readFileTool.spec.ts | 51 +++++++++++++-- 7 files changed, 180 insertions(+), 26 deletions(-) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 546c43c06c..3a8e92e910 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -215,7 +215,7 @@ export async function presentAssistantMessage(cline: Task) { progressStatus?: ToolProgressStatus, isProtected?: boolean, ) => { - const { response, text, images } = await cline.ask( + const { response, text, images, queuedMessageId } = await cline.ask( type, partialMessage, false, @@ -224,8 +224,8 @@ export async function presentAssistantMessage(cline: Task) { ) if (response !== "yesButtonClicked") { + await cline.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) if (text) { - await cline.say("user_feedback", text, images) pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images)) } else { pushToolResult(formatResponse.toolDenied()) @@ -237,8 +237,8 @@ export async function presentAssistantMessage(cline: Task) { // Store approval feedback to be merged into tool result (GitHub #10465) // Don't push it as a separate tool_result here - that would create duplicates. // The tool will call pushToolResult, which will merge the feedback into the actual result. + await cline.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) if (text) { - await cline.say("user_feedback", text, images) approvalFeedback = { text, images } } @@ -674,7 +674,7 @@ export async function presentAssistantMessage(cline: Task) { // If execution is not allowed, notify user and break. if (!repetitionCheck.allowExecution && repetitionCheck.askUser) { // Handle repetition similar to mistake_limit_reached pattern. - const { response, text, images } = await cline.ask( + const { response, text, images, queuedMessageId } = await cline.ask( repetitionCheck.askUser.messageKey as ClineAsk, repetitionCheck.askUser.messageDetail.replace("{toolName}", block.name), ) @@ -690,7 +690,7 @@ export async function presentAssistantMessage(cline: Task) { ) // Add user feedback to chat. - await cline.say("user_feedback", text, images) + await cline.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) } // Track tool repetition in telemetry via PostHog exception tracking and event. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1971de283f..823e3cc87f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -991,6 +991,43 @@ export class Task extends EventEmitter implements TaskLike { return false } + /** + * Persist the user feedback carried by an ask result. When the ask consumed + * a queued message (intercepted its drain submission), the queue entry is + * removed only after the feedback's history write succeeds; a failed write + * re-queues it. Consumers that surface returned feedback as user_feedback + * must call this instead of say("user_feedback", ...) so a consumed queued + * message is acked exactly once instead of being redelivered by a later + * drain or claim. + */ + public async sayUserFeedbackAndAckQueued( + text: string | undefined, + images: string[] | undefined, + queuedMessageId: string | undefined, + ): Promise { + if (queuedMessageId) { + const persisted = await this.persistQueuedFeedbackAndAcknowledge(queuedMessageId, text, images) + if (!persisted) { + throw new Error(`[Task] Failed to persist queued feedback ${queuedMessageId}`) + } + return + } + if (text || images?.length) { + await this.say("user_feedback", text ?? "", images) + } + } + + /** + * Drop a queued message whose response this ask consumed without persisting + * feedback (the consumer only inspected the button response, e.g. retry and + * approval gates). Removes the entry without inventing a history write. + */ + public discardConsumedQueuedMessage(queuedMessageId: string | undefined): void { + if (queuedMessageId) { + this.messageQueueService.removeMessage(queuedMessageId) + } + } + /** * Clears the pending action metadata after its durable result is saved. * Reconciles in-memory state with the task history store to avoid clearing a newer action. @@ -3107,7 +3144,7 @@ export class Task extends EventEmitter implements TaskLike { ), ) - const { response, text, images } = await this.ask( + const { response, text, images, queuedMessageId } = await this.ask( "mistake_limit_reached", t("common:errors.mistake_limit_guidance"), ) @@ -3120,7 +3157,7 @@ export class Task extends EventEmitter implements TaskLike { ], ) - await this.say("user_feedback", text, images) + await this.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) } this.consecutiveMistakeCount = 0 @@ -4253,10 +4290,13 @@ export class Task extends EventEmitter implements TaskLike { continue } else { // Prompt the user for retry decision - const { response } = await this.ask( + const { response, queuedMessageId } = await this.ask( "api_req_failed", "The model returned no assistant messages. This may indicate an issue with the API or the model's output.", ) + // Only the button response is inspected; a consumed queued + // message is dropped without inventing a history write. + this.discardConsumedQueuedMessage(queuedMessageId) if (response === "yesButtonClicked") { await this.say("api_req_retried") @@ -4912,7 +4952,13 @@ export class Task extends EventEmitter implements TaskLike { const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( state, this.combineMessages(this.clineMessages.slice(1)), - async (type, data) => this.ask(type, data), + async (type, data) => { + const result = await this.ask(type, data) + // The handler only inspects the button response; a consumed + // queued message is dropped without inventing a history write. + this.discardConsumedQueuedMessage(result.queuedMessageId) + return result + }, ) if (!approvalResult.shouldProceed) { @@ -5067,10 +5113,13 @@ export class Task extends EventEmitter implements TaskLike { return } else { - const { response } = await this.ask( + const { response, queuedMessageId } = await this.ask( "api_req_failed", error.message ?? JSON.stringify(serializeError(error), null, 2), ) + // Only the button response is inspected; a consumed queued + // message is dropped without inventing a history write. + this.discardConsumedQueuedMessage(queuedMessageId) if (response !== "yesButtonClicked") { // This will never happen since if noButtonClicked, we will @@ -5525,7 +5574,8 @@ export class Task extends EventEmitter implements TaskLike { * rejected drain does not block later drains. * * @returns Promise resolving to true when a queued message was submitted - * (and remains queued until consumed); false when the queue was empty. + * (and remains queued until consumed) or was already pending submission and + * left queued; false when the queue was empty. */ public processQueuedMessages(): Promise { const run = this.queuedMessageDrainChain.then(() => this.claimAndSubmitNextQueuedMessage()) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 5fb0048440..aa818fd4d1 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -271,6 +271,61 @@ describe("Task.ask queued message drain", () => { } }) + it("delivers an intercepted message exactly once when a consumer acks through the durable helper", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + + // ReadFileTool-style blocked approval ask: the queue is empty at ask + // start, so a drain that posts mid-block intercepts the ask. + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("user correction") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + + expect(result).toMatchObject({ response: "messageResponse", text: "user correction" }) + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + + // The consumer persists the feedback through the acking helper, which + // removes the queue entry only after the history write succeeds. + getQueueTaskTestAccess(task).saveClineMessages = vi.fn(async () => true) + await task.sayUserFeedbackAndAckQueued(result.text, result.images, result.queuedMessageId) + expect(task.messageQueueService.isEmpty()).toBe(true) + + // No later drain or claim may redeliver the consumed message. + await expect(task.processQueuedMessages()).resolves.toBe(false) + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + it("drops an intercepted message consumed without persisting feedback", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + + // api_req_failed-style gate: the consumer only inspects the button + // response, so the intercepted queued message is discarded, not acked. + const askPromise = task.ask("api_req_failed", "The model returned no assistant messages.", false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("queued note") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + + expect(result).toMatchObject({ response: "messageResponse", text: "queued note" }) + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + + task.discardConsumedQueuedMessage(result.queuedMessageId) + expect(task.messageQueueService.isEmpty()).toBe(true) + + // No redelivery: the next drain finds an empty queue. + await expect(task.processQueuedMessages()).resolves.toBe(false) + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + it("does not consume queued messages for command_output asks", async () => { const task = await createTask() diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 1f6eb188a4..18644de710 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -167,7 +167,10 @@ export class EditFileTool extends BaseTool<"edit_file"> { } // Finalize the existing partial tool ask row so the UI doesn't get stuck in a spinner state. - await task.ask("tool", JSON.stringify(sharedMessageProps), false).catch(() => {}) + const result = await task.ask("tool", JSON.stringify(sharedMessageProps), false).catch(() => undefined) + // The result is only used to finalize the row; a consumed queued + // message is dropped without inventing a history write. + task.discardConsumedQueuedMessage(result?.queuedMessageId) } const recordFailureForPathAndMaybeEscalate = async (relPath: string, formattedError: string): Promise => { diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..3d231467b5 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -446,15 +446,15 @@ export class ReadFileTool extends BaseTool<"read_file"> { }) const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) - const { response, text, images } = await task.ask("tool", completeMessage, false) + const { response, text, images, queuedMessageId } = await task.ask("tool", completeMessage, false) if (response === "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) + await task.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) filesToApprove.forEach((fr) => { updateFileResult(fr.path, { status: "approved", feedbackText: text, feedbackImages: images }) }) } else if (response === "noButtonClicked") { - if (text) await task.say("user_feedback", text, images) + await task.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) task.didRejectTool = true filesToApprove.forEach((fr) => { updateFileResult(fr.path, { @@ -465,7 +465,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { }) }) } else { - // Individual permissions + // Individual permissions. A queued message consumed here is free + // text, not a permissions payload, so drop it without a write. + task.discardConsumedQueuedMessage(queuedMessageId) try { const individualPermissions = JSON.parse(text || "{}") let hasAnyDenial = false @@ -515,10 +517,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { startLine, } satisfies ClineSayTool) - const { response, text, images } = await task.ask("tool", completeMessage, false) + const { response, text, images, queuedMessageId } = await task.ask("tool", completeMessage, false) if (response !== "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) + await task.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) task.didRejectTool = true updateFileResult(relPath, { status: "denied", @@ -527,7 +529,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { feedbackImages: images, }) } else { - if (text) await task.say("user_feedback", text, images) + await task.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) } } @@ -712,16 +714,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { reason: lineSnippet || undefined, } satisfies ClineSayTool) - const { response, text, images } = await task.ask("tool", completeMessage, false) + const { response, text, images, queuedMessageId } = await task.ask("tool", completeMessage, false) if (response !== "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) + await task.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) task.didRejectTool = true results.push(`File: ${relPath}\nStatus: Denied by user`) continue } - if (text) await task.say("user_feedback", text, images) + await task.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) try { // Check if the path is a directory diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index c578be586f..743e65215c 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -147,6 +147,7 @@ describe("editFileTool", () => { } mockTask.say = vi.fn().mockResolvedValue(undefined) mockTask.ask = vi.fn().mockResolvedValue(undefined) + mockTask.discardConsumedQueuedMessage = vi.fn() mockTask.recordToolError = vi.fn() mockTask.recordToolUsage = vi.fn() mockTask.processQueuedMessages = vi.fn().mockResolvedValue(undefined) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..0c37783d63 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -18,6 +18,7 @@ import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" import { formatResponse } from "../../prompts/responses" +import type { Task } from "../../task/Task" import { validateImageForProcessing, processImageFile, @@ -153,6 +154,8 @@ function createMockTask(options: MockTaskOptions = {}) { didRejectTool: false, ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }), say: vi.fn().mockResolvedValue(undefined), + sayUserFeedbackAndAckQueued: vi.fn().mockResolvedValue(undefined), + discardConsumedQueuedMessage: vi.fn(), sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing required parameter: path"), recordToolError: vi.fn(), rooIgnoreController: { @@ -630,7 +633,12 @@ describe("ReadFileTool", () => { await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Please be careful with this file", undefined) + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledWith( + "Please be careful with this file", + undefined, + undefined, + ) + expect(mockTask.say).not.toHaveBeenCalled() expect(formatResponse.toolApprovedWithFeedback).toHaveBeenCalledWith("Please be careful with this file") }) @@ -646,9 +654,42 @@ describe("ReadFileTool", () => { await readFileTool.execute({ path: "secrets.env" }, mockTask as any, callbacks) - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledWith( + "This file contains secrets", + undefined, + undefined, + ) + expect(mockTask.say).not.toHaveBeenCalled() expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") }) + + it("acks a queued message intercepted by the approval ask instead of leaving it queued", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "noButtonClicked", + text: "Do not read it", + images: undefined, + queuedMessageId: "queued-1", + }) + + await readFileTool.execute( + { path: "secrets.env" }, + // Double assertion: the file's `as any` budget is capped by + // eslint-suppressions.json and must not grow. + mockTask as unknown as Task, + callbacks, + ) + + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledExactlyOnceWith( + "Do not read it", + undefined, + "queued-1", + ) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockTask.discardConsumedQueuedMessage).not.toHaveBeenCalled() + }) }) describe("output structure", () => { @@ -936,7 +977,8 @@ describe("ReadFileTool", () => { await readFileTool.execute({ files: [{ path: "test.ts" }] } as any, mockTask as any, callbacks) - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Read carefully", undefined) + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledWith("Read carefully", undefined, undefined) + expect(mockTask.say).not.toHaveBeenCalled() }) it("should handle user feedback on denial in legacy format", async () => { @@ -951,7 +993,8 @@ describe("ReadFileTool", () => { await readFileTool.execute({ files: [{ path: "secret.ts" }] } as any, mockTask as any, callbacks) - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Not allowed", undefined) + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledWith("Not allowed", undefined, undefined) + expect(mockTask.say).not.toHaveBeenCalled() }) it("should handle truncation in legacy format when no line ranges", async () => { From 87642128bab686d208979a29fc08f93fb5ef6af7 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 03:14:04 +0900 Subject: [PATCH 15/51] fix(task): make queued-feedback persistence idempotent and drains cancellation-aware What: persistQueuedFeedbackAndAcknowledge now associates the queued message ID with the user_feedback row it appends (queuedFeedbackRows) and reconciles that same row on redelivery instead of appending a duplicate, so a metadata failure after the message-file write already succeeded (or a throw mid-save) cannot leave duplicate feedback rows when the released entry is redelivered; the association is recorded only after the row append succeeds. Drains now honour cancellation: claimAndSubmitNextQueuedMessage returns false when the task is aborted/abandoned before claiming, releases quietly when abort lands mid-submission, and submitUserMessage refuses the slot write (returning false) when the task is aborted, so no TaskUserMessage emission or checkpoint can start on a cancelled task; ExecuteCommandTool's background-completion drain checks the same guard before draining. Why: A partial save failure released the queue entry while its feedback row was already persisted, and redelivery appended the same feedback again. And during abortTask the abort flag is set before the awaited webview flush and queue disposal, so a drain could submit in that gap and start a checkpoint on the dying task. Impact: Queued feedback persists exactly once across redelivery; no queued message is submitted, emitted, or checkpointed after cancellation, while live-task drains are unchanged. --- src/core/task/Task.ts | 64 +++++++- .../ask-queued-message-drain.spec.ts | 139 +++++++++++++++++- src/core/tools/ExecuteCommandTool.ts | 7 + 3 files changed, 203 insertions(+), 7 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 823e3cc87f..94e093e079 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -430,6 +430,10 @@ export class Task extends EventEmitter implements TaskLike { // submission was consumed (hand the ID to the caller for a durable ack) or // overwritten unconsumed (retain for a later ask). private pendingSubmittedQueuedMessageId: string | undefined + // Association between a queued message ID and the user_feedback row its ack + // persisted. A redelivery after a partial save failure reconciles the same + // row instead of appending a duplicate feedback row. + private queuedFeedbackRows = new Map() // Streaming isWaitingForFirstChunk = false @@ -963,8 +967,33 @@ export class Task extends EventEmitter implements TaskLike { text?: string, images?: string[], ): Promise { + let row = this.queuedFeedbackRows.get(messageId) try { - await this.say("user_feedback", text ?? "", images) + if (row) { + // Redelivery after a partial save failure (e.g. the message file + // was written but metadata persistence failed): reconcile the same + // row instead of appending a duplicate feedback row. + row.text = text ?? "" + row.images = images + this.updateClineMessage(row).catch((error) => { + console.error("[Task#persistQueuedFeedbackAndAcknowledge] updateClineMessage failed:", error) + }) + } else { + row = { + ts: Date.now(), + type: "say", + say: "user_feedback", + text: text ?? "", + images, + } + // Mirrors say()'s interactive user_feedback append: bump + // lastMessageTs and let the retry loop below own persistence. + // The association is recorded only after the append succeeds so + // a redelivery can never reconcile a row that was never added. + this.lastMessageTs = row.ts + await this.addToClineMessages(row) + this.queuedFeedbackRows.set(messageId, row) + } } catch (error) { // A failed write must not leave the message claimed: release it so a // later drain can redeliver it. (No-op when the drain path already @@ -978,6 +1007,7 @@ export class Task extends EventEmitter implements TaskLike { return false } if (await this.saveClineMessages()) { + this.queuedFeedbackRows.delete(messageId) return this.messageQueueService.removeMessage(messageId) } if (attempt < QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length) { @@ -1950,6 +1980,15 @@ export class Task extends EventEmitter implements TaskLike { } } + // Cancellation guard immediately before the slot write: abort and + // dispose both set this flag, and the emit/checkpoint side effects + // below must never run on a cancelled task. The check is adjacent + // to the write so no cancellation can interleave (single-threaded). + if (this.abort || this.abandoned) { + console.error("[Task#submitUserMessage] Task aborted, dropping user message submission") + return false + } + this.emit(RooCodeEventName.TaskUserMessage, this.taskId) // Handle the message directly instead of routing through the webview. @@ -5589,6 +5628,13 @@ export class Task extends EventEmitter implements TaskLike { } private async claimAndSubmitNextQueuedMessage(): Promise { + // A cancelled task must not submit: abortTask sets this flag before its + // awaited webview flush, and dispose clears the queue afterwards, so this + // guard closes the window where a drain could otherwise post into a + // dying task. + if (this.abort || this.abandoned) { + return false + } const queued = this.messageQueueService.claimNextMessage() if (!queued) { return false @@ -5604,8 +5650,24 @@ export class Task extends EventEmitter implements TaskLike { try { const submitted = await this.submitUserMessage(queued.text, queued.images, undefined, undefined, queued.id) if (!submitted) { + if (this.abort || this.abandoned) { + // Cancelled mid-handoff: the guard in submitUserMessage + // dropped the write, so release quietly instead of failing + // the drain on a task that is already stopping. + this.messageQueueService.releaseMessage(queued.id) + return false + } throw new Error(`[Task] Failed to submit queued message ${queued.id}`) } + if (this.abort || this.abandoned) { + // The submission raced with cancellation: no consuming ask will + // run on a dying task, so do not track it as pending. + if (this.pendingSubmittedQueuedMessageId === queued.id) { + this.pendingSubmittedQueuedMessageId = undefined + } + this.messageQueueService.releaseMessage(queued.id) + return false + } } catch (error) { // Release the claim so a later drain can retry the message. this.messageQueueService.releaseMessage(queued.id) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index aa818fd4d1..8cdb545927 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -1,9 +1,14 @@ +import type { ClineMessage } from "@roo-code/types" + import { Task } from "../Task" type QueueTaskTestAccess = { say: Task["say"] saveClineMessages: () => Promise - addToClineMessages: () => Promise + addToClineMessages: (message?: ClineMessage) => Promise + updateClineMessage: (message?: ClineMessage) => Promise + clineMessages: ClineMessage[] + queuedFeedbackRows: Map lastMessageTs?: number abort: boolean queuedMessageDrainChain: Promise @@ -28,6 +33,9 @@ describe("Task.ask queued message drain", () => { // Object.create skips field initializers; the drain chain must exist // for processQueuedMessages to schedule behind it. getQueueTaskTestAccess(task).queuedMessageDrainChain = Promise.resolve() + // Object.create skips field initializers; the drain chain and the + // feedback-row association map must exist for their paths. + getQueueTaskTestAccess(task).queuedFeedbackRows = new Map() ;(task as any).addToClineMessages = vi.fn(async () => {}) ;(task as any).saveClineMessages = vi.fn(async () => {}) ;(task as any).updateClineMessage = vi.fn(async () => {}) @@ -326,6 +334,121 @@ describe("Task.ask queued message drain", () => { expect(submitSpy).toHaveBeenCalledTimes(1) }) + it("keeps exactly one feedback row when a redelivery follows a partial save failure", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("dedupe me") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + const messageId = result.queuedMessageId! + + const taskAccess = getQueueTaskTestAccess(task) + taskAccess.addToClineMessages = async (message) => { + taskAccess.clineMessages.push(message!) + return true + } + // The messages-file write succeeds but metadata persistence fails, so + // saveClineMessages reports false and the entry is released queued. + const saveClineMessages = vi.fn().mockResolvedValue(false) + taskAccess.saveClineMessages = saveClineMessages + + vi.useFakeTimers() + try { + const first = task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images) + await vi.runAllTimersAsync() + await expect(first).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + expect(task.messageQueueService.messages).toHaveLength(1) + expect(taskAccess.clineMessages.filter((message) => message.say === "user_feedback")).toHaveLength(1) + + // Redelivery: the retained entry is acked again and the reconciled + // attempt must update the same row instead of appending a duplicate. + saveClineMessages.mockResolvedValue(true) + await expect(task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images)).resolves.toBe( + true, + ) + const rows = taskAccess.clineMessages.filter((message) => message.say === "user_feedback") + expect(rows).toHaveLength(1) + expect(rows[0].text).toBe("dedupe me") + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("does not submit queued messages once the task is aborted", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + task.messageQueueService.addMessage("too late") + getQueueTaskTestAccess(task).abort = true + + await expect(task.processQueuedMessages()).resolves.toBe(false) + + expect(submitSpy).not.toHaveBeenCalled() + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["too late"]) + // The ask-response slot stays empty: no emission, no checkpoint. + expect(task["askResponse"]).toBeUndefined() + }) + + it("drops the drain quietly when abort lands mid-submission", async () => { + const task = await createTask({ getState: async () => ({}) }) + task.messageQueueService.addMessage("too late") + const realSubmit = task.submitUserMessage.bind(task) + vi.spyOn(task, "submitUserMessage").mockImplementation((...args) => { + getQueueTaskTestAccess(task).abort = true + return realSubmit(...args) + }) + + await expect(task.processQueuedMessages()).resolves.toBe(false) + + expect(task.messageQueueService.messages).toHaveLength(1) + expect(task["askResponse"]).toBeUndefined() + }) + + describe("sayUserFeedbackAndAckQueued", () => { + it("delegates to the durable ack when a queued message was consumed", async () => { + const task = await createTask() + const persist = vi.spyOn(task, "persistQueuedFeedbackAndAcknowledge").mockResolvedValue(true) + const say = vi.spyOn(task, "say") + + await task.sayUserFeedbackAndAckQueued("words", ["img.png"], "queued-1") + + expect(persist).toHaveBeenCalledExactlyOnceWith("queued-1", "words", ["img.png"]) + expect(say).not.toHaveBeenCalled() + }) + + it("throws when the durable ack fails", async () => { + const task = await createTask() + vi.spyOn(task, "persistQueuedFeedbackAndAcknowledge").mockResolvedValue(false) + + await expect(task.sayUserFeedbackAndAckQueued("words", undefined, "queued-1")).rejects.toThrow( + "Failed to persist queued feedback queued-1", + ) + }) + + it("says user feedback without a queued message", async () => { + const task = await createTask() + const say = vi.spyOn(task, "say").mockResolvedValue(true) + + await task.sayUserFeedbackAndAckQueued("direct words", undefined, undefined) + + expect(say).toHaveBeenCalledExactlyOnceWith("user_feedback", "direct words", undefined) + }) + + it("skips saying when there is no feedback content and no queued message", async () => { + const task = await createTask() + const say = vi.spyOn(task, "say") + + await task.sayUserFeedbackAndAckQueued(undefined, undefined, undefined) + + expect(say).not.toHaveBeenCalled() + }) + }) + it("does not consume queued messages for command_output asks", async () => { const task = await createTask() @@ -459,9 +582,12 @@ describe("Task.ask queued message drain", () => { task.messageQueueService.addMessage("Retry feedback") const result = await task.ask("tool", JSON.stringify({ tool: "finishTask" }), false) const saveClineMessages = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) - const say = vi.fn().mockResolvedValue(undefined) const taskAccess = getQueueTaskTestAccess(task) - taskAccess.say = say + const addToClineMessages = vi.fn(async (message?: ClineMessage) => { + taskAccess.clineMessages.push(message!) + return true + }) + taskAccess.addToClineMessages = addToClineMessages taskAccess.saveClineMessages = saveClineMessages const persistence = task.persistQueuedFeedbackAndAcknowledge( @@ -472,8 +598,9 @@ describe("Task.ask queued message drain", () => { await vi.advanceTimersByTimeAsync(250) await persistence - expect(say).toHaveBeenCalledTimes(1) + expect(addToClineMessages).toHaveBeenCalledTimes(1) expect(saveClineMessages).toHaveBeenCalledTimes(2) + expect(taskAccess.clineMessages.filter((message) => message.say === "user_feedback")).toHaveLength(1) expect(task.messageQueueService.isEmpty()).toBe(true) } finally { vi.useRealTimers() @@ -487,7 +614,7 @@ describe("Task.ask queued message drain", () => { finishAddingAsk = resolve }) const access = getQueueTaskTestAccess(task) - access.addToClineMessages = vi.fn(() => addingAsk) + access.addToClineMessages = vi.fn(() => addingAsk.then(() => true)) task.messageQueueService.addMessage("Still durable") const ask = task.ask("tool", JSON.stringify({ tool: "finishTask" }), false) await Promise.resolve() @@ -506,7 +633,7 @@ describe("Task.ask queued message drain", () => { finishAddingAsk = resolve }) const access = getQueueTaskTestAccess(task) - access.addToClineMessages = vi.fn(() => addingAsk) + access.addToClineMessages = vi.fn(() => addingAsk.then(() => true)) task.messageQueueService.addMessage("Persist me later") const ask = task.ask("completion_result", "Done", false) await Promise.resolve() diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 99113ffda6..9d2973f691 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -491,6 +491,13 @@ export async function executeCommandInTerminal( return } await toolResultPublished + // A task cancelled while the command finished in the + // background must not drain: the guards inside + // processQueuedMessages would no-op anyway, and skipping here + // avoids pointless work plus error-log noise on the dying task. + if (task.abort || task.abandoned) { + return + } return task.processQueuedMessages().catch((error) => { console.error("[ExecuteCommandTool] Failed to process queued messages:", error) }) From fbb06bc5bcf010330738a9df46f14ec527a3fe42 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 03:15:21 +0900 Subject: [PATCH 16/51] test(tools,assistant-message): cover queued-ack wrapper branches What: Add behavior-focused coverage for the round-2 ack wiring: ReadFileTool batch approval ack, batch permissions-branch discard, and legacy per-file denial ack; presentAssistantMessage MCP approval feedback and tool-repetition feedback routed through sayUserFeedbackAndAckQueued (both fixtures now define the wrapper, and the attribution fixture gains the telemetry methods the repetition path emits). The direct requestApproval tests use the batch branch, which the public single-file execute path cannot reach. Why: Codecov flagged missing lines in ReadFileTool.ts and presentAssistantMessage.ts after the wrapper introduction, and the assistant-message fixtures predated it (the MCP path threw invisibly after onValidated in the attribution tests). Impact: The wrapper contract is pinned at every consumer branch it changed; no source behavior changes. --- ...tantMessage-tool-usage-attribution.spec.ts | 83 +++++++++++++++++++ ...esentAssistantMessage-unknown-tool.spec.ts | 1 + src/core/tools/__tests__/readFileTool.spec.ts | 80 ++++++++++++++++++ 3 files changed, 164 insertions(+) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index 3247ba49d2..abffc3d986 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -2,6 +2,7 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" +import { providerIdentifiers } from "@roo-code/types" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" import { getModeBySlug } from "../../../shared/modes" @@ -39,6 +40,7 @@ vi.mock("@roo-code/telemetry", () => ({ instance: { captureToolUsage: vi.fn(), captureConsecutiveMistakeError: vi.fn(), + captureException: vi.fn(), captureEvent: vi.fn(), }, }, @@ -64,6 +66,7 @@ interface MockTask { api: { getModel: () => { id: string; info: Record } } recordToolUsage: ReturnType recordToolError: ReturnType + apiConfiguration?: { apiProvider: string } toolRepetitionDetector: { check: ReturnType } providerRef: { deref: () => @@ -74,6 +77,7 @@ interface MockTask { | undefined } say: ReturnType + sayUserFeedbackAndAckQueued: ReturnType ask: ReturnType pushToolResultToUserContent: ReturnType } @@ -117,6 +121,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { }), }, say: vi.fn().mockResolvedValue(undefined), + sayUserFeedbackAndAckQueued: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), pushToolResultToUserContent: vi.fn(), } @@ -348,6 +353,84 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(mockTask.recordToolUsage).not.toHaveBeenCalled() expect(TelemetryService.instance.captureToolUsage).not.toHaveBeenCalled() }) + + it("routes MCP approval feedback through the queued-ack wrapper", async () => { + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + getMcpHub: () => ({ + findServerNameBySanitizedName: () => "my_server", + getAllServers: () => [ + { + name: "my_server", + tools: [{ name: "do_thing", enabledForPrompt: true }], + }, + ], + }), + }), + } + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_feedback", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + mockTask.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: "Careful with this server" }) + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledExactlyOnceWith( + "Careful with this server", + undefined, + undefined, + ) + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + }) + + it("routes tool-repetition feedback through the queued-ack wrapper", async () => { + mockTask.toolRepetitionDetector.check = vi.fn().mockReturnValue({ + allowExecution: false, + askUser: { + messageKey: "mistake_limit_reached", + messageDetail: "The tool {toolName} was called consecutively without progress.", + }, + }) + mockTask.apiConfiguration = { apiProvider: providerIdentifiers.anthropic } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_repetition_feedback", + name: "read_file", + params: { path: "a.txt" }, + nativeArgs: { path: "a.txt" }, + partial: false, + }, + ] + mockTask.ask = vi.fn().mockResolvedValue({ response: "messageResponse", text: "Try another approach" }) + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledExactlyOnceWith( + "Try another approach", + undefined, + undefined, + ) + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + expect(mockTask.userMessageContent).toContainEqual( + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Try another approach"), + }), + ) + }) }) describe("undefined provider state", () => { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 8cfaa10972..06ee989a88 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -62,6 +62,7 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { }), }, say: vi.fn().mockResolvedValue(undefined), + sayUserFeedbackAndAckQueued: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 0c37783d63..2778e8b9ed 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -690,6 +690,86 @@ describe("ReadFileTool", () => { expect(mockTask.say).not.toHaveBeenCalled() expect(mockTask.discardConsumedQueuedMessage).not.toHaveBeenCalled() }) + + it("acks queued feedback on a batch approval", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "yesButtonClicked", + text: "ok to proceed", + images: undefined, + queuedMessageId: "queued-batch-yes", + }) + + await readFileTool["requestApproval"]( + mockTask as unknown as Task, + [ + { path: "a.ts", status: "pending", entry: { path: "a.ts", mode: "slice", offset: 1 } }, + { path: "b.ts", status: "pending", entry: { path: "b.ts", mode: "slice", offset: 1 } }, + ], + () => {}, + ) + + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledExactlyOnceWith( + "ok to proceed", + undefined, + "queued-batch-yes", + ) + expect(mockTask.say).not.toHaveBeenCalled() + }) + + it("discards a queued message consumed by the batch permissions branch", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "messageResponse", + text: "free-form note", + images: undefined, + queuedMessageId: "queued-batch", + }) + + await readFileTool["requestApproval"]( + mockTask as unknown as Task, + [ + { path: "a.ts", status: "pending", entry: { path: "a.ts", mode: "slice", offset: 1 } }, + { path: "b.ts", status: "pending", entry: { path: "b.ts", mode: "slice", offset: 1 } }, + ], + () => {}, + ) + + expect(mockTask.discardConsumedQueuedMessage).toHaveBeenCalledExactlyOnceWith("queued-batch") + expect(mockTask.sayUserFeedbackAndAckQueued).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + // Free text is not a permissions payload, so both files are denied. + expect(mockTask.didRejectTool).toBe(true) + }) + + it("acks queued feedback on a legacy per-file denial", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "noButtonClicked", + text: "Do not read it", + images: undefined, + queuedMessageId: "queued-legacy", + }) + + await readFileTool.execute( + { files: [{ path: "legacy-secret.ts" }] } as unknown as Parameters[0], + mockTask as unknown as Task, + callbacks, + ) + + expect(mockTask.sayUserFeedbackAndAckQueued).toHaveBeenCalledExactlyOnceWith( + "Do not read it", + undefined, + "queued-legacy", + ) + expect(mockTask.discardConsumedQueuedMessage).not.toHaveBeenCalled() + }) }) describe("output structure", () => { From 59f0e3fa5061362382b75f70ab9253e1d8b11d88 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 03:56:16 +0900 Subject: [PATCH 17/51] fix(task): keep queued conversational messages out of approval asks What: Approval-gating ask types (command, use_mcp_server, and tool asks other than the newTask/finishTask lifecycle flow) no longer consume a claimed queued message: queuedResponseForAsk returns undefined for them, so both claim sites (ask start and the blocked-ask poll) leave the entry queued, and resolution is now computed before claiming so an undefined resolution never leaks a claim. A retained queued message can therefore no longer be converted into yesButtonClicked for a later approval ask; conversational asks (follow-up, resume, completion, mistake-limit, repetition) still receive it, as does the lifecycle tool flow. Why: With retention, a message queued during command A stayed queued after the drain and was claimed by the next command/tool approval ask, which mapped it to yesButtonClicked before checkAutoApproval, silently approving command B (including protected commands) without an explicit approval. Impact: Explicit user approval is the only path to yesButtonClicked for execution-gating asks; queued feedback is delivered conversationally and never lost. Tests that pinned queued-text-as-approval were updated to the new intended semantics (documented); the blocking explicit-approval flow is covered in ask-queued-message-drain.spec.ts. --- src/core/task/Task.ts | 27 +++-- src/core/task/__tests__/Task.spec.ts | 42 ++++---- .../ask-queued-message-drain.spec.ts | 98 +++++++++++++++++-- 3 files changed, 135 insertions(+), 32 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 94e093e079..2e98c0f28a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -169,13 +169,18 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio return { response: "messageResponse", requiresDurableAck: true } } } catch { - // Malformed tool asks retain the existing approve-with-feedback behavior. + // Malformed tool asks retain the existing behavior: the queued + // message is left for a conversational turn, not read as an answer. } - return { response: "yesButtonClicked", requiresDurableAck: false } + // A queued conversational message must never approve tool execution: + // only an explicit user response may return yesButtonClicked. + return undefined } if (type === "command" || type === "use_mcp_server") { - return { response: "yesButtonClicked", requiresDurableAck: false } + // Approval-gating asks: a queued conversational message is not an + // approval, so the claim path must not convert it to yesButtonClicked. + return undefined } return { response: "messageResponse", requiresDurableAck: type === "completion_result" } @@ -1554,9 +1559,12 @@ export class Task extends EventEmitter implements TaskLike { // rendered, leaving them stuck on-screen). const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined - const queuedMessage = - partial === true || type === "command_output" ? undefined : this.messageQueueService.claimNextMessage() - const queuedAskResolution = queuedMessage ? queuedResponseForAsk(type, text) : undefined + // Resolve before claiming: approval-gating ask types never consume a + // queued conversational message, and claiming without a resolution + // would leak the claim. + const queuedAskResolution = + partial === true || type === "command_output" ? undefined : queuedResponseForAsk(type, text) + const queuedMessage = queuedAskResolution ? this.messageQueueService.claimNextMessage() : undefined // `this.cwd`, not `provider.cwd`: // The path inside `text` was made relative to this task's workspace, // which for a resumed or child task need not be the one the provider @@ -1756,10 +1764,11 @@ export class Task extends EventEmitter implements TaskLike { // If a queued message arrives while we're blocked on an ask (e.g. a follow-up // suggestion click that was incorrectly queued due to UI state), consume it - // immediately so the task doesn't hang. + // immediately so the task doesn't hang. Approval-gating ask types get no + // resolution, so the message stays queued for a conversational turn. if (shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { - const message = this.messageQueueService.claimNextMessage() - const resolution = message ? queuedResponseForAsk(type, text) : undefined + const resolution = queuedResponseForAsk(type, text) + const message = resolution ? this.messageQueueService.claimNextMessage() : undefined if (message && resolution) { queuedMessageId = this.handleQueuedAskResponse(message, resolution) } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 444f469dcd..61a5b2b355 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -5902,11 +5902,11 @@ describe("Queued message processing after condense", () => { undefined, task.messageQueueService.messages[0]?.id, ) - // Submission does not remove: the message stays queued until an ask - // consumes it. + // Submission does not remove: the message stays queued until a + // conversational ask consumes it. expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued text"]) - const result = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) - expect(result.text).toBe("queued text") + const result = await task.ask("followup", "Anything else?", false) + expect(result).toMatchObject({ response: "messageResponse", text: "queued text" }) expect(task.messageQueueService.isEmpty()).toBe(true) }) @@ -5964,8 +5964,8 @@ describe("Queued message processing after condense", () => { expect(taskA.messageQueueService.messages.map((message) => message.text)).toEqual(["A message"]) expect(taskB.messageQueueService.messages.map((message) => message.text)).toEqual(["B message"]) - const resultB = await taskB.ask("tool", JSON.stringify({ tool: "readFile" }), false) - expect(resultB.text).toBe("B message") + const resultB = await taskB.ask("followup", "Anything else?", false) + expect(resultB).toMatchObject({ response: "messageResponse", text: "B message" }) expect(taskB.messageQueueService.isEmpty()).toBe(true) // Consuming B's message must not touch A's queue. expect(taskA.messageQueueService.messages.map((message) => message.text)).toEqual(["A message"]) @@ -5980,7 +5980,7 @@ describe("Queued message processing after condense", () => { startTask: false, }) - it("submits the next queued message and retains it until an ask consumes it", async () => { + it("submits the next queued message and retains it until a conversational ask consumes it", async () => { const task = createQueueTask() vi.spyOn(getTaskTestAccess(task), "checkpointSave").mockResolvedValue(undefined) task.messageQueueService.addMessage("queued text", ["img1.png"]) @@ -5991,9 +5991,17 @@ describe("Queued message processing after condense", () => { // the message stays queued until an ask claims it. expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued text"]) - const result = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + // An approval-gating tool ask must not consume the conversational + // message (this file mocks p-wait-for, so the ask resolves without a + // response; the explicit-approval blocking flow is covered in + // ask-queued-message-drain.spec.ts). + const approval = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + expect(approval.text).toBeUndefined() + expect(approval.queuedMessageId).toBeUndefined() + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued text"]) - expect(result).toMatchObject({ response: "yesButtonClicked", text: "queued text", images: ["img1.png"] }) + const result = await task.ask("followup", "Anything else?", false) + expect(result).toMatchObject({ response: "messageResponse", text: "queued text", images: ["img1.png"] }) expect(task.messageQueueService.isEmpty()).toBe(true) }) @@ -6081,10 +6089,10 @@ describe("Queued message processing after condense", () => { expect(submitSpy).toHaveBeenCalledTimes(1) expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["first", "second"]) - const firstResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) - expect(firstResult).toMatchObject({ response: "yesButtonClicked", text: "first" }) - const secondResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) - expect(secondResult).toMatchObject({ response: "yesButtonClicked", text: "second" }) + const firstResult = await task.ask("followup", "first question?", false) + expect(firstResult).toMatchObject({ response: "messageResponse", text: "first" }) + const secondResult = await task.ask("followup", "second question?", false) + expect(secondResult).toMatchObject({ response: "messageResponse", text: "second" }) expect(task.messageQueueService.isEmpty()).toBe(true) }) @@ -6105,10 +6113,10 @@ describe("Queued message processing after condense", () => { "retry me", "still deliverable", ]) - const firstResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) - expect(firstResult).toMatchObject({ response: "yesButtonClicked", text: "retry me" }) - const secondResult = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) - expect(secondResult).toMatchObject({ response: "yesButtonClicked", text: "still deliverable" }) + const firstResult = await task.ask("followup", "first question?", false) + expect(firstResult).toMatchObject({ response: "messageResponse", text: "retry me" }) + const secondResult = await task.ask("followup", "second question?", false) + expect(secondResult).toMatchObject({ response: "messageResponse", text: "still deliverable" }) expect(task.messageQueueService.isEmpty()).toBe(true) }) }) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 8cdb545927..829eefba1f 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -11,6 +11,7 @@ type QueueTaskTestAccess = { queuedFeedbackRows: Map lastMessageTs?: number abort: boolean + abandoned: boolean queuedMessageDrainChain: Promise } @@ -409,6 +410,34 @@ describe("Task.ask queued message drain", () => { expect(task["askResponse"]).toBeUndefined() }) + it("does not submit queued messages once the task is abandoned", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + task.messageQueueService.addMessage("too late") + getQueueTaskTestAccess(task).abandoned = true + + await expect(task.processQueuedMessages()).resolves.toBe(false) + + expect(submitSpy).not.toHaveBeenCalled() + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["too late"]) + expect(task["askResponse"]).toBeUndefined() + }) + + it("drops the drain quietly when abandonment lands mid-submission", async () => { + const task = await createTask({ getState: async () => ({}) }) + task.messageQueueService.addMessage("too late") + const realSubmit = task.submitUserMessage.bind(task) + vi.spyOn(task, "submitUserMessage").mockImplementation((...args) => { + getQueueTaskTestAccess(task).abandoned = true + return realSubmit(...args) + }) + + await expect(task.processQueuedMessages()).resolves.toBe(false) + + expect(task.messageQueueService.messages).toHaveLength(1) + expect(task["askResponse"]).toBeUndefined() + }) + describe("sayUserFeedbackAndAckQueued", () => { it("delegates to the durable ack when a queued message was consumed", async () => { const task = await createTask() @@ -499,13 +528,29 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.isEmpty()).toBe(true) }) - it("preserves approve-with-feedback behavior for ordinary tool asks", async () => { + it("does not consume a queued message as a tool approval; it stays queued for a conversational turn", async () => { const task = await createTask() task.messageQueueService.addMessage("Use this context") - const result = await task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + // The conversational message must not approve the tool: the ask is + // still blocked waiting for an explicit user response. + let settled = false + void askPromise.then(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(settled).toBe(false) - expect(result).toMatchObject({ response: "yesButtonClicked", text: "Use this context" }) + setTimeout(() => task.approveAsk(), 0) + const result = await askPromise + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + // The message is retained for a conversational ask, not consumed here. + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["Use this context"]) + + const nextResult = await task.ask("followup", "Q?", false) + expect(nextResult).toMatchObject({ response: "messageResponse", text: "Use this context" }) expect(task.messageQueueService.isEmpty()).toBe(true) }) @@ -513,13 +558,54 @@ describe("Task.ask queued message drain", () => { ["command", "npm test"], ["use_mcp_server", "{}"], ["tool", "not-json"], - ] as const)("preserves approve-with-feedback behavior for %s asks", async (type, text) => { + ] as const)("leaves queued conversational text out of %s approvals", async (type, text) => { const task = await createTask() task.messageQueueService.addMessage("Approval context") - const result = await task.ask(type, text, false) + const askPromise = task.ask(type, text, false) + await new Promise((resolve) => setTimeout(resolve, 150)) + let settled = false + void askPromise.then(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(settled).toBe(false) + + setTimeout(() => task.approveAsk(), 0) + const result = await askPromise + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["Approval context"]) + }) + + it("never lets a drained queued message approve a later command ask", async () => { + const task = await createTask({ getState: async () => ({}) }) // auto-approval disabled + const submitSpy = vi.spyOn(task, "submitUserMessage") + + // The user queues conversational feedback while command A runs. + task.messageQueueService.addMessage("also fix the tests") + // Command A finishes: the drain submits the feedback (conversational + // delivery) and retains the entry until an ask consumes it. + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(submitSpy).toHaveBeenCalledTimes(1) + + // The model now requests command B. The retained entry must not be + // claimed as an approval: the ask keeps waiting for the user. + const askPromise = task.ask("command", "git push --force", false) + let settled = false + void askPromise.then(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(settled).toBe(false) + + // Explicit approval executes B; the feedback is still delivered later. + setTimeout(() => task.approveAsk(), 0) + const result = await askPromise + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["also fix the tests"]) - expect(result).toMatchObject({ response: "yesButtonClicked", text: "Approval context" }) + const followup = await task.ask("followup", "anything else?", false) + expect(followup).toMatchObject({ response: "messageResponse", text: "also fix the tests" }) expect(task.messageQueueService.isEmpty()).toBe(true) }) From 844e2c2ed8296260e9404a559a9269a7de799d59 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 03:56:29 +0900 Subject: [PATCH 18/51] fix(tools): settle the command publication signal on terminal errors What: Resolve ExecuteCommandTool's per-invocation toolResultPublished signal before a generic (non-ShellIntegrationError) execution error bubbles to the tool error path, and skip the background-completion drain on abandoned tasks. Resolving is idempotent, so success-path ordering (background drain still waits for the actual publication) is unchanged. Why: The unresolved signal is captured in the options passed to executeCommandInTerminal, where the fire-and-forget background-completion drain chain awaits it; a terminal error rethrow left it unsettled, so the chain hung forever holding task references. An abandoned task likewise had no reason to drain after background completion. Impact: No pending promise outlives a failed command execution; no drain runs on abandoned tasks; focused tests pin both behaviors plus the unchanged error propagation. --- src/core/tools/ExecuteCommandTool.ts | 5 ++ .../__tests__/executeCommandTool.spec.ts | 85 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 9d2973f691..34e354752a 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -255,6 +255,11 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { // not a shell-integration failure, so it must not emit the // shell-integration warning; surface it through the tool's // error path instead. + // Settle the publication signal before the error bubbles: the + // background-completion drain chain captured toolResultPublished + // and awaits it, so an unresolved promise would hang that chain + // (and hold task references) forever. Resolving is idempotent. + resolveToolResultPublished?.() throw error } } diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 262fe3beb4..f17e452d67 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -1242,6 +1242,91 @@ describe("executeCommandTool", () => { } }) + it("skips the background-completion drain when the task was abandoned but still publishes the result", async () => { + vitest.useFakeTimers() + mockCline.processQueuedMessages.mockResolvedValue(true) + mockCline.abandoned = true + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("npm run dev", 2) + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("server starting...\n", proc) + + // The agent timeout moves the command to the background; the tool + // returns its "still running" result and runs the post-result drain. + await vitest.advanceTimersByTimeAsync(2_000) + await handlePromise + + expect(mockPushToolResult.mock.calls[0][0]).toContain("still running") + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + + // When the background command later completes, the + // background-completion drain is skipped on the abandoned task. + await callbacks.onCompleted!("server exited\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + await vitest.advanceTimersByTimeAsync(100) + + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("settles the background drain chain when terminal execution fails after output", async () => { + vitest.useFakeTimers() + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) + try { + let rejectProcess!: (error: Error) => void + const processPromise = new Promise((_resolve, reject) => { + rejectProcess = reject + }) + const failingProcess = Object.assign(processPromise, { + continue: vitest.fn(), + abort: vitest.fn(), + }) as unknown as RooTerminalProcess + let capturedCallbacks: RooTerminalCallbacks | undefined + vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValue({ + runCommand: vitest.fn().mockImplementation((_command: string, callbacks: RooTerminalCallbacks) => { + capturedCallbacks = callbacks + return failingProcess + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + const handlePromise = handleCommand("npm test") + + await vitest.waitFor(() => expect(capturedCallbacks).toBeDefined()) + const callbacks = capturedCallbacks! + callbacks.onShellExecutionStarted!(1234, failingProcess) + await callbacks.onLine!("partial output\n", failingProcess) + // Completion creates the background-completion drain chain, which + // awaits the per-invocation publication signal. + await callbacks.onCompleted!("partial output\n", failingProcess) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, failingProcess) + + // The terminal then fails with a generic (non-shell-integration) + // error. The publication signal must settle so the awaiting drain + // chain cannot hang, and the error must surface through the tool + // error path. + const failure = new Error("terminal process crashed") + rejectProcess(failure) + + await vitest.advanceTimersByTimeAsync(100) + await handlePromise + await vitest.advanceTimersByTimeAsync(0) + + expect(mockHandleError).toHaveBeenCalledWith("executing command", failure) + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) + it("returns the persisted output format when the interceptor reports truncated output", async () => { vitest.useFakeTimers() mockCline.providerRef.deref.mockResolvedValue({ From 31eb23275e77dc38d1573a1d4d563343fab55236 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 04:07:06 +0900 Subject: [PATCH 19/51] test(tools): strengthen the publication-signal settle test What: Rework 'settles the background drain chain when terminal execution fails after output' so the background-completion chain genuinely awaits the per-invocation publication signal: the command moves to background via the agent-timeout racer while the tool sits in its settle delay (process continue is a no-op, so the race stays process-pending), then completion fires with runInBackground already true. The signal's only settler is then the generic-error branch: the tool-result publication itself throws, the error rethrows through the else branch, and the settled chain runs the background-completion drain exactly once. Why: The previous version never entered background mode, so the chain short-circuited before awaiting the signal and passed with or without the fix. Verified by mutation check: with resolveToolResultPublished?.() removed the strengthened test fails (the chain hangs, the drain never runs); with it restored the suite is green. Impact: The fix is now pinned by a test that fails without it; no production code changes. --- .../__tests__/executeCommandTool.spec.ts | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index f17e452d67..5ee85a2a37 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -1276,15 +1276,13 @@ describe("executeCommandTool", () => { expect(mockHandleError).not.toHaveBeenCalled() }) - it("settles the background drain chain when terminal execution fails after output", async () => { + it("settles the background drain chain when publication fails on a backgrounded command", async () => { vitest.useFakeTimers() const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) try { - let rejectProcess!: (error: Error) => void - const processPromise = new Promise((_resolve, reject) => { - rejectProcess = reject - }) - const failingProcess = Object.assign(processPromise, { + // A process that never settles: the agent-timeout racer moves the + // command to background while the process stays pending. + const pendingProcess = Object.assign(new Promise(() => {}), { continue: vitest.fn(), abort: vitest.fn(), }) as unknown as RooTerminalProcess @@ -1292,36 +1290,46 @@ describe("executeCommandTool", () => { vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValue({ runCommand: vitest.fn().mockImplementation((_command: string, callbacks: RooTerminalCallbacks) => { capturedCallbacks = callbacks - return failingProcess + return pendingProcess }), getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), } as never) - const handlePromise = handleCommand("npm test") + // The tool-result publication itself throws: a generic + // (non-shell-integration) error that reaches the else branch. + const publishError = new Error("tool result publication failed") + mockPushToolResult.mockImplementationOnce(() => { + throw publishError + }) + + const handlePromise = handleCommand("npm test", 2) await vitest.waitFor(() => expect(capturedCallbacks).toBeDefined()) const callbacks = capturedCallbacks! - callbacks.onShellExecutionStarted!(1234, failingProcess) - await callbacks.onLine!("partial output\n", failingProcess) - // Completion creates the background-completion drain chain, which - // awaits the per-invocation publication signal. - await callbacks.onCompleted!("partial output\n", failingProcess) - callbacks.onShellExecutionComplete!({ exitCode: 0 }, failingProcess) - - // The terminal then fails with a generic (non-shell-integration) - // error. The publication signal must settle so the awaiting drain - // chain cannot hang, and the error must surface through the tool - // error path. - const failure = new Error("terminal process crashed") - rejectProcess(failure) + callbacks.onShellExecutionStarted!(1234, pendingProcess) + await callbacks.onLine!("partial output\n", pendingProcess) + + // The agent timeout fires while the tool sits in the settle + // delay: runInBackground is set, so the completion chain created + // below really awaits the publication signal. + await vitest.advanceTimersByTimeAsync(2_000) + + await callbacks.onCompleted!("partial output\n", pendingProcess) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, pendingProcess) + await vitest.advanceTimersByTimeAsync(0) + // The settle delay elapses, the tool tries to publish, and the + // publication error rethrows through the generic error path. The + // publication signal must settle so the awaiting background drain + // chain cannot hang: it is the chain's only possible settler. await vitest.advanceTimersByTimeAsync(100) await handlePromise await vitest.advanceTimersByTimeAsync(0) - expect(mockHandleError).toHaveBeenCalledWith("executing command", failure) - expect(mockPushToolResult).not.toHaveBeenCalled() - expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith("executing command", publishError) + // The settled chain runs the background-completion drain exactly + // once (no post-result drain exists on the error path). + expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) } finally { consoleErrorSpy.mockRestore() } From b6112c54b6c5b18aa5d337a26ed6745c12c520f7 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 04:36:41 +0900 Subject: [PATCH 20/51] fix(task): keep unclaimed ask resolutions inert so auto-approval still applies What: The round-4 claim reorder computed queuedResponseForAsk before claiming but left the resolution actionable when nothing was claimed, so the forced manual-ask branch fired for lifecycle tool asks (newTask/ finishTask) even with an empty queue. Gate the resolution on an actual claim: queuedAskResolution is now defined only when a message was claimed, restoring the pre-round-4 approval semantics while keeping the security gate and the no-leak ordering. Why: With an empty queue, the child's finishTask ask and the parent's newTask ask were forced into a manual ask, bypassing auto-approval, so delegated subtask flows blocked forever waiting for a button click (e2e-mock: 9 Roo Code Subtasks tests timed out at 30s). Impact: Empty-queue lifecycle tool asks auto-approve again; queued conversational messages still cannot approve execution-gating asks. New regression test: empty queue + auto-approval + finishTask/newTask ask must resolve yesButtonClicked (verified to time out with the regression re-introduced). --- src/core/task/Task.ts | 9 ++++++--- .../__tests__/ask-queued-message-drain.spec.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2e98c0f28a..014400531c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1561,10 +1561,13 @@ export class Task extends EventEmitter implements TaskLike { const state = provider ? await provider.getState() : undefined // Resolve before claiming: approval-gating ask types never consume a // queued conversational message, and claiming without a resolution - // would leak the claim. - const queuedAskResolution = + // would leak the claim. The resolution is only actionable when a + // message was actually claimed: otherwise it must not force a manual + // ask (that would bypass auto-approval for empty-queue lifecycle asks). + const claimableResolution = partial === true || type === "command_output" ? undefined : queuedResponseForAsk(type, text) - const queuedMessage = queuedAskResolution ? this.messageQueueService.claimNextMessage() : undefined + const queuedMessage = claimableResolution ? this.messageQueueService.claimNextMessage() : undefined + const queuedAskResolution = queuedMessage ? claimableResolution : undefined // `this.cwd`, not `provider.cwd`: // The path inside `text` was made relative to this task's workspace, // which for a resumed or child task need not be the one the provider diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 829eefba1f..d65af0da80 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -577,6 +577,21 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["Approval context"]) }) + it.each(["finishTask", "newTask"])( + "auto-approves a %s tool ask when the queue is empty (resolution stays inert)", + async (tool) => { + const task = await createTask({ + getState: async () => ({ autoApprovalEnabled: true, alwaysAllowSubtasks: true }), + }) + // No queued message: the ask resolution must not force a manual ask, + // or auto-approval would be bypassed and delegation flows would hang. + const result = await task.ask("tool", JSON.stringify({ tool }), false) + + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }, + ) + it("never lets a drained queued message approve a later command ask", async () => { const task = await createTask({ getState: async () => ({}) }) // auto-approval disabled const submitSpy = vi.spyOn(task, "submitUserMessage") From 43f1cd08d6d4aa1a765f9fcacd1684f483745442 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 05:16:01 +0900 Subject: [PATCH 21/51] fix(tools): carry the publication outcome and never drain after a failed publish What: toolResultPublished now resolves with a boolean outcome: true on successful publication, false when publication throws or the generic error path settles it. publishToolResult settles false and rethrows when pushToolResult throws, covering the normal and shell-integration fallback publish sites; the background-completion chain awaits the outcome and skips draining after a failed publication. Why: The previous void signal let the completion chain drain queued messages after a failed publication, before handleError reported the command error, and a throw from the fallback publish call bypassed the error-path settle entirely, leaving the chain waiting forever. Impact: Queued messages are only drained after the tool result actually reached the model; a failed publication settles the chain exactly once and the queued message waits for a later turn. The settle test is inverted (no drain, error propagates) and the fallback-publish-throw arm is covered. --- src/core/tools/ExecuteCommandTool.ts | 46 ++++++++---- .../__tests__/executeCommandTool.spec.ts | 75 +++++++++++++++++-- 2 files changed, 100 insertions(+), 21 deletions(-) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 34e354752a..101f3bab01 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -189,16 +189,22 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { // The background-completion drain inside executeCommandInTerminal // must not run before this command's tool result is published: a // background command can finish during the settle delay while the - // result is still pending. Resolve at each pushToolResult site so - // the drain waits on a structural signal, not on timer or - // microtask ordering. - let resolveToolResultPublished: (() => void) | undefined - const toolResultPublished = new Promise((resolve) => { - resolveToolResultPublished = resolve + // result is still pending. Settle at each pushToolResult site with + // the publication outcome so the drain waits on a structural signal + // and skips draining after a failed publication (handleError + // reports first; the queued message waits for a later turn). + let settleToolResultPublished: ((published: boolean) => void) | undefined + const toolResultPublished = new Promise((resolve) => { + settleToolResultPublished = resolve }) const publishToolResult = (result: ToolResponse): void => { - pushToolResult(result) - resolveToolResultPublished?.() + try { + pushToolResult(result) + } catch (error) { + settleToolResultPublished?.(false) + throw error + } + settleToolResultPublished?.(true) } const options: ExecuteCommandOptions = { @@ -258,8 +264,10 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { // Settle the publication signal before the error bubbles: the // background-completion drain chain captured toolResultPublished // and awaits it, so an unresolved promise would hang that chain - // (and hold task references) forever. Resolving is idempotent. - resolveToolResultPublished?.() + // (and hold task references) forever. The outcome is false, so + // the chain never drains after a failed publication. Settling + // is idempotent. + settleToolResultPublished?.(false) throw error } } @@ -299,10 +307,12 @@ export type ExecuteCommandOptions = { agentTimeout?: number /** * Resolves once the tool result for this command has been published. - * The background-completion drain awaits this before processing queued - * messages so they are never submitted ahead of the current tool result. + * Resolves true on successful publication and false when publication + * failed; the background-completion drain awaits this before processing + * queued messages so they are never submitted ahead of the current tool + * result, and skips draining entirely after a failed publication. */ - toolResultPublished?: Promise + toolResultPublished?: Promise } export async function executeCommandInTerminal( @@ -315,7 +325,7 @@ export async function executeCommandInTerminal( commandExecutionTimeout = 0, agentTimeout = 0, // Direct callers without a tool-result lifecycle drain immediately. - toolResultPublished = Promise.resolve(), + toolResultPublished = Promise.resolve(true), }: ExecuteCommandOptions, ): Promise<[boolean, ToolResponse, boolean]> { // Convert milliseconds back to seconds for display purposes. @@ -495,7 +505,7 @@ export async function executeCommandInTerminal( if (!runInBackground) { return } - await toolResultPublished + const published = await toolResultPublished // A task cancelled while the command finished in the // background must not drain: the guards inside // processQueuedMessages would no-op anyway, and skipping here @@ -503,6 +513,12 @@ export async function executeCommandInTerminal( if (task.abort || task.abandoned) { return } + // A failed publication must not drain either: handleError + // reports the command error first, and the queued message + // waits for a later turn. + if (!published) { + return + } return task.processQueuedMessages().catch((error) => { console.error("[ExecuteCommandTool] Failed to process queued messages:", error) }) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 5ee85a2a37..8e4e26a92d 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -1276,7 +1276,7 @@ describe("executeCommandTool", () => { expect(mockHandleError).not.toHaveBeenCalled() }) - it("settles the background drain chain when publication fails on a backgrounded command", async () => { + it("does not drain the background chain when publication fails on a backgrounded command", async () => { vitest.useFakeTimers() const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) try { @@ -1320,16 +1320,79 @@ describe("executeCommandTool", () => { // The settle delay elapses, the tool tries to publish, and the // publication error rethrows through the generic error path. The - // publication signal must settle so the awaiting background drain - // chain cannot hang: it is the chain's only possible settler. + // signal settles with a failure outcome, so the awaiting chain + // releases without draining: handleError reports the error first + // and the queued message waits for a later turn. await vitest.advanceTimersByTimeAsync(100) await handlePromise await vitest.advanceTimersByTimeAsync(0) expect(mockHandleError).toHaveBeenCalledWith("executing command", publishError) - // The settled chain runs the background-completion drain exactly - // once (no post-result drain exists on the error path). - expect(mockCline.processQueuedMessages).toHaveBeenCalledTimes(1) + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("settles the chain without draining when the retry publication throws", async () => { + vitest.useFakeTimers() + const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}) + try { + // Attempt 1 (vscode): shell-integration startup race, command not + // submitted, so the tool retries through execa. + const shellError = new executeCommandModule.ShellIntegrationError("startup failed", false) + const failedProcess = Object.assign(Promise.reject(shellError), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + // Attempt 2 (execa): a process that never settles; the agent + // timeout moves the retried command to background. + const pendingProcess = Object.assign(new Promise(() => {}), { + continue: vitest.fn(), + abort: vitest.fn(), + }) as unknown as RooTerminalProcess + let retryCallbacks: RooTerminalCallbacks | undefined + vitest + .mocked(TerminalRegistry.getOrCreateTerminal) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockReturnValue(failedProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + .mockResolvedValueOnce({ + runCommand: vitest + .fn() + .mockImplementation((_command: string, callbacks: RooTerminalCallbacks) => { + retryCallbacks = callbacks + return pendingProcess + }), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + // The retry's own publication throws: the fallback publish call + // must also settle the signal (success=false) instead of leaving + // the completion chain waiting. + const publishError = new Error("retry publication failed") + mockPushToolResult.mockImplementationOnce(() => { + throw publishError + }) + + const handlePromise = handleCommand("npm test", 2) + + await vitest.waitFor(() => expect(retryCallbacks).toBeDefined()) + const callbacks = retryCallbacks! + callbacks.onShellExecutionStarted!(1234, pendingProcess) + await callbacks.onLine!("retry output\n", pendingProcess) + + await vitest.advanceTimersByTimeAsync(2_000) + + await callbacks.onCompleted!("retry output\n", pendingProcess) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, pendingProcess) + await vitest.advanceTimersByTimeAsync(100) + await handlePromise + await vitest.advanceTimersByTimeAsync(0) + + expect(mockHandleError).toHaveBeenCalledWith("executing command", publishError) + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() } finally { consoleErrorSpy.mockRestore() } From b25c04a4a5aceb6e684348836030febde31dcc72 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 05:17:21 +0900 Subject: [PATCH 22/51] fix(task): await the reconciled feedback row and make backoff cancellation-aware What: persistQueuedFeedbackAndAcknowledge now awaits updateClineMessage in the redelivery reconciliation branch so a failed webview update reaches the catch, which releases the queued message instead of acking over a stale row; and its retry backoff is interruptible (new waitForQueuedFeedbackBackoff helper): an abort or abandonment during the wait resolves promptly, releasing the claim without retaining the task through the remaining delay, with no further save attempts. Loop-top cancellation now also honors abandoned alongside abort. Why: The detached update could leave the webview stale while acking, and the fixed delay(250/1000/4000) backoff kept the task alive through the full timer after cancellation. Impact: Reconciliation failures surface as releases; cancelled tasks let go of queued feedback claims immediately. Coverage: resume tests pin the resume-ask ack order and the stop-on-failed-ack path; editFile finalize tests assert the discarded queued ID (and undefined on ask rejection); a fake-timer test proves prompt mid-backoff release. --- src/core/task/Task.ts | 31 +++++++-- .../task/__tests__/Task.persistence.spec.ts | 69 +++++++++++++++++++ .../ask-queued-message-drain.spec.ts | 34 +++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 18 +++++ 4 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 014400531c..5277fef61a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -977,12 +977,12 @@ export class Task extends EventEmitter implements TaskLike { if (row) { // Redelivery after a partial save failure (e.g. the message file // was written but metadata persistence failed): reconcile the same - // row instead of appending a duplicate feedback row. + // row instead of appending a duplicate feedback row. Awaited so a + // failed webview update reaches the catch below, which releases + // the queued message instead of acking over a stale row. row.text = text ?? "" row.images = images - this.updateClineMessage(row).catch((error) => { - console.error("[Task#persistQueuedFeedbackAndAcknowledge] updateClineMessage failed:", error) - }) + await this.updateClineMessage(row) } else { row = { ts: Date.now(), @@ -1007,7 +1007,7 @@ export class Task extends EventEmitter implements TaskLike { throw error } for (let attempt = 0; attempt <= QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length; attempt++) { - if (this.abort) { + if (this.abort || this.abandoned) { this.messageQueueService.releaseMessage(messageId) return false } @@ -1016,7 +1016,10 @@ export class Task extends EventEmitter implements TaskLike { return this.messageQueueService.removeMessage(messageId) } if (attempt < QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length) { - await delay(QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS[attempt]) + // Interruptible backoff: an abort or abandonment during the wait + // resolves promptly (releasing the claim at the loop-top check) + // instead of retaining the task through the full delay. + await this.waitForQueuedFeedbackBackoff(QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS[attempt]) } } console.error( @@ -1026,6 +1029,22 @@ export class Task extends EventEmitter implements TaskLike { return false } + private waitForQueuedFeedbackBackoff(ms: number): Promise { + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer) + clearInterval(poll) + resolve() + } + const timer = setTimeout(finish, ms) + const poll = setInterval(() => { + if (this.abort === true || this.abandoned === true) { + finish() + } + }, 50) + }) + } + /** * Persist the user feedback carried by an ask result. When the ask consumed * a queued message (intercepted its drain submission), the queue entry is diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index c64da4652c..b65233c4f2 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -1415,6 +1415,75 @@ describe("Task persistence", () => { expect(task.ask).toHaveBeenCalledWith("resume_task") }) + it("persists and acks queued feedback returned by the resume ask before continuing", async () => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Child" }]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "resume me" }]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "child-1", + number: 1, + ts: 1, + task: "Child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + startTask: false, + }) + vi.spyOn(task, "ask").mockResolvedValue({ + response: "messageResponse", + text: "queued resume feedback", + queuedMessageId: "queued-resume", + }) + const persist = vi.spyOn(task, "persistQueuedFeedbackAndAcknowledge").mockResolvedValue(true) + const initiateTaskLoop = vi + .spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop") + .mockResolvedValue(undefined) + + await getTaskPersistenceAccess(task).resumeTaskFromHistory() + + expect(persist).toHaveBeenCalledExactlyOnceWith("queued-resume", "queued resume feedback", undefined) + // The ack runs before the task loop continues. + expect(persist.mock.invocationCallOrder[0]).toBeLessThan(initiateTaskLoop.mock.invocationCallOrder[0]) + expect(initiateTaskLoop).toHaveBeenCalled() + }) + + it("stops resumption when the queued resume feedback cannot be persisted", async () => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Child" }]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "resume me" }]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "child-1", + number: 1, + ts: 1, + task: "Child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + startTask: false, + }) + vi.spyOn(task, "ask").mockResolvedValue({ + response: "messageResponse", + text: "queued resume feedback", + queuedMessageId: "queued-resume", + }) + vi.spyOn(task, "persistQueuedFeedbackAndAcknowledge").mockResolvedValue(false) + const initiateTaskLoop = vi + .spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop") + .mockResolvedValue(undefined) + + await expect(getTaskPersistenceAccess(task).resumeTaskFromHistory()).rejects.toThrow( + "Failed to persist queued feedback queued-resume", + ) + + expect(initiateTaskLoop).not.toHaveBeenCalled() + }) + it("clears pending metadata after the matching tool result is saved", async () => { mockProvider.clearPendingTaskAction = vi.fn().mockResolvedValue(true) const task = new Task({ diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index d65af0da80..d2e8d8ed3e 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -801,4 +801,38 @@ describe("Task.ask queued message drain", () => { vi.useRealTimers() } }) + + it("releases the claim promptly when abort lands mid-backoff, without further save attempts", async () => { + vi.useFakeTimers() + try { + const task = await createTask() + task.messageQueueService.addMessage("Retry after abort") + const result = await task.ask("completion_result", "Done", false) + const access = getQueueTaskTestAccess(task) + access.say = vi.fn().mockResolvedValue(true) + const saveClineMessages = vi.fn().mockResolvedValue(false) + access.saveClineMessages = saveClineMessages + + const persistence = task.persistQueuedFeedbackAndAcknowledge( + result.queuedMessageId!, + result.text, + result.images, + ) + await vi.advanceTimersByTimeAsync(0) + expect(saveClineMessages).toHaveBeenCalledTimes(1) + + // Abort inside the 250ms backoff: the wait must interrupt well + // before the delay expires, release the claim, and skip the + // remaining retries instead of retaining the task. + access.abort = true + await vi.advanceTimersByTimeAsync(100) + + await expect(persistence).resolves.toBe(false) + expect(saveClineMessages).toHaveBeenCalledTimes(1) + expect(task.messageQueueService.messages).toHaveLength(1) + expect(task.messageQueueService.claimNextMessage()?.text).toBe("Retry after abort") + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 743e65215c..2a3a55cd17 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -600,6 +600,7 @@ describe("editFileTool", () => { await executeEditFileTool({ old_string: "NonExistent" }, { isPartial: true }) await executeEditFileTool({ old_string: "NonExistent" }, { isPartial: true }) + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", queuedMessageId: "queued-finalize" }) await executeEditFileTool( { old_string: "NonExistent" }, { isPartial: false, fileContent: "Line 1\nLine 2\nLine 3" }, @@ -608,6 +609,8 @@ describe("editFileTool", () => { const askCalls = mockTask.ask.mock.calls const hasFinalToolAsk = askCalls.some((call: any[]) => call[0] === "tool" && call[2] === false) expect(hasFinalToolAsk).toBe(true) + // The finalize ask consumed a queued message that the tool drops. + expect(mockTask.discardConsumedQueuedMessage).toHaveBeenCalledExactlyOnceWith("queued-finalize") }) it("finalizes a partial tool preview row on no-op success (no changes needed)", async () => { @@ -621,6 +624,7 @@ describe("editFileTool", () => { { isPartial: true, fileContent: "Line 1\nLine 2\nLine 3" }, ) + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", queuedMessageId: "queued-finalize" }) const result = await executeEditFileTool( { old_string: " Line 2", new_string: "Line 2" }, { isPartial: false, fileContent: "Line 1\nLine 2\nLine 3" }, @@ -630,6 +634,20 @@ describe("editFileTool", () => { const askCalls = mockTask.ask.mock.calls const hasFinalToolAsk = askCalls.some((call: any[]) => call[0] === "tool" && call[2] === false) expect(hasFinalToolAsk).toBe(true) + expect(mockTask.discardConsumedQueuedMessage).toHaveBeenCalledExactlyOnceWith("queued-finalize") + }) + + it("drops nothing when the finalize ask itself rejects", async () => { + await executeEditFileTool({ old_string: "NonExistent" }, { isPartial: true }) + await executeEditFileTool({ old_string: "NonExistent" }, { isPartial: true }) + + mockTask.ask.mockRejectedValue(new Error("superseded partial message")) + await executeEditFileTool( + { old_string: "NonExistent" }, + { isPartial: false, fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(mockTask.discardConsumedQueuedMessage).toHaveBeenCalledExactlyOnceWith(undefined) }) }) From 3a5ae4d51719249ae4a6b5999864927a6e7ece44 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 29 Sep 2026 05:29:15 +0900 Subject: [PATCH 23/51] test(task): pin the awaited reconciled-row update failure path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: Redelivery after a partial save failure with updateClineMessage rejecting must propagate the failure, release the queued entry (never ack/remove it), and skip the save loop. Why: The round-6 change from fire-and-forget to an awaited reconciled row update had no pinning test; reverting would pass the suite silently. Impact: Mutation-checked both ways — fire-and-forget makes the test fail (the update error is swallowed and the entry is acked); the awaited form keeps the spec at 40/40. --- .../ask-queued-message-drain.spec.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index d2e8d8ed3e..7d342b4490 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -381,6 +381,53 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.isEmpty()).toBe(true) }) + it("releases the queued message when the reconciled row update fails", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("dedupe me") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + const messageId = result.queuedMessageId! + + const taskAccess = getQueueTaskTestAccess(task) + const updateClineMessage = vi.fn(async () => {}) + taskAccess.updateClineMessage = updateClineMessage + taskAccess.addToClineMessages = async (message) => { + taskAccess.clineMessages.push(message!) + return true + } + // First ack: all saves fail, releasing the entry queued while the row + // association is retained for a later redelivery. + const saveClineMessages = vi.fn().mockResolvedValue(false) + taskAccess.saveClineMessages = saveClineMessages + + vi.useFakeTimers() + try { + const first = task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images) + await vi.runAllTimersAsync() + await expect(first).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + expect(task.messageQueueService.messages).toHaveLength(1) + + // Redelivery: the reconciled row update fails. The failure must + // propagate and release the entry — it must NOT be acked/removed. + updateClineMessage.mockRejectedValueOnce(new Error("webview update failed")) + saveClineMessages.mockResolvedValue(true) + + await expect(task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images)).rejects.toThrow( + "webview update failed", + ) + expect(saveClineMessages).toHaveBeenCalledTimes(4) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["dedupe me"]) + }) + it("does not submit queued messages once the task is aborted", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") From af09e709465a50049d5264347baf2c963c5f0550 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 19:44:17 +0900 Subject: [PATCH 24/51] fix(task): gate queued-message drains against in-flight approval asks What: Task.ask now records the ask it blocks on in an inFlightAskGate for the duration of its pWaitFor, and submitUserMessage refuses to write the ask-response slot while a blocked ask is waiting on it when either a direct response already landed or the ask is one the claim path's queuedResponseForAsk gate refuses (command, use_mcp_server, tool). claimAndSubmitNextQueuedMessage consults the same gate before submitting and treats a refused submission as benign (leave queued) instead of a drain failure. Why: the drain path posted messageResponse into the slot unconditionally, so a queued conversational message could overwrite an already-landed direct response (an Approve click becoming denial-with-feedback) or answer an approval ask the claim path explicitly refuses, diverging from the claim path's safety semantics in ask-then-drain ordering. Impact: mid-block interception still works for resolvable asks (completion_result, finishTask/newTask, followup, api_req_failed); approval-gating asks can only be answered by explicit user responses. Existing interception tests now use completion_result asks, and new pins cover the blocked-approval gate, the no-overwrite rule, and the slot-refusal contract. All 45 drain-spec tests and 687 core/task tests pass. --- src/core/task/Task.ts | 92 +++++++++++++---- .../ask-queued-message-drain.spec.ts | 99 ++++++++++++++++--- 2 files changed, 157 insertions(+), 34 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3162cc3afc..86d1243a54 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -443,6 +443,11 @@ export class Task extends EventEmitter implements TaskLike { // submission was consumed (hand the ID to the caller for a durable ack) or // overwritten unconsumed (retain for a later ask). private pendingSubmittedQueuedMessageId: string | undefined + // The ask currently blocked in Task.ask's response wait, when any. A + // background drain consults queuedResponseForAsk against this gate so a + // queued conversational message can never answer an approval-gating ask, + // exactly matching the claim path's resolution gate. + private inFlightAskGate: { type: ClineAsk; text?: string } | undefined // Association between a queued message ID and the user_feedback row its ack // persisted. A redelivery after a partial save failure reconciles the same // row instead of appending a duplicate feedback row. @@ -1874,29 +1879,37 @@ export class Task extends EventEmitter implements TaskLike { queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) } - // Wait for askResponse to be set - await pWaitFor( - () => { - if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { - return true - } + // Wait for askResponse to be set. The drain gate is accurate for the + // whole wait: a background drain consults it so an approval-gating ask + // can never be answered by a queued conversational message (the same + // resolution gate the claim path applies). + this.inFlightAskGate = { type, text } + try { + await pWaitFor( + () => { + if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { + return true + } - // If a queued message arrives while we're blocked on an ask (e.g. a follow-up - // suggestion click that was incorrectly queued due to UI state), consume it - // immediately so the task doesn't hang. Approval-gating ask types get no - // resolution, so the message stays queued for a conversational turn. - if (shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { - const resolution = queuedResponseForAsk(type, text) - const message = resolution ? this.messageQueueService.claimNextMessage() : undefined - if (message && resolution) { - queuedMessageId = this.handleQueuedAskResponse(message, resolution) + // If a queued message arrives while we're blocked on an ask (e.g. a follow-up + // suggestion click that was incorrectly queued due to UI state), consume it + // immediately so the task doesn't hang. Approval-gating ask types get no + // resolution, so the message stays queued for a conversational turn. + if (shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { + const resolution = queuedResponseForAsk(type, text) + const message = resolution ? this.messageQueueService.claimNextMessage() : undefined + if (message && resolution) { + queuedMessageId = this.handleQueuedAskResponse(message, resolution) + } } - } - return false - }, - { interval: 100 }, - ) + return false + }, + { interval: 100 }, + ) + } finally { + this.inFlightAskGate = undefined + } /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ if (this.abort) { @@ -2117,6 +2130,23 @@ export class Task extends EventEmitter implements TaskLike { return false } + // Never overwrite a response an ask is blocked waiting on: an + // approval-gating ask (queuedResponseForAsk returns undefined) + // must not be answered by the queue, and a direct response that + // already landed in the slot must not be replaced (an approval + // would become a conversational answer). Outside an in-flight + // ask the slot only holds stale residue the next ask clears, so + // between-turn submissions proceed. Queue drains treat the + // returned false as "leave the message queued". + const inFlightGate = this.inFlightAskGate + if ( + inFlightGate && + (this.askResponse !== undefined || + queuedResponseForAsk(inFlightGate.type, inFlightGate.text) === undefined) + ) { + return false + } + this.emit(RooCodeEventName.TaskUserMessage, this.taskId) // Handle the message directly instead of routing through the webview. @@ -5802,6 +5832,16 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueService.releaseMessage(queued.id) return true } + // An approval-gating ask is blocked in its response wait. The claim path + // refuses to answer it with a queued conversational message; the drain + // must honor the same gate instead of posting messageResponse into the + // ask-response slot, so release the claim and leave the message queued + // for a conversational turn. + const inFlightGate = this.inFlightAskGate + if (inFlightGate && queuedResponseForAsk(inFlightGate.type, inFlightGate.text) === undefined) { + this.messageQueueService.releaseMessage(queued.id) + return true + } try { const submitted = await this.submitUserMessage(queued.text, queued.images, undefined, undefined, queued.id) if (!submitted) { @@ -5812,6 +5852,18 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueService.releaseMessage(queued.id) return false } + const gate = this.inFlightAskGate + if ( + gate && + (this.askResponse !== undefined || queuedResponseForAsk(gate.type, gate.text) === undefined) + ) { + // A direct response landed or an approval-gating ask is + // waiting: the submission was dropped to keep the slot + // intact, so leave the message queued instead of failing + // the drain. + this.messageQueueService.releaseMessage(queued.id) + return true + } throw new Error(`[Task] Failed to submit queued message ${queued.id}`) } if (this.abort || this.abandoned) { diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 7d342b4490..1f48e170f9 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -64,7 +64,7 @@ describe("Task.ask queued message drain", () => { it("acks a drained padded message through the consuming ask", async () => { const task = await createTask({ getState: async () => ({}) }) - const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + const askPromise = task.ask("completion_result", "Done", false) await new Promise((resolve) => setTimeout(resolve, 150)) // editQueuedMessage saves untrimmed text; submitUserMessage trims before @@ -95,9 +95,10 @@ describe("Task.ask queued message drain", () => { it("acks an intercepted drained message through the consuming ask", async () => { const task = await createTask({ getState: async () => ({}) }) - // Park a tool ask in the real pWaitFor: no auto-approval and nothing - // queued at ask start, so it blocks. - const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + // Park a completion ask in the real pWaitFor: no auto-approval and + // nothing queued at ask start, so it blocks. A completion ask has a + // queued-ask resolution, so a mid-block drain may intercept it. + const askPromise = task.ask("completion_result", "Done", false) // Let the ask reach its pWaitFor before the drain runs. await new Promise((resolve) => setTimeout(resolve, 150)) @@ -134,8 +135,9 @@ describe("Task.ask queued message drain", () => { const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) await new Promise((resolve) => setTimeout(resolve, 150)) - // The drain posts the message into the pending slot, but the user - // answers the blocked ask directly before any ask consumed it. + // The drain would post the message into the pending slot, but the + // approval-gating gate drops the submission instead; the user then + // answers the blocked ask directly. task.messageQueueService.addMessage("queued correction") await task.processQueuedMessages() setTimeout(() => task.approveAsk(), 0) @@ -178,7 +180,7 @@ describe("Task.ask queued message drain", () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") - const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + const askPromise = task.ask("completion_result", "Done", false) await new Promise((resolve) => setTimeout(resolve, 150)) task.messageQueueService.addMessage("queued correction") @@ -207,7 +209,7 @@ describe("Task.ask queued message drain", () => { it("retains an intercepted drained message until its history write succeeds", async () => { const task = await createTask({ getState: async () => ({}) }) - const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + const askPromise = task.ask("completion_result", "Done", false) await new Promise((resolve) => setTimeout(resolve, 150)) task.messageQueueService.addMessage("Keep this correction") @@ -247,7 +249,7 @@ describe("Task.ask queued message drain", () => { it("re-queues an intercepted drained message when its history write keeps failing", async () => { const task = await createTask({ getState: async () => ({}) }) - const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + const askPromise = task.ask("completion_result", "Done", false) await new Promise((resolve) => setTimeout(resolve, 150)) task.messageQueueService.addMessage("Do not lose me") @@ -284,9 +286,10 @@ describe("Task.ask queued message drain", () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") - // ReadFileTool-style blocked approval ask: the queue is empty at ask - // start, so a drain that posts mid-block intercepts the ask. - const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + // Blocked completion ask: the queue is empty at ask start, so a drain + // that posts mid-block intercepts the ask (the approval-gating gate + // does not apply to a resolvable ask). + const askPromise = task.ask("completion_result", "Done", false) await new Promise((resolve) => setTimeout(resolve, 150)) task.messageQueueService.addMessage("user correction") @@ -338,7 +341,7 @@ describe("Task.ask queued message drain", () => { it("keeps exactly one feedback row when a redelivery follows a partial save failure", async () => { const task = await createTask({ getState: async () => ({}) }) - const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + const askPromise = task.ask("completion_result", "Done", false) await new Promise((resolve) => setTimeout(resolve, 150)) task.messageQueueService.addMessage("dedupe me") @@ -384,7 +387,7 @@ describe("Task.ask queued message drain", () => { it("releases the queued message when the reconciled row update fails", async () => { const task = await createTask({ getState: async () => ({}) }) - const askPromise = task.ask("tool", JSON.stringify({ tool: "readFile" }), false) + const askPromise = task.ask("completion_result", "Done", false) await new Promise((resolve) => setTimeout(resolve, 150)) task.messageQueueService.addMessage("dedupe me") @@ -671,6 +674,74 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.isEmpty()).toBe(true) }) + it.each([ + ["command", "npm test"], + ["use_mcp_server", "{}"], + ["tool", JSON.stringify({ tool: "readFile" })], + ] as const)("keeps a drain from answering a blocked %s ask", async (type, text) => { + const task = await createTask({ getState: async () => ({}) }) // auto-approval disabled + const submitSpy = vi.spyOn(task, "submitUserMessage") + + // Park an approval-gating ask in the real pWaitFor. + const askPromise = task.ask(type, text, false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Background-completion style drain while the approval ask is blocked: + // the same queuedResponseForAsk gate the claim path applies must keep + // the queued conversational message out of the ask-response slot. + task.messageQueueService.addMessage("queued note") + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(submitSpy).not.toHaveBeenCalled() + + // The ask is still waiting for an explicit user response. + let settled = false + void askPromise.then(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(settled).toBe(false) + + setTimeout(() => task.approveAsk(), 0) + const result = await askPromise + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued note"]) + + // The retained message is delivered to a later conversational ask. + const followup = await task.ask("followup", "anything else?", false) + expect(followup).toMatchObject({ response: "messageResponse", text: "queued note" }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("refuses to overwrite a response a blocked ask is waiting on", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("followup", "Q?", false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + // A direct response (e.g. an Approve click) lands first; a drain racing + // behind it must not overwrite the slot the blocked ask is polling. + task.handleWebviewAskResponse("yesButtonClicked") + await expect(task.submitUserMessage("queued note", undefined, undefined, undefined, "queued-1")).resolves.toBe( + false, + ) + + const result = await askPromise + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + }) + + it("refuses to submit while an approval-gating ask is in flight", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("command", "npm test", false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + await expect(task.submitUserMessage("queued note")).resolves.toBe(false) + + setTimeout(() => task.approveAsk(), 0) + const result = await askPromise + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + }) + it("claims lifecycle feedback that arrives while an ask is waiting", async () => { const task = await createTask() const ask = task.ask("tool", JSON.stringify({ tool: "finishTask" }), false) From e7fbd33dfe8981381e5d7c4a74b9d58067b9c5aa Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 19:46:48 +0900 Subject: [PATCH 25/51] fix(task): clear the pending drain tracker when the durable ack settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: persistQueuedFeedbackAndAcknowledge now wraps its persistence retry loop in a finally that clears pendingSubmittedQueuedMessageId whenever it points at the message being settled. Why: when a drain submission was consumed through the durable claim path (completion_result/finishTask/newTask) and every persistence retry failed, the message was released back to the queue but the tracker stayed set. Every later drain then matched the stale tracker ID, released the claim, and returned without resubmitting — permanently stalling that message and starving every message behind it. Impact: a re-queued message is resubmitted by the next drain after an ack failure, success, abort, or failed row write alike. New pin test covers the drain-then-durable-claim-then-total-failure combination. 46 drain-spec tests pass. --- src/core/task/Task.ts | 48 ++++++++++++------- .../ask-queued-message-drain.spec.ts | 40 ++++++++++++++++ 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 86d1243a54..e56927307f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1019,27 +1019,39 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueService.releaseMessage(messageId) throw error } - for (let attempt = 0; attempt <= QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length; attempt++) { - if (this.abort || this.abandoned) { - this.messageQueueService.releaseMessage(messageId) - return false - } - if (await this.saveClineMessages()) { - this.queuedFeedbackRows.delete(messageId) - return this.messageQueueService.removeMessage(messageId) + try { + for (let attempt = 0; attempt <= QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length; attempt++) { + if (this.abort || this.abandoned) { + this.messageQueueService.releaseMessage(messageId) + return false + } + if (await this.saveClineMessages()) { + this.queuedFeedbackRows.delete(messageId) + return this.messageQueueService.removeMessage(messageId) + } + if (attempt < QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length) { + // Interruptible backoff: an abort or abandonment during the wait + // resolves promptly (releasing the claim at the loop-top check) + // instead of retaining the task through the full delay. + await this.waitForQueuedFeedbackBackoff(QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS[attempt]) + } } - if (attempt < QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length) { - // Interruptible backoff: an abort or abandonment during the wait - // resolves promptly (releasing the claim at the loop-top check) - // instead of retaining the task through the full delay. - await this.waitForQueuedFeedbackBackoff(QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS[attempt]) + console.error( + `[Task#persistQueuedFeedbackAndAcknowledge] Failed to durably save queued feedback ${messageId} after ${QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length + 1} attempts`, + ) + this.messageQueueService.releaseMessage(messageId) + return false + } finally { + // The drain-side tracker only guards a submitted message against + // re-submission while an ask has not consumed it yet. Once persistence + // settles — success (entry removed), failure (entry re-queued), abort, + // or a failed row write — a stale tracker would block every later + // drain from resubmitting this message (and starve the messages + // behind it), so release it on every exit path. + if (this.pendingSubmittedQueuedMessageId === messageId) { + this.pendingSubmittedQueuedMessageId = undefined } } - console.error( - `[Task#persistQueuedFeedbackAndAcknowledge] Failed to durably save queued feedback ${messageId} after ${QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length + 1} attempts`, - ) - this.messageQueueService.releaseMessage(messageId) - return false } private waitForQueuedFeedbackBackoff(ms: number): Promise { diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 1f48e170f9..f51a218943 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -282,6 +282,46 @@ describe("Task.ask queued message drain", () => { } }) + it("releases the drain tracker when a claimed durable ack keeps failing", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + + task.messageQueueService.addMessage("Do not lose me") + // Between-turns drain: submits the message and tracks it as pending. + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(submitSpy).toHaveBeenCalledTimes(1) + + // A completion ask claims the retained entry through the durable path; + // the drain-side tracker is still set while the ack runs. + const result = await task.ask("completion_result", "Done", false) + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + + const access = getQueueTaskTestAccess(task) + access.say = vi.fn().mockResolvedValue(undefined) + access.saveClineMessages = vi.fn().mockResolvedValue(false) + + vi.useFakeTimers() + try { + const persistence = task.persistQueuedFeedbackAndAcknowledge( + result.queuedMessageId!, + result.text, + result.images, + ) + await vi.runAllTimersAsync() + await expect(persistence).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + expect(access.saveClineMessages).toHaveBeenCalledTimes(4) + expect(task.messageQueueService.messages).toHaveLength(1) + + // The tracker must be cleared even though the message was re-queued, so + // this drain resubmits it instead of stalling on the stale pending ID + // (which would also starve every message behind it). + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(submitSpy).toHaveBeenCalledTimes(2) + }) + it("delivers an intercepted message exactly once when a consumer acks through the durable helper", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") From 9cb26861a1d41f381f0cadb22819cea1e2aed638 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 19:51:27 +0900 Subject: [PATCH 26/51] fix(tools): settle toolResultPublished on execa fallback and warning throw paths What: wrap the execa fallback retry and the shell_integration_warning arm in try/catch that settles the shared toolResultPublished signal with false and rethrows, so a throw before publishToolResult can no longer leave the signal pending. Why: the background-completion drain chain inside executeCommandInTerminal awaits toolResultPublished (line ~524) before draining queued messages. When the retry or the warning say threw first, the signal stayed unsettled: the chain awaited forever, the queued-message drain was lost, and the closure held task references. Impact: every exit path of the publication window now settles the signal exactly once (settling is idempotent). New pins cover the retry-throw and warning-say-throw arms reporting through handleError without publishing or draining. 50 executeCommandTool tests pass. --- src/core/tools/ExecuteCommandTool.ts | 44 ++++++++---- .../__tests__/executeCommandTool.spec.ts | 70 +++++++++++++++++++ 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 101f3bab01..39ab3ff0e3 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -239,23 +239,39 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { const status: CommandExecutionStatus = { executionId, status: "fallback" } postCommandExecutionStatus(provider, status) - const [rejected, result, commandSubmitted] = await executeCommandInTerminal(task, { - ...options, - terminalShellIntegrationDisabled: true, - }) - - if (rejected) { - task.didRejectTool = true + try { + const [rejected, result, commandSubmitted] = await executeCommandInTerminal(task, { + ...options, + terminalShellIntegrationDisabled: true, + }) + + if (rejected) { + task.didRejectTool = true + } + + publishToolResult(result) + shouldDrainQueuedMessages = commandSubmitted + } catch (fallbackError) { + // The retry never published: settle the signal (and + // rethrow) so the background-completion drain chain + // awaiting toolResultPublished cannot hang forever and + // hold task references. Settling is idempotent. + settleToolResultPublished?.(false) + throw fallbackError } - - publishToolResult(result) - shouldDrainQueuedMessages = commandSubmitted } else if (error instanceof ShellIntegrationError) { // Command was submitted but shell integration lost track of it — show warning. - await task.say("shell_integration_warning") - publishToolResult( - "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", - ) + try { + await task.say("shell_integration_warning") + publishToolResult( + "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", + ) + } catch (warningError) { + // Same hang hazard as the fallback arm: a throw before + // publishToolResult leaves the signal unsettled. + settleToolResultPublished?.(false) + throw warningError + } } else { // Ordinary execution error (e.g. the terminal failed to start) — // not a shell-integration failure, so it must not emit the diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 8e4e26a92d..a5617ba9ca 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -857,6 +857,76 @@ describe("executeCommandTool", () => { ) expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() }) + + it("settles the publication signal and reports when the execa fallback retry throws", async () => { + mockToolUse.params.command = "npm test" + mockToolUse.nativeArgs = { command: "npm test" } + // First attempt fails shell integration startup (retryable); the + // execa retry then fails with an ordinary terminal error before any + // result is published. + const shellError = new executeCommandModule.ShellIntegrationError("startup failed", false) + const retryError = new Error("terminal crashed during retry") + const failedProcess = Object.assign(Promise.reject(shellError), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + const retryProcess = Object.assign(Promise.reject(retryError), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + vitest + .mocked(TerminalRegistry.getOrCreateTerminal) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockReturnValue(failedProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + .mockResolvedValueOnce({ + runCommand: vitest.fn().mockReturnValue(retryProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + // The retry error is reported through the tool error path and no + // result is published; the catch arm also settles the shared + // toolResultPublished signal (false) so the background-completion + // drain chain awaiting it cannot hang forever and hold task refs. + expect(mockHandleError).toHaveBeenCalledWith("executing command", retryError) + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + }) + + it("settles the publication signal and reports when the shell-integration warning say throws", async () => { + mockToolUse.params.command = "npm test" + mockToolUse.nativeArgs = { command: "npm test" } + // Command was submitted but shell integration lost track of it + // (non-retryable); the warning say then fails before publication. + const shellError = new executeCommandModule.ShellIntegrationError("lost track", true) + const failedProcess = Object.assign(Promise.reject(shellError), { + continue: vitest.fn(), + abort: vitest.fn(), + }) + vitest.mocked(TerminalRegistry.getOrCreateTerminal).mockResolvedValueOnce({ + runCommand: vitest.fn().mockReturnValue(failedProcess), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + } as never) + const sayError = new Error("webview gone") + mockCline.say.mockRejectedValueOnce(sayError) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("executing command", sayError) + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockCline.processQueuedMessages).not.toHaveBeenCalled() + }) }) describe("Command execution timeout configuration", () => { From e7613b161cf4e0d7757d45242506d0dca1535990 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 19:53:51 +0900 Subject: [PATCH 27/51] fix(task): discard queued messages consumed by the truncation retry gate What: the OutputTokenLimitError api_req_failed arm now destructures queuedMessageId from the ask result and drops it via discardConsumedQueuedMessage, matching the sibling api_req_failed arms. Why: the merge-imported arm ignored queuedMessageId, so when interception delivered a queued message to this retry gate the entry stayed queued: a later drain or claim could redeliver identical text, and the declined retry aborts the task with the queue entry still live. Impact: the consumed entry is removed without inventing a history write. The retry gate deliberately stays answerable by a queued message (unlike command_output gating): it gates no execution, and a typed no that declines the retry destroys the queue either way. New pin test drives the mid-stream truncation loop and asserts the entry is discarded. 3/3 output-token-limit tests pass. --- src/core/task/Task.ts | 9 ++++++++- src/core/task/__tests__/Task.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e56927307f..3c8099beaa 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4061,7 +4061,14 @@ export class Task extends EventEmitter implements TaskLike { } else if (error instanceof OutputTokenLimitError) { // Truncation repeats on an identical request, so never auto-retry it // (even with auto-approval); let the user decide once. - const { response } = await this.ask("api_req_failed", rawErrorMessage) + const { response, queuedMessageId } = await this.ask("api_req_failed", rawErrorMessage) + // Only the button response is inspected; a consumed queued + // message is dropped without inventing a history write (same + // as the sibling api_req_failed asks below). The retry gate + // stays answerable by a queued message: unlike command_output + // asks it does not gate execution, and a typed "no" that + // aborts the task destroys the queue either way. + this.discardConsumedQueuedMessage(queuedMessageId) if (response !== "yesButtonClicked") { throw new Error("API request failed") diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index eb738aab2d..bbf8983792 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -559,6 +559,28 @@ describe("Cline", () => { expect(askSpy).toHaveBeenCalledWith("api_req_failed", expect.stringContaining("Output token limit reached")) }) + it("discards a queued message consumed by the truncation retry gate", async () => { + const task = await createAutoApprovedTask() + // Simulate the ask claim path consuming the queued message as its answer. + vi.spyOn(task, "ask").mockImplementation(async () => { + const message = task.messageQueueService.claimNextMessage() + return { + response: "noButtonClicked", + text: message?.text, + queuedMessageId: message?.id, + } satisfies TaskAskResult + }) + vi.spyOn(task, "attemptApiRequest").mockImplementation(() => truncatedStream()) + task.messageQueueService.addMessage("do not redeliver me") + + await task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }]) + + // The consumed entry is discarded like the sibling api_req_failed + // arms, not left queued for a redelivery that can never be acked: + // the declined retry destroys the task queue either way. + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + it("retries from a fresh attempt count when the user confirms", async () => { const task = await createAutoApprovedTask() vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) From b38f1961ab48cd409d7d84fa3cee01bd46bdd3eb Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 19:56:15 +0900 Subject: [PATCH 28/51] fix(task): associate queued feedback rows before the history append What: persistQueuedFeedbackAndAcknowledge now registers the queuedFeedbackRows association before awaiting addToClineMessages instead of after it. Why: the feedback row is pushed to clineMessages inside the append, and the append's only uncaught throw is a synchronously throwing Message listener, which runs after that push. With the association recorded only after the append returned, the throw left a pushed-but-unregistered row: the entry was released for redelivery, which then appended a duplicate feedback row to history. Impact: a throw after the push now keeps the row associated, so a redelivery reconciles the same row via updateClineMessage. The existing idempotent-redelivery tests still pass (47/47 in the drain spec), and a new pin covers the throwing-listener window end to end. --- src/core/task/Task.ts | 9 ++-- .../ask-queued-message-drain.spec.ts | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3c8099beaa..d6f79a29d7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1006,11 +1006,14 @@ export class Task extends EventEmitter implements TaskLike { } // Mirrors say()'s interactive user_feedback append: bump // lastMessageTs and let the retry loop below own persistence. - // The association is recorded only after the append succeeds so - // a redelivery can never reconcile a row that was never added. + // The association is registered before the append: the append's + // only uncaught throw is a synchronously throwing Message + // listener, which runs after the row is already pushed, so a + // redelivery can reconcile the same row instead of appending a + // duplicate feedback row. this.lastMessageTs = row.ts - await this.addToClineMessages(row) this.queuedFeedbackRows.set(messageId, row) + await this.addToClineMessages(row) } } catch (error) { // A failed write must not leave the message claimed: release it so a diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index f51a218943..6e7e4efe3b 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -13,6 +13,7 @@ type QueueTaskTestAccess = { abort: boolean abandoned: boolean queuedMessageDrainChain: Promise + emit: (...args: never[]) => void } const getQueueTaskTestAccess = (task: Task) => task as unknown as QueueTaskTestAccess @@ -424,6 +425,47 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.isEmpty()).toBe(true) }) + it("associates the feedback row before the append so a throwing listener cannot strand it", async () => { + const task = await createTask({ getState: async () => ({}) }) + task.messageQueueService.addMessage("dedupe me") + const result = await task.ask("completion_result", "Done", false) + const messageId = result.queuedMessageId! + + const access = getQueueTaskTestAccess(task) + // Mirror the real append: the row is pushed before the Message emit, + // and a consumer-attached listener can throw synchronously. + access.addToClineMessages = vi.fn(async (message?: ClineMessage) => { + access.clineMessages.push(message!) + access.emit("message") + return true + }) + access.emit = vi.fn(() => { + throw new Error("listener boom") + }) + + await expect(task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images)).rejects.toThrow( + "listener boom", + ) + + // The pushed row must stay associated with the queue entry, and the + // failed write must release the entry for redelivery. + expect(access.clineMessages.filter((message) => message.say === "user_feedback")).toHaveLength(1) + expect(access.queuedFeedbackRows.has(messageId)).toBe(true) + expect(task.messageQueueService.messages).toHaveLength(1) + + // Redelivery reconciles the same row instead of appending a duplicate. + access.emit = vi.fn() + const updateClineMessage = vi.fn(async () => {}) + access.updateClineMessage = updateClineMessage + access.saveClineMessages = vi.fn(async () => true) + await expect(task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images)).resolves.toBe( + true, + ) + expect(updateClineMessage).toHaveBeenCalledTimes(1) + expect(access.clineMessages.filter((message) => message.say === "user_feedback")).toHaveLength(1) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + it("releases the queued message when the reconciled row update fails", async () => { const task = await createTask({ getState: async () => ({}) }) From 8eba8143bd2a11e23d2b8c27207447b8f52cae9d Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 19:58:22 +0900 Subject: [PATCH 29/51] fix(task,assistant-message): ack queued feedback before it enters the API turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: in presentAssistantMessage's tool-repetition arm and Task's mistake_limit_reached arm, sayUserFeedbackAndAckQueued now runs before the feedback text is pushed into the API-turn content. Why: the previous order pushed the text into the model-visible turn first, so a failed durable ack (persist retries exhausted) left the feedback in the request while the queue entry was re-queued — a duplicate-delivery window on the redelivery. AttemptCompletionTool already acks first; the two queued-ack consumers now match that ordering. Impact: when the ack fails, the error propagates before the API content mutates, so redelivery stays exactly-once. New pin test asserts the API turn stays empty when the ack rejects. 20/20 attribution-spec tests pass. --- ...tantMessage-tool-usage-attribution.spec.ts | 29 +++++++++++++++++++ .../presentAssistantMessage.ts | 9 ++++-- src/core/task/Task.ts | 8 +++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index 12c2859c02..e1d8bfab4d 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -738,6 +738,35 @@ describe("presentAssistantMessage - tool usage attribution", () => { }), ) }) + + it("acks tool-repetition feedback before it reaches the API turn", async () => { + mockTask.toolRepetitionDetector.check = vi.fn().mockReturnValue({ + allowExecution: false, + askUser: { + messageKey: "mistake_limit_reached", + messageDetail: "The tool {toolName} was called consecutively without progress.", + }, + }) + mockTask.apiConfiguration = { apiProvider: providerIdentifiers.anthropic } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_repetition_feedback", + name: "read_file", + params: { path: "a.txt" }, + nativeArgs: { path: "a.txt" }, + partial: false, + }, + ] + mockTask.ask = vi.fn().mockResolvedValue({ response: "messageResponse", text: "Try another approach" }) + mockTask.sayUserFeedbackAndAckQueued = vi.fn().mockRejectedValue(new Error("persist failed")) + + await expect(presentAssistantMessage(mockTask as unknown as Task)).rejects.toThrow("persist failed") + + // The durable ack failed and the entry is re-queued for redelivery: + // the feedback must not already be part of the API turn. + expect(mockTask.userMessageContent).toHaveLength(0) + }) }) describe("undefined provider state", () => { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 47dfd7d71a..8c78821e7a 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -741,6 +741,12 @@ export async function presentAssistantMessage(cline: Task) { ) if (response === "messageResponse") { + // Durable ack first: the feedback must reach history before + // the API turn. When the ack fails the entry is re-queued + // for redelivery, and the model must not already have seen + // the text (mirrors AttemptCompletionTool's ordering). + await cline.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) + // Add user feedback to userContent. cline.userMessageContent.push( { @@ -749,9 +755,6 @@ export async function presentAssistantMessage(cline: Task) { }, ...formatResponse.imageBlocks(images), ) - - // Add user feedback to chat. - await cline.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) } // Track tool repetition in telemetry via PostHog exception tracking and event. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d6f79a29d7..153b8cc12c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3363,14 +3363,18 @@ export class Task extends EventEmitter implements TaskLike { ) if (response === "messageResponse") { + // Durable ack first: the feedback must reach history before + // the API turn. When the ack fails the entry is re-queued for + // redelivery, and the model must not already have seen the text + // (mirrors AttemptCompletionTool's ordering). + await this.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) + currentUserContent.push( ...[ { type: "text" as const, text: formatResponse.tooManyMistakes(text) }, ...formatResponse.imageBlocks(images), ], ) - - await this.sayUserFeedbackAndAckQueued(text, images, queuedMessageId) } this.consecutiveMistakeCount = 0 From cf554c75cb4c592d6854fe141c53a1ac4d7c2898 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:01:35 +0900 Subject: [PATCH 30/51] fix(webview): validate queued-message edits and handle condense rejection What: editQueuedMessage now routes its text/images through the same resolveIncomingImages validation as queueMessage before updating the entry, and the condenseTaskContextRequest handler wraps provider.condenseTaskContext in a try/catch that logs failures through provider.log instead of letting them escape the message handler. Why: edits can attach images, but the edit path bypassed the size/mention validation applied to fresh queued messages, letting oversized images into the queue. And condenseContext now ends by draining queued messages, whose failed submission rejects: the unguarded await turned that into an unhandled rejection from the webview message handler. Impact: edited queued messages carry only validated images, and a condense-time drain failure is observable in the output log. Two new webviewMessageHandler tests pin both behaviors (83/83 in the spec). --- .../__tests__/webviewMessageHandler.spec.ts | 46 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 16 ++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..113f1c07e6 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2273,3 +2273,49 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) }) + +describe("webviewMessageHandler - chat message queue", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.getState).mockResolvedValue({} as never) + }) + + it("routes editQueuedMessage through the same image validation as fresh queued messages", async () => { + const { MessageQueueService } = await import("../../message-queue/MessageQueueService") + const queue = new MessageQueueService() + const added = queue.addMessage("original")! + const updateSpy = vi.spyOn(queue, "updateMessage") + vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ + cwd: "/mock/workspace", + rooIgnoreController: undefined, + messageQueueService: queue, + } as unknown as ReturnType) + + await webviewMessageHandler(mockClineProvider, { + type: "editQueuedMessage", + payload: { id: added.id, text: "edited", images: [] }, + }) + + // resolveImageMentions is mocked to tag validated payloads; the edited + // message must carry the validated images, proving the edit path no + // longer bypasses the size/mention validation applied to queueMessage. + expect(resolveImageMentions).toHaveBeenCalled() + expect(updateSpy).toHaveBeenCalledWith(added.id, "edited", ["data:image/png;base64,from-mention"]) + }) + + it("logs instead of leaking when condenseTaskContext rejects", async () => { + const condenseError = new Error("queued submission failed") + const providerWithCondense = mockClineProvider as unknown as { + condenseTaskContext: ReturnType + } + providerWithCondense.condenseTaskContext = vi.fn().mockRejectedValue(condenseError) + + await expect( + webviewMessageHandler(mockClineProvider, { type: "condenseTaskContextRequest", text: "task-1" }), + ).resolves.toBeUndefined() + + expect(mockClineProvider.log).toHaveBeenCalledWith( + "[condenseTaskContextRequest] Failed: queued submission failed", + ) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4ba94d454c..93df90f48b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -908,7 +908,16 @@ export const webviewMessageHandler = async ( await provider.showTaskWithId(message.text!) break case "condenseTaskContextRequest": - await provider.condenseTaskContext(message.text!) + try { + await provider.condenseTaskContext(message.text!) + } catch (error) { + // condenseContext drains queued messages after summarizing, and a + // failed submission rejects: surface it in the log instead of + // leaking an unhandled rejection from the message handler. + provider.log( + `[condenseTaskContextRequest] Failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } break case "deleteTaskWithId": await provider.deleteTaskWithId(message.text!) @@ -3812,7 +3821,10 @@ export const webviewMessageHandler = async ( case "editQueuedMessage": { if (message.payload) { const { id, text, images } = message.payload as EditQueuedMessagePayload - provider.getCurrentTask()?.messageQueueService.updateMessage(id, text, images) + // Edits can attach images too, so they go through the same + // size/mention validation as fresh queued messages. + const resolved = await resolveIncomingImages({ text, images }) + provider.getCurrentTask()?.messageQueueService.updateMessage(id, resolved.text, resolved.images) } break From 306626b932faded3dc97270e0b4ca311fe3e3400 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:05:04 +0900 Subject: [PATCH 31/51] test(task): widen the drain-spec emit test-double signature What: the QueueTaskTestAccess emit type now accepts (event: string, ...args: unknown[]) so the throwing-listener test can invoke it. Why: the never[] parameter type failed tsc --noEmit after check-types caught it; the emit double is only called with a dummy event name. Impact: check-types passes; no runtime change. --- src/core/task/__tests__/ask-queued-message-drain.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 6e7e4efe3b..8eb1ee4219 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -13,7 +13,7 @@ type QueueTaskTestAccess = { abort: boolean abandoned: boolean queuedMessageDrainChain: Promise - emit: (...args: never[]) => void + emit: (event: string, ...args: unknown[]) => void } const getQueueTaskTestAccess = (task: Task) => task as unknown as QueueTaskTestAccess From c14114d5540971657727523febc9d27a6c540682 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 22:23:34 +0900 Subject: [PATCH 32/51] fix(task): harden queued-drain guards (tracker release, gate arming, predicate helper) What: three drain-guard corrections in Task. (1) persistQueuedFeedbackAndAcknowledge now releases pendingSubmittedQueuedMessageId through a shared releasePendingTracker helper that runs on EVERY exit path: the retry loop's finally AND the row-write catch before it rethrows. (2) Task.ask arms inFlightAskGate at the top of the method, before its first await, and holds it through a whole-body try/finally, so a drain landing in the ask prefix (auto-approval or partial handling) hits the same gate as one landing in the response wait. (3) The triplicated gate predicate in submitUserMessage and claimAndSubmitNextQueuedMessage is extracted into inFlightAskBlocksQueuedSubmission(). Why: (1) a failed row write rethrew past the finally, leaving the tracker stale so the re-queued message could never be resubmitted and every message behind it starved. (2) the gate was only armed at the response wait, so a drain in the prefix window could post messageResponse into an approval-gating ask, violating the stated invariant. (3) three copies of the predicate could drift. Impact: every tracker release path is pinned (retry failure, abort, row write throw); the gate covers the full ask lifecycle; the predicate has a single definition. Two new drain-spec pins cover the row-write rethrow and the prefix window. 48/48 drain-spec tests pass; full core suite green (78 files, 1426 passed). --- src/core/task/Task.ts | 681 +++++++++--------- .../ask-queued-message-drain.spec.ts | 104 +-- 2 files changed, 402 insertions(+), 383 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 153b8cc12c..051428fa0e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -448,6 +448,17 @@ export class Task extends EventEmitter implements TaskLike { // queued conversational message can never answer an approval-gating ask, // exactly matching the claim path's resolution gate. private inFlightAskGate: { type: ClineAsk; text?: string } | undefined + /** + * True while a queued submission must not be posted: an ask is in flight + * AND either its slot already carries a direct response or the ask type is + * one the claim-path gate (queuedResponseForAsk) refuses. Outside an + * in-flight ask the slot holds only stale residue the next ask clears, so + * between-turn submissions proceed. + */ + private inFlightAskBlocksQueuedSubmission(): boolean { + const gate = this.inFlightAskGate + return !!gate && (this.askResponse !== undefined || queuedResponseForAsk(gate.type, gate.text) === undefined) + } // Association between a queued message ID and the user_feedback row its ack // persisted. A redelivery after a partial save failure reconciles the same // row instead of appending a duplicate feedback row. @@ -986,43 +997,57 @@ export class Task extends EventEmitter implements TaskLike { images?: string[], ): Promise { let row = this.queuedFeedbackRows.get(messageId) - try { - if (row) { - // Redelivery after a partial save failure (e.g. the message file - // was written but metadata persistence failed): reconcile the same - // row instead of appending a duplicate feedback row. Awaited so a - // failed webview update reaches the catch below, which releases - // the queued message instead of acking over a stale row. - row.text = text ?? "" - row.images = images - await this.updateClineMessage(row) - } else { - row = { - ts: Date.now(), - type: "say", - say: "user_feedback", - text: text ?? "", - images, - } - // Mirrors say()'s interactive user_feedback append: bump - // lastMessageTs and let the retry loop below own persistence. - // The association is registered before the append: the append's - // only uncaught throw is a synchronously throwing Message - // listener, which runs after the row is already pushed, so a - // redelivery can reconcile the same row instead of appending a - // duplicate feedback row. - this.lastMessageTs = row.ts - this.queuedFeedbackRows.set(messageId, row) - await this.addToClineMessages(row) + // The drain-side tracker only guards a submitted message against + // re-submission while an ask has not consumed it yet. Once persistence + // settles — success (entry removed), failure (entry re-queued), abort, + // or a failed row write — a stale tracker would block every later + // drain from resubmitting this message (and starve the messages + // behind it), so release it on every exit path. + const releasePendingTracker = () => { + if (this.pendingSubmittedQueuedMessageId === messageId) { + this.pendingSubmittedQueuedMessageId = undefined } - } catch (error) { - // A failed write must not leave the message claimed: release it so a - // later drain can redeliver it. (No-op when the drain path already - // released the claim.) - this.messageQueueService.releaseMessage(messageId) - throw error } try { + try { + if (row) { + // Redelivery after a partial save failure (e.g. the message file + // was written but metadata persistence failed): reconcile the same + // row instead of appending a duplicate feedback row. Awaited so a + // failed webview update reaches the catch below, which releases + // the queued message instead of acking over a stale row. + row.text = text ?? "" + row.images = images + await this.updateClineMessage(row) + } else { + row = { + ts: Date.now(), + type: "say", + say: "user_feedback", + text: text ?? "", + images, + } + // Mirrors say()'s interactive user_feedback append: bump + // lastMessageTs and let the retry loop below own persistence. + // The association is registered before the append: the append's + // only uncaught throw is a synchronously throwing Message + // listener, which runs after the row is already pushed, so a + // redelivery can reconcile the same row instead of appending a + // duplicate feedback row. + this.lastMessageTs = row.ts + this.queuedFeedbackRows.set(messageId, row) + await this.addToClineMessages(row) + } + } catch (error) { + // A failed write must not leave the message claimed: release it so a + // later drain can redeliver it. (No-op when the drain path already + // released the claim.) The pending tracker is released here too: + // this catch rethrows past the finally below, which must not be + // the only place that clears it. + this.messageQueueService.releaseMessage(messageId) + releasePendingTracker() + throw error + } for (let attempt = 0; attempt <= QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length; attempt++) { if (this.abort || this.abandoned) { this.messageQueueService.releaseMessage(messageId) @@ -1045,15 +1070,7 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueService.releaseMessage(messageId) return false } finally { - // The drain-side tracker only guards a submitted message against - // re-submission while an ask has not consumed it yet. Once persistence - // settles — success (entry removed), failure (entry re-queued), abort, - // or a failed row write — a stale tracker would block every later - // drain from resubmitting this message (and starve the messages - // behind it), so release it on every exit path. - if (this.pendingSubmittedQueuedMessageId === messageId) { - this.pendingSubmittedQueuedMessageId = undefined - } + releasePendingTracker() } } @@ -1685,221 +1702,225 @@ export class Task extends EventEmitter implements TaskLike { throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } - let askTs: number - - // Resolve auto-approval before adding the message so the state snapshot - // sent to the webview already carries isAnswered:true when the ask will - // be immediately resolved. This eliminates the race between the state - // update (which shows approval buttons) and the former separate - // clearApprovalButtons message (which could arrive before buttons were - // rendered, leaving them stuck on-screen). - const provider = this.providerRef.deref() - const state = provider ? await provider.getState() : undefined - // Resolve before claiming: approval-gating ask types never consume a - // queued conversational message, and claiming without a resolution - // would leak the claim. The resolution is only actionable when a - // message was actually claimed: otherwise it must not force a manual - // ask (that would bypass auto-approval for empty-queue lifecycle asks). - const claimableResolution = - partial === true || type === "command_output" ? undefined : queuedResponseForAsk(type, text) - const queuedMessage = claimableResolution ? this.messageQueueService.claimNextMessage() : undefined - const queuedAskResolution = queuedMessage ? claimableResolution : undefined - // `this.cwd`, not `provider.cwd`: - // The path inside `text` was made relative to this task's workspace, - // which for a resumed or child task need not be the one the provider - // currently reports. - const approval = queuedAskResolution - ? ({ decision: "ask" } as const) - : await checkAutoApproval({ state, cwd: this.cwd, ask: type, text, isProtected }) - const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" - const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined - - if (partial !== undefined) { - const lastMessage = this.clineMessages.at(-1) - - const isUpdatingPreviousPartial = - lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type - - if (partial) { - if (isUpdatingPreviousPartial) { - // Existing partial message, so update it. - lastMessage.text = text - lastMessage.partial = partial - lastMessage.progressStatus = progressStatus - lastMessage.isProtected = isProtected - // TODO: Be more efficient about saving and posting only new - // data or one whole message at a time so ignore partial for - // saves, and only post parts of partial message instead of - // whole array in new listener. - // Fire-and-forget: the webview post is internally guarded, but - // the `RooCodeEventName.Message` emit can synchronously throw - // if any consumer-attached listener does, which would surface - // here as an unhandled rejection. Log it instead. - this.updateClineMessage(lastMessage).catch((error) => { - console.error("[Task#ask] updateClineMessage failed:", error) - }) - // console.log("Task#ask: current ask promise was ignored (#1)") - throw new AskIgnoredError("updating existing partial") - } else { - // This is a new partial message, so add it with partial - // state. - askTs = Date.now() - this.lastMessageTs = askTs - await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected }) - // console.log("Task#ask: current ask promise was ignored (#2)") - throw new AskIgnoredError("new partial") - } - } else { - if (isUpdatingPreviousPartial) { - // This is the complete version of a previously partial - // message, so replace the partial with the complete version. - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.askResponseQueuedMessageId = undefined - - // Bug for the history books: - // In the webview we use the ts as the chatrow key for the - // virtuoso list. Since we would update this ts right at the - // end of streaming, it would cause the view to flicker. The - // key prop has to be stable otherwise react has trouble - // reconciling items between renders, causing unmounting and - // remounting of components (flickering). - // The lesson here is if you see flickering when rendering - // lists, it's likely because the key prop is not stable. - // So in this case we must make sure that the message ts is - // never altered after first setting it. - askTs = lastMessage.ts - this.lastMessageTs = askTs - lastMessage.text = text - lastMessage.partial = false - lastMessage.progressStatus = progressStatus - lastMessage.isProtected = isProtected - if (isAutoAnswered) { - lastMessage.isAnswered = true - lastMessage.autoApprovalDecision = autoApprovalDecision + // Arm the drain gate before the first await of this ask and hold it for + // the whole lifecycle: a queued submission landing anywhere in the ask + // prefix (auto-approval, partial handling) must hit the same + // queuedResponseForAsk gate as one landing in the response wait, so an + // approval-gating ask can never be answered by the queue. + this.inFlightAskGate = { type, text } + try { + let askTs: number + + // Resolve auto-approval before adding the message so the state snapshot + // sent to the webview already carries isAnswered:true when the ask will + // be immediately resolved. This eliminates the race between the state + // update (which shows approval buttons) and the former separate + // clearApprovalButtons message (which could arrive before buttons were + // rendered, leaving them stuck on-screen). + const provider = this.providerRef.deref() + const state = provider ? await provider.getState() : undefined + // Resolve before claiming: approval-gating ask types never consume a + // queued conversational message, and claiming without a resolution + // would leak the claim. The resolution is only actionable when a + // message was actually claimed: otherwise it must not force a manual + // ask (that would bypass auto-approval for empty-queue lifecycle asks). + const claimableResolution = + partial === true || type === "command_output" ? undefined : queuedResponseForAsk(type, text) + const queuedMessage = claimableResolution ? this.messageQueueService.claimNextMessage() : undefined + const queuedAskResolution = queuedMessage ? claimableResolution : undefined + // `this.cwd`, not `provider.cwd`: + // The path inside `text` was made relative to this task's workspace, + // which for a resumed or child task need not be the one the provider + // currently reports. + const approval = queuedAskResolution + ? ({ decision: "ask" } as const) + : await checkAutoApproval({ state, cwd: this.cwd, ask: type, text, isProtected }) + const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" + const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined + + if (partial !== undefined) { + const lastMessage = this.clineMessages.at(-1) + + const isUpdatingPreviousPartial = + lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type + + if (partial) { + if (isUpdatingPreviousPartial) { + // Existing partial message, so update it. + lastMessage.text = text + lastMessage.partial = partial + lastMessage.progressStatus = progressStatus + lastMessage.isProtected = isProtected + // TODO: Be more efficient about saving and posting only new + // data or one whole message at a time so ignore partial for + // saves, and only post parts of partial message instead of + // whole array in new listener. + // Fire-and-forget: the webview post is internally guarded, but + // the `RooCodeEventName.Message` emit can synchronously throw + // if any consumer-attached listener does, which would surface + // here as an unhandled rejection. Log it instead. + this.updateClineMessage(lastMessage).catch((error) => { + console.error("[Task#ask] updateClineMessage failed:", error) + }) + // console.log("Task#ask: current ask promise was ignored (#1)") + throw new AskIgnoredError("updating existing partial") + } else { + // This is a new partial message, so add it with partial + // state. + askTs = Date.now() + this.lastMessageTs = askTs + await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected }) + // console.log("Task#ask: current ask promise was ignored (#2)") + throw new AskIgnoredError("new partial") } - await this.saveClineMessages() - // Fire-and-forget: see updateClineMessage call above for the - // rationale on the .catch arm. - this.updateClineMessage(lastMessage).catch((error) => { - console.error("[Task#ask] updateClineMessage failed:", error) - }) } else { - // This is a new and complete message, so add it like normal. - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.askResponseQueuedMessageId = undefined - askTs = Date.now() - this.lastMessageTs = askTs - await this.addToClineMessages({ - ts: askTs, - type: "ask", - ask: type, - text, - isProtected, - isAnswered: isAutoAnswered || undefined, - autoApprovalDecision, - }) + if (isUpdatingPreviousPartial) { + // This is the complete version of a previously partial + // message, so replace the partial with the complete version. + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined + + // Bug for the history books: + // In the webview we use the ts as the chatrow key for the + // virtuoso list. Since we would update this ts right at the + // end of streaming, it would cause the view to flicker. The + // key prop has to be stable otherwise react has trouble + // reconciling items between renders, causing unmounting and + // remounting of components (flickering). + // The lesson here is if you see flickering when rendering + // lists, it's likely because the key prop is not stable. + // So in this case we must make sure that the message ts is + // never altered after first setting it. + askTs = lastMessage.ts + this.lastMessageTs = askTs + lastMessage.text = text + lastMessage.partial = false + lastMessage.progressStatus = progressStatus + lastMessage.isProtected = isProtected + if (isAutoAnswered) { + lastMessage.isAnswered = true + lastMessage.autoApprovalDecision = autoApprovalDecision + } + await this.saveClineMessages() + // Fire-and-forget: see updateClineMessage call above for the + // rationale on the .catch arm. + this.updateClineMessage(lastMessage).catch((error) => { + console.error("[Task#ask] updateClineMessage failed:", error) + }) + } else { + // This is a new and complete message, so add it like normal. + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined + askTs = Date.now() + this.lastMessageTs = askTs + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + isProtected, + isAnswered: isAutoAnswered || undefined, + autoApprovalDecision, + }) + } } + } else { + // This is a new non-partial message, so add it like normal. + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined + askTs = Date.now() + this.lastMessageTs = askTs + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + isProtected, + isAnswered: isAutoAnswered || undefined, + autoApprovalDecision, + }) } - } else { - // This is a new non-partial message, so add it like normal. - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.askResponseQueuedMessageId = undefined - askTs = Date.now() - this.lastMessageTs = askTs - await this.addToClineMessages({ - ts: askTs, - type: "ask", - ask: type, - text, - isProtected, - isAnswered: isAutoAnswered || undefined, - autoApprovalDecision, - }) - } - const timeouts: NodeJS.Timeout[] = [] - - if (approval.decision === "approve") { - this.approveAsk() - } else if (approval.decision === "deny") { - this.denyAsk() - } else if (approval.decision === "timeout") { - // Store the auto-approval timeout so it can be cancelled if user interacts - this.autoApprovalTimeoutRef = setTimeout(() => { - const { askResponse, text, images } = approval.fn() - this.handleWebviewAskResponse(askResponse, text, images) - this.autoApprovalTimeoutRef = undefined - }, approval.timeout) - timeouts.push(this.autoApprovalTimeoutRef) - } - - // The state is mutable if the message is complete and the task will - // block (via the `pWaitFor`). - const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) - const isMessageQueued = !this.messageQueueService.isEmpty() - // Keep queued user messages intact during command_output asks. Those asks - // are terminal flow-control, not conversational turns. - const shouldDrainQueuedMessageForAsk = type !== "command_output" - const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" - - let queuedMessageId: string | undefined - if (isStatusMutable) { - const statusMutationTimeout = 2_000 - - if (isInteractiveAsk(type)) { - timeouts.push( - setTimeout(() => { - const message = this.findMessageByTimestamp(askTs) - - if (message) { - this.interactiveAsk = message - this.emit(RooCodeEventName.TaskInteractive, this.taskId) - /* v8 ignore next 3 -- fires inside 2s timer after ask() resolves; not reachable in unit tests */ - void provider?.postMessageToWebview({ type: "interactionRequired" }).catch((error) => { - console.error("[Task#ask] postMessageToWebview interactionRequired failed:", error) - }) - } - }, statusMutationTimeout), - ) - } else if (isResumableAsk(type)) { - timeouts.push( - setTimeout(() => { - const message = this.findMessageByTimestamp(askTs) - - if (message) { - this.resumableAsk = message - this.emit(RooCodeEventName.TaskResumable, this.taskId) - } - }, statusMutationTimeout), - ) - } else if (isIdleAsk(type)) { - timeouts.push( - setTimeout(() => { - const message = this.findMessageByTimestamp(askTs) - - if (message) { - this.idleAsk = message - this.emit(RooCodeEventName.TaskIdle, this.taskId) - } - }, statusMutationTimeout), - ) + const timeouts: NodeJS.Timeout[] = [] + + if (approval.decision === "approve") { + this.approveAsk() + } else if (approval.decision === "deny") { + this.denyAsk() + } else if (approval.decision === "timeout") { + // Store the auto-approval timeout so it can be cancelled if user interacts + this.autoApprovalTimeoutRef = setTimeout(() => { + const { askResponse, text, images } = approval.fn() + this.handleWebviewAskResponse(askResponse, text, images) + this.autoApprovalTimeoutRef = undefined + }, approval.timeout) + timeouts.push(this.autoApprovalTimeoutRef) } - } else if (isMessageQueued && shouldDrainQueuedMessageForAsk && queuedMessage && queuedAskResolution) { - queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) - } - // Wait for askResponse to be set. The drain gate is accurate for the - // whole wait: a background drain consults it so an approval-gating ask - // can never be answered by a queued conversational message (the same - // resolution gate the claim path applies). - this.inFlightAskGate = { type, text } - try { + // The state is mutable if the message is complete and the task will + // block (via the `pWaitFor`). + const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) + const isMessageQueued = !this.messageQueueService.isEmpty() + // Keep queued user messages intact during command_output asks. Those asks + // are terminal flow-control, not conversational turns. + const shouldDrainQueuedMessageForAsk = type !== "command_output" + const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" + + let queuedMessageId: string | undefined + if (isStatusMutable) { + const statusMutationTimeout = 2_000 + + if (isInteractiveAsk(type)) { + timeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.interactiveAsk = message + this.emit(RooCodeEventName.TaskInteractive, this.taskId) + /* v8 ignore next 3 -- fires inside 2s timer after ask() resolves; not reachable in unit tests */ + void provider?.postMessageToWebview({ type: "interactionRequired" }).catch((error) => { + console.error("[Task#ask] postMessageToWebview interactionRequired failed:", error) + }) + } + }, statusMutationTimeout), + ) + } else if (isResumableAsk(type)) { + timeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.resumableAsk = message + this.emit(RooCodeEventName.TaskResumable, this.taskId) + } + }, statusMutationTimeout), + ) + } else if (isIdleAsk(type)) { + timeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.idleAsk = message + this.emit(RooCodeEventName.TaskIdle, this.taskId) + } + }, statusMutationTimeout), + ) + } + } else if (isMessageQueued && shouldDrainQueuedMessageForAsk && queuedMessage && queuedAskResolution) { + queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) + } + + // Wait for askResponse to be set. The drain gate has been armed since + // the top of this ask, so a drain landing anywhere in the lifecycle + // consults the same queuedResponseForAsk gate as the claim path. await pWaitFor( () => { if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { @@ -1922,80 +1943,80 @@ export class Task extends EventEmitter implements TaskLike { }, { interval: 100 }, ) - } finally { - this.inFlightAskGate = undefined - } - /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ - if (this.abort) { - if (queuedMessageId) { - this.messageQueueService.releaseMessage(queuedMessageId) + /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ + if (this.abort) { + if (queuedMessageId) { + this.messageQueueService.releaseMessage(queuedMessageId) + } + throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } - throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) - } - if (this.lastMessageTs !== askTs) { - // Could happen if we send multiple asks in a row i.e. with - // command_output. It's important that when we know an ask could - // fail, it is handled gracefully. - if (queuedMessageId) { - this.messageQueueService.releaseMessage(queuedMessageId) + if (this.lastMessageTs !== askTs) { + // Could happen if we send multiple asks in a row i.e. with + // command_output. It's important that when we know an ask could + // fail, it is handled gracefully. + if (queuedMessageId) { + this.messageQueueService.releaseMessage(queuedMessageId) + } + throw new AskIgnoredError("superseded") } - throw new AskIgnoredError("superseded") - } - - // Tie a drain-submitted queued message to actual consumption by identity: - // the ask consumed the submission only if the pending slot still carries - // that message's ID. A direct response clears the slot ID even when its - // text/images are identical, so it cannot consume the queue entry. The - // claim path is excluded (durable flows already carry queuedMessageId; - // non-durable flows removed the message inline). - if (this.pendingSubmittedQueuedMessageId) { - const consumedViaPendingSlot = - queuedMessageId === undefined && - this.askResponseQueuedMessageId === this.pendingSubmittedQueuedMessageId - if (consumedViaPendingSlot) { - // Hand the ID to the caller instead of removing inline: the queue - // entry is deleted only after the feedback is durably saved - // (persistQueuedFeedbackAndAcknowledge), so a failed history write - // cannot lose a message that was already dequeued. - queuedMessageId = this.pendingSubmittedQueuedMessageId - this.pendingSubmittedQueuedMessageId = undefined - } else if ( - !this.messageQueueService.messages.some( - (message) => message.id === this.pendingSubmittedQueuedMessageId, - ) - ) { - // The message was consumed via the ask claim path or discarded - // by an existing path; the tracker is stale, so clear it. - this.pendingSubmittedQueuedMessageId = undefined + + // Tie a drain-submitted queued message to actual consumption by identity: + // the ask consumed the submission only if the pending slot still carries + // that message's ID. A direct response clears the slot ID even when its + // text/images are identical, so it cannot consume the queue entry. The + // claim path is excluded (durable flows already carry queuedMessageId; + // non-durable flows removed the message inline). + if (this.pendingSubmittedQueuedMessageId) { + const consumedViaPendingSlot = + queuedMessageId === undefined && + this.askResponseQueuedMessageId === this.pendingSubmittedQueuedMessageId + if (consumedViaPendingSlot) { + // Hand the ID to the caller instead of removing inline: the queue + // entry is deleted only after the feedback is durably saved + // (persistQueuedFeedbackAndAcknowledge), so a failed history write + // cannot lose a message that was already dequeued. + queuedMessageId = this.pendingSubmittedQueuedMessageId + this.pendingSubmittedQueuedMessageId = undefined + } else if ( + !this.messageQueueService.messages.some( + (message) => message.id === this.pendingSubmittedQueuedMessageId, + ) + ) { + // The message was consumed via the ask claim path or discarded + // by an existing path; the tracker is stale, so clear it. + this.pendingSubmittedQueuedMessageId = undefined + } } - } - const result = { - response: this.askResponse!, - text: this.askResponseText, - images: this.askResponseImages, - queuedMessageId, - } - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.askResponseQueuedMessageId = undefined + const result = { + response: this.askResponse!, + text: this.askResponseText, + images: this.askResponseImages, + queuedMessageId, + } + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined - // Cancel the timeouts if they are still running. - timeouts.forEach((timeout) => clearTimeout(timeout)) + // Cancel the timeouts if they are still running. + timeouts.forEach((timeout) => clearTimeout(timeout)) - // Switch back to an active state. - if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { - this.idleAsk = undefined - this.resumableAsk = undefined - this.interactiveAsk = undefined - this.emit(RooCodeEventName.TaskActive, this.taskId) - } + // Switch back to an active state. + if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { + this.idleAsk = undefined + this.resumableAsk = undefined + this.interactiveAsk = undefined + this.emit(RooCodeEventName.TaskActive, this.taskId) + } - this.emit(RooCodeEventName.TaskAskResponded) - return result + this.emit(RooCodeEventName.TaskAskResponded) + return result + } finally { + this.inFlightAskGate = undefined + } } handleWebviewAskResponse( @@ -2146,19 +2167,12 @@ export class Task extends EventEmitter implements TaskLike { } // Never overwrite a response an ask is blocked waiting on: an - // approval-gating ask (queuedResponseForAsk returns undefined) - // must not be answered by the queue, and a direct response that - // already landed in the slot must not be replaced (an approval - // would become a conversational answer). Outside an in-flight - // ask the slot only holds stale residue the next ask clears, so - // between-turn submissions proceed. Queue drains treat the - // returned false as "leave the message queued". - const inFlightGate = this.inFlightAskGate - if ( - inFlightGate && - (this.askResponse !== undefined || - queuedResponseForAsk(inFlightGate.type, inFlightGate.text) === undefined) - ) { + // approval-gating ask must not be answered by the queue, and a + // direct response that already landed in the slot must not be + // replaced (an approval would become a conversational answer). + // Queue drains treat the returned false as "leave the message + // queued". + if (this.inFlightAskBlocksQueuedSubmission()) { return false } @@ -5863,8 +5877,7 @@ export class Task extends EventEmitter implements TaskLike { // must honor the same gate instead of posting messageResponse into the // ask-response slot, so release the claim and leave the message queued // for a conversational turn. - const inFlightGate = this.inFlightAskGate - if (inFlightGate && queuedResponseForAsk(inFlightGate.type, inFlightGate.text) === undefined) { + if (this.inFlightAskBlocksQueuedSubmission()) { this.messageQueueService.releaseMessage(queued.id) return true } @@ -5878,11 +5891,7 @@ export class Task extends EventEmitter implements TaskLike { this.messageQueueService.releaseMessage(queued.id) return false } - const gate = this.inFlightAskGate - if ( - gate && - (this.askResponse !== undefined || queuedResponseForAsk(gate.type, gate.text) === undefined) - ) { + if (this.inFlightAskBlocksQueuedSubmission()) { // A direct response landed or an approval-gating ask is // waiting: the submission was dropped to keep the slot // intact, so leave the message queued instead of failing diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 8eb1ee4219..8adc9bdda3 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -323,6 +323,63 @@ describe("Task.ask queued message drain", () => { expect(submitSpy).toHaveBeenCalledTimes(2) }) + it("releases the drain tracker when the row write throws after a durable claim", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + + task.messageQueueService.addMessage("Do not lose me") + // Between-turns drain: submits the message and tracks it as pending. + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(submitSpy).toHaveBeenCalledTimes(1) + + // A completion ask claims the retained entry through the durable path. + const result = await task.ask("completion_result", "Done", false) + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + + // The row write throws (a synchronously throwing Message listener): + // the catch releases the entry and rethrows, and must ALSO release the + // pending tracker — it cannot be the finally's job alone. + const access = getQueueTaskTestAccess(task) + access.addToClineMessages = vi.fn(async () => { + throw new Error("listener boom") + }) + await expect( + task.persistQueuedFeedbackAndAcknowledge(result.queuedMessageId!, result.text, result.images), + ).rejects.toThrow("listener boom") + + // The re-queued message must be resubmitted by the next drain instead + // of stalling on the stale tracker ID forever. + expect(task.messageQueueService.messages).toHaveLength(1) + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(submitSpy).toHaveBeenCalledTimes(2) + }) + + it("arms the drain gate for the whole ask lifecycle, including the prefix", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + const access = getQueueTaskTestAccess(task) + let finishAddingAsk!: () => void + const addingAsk = new Promise((resolve) => { + finishAddingAsk = resolve + }) + // Block the ask in its prefix (the addToClineMessages await) so a drain + // lands before the response wait begins. + access.addToClineMessages = vi.fn(() => addingAsk.then(() => true)) + + const askPromise = task.ask("command", "npm test", false) + await Promise.resolve() + + task.messageQueueService.addMessage("queued note") + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(submitSpy).not.toHaveBeenCalled() + + finishAddingAsk() + setTimeout(() => task.approveAsk(), 0) + const result = await askPromise + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued note"]) + }) + it("delivers an intercepted message exactly once when a consumer acks through the durable helper", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") @@ -466,53 +523,6 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.isEmpty()).toBe(true) }) - it("releases the queued message when the reconciled row update fails", async () => { - const task = await createTask({ getState: async () => ({}) }) - - const askPromise = task.ask("completion_result", "Done", false) - await new Promise((resolve) => setTimeout(resolve, 150)) - - task.messageQueueService.addMessage("dedupe me") - const drain = task.processQueuedMessages() - - const result = await askPromise - await drain - const messageId = result.queuedMessageId! - - const taskAccess = getQueueTaskTestAccess(task) - const updateClineMessage = vi.fn(async () => {}) - taskAccess.updateClineMessage = updateClineMessage - taskAccess.addToClineMessages = async (message) => { - taskAccess.clineMessages.push(message!) - return true - } - // First ack: all saves fail, releasing the entry queued while the row - // association is retained for a later redelivery. - const saveClineMessages = vi.fn().mockResolvedValue(false) - taskAccess.saveClineMessages = saveClineMessages - - vi.useFakeTimers() - try { - const first = task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images) - await vi.runAllTimersAsync() - await expect(first).resolves.toBe(false) - } finally { - vi.useRealTimers() - } - expect(task.messageQueueService.messages).toHaveLength(1) - - // Redelivery: the reconciled row update fails. The failure must - // propagate and release the entry — it must NOT be acked/removed. - updateClineMessage.mockRejectedValueOnce(new Error("webview update failed")) - saveClineMessages.mockResolvedValue(true) - - await expect(task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images)).rejects.toThrow( - "webview update failed", - ) - expect(saveClineMessages).toHaveBeenCalledTimes(4) - expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["dedupe me"]) - }) - it("does not submit queued messages once the task is aborted", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") From 3984e8baaf6b9906630849b88415701169dd04a4 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 22:25:46 +0900 Subject: [PATCH 33/51] fix(task): reconcile registered feedback rows through non-durable claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: handleQueuedAskResponse now also returns the queued message ID when the ask resolution is non-durable but queuedFeedbackRows already has an association for that message, instead of removing the entry inline and returning undefined. Why: a redelivery after a failed durable persist carries a registered feedback row from the earlier attempt. When a non-durable ask (followup) then consumed the entry, the undefined return pushed consumers into the say('user_feedback') fallback, which appends a brand-new row without reconciling the registered one — a duplicate user_feedback row in history. Impact: consumers reconcile the same row through the durable ack (persistQueuedFeedbackAndAcknowledge) and the entry is removed only after the write succeeds. New pin test drives first-delivery failure plus a followup redelivery and asserts a single row and an empty queue. 50/50 drain-spec tests pass. --- src/core/task/Task.ts | 6 +- .../ask-queued-message-drain.spec.ts | 88 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 051428fa0e..1a98481510 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1236,7 +1236,11 @@ export class Task extends EventEmitter implements TaskLike { private handleQueuedAskResponse(message: QueuedMessage, resolution: QueuedAskResolution): string | undefined { this.handleWebviewAskResponse(resolution.response, message.text, message.images) - if (resolution.requiresDurableAck) { + if (resolution.requiresDurableAck || this.queuedFeedbackRows.has(message.id)) { + // A registered feedback row means an earlier delivery attempt left + // history behind: hand the ID back so the consumer reconciles that + // row through the durable ack instead of appending a duplicate via + // the say("user_feedback") fallback. return message.id } this.messageQueueService.removeMessage(message.id) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 8adc9bdda3..db02771788 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -1045,4 +1045,92 @@ describe("Task.ask queued message drain", () => { vi.useRealTimers() } }) + it("hands the queued ID back through a non-durable claim when a feedback row is registered", async () => { + const task = await createTask({ getState: async () => ({}) }) + task.messageQueueService.addMessage("dedupe me") + // First delivery: the durable interception leaves a registered row and + // a re-queued entry when persistence fails. + const result = await task.ask("completion_result", "Done", false) + const messageId = result.queuedMessageId! + const access = getQueueTaskTestAccess(task) + access.addToClineMessages = vi.fn(async (message?: ClineMessage) => { + access.clineMessages.push(message!) + return true + }) + access.saveClineMessages = vi.fn(async () => false) + access.say = vi.fn().mockResolvedValue(undefined) + + vi.useFakeTimers() + try { + const first = task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images) + await vi.runAllTimersAsync() + await expect(first).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + expect(task.messageQueueService.messages).toHaveLength(1) + expect(access.queuedFeedbackRows.has(messageId)).toBe(true) + + // Redelivery consumed by a non-durable ask (followup): the registered + // row must route the consumer through the durable ack so it reconciles + // the same row instead of appending a duplicate via the + // say("user_feedback") fallback. + const followup = await task.ask("followup", "Q?", false) + expect(followup).toMatchObject({ response: "messageResponse", text: "dedupe me" }) + expect(followup.queuedMessageId).toBe(messageId) + // The entry is not removed inline; the durable ack owns its removal. + expect(task.messageQueueService.messages).toHaveLength(1) + + access.saveClineMessages = vi.fn(async () => true) + await task.sayUserFeedbackAndAckQueued(followup.text, followup.images, followup.queuedMessageId) + expect(access.clineMessages.filter((message) => message.say === "user_feedback")).toHaveLength(1) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("releases the queued message when the reconciled row update fails", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("completion_result", "Done", false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("dedupe me") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + const messageId = result.queuedMessageId! + + const taskAccess = getQueueTaskTestAccess(task) + const updateClineMessage = vi.fn(async () => {}) + taskAccess.updateClineMessage = updateClineMessage + taskAccess.addToClineMessages = async (message) => { + taskAccess.clineMessages.push(message!) + return true + } + // First ack: all saves fail, releasing the entry queued while the row + // association is retained for a later redelivery. + const saveClineMessages = vi.fn().mockResolvedValue(false) + taskAccess.saveClineMessages = saveClineMessages + + vi.useFakeTimers() + try { + const first = task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images) + await vi.runAllTimersAsync() + await expect(first).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + expect(task.messageQueueService.messages).toHaveLength(1) + + // Redelivery: the reconciled row update fails. The failure must + // propagate and release the entry — it must NOT be acked/removed. + updateClineMessage.mockRejectedValueOnce(new Error("webview update failed")) + saveClineMessages.mockResolvedValue(true) + + await expect(task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images)).rejects.toThrow( + "webview update failed", + ) + expect(saveClineMessages).toHaveBeenCalledTimes(4) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["dedupe me"]) + }) }) From 2fcf21dd22d81a52ba4e2f61bb2cb40ffcdbd0e0 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 22:26:48 +0900 Subject: [PATCH 34/51] fix(api): reject headless sendMessage when the task refuses delivery What: the headless branch of API.sendMessage now checks submitUserMessage's return and throws when the task refused the slot write, instead of dropping the boolean. Why: with an approval ask in flight (or a stopping task) the write is refused and the caller previously had no signal: the message vanished while the webview ask kept waiting on its own response channel. Impact: headless API callers get a rejected promise they can handle. New pin test asserts the rejection, the task handoff arguments, and that no webview invoke is posted in headless mode. 7/7 api-send-message tests pass. --- .../__tests__/api-send-message.spec.ts | 22 +++++++++++++++++++ src/extension/api.ts | 10 ++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api-send-message.spec.ts index 23677b1218..d574019c1c 100644 --- a/src/extension/__tests__/api-send-message.spec.ts +++ b/src/extension/__tests__/api-send-message.spec.ts @@ -153,4 +153,26 @@ describe("API - SendMessage Command", () => { }) expect(mockPostMessageToWebview).toHaveBeenCalledTimes(1) }) + + it("rejects in headless mode when the task refuses the delivery", async () => { + // Arrange: headless flow with an in-flight approval ask — the task + // refuses the slot write, and the API caller must see the failure. + const submitUserMessage = vi.fn().mockResolvedValue(false) + const headlessProvider = { + context: {} as vscode.ExtensionContext, + postMessageToWebview: mockPostMessageToWebview, + on: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + getCurrentTask: vi.fn().mockReturnValue({ submitUserMessage }), + viewLaunched: false, + } as unknown as ClineProvider + const headlessApi = new API(mockOutputChannel, headlessProvider, undefined, true) + + // Act + Assert + await expect(headlessApi.sendMessage("Hello from headless")).rejects.toThrow( + "[API#sendMessage] message was not delivered", + ) + expect(submitUserMessage).toHaveBeenCalledWith("Hello from headless", undefined) + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) }) diff --git a/src/extension/api.ts b/src/extension/api.ts index 316e7a6c9d..8280fcf788 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -332,7 +332,15 @@ export class API extends EventEmitter implements RooCodeAPI { return } - await currentTask.submitUserMessage(text ?? "", images) + const submitted = await currentTask.submitUserMessage(text ?? "", images) + if (!submitted) { + // The task refused the write (it is stopping or an approval ask + // is in flight): reject so headless callers see the message was + // not delivered instead of it vanishing silently. + throw new Error( + "[API#sendMessage] message was not delivered to the task (task stopping or an approval ask is pending)", + ) + } return } From c42dd9ca0772484b5680bcfa522411b75a8c6483 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 22:28:29 +0900 Subject: [PATCH 35/51] fix(webview): surface refused edit resubmissions and condense failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: two webviewMessageHandler visibility fixes. (1) The edit-resubmit path checks submitUserMessage's return and throws when the task refused the slot write, so the existing catch shows the user an error dialog — the rewind above it is destructive, so a silent drop loses the edited message. (2) The condenseTaskContextRequest catch now also shows common:errors.condense_failed via showErrorMessage, alongside the provider.log detail. Why: both paths previously swallowed failures: an in-flight approval ask made edited or condensed work vanish without any user-visible signal. Impact: refused edit resubmissions and condense-time drain failures reach the user as visible errors. New pin tests: edit-resubmit refusal shows the error dialog after the rewind (webviewMessageHandler.edit.spec), and the condense rejection logs plus shows the condense_failed message (webviewMessageHandler.spec). 83+7 webview handler tests pass. --- .../webviewMessageHandler.edit.spec.ts | 78 +++++++++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 3 +- src/core/webview/webviewMessageHandler.ts | 16 +++- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 523f03e1c2..f84811cfcf 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -1,5 +1,6 @@ import type { Mock } from "vitest" import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" // Mock dependencies first vi.mock("vscode", () => ({ @@ -43,6 +44,7 @@ import type { ClineProvider } from "../ClineProvider" import type { ClineMessage } from "@roo-code/types" import type { ApiMessage } from "../../task-persistence/apiMessages" import { MessageManager } from "../../message-manager" +import { Task } from "../../task/Task" describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { let mockClineProvider: ClineProvider @@ -396,3 +398,79 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([]) }) }) + +describe("webviewMessageHandler - edit resubmission refusal", () => { + let mockClineProvider: ClineProvider + let mockCurrentTask: { + taskId: string + clineMessages: ClineMessage[] + apiConversationHistory: ApiMessage[] + overwriteClineMessages: ReturnType + overwriteApiConversationHistory: ReturnType + handleWebviewAskResponse: ReturnType + submitUserMessage: ReturnType + messageManager: MessageManager + } + + beforeEach(() => { + vi.clearAllMocks() + + const taskBase = { + taskId: "test-task-id", + clineMessages: [] as ClineMessage[], + apiConversationHistory: [] as ApiMessage[], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + handleWebviewAskResponse: vi.fn(), + // The task refuses the slot write: an approval ask is in flight. + submitUserMessage: vi.fn().mockResolvedValue(false), + } + mockCurrentTask = { + ...taskBase, + // MessageManager operates on the task's message arrays; the + // structural cast stays inside the helper per the test-utils rule. + messageManager: new MessageManager(taskBase as unknown as Task), + } + + mockClineProvider = { + getCurrentTask: vi.fn().mockReturnValue(mockCurrentTask), + postMessageToWebview: vi.fn(), + postStateToWebview: vi.fn(), + contextProxy: { + getValue: vi.fn(), + setValue: vi.fn(), + globalStorageUri: { fsPath: "/mock/storage" }, + }, + log: vi.fn(), + getState: vi.fn().mockResolvedValue({ + maxImageFileSize: 5, + maxTotalImageSize: 20, + }), + } as unknown as ClineProvider + }) + + it("surfaces a refused edit resubmission instead of dropping the edited message", async () => { + const userMessageTs = 1000 + // Mutate the shared array: the MessageManager was constructed against + // it, and property reassignment here would not be visible to the + // manager's task reference. + mockCurrentTask.clineMessages.push({ + ts: userMessageTs, + type: "say", + say: "user_feedback", + text: "Hello", + } as ClineMessage) + + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: userMessageTs, + text: "Hello World", + restoreCheckpoint: false, + }) + + // The rewind already happened; the refusal must reach the user as a + // visible error instead of vanishing silently. + expect(mockCurrentTask.submitUserMessage).toHaveBeenCalledWith("Hello World", []) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining("error_editing_message")) + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 113f1c07e6..8ebeb57b85 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2303,7 +2303,7 @@ describe("webviewMessageHandler - chat message queue", () => { expect(updateSpy).toHaveBeenCalledWith(added.id, "edited", ["data:image/png;base64,from-mention"]) }) - it("logs instead of leaking when condenseTaskContext rejects", async () => { + it("logs and shows a visible error when condenseTaskContext rejects", async () => { const condenseError = new Error("queued submission failed") const providerWithCondense = mockClineProvider as unknown as { condenseTaskContext: ReturnType @@ -2317,5 +2317,6 @@ describe("webviewMessageHandler - chat message queue", () => { expect(mockClineProvider.log).toHaveBeenCalledWith( "[condenseTaskContextRequest] Failed: queued submission failed", ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.condense_failed") }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 93df90f48b..247dca56de 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -542,7 +542,16 @@ export const webviewMessageHandler = async ( // Update the UI to reflect the deletion await provider.postStateToWebview() - await currentCline.submitUserMessage(editedContent, images) + const submitted = await currentCline.submitUserMessage(editedContent, images) + if (!submitted) { + // The rewind above is destructive: if the task refused the slot + // write (an approval ask is in flight or the task is stopping), + // the edited message would vanish silently — surface it through + // the shared error dialog instead. + throw new Error( + "Edited message could not be delivered: an approval ask is in flight or the task is stopping.", + ) + } } catch (error) { console.error("Error in edit message:", error) vscode.window.showErrorMessage( @@ -912,11 +921,12 @@ export const webviewMessageHandler = async ( await provider.condenseTaskContext(message.text!) } catch (error) { // condenseContext drains queued messages after summarizing, and a - // failed submission rejects: surface it in the log instead of - // leaking an unhandled rejection from the message handler. + // failed submission rejects: log the details for support and show + // the triggering user a visible error like sibling handlers. provider.log( `[condenseTaskContextRequest] Failed: ${error instanceof Error ? error.message : String(error)}`, ) + await vscode.window.showErrorMessage(t("common:errors.condense_failed")) } break case "deleteTaskWithId": From f1d1a139dc3603906674e168a3ef5da66102f22b Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 23:02:35 +0900 Subject: [PATCH 36/51] fix(task): keep queued messages out of failure-gate retry asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: queuedResponseForAsk now returns undefined for api_req_failed and auto_approval_max_req_reached, alongside the existing approval-gating exclusions. Because the claim path, the in-ask auto-claim, and the drain gate all consult this one function, the exclusion covers every conversion seam at once. Why: failure-gate retry prompts auto-claimed a queued conversational message as a NON-DURABLE messageResponse; the consumers then discarded it and aborted — the user's typed text and images were destroyed with no history row and a surprise task abort (qwen M-1). Impact: a queued message now stays queued while a failure-gate ask is in flight and is delivered at the next conversational ask. The truncation retry gate's defensive discard is kept with a corrected comment (the round-2 'stays answerable' rationale is superseded). The drain-gate it.each gains both failure-gate types, a new pin covers retain-and- deliver for each, the discard-interception test moves to followup (the only remaining non-durable conversational ask), and the now-impossible truncation-discard test is removed. The user-clicks-retry flow is pinned by the existing output-token-limit tests. Full core suite, check-types, and eslint pass. --- src/core/task/Task.ts | 19 ++++++--- src/core/task/__tests__/Task.spec.ts | 22 ---------- .../ask-queued-message-drain.spec.ts | 42 +++++++++++++++++-- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1a98481510..fb67c7051f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -184,6 +184,15 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio return undefined } + if (type === "api_req_failed" || type === "auto_approval_max_req_reached") { + // Failure-gate retry prompts: any non-yes answer aborts the task, so a + // queued conversational message must not be converted into an answer — + // its text and images would be destroyed with no history row and a + // surprise abort. The message stays queued for the next conversational + // ask, like the approval-gating asks above. + return undefined + } + return { response: "messageResponse", requiresDurableAck: type === "completion_result" } } @@ -4087,12 +4096,10 @@ export class Task extends EventEmitter implements TaskLike { // Truncation repeats on an identical request, so never auto-retry it // (even with auto-approval); let the user decide once. const { response, queuedMessageId } = await this.ask("api_req_failed", rawErrorMessage) - // Only the button response is inspected; a consumed queued - // message is dropped without inventing a history write (same - // as the sibling api_req_failed asks below). The retry gate - // stays answerable by a queued message: unlike command_output - // asks it does not gate execution, and a typed "no" that - // aborts the task destroys the queue either way. + // Failure-gate asks refuse queued-message conversion + // (queuedResponseForAsk returns undefined), so queuedMessageId + // is always undefined here today; the discard stays as a + // defensive no-op should that seam ever change. this.discardConsumedQueuedMessage(queuedMessageId) if (response !== "yesButtonClicked") { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index bbf8983792..eb738aab2d 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -559,28 +559,6 @@ describe("Cline", () => { expect(askSpy).toHaveBeenCalledWith("api_req_failed", expect.stringContaining("Output token limit reached")) }) - it("discards a queued message consumed by the truncation retry gate", async () => { - const task = await createAutoApprovedTask() - // Simulate the ask claim path consuming the queued message as its answer. - vi.spyOn(task, "ask").mockImplementation(async () => { - const message = task.messageQueueService.claimNextMessage() - return { - response: "noButtonClicked", - text: message?.text, - queuedMessageId: message?.id, - } satisfies TaskAskResult - }) - vi.spyOn(task, "attemptApiRequest").mockImplementation(() => truncatedStream()) - task.messageQueueService.addMessage("do not redeliver me") - - await task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }]) - - // The consumed entry is discarded like the sibling api_req_failed - // arms, not left queued for a redelivery that can never be acked: - // the declined retry destroys the task queue either way. - expect(task.messageQueueService.isEmpty()).toBe(true) - }) - it("retries from a fresh attempt count when the user confirms", async () => { const task = await createAutoApprovedTask() vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index db02771788..09a3e00830 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -414,9 +414,10 @@ describe("Task.ask queued message drain", () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") - // api_req_failed-style gate: the consumer only inspects the button - // response, so the intercepted queued message is discarded, not acked. - const askPromise = task.ask("api_req_failed", "The model returned no assistant messages.", false) + // Followup-style conversational ask: the consumer only inspects the + // button response, so an intercepted queued message can be discarded + // instead of acked. + const askPromise = task.ask("followup", "Anything to add?", false) await new Promise((resolve) => setTimeout(resolve, 150)) task.messageQueueService.addMessage("queued note") @@ -770,6 +771,8 @@ describe("Task.ask queued message drain", () => { ["command", "npm test"], ["use_mcp_server", "{}"], ["tool", JSON.stringify({ tool: "readFile" })], + ["api_req_failed", "stream failed"], + ["auto_approval_max_req_reached", "{}"], ] as const)("keeps a drain from answering a blocked %s ask", async (type, text) => { const task = await createTask({ getState: async () => ({}) }) // auto-approval disabled const submitSpy = vi.spyOn(task, "submitUserMessage") @@ -834,6 +837,39 @@ describe("Task.ask queued message drain", () => { expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) }) + it.each([ + ["api_req_failed", "stream failed"], + ["auto_approval_max_req_reached", JSON.stringify({ count: 3, type: "requests" })], + ] as const)( + "keeps a queued message out of the %s failure gate and delivers it at the next conversational ask", + async (type, text) => { + const task = await createTask({ getState: async () => ({}) }) // auto-approval disabled + task.messageQueueService.addMessage("typed feedback") + + // A failure-gate ask is in flight: any non-yes answer aborts the + // task, so the queued message must not be read as its answer. + const askPromise = task.ask(type, text, false) + await new Promise((resolve) => setTimeout(resolve, 150)) + let settled = false + void askPromise.then(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(settled).toBe(false) + + // The user explicitly retries/approves; the typed text is retained. + setTimeout(() => task.approveAsk(), 0) + const result = await askPromise + expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["typed feedback"]) + + // The retained message is delivered to the next conversational ask. + const followup = await task.ask("followup", "anything else?", false) + expect(followup).toMatchObject({ response: "messageResponse", text: "typed feedback" }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }, + ) + it("claims lifecycle feedback that arrives while an ask is waiting", async () => { const task = await createTask() const ask = task.ask("tool", JSON.stringify({ tool: "finishTask" }), false) From 8be15bc50d8f7fe1ad84b5ae38bd71b7fc8ccfe2 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 23:28:34 +0900 Subject: [PATCH 37/51] refactor(task): split ask into a gate wrapper and askImpl (CI diff budget) What: Task.ask is now a thin synchronous wrapper that arms inFlightAskGate, awaits askImpl, and disarms in a finally; the ask body moved verbatim into private askImpl at its original indentation, undoing the round-3 try/finally wrap's whole-body re-indent. Why: the CI mutation-diff gate counts every changed executable line vs upstream/main (scripts/stryker-diff.mjs, limit 500), and the round-3 re-indent made the entire ask body count as changed (532 > 500) even where the text was unchanged. The wrapper/impl split keeps the gate semantics (armed synchronously before the first await, disarmed exactly once on every exit) while restoring the body lines to byte-identical base indentation so whitespace-only changes leave the diff. Impact: behavior and the public ask() contract are unchanged for all call sites; only whitespace/structure differ. Full core/task suite passes (696 tests); check-types and eslint clean. The budget gate is re-run on the new HEAD as part of this change's verification. --- src/core/task/Task.ts | 598 ++++++++++++++++++++++-------------------- 1 file changed, 309 insertions(+), 289 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fb67c7051f..935931ad9b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1702,6 +1702,30 @@ export class Task extends EventEmitter implements TaskLike { partial?: boolean, progressStatus?: ToolProgressStatus, isProtected?: boolean, + ): Promise<{ response: ClineAskResponse; text?: string; images?: string[]; queuedMessageId?: string }> { + // Arm the drain gate synchronously at ask() entry, before the first + // await, and hold it for the whole ask lifecycle: a queued submission + // landing anywhere in the ask prefix (auto-approval, partial handling) + // must hit the same queuedResponseForAsk gate as one landing in the + // response wait, so an approval-gating ask can never be answered by + // the queue. The thin-wrapper shape keeps the body at its long-standing + // indentation instead of re-indenting it under a wrapping try block, + // so whitespace-only changes do not count against the mutation-diff + // line budget. + this.inFlightAskGate = { type, text } + try { + return await this.askImpl(type, text, partial, progressStatus, isProtected) + } finally { + this.inFlightAskGate = undefined + } + } + + private async askImpl( + type: ClineAsk, + text?: string, + partial?: boolean, + progressStatus?: ToolProgressStatus, + isProtected?: boolean, ): Promise<{ response: ClineAskResponse; text?: string; images?: string[]; queuedMessageId?: string }> { // If this Cline instance was aborted by the provider, then the only // thing keeping us alive is a promise still running in the background, @@ -1721,315 +1745,311 @@ export class Task extends EventEmitter implements TaskLike { // queuedResponseForAsk gate as one landing in the response wait, so an // approval-gating ask can never be answered by the queue. this.inFlightAskGate = { type, text } - try { - let askTs: number - - // Resolve auto-approval before adding the message so the state snapshot - // sent to the webview already carries isAnswered:true when the ask will - // be immediately resolved. This eliminates the race between the state - // update (which shows approval buttons) and the former separate - // clearApprovalButtons message (which could arrive before buttons were - // rendered, leaving them stuck on-screen). - const provider = this.providerRef.deref() - const state = provider ? await provider.getState() : undefined - // Resolve before claiming: approval-gating ask types never consume a - // queued conversational message, and claiming without a resolution - // would leak the claim. The resolution is only actionable when a - // message was actually claimed: otherwise it must not force a manual - // ask (that would bypass auto-approval for empty-queue lifecycle asks). - const claimableResolution = - partial === true || type === "command_output" ? undefined : queuedResponseForAsk(type, text) - const queuedMessage = claimableResolution ? this.messageQueueService.claimNextMessage() : undefined - const queuedAskResolution = queuedMessage ? claimableResolution : undefined - // `this.cwd`, not `provider.cwd`: - // The path inside `text` was made relative to this task's workspace, - // which for a resumed or child task need not be the one the provider - // currently reports. - const approval = queuedAskResolution - ? ({ decision: "ask" } as const) - : await checkAutoApproval({ state, cwd: this.cwd, ask: type, text, isProtected }) - const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" - const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined - - if (partial !== undefined) { - const lastMessage = this.clineMessages.at(-1) - - const isUpdatingPreviousPartial = - lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type - - if (partial) { - if (isUpdatingPreviousPartial) { - // Existing partial message, so update it. - lastMessage.text = text - lastMessage.partial = partial - lastMessage.progressStatus = progressStatus - lastMessage.isProtected = isProtected - // TODO: Be more efficient about saving and posting only new - // data or one whole message at a time so ignore partial for - // saves, and only post parts of partial message instead of - // whole array in new listener. - // Fire-and-forget: the webview post is internally guarded, but - // the `RooCodeEventName.Message` emit can synchronously throw - // if any consumer-attached listener does, which would surface - // here as an unhandled rejection. Log it instead. - this.updateClineMessage(lastMessage).catch((error) => { - console.error("[Task#ask] updateClineMessage failed:", error) - }) - // console.log("Task#ask: current ask promise was ignored (#1)") - throw new AskIgnoredError("updating existing partial") - } else { - // This is a new partial message, so add it with partial - // state. - askTs = Date.now() - this.lastMessageTs = askTs - await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected }) - // console.log("Task#ask: current ask promise was ignored (#2)") - throw new AskIgnoredError("new partial") - } + let askTs: number + + // Resolve auto-approval before adding the message so the state snapshot + // sent to the webview already carries isAnswered:true when the ask will + // be immediately resolved. This eliminates the race between the state + // update (which shows approval buttons) and the former separate + // clearApprovalButtons message (which could arrive before buttons were + // rendered, leaving them stuck on-screen). + const provider = this.providerRef.deref() + const state = provider ? await provider.getState() : undefined + // Resolve before claiming: approval-gating ask types never consume a + // queued conversational message, and claiming without a resolution + // would leak the claim. The resolution is only actionable when a + // message was actually claimed: otherwise it must not force a manual + // ask (that would bypass auto-approval for empty-queue lifecycle asks). + const claimableResolution = + partial === true || type === "command_output" ? undefined : queuedResponseForAsk(type, text) + const queuedMessage = claimableResolution ? this.messageQueueService.claimNextMessage() : undefined + const queuedAskResolution = queuedMessage ? claimableResolution : undefined + // `this.cwd`, not `provider.cwd`: + // The path inside `text` was made relative to this task's workspace, + // which for a resumed or child task need not be the one the provider + // currently reports. + const approval = queuedAskResolution + ? ({ decision: "ask" } as const) + : await checkAutoApproval({ state, cwd: this.cwd, ask: type, text, isProtected }) + const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" + const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined + + if (partial !== undefined) { + const lastMessage = this.clineMessages.at(-1) + + const isUpdatingPreviousPartial = + lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type + + if (partial) { + if (isUpdatingPreviousPartial) { + // Existing partial message, so update it. + lastMessage.text = text + lastMessage.partial = partial + lastMessage.progressStatus = progressStatus + lastMessage.isProtected = isProtected + // TODO: Be more efficient about saving and posting only new + // data or one whole message at a time so ignore partial for + // saves, and only post parts of partial message instead of + // whole array in new listener. + // Fire-and-forget: the webview post is internally guarded, but + // the `RooCodeEventName.Message` emit can synchronously throw + // if any consumer-attached listener does, which would surface + // here as an unhandled rejection. Log it instead. + this.updateClineMessage(lastMessage).catch((error) => { + console.error("[Task#ask] updateClineMessage failed:", error) + }) + // console.log("Task#ask: current ask promise was ignored (#1)") + throw new AskIgnoredError("updating existing partial") } else { - if (isUpdatingPreviousPartial) { - // This is the complete version of a previously partial - // message, so replace the partial with the complete version. - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.askResponseQueuedMessageId = undefined - - // Bug for the history books: - // In the webview we use the ts as the chatrow key for the - // virtuoso list. Since we would update this ts right at the - // end of streaming, it would cause the view to flicker. The - // key prop has to be stable otherwise react has trouble - // reconciling items between renders, causing unmounting and - // remounting of components (flickering). - // The lesson here is if you see flickering when rendering - // lists, it's likely because the key prop is not stable. - // So in this case we must make sure that the message ts is - // never altered after first setting it. - askTs = lastMessage.ts - this.lastMessageTs = askTs - lastMessage.text = text - lastMessage.partial = false - lastMessage.progressStatus = progressStatus - lastMessage.isProtected = isProtected - if (isAutoAnswered) { - lastMessage.isAnswered = true - lastMessage.autoApprovalDecision = autoApprovalDecision - } - await this.saveClineMessages() - // Fire-and-forget: see updateClineMessage call above for the - // rationale on the .catch arm. - this.updateClineMessage(lastMessage).catch((error) => { - console.error("[Task#ask] updateClineMessage failed:", error) - }) - } else { - // This is a new and complete message, so add it like normal. - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.askResponseQueuedMessageId = undefined - askTs = Date.now() - this.lastMessageTs = askTs - await this.addToClineMessages({ - ts: askTs, - type: "ask", - ask: type, - text, - isProtected, - isAnswered: isAutoAnswered || undefined, - autoApprovalDecision, - }) - } + // This is a new partial message, so add it with partial + // state. + askTs = Date.now() + this.lastMessageTs = askTs + await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected }) + // console.log("Task#ask: current ask promise was ignored (#2)") + throw new AskIgnoredError("new partial") } } else { - // This is a new non-partial message, so add it like normal. - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.askResponseQueuedMessageId = undefined - askTs = Date.now() - this.lastMessageTs = askTs - await this.addToClineMessages({ - ts: askTs, - type: "ask", - ask: type, - text, - isProtected, - isAnswered: isAutoAnswered || undefined, - autoApprovalDecision, - }) + if (isUpdatingPreviousPartial) { + // This is the complete version of a previously partial + // message, so replace the partial with the complete version. + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined + + // Bug for the history books: + // In the webview we use the ts as the chatrow key for the + // virtuoso list. Since we would update this ts right at the + // end of streaming, it would cause the view to flicker. The + // key prop has to be stable otherwise react has trouble + // reconciling items between renders, causing unmounting and + // remounting of components (flickering). + // The lesson here is if you see flickering when rendering + // lists, it's likely because the key prop is not stable. + // So in this case we must make sure that the message ts is + // never altered after first setting it. + askTs = lastMessage.ts + this.lastMessageTs = askTs + lastMessage.text = text + lastMessage.partial = false + lastMessage.progressStatus = progressStatus + lastMessage.isProtected = isProtected + if (isAutoAnswered) { + lastMessage.isAnswered = true + lastMessage.autoApprovalDecision = autoApprovalDecision + } + await this.saveClineMessages() + // Fire-and-forget: see updateClineMessage call above for the + // rationale on the .catch arm. + this.updateClineMessage(lastMessage).catch((error) => { + console.error("[Task#ask] updateClineMessage failed:", error) + }) + } else { + // This is a new and complete message, so add it like normal. + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined + askTs = Date.now() + this.lastMessageTs = askTs + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + isProtected, + isAnswered: isAutoAnswered || undefined, + autoApprovalDecision, + }) + } } + } else { + // This is a new non-partial message, so add it like normal. + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined + askTs = Date.now() + this.lastMessageTs = askTs + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + isProtected, + isAnswered: isAutoAnswered || undefined, + autoApprovalDecision, + }) + } - const timeouts: NodeJS.Timeout[] = [] - - if (approval.decision === "approve") { - this.approveAsk() - } else if (approval.decision === "deny") { - this.denyAsk() - } else if (approval.decision === "timeout") { - // Store the auto-approval timeout so it can be cancelled if user interacts - this.autoApprovalTimeoutRef = setTimeout(() => { - const { askResponse, text, images } = approval.fn() - this.handleWebviewAskResponse(askResponse, text, images) - this.autoApprovalTimeoutRef = undefined - }, approval.timeout) - timeouts.push(this.autoApprovalTimeoutRef) + const timeouts: NodeJS.Timeout[] = [] + + if (approval.decision === "approve") { + this.approveAsk() + } else if (approval.decision === "deny") { + this.denyAsk() + } else if (approval.decision === "timeout") { + // Store the auto-approval timeout so it can be cancelled if user interacts + this.autoApprovalTimeoutRef = setTimeout(() => { + const { askResponse, text, images } = approval.fn() + this.handleWebviewAskResponse(askResponse, text, images) + this.autoApprovalTimeoutRef = undefined + }, approval.timeout) + timeouts.push(this.autoApprovalTimeoutRef) + } + + // The state is mutable if the message is complete and the task will + // block (via the `pWaitFor`). + const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) + const isMessageQueued = !this.messageQueueService.isEmpty() + // Keep queued user messages intact during command_output asks. Those asks + // are terminal flow-control, not conversational turns. + const shouldDrainQueuedMessageForAsk = type !== "command_output" + const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" + + let queuedMessageId: string | undefined + if (isStatusMutable) { + const statusMutationTimeout = 2_000 + + if (isInteractiveAsk(type)) { + timeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.interactiveAsk = message + this.emit(RooCodeEventName.TaskInteractive, this.taskId) + /* v8 ignore next 3 -- fires inside 2s timer after ask() resolves; not reachable in unit tests */ + void provider?.postMessageToWebview({ type: "interactionRequired" }).catch((error) => { + console.error("[Task#ask] postMessageToWebview interactionRequired failed:", error) + }) + } + }, statusMutationTimeout), + ) + } else if (isResumableAsk(type)) { + timeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.resumableAsk = message + this.emit(RooCodeEventName.TaskResumable, this.taskId) + } + }, statusMutationTimeout), + ) + } else if (isIdleAsk(type)) { + timeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.idleAsk = message + this.emit(RooCodeEventName.TaskIdle, this.taskId) + } + }, statusMutationTimeout), + ) } + } else if (isMessageQueued && shouldDrainQueuedMessageForAsk && queuedMessage && queuedAskResolution) { + queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) + } - // The state is mutable if the message is complete and the task will - // block (via the `pWaitFor`). - const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) - const isMessageQueued = !this.messageQueueService.isEmpty() - // Keep queued user messages intact during command_output asks. Those asks - // are terminal flow-control, not conversational turns. - const shouldDrainQueuedMessageForAsk = type !== "command_output" - const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" - - let queuedMessageId: string | undefined - if (isStatusMutable) { - const statusMutationTimeout = 2_000 - - if (isInteractiveAsk(type)) { - timeouts.push( - setTimeout(() => { - const message = this.findMessageByTimestamp(askTs) - - if (message) { - this.interactiveAsk = message - this.emit(RooCodeEventName.TaskInteractive, this.taskId) - /* v8 ignore next 3 -- fires inside 2s timer after ask() resolves; not reachable in unit tests */ - void provider?.postMessageToWebview({ type: "interactionRequired" }).catch((error) => { - console.error("[Task#ask] postMessageToWebview interactionRequired failed:", error) - }) - } - }, statusMutationTimeout), - ) - } else if (isResumableAsk(type)) { - timeouts.push( - setTimeout(() => { - const message = this.findMessageByTimestamp(askTs) - - if (message) { - this.resumableAsk = message - this.emit(RooCodeEventName.TaskResumable, this.taskId) - } - }, statusMutationTimeout), - ) - } else if (isIdleAsk(type)) { - timeouts.push( - setTimeout(() => { - const message = this.findMessageByTimestamp(askTs) - - if (message) { - this.idleAsk = message - this.emit(RooCodeEventName.TaskIdle, this.taskId) - } - }, statusMutationTimeout), - ) + // Wait for askResponse to be set. The drain gate has been armed since + // the top of this ask, so a drain landing anywhere in the lifecycle + // consults the same queuedResponseForAsk gate as the claim path. + await pWaitFor( + () => { + if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { + return true } - } else if (isMessageQueued && shouldDrainQueuedMessageForAsk && queuedMessage && queuedAskResolution) { - queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) - } - // Wait for askResponse to be set. The drain gate has been armed since - // the top of this ask, so a drain landing anywhere in the lifecycle - // consults the same queuedResponseForAsk gate as the claim path. - await pWaitFor( - () => { - if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { - return true + // If a queued message arrives while we're blocked on an ask (e.g. a follow-up + // suggestion click that was incorrectly queued due to UI state), consume it + // immediately so the task doesn't hang. Approval-gating ask types get no + // resolution, so the message stays queued for a conversational turn. + if (shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { + const resolution = queuedResponseForAsk(type, text) + const message = resolution ? this.messageQueueService.claimNextMessage() : undefined + if (message && resolution) { + queuedMessageId = this.handleQueuedAskResponse(message, resolution) } - - // If a queued message arrives while we're blocked on an ask (e.g. a follow-up - // suggestion click that was incorrectly queued due to UI state), consume it - // immediately so the task doesn't hang. Approval-gating ask types get no - // resolution, so the message stays queued for a conversational turn. - if (shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { - const resolution = queuedResponseForAsk(type, text) - const message = resolution ? this.messageQueueService.claimNextMessage() : undefined - if (message && resolution) { - queuedMessageId = this.handleQueuedAskResponse(message, resolution) - } - } - - return false - }, - { interval: 100 }, - ) - - /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ - if (this.abort) { - if (queuedMessageId) { - this.messageQueueService.releaseMessage(queuedMessageId) } - throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) - } - if (this.lastMessageTs !== askTs) { - // Could happen if we send multiple asks in a row i.e. with - // command_output. It's important that when we know an ask could - // fail, it is handled gracefully. - if (queuedMessageId) { - this.messageQueueService.releaseMessage(queuedMessageId) - } - throw new AskIgnoredError("superseded") - } + return false + }, + { interval: 100 }, + ) - // Tie a drain-submitted queued message to actual consumption by identity: - // the ask consumed the submission only if the pending slot still carries - // that message's ID. A direct response clears the slot ID even when its - // text/images are identical, so it cannot consume the queue entry. The - // claim path is excluded (durable flows already carry queuedMessageId; - // non-durable flows removed the message inline). - if (this.pendingSubmittedQueuedMessageId) { - const consumedViaPendingSlot = - queuedMessageId === undefined && - this.askResponseQueuedMessageId === this.pendingSubmittedQueuedMessageId - if (consumedViaPendingSlot) { - // Hand the ID to the caller instead of removing inline: the queue - // entry is deleted only after the feedback is durably saved - // (persistQueuedFeedbackAndAcknowledge), so a failed history write - // cannot lose a message that was already dequeued. - queuedMessageId = this.pendingSubmittedQueuedMessageId - this.pendingSubmittedQueuedMessageId = undefined - } else if ( - !this.messageQueueService.messages.some( - (message) => message.id === this.pendingSubmittedQueuedMessageId, - ) - ) { - // The message was consumed via the ask claim path or discarded - // by an existing path; the tracker is stale, so clear it. - this.pendingSubmittedQueuedMessageId = undefined - } + /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ + if (this.abort) { + if (queuedMessageId) { + this.messageQueueService.releaseMessage(queuedMessageId) } + throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) + } - const result = { - response: this.askResponse!, - text: this.askResponseText, - images: this.askResponseImages, - queuedMessageId, + if (this.lastMessageTs !== askTs) { + // Could happen if we send multiple asks in a row i.e. with + // command_output. It's important that when we know an ask could + // fail, it is handled gracefully. + if (queuedMessageId) { + this.messageQueueService.releaseMessage(queuedMessageId) } - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.askResponseQueuedMessageId = undefined + throw new AskIgnoredError("superseded") + } + + // Tie a drain-submitted queued message to actual consumption by identity: + // the ask consumed the submission only if the pending slot still carries + // that message's ID. A direct response clears the slot ID even when its + // text/images are identical, so it cannot consume the queue entry. The + // claim path is excluded (durable flows already carry queuedMessageId; + // non-durable flows removed the message inline). + if (this.pendingSubmittedQueuedMessageId) { + const consumedViaPendingSlot = + queuedMessageId === undefined && + this.askResponseQueuedMessageId === this.pendingSubmittedQueuedMessageId + if (consumedViaPendingSlot) { + // Hand the ID to the caller instead of removing inline: the queue + // entry is deleted only after the feedback is durably saved + // (persistQueuedFeedbackAndAcknowledge), so a failed history write + // cannot lose a message that was already dequeued. + queuedMessageId = this.pendingSubmittedQueuedMessageId + this.pendingSubmittedQueuedMessageId = undefined + } else if ( + !this.messageQueueService.messages.some( + (message) => message.id === this.pendingSubmittedQueuedMessageId, + ) + ) { + // The message was consumed via the ask claim path or discarded + // by an existing path; the tracker is stale, so clear it. + this.pendingSubmittedQueuedMessageId = undefined + } + } - // Cancel the timeouts if they are still running. - timeouts.forEach((timeout) => clearTimeout(timeout)) + const result = { + response: this.askResponse!, + text: this.askResponseText, + images: this.askResponseImages, + queuedMessageId, + } + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.askResponseQueuedMessageId = undefined - // Switch back to an active state. - if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { - this.idleAsk = undefined - this.resumableAsk = undefined - this.interactiveAsk = undefined - this.emit(RooCodeEventName.TaskActive, this.taskId) - } + // Cancel the timeouts if they are still running. + timeouts.forEach((timeout) => clearTimeout(timeout)) - this.emit(RooCodeEventName.TaskAskResponded) - return result - } finally { - this.inFlightAskGate = undefined + // Switch back to an active state. + if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { + this.idleAsk = undefined + this.resumableAsk = undefined + this.interactiveAsk = undefined + this.emit(RooCodeEventName.TaskActive, this.taskId) } + + this.emit(RooCodeEventName.TaskAskResponded) + return result } handleWebviewAskResponse( From 4a2e6753dd8b22e331c3220e1234ae5553035a66 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 4 Oct 2026 02:00:49 +0900 Subject: [PATCH 38/51] fix(webview): post condenseTaskContextResponse on condense failure What: the condenseTaskContextRequest catch now also posts {type: 'condenseTaskContextResponse', text: taskId} after logging and showing the error. Why: ClineProvider.condenseTaskContext throws before its success-only response post when taskRegistry.getById finds no task (request racing task removal), and task.condenseContext failures reject the same way. ChatView only clears isCondensing/sendingDisabled from the response message, so the stuck state persisted (CodeRabbit 4172972739). Impact: failure responses carry the same shape as the success path, so the webview state always settles. Pin: rejection now asserts the response post alongside the log and the visible error. --- .../webview/__tests__/webviewMessageHandler.spec.ts | 12 +++++++++--- src/core/webview/webviewMessageHandler.ts | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index ed80cc3949..7233123d6e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2337,8 +2337,8 @@ describe("webviewMessageHandler - chat message queue", () => { expect(updateSpy).toHaveBeenCalledWith(added.id, "edited", ["data:image/png;base64,from-mention"]) }) - it("logs and shows a visible error when condenseTaskContext rejects", async () => { - const condenseError = new Error("queued submission failed") + it("logs, shows an error, and still posts the response when condenseTaskContext rejects", async () => { + const condenseError = new Error("Task with id task-1 not found in stack") const providerWithCondense = mockClineProvider as unknown as { condenseTaskContext: ReturnType } @@ -2349,8 +2349,14 @@ describe("webviewMessageHandler - chat message queue", () => { ).resolves.toBeUndefined() expect(mockClineProvider.log).toHaveBeenCalledWith( - "[condenseTaskContextRequest] Failed: queued submission failed", + "[condenseTaskContextRequest] Failed: Task with id task-1 not found in stack", ) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.condense_failed") + // The response must still reach the webview so ChatView clears + // isCondensing/sendingDisabled when the task vanished mid-request. + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "condenseTaskContextResponse", + text: "task-1", + }) }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 247dca56de..f02edb8f6b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -927,6 +927,11 @@ export const webviewMessageHandler = async ( `[condenseTaskContextRequest] Failed: ${error instanceof Error ? error.message : String(error)}`, ) await vscode.window.showErrorMessage(t("common:errors.condense_failed")) + // Post the response even on failure: a request racing task removal + // (or a failed condense) must still clear ChatView's + // isCondensing/sendingDisabled, which only the response message + // releases. The shape matches the success path. + await provider.postMessageToWebview({ type: "condenseTaskContextResponse", text: message.text! }) } break case "deleteTaskWithId": From 43a5ead5e10112eb23eb23b39c7968b231f0f389 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 4 Oct 2026 02:01:48 +0900 Subject: [PATCH 39/51] fix(api): contain rejected headless SendMessage inside the IPC boundary What: the TaskCommand SendMessage dispatch wraps sendMessage in try/catch, logging '[API] SendMessage failed: ...' and swallowing, the same boundary convention as the neighboring ResumeTask case. Why: headless delivery now rejects when the task refuses the slot write (an approval ask pending) or no task exists. The IpcServer dispatch is fire-and-forget, so the rejection surfaced as an unhandled rejection and the client got no failure signal (CodeRabbit 4173440724). No typed SendMessage failure response exists (unlike Commands/Modes/Models), so catch-and-log is the established shape. Impact: no unhandled rejection from the IPC listener; failures are visible in the log. Pin: a mocked @roo-code/ipc server captures the registered handler; a refusing task's SendMessage resolves undefined and logs the failure. --- .../__tests__/api-send-message.spec.ts | 47 ++++++++++++++++++- src/extension/api.ts | 13 ++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api-send-message.spec.ts index d574019c1c..02977630ee 100644 --- a/src/extension/__tests__/api-send-message.spec.ts +++ b/src/extension/__tests__/api-send-message.spec.ts @@ -3,11 +3,32 @@ import * as vscode from "vscode" import { API } from "../api" import { ClineProvider } from "../../core/webview/ClineProvider" -import { TaskCommandName } from "@roo-code/types" +import { IpcMessageType, TaskCommandName } from "@roo-code/types" vi.mock("vscode") vi.mock("../../core/webview/ClineProvider") +// Capture the registered IPC TaskCommand handler so a test can drive the +// dispatch directly. IpcServer dispatches with emit (no promise handling), so +// a rejected command must be contained inside the listener itself. +const ipcState = vi.hoisted(() => ({ + handlers: new Map unknown>(), + instances: [] as unknown[], +})) + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class { + listen = vi.fn() + send = vi.fn() + on = vi.fn((type: string, handler: (...args: unknown[]) => unknown) => { + ipcState.handlers.set(type, handler) + }) + constructor(...args: unknown[]) { + ipcState.instances.push(args) + } + }, +})) + describe("API - SendMessage Command", () => { let api: API let mockOutputChannel: vscode.OutputChannel @@ -175,4 +196,28 @@ describe("API - SendMessage Command", () => { expect(submitUserMessage).toHaveBeenCalledWith("Hello from headless", undefined) expect(mockPostMessageToWebview).not.toHaveBeenCalled() }) + + it("contains a rejected headless SendMessage inside the IPC dispatch boundary", async () => { + // The task refuses the delivery; the fire-and-forget IPC listener must + // swallow and log the rejection instead of leaking an unhandled one. + const submitUserMessage = vi.fn().mockResolvedValue(false) + const headlessProvider = { + context: {} as vscode.ExtensionContext, + postMessageToWebview: mockPostMessageToWebview, + on: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + getCurrentTask: vi.fn().mockReturnValue({ submitUserMessage }), + viewLaunched: false, + } as unknown as ClineProvider + new API(mockOutputChannel, headlessProvider, "/tmp/test-roo-code.sock", true) + const handler = ipcState.handlers.get(IpcMessageType.TaskCommand) + expect(handler).toBeDefined() + + await expect( + handler!("client-1", { commandName: TaskCommandName.SendMessage, data: { text: "hi" } }), + ).resolves.toBeUndefined() + + // The rejection is contained: logged, not rethrown. + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining("[API] SendMessage failed")) + }) }) diff --git a/src/extension/api.ts b/src/extension/api.ts index 8280fcf788..0e354a9038 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -110,7 +110,18 @@ export class API extends EventEmitter implements RooCodeAPI { break case TaskCommandName.SendMessage: this.log(`[API] SendMessage -> ${command.data.text}`) - await this.sendMessage(command.data.text, command.data.images) + try { + await this.sendMessage(command.data.text, command.data.images) + } catch (error) { + // Headless delivery can reject (no task, or the task + // refused the slot write while an approval ask is pending). + // The IPC dispatch is fire-and-forget, so an uncaught + // rejection here would be unhandled; log like the + // ResumeTask boundary and swallow — there is no typed + // SendMessage failure response. + const errorMessage = error instanceof Error ? error.message : String(error) + this.log(`[API] SendMessage failed: ${errorMessage}`) + } break case TaskCommandName.GetCommands: try { From 9a07b0c9ec3bf269ed31659e34d636373ffaf91a Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 4 Oct 2026 02:08:01 +0900 Subject: [PATCH 40/51] fix(task): route queued-message edits through the task's pending submission What: new Task.editQueuedMessage updates the queue entry and, when the edited entry is still the pending drain submission (tracker and slot ID both match), replaces the ask-response slot's text/images with the edited content. webviewMessageHandler's editQueuedMessage case routes through it, keeping the resolveIncomingImages validation. Why: processQueuedMessages submits the original text/images but retains the queue entry; editing only the entry left the task-owned submitted copy stale, so an ask that intercepted the submission returned the original response and the edit was lost (CodeRabbit 4172972741). Impact: an edit landing between submission and consumption is reflected in the consumed response. Pins: drain-submit -> edit -> intercepted ask returns edited text/images; the handler test now asserts the task-level routing plus the unchanged image validation. --- src/core/task/Task.ts | 18 +++++++++++++++ .../ask-queued-message-drain.spec.ts | 22 +++++++++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 5 +++++ src/core/webview/webviewMessageHandler.ts | 5 +++-- 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d2c45e52e1..500f1d6fdb 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1220,6 +1220,24 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Apply a webview edit to a queued message. Besides updating the queue + * entry, the task-owned pending submission is updated in place: while the + * ask-response slot still carries that submission (no direct response has + * overwritten it), the edited text/images replace the original copy so the + * consuming ask returns the edited content instead of the stale one. + */ + public editQueuedMessage(queuedMessageId: string, text: string, images?: string[]): void { + this.messageQueueService.updateMessage(queuedMessageId, text, images) + if ( + this.pendingSubmittedQueuedMessageId === queuedMessageId && + this.askResponseQueuedMessageId === queuedMessageId + ) { + this.askResponseText = text + this.askResponseImages = images + } + } + /** * Clears the pending action metadata after its durable result is saved. * Reconciles in-memory state with the task history store to avoid clearing a newer action. diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index f9d74903e5..61f6f10fd3 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -381,6 +381,28 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.messages.map((message) => message.text)).toEqual(["queued note"]) }) + it("applies a queue edit to the pending submitted response before the ask consumes it", async () => { + const task = await createTask({ getState: async () => ({}) }) + + // Park a completion ask, then drain: the submission lands in the + // ask-response slot and stays queued until the durable ack. + const askPromise = task.ask("completion_result", "Done", false) + await new Promise((resolve) => setTimeout(resolve, 150)) + task.messageQueueService.addMessage("original text") + await task.processQueuedMessages() + const queuedEntry = task.messageQueueService.messages.at(0) + if (!queuedEntry) throw new Error("queued message missing") + expect(task["askResponseText"]).toBe("original text") + + // The user edits the queued message before the ask observes the slot: + // the task-owned pending submission must carry the edited content. + task.editQueuedMessage(queuedEntry.id, "edited text", ["edited.png"]) + + const result = await askPromise + expect(result).toMatchObject({ response: "messageResponse", text: "edited text", images: ["edited.png"] }) + expect(result.queuedMessageId).toBe(queuedEntry.id) + }) + it("delivers an intercepted message exactly once when a consumer acks through the durable helper", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 7233123d6e..19e673e70e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2319,10 +2319,14 @@ describe("webviewMessageHandler - chat message queue", () => { const queue = new MessageQueueService() const added = queue.addMessage("original")! const updateSpy = vi.spyOn(queue, "updateMessage") + const editQueuedMessage = vi.fn((id: string, text: string, images?: string[]) => + queue.updateMessage(id, text, images), + ) vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ cwd: "/mock/workspace", rooIgnoreController: undefined, messageQueueService: queue, + editQueuedMessage, } as unknown as ReturnType) await webviewMessageHandler(mockClineProvider, { @@ -2334,6 +2338,7 @@ describe("webviewMessageHandler - chat message queue", () => { // message must carry the validated images, proving the edit path no // longer bypasses the size/mention validation applied to queueMessage. expect(resolveImageMentions).toHaveBeenCalled() + expect(editQueuedMessage).toHaveBeenCalledWith(added.id, "edited", ["data:image/png;base64,from-mention"]) expect(updateSpy).toHaveBeenCalledWith(added.id, "edited", ["data:image/png;base64,from-mention"]) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f02edb8f6b..3e187d1359 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3837,9 +3837,10 @@ export const webviewMessageHandler = async ( if (message.payload) { const { id, text, images } = message.payload as EditQueuedMessagePayload // Edits can attach images too, so they go through the same - // size/mention validation as fresh queued messages. + // size/mention validation as fresh queued messages, and through + // the task so a pending drain submission is updated in place. const resolved = await resolveIncomingImages({ text, images }) - provider.getCurrentTask()?.messageQueueService.updateMessage(id, resolved.text, resolved.images) + provider.getCurrentTask()?.editQueuedMessage(id, resolved.text, resolved.images) } break From ed89a6e7dad0369db591a305072f655f7a0efcfc Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 4 Oct 2026 02:14:13 +0900 Subject: [PATCH 41/51] fix(task): defer the ask-start queued claim to the handoff point What: askImpl now computes the queued-ask resolution in the prefix but takes claimNextMessage() in the handoff branch itself, immediately before the response is posted (non-command) or the command policy re-check starts (which owns its own release in its finally). Why: the claim used to run in the ask prefix while the handoff happened only after awaited addToClineMessages; a synchronously throwing Message listener in between propagated out of ask() with the claim stranded in claimedMessageIds, where no later drain or ask could deliver it for the task's lifetime (CodeRabbit 4173440718). Impact: claim-to-use is now synchronous for non-command asks, so no prefix throw can strand the claim; command asks keep the existing re-check finally. Pins: a throwing prefix leaves the message immediately claimable again. All 42 core/task files pass (719 tests). --- src/core/task/Task.ts | 124 ++++++++++-------- .../ask-queued-message-drain.spec.ts | 17 +++ 2 files changed, 83 insertions(+), 58 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 500f1d6fdb..4a04b99bc7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2035,16 +2035,17 @@ export class Task extends EventEmitter implements TaskLike { // resolution would leak the claim. The resolution is only actionable // when a message was actually claimed: otherwise it must not force a // manual ask (that would bypass auto-approval for empty-queue lifecycle - // asks). - const claimableResolution = + // asks). The claim itself is deferred to the handoff branch below: it + // runs at the point of use with no prefix await in between, so a throw + // from the prefix (e.g. a synchronously throwing Message listener in + // addToClineMessages) can never strand the claim in claimedMessageIds. + const queuedAskResolution = partial === true || type === "command_output" || !queueMayAnswerThisAsk || !this.mayDrainQueuedMessageForAsk() ? undefined : queuedResponseForAsk(type, text) - const queuedMessage = claimableResolution ? this.messageQueueService.claimNextMessage() : undefined - const queuedAskResolution = queuedMessage ? claimableResolution : undefined // `this.cwd`, not `provider.cwd`: // The path inside `text` was made relative to this task's workspace, // which for a resumed or child task need not be the one the provider @@ -2303,65 +2304,72 @@ export class Task extends EventEmitter implements TaskLike { if (isStatusMutable) { armAskStatusTimers() - } else if (queuedMessage && queuedAskResolution) { - if (type === "command") { - // The snapshot gate is frozen; blanket deny may have engaged since the - // ask began. Re-read policy before the message stands in for approval. - // Drain-site parity for cancellation and cleanup: the fresh policy - // read ends in an uncancellable provider read, so an abort poller - // aborts a signal the re-check itself awaits — settling it on the - // abort instead of leaving a pending promise that retains this task - // and runs policy post-abort — and one `finally` releases the claim - // on the abort, throw, supersession, and "release" outcomes alike. - const recheckAbort = new AbortController() - const checkAbort = () => { - if (this.abort) { - recheckAbort.abort() + } else if (queuedAskResolution && this.mayDrainQueuedMessageForAsk()) { + // Claim at the point of handoff: for non-command asks the response is + // posted synchronously below (no await between claim and use), and the + // command re-check owns its own release in its finally — so a prefix + // throw can never strand this claim. + const queuedMessage = this.messageQueueService.claimNextMessage() + if (queuedMessage) { + if (type === "command") { + // The snapshot gate is frozen; blanket deny may have engaged since the + // ask began. Re-read policy before the message stands in for approval. + // Drain-site parity for cancellation and cleanup: the fresh policy + // read ends in an uncancellable provider read, so an abort poller + // aborts a signal the re-check itself awaits — settling it on the + // abort instead of leaving a pending promise that retains this task + // and runs policy post-abort — and one `finally` releases the claim + // on the abort, throw, supersession, and "release" outcomes alike. + const recheckAbort = new AbortController() + const checkAbort = () => { + if (this.abort) { + recheckAbort.abort() + } } - } - checkAbort() - const abortWatcher = setInterval(checkAbort, 100) - try { - const action = await this.recheckQueuedCommandPolicy( - { - text, - isProtected, - dcgDecision: autoApprovalContext?.dcgDecision, - }, - recheckAbort.signal, - ) - if (!this.abort && this.askResponse === undefined && this.lastMessageTs === askTs) { - // Any outcome on a still-live ask — the user answered or the - // ask was superseded — leaves the message for the next - // consumer; the `finally` below releases the claim and the ask - // resolves with the user's own response via the pWaitFor. - queuedMessageId = this.applyQueuedCommandPolicyAction( - action, - queuedMessage, - queuedAskResolution, + checkAbort() + const abortWatcher = setInterval(checkAbort, 100) + try { + const action = await this.recheckQueuedCommandPolicy( + { + text, + isProtected, + dcgDecision: autoApprovalContext?.dcgDecision, + }, + recheckAbort.signal, ) - // A "release" outcome leaves the ask pending; consume/deny/approve - // resolved it, and the arm's pending check declines to arm then. + if (!this.abort && this.askResponse === undefined && this.lastMessageTs === askTs) { + // Any outcome on a still-live ask — the user answered or the + // ask was superseded — leaves the message for the next + // consumer; the `finally` below releases the claim and the ask + // resolves with the user's own response via the pWaitFor. + queuedMessageId = this.applyQueuedCommandPolicyAction( + action, + queuedMessage, + queuedAskResolution, + ) + // A "release" outcome leaves the ask pending; consume/deny/approve + // resolved it, and the arm's pending check declines to arm then. + armAskStatusTimers() + } + } catch (error) { + // Drain-site parity: a failed re-check must not reject ask() nor + // strand the claim; the prompt stays pending for the user. + console.error("[Task#ask] queued command policy re-check failed:", error) armAskStatusTimers() + } finally { + clearInterval(abortWatcher) + // One release path for abort, throw, supersession, and "release". + // `releaseMessage` is idempotent, so it stays safe beside the + // helper-internal release. The durable consume is the one outcome + // that must keep its claim until persistence removes the message — + // it is the only path that assigned `queuedMessageId` here. + if (queuedMessageId !== queuedMessage.id) { + this.messageQueueService.releaseMessage(queuedMessage.id) + } } - } catch (error) { - // Drain-site parity: a failed re-check must not reject ask() nor - // strand the claim; the prompt stays pending for the user. - console.error("[Task#ask] queued command policy re-check failed:", error) - armAskStatusTimers() - } finally { - clearInterval(abortWatcher) - // One release path for abort, throw, supersession, and "release". - // `releaseMessage` is idempotent, so it stays safe beside the - // helper-internal release. The durable consume is the one outcome - // that must keep its claim until persistence removes the message — - // it is the only path that assigned `queuedMessageId` here. - if (queuedMessageId !== queuedMessage.id) { - this.messageQueueService.releaseMessage(queuedMessage.id) - } + } else { + queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) } - } else { - queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) } } else if (shouldDrainQueuedMessageForAsk && isMessageQueued) { // The claim gate (per-turn latch, or blanket deny engaged for a command diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 61f6f10fd3..7099afe812 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -403,6 +403,23 @@ describe("Task.ask queued message drain", () => { expect(result.queuedMessageId).toBe(queuedEntry.id) }) + it("leaves a queued message redeliverable when the ask prefix throws", async () => { + const task = await createTask() + task.messageQueueService.addMessage("stranded no more") + const access = getQueueTaskTestAccess(task) + // A synchronously throwing Message listener during the ask prefix. + access.addToClineMessages = vi.fn(async () => { + throw new Error("listener boom") + }) + + await expect(task.ask("followup", "Q?", false)).rejects.toThrow("listener boom") + + // The claim is taken at the handoff, not in the prefix, so the throw + // cannot strand it in claimedMessageIds: the message is immediately + // claimable again for a later ask or drain. + expect(task.messageQueueService.claimNextMessage()?.text).toBe("stranded no more") + }) + it("delivers an intercepted message exactly once when a consumer acks through the durable helper", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") From f65eb12cb7c2deb4cc6692c664c5f40e4c2a5119 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 4 Oct 2026 02:17:30 +0900 Subject: [PATCH 42/51] fix(task): reserve a consumed drain submission through its durable ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: MessageQueueService gains claimMessage(id), and the pending-slot consumption in Task.ask claims the entry when handing its ID to the caller. Persistence already settles the reservation: removeMessage on success, releaseMessage on failure/abort, and the ask abort/superseded paths release the handed ID. Why: the drain path releases its claim after submitting, and the pending-slot consumption handed the ID back without claiming — so while persistQueuedFeedbackAndAcknowledge retried (backoff can run seconds), the entry sat unclaimed in the queue. A background drain (tracker already cleared) or a second ask could claim it again, submitting and persisting the same user message twice, with one ack failing after the other removed the entry (CodeRabbit 4173559934). Impact: the entry stays reserved from consumption until persistence settles; the claim-path consumption was already reserved by its own claim. Pin: interception -> claimNextMessage undefined and a second drain no-ops while the ack is unsettled; a failed ack releases the reservation for redelivery. --- src/core/message-queue/MessageQueueService.ts | 17 +++++++++ src/core/task/Task.ts | 6 +++ .../ask-queued-message-drain.spec.ts | 37 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/src/core/message-queue/MessageQueueService.ts b/src/core/message-queue/MessageQueueService.ts index 85a2192217..c1e6b18b52 100644 --- a/src/core/message-queue/MessageQueueService.ts +++ b/src/core/message-queue/MessageQueueService.ts @@ -113,6 +113,23 @@ export class MessageQueueService extends EventEmitter { return this._messages.length === 0 } + /** + * Reserve a specific queued message by ID when it is still present and + * unclaimed. Lets a consumer hold a consumed entry through its durable ack + * so neither another ask nor a background drain can claim it again + * mid-persistence. + */ + public claimMessage(id: string): boolean { + if (this.claimedMessageIds.has(id)) { + return false + } + if (!this._messages.some((message) => message.id === id)) { + return false + } + this.claimedMessageIds.add(id) + return true + } + /** * Whether at least one queued message is still available to be claimed. * diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4a04b99bc7..def78d3873 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2529,6 +2529,12 @@ export class Task extends EventEmitter implements TaskLike { // cannot lose a message that was already dequeued. queuedMessageId = this.pendingSubmittedQueuedMessageId this.pendingSubmittedQueuedMessageId = undefined + // Reserve the entry through the durable ack: while persistence + // retries (which can take seconds), neither a second ask nor a + // background drain may claim it again and persist the same + // message twice. Persistence settles the reservation itself — + // removeMessage on success, releaseMessage on failure/abort. + this.messageQueueService.claimMessage(queuedMessageId) } else if ( !this.messageQueueService.messages.some( (message) => message.id === this.pendingSubmittedQueuedMessageId, diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 7099afe812..38d11aeb07 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -420,6 +420,43 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.claimNextMessage()?.text).toBe("stranded no more") }) + it("reserves a consumed drain submission through its durable ack", async () => { + const task = await createTask({ getState: async () => ({}) }) + + // Interception: the ask is blocked before the drain submits, so the + // pending-slot consumption path hands the ID back. + const askPromise = task.ask("completion_result", "Done", false) + await new Promise((resolve) => setTimeout(resolve, 150)) + task.messageQueueService.addMessage("Reserve me") + await task.processQueuedMessages() + const result = await askPromise + const entry = task.messageQueueService.messages.at(0) + if (!entry) throw new Error("queued message missing") + expect(result.queuedMessageId).toBe(entry.id) + + // While the durable ack has not settled, the entry stays reserved: + // neither a later ask nor a background drain can claim it again and + // persist the same message twice. + expect(task.messageQueueService.claimNextMessage()).toBeUndefined() + const submitSpy = vi.spyOn(task, "submitUserMessage") + await expect(task.processQueuedMessages()).resolves.toBe(false) + expect(submitSpy).not.toHaveBeenCalled() + + // A failed ack releases the reservation for redelivery. + const access = getQueueTaskTestAccess(task) + access.say = vi.fn().mockResolvedValue(undefined) + access.saveClineMessages = vi.fn(async () => false) + vi.useFakeTimers() + try { + const persistence = task.persistQueuedFeedbackAndAcknowledge(entry.id, result.text, result.images) + await vi.runAllTimersAsync() + await expect(persistence).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + expect(task.messageQueueService.claimNextMessage()?.text).toBe("Reserve me") + }) + it("delivers an intercepted message exactly once when a consumer acks through the durable helper", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") From d6d0b8629fb5b6a677206eaadacc863187504e9f Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 4 Oct 2026 05:11:54 +0900 Subject: [PATCH 43/51] fix(task): track in-flight ask gates per ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: the single inFlightAskGate field becomes a per-ask Set; the ask wrapper adds only its own gate and the finally removes only that gate. inFlightAskBlocksQueuedSubmission() refuses when ANY armed gate blocks (identical per-gate predicate). Lazily initialized because Object.create(Task.prototype) harnesses skip field initializers. Why: overlapping asks shared one field, so ask A's finally (A superseded or throwing AskIgnoredError while approval ask B waits) cleared B's gate; a drain could then post messageResponse into B's slot and B was answered without an explicit user decision (CodeRabbit 4174027311, noting 8be15bc was NOT reverted — the wrapper/impl split kept the single field by design; only Message-listener re-entrancy or the supersede flow could overlap asks). Impact: per-ask gates make the overlap safe without changing the single-ask fast path (one gate in the set behaves exactly as before). Pin: ask A superseded by ask B — A's exit keeps B's gate armed and a submission is still refused while B blocks. --- src/core/task/Task.ts | 35 ++++++++++++------- .../ask-queued-message-drain.spec.ts | 24 +++++++++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index def78d3873..f682526f4a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -524,11 +524,13 @@ export class Task extends EventEmitter implements TaskLike { // submission was consumed (hand the ID to the caller for a durable ack) or // overwritten unconsumed (retain for a later ask). private pendingSubmittedQueuedMessageId: string | undefined - // The ask currently blocked in Task.ask's response wait, when any. A - // background drain consults queuedResponseForAsk against this gate so a - // queued conversational message can never answer an approval-gating ask, - // exactly matching the claim path's resolution gate. - private inFlightAskGate: { type: ClineAsk; text?: string } | undefined + // The asks currently inside Task.ask, tracked per ask so an exiting ask + // (including a re-entrant one from a Message listener) can never clear + // another ask's gate. A background drain consults queuedResponseForAsk + // against these gates so a queued conversational message can never answer + // an approval-gating ask, exactly matching the claim path's resolution + // gate. + private inFlightAskGates: Set<{ type: ClineAsk; text?: string }> | undefined /** * True while a raw queued submission must not be posted into the ask slot: * an ask is in flight AND either its slot already carries a direct response, @@ -542,15 +544,18 @@ export class Task extends EventEmitter implements TaskLike { * submissions proceed. */ private inFlightAskBlocksQueuedSubmission(): boolean { - const gate = this.inFlightAskGate - return ( - !!gate && - (this.askResponse !== undefined || + for (const gate of this.inFlightAskGates ?? []) { + if ( + this.askResponse !== undefined || queuedResponseForAsk(gate.type, gate.text) === undefined || gate.type === "command" || gate.type === "use_mcp_server" || - gate.type === "tool") - ) + gate.type === "tool" + ) { + return true + } + } + return false } // Association between a queued message ID and the user_feedback row its ack // persisted. A redelivery after a partial save failure reconciles the same @@ -1966,11 +1971,15 @@ export class Task extends EventEmitter implements TaskLike { // indentation instead of re-indenting it under a wrapping try block, // so whitespace-only changes do not count against the mutation-diff // line budget. - this.inFlightAskGate = { type, text } + const gate = { type, text } + // Lazily initialized: Object.create(Task.prototype)-based harnesses skip + // field initializers, and the gate must exist before the try's finally. + const gates = (this.inFlightAskGates ??= new Set<{ type: ClineAsk; text?: string }>()) + gates.add(gate) try { return await this.askImpl(type, text, partial, progressStatus, isProtected, autoApprovalContext) } finally { - this.inFlightAskGate = undefined + gates.delete(gate) } } diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 38d11aeb07..6d6baffc33 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -457,6 +457,30 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.claimNextMessage()?.text).toBe("Reserve me") }) + it("keeps a blocked ask's drain gate when a superseded ask exits", async () => { + const task = await createTask({ getState: async () => ({}) }) + + // Ask A blocks in its wait. + const askA = task.ask("followup", "A?", false).catch((error: unknown) => error) + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Ask B starts: its prefix supersedes A (lastMessageTs moves) and arms + // its own gate before A's superseded-exit finally runs. A's finally + // must remove only A's gate. + const askB = task.ask("api_req_failed", "B failure gate", false).catch((error: unknown) => error) + await new Promise((resolve) => setTimeout(resolve, 200)) + + // A has exited; B is still blocked. A's exit must not have cleared B's + // gate: a queued submission is still refused. + await expect(task.submitUserMessage("queued note")).resolves.toBe(false) + + setTimeout(() => task.approveAsk(), 0) + const resultB = await askB + expect(resultB).toMatchObject({ response: "yesButtonClicked" }) + const resultA = await askA + expect(String(resultA)).toContain("superseded") + }) + it("delivers an intercepted message exactly once when a consumer acks through the durable helper", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") From a0f9770c2a326d9055cede87e23e3f52ba2ed6ce Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 4 Oct 2026 05:13:29 +0900 Subject: [PATCH 44/51] fix(task): hand a consumed submission's ID only when its entry is reserved What: the pending-slot consumption hands queuedMessageId to the caller only when claimMessage(submittedId) succeeds; the tracker is cleared either way. When the entry is gone (user deleted the queued message between drain submission and consumption), the response text/images are still returned but with queuedMessageId undefined, so the consumer treats them as direct feedback via the say path. Why: with the reservation change, claimMessage can fail on an entry the webview removed mid-flight; handing the ID anyway made the durable ack persist feedback and then fail at removeMessage on the missing entry, turning sayUserFeedbackAndAckQueued into a throw (CodeRabbit 4174162031). Impact: no ID without a live reserved entry; deleted-mid-flight submissions degrade to direct feedback. Pin: drain-submit -> remove -> consume returns the text with queuedMessageId undefined. --- src/core/task/Task.ts | 13 +++++++++--- .../ask-queued-message-drain.spec.ts | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f682526f4a..e3516ee4a9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2535,15 +2535,22 @@ export class Task extends EventEmitter implements TaskLike { // Hand the ID to the caller instead of removing inline: the queue // entry is deleted only after the feedback is durably saved // (persistQueuedFeedbackAndAcknowledge), so a failed history write - // cannot lose a message that was already dequeued. - queuedMessageId = this.pendingSubmittedQueuedMessageId + // cannot lose a message that was already dequeued. The ID is + // handed only when the entry can also be reserved: the user may + // have deleted the queued message between submission and + // consumption, and acking an entry that no longer exists would + // fail at removeMessage — without a reservation the response is + // treated as direct feedback instead. + const submittedId = this.pendingSubmittedQueuedMessageId this.pendingSubmittedQueuedMessageId = undefined // Reserve the entry through the durable ack: while persistence // retries (which can take seconds), neither a second ask nor a // background drain may claim it again and persist the same // message twice. Persistence settles the reservation itself — // removeMessage on success, releaseMessage on failure/abort. - this.messageQueueService.claimMessage(queuedMessageId) + if (this.messageQueueService.claimMessage(submittedId)) { + queuedMessageId = submittedId + } } else if ( !this.messageQueueService.messages.some( (message) => message.id === this.pendingSubmittedQueuedMessageId, diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 6d6baffc33..91074eadfb 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -481,6 +481,26 @@ describe("Task.ask queued message drain", () => { expect(String(resultA)).toContain("superseded") }) + it("treats a consumed submission as direct feedback when the entry was deleted mid-flight", async () => { + const task = await createTask({ getState: async () => ({}) }) + + // Interception: blocked ask, drain submits, then the user deletes the + // queued message before the ask observes the pending slot. + const askPromise = task.ask("completion_result", "Done", false) + await new Promise((resolve) => setTimeout(resolve, 150)) + task.messageQueueService.addMessage("deleted before consume") + await task.processQueuedMessages() + const entry = task.messageQueueService.messages.at(0) + if (!entry) throw new Error("queued message missing") + task.messageQueueService.removeMessage(entry.id) + + const result = await askPromise + + // No entry remains to ack: the ID must not be handed back, and the + // response text is delivered as direct feedback instead. + expect(result).toMatchObject({ response: "messageResponse", text: "deleted before consume" }) + expect(result.queuedMessageId).toBeUndefined() + }) it("delivers an intercepted message exactly once when a consumer acks through the durable helper", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") From db3515260416c6b94440389314d0f1917e960700 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 6 Oct 2026 02:03:10 +0900 Subject: [PATCH 45/51] fix(task): repair ask() abort re-check after main merge Merging origin/main (RSK-20) textually kept a release of the queued message that main's flow claimed in the prefix. This branch deliberately defers claiming to the handoff branch, which owns its own release, so no claim exists at the re-check and the reference broke ask() with a ReferenceError on every mid-await abort. The re-check now only guards against posting an ask row. The merged ask-abort-race spec also assigned a Promise mock to addToClineMessages, which this branch types as Promise; align the mock with the signature. --- src/core/task/Task.ts | 5 ++--- src/core/task/__tests__/ask-abort-race.spec.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7e59c8130b..ccb533c1d7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2072,10 +2072,9 @@ export class Task extends EventEmitter implements TaskLike { const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined // Re-check: an abort during the getState/checkAutoApproval awaits must not post an ask row. + // No queued claim exists yet at this point — claiming is deferred to the handoff + // branch below, which owns its release — so there is nothing to release here. if (this.abort) { - if (queuedMessage) { - this.messageQueueService.releaseMessage(queuedMessage.id) - } throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } diff --git a/src/core/task/__tests__/ask-abort-race.spec.ts b/src/core/task/__tests__/ask-abort-race.spec.ts index aa5dbe4a82..8efdbf8b2a 100644 --- a/src/core/task/__tests__/ask-abort-race.spec.ts +++ b/src/core/task/__tests__/ask-abort-race.spec.ts @@ -14,7 +14,7 @@ function buildTask(getState: () => Promise>) { task["abort"] = false task["clineMessages"] = [] task["lastMessageTs"] = undefined - task["addToClineMessages"] = vi.fn(async () => {}) + task["addToClineMessages"] = vi.fn(async () => true) task["saveClineMessages"] = vi.fn(async () => true) task["updateClineMessage"] = vi.fn(async () => {}) // Double assertion: `providerRef` is a `WeakRef`; `Task.ask` only calls `deref()` and `getState()`. From b3561ff11d396539018ee67fa9b583647aaedcab Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 6 Oct 2026 02:04:30 +0900 Subject: [PATCH 46/51] fix(task): persist queued edits made during a failed-save backoff The queue entry stays claimed (and editable in the queue UI) until persistQueuedFeedbackAndAcknowledge removes it, and updateMessage accepts claimed entries. An edit that landed while a save was retrying changed only the entry, so a later successful retry persisted the stale feedback row and removed the edited entry. The ack now snapshots the retained entry and reconciles the feedback row against it before every save attempt, and re-confirms the match after a successful save (re-saving when an edit landed in flight), so removal can no longer drop an edit the history write never held. Regression: an entry edited mid-backoff is persisted, and exactly one retry runs after the initial failure. --- src/core/task/Task.ts | 115 ++++++++++++++---- .../ask-queued-message-drain.spec.ts | 42 +++++++ 2 files changed, 136 insertions(+), 21 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ccb533c1d7..d2746b7e21 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -163,6 +163,19 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds export const MODEL_FETCH_TIMEOUT_MS = 5_000 const QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS = [250, 1_000, 4_000] as const +// Compares two queued-entry image lists by value. An edit replaces the entry's +// array with a fresh one, so identity alone cannot tell "edited to equal +// content" apart from "not edited". +function queuedEntryImagesEqual(a: string[] | undefined, b: string[] | undefined): boolean { + if (a === b) { + return true + } + if (!a || !b || a.length !== b.length) { + return false + } + return a.every((image, index) => image === b[index]) +} + type QueuedAskResolution = { response: ClineAskResponse; requiresDurableAck: boolean } /** @@ -1105,6 +1118,16 @@ export class Task extends EventEmitter implements TaskLike { this.pendingSubmittedQueuedMessageId = undefined } } + // The entry stays claimed (and editable in the queue UI) for the whole + // ack, so its content can drift from the submission copy while saves + // retry. Snapshot the entry now and reconcile the row against it before + // every save; comparing against this snapshot rather than the row keeps + // the trimmed submission text from reading as an edit next to the + // untrimmed entry text. + const entrySnapshot = (() => { + const entry = this.messageQueueService.messages.find((queued) => queued.id === messageId) + return entry ? { text: entry.text, images: entry.images } : undefined + })() try { try { if (row) { @@ -1135,6 +1158,49 @@ export class Task extends EventEmitter implements TaskLike { this.queuedFeedbackRows.set(messageId, row) await this.addToClineMessages(row) } + for (let attempt = 0; attempt <= QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length; attempt++) { + if (this.abort || this.abandoned) { + this.messageQueueService.releaseMessage(messageId) + return false + } + if (entrySnapshot) { + // An edit during a failed-save backoff changed only the queue + // entry: fold it into the row before this attempt's save so the + // ack persists the edit instead of the stale submission copy. + await this.reconcileQueuedFeedbackRowWithQueueEntry(row, messageId, entrySnapshot) + } + if (await this.saveClineMessages()) { + // The save clones history up front, so an edit that lands + // while it is in flight is missing from the persisted copy. + // Confirm the entry still matches the row and repeat the save + // when it does not; only then may the entry be removed. + let confirmed = !entrySnapshot + while (entrySnapshot && !this.abort && !this.abandoned) { + if (!(await this.reconcileQueuedFeedbackRowWithQueueEntry(row, messageId, entrySnapshot))) { + confirmed = true + break + } + if (!(await this.saveClineMessages())) { + break + } + } + if (confirmed) { + this.queuedFeedbackRows.delete(messageId) + return this.messageQueueService.removeMessage(messageId) + } + } + if (attempt < QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length) { + // Interruptible backoff: an abort or abandonment during the wait + // resolves promptly (releasing the claim at the loop-top check) + // instead of retaining the task through the full delay. + await this.waitForQueuedFeedbackBackoff(QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS[attempt]) + } + } + console.error( + `[Task#persistQueuedFeedbackAndAcknowledge] Failed to durably save queued feedback ${messageId} after ${QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length + 1} attempts`, + ) + this.messageQueueService.releaseMessage(messageId) + return false } catch (error) { // A failed write must not leave the message claimed: release it so a // later drain can redeliver it. (No-op when the drain path already @@ -1145,32 +1211,39 @@ export class Task extends EventEmitter implements TaskLike { releasePendingTracker() throw error } - for (let attempt = 0; attempt <= QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length; attempt++) { - if (this.abort || this.abandoned) { - this.messageQueueService.releaseMessage(messageId) - return false - } - if (await this.saveClineMessages()) { - this.queuedFeedbackRows.delete(messageId) - return this.messageQueueService.removeMessage(messageId) - } - if (attempt < QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length) { - // Interruptible backoff: an abort or abandonment during the wait - // resolves promptly (releasing the claim at the loop-top check) - // instead of retaining the task through the full delay. - await this.waitForQueuedFeedbackBackoff(QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS[attempt]) - } - } - console.error( - `[Task#persistQueuedFeedbackAndAcknowledge] Failed to durably save queued feedback ${messageId} after ${QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length + 1} attempts`, - ) - this.messageQueueService.releaseMessage(messageId) - return false } finally { releasePendingTracker() } } + /** + * Fold the latest content of a retained (claimed) queue entry into its + * in-flight feedback row. The queue UI accepts edits while the entry is + * claimed, so during a failed-save backoff an edit would otherwise change + * only the entry and be lost when the ack removes it. The row receives the + * trimmed text, mirroring what submitting the entry would have sent. + * Returns true when an edit was folded in. + */ + private async reconcileQueuedFeedbackRowWithQueueEntry( + row: ClineMessage, + messageId: string, + snapshot: { text: string; images?: string[] }, + ): Promise { + const latest = this.messageQueueService.messages.find((queued) => queued.id === messageId) + if (!latest) { + return false + } + if (latest.text === snapshot.text && queuedEntryImagesEqual(latest.images, snapshot.images)) { + return false + } + snapshot.text = latest.text + snapshot.images = latest.images + row.text = latest.text.trim() + row.images = latest.images + await this.updateClineMessage(row) + return true + } + private waitForQueuedFeedbackBackoff(ms: number): Promise { return new Promise((resolve) => { const finish = () => { diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 91074eadfb..0d4910a6eb 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -283,6 +283,48 @@ describe("Task.ask queued message drain", () => { } }) + it("persists an entry edit made during a failed-save backoff", async () => { + const task = await createTask({ getState: async () => ({}) }) + const submitSpy = vi.spyOn(task, "submitUserMessage") + + task.messageQueueService.addMessage("original text") + // Between-turns drain: submits the message and tracks it as pending. + await expect(task.processQueuedMessages()).resolves.toBe(true) + expect(submitSpy).toHaveBeenCalledTimes(1) + + // A completion ask claims the retained entry through the durable path. + const result = await task.ask("completion_result", "Done", false) + expect(result.queuedMessageId).toBe(task.messageQueueService.messages[0]?.id) + + const access = getQueueTaskTestAccess(task) + access.say = vi.fn().mockResolvedValue(undefined) + access.saveClineMessages = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) + + vi.useFakeTimers() + try { + const persistence = task.persistQueuedFeedbackAndAcknowledge( + result.queuedMessageId!, + result.text, + result.images, + ) + // First save attempt fails; the ack is now waiting in the backoff delay. + await vi.advanceTimersByTimeAsync(0) + expect(task.messageQueueService.isEmpty()).toBe(false) + // The queue UI still accepts edits while the entry is retained. + task.editQueuedMessage(result.queuedMessageId!, "edited text") + await vi.advanceTimersByTimeAsync(250) + await expect(persistence).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + + expect(task.messageQueueService.isEmpty()).toBe(true) + // One retry after the initial failure, with no extra save storm. + expect(access.saveClineMessages).toHaveBeenCalledTimes(2) + // The ack persisted the edit, not the stale submission copy. + expect(access.updateClineMessage).toHaveBeenCalledWith(expect.objectContaining({ text: "edited text" })) + }) + it("releases the drain tracker when a claimed durable ack keeps failing", async () => { const task = await createTask({ getState: async () => ({}) }) const submitSpy = vi.spyOn(task, "submitUserMessage") From 6b801ce5a588cb3272761cfaf91014e3b6739ead Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 6 Oct 2026 02:05:14 +0900 Subject: [PATCH 47/51] test(tools): assert commandSubmitted in direct executeCommandInTerminal tests The normal-completion path returns a third tuple value, commandSubmitted, that gates queued-message draining, but the direct specs dropped it on destructuring. Capture and assert it on the normal-completion (true) and working-directory validation (false) paths so an incorrect flag from real execution fails the suite. --- src/core/tools/__tests__/executeCommand.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index 4ac6b07471..ab4c07ffa0 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -261,10 +261,11 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommandInTerminal(mockTask, options) + const [rejected, result, commandSubmitted] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) + expect(commandSubmitted).toBe(false) expect(result).toBe(`Working directory '${nonExistentCwd}' does not exist.`) expect(TerminalRegistry.getOrCreateTerminal).not.toHaveBeenCalled() }) @@ -387,10 +388,11 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommandInTerminal(mockTask, options) + const [rejected, result, commandSubmitted] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) + expect(commandSubmitted).toBe(true) expect(result).toContain("Exit code: 0") expect(result).toContain("within working directory '/test/project'") }) From 3e4161198b28d8e0de1ecdfb57f0c3da2f62bb1c Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 6 Oct 2026 04:14:34 +0900 Subject: [PATCH 48/51] chore: retrigger review-state reconciliation From bc38df91220fdf66441913077802c6ff790d1765 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 6 Oct 2026 04:54:59 +0900 Subject: [PATCH 49/51] chore: re-run checks cancelled by infra From d5872e3a03438636f42ad3c15cfe0b844825df20 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Tue, 6 Oct 2026 05:30:31 +0900 Subject: [PATCH 50/51] chore: retrigger review-state reconciliation From f6af7d89a0408250b7dfe2652a1d891fd21dd9a9 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Fri, 9 Oct 2026 09:12:29 +0900 Subject: [PATCH 51/51] fix(task): address CodeRabbit review on queued-message delivery What: let direct (non-queued) submitUserMessage calls answer approval asks as deny-with-feedback while keeping queued drains barred, and treat a queue entry deleted mid-ack as a successful durable save. Why: the drain guard was over-applied to direct submissions, so a headless sendMessage during a command/use_mcp_server/tool approval ask threw instead of delivering the user's denial, and a user deleting the entry during the ack backoff made persistQueuedFeedbackAndAcknowledge report failure after the feedback row was durably saved. Impact: restores pre-drain direct-submission semantics; queued-message invariants unchanged. Regression tests cover both paths. --- src/core/task/Task.ts | 25 +++++++-- .../ask-queued-message-drain.spec.ts | 55 +++++++++++++++++-- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d2746b7e21..6f361641d7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1186,7 +1186,11 @@ export class Task extends EventEmitter implements TaskLike { } if (confirmed) { this.queuedFeedbackRows.delete(messageId) - return this.messageQueueService.removeMessage(messageId) + // The row is durably saved. A missing entry means the + // user deleted it during the ack (the webview remove + // handler ignores claims); that is not a save failure. + this.messageQueueService.removeMessage(messageId) + return true } } if (attempt < QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length) { @@ -2821,13 +2825,26 @@ export class Task extends EventEmitter implements TaskLike { return false } - // Never overwrite a response an ask is blocked waiting on: an - // approval-gating ask must not be answered by the queue, and a + // Never overwrite a response an ask is blocked waiting on: a // direct response that already landed in the slot must not be // replaced (an approval would become a conversational answer). + // A queued (drain) submission must additionally never answer an + // approval ask or a failure gate on its own — a raw conversational + // post carries no user intent for those — while a direct + // submission is exactly that intent and may answer an approval + // ask (deny-with-feedback), matching the pre-drain behavior. // Queue drains treat the returned false as "leave the message // queued". - if (this.inFlightAskBlocksQueuedSubmission()) { + const inFlightAskGates = this.inFlightAskGates + const directAskBlocksSubmission = + inFlightAskGates !== undefined && + [...inFlightAskGates].some(({ type, text }) => queuedResponseForAsk(type, text) === undefined) + const inFlightAskHasResponse = (inFlightAskGates?.size ?? 0) > 0 && this.askResponse !== undefined + if ( + inFlightAskHasResponse || + (sourceQueuedMessageId === undefined && directAskBlocksSubmission) || + (sourceQueuedMessageId !== undefined && this.inFlightAskBlocksQueuedSubmission()) + ) { return false } diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 0d4910a6eb..1578a69661 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -646,6 +646,45 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.isEmpty()).toBe(true) }) + it("returns success when the user deletes the queue entry during a failed-save backoff", async () => { + const task = await createTask({ getState: async () => ({}) }) + + const askPromise = task.ask("completion_result", "Done", false) + await new Promise((resolve) => setTimeout(resolve, 150)) + + task.messageQueueService.addMessage("deleted during ack") + const drain = task.processQueuedMessages() + + const result = await askPromise + await drain + const messageId = result.queuedMessageId! + + const taskAccess = getQueueTaskTestAccess(task) + taskAccess.addToClineMessages = async (message) => { + taskAccess.clineMessages.push(message!) + return true + } + const saveClineMessages = vi.fn().mockResolvedValue(false) + taskAccess.saveClineMessages = saveClineMessages + + vi.useFakeTimers() + try { + const persistence = task.persistQueuedFeedbackAndAcknowledge(messageId, result.text, result.images) + // The first save fails and the retry backoff starts; while it runs, + // the user deletes the entry from the queue UI (the webview remove + // handler ignores claims). The next save succeeds, so the feedback + // row is durable even though the cleanup removal no-ops. + await vi.advanceTimersByTimeAsync(1) + task.messageQueueService.removeMessage(messageId) + saveClineMessages.mockResolvedValue(true) + await vi.runAllTimersAsync() + await expect(persistence).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + expect(taskAccess.clineMessages.filter((message) => message.say === "user_feedback")).toHaveLength(1) + }) + it("associates the feedback row before the append so a throwing listener cannot strand it", async () => { const task = await createTask({ getState: async () => ({}) }) task.messageQueueService.addMessage("dedupe me") @@ -948,17 +987,25 @@ describe("Task.ask queued message drain", () => { expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) }) - it("refuses to submit while an approval-gating ask is in flight", async () => { + it("refuses a queued submission during an approval-gating ask but lets a direct one deny it", async () => { const task = await createTask({ getState: async () => ({}) }) const askPromise = task.ask("command", "npm test", false) await new Promise((resolve) => setTimeout(resolve, 150)) - await expect(task.submitUserMessage("queued note")).resolves.toBe(false) + // A queued submission carries no user intent and must never answer an + // approval ask on its own. + await expect(task.submitUserMessage("queued note", undefined, undefined, undefined, "queued-1")).resolves.toBe( + false, + ) + + // A direct submission is explicit user intent: it answers the approval + // ask as deny-with-feedback, matching the pre-drain behavior the + // headless API relies on. + await expect(task.submitUserMessage("no, do not run it")).resolves.toBe(true) - setTimeout(() => task.approveAsk(), 0) const result = await askPromise - expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined }) + expect(result).toMatchObject({ response: "messageResponse", text: "no, do not run it" }) }) it.each([