Skip to content

Commit de6db59

Browse files
committed
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.
1 parent 76e5a72 commit de6db59

File tree

4 files changed

+271
-0
lines changed

4 files changed

+271
-0
lines changed

src/core/task/Task.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,30 @@ import { AutoApprovalHandler } from "./AutoApprovalHandler"
102102

103103
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
104104

105+
export type ClineEvents = {
106+
message: [{ action: "created" | "updated"; message: ClineMessage }]
107+
taskStarted: []
108+
taskModeSwitched: [taskId: string, mode: string]
109+
taskPaused: []
110+
taskUnpaused: []
111+
taskAskResponded: []
112+
taskAborted: []
113+
taskSpawned: [taskId: string]
114+
taskCompleted: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage]
115+
taskTokenUsageUpdated: [taskId: string, tokenUsage: TokenUsage]
116+
taskToolFailed: [taskId: string, tool: ToolName, error: string]
117+
taskCommandExecuted: [
118+
taskId: string,
119+
details: {
120+
command: string
121+
exitCode: number | undefined
122+
output: string
123+
succeeded: boolean
124+
failureReason?: string
125+
},
126+
]
127+
}
128+
105129
export type TaskOptions = {
106130
provider: ClineProvider
107131
apiConfiguration: ProviderSettings

src/core/tools/__tests__/executeCommand.spec.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ describe("executeCommand", () => {
5454
},
5555
say: vitest.fn().mockResolvedValue(undefined),
5656
terminalProcess: undefined,
57+
emit: vitest.fn(),
5758
}
5859

5960
// Create mock process that resolves immediately
@@ -471,4 +472,217 @@ describe("executeCommand", () => {
471472
expect(mockTerminalInstance.getCurrentWorkingDirectory).toHaveBeenCalled()
472473
})
473474
})
475+
476+
describe("taskCommandExecuted Event", () => {
477+
it("should emit taskCommandExecuted event when command completes successfully", async () => {
478+
mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project")
479+
480+
// We need to mock Terminal.compressTerminalOutput since that's what sets the result
481+
const mockCompressTerminalOutput = vitest.spyOn(Terminal, "compressTerminalOutput")
482+
mockCompressTerminalOutput.mockReturnValue("Command output")
483+
484+
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
485+
// Simulate async callback execution
486+
setTimeout(() => {
487+
callbacks.onShellExecutionStarted(1234, mockProcess)
488+
callbacks.onCompleted("Command output", mockProcess)
489+
callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess)
490+
}, 0)
491+
return mockProcess
492+
})
493+
494+
const options: ExecuteCommandOptions = {
495+
executionId: "test-123",
496+
command: "echo test",
497+
terminalShellIntegrationDisabled: false,
498+
terminalOutputLineLimit: 500,
499+
}
500+
501+
// Execute
502+
const [rejected, result] = await executeCommand(mockTask, options)
503+
504+
// Verify
505+
expect(rejected).toBe(false)
506+
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
507+
command: "echo test",
508+
exitCode: 0,
509+
output: "Command output",
510+
succeeded: true,
511+
failureReason: undefined,
512+
})
513+
514+
mockCompressTerminalOutput.mockRestore()
515+
})
516+
517+
it("should emit taskCommandExecuted event when command fails with non-zero exit code", async () => {
518+
mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project")
519+
520+
const mockCompressTerminalOutput = vitest.spyOn(Terminal, "compressTerminalOutput")
521+
mockCompressTerminalOutput.mockReturnValue("Error output")
522+
523+
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
524+
setTimeout(() => {
525+
callbacks.onShellExecutionStarted(1234, mockProcess)
526+
callbacks.onCompleted("Error output", mockProcess)
527+
callbacks.onShellExecutionComplete({ exitCode: 1 }, mockProcess)
528+
}, 0)
529+
return mockProcess
530+
})
531+
532+
const options: ExecuteCommandOptions = {
533+
executionId: "test-123",
534+
command: "exit 1",
535+
terminalShellIntegrationDisabled: false,
536+
terminalOutputLineLimit: 500,
537+
}
538+
539+
// Execute
540+
const [rejected, result] = await executeCommand(mockTask, options)
541+
542+
// Verify
543+
expect(rejected).toBe(false)
544+
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
545+
command: "exit 1",
546+
exitCode: 1,
547+
output: "Error output",
548+
succeeded: false,
549+
failureReason: expect.stringContaining("Command execution was not successful"),
550+
})
551+
552+
mockCompressTerminalOutput.mockRestore()
553+
})
554+
555+
it("should emit taskCommandExecuted event when command is terminated by signal", async () => {
556+
mockTerminal.getCurrentWorkingDirectory.mockReturnValue("/test/project")
557+
558+
const mockCompressTerminalOutput = vitest.spyOn(Terminal, "compressTerminalOutput")
559+
mockCompressTerminalOutput.mockReturnValue("Interrupted output")
560+
561+
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
562+
setTimeout(() => {
563+
callbacks.onShellExecutionStarted(1234, mockProcess)
564+
callbacks.onCompleted("Interrupted output", mockProcess)
565+
callbacks.onShellExecutionComplete(
566+
{
567+
exitCode: undefined,
568+
signalName: "SIGTERM",
569+
coreDumpPossible: false,
570+
},
571+
mockProcess,
572+
)
573+
}, 0)
574+
return mockProcess
575+
})
576+
577+
const options: ExecuteCommandOptions = {
578+
executionId: "test-123",
579+
command: "long-running-command",
580+
terminalShellIntegrationDisabled: false,
581+
terminalOutputLineLimit: 500,
582+
}
583+
584+
// Execute
585+
const [rejected, result] = await executeCommand(mockTask, options)
586+
587+
// Verify
588+
expect(rejected).toBe(false)
589+
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
590+
command: "long-running-command",
591+
exitCode: undefined,
592+
output: "Interrupted output",
593+
succeeded: false,
594+
failureReason: expect.stringContaining("Process terminated by signal SIGTERM"),
595+
})
596+
597+
mockCompressTerminalOutput.mockRestore()
598+
})
599+
600+
it("should emit taskCommandExecuted event when command times out", async () => {
601+
// Mock the terminal process to not complete before timeout
602+
let timeoutId: NodeJS.Timeout
603+
const neverEndingProcess = new Promise<void>((resolve) => {
604+
timeoutId = setTimeout(resolve, 10000) // Would resolve after 10 seconds
605+
})
606+
Object.assign(neverEndingProcess, {
607+
continue: vitest.fn(),
608+
abort: vitest.fn(() => {
609+
clearTimeout(timeoutId)
610+
}),
611+
})
612+
613+
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
614+
callbacks.onLine("Partial output", neverEndingProcess as any)
615+
return neverEndingProcess
616+
})
617+
618+
const options: ExecuteCommandOptions = {
619+
executionId: "test-123",
620+
command: "sleep 100",
621+
terminalShellIntegrationDisabled: false,
622+
terminalOutputLineLimit: 500,
623+
commandExecutionTimeout: 100, // 100ms timeout
624+
}
625+
626+
// Execute
627+
const [rejected, result] = await executeCommand(mockTask, options)
628+
629+
// Verify
630+
expect(rejected).toBe(false)
631+
expect(result).toContain("terminated after exceeding")
632+
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
633+
command: "sleep 100",
634+
exitCode: undefined,
635+
output: "Partial output",
636+
succeeded: false,
637+
failureReason: "Command timed out after 0.1s",
638+
})
639+
})
640+
641+
it("should emit taskCommandExecuted event when user provides feedback while command is running", async () => {
642+
// Mock the ask function to simulate user feedback
643+
mockTask.ask = vitest.fn().mockResolvedValue({
644+
response: "messageResponse",
645+
text: "Please stop the command",
646+
images: [],
647+
})
648+
649+
// Mock a long-running command
650+
let commandResolve: () => void
651+
const longRunningProcess = new Promise<void>((resolve) => {
652+
commandResolve = resolve
653+
})
654+
Object.assign(longRunningProcess, {
655+
continue: vitest.fn(() => {
656+
// Simulate command continuing after feedback
657+
setTimeout(() => commandResolve(), 10)
658+
}),
659+
})
660+
661+
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
662+
// Simulate output that triggers user interaction
663+
callbacks.onLine("Command is running...\n", longRunningProcess as any)
664+
return longRunningProcess
665+
})
666+
667+
const options: ExecuteCommandOptions = {
668+
executionId: "test-123",
669+
command: "npm install",
670+
terminalShellIntegrationDisabled: false,
671+
terminalOutputLineLimit: 500,
672+
}
673+
674+
// Execute
675+
const [rejected, result] = await executeCommand(mockTask, options)
676+
677+
// Verify
678+
expect(rejected).toBe(true) // User feedback causes rejection
679+
expect(mockTask.emit).toHaveBeenCalledWith("taskCommandExecuted", mockTask.taskId, {
680+
command: "npm install",
681+
exitCode: undefined,
682+
output: "Command is running...\n",
683+
succeeded: false,
684+
failureReason: "Command is still running (user provided feedback)",
685+
})
686+
})
687+
})
474688
})

src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ describe("Command Execution Timeout Integration", () => {
4646
// Mock task
4747
mockTask = {
4848
cwd: "/test/directory",
49+
taskId: "test-task-123",
4950
terminalProcess: undefined,
5051
providerRef: {
5152
deref: vitest.fn().mockResolvedValue({
5253
postMessageToWebview: vitest.fn(),
5354
}),
5455
},
5556
say: vitest.fn().mockResolvedValue(undefined),
57+
emit: vitest.fn(),
5658
}
5759

5860
// Mock terminal process
@@ -231,6 +233,7 @@ describe("Command Execution Timeout Integration", () => {
231233
// Mock task with additional properties needed by executeCommandTool
232234
mockTask = {
233235
cwd: "/test/directory",
236+
taskId: "test-task-123",
234237
terminalProcess: undefined,
235238
providerRef: {
236239
deref: vitest.fn().mockResolvedValue({
@@ -251,6 +254,7 @@ describe("Command Execution Timeout Integration", () => {
251254
lastMessageTs: Date.now(),
252255
ask: vitest.fn(),
253256
didRejectTool: false,
257+
emit: vitest.fn(),
254258
}
255259
})
256260

src/core/tools/executeCommandTool.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,15 @@ export async function executeCommand(
274274
await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }))
275275
task.terminalProcess = undefined
276276

277+
// Emit taskCommandExecuted event for timeout
278+
task.emit("taskCommandExecuted", task.taskId, {
279+
command,
280+
exitCode: undefined,
281+
output: accumulatedOutput, // Use accumulatedOutput instead of result
282+
succeeded: false,
283+
failureReason: `Command timed out after ${commandExecutionTimeoutSeconds}s`,
284+
})
285+
277286
return [
278287
false,
279288
`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(
311320
const { text, images } = message
312321
await task.say("user_feedback", text, images)
313322

323+
// Emit taskCommandExecuted event for running command with user feedback
324+
task.emit("taskCommandExecuted", task.taskId, {
325+
command,
326+
exitCode: undefined,
327+
output: accumulatedOutput, // Use accumulatedOutput instead of result
328+
succeeded: false,
329+
failureReason: "Command is still running (user provided feedback)",
330+
})
331+
314332
return [
315333
true,
316334
formatResponse.toolResult(
@@ -325,6 +343,7 @@ export async function executeCommand(
325343
]
326344
} else if (completed || exitDetails) {
327345
let exitStatus: string = ""
346+
let exitCode: number | undefined = exitDetails?.exitCode
328347

329348
if (exitDetails !== undefined) {
330349
if (exitDetails.signalName) {
@@ -350,6 +369,16 @@ export async function executeCommand(
350369

351370
let workingDirInfo = ` within working directory '${terminal.getCurrentWorkingDirectory().toPosix()}'`
352371

372+
// Emit taskCommandExecuted event
373+
const succeeded = exitCode === 0
374+
task.emit("taskCommandExecuted", task.taskId, {
375+
command,
376+
exitCode,
377+
output: result,
378+
succeeded,
379+
failureReason: succeeded ? undefined : exitStatus,
380+
})
381+
353382
return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`]
354383
} else {
355384
return [

0 commit comments

Comments
 (0)