From 3b27135cf7c45d2e132384e5df4923c9c84379a8 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 20 Feb 2026 16:03:01 +0000 Subject: [PATCH] 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 } }) }