From c6138697a480c8bfa19907a8c875831a3ad6c65a Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 21 Aug 2026 03:12:45 +0000 Subject: [PATCH 1/4] fix(task): preserve queued input as feedback --- apps/vscode-e2e/src/fixtures/subtasks.ts | 85 +++++++++++++++ apps/vscode-e2e/src/suite/subtasks.test.ts | 72 ++++++++++++ src/core/task/Task.ts | 19 +--- .../ask-queued-message-drain.spec.ts | 103 +++++++++++------- src/eslint-suppressions.json | 5 - 5 files changed, 221 insertions(+), 63 deletions(-) diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 30d3852b0a..b9d44eb387 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -14,6 +14,8 @@ const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION" const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE" const SUBTASK_XPROFILE_SAME_CHILD_MARKER = "SUBTASK_CHILD_SAME_PROFILE" const SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER = "SUBTASK_CHILD_DIFFERENT_PROFILE" +export const SUBTASK_QUEUED_INPUT_PARENT_MARKER = "SUBTASK_PARENT_QUEUED_INPUT" +export const SUBTASK_QUEUED_INPUT_CHILD_MARKER = "SUBTASK_CHILD_QUEUED_INPUT" const SUBTASK_CHILD_PROMPT = `${SUBTASK_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.` export const SUBTASK_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.` @@ -54,6 +56,14 @@ export const SUBTASK_XPROFILE_SAME_CHILD_RESULT = "Same-profile child completed" export const SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT = "Different-profile child completed" export const SUBTASK_XPROFILE_PARENT_RESULT = "Sequential cross-profile parent resumed" +const SUBTASK_QUEUED_INPUT_INITIAL_RESULT = "Child completed before queued input" +export const SUBTASK_QUEUED_INPUT_MESSAGE = "Use the queued instruction before completing." +export const SUBTASK_QUEUED_INPUT_CHILD_RESULT = "Child processed queued input" +export const SUBTASK_QUEUED_INPUT_PARENT_RESULT = "Parent resumed after queued input" +const SUBTASK_QUEUED_INPUT_CHILD_PROMPT = `${SUBTASK_QUEUED_INPUT_CHILD_MARKER}: Complete immediately with the exact result "${SUBTASK_QUEUED_INPUT_INITIAL_RESULT}".` +export const SUBTASK_QUEUED_INPUT_PARENT_PROMPT = `${SUBTASK_QUEUED_INPUT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_QUEUED_INPUT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "${SUBTASK_QUEUED_INPUT_PARENT_RESULT}".` +export const SUBTASK_QUEUED_INPUT_RESPONSE_LATENCY_MS = 2_000 + // Scheduler regression tests — exercises TaskScheduler + run() dispatch post-CodeRabbit fix. // Separate markers to avoid collisions with the other subtask fixtures. const SCHED_STANDALONE_MARKER = "SCHED_STANDALONE_INTERRUPT_RESUME" @@ -122,6 +132,81 @@ const completionAfterAnswer = (followupId: string, completionId: string) => ({ }) export function addSubtaskFixtures(mock: InstanceType) { + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_QUEUED_INPUT_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: SUBTASK_QUEUED_INPUT_CHILD_PROMPT, + }), + id: "call_queued_input_parent_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, SUBTASK_QUEUED_INPUT_CHILD_MARKER) && + !requestContains(req, [SUBTASK_QUEUED_INPUT_PARENT_MARKER]) && + !requestContains(req, [SUBTASK_QUEUED_INPUT_MESSAGE]), + }, + streamingProfile: { ttft: SUBTASK_QUEUED_INPUT_RESPONSE_LATENCY_MS }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_QUEUED_INPUT_INITIAL_RESULT }), + id: "call_queued_input_child_initial_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [SUBTASK_QUEUED_INPUT_CHILD_MARKER, SUBTASK_QUEUED_INPUT_MESSAGE]) && + !requestContains(req, [SUBTASK_QUEUED_INPUT_PARENT_MARKER]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_QUEUED_INPUT_CHILD_RESULT }), + id: "call_queued_input_child_revised_completion_003", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [ + SUBTASK_QUEUED_INPUT_PARENT_MARKER, + SUBTASK_RESULT_INJECTION, + SUBTASK_QUEUED_INPUT_CHILD_RESULT, + ]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_QUEUED_INPUT_PARENT_RESULT }), + id: "call_queued_input_parent_completion_004", + }, + ], + }, + }) + mock.addFixture({ match: { userMessage: new RegExp(SUBTASK_FAST_PARENT_MARKER), diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 02d3dfe487..15abdaf3c8 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -25,6 +25,11 @@ import { SUBTASK_INTERRUPT_PARENT_PROMPT, SUBTASK_INTERRUPT_PARENT_RESULT, SUBTASK_PARENT_PROMPT, + SUBTASK_QUEUED_INPUT_CHILD_MARKER, + SUBTASK_QUEUED_INPUT_CHILD_RESULT, + SUBTASK_QUEUED_INPUT_MESSAGE, + SUBTASK_QUEUED_INPUT_PARENT_PROMPT, + SUBTASK_QUEUED_INPUT_PARENT_RESULT, SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT, SUBTASK_XPROFILE_PARENT_PROMPT, SUBTASK_XPROFILE_PARENT_RESULT, @@ -174,6 +179,73 @@ suite("Roo Code Subtasks", function () { } }) + test("queued input interrupts child completion before the parent resumes", async () => { + const api = globalThis.api + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + try { + const parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SUBTASK_QUEUED_INPUT_PARENT_PROMPT, + }) + + let childTaskId: string | undefined + await waitFor(() => { + const current = api.getCurrentTaskStack().at(-1) + if (current && current !== parentTaskId) { + childTaskId = current + return true + } + return false + }) + + await waitForAimockRequestContaining(SUBTASK_QUEUED_INPUT_CHILD_MARKER) + + const completedParentTaskId = await waitUntilCompleted({ + api, + start: async () => { + await api.sendMessage(SUBTASK_QUEUED_INPUT_MESSAGE) + return parentTaskId + }, + }) + + assert.strictEqual(completedParentTaskId, parentTaskId) + assert.ok( + says[childTaskId!]?.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === SUBTASK_QUEUED_INPUT_CHILD_RESULT, + ), + "Child should process the queued instruction before returning to its parent", + ) + assert.strictEqual( + says[parentTaskId]?.find(({ say }) => say === "completion_result")?.text?.trim(), + SUBTASK_QUEUED_INPUT_PARENT_RESULT, + "Parent should resume only after the child processes the queued instruction", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {}) + } + }) + // Smoke: child completing normally must resume the parent task. test("child task returns to parent after normal completion", async () => { const api = globalThis.api diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4be087394e..00820ab17a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1362,16 +1362,7 @@ export class Task extends EventEmitter implements TaskLike { const message = this.messageQueueService.dequeueMessage() if (message) { - // Check if this is a tool approval ask that needs to be handled. - if (type === "tool" || type === "command" || type === "use_mcp_server") { - // For tool approvals, we need to approve first, then send - // the message if there's text/images. - this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images) - } else { - // For other ask types (like followup or command_output), fulfill the ask - // directly. - this.handleWebviewAskResponse("messageResponse", message.text, message.images) - } + this.handleWebviewAskResponse("messageResponse", message.text, message.images) } } @@ -1388,13 +1379,7 @@ export class Task extends EventEmitter implements TaskLike { if (shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { const message = this.messageQueueService.dequeueMessage() if (message) { - // If this is a tool approval ask, we need to approve first (yesButtonClicked) - // and include any queued text/images. - if (type === "tool" || type === "command" || type === "use_mcp_server") { - this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images) - } else { - this.handleWebviewAskResponse("messageResponse", message.text, message.images) - } + this.handleWebviewAskResponse("messageResponse", message.text, message.images) } } 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 06f577881e..b6e6ca312c 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -1,35 +1,72 @@ import { Task } from "../Task" +import { MessageQueueService } from "../../message-queue/MessageQueueService" // Keep this test focused: if a queued message arrives while Task.ask() is blocked, // it should be consumed and used to fulfill the ask. +const buildTask = () => { + const task = Object.create(Task.prototype) as Task + + Object.assign(task, { + abort: false, + clineMessages: [], + askResponse: undefined, + askResponseText: undefined, + askResponseImages: undefined, + lastMessageTs: undefined, + messageQueueService: new MessageQueueService(), + addToClineMessages: vi.fn(async () => {}), + saveClineMessages: vi.fn(async () => {}), + updateClineMessage: vi.fn(async () => {}), + cancelAutoApprovalTimeout: vi.fn(() => {}), + checkpointSave: vi.fn(async () => {}), + emit: vi.fn(), + providerRef: { deref: () => undefined }, + }) + + return task +} + describe("Task.ask queued message drain", () => { + it.each(["tool", "command", "use_mcp_server"] as const)( + "treats queued input as feedback instead of approving a %s ask", + async (askType) => { + const task = buildTask() + task.messageQueueService.addMessage("change direction", ["queued-image.png"]) + + const result = await task.ask(askType, "pending approval", false) + + expect(result).toEqual({ + response: "messageResponse", + text: "change direction", + images: ["queued-image.png"], + }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }, + ) + + it.each(["tool", "command", "use_mcp_server"] as const)( + "treats input queued while blocked as feedback instead of approving a %s ask", + async (askType) => { + const task = buildTask() + const askPromise = task.ask(askType, "pending approval", false) + + task.messageQueueService.addMessage("change direction") + + await expect(askPromise).resolves.toMatchObject({ + response: "messageResponse", + text: "change direction", + }) + }, + ) + it("consumes queued message while blocked on followup ask", async () => { - const task = Object.create(Task.prototype) as Task - ;(task as any).abort = false - ;(task as any).clineMessages = [] - ;(task as any).askResponse = undefined - ;(task as any).askResponseText = undefined - ;(task as any).askResponseImages = undefined - ;(task as any).lastMessageTs = undefined - - // Message queue service exists in constructor; for unit test we can attach a real one. - const { MessageQueueService } = await import("../../message-queue/MessageQueueService") - ;(task as any).messageQueueService = new MessageQueueService() - - // Minimal stubs used by ask() - ;(task as any).addToClineMessages = vi.fn(async () => {}) - ;(task as any).saveClineMessages = vi.fn(async () => {}) - ;(task as any).updateClineMessage = vi.fn(async () => {}) - ;(task as any).cancelAutoApprovalTimeout = vi.fn(() => {}) - ;(task as any).checkpointSave = vi.fn(async () => {}) - ;(task as any).emit = vi.fn() - ;(task as any).providerRef = { deref: () => undefined } + const task = buildTask() const askPromise = task.ask("followup", "Q?", false) // Simulate webview queuing the user's selection text while the ask is pending. - ;(task as any).messageQueueService.addMessage("picked answer") + task.messageQueueService.addMessage("picked answer") const result = await askPromise expect(result.response).toBe("messageResponse") @@ -37,26 +74,10 @@ describe("Task.ask queued message drain", () => { }) it("does not consume queued messages for command_output asks", async () => { - const task = Object.create(Task.prototype) as Task - ;(task as any).abort = false - ;(task as any).clineMessages = [] - ;(task as any).askResponse = undefined - ;(task as any).askResponseText = undefined - ;(task as any).askResponseImages = undefined - ;(task as any).lastMessageTs = undefined - - const { MessageQueueService } = await import("../../message-queue/MessageQueueService") - ;(task as any).messageQueueService = new MessageQueueService() - ;(task as any).addToClineMessages = vi.fn(async () => {}) - ;(task as any).saveClineMessages = vi.fn(async () => {}) - ;(task as any).updateClineMessage = vi.fn(async () => {}) - ;(task as any).cancelAutoApprovalTimeout = vi.fn(() => {}) - ;(task as any).checkpointSave = vi.fn(async () => {}) - ;(task as any).emit = vi.fn() - ;(task as any).providerRef = { deref: () => undefined } + const task = buildTask() const askPromise = task.ask("command_output", "command is still running...", false) - ;(task as any).messageQueueService.addMessage("1+1=?") + task.messageQueueService.addMessage("1+1=?") setTimeout(() => { task.approveAsk() @@ -66,7 +87,7 @@ describe("Task.ask queued message drain", () => { expect(result.response).toBe("yesButtonClicked") expect(result.text).toBeUndefined() - expect((task as any).messageQueueService.isEmpty()).toBe(false) - expect((task as any).messageQueueService.messages[0]?.text).toBe("1+1=?") + expect(task.messageQueueService.isEmpty()).toBe(false) + expect(task.messageQueueService.messages[0]?.text).toBe("1+1=?") }) }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index f790fba436..9cc26c93e6 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -839,11 +839,6 @@ "count": 19 } }, - "core/task/__tests__/ask-queued-message-drain.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 32 - } - }, "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 14 From 7b84133e85a070c5cd6e0cd409881d41e8c169c5 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 21 Aug 2026 12:40:42 +0000 Subject: [PATCH 2/4] test(task): cover late queued approval feedback --- src/core/task/__tests__/ask-queued-message-drain.spec.ts | 3 +++ 1 file changed, 3 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 b6e6ca312c..db605ea938 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -51,6 +51,9 @@ describe("Task.ask queued message drain", () => { const task = buildTask() const askPromise = task.ask(askType, "pending approval", false) + // Let ask() observe an empty queue and enter its pWaitFor loop before + // simulating input that arrives while the approval is already blocked. + await new Promise((resolve) => setTimeout(resolve, 0)) task.messageQueueService.addMessage("change direction") await expect(askPromise).resolves.toMatchObject({ From eaef1ae91ed9d78147b64b86711727e3e9aed7f3 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Wed, 26 Aug 2026 02:43:57 +0000 Subject: [PATCH 3/4] fix(task): safely drain queued messages into pending asks --- src/core/task/Task.ts | 27 ++++++++----- .../ask-queued-message-drain.spec.ts | 26 +++++++++++-- src/core/tools/ReadFileTool.ts | 8 ++-- src/core/tools/__tests__/readFileTool.spec.ts | 38 +++++++++++++++++++ 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 00820ab17a..bc52d12e93 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1150,6 +1150,19 @@ export class Task extends EventEmitter implements TaskLike { return undefined } + private drainQueuedMessageIntoAskResponse(): void { + // A synchronous auto-approval may already have resolved the ask before the + // entry queue snapshot is acted on. Never replace that resolved response. + if (this.askResponse !== undefined) { + return + } + + const message = this.messageQueueService.dequeueMessage() + if (message) { + this.handleWebviewAskResponse("messageResponse", message.text, message.images) + } + } + // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). @@ -1359,11 +1372,8 @@ export class Task extends EventEmitter implements TaskLike { ) } } else if (isMessageQueued && shouldDrainQueuedMessageForAsk) { - const message = this.messageQueueService.dequeueMessage() - - if (message) { - this.handleWebviewAskResponse("messageResponse", message.text, message.images) - } + // This branch acts on the queue state captured when the ask was entered. + this.drainQueuedMessageIntoAskResponse() } // Wait for askResponse to be set @@ -1377,10 +1387,9 @@ export class Task extends EventEmitter implements TaskLike { // suggestion click that was incorrectly queued due to UI state), consume it // immediately so the task doesn't hang. if (shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { - const message = this.messageQueueService.dequeueMessage() - if (message) { - this.handleWebviewAskResponse("messageResponse", message.text, message.images) - } + // Unlike the entry snapshot above, this live check handles messages that + // arrive after the ask has begun waiting. + this.drainQueuedMessageIntoAskResponse() } 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 db605ea938..78d016224a 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -4,7 +4,7 @@ import { MessageQueueService } from "../../message-queue/MessageQueueService" // Keep this test focused: if a queued message arrives while Task.ask() is blocked, // it should be consumed and used to fulfill the ask. -const buildTask = () => { +const buildTask = (providerState?: Record) => { const task = Object.create(Task.prototype) as Task Object.assign(task, { @@ -21,7 +21,9 @@ const buildTask = () => { cancelAutoApprovalTimeout: vi.fn(() => {}), checkpointSave: vi.fn(async () => {}), emit: vi.fn(), - providerRef: { deref: () => undefined }, + providerRef: { + deref: () => (providerState ? { getState: async () => providerState } : undefined), + }, }) return task @@ -76,11 +78,27 @@ describe("Task.ask queued message drain", () => { expect(result.text).toBe("picked answer") }) - it("does not consume queued messages for command_output asks", async () => { + it("preserves a pre-queued message when auto-approval has already resolved the ask", async () => { + const task = buildTask({ + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: ["echo"], + deniedCommands: [], + }) + task.messageQueueService.addMessage("change direction") + + const result = await task.ask("command", "echo ready", false) + + expect(result).toEqual({ response: "yesButtonClicked", text: undefined, images: undefined }) + expect(task.messageQueueService.messages).toHaveLength(1) + expect(task.messageQueueService.messages[0]?.text).toBe("change direction") + }) + + it("does not consume messages that were queued before a command_output ask", async () => { const task = buildTask() + task.messageQueueService.addMessage("1+1=?") const askPromise = task.ask("command_output", "command is still running...", false) - task.messageQueueService.addMessage("1+1=?") setTimeout(() => { task.approveAsk() diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..fa26e32630 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -292,7 +292,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = `IMPORTANT: File content truncated. Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}. - + ${result.content}` } else if (result.includedRanges.length > 0) { const rangeStr = result.includedRanges.map(([s, e]) => `${s}-${e}`).join(", ") @@ -320,7 +320,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = `IMPORTANT: File content truncated. Status: Showing lines ${startLine}-${endLine} of ${result.totalLines} total lines. To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. - + ${result.content}` } else if (result.returnedLines === 0) { output = "Note: File is empty" @@ -453,7 +453,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { filesToApprove.forEach((fr) => { updateFileResult(fr.path, { status: "approved", feedbackText: text, feedbackImages: images }) }) - } else if (response === "noButtonClicked") { + } else if (response === "noButtonClicked" || response === "messageResponse") { + // A queued conversational message resolves the ask as messageResponse; + // it is feedback, not the JSON payload used by per-file permissions. if (text) await task.say("user_feedback", text, images) task.didRejectTool = true filesToApprove.forEach((fr) => { diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..e71f90b203 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -17,6 +17,7 @@ import path from "path" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -649,6 +650,43 @@ describe("ReadFileTool", () => { expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") }) + + it("denies batch reads and reports queued message feedback without parsing it as permissions", async () => { + const task = Object.create(Task.prototype) as Task + Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) + Object.assign(task, createMockTask()) + const queuedImages = ["data:image/png;base64,queued"] + task.ask = vi.fn().mockResolvedValue({ + response: "messageResponse", + text: "Read a different file instead", + images: queuedImages, + }) + const fileResults = [ + { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, + { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, + ] + const updates = new Map>() + const parseSpy = vi.spyOn(JSON, "parse") + + await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { + updates.set(filePath, update) + }) + + expect(parseSpy).not.toHaveBeenCalled() + expect(task.say).toHaveBeenCalledWith("user_feedback", "Read a different file instead", queuedImages) + expect(task.didRejectTool).toBe(true) + expect(updates.get("one.ts")).toMatchObject({ + status: "denied", + feedbackText: "Read a different file instead", + feedbackImages: queuedImages, + }) + expect(updates.get("two.ts")).toMatchObject({ + status: "denied", + feedbackText: "Read a different file instead", + feedbackImages: queuedImages, + }) + parseSpy.mockRestore() + }) }) describe("output structure", () => { From 0cc009670e7903ea782883c9157bcdd73a6d763f Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Thu, 27 Aug 2026 01:44:13 +0000 Subject: [PATCH 4/4] fix(task): enqueue streaming input before child completion --- src/core/task/Task.ts | 17 +++++++-- .../ask-queued-message-drain.spec.ts | 37 +++++++++++++++++++ src/core/tools/__tests__/readFileTool.spec.ts | 16 ++++++++ .../__tests__/api-send-message.spec.ts | 36 ++++++++++++++++++ src/extension/api.ts | 9 +++++ 5 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index bc52d12e93..02306b4c72 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -29,6 +29,7 @@ import { type ContextTruncation, type ClineMessage, type ClineSay, + type ClineSayTool, type ClineAsk, type ToolProgressStatus, type HistoryItem, @@ -1150,10 +1151,10 @@ export class Task extends EventEmitter implements TaskLike { return undefined } - private drainQueuedMessageIntoAskResponse(): void { + private drainQueuedMessageIntoAskResponse(allowResolvedAskOverride = false): void { // A synchronous auto-approval may already have resolved the ask before the // entry queue snapshot is acted on. Never replace that resolved response. - if (this.askResponse !== undefined) { + if (this.askResponse !== undefined && !allowResolvedAskOverride) { return } @@ -1328,6 +1329,14 @@ export class Task extends EventEmitter implements TaskLike { // Keep queued user messages intact during command_output asks. Those asks // are terminal flow-control, not conversational turns. const shouldDrainQueuedMessageForAsk = type !== "command_output" + let isFinishTaskAsk = false + if (type === "tool") { + try { + isFinishTaskAsk = (JSON.parse(text || "{}") as ClineSayTool).tool === "finishTask" + } catch { + // Invalid tool payloads are handled by their caller; they are not finishTask asks. + } + } const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" if (isStatusMutable) { @@ -1373,7 +1382,9 @@ export class Task extends EventEmitter implements TaskLike { } } else if (isMessageQueued && shouldDrainQueuedMessageForAsk) { // This branch acts on the queue state captured when the ask was entered. - this.drainQueuedMessageIntoAskResponse() + // A queued instruction must interrupt finishTask before the child returns + // to its parent, even when subtask completion is otherwise auto-approved. + this.drainQueuedMessageIntoAskResponse(isFinishTaskAsk) } // Wait for askResponse to be set 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 78d016224a..ce593cafcc 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -94,6 +94,43 @@ describe("Task.ask queued message drain", () => { expect(task.messageQueueService.messages[0]?.text).toBe("change direction") }) + it("lets queued input interrupt an auto-approved finishTask ask", async () => { + const task = buildTask({ + autoApprovalEnabled: true, + alwaysAllowSubtasks: true, + }) + task.messageQueueService.addMessage("Use the queued instruction before completing.") + + const result = await task.ask("tool", JSON.stringify({ tool: "finishTask" }), false) + + expect(result).toEqual({ + response: "messageResponse", + text: "Use the queued instruction before completing.", + images: undefined, + }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + + it("preserves queued input behind an ordinary auto-approved tool ask", async () => { + const task = buildTask({ autoApprovalEnabled: true }) + task.messageQueueService.addMessage("change direction") + + const result = await task.ask("tool", JSON.stringify({ tool: "updateTodoList" }), false) + + expect(result.response).toBe("yesButtonClicked") + expect(task.messageQueueService.messages).toHaveLength(1) + }) + + it("treats queued input as feedback when a tool ask payload is malformed", async () => { + const task = buildTask() + task.messageQueueService.addMessage("change direction") + + const result = await task.ask("tool", "{", false) + + expect(result).toMatchObject({ response: "messageResponse", text: "change direction" }) + expect(task.messageQueueService.isEmpty()).toBe(true) + }) + it("does not consume messages that were queued before a command_output ask", async () => { const task = buildTask() task.messageQueueService.addMessage("1+1=?") diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index e71f90b203..862126edad 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -687,6 +687,22 @@ describe("ReadFileTool", () => { }) parseSpy.mockRestore() }) + + it("denies batch reads for a queued message response without text", async () => { + const task = Object.create(Task.prototype) as Task + Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) + Object.assign(task, createMockTask()) + task.ask = vi.fn().mockResolvedValue({ response: "messageResponse", text: undefined, images: undefined }) + const fileResults = [ + { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, + { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, + ] + + await readFileTool["requestApproval"](task, fileResults, () => {}) + + expect(task.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + expect(task.didRejectTool).toBe(true) + }) }) describe("output structure", () => { diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api-send-message.spec.ts index 23677b1218..d198919b19 100644 --- a/src/extension/__tests__/api-send-message.spec.ts +++ b/src/extension/__tests__/api-send-message.spec.ts @@ -56,6 +56,42 @@ describe("API - SendMessage Command", () => { }) }) + it("should enqueue directly when the current task is streaming", async () => { + const addMessage = vi.fn() + const messageText = "Use this before completing" + const images = ["data:image/png;base64,image1data"] + const currentTask = { + isStreaming: true, + messageQueueService: { addMessage }, + } + mockProvider.getCurrentTask = vi.fn().mockReturnValue(currentTask) + + await api.sendMessage(messageText, images) + + expect(addMessage).toHaveBeenCalledWith(messageText, images) + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) + + it("should retain webview routing when the current task is not streaming", async () => { + const addMessage = vi.fn() + const messageText = "Answer the current ask" + const currentTask = { + isStreaming: false, + messageQueueService: { addMessage }, + } + mockProvider.getCurrentTask = vi.fn().mockReturnValue(currentTask) + + await api.sendMessage(messageText) + + expect(addMessage).not.toHaveBeenCalled() + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "invoke", + invoke: "sendMessage", + text: messageText, + images: undefined, + }) + }) + it("should handle SendMessage command with text and images", async () => { // Arrange const messageText = "Analyze this image" diff --git a/src/extension/api.ts b/src/extension/api.ts index b57dc89b74..1dd8ab0015 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -271,6 +271,15 @@ export class API extends EventEmitter implements RooCodeAPI { public async sendMessage(text?: string, images?: string[]) { const currentTask = this.sidebarProvider.getCurrentTask() + // API callers need the returned promise to mean that sequencing-critical + // input has reached the active task. During a stream, the webview would + // only relay this message back as queueMessage asynchronously, so enqueue + // it in the extension host instead of racing task completion. + if (currentTask?.isStreaming) { + currentTask.messageQueueService.addMessage(text ?? "", images) + return + } + // In headless/sandbox flows the webview may not be launched, so routing // through invoke=sendMessage drops the message. Deliver directly to the // task ask-response channel instead.