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
This commit is contained in:
Roo Code 2026-02-20 16:03:01 +00:00 • committed by daniel-lxs
parent 9918e837ba
commit 3b27135cf7
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
5 changed files with 114 additions and 0 deletions

View file

@ -115,6 +115,12 @@ export class IpcClient extends EventEmitter<IpcClientEvents> {
})
}
public cancelCommand() {
this.sendCommand({
commandName: TaskCommandName.CancelCommand,
})
}
public sendMessage(message: IpcMessage) {
ipc.of[this._id]?.emit("message", message)
}

View file

@ -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)
})
})
})

View file

@ -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<typeof taskCommandSchema>

View file

@ -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<typeof vi.fn>
let mockLog: ReturnType<typeof vi.fn>
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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue({
handleTerminalOperation: mockHandleOp,
})
const currentTask = (api as any).sidebarProvider.getCurrentTask()
currentTask?.handleTerminalOperation("abort")
expect(mockHandleOp).toHaveBeenCalledWith("abort")
})
})

View file

@ -160,6 +160,10 @@ export class API extends EventEmitter<RooCodeEvents> 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
}
})
}