mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
Merge 80e6750f38 into 7adbfec2a4
This commit is contained in:
commit
38ea034dc6
9 changed files with 189 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
src/extension/__tests__/api-cancel-command.spec.ts
Normal file
71
src/extension/__tests__/api-cancel-command.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue