From de6db594b59af701aa9fe736f1cad2854ba1edb4 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 29 Jul 2025 17:29:17 +0000 Subject: [PATCH] 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. --- src/core/task/Task.ts | 24 ++ .../tools/__tests__/executeCommand.spec.ts | 214 ++++++++++++++++++ .../executeCommandTimeout.integration.spec.ts | 4 + src/core/tools/executeCommandTool.ts | 29 +++ 4 files changed, 271 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5e96b6fb16..9a17a56f4f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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 diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index 68dec5c456..a8efd36e07 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -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((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((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)", + }) + }) + }) }) diff --git a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts index b9e0af3a8a..7d493d3927 100644 --- a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts +++ b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts @@ -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(), } }) diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index c346526a2e..d54a89552a 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -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 [