mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
feat: add taskCommandExecuted event to track command execution results
- Added new event type to ClineEvents for tracking command execution completion - Event includes command, exit code, output, success status, and failure reason - Emits event in executeCommandTool for all execution paths (success, failure, timeout, user feedback) - Added comprehensive tests for the new event emission - Updated mock tasks in tests to include emit method This allows API consumers to listen for command execution events and surface failures for easier debugging.
This commit is contained in:
parent
76e5a726a3
commit
de6db594b5
4 changed files with 271 additions and 0 deletions
|
|
@ -102,6 +102,30 @@ import { AutoApprovalHandler } from "./AutoApprovalHandler"
|
|||
|
||||
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
|
||||
|
||||
export type ClineEvents = {
|
||||
message: [{ action: "created" | "updated"; message: ClineMessage }]
|
||||
taskStarted: []
|
||||
taskModeSwitched: [taskId: string, mode: string]
|
||||
taskPaused: []
|
||||
taskUnpaused: []
|
||||
taskAskResponded: []
|
||||
taskAborted: []
|
||||
taskSpawned: [taskId: string]
|
||||
taskCompleted: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage]
|
||||
taskTokenUsageUpdated: [taskId: string, tokenUsage: TokenUsage]
|
||||
taskToolFailed: [taskId: string, tool: ToolName, error: string]
|
||||
taskCommandExecuted: [
|
||||
taskId: string,
|
||||
details: {
|
||||
command: string
|
||||
exitCode: number | undefined
|
||||
output: string
|
||||
succeeded: boolean
|
||||
failureReason?: string
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export type TaskOptions = {
|
||||
provider: ClineProvider
|
||||
apiConfiguration: ProviderSettings
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ describe("executeCommand", () => {
|
|||
},
|
||||
say: vitest.fn().mockResolvedValue(undefined),
|
||||
terminalProcess: undefined,
|
||||
emit: vitest.fn(),
|
||||
}
|
||||
|
||||
// Create mock process that resolves immediately
|
||||
|
|
@ -471,4 +472,217 @@ describe("executeCommand", () => {
|
|||
expect(mockTerminalInstance.getCurrentWorkingDirectory).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("taskCommandExecuted Event", () => {
|
||||
it("should emit taskCommandExecuted event when command completes successfully", async () => {
|
||||
mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project")
|
||||
|
||||
// We need to mock Terminal.compressTerminalOutput since that's what sets the result
|
||||
const mockCompressTerminalOutput = vitest.spyOn(Terminal, "compressTerminalOutput")
|
||||
mockCompressTerminalOutput.mockReturnValue("Command output")
|
||||
|
||||
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
|
||||
// Simulate async callback execution
|
||||
setTimeout(() => {
|
||||
callbacks.onShellExecutionStarted(1234, mockProcess)
|
||||
callbacks.onCompleted("Command output", mockProcess)
|
||||
callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess)
|
||||
}, 0)
|
||||
return mockProcess
|
||||
})
|
||||
|
||||
const options: ExecuteCommandOptions = {
|
||||
executionId: "test-123",
|
||||
command: "echo test",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
const [rejected, result] = await executeCommand(mockTask, options)
|
||||
|
||||
// Verify
|
||||
expect(rejected).toBe(false)
|
||||
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
|
||||
command: "echo test",
|
||||
exitCode: 0,
|
||||
output: "Command output",
|
||||
succeeded: true,
|
||||
failureReason: undefined,
|
||||
})
|
||||
|
||||
mockCompressTerminalOutput.mockRestore()
|
||||
})
|
||||
|
||||
it("should emit taskCommandExecuted event when command fails with non-zero exit code", async () => {
|
||||
mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project")
|
||||
|
||||
const mockCompressTerminalOutput = vitest.spyOn(Terminal, "compressTerminalOutput")
|
||||
mockCompressTerminalOutput.mockReturnValue("Error output")
|
||||
|
||||
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
|
||||
setTimeout(() => {
|
||||
callbacks.onShellExecutionStarted(1234, mockProcess)
|
||||
callbacks.onCompleted("Error output", mockProcess)
|
||||
callbacks.onShellExecutionComplete({ exitCode: 1 }, mockProcess)
|
||||
}, 0)
|
||||
return mockProcess
|
||||
})
|
||||
|
||||
const options: ExecuteCommandOptions = {
|
||||
executionId: "test-123",
|
||||
command: "exit 1",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
const [rejected, result] = await executeCommand(mockTask, options)
|
||||
|
||||
// Verify
|
||||
expect(rejected).toBe(false)
|
||||
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
|
||||
command: "exit 1",
|
||||
exitCode: 1,
|
||||
output: "Error output",
|
||||
succeeded: false,
|
||||
failureReason: expect.stringContaining("Command execution was not successful"),
|
||||
})
|
||||
|
||||
mockCompressTerminalOutput.mockRestore()
|
||||
})
|
||||
|
||||
it("should emit taskCommandExecuted event when command is terminated by signal", async () => {
|
||||
mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project")
|
||||
|
||||
const mockCompressTerminalOutput = vitest.spyOn(Terminal, "compressTerminalOutput")
|
||||
mockCompressTerminalOutput.mockReturnValue("Interrupted output")
|
||||
|
||||
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
|
||||
setTimeout(() => {
|
||||
callbacks.onShellExecutionStarted(1234, mockProcess)
|
||||
callbacks.onCompleted("Interrupted output", mockProcess)
|
||||
callbacks.onShellExecutionComplete(
|
||||
{
|
||||
exitCode: undefined,
|
||||
signalName: "SIGTERM",
|
||||
coreDumpPossible: false,
|
||||
},
|
||||
mockProcess,
|
||||
)
|
||||
}, 0)
|
||||
return mockProcess
|
||||
})
|
||||
|
||||
const options: ExecuteCommandOptions = {
|
||||
executionId: "test-123",
|
||||
command: "long-running-command",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
const [rejected, result] = await executeCommand(mockTask, options)
|
||||
|
||||
// Verify
|
||||
expect(rejected).toBe(false)
|
||||
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
|
||||
command: "long-running-command",
|
||||
exitCode: undefined,
|
||||
output: "Interrupted output",
|
||||
succeeded: false,
|
||||
failureReason: expect.stringContaining("Process terminated by signal SIGTERM"),
|
||||
})
|
||||
|
||||
mockCompressTerminalOutput.mockRestore()
|
||||
})
|
||||
|
||||
it("should emit taskCommandExecuted event when command times out", async () => {
|
||||
// Mock the terminal process to not complete before timeout
|
||||
let timeoutId: NodeJS.Timeout
|
||||
const neverEndingProcess = new Promise<void>((resolve) => {
|
||||
timeoutId = setTimeout(resolve, 10000) // Would resolve after 10 seconds
|
||||
})
|
||||
Object.assign(neverEndingProcess, {
|
||||
continue: vitest.fn(),
|
||||
abort: vitest.fn(() => {
|
||||
clearTimeout(timeoutId)
|
||||
}),
|
||||
})
|
||||
|
||||
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
|
||||
callbacks.onLine("Partial output", neverEndingProcess as any)
|
||||
return neverEndingProcess
|
||||
})
|
||||
|
||||
const options: ExecuteCommandOptions = {
|
||||
executionId: "test-123",
|
||||
command: "sleep 100",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
commandExecutionTimeout: 100, // 100ms timeout
|
||||
}
|
||||
|
||||
// Execute
|
||||
const [rejected, result] = await executeCommand(mockTask, options)
|
||||
|
||||
// Verify
|
||||
expect(rejected).toBe(false)
|
||||
expect(result).toContain("terminated after exceeding")
|
||||
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
|
||||
command: "sleep 100",
|
||||
exitCode: undefined,
|
||||
output: "Partial output",
|
||||
succeeded: false,
|
||||
failureReason: "Command timed out after 0.1s",
|
||||
})
|
||||
})
|
||||
|
||||
it("should emit taskCommandExecuted event when user provides feedback while command is running", async () => {
|
||||
// Mock the ask function to simulate user feedback
|
||||
mockTask.ask = vitest.fn().mockResolvedValue({
|
||||
response: "messageResponse",
|
||||
text: "Please stop the command",
|
||||
images: [],
|
||||
})
|
||||
|
||||
// Mock a long-running command
|
||||
let commandResolve: () => void
|
||||
const longRunningProcess = new Promise<void>((resolve) => {
|
||||
commandResolve = resolve
|
||||
})
|
||||
Object.assign(longRunningProcess, {
|
||||
continue: vitest.fn(() => {
|
||||
// Simulate command continuing after feedback
|
||||
setTimeout(() => commandResolve(), 10)
|
||||
}),
|
||||
})
|
||||
|
||||
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
|
||||
// Simulate output that triggers user interaction
|
||||
callbacks.onLine("Command is running...\n", longRunningProcess as any)
|
||||
return longRunningProcess
|
||||
})
|
||||
|
||||
const options: ExecuteCommandOptions = {
|
||||
executionId: "test-123",
|
||||
command: "npm install",
|
||||
terminalShellIntegrationDisabled: false,
|
||||
terminalOutputLineLimit: 500,
|
||||
}
|
||||
|
||||
// Execute
|
||||
const [rejected, result] = await executeCommand(mockTask, options)
|
||||
|
||||
// Verify
|
||||
expect(rejected).toBe(true) // User feedback causes rejection
|
||||
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
|
||||
command: "npm install",
|
||||
exitCode: undefined,
|
||||
output: "Command is running...\n",
|
||||
succeeded: false,
|
||||
failureReason: "Command is still running (user provided feedback)",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ describe("Command Execution Timeout Integration", () => {
|
|||
// Mock task
|
||||
mockTask = {
|
||||
cwd: "/test/directory",
|
||||
taskId: "test-task-123",
|
||||
terminalProcess: undefined,
|
||||
providerRef: {
|
||||
deref: vitest.fn().mockResolvedValue({
|
||||
|
|
@ -53,6 +54,7 @@ describe("Command Execution Timeout Integration", () => {
|
|||
}),
|
||||
},
|
||||
say: vitest.fn().mockResolvedValue(undefined),
|
||||
emit: vitest.fn(),
|
||||
}
|
||||
|
||||
// Mock terminal process
|
||||
|
|
@ -231,6 +233,7 @@ describe("Command Execution Timeout Integration", () => {
|
|||
// Mock task with additional properties needed by executeCommandTool
|
||||
mockTask = {
|
||||
cwd: "/test/directory",
|
||||
taskId: "test-task-123",
|
||||
terminalProcess: undefined,
|
||||
providerRef: {
|
||||
deref: vitest.fn().mockResolvedValue({
|
||||
|
|
@ -251,6 +254,7 @@ describe("Command Execution Timeout Integration", () => {
|
|||
lastMessageTs: Date.now(),
|
||||
ask: vitest.fn(),
|
||||
didRejectTool: false,
|
||||
emit: vitest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -274,6 +274,15 @@ export async function executeCommand(
|
|||
await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }))
|
||||
task.terminalProcess = undefined
|
||||
|
||||
// Emit taskCommandExecuted event for timeout
|
||||
task.emit("taskCommandExecuted", task.taskId, {
|
||||
command,
|
||||
exitCode: undefined,
|
||||
output: accumulatedOutput, // Use accumulatedOutput instead of result
|
||||
succeeded: false,
|
||||
failureReason: `Command timed out after ${commandExecutionTimeoutSeconds}s`,
|
||||
})
|
||||
|
||||
return [
|
||||
false,
|
||||
`The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`,
|
||||
|
|
@ -311,6 +320,15 @@ export async function executeCommand(
|
|||
const { text, images } = message
|
||||
await task.say("user_feedback", text, images)
|
||||
|
||||
// Emit taskCommandExecuted event for running command with user feedback
|
||||
task.emit("taskCommandExecuted", task.taskId, {
|
||||
command,
|
||||
exitCode: undefined,
|
||||
output: accumulatedOutput, // Use accumulatedOutput instead of result
|
||||
succeeded: false,
|
||||
failureReason: "Command is still running (user provided feedback)",
|
||||
})
|
||||
|
||||
return [
|
||||
true,
|
||||
formatResponse.toolResult(
|
||||
|
|
@ -325,6 +343,7 @@ export async function executeCommand(
|
|||
]
|
||||
} else if (completed || exitDetails) {
|
||||
let exitStatus: string = ""
|
||||
let exitCode: number | undefined = exitDetails?.exitCode
|
||||
|
||||
if (exitDetails !== undefined) {
|
||||
if (exitDetails.signalName) {
|
||||
|
|
@ -350,6 +369,16 @@ export async function executeCommand(
|
|||
|
||||
let workingDirInfo = ` within working directory '${terminal.getCurrentWorkingDirectory().toPosix()}'`
|
||||
|
||||
// Emit taskCommandExecuted event
|
||||
const succeeded = exitCode === 0
|
||||
task.emit("taskCommandExecuted", task.taskId, {
|
||||
command,
|
||||
exitCode,
|
||||
output: result,
|
||||
succeeded,
|
||||
failureReason: succeeded ? undefined : exitStatus,
|
||||
})
|
||||
|
||||
return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`]
|
||||
} else {
|
||||
return [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue