This commit is contained in:
roomote-v0[bot] 2026-04-09 10:15:17 -06:00 committed by GitHub
commit 38ea034dc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 189 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

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

View file

@ -299,6 +299,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
rooProtectedController?: RooProtectedController
fileContextTracker: FileContextTracker
terminalProcess?: RooTerminalProcess
isTerminalAbortedExternally: boolean = false
// Editing
diffViewProvider: DiffViewProvider
@ -1632,6 +1633,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()
}
}

View file

@ -433,6 +433,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)

View file

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

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