Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/ipc/src/ipc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
29 changes: 29 additions & 0 deletions packages/types/src/__tests__/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,6 +22,7 @@ describe("IPC Types", () => {
"ResumeTask",
"SendMessage",
"DeleteQueuedMessage",
"CancelCommand",
]
const actualCommands = Object.values(TaskCommandName)

Expand Down Expand Up @@ -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)
})
})
})
4 changes: 4 additions & 0 deletions packages/types/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export enum TaskCommandName {
GetModes = "GetModes",
GetModels = "GetModels",
DeleteQueuedMessage = "DeleteQueuedMessage",
CancelCommand = "CancelCommand",
}

/**
Expand Down Expand Up @@ -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>
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof commandExecutionStatusSchema>
Expand Down
2 changes: 2 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
rooProtectedController?: RooProtectedController
fileContextTracker: FileContextTracker
terminalProcess?: RooTerminalProcess
isTerminalAbortedExternally: boolean = false

// Editing
diffViewProvider: DiffViewProvider
Expand Down Expand Up @@ -1628,6 +1629,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (terminalOperation === "continue") {
this.terminalProcess?.continue()
} else if (terminalOperation === "abort") {
this.isTerminalAbortedExternally = true
this.terminalProcess?.abort()
}
}
Expand Down
12 changes: 12 additions & 0 deletions src/core/tools/ExecuteCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions src/core/tools/__tests__/executeCommand.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions src/extension/__tests__/api-cancel-command.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
4 changes: 4 additions & 0 deletions src/extension/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
}
Expand Down
Loading