diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 42c389ab60..52bae14e1a 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -34,6 +34,9 @@ export enum RooCodeEventName { TaskTokenUsageUpdated = "taskTokenUsageUpdated", TaskToolFailed = "taskToolFailed", + // Command Execution + TaskCommandExecuted = "taskCommandExecuted", + // Evals EvalPass = "evalPass", EvalFail = "evalFail", @@ -77,6 +80,18 @@ export const rooCodeEventsSchema = z.object({ [RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]), [RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema]), + + // Command Execution + [RooCodeEventName.TaskCommandExecuted]: z.tuple([ + z.string(), + z.object({ + command: z.string(), + exitCode: z.number().optional(), + output: z.string(), + succeeded: z.boolean(), + failureReason: z.string().optional(), + }), + ]), }) export type RooCodeEvents = z.infer @@ -176,6 +191,13 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ taskId: z.number().optional(), }), + // Command Execution + z.object({ + eventName: z.literal(RooCodeEventName.TaskCommandExecuted), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskCommandExecuted], + taskId: z.number().optional(), + }), + // Evals z.object({ eventName: z.literal(RooCodeEventName.EvalPass), diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index a4a2f5f0fc..5821f2d2fb 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -96,4 +96,16 @@ export type TaskEvents = { // Task Analytics [RooCodeEventName.TaskToolFailed]: [taskId: string, tool: ToolName, error: string] [RooCodeEventName.TaskTokenUsageUpdated]: [taskId: string, tokenUsage: TokenUsage] + + // Command Execution + [RooCodeEventName.TaskCommandExecuted]: [ + taskId: string, + details: { + command: string + exitCode: number | undefined + output: string + succeeded: boolean + failureReason?: string + }, + ] } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5e96b6fb16..e48622e4ad 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] + [RooCodeEventName.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..cf687d1002 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -4,7 +4,7 @@ import * as vscode from "vscode" import delay from "delay" -import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types" +import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, RooCodeEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" @@ -143,6 +143,26 @@ export type ExecuteCommandOptions = { commandExecutionTimeout?: number } +/** + * Helper function to emit taskCommandExecuted event with consistent payload + */ +function emitCommandExecutedEvent( + task: Task, + command: string, + exitCode: number | undefined, + output: string, + succeeded: boolean, + failureReason?: string, +) { + task.emit(RooCodeEventName.TaskCommandExecuted, task.taskId, { + command, + exitCode, + output, + succeeded, + failureReason, + }) +} + export async function executeCommand( task: Task, { @@ -274,6 +294,16 @@ export async function executeCommand( await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds })) task.terminalProcess = undefined + // Emit taskCommandExecuted event for timeout + emitCommandExecutedEvent( + task, + command, + undefined, + accumulatedOutput, + false, + `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 +341,16 @@ export async function executeCommand( const { text, images } = message await task.say("user_feedback", text, images) + // Emit taskCommandExecuted event for running command with user feedback + emitCommandExecutedEvent( + task, + command, + undefined, + accumulatedOutput, + false, + "Command is still running (user provided feedback)", + ) + return [ true, formatResponse.toolResult( @@ -325,6 +365,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 +391,10 @@ export async function executeCommand( let workingDirInfo = ` within working directory '${terminal.getCurrentWorkingDirectory().toPosix()}'` + // Emit taskCommandExecuted event + const succeeded = exitCode === 0 + emitCommandExecutedEvent(task, command, exitCode, result, succeeded, succeeded ? undefined : exitStatus) + return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`] } else { return [