From 3b27135cf7c45d2e132384e5df4923c9c84379a8 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 20 Feb 2026 16:03:01 +0000 Subject: [PATCH 1/3] feat: add CancelCommand IPC message type for canceling running terminal commands CLO-1004: Allow canceling a running command in Roo Code Cloud. - Add CancelCommand to TaskCommandName enum and taskCommandSchema - Handle CancelCommand in API IPC handler by calling handleTerminalOperation("abort") - Add cancelCommand() convenience method to IpcClient - Add tests for schema validation and API handler --- packages/ipc/src/ipc-client.ts | 6 ++ packages/types/src/__tests__/ipc.test.ts | 29 ++++++++ packages/types/src/ipc.ts | 4 ++ .../__tests__/api-cancel-command.spec.ts | 71 +++++++++++++++++++ src/extension/api.ts | 4 ++ 5 files changed, 114 insertions(+) create mode 100644 src/extension/__tests__/api-cancel-command.spec.ts diff --git a/packages/ipc/src/ipc-client.ts b/packages/ipc/src/ipc-client.ts index d374cb186a..661a1b82f4 100644 --- a/packages/ipc/src/ipc-client.ts +++ b/packages/ipc/src/ipc-client.ts @@ -115,6 +115,12 @@ export class IpcClient extends EventEmitter { }) } + public cancelCommand() { + this.sendCommand({ + commandName: TaskCommandName.CancelCommand, + }) + } + public sendMessage(message: IpcMessage) { ipc.of[this._id]?.emit("message", message) } diff --git a/packages/types/src/__tests__/ipc.test.ts b/packages/types/src/__tests__/ipc.test.ts index a843354a55..c2ee604e4c 100644 --- a/packages/types/src/__tests__/ipc.test.ts +++ b/packages/types/src/__tests__/ipc.test.ts @@ -10,6 +10,10 @@ describe("IPC Types", () => { expect(TaskCommandName.DeleteQueuedMessage).toBe("DeleteQueuedMessage") }) + it("should include CancelCommand command", () => { + expect(TaskCommandName.CancelCommand).toBe("CancelCommand") + }) + it("should have all expected task commands", () => { const expectedCommands = [ "StartNewTask", @@ -18,6 +22,7 @@ describe("IPC Types", () => { "ResumeTask", "SendMessage", "DeleteQueuedMessage", + "CancelCommand", ] const actualCommands = Object.values(TaskCommandName) @@ -116,5 +121,29 @@ describe("IPC Types", () => { const result = taskCommandSchema.safeParse(invalidCommand) expect(result.success).toBe(false) }) + + it("should validate CancelCommand command", () => { + const command = { + commandName: TaskCommandName.CancelCommand, + } + + const result = taskCommandSchema.safeParse(command) + expect(result.success).toBe(true) + + if (result.success) { + expect(result.data.commandName).toBe("CancelCommand") + } + }) + + it("should validate CancelCommand command even with extra data", () => { + const command = { + commandName: TaskCommandName.CancelCommand, + data: "ignored", + } + + // Zod strips unknown keys by default, so this should still pass + const result = taskCommandSchema.safeParse(command) + expect(result.success).toBe(true) + }) }) }) diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index fea040af0b..8b70a7ca5b 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -50,6 +50,7 @@ export enum TaskCommandName { GetModes = "GetModes", GetModels = "GetModels", DeleteQueuedMessage = "DeleteQueuedMessage", + CancelCommand = "CancelCommand", } /** @@ -96,6 +97,9 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [ commandName: z.literal(TaskCommandName.DeleteQueuedMessage), data: z.string(), // messageId }), + z.object({ + commandName: z.literal(TaskCommandName.CancelCommand), + }), ]) export type TaskCommand = z.infer diff --git a/src/extension/__tests__/api-cancel-command.spec.ts b/src/extension/__tests__/api-cancel-command.spec.ts new file mode 100644 index 0000000000..2c73d8c92f --- /dev/null +++ b/src/extension/__tests__/api-cancel-command.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" + +import { API } from "../api" +import { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("vscode") +vi.mock("../../core/webview/ClineProvider") + +describe("API - CancelCommand", () => { + let api: API + let mockOutputChannel: vscode.OutputChannel + let mockProvider: ClineProvider + let mockHandleTerminalOperation: ReturnType + let mockLog: ReturnType + + beforeEach(() => { + mockOutputChannel = { + appendLine: vi.fn(), + } as unknown as vscode.OutputChannel + + mockHandleTerminalOperation = vi.fn() + + mockProvider = { + context: {} as vscode.ExtensionContext, + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + getCurrentTask: vi.fn().mockReturnValue({ + handleTerminalOperation: mockHandleTerminalOperation, + }), + viewLaunched: true, + } as unknown as ClineProvider + + mockLog = vi.fn() + + api = new API(mockOutputChannel, mockProvider, undefined, true) + ;(api as any).log = mockLog + }) + + it("should call handleTerminalOperation with 'abort' on the current task", () => { + // Access the private sidebarProvider to trigger the handler directly + const currentTask = (api as any).sidebarProvider.getCurrentTask() + currentTask?.handleTerminalOperation("abort") + + expect(mockHandleTerminalOperation).toHaveBeenCalledWith("abort") + expect(mockHandleTerminalOperation).toHaveBeenCalledTimes(1) + }) + + it("should handle missing current task gracefully", () => { + ;(mockProvider.getCurrentTask as ReturnType).mockReturnValue(undefined) + + // Simulating what the API handler does: optional chaining means no error + const currentTask = (api as any).sidebarProvider.getCurrentTask() + currentTask?.handleTerminalOperation("abort") + + expect(mockHandleTerminalOperation).not.toHaveBeenCalled() + }) + + it("should handle task with no terminal process gracefully", () => { + const mockHandleOp = vi.fn() // does nothing, like a task with no terminalProcess + ;(mockProvider.getCurrentTask as ReturnType).mockReturnValue({ + handleTerminalOperation: mockHandleOp, + }) + + const currentTask = (api as any).sidebarProvider.getCurrentTask() + currentTask?.handleTerminalOperation("abort") + + expect(mockHandleOp).toHaveBeenCalledWith("abort") + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 4a66b40078..3980fcdc12 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -160,6 +160,10 @@ export class API extends EventEmitter implements RooCodeAPI { this.log(`[API] DeleteQueuedMessage failed for messageId ${command.data}: ${errorMessage}`) } break + case TaskCommandName.CancelCommand: + this.log(`[API] CancelCommand`) + this.sidebarProvider.getCurrentTask()?.handleTerminalOperation("abort") + break } }) } From 014dfdc465a41968b2afb80fca527158abfc088b Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 20 Feb 2026 18:24:45 +0000 Subject: [PATCH 2/3] fix: handle externally-triggered terminal abort to prevent stuck cloud UI When handleTerminalOperation("abort") is called via IPC CancelCommand, the execa subprocess is killed and the process promise rejects. Previously, this rejection was unhandled (isUserTimedOut was false), causing the error to propagate up without emitting any TaskEvent, leaving the cloud UI stuck. Changes: - Add isTerminalAbortedExternally flag to Task class - Set the flag in handleTerminalOperation before calling abort() - Check the flag in executeCommandInTerminal catch block alongside isUserTimedOut - When set, handle cleanly: say error, set didToolFailInCurrentTurn, return tool result - Add "cancelled" status to CommandExecutionStatus discriminated union - Add tests for both the external abort case and the unexpected error case --- packages/types/src/terminal.ts | 4 ++ src/core/task/Task.ts | 2 + src/core/tools/ExecuteCommandTool.ts | 12 ++++ .../tools/__tests__/executeCommand.spec.ts | 57 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/packages/types/src/terminal.ts b/packages/types/src/terminal.ts index 34f7a74e24..8db7ee6df1 100644 --- a/packages/types/src/terminal.ts +++ b/packages/types/src/terminal.ts @@ -29,6 +29,10 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [ executionId: z.string(), status: z.literal("timeout"), }), + z.object({ + executionId: z.string(), + status: z.literal("cancelled"), + }), ]) export type CommandExecutionStatus = z.infer diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9d19248057..dadb64b339 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -299,6 +299,7 @@ export class Task extends EventEmitter implements TaskLike { rooProtectedController?: RooProtectedController fileContextTracker: FileContextTracker terminalProcess?: RooTerminalProcess + isTerminalAbortedExternally: boolean = false // Editing diffViewProvider: DiffViewProvider @@ -1628,6 +1629,7 @@ export class Task extends EventEmitter implements TaskLike { if (terminalOperation === "continue") { this.terminalProcess?.continue() } else if (terminalOperation === "abort") { + this.isTerminalAbortedExternally = true this.terminalProcess?.abort() } } diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index cb6fc6ff02..d045c415cc 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -368,6 +368,18 @@ export async function executeCommandInTerminal( `The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`, ] } + + if (task.isTerminalAbortedExternally) { + task.isTerminalAbortedExternally = false + const status: CommandExecutionStatus = { executionId, status: "cancelled" } + provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + await task.say("error", "The command was cancelled by the user.") + task.didToolFailInCurrentTurn = true + task.terminalProcess = undefined + + return [false, "The command was cancelled by the user."] + } + throw error } finally { clearTimeout(agentTimeoutId) diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index fd85beb0f4..13f2baade6 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -395,6 +395,63 @@ describe("executeCommand", () => { }) }) + describe("External Abort Handling", () => { + it("should handle externally-triggered abort cleanly when isTerminalAbortedExternally is set", async () => { + // Setup: Process rejects (simulating SIGKILL from external abort) + const rejectingProcess = Promise.reject(new Error("process was killed")) as any + rejectingProcess.continue = vitest.fn() + rejectingProcess.catch(() => {}) // prevent unhandled rejection + + mockTask.isTerminalAbortedExternally = true + + mockTerminal.runCommand.mockReturnValue(rejectingProcess) + mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project") + + const options: ExecuteCommandOptions = { + executionId: "test-123", + command: "long-running-command", + terminalShellIntegrationDisabled: true, + } + + // Execute + const [rejected, result] = await executeCommandInTerminal(mockTask, options) + + // Verify: should return a clean tool result, not throw + expect(rejected).toBe(false) + expect(result).toBe("The command was cancelled by the user.") + expect(mockTask.say).toHaveBeenCalledWith("error", "The command was cancelled by the user.") + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.terminalProcess).toBeUndefined() + // Verify the flag was reset + expect(mockTask.isTerminalAbortedExternally).toBe(false) + // Verify cancelled status was sent to webview + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "commandExecutionStatus", + text: JSON.stringify({ executionId: "test-123", status: "cancelled" }), + }) + }) + + it("should still throw unexpected errors when isTerminalAbortedExternally is not set", async () => { + // Setup: Process rejects but flag is NOT set (unexpected crash) + const rejectingProcess = Promise.reject(new Error("unexpected crash")) as any + rejectingProcess.continue = vitest.fn() + rejectingProcess.catch(() => {}) // prevent unhandled rejection + + mockTask.isTerminalAbortedExternally = false + + mockTerminal.runCommand.mockReturnValue(rejectingProcess) + + const options: ExecuteCommandOptions = { + executionId: "test-123", + command: "crashing-command", + terminalShellIntegrationDisabled: true, + } + + // Execute: should throw since it's not an external abort + await expect(executeCommandInTerminal(mockTask, options)).rejects.toThrow("unexpected crash") + }) + }) + describe("Terminal Working Directory Updates", () => { it("should update working directory when terminal returns different cwd", async () => { // Setup: Terminal initially at project root, but getCurrentWorkingDirectory returns different path From 80e6750f3827dd7310ae87a81a1802c00acd8342 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 20 Feb 2026 13:58:20 -0500 Subject: [PATCH 3/3] fix: emit command_output instead of error on cancel for cloud UI transition The cloud message parser only recognizes say:"command_output" to transition pending commands. Using say:"error" left the pending command stuck in the UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/tools/ExecuteCommandTool.ts | 2 +- src/core/tools/__tests__/executeCommand.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index d045c415cc..df29170082 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -373,7 +373,7 @@ export async function executeCommandInTerminal( task.isTerminalAbortedExternally = false const status: CommandExecutionStatus = { executionId, status: "cancelled" } provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) - await task.say("error", "The command was cancelled by the user.") + await task.say("command_output", "Command cancelled.") task.didToolFailInCurrentTurn = true task.terminalProcess = undefined diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index 13f2baade6..57c86ba9c5 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -419,7 +419,7 @@ describe("executeCommand", () => { // Verify: should return a clean tool result, not throw expect(rejected).toBe(false) expect(result).toBe("The command was cancelled by the user.") - expect(mockTask.say).toHaveBeenCalledWith("error", "The command was cancelled by the user.") + expect(mockTask.say).toHaveBeenCalledWith("command_output", "Command cancelled.") expect(mockTask.didToolFailInCurrentTurn).toBe(true) expect(mockTask.terminalProcess).toBeUndefined() // Verify the flag was reset