diff --git a/packages/ipc/src/ipc-client.ts b/packages/ipc/src/ipc-client.ts index d374cb186a3..661a1b82f48 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 a843354a559..c2ee604e4c1 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 fea040af0b6..8b70a7ca5ba 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/packages/types/src/terminal.ts b/packages/types/src/terminal.ts index 34f7a74e244..8db7ee6df16 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 9d19248057d..dadb64b339d 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 cb6fc6ff023..df291700826 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("command_output", "Command cancelled.") + 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 fd85beb0f46..57c86ba9c50 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("command_output", "Command cancelled.") + 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 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 00000000000..2c73d8c92f4 --- /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 4a66b40078d..3980fcdc128 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 } }) }