From 9230b1bc72ef83d37f541ba40503b88bac9915f6 Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 21 Aug 2026 03:12:45 +0000 Subject: [PATCH 1/7] fix(task): preserve queued input as feedback --- apps/vscode-e2e/src/fixtures/subtasks.ts | 85 +++++++++++++++++++ apps/vscode-e2e/src/suite/subtasks.test.ts | 72 ++++++++++++++++ .../ask-queued-message-drain.spec.ts | 8 +- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index ebfd94324e..7ce8122185 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -16,6 +16,8 @@ const SUBTASK_APPROVAL_RESTORE_CHILD_MARKER = "SUBTASK_CHILD_APPROVAL_RESTORE" 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.` @@ -59,6 +61,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" @@ -179,6 +189,81 @@ 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 857c8accc5..1116ebf89c 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -27,6 +27,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, @@ -260,6 +265,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/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index b137130174..44cbe6d06e 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -41,7 +41,7 @@ describe("Task.ask queued message drain", () => { 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") @@ -52,7 +52,7 @@ describe("Task.ask queued message drain", () => { const task = await createTask() 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() @@ -62,8 +62,8 @@ 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=?") }) it("does not consume a message already queued before a command_output ask", async () => { From 9abb623161cb396ccbab9f32b97c50777d716783 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 03:19:58 +0000 Subject: [PATCH 2/7] test(e2e): wait for queued-input child request --- apps/vscode-e2e/src/suite/subtasks.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 1116ebf89c..cc9d73ffbd 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -30,6 +30,7 @@ import { SUBTASK_QUEUED_INPUT_CHILD_MARKER, SUBTASK_QUEUED_INPUT_CHILD_RESULT, SUBTASK_QUEUED_INPUT_MESSAGE, + SUBTASK_QUEUED_INPUT_PARENT_MARKER, SUBTASK_QUEUED_INPUT_PARENT_PROMPT, SUBTASK_QUEUED_INPUT_PARENT_RESULT, SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT, @@ -300,7 +301,7 @@ suite("Roo Code Subtasks", function () { return false }) - await waitForAimockRequestContaining(SUBTASK_QUEUED_INPUT_CHILD_MARKER) + await waitForAimockRequestContaining(SUBTASK_QUEUED_INPUT_CHILD_MARKER, SUBTASK_QUEUED_INPUT_PARENT_MARKER) const completedParentTaskId = await waitUntilCompleted({ api, From e0bce6c5f56406eec9fc59e62a2d5bae21bad3ca Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 16 Sep 2026 00:25:37 +0000 Subject: [PATCH 3/7] fix(task): narrow queued input delivery --- apps/vscode-e2e/src/suite/subtasks.test.ts | 2 +- .../__tests__/ask-queued-message-drain.spec.ts | 8 ++++---- src/extension/__tests__/api-send-message.spec.ts | 15 +++++++++++++++ src/extension/api.ts | 6 ++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index cc9d73ffbd..0da28c1aca 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -271,7 +271,7 @@ suite("Roo Code Subtasks", function () { const says: Record = {} const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { - if (message.type === "say" && message.partial === false) { + if (message.type === "say" && message.partial !== true) { says[taskId] = says[taskId] || [] says[taskId].push(message) } 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 44cbe6d06e..b137130174 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -41,7 +41,7 @@ describe("Task.ask queued message drain", () => { const askPromise = task.ask("followup", "Q?", false) // Simulate webview queuing the user's selection text while the ask is pending. - task.messageQueueService.addMessage("picked answer") + ;(task as any).messageQueueService.addMessage("picked answer") const result = await askPromise expect(result.response).toBe("messageResponse") @@ -52,7 +52,7 @@ describe("Task.ask queued message drain", () => { const task = await createTask() const askPromise = task.ask("command_output", "command is still running...", false) - task.messageQueueService.addMessage("1+1=?") + ;(task as any).messageQueueService.addMessage("1+1=?") setTimeout(() => { task.approveAsk() @@ -62,8 +62,8 @@ describe("Task.ask queued message drain", () => { expect(result.response).toBe("yesButtonClicked") expect(result.text).toBeUndefined() - expect(task.messageQueueService.isEmpty()).toBe(false) - expect(task.messageQueueService.messages[0]?.text).toBe("1+1=?") + expect((task as any).messageQueueService.isEmpty()).toBe(false) + expect((task as any).messageQueueService.messages[0]?.text).toBe("1+1=?") }) it("does not consume a message already queued before a command_output ask", async () => { diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api-send-message.spec.ts index 23677b1218..4853857554 100644 --- a/src/extension/__tests__/api-send-message.spec.ts +++ b/src/extension/__tests__/api-send-message.spec.ts @@ -56,6 +56,21 @@ 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"] + mockProvider.getCurrentTask = vi.fn().mockReturnValue({ + isStreaming: true, + messageQueueService: { addMessage }, + }) + + await api.sendMessage(messageText, images) + + expect(addMessage).toHaveBeenCalledWith(messageText, images) + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) + 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 316e7a6c9d..25b79c677f 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -323,6 +323,12 @@ export class API extends EventEmitter implements RooCodeAPI { public async sendMessage(text?: string, images?: string[]) { const currentTask = this.sidebarProvider.getCurrentTask() + // Ensure steering input reaches the active task before it can finish. + 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. From 4e5d1554183c11bf2d64cc9402cc916bb51cfec7 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 16 Sep 2026 00:35:51 +0000 Subject: [PATCH 4/7] test: preserve focused queue validation --- .../src/suite/tools/use-mcp-tool.test.ts | 3 +- .../__tests__/api-send-message.spec.ts | 15 ---------- src/extension/__tests__/api.spec.ts | 30 +++++++++++++++++++ 3 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 src/extension/__tests__/api.spec.ts diff --git a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts index 21e189d7c1..a413e37600 100644 --- a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts +++ b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts @@ -64,9 +64,10 @@ suite("Roo Code use_mcp_tool Tool", function () { { mcpServers: { [FILESYSTEM_SERVER_NAME]: { - command: process.env.npm_node_execpath ?? "node", + command: process.execPath, args: [path.join(__dirname, "fixtures", "filesystem-mcp-server.js"), workspaceDir], env: { + ELECTRON_RUN_AS_NODE: "1", MCP_TEST_READY_FILE: mcpServerReadyPath, }, alwaysAllow: [ diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api-send-message.spec.ts index 4853857554..23677b1218 100644 --- a/src/extension/__tests__/api-send-message.spec.ts +++ b/src/extension/__tests__/api-send-message.spec.ts @@ -56,21 +56,6 @@ 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"] - mockProvider.getCurrentTask = vi.fn().mockReturnValue({ - isStreaming: true, - messageQueueService: { addMessage }, - }) - - await api.sendMessage(messageText, images) - - expect(addMessage).toHaveBeenCalledWith(messageText, images) - expect(mockPostMessageToWebview).not.toHaveBeenCalled() - }) - it("should handle SendMessage command with text and images", async () => { // Arrange const messageText = "Analyze this image" diff --git a/src/extension/__tests__/api.spec.ts b/src/extension/__tests__/api.spec.ts new file mode 100644 index 0000000000..fde519e99b --- /dev/null +++ b/src/extension/__tests__/api.spec.ts @@ -0,0 +1,30 @@ +import type * as vscode from "vscode" + +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("vscode") +vi.mock("../../core/webview/ClineProvider") + +describe("API.sendMessage", () => { + it("enqueues directly when the current task is streaming", async () => { + const addMessage = vi.fn() + const postMessageToWebview = vi.fn() + const provider = { + getCurrentTask: vi.fn().mockReturnValue({ + isStreaming: true, + messageQueueService: { addMessage }, + }), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + postMessageToWebview, + on: vi.fn(), + } as unknown as ClineProvider + const api = new API({} as vscode.OutputChannel, provider) + const images = ["data:image/png;base64,image1data"] + + await api.sendMessage("Use this before completing", images) + + expect(addMessage).toHaveBeenCalledWith("Use this before completing", images) + expect(postMessageToWebview).not.toHaveBeenCalled() + }) +}) From 0993b9bff33290d08a8282985e52c434e97cd706 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 16 Sep 2026 00:38:53 +0000 Subject: [PATCH 5/7] test(api): cover image-only queued input --- src/extension/__tests__/api.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extension/__tests__/api.spec.ts b/src/extension/__tests__/api.spec.ts index fde519e99b..e62d27066f 100644 --- a/src/extension/__tests__/api.spec.ts +++ b/src/extension/__tests__/api.spec.ts @@ -26,5 +26,9 @@ describe("API.sendMessage", () => { expect(addMessage).toHaveBeenCalledWith("Use this before completing", images) expect(postMessageToWebview).not.toHaveBeenCalled() + + addMessage.mockClear() + await api.sendMessage(undefined, images) + expect(addMessage).toHaveBeenCalledWith("", images) }) }) From f9aa31a75fe4e37119d6015a2c3e95949f9c6226 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 18 Sep 2026 01:36:46 +0000 Subject: [PATCH 6/7] fix(extension): handle send message command failures --- src/extension/__tests__/api.spec.ts | 65 +++++++++++++++++++++++++++++ src/extension/api.ts | 7 +++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/extension/__tests__/api.spec.ts b/src/extension/__tests__/api.spec.ts index e62d27066f..c70003b28d 100644 --- a/src/extension/__tests__/api.spec.ts +++ b/src/extension/__tests__/api.spec.ts @@ -1,4 +1,5 @@ import type * as vscode from "vscode" +import { IpcMessageType, TaskCommandName, type IpcMessage } from "@roo-code/types" import { API } from "../api" import type { ClineProvider } from "../../core/webview/ClineProvider" @@ -6,6 +7,24 @@ import type { ClineProvider } from "../../core/webview/ClineProvider" vi.mock("vscode") vi.mock("../../core/webview/ClineProvider") +type TaskCommandHandler = ( + clientId: string, + command: Extract["data"], +) => Promise + +let taskCommandHandler: TaskCommandHandler | undefined + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class { + listen() {} + on(messageType: IpcMessageType, handler: TaskCommandHandler) { + if (messageType === IpcMessageType.TaskCommand) { + taskCommandHandler = handler + } + } + }, +})) + describe("API.sendMessage", () => { it("enqueues directly when the current task is streaming", async () => { const addMessage = vi.fn() @@ -31,4 +50,50 @@ describe("API.sendMessage", () => { await api.sendMessage(undefined, images) expect(addMessage).toHaveBeenCalledWith("", images) }) + + it("delivers SendMessage commands through the IPC handler", async () => { + const addMessage = vi.fn() + const appendLine = vi.fn() + const provider = { + context: {}, + cwd: "/test/cwd", + getCurrentTask: vi.fn().mockReturnValue({ + isStreaming: true, + messageQueueService: { addMessage }, + }), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + on: vi.fn(), + } as unknown as ClineProvider + new API({ appendLine } as unknown as vscode.OutputChannel, provider, "/tmp/roo-test.sock", true) + const images = ["data:image/png;base64,image1data"] + + await taskCommandHandler?.("client-1", { + commandName: TaskCommandName.SendMessage, + data: { text: "Use this before completing", images }, + }) + + expect(appendLine).toHaveBeenCalledWith("[API] SendMessage -> Use this before completing") + expect(addMessage).toHaveBeenCalledWith("Use this before completing", images) + }) + + it("logs rejected SendMessage commands without rejecting the IPC handler", async () => { + const appendLine = vi.fn() + const provider = { + context: {}, + cwd: "/test/cwd", + getCurrentTask: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + on: vi.fn(), + } as unknown as ClineProvider + const api = new API({ appendLine } as unknown as vscode.OutputChannel, provider, "/tmp/roo-test.sock", true) + vi.spyOn(api, "sendMessage").mockRejectedValue(new Error("invalid input")) + + await expect( + taskCommandHandler?.("client-1", { + commandName: TaskCommandName.SendMessage, + data: { text: "" }, + }), + ).resolves.toBeUndefined() + expect(appendLine).toHaveBeenCalledWith("[API] SendMessage failed: invalid input") + }) }) diff --git a/src/extension/api.ts b/src/extension/api.ts index 25b79c677f..65ba27a5d4 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -110,7 +110,12 @@ 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) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.log(`[API] SendMessage failed: ${errorMessage}`) + } break case TaskCommandName.GetCommands: try { From 711c1a45fce4cae467d37a2bbf72d41d17afd233 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 18 Sep 2026 02:22:09 +0000 Subject: [PATCH 7/7] fix(extension): preserve headless tool approval checks --- .../src/suite/tools/use-mcp-tool.test.ts | 3 +- src/extension/__tests__/api.spec.ts | 60 ++++++++++++++++--- src/extension/api.ts | 16 ++--- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts index a413e37600..21e189d7c1 100644 --- a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts +++ b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts @@ -64,10 +64,9 @@ suite("Roo Code use_mcp_tool Tool", function () { { mcpServers: { [FILESYSTEM_SERVER_NAME]: { - command: process.execPath, + command: process.env.npm_node_execpath ?? "node", args: [path.join(__dirname, "fixtures", "filesystem-mcp-server.js"), workspaceDir], env: { - ELECTRON_RUN_AS_NODE: "1", MCP_TEST_READY_FILE: mcpServerReadyPath, }, alwaysAllow: [ diff --git a/src/extension/__tests__/api.spec.ts b/src/extension/__tests__/api.spec.ts index c70003b28d..eca673daa2 100644 --- a/src/extension/__tests__/api.spec.ts +++ b/src/extension/__tests__/api.spec.ts @@ -1,8 +1,10 @@ import type * as vscode from "vscode" -import { IpcMessageType, TaskCommandName, type IpcMessage } from "@roo-code/types" +import { IpcMessageType, TaskCommandName, type ClineMessage, type IpcMessage } from "@roo-code/types" import { API } from "../api" import type { ClineProvider } from "../../core/webview/ClineProvider" +import { MessageQueueService } from "../../core/message-queue/MessageQueueService" +import { Task } from "../../core/task/Task" vi.mock("vscode") vi.mock("../../core/webview/ClineProvider") @@ -14,6 +16,33 @@ type TaskCommandHandler = ( let taskCommandHandler: TaskCommandHandler | undefined +type TaskTestAccess = { + addToClineMessages: (message: ClineMessage) => Promise +} + +const createStreamingTask = (provider: object) => { + const task = Object.create(Task.prototype) as Task + Object.assign(task, { + abort: false, + clineMessages: [], + taskId: "task-1", + instanceId: "instance-1", + isStreaming: true, + messageQueueService: new MessageQueueService(), + providerRef: { deref: () => provider }, + addToClineMessages: vi.fn(async () => {}), + saveClineMessages: vi.fn(async () => true), + updateClineMessage: vi.fn(async () => {}), + cancelAutoApprovalTimeout: vi.fn(), + checkpointSave: vi.fn(async () => {}), + emit: vi.fn(), + }) + vi.spyOn(task as unknown as TaskTestAccess, "addToClineMessages").mockImplementation(async (message) => { + task.clineMessages.push(message) + }) + return task +} + vi.mock("@roo-code/ipc", () => ({ IpcServer: class { listen() {} @@ -26,10 +55,11 @@ vi.mock("@roo-code/ipc", () => ({ })) describe("API.sendMessage", () => { - it("enqueues directly when the current task is streaming", async () => { + it("enqueues directly when the current webview task is streaming", async () => { const addMessage = vi.fn() const postMessageToWebview = vi.fn() const provider = { + viewLaunched: true, getCurrentTask: vi.fn().mockReturnValue({ isStreaming: true, messageQueueService: { addMessage }, @@ -51,29 +81,41 @@ describe("API.sendMessage", () => { expect(addMessage).toHaveBeenCalledWith("", images) }) - it("delivers SendMessage commands through the IPC handler", async () => { - const addMessage = vi.fn() + it.each([ + ["command", "npm publish"], + ["use_mcp_server", '{"server_name":"filesystem","tool_name":"write_file"}'], + ] as const)("does not approve a protected headless %s ask from queued IPC input", async (askType, askText) => { const appendLine = vi.fn() const provider = { context: {}, cwd: "/test/cwd", - getCurrentTask: vi.fn().mockReturnValue({ - isStreaming: true, - messageQueueService: { addMessage }, - }), + viewLaunched: false, + getState: vi.fn().mockResolvedValue({ autoApprovalEnabled: false }), + getCurrentTask: vi.fn(), getCurrentTaskStack: vi.fn().mockReturnValue([]), on: vi.fn(), } as unknown as ClineProvider + const task = createStreamingTask(provider) + vi.mocked(provider.getCurrentTask).mockReturnValue(task) new API({ appendLine } as unknown as vscode.OutputChannel, provider, "/tmp/roo-test.sock", true) const images = ["data:image/png;base64,image1data"] + const executeProtectedTool = vi.fn() + const ask = task.ask(askType, askText, false) + await vi.waitFor(() => expect(task.clineMessages).toHaveLength(1)) await taskCommandHandler?.("client-1", { commandName: TaskCommandName.SendMessage, data: { text: "Use this before completing", images }, }) + const result = await ask + if (result.response === "yesButtonClicked") { + executeProtectedTool() + } expect(appendLine).toHaveBeenCalledWith("[API] SendMessage -> Use this before completing") - expect(addMessage).toHaveBeenCalledWith("Use this before completing", images) + expect(result).toMatchObject({ response: "messageResponse", text: "Use this before completing", images }) + expect(task.messageQueueService.isEmpty()).toBe(true) + expect(executeProtectedTool).not.toHaveBeenCalled() }) it("logs rejected SendMessage commands without rejecting the IPC handler", async () => { diff --git a/src/extension/api.ts b/src/extension/api.ts index 65ba27a5d4..23d7cfbf58 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -328,15 +328,9 @@ export class API extends EventEmitter implements RooCodeAPI { public async sendMessage(text?: string, images?: string[]) { const currentTask = this.sidebarProvider.getCurrentTask() - // Ensure steering input reaches the active task before it can finish. - 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. + // through invoke=sendMessage drops the message. Keep this path on the task + // ask-response channel so queued input cannot approve protected tool asks. if (!this.sidebarProvider.viewLaunched) { if (!currentTask) { this.log("[API#sendMessage] no current task in headless mode; message dropped") @@ -347,6 +341,12 @@ export class API extends EventEmitter implements RooCodeAPI { return } + // Ensure steering input reaches the active task before it can finish. + if (currentTask?.isStreaming) { + currentTask.messageQueueService.addMessage(text ?? "", images) + return + } + await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) }