-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathexecuteCommandTool.spec.ts
More file actions
722 lines (589 loc) · 27.2 KB
/
Copy pathexecuteCommandTool.spec.ts
File metadata and controls
722 lines (589 loc) · 27.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
// npx vitest run src/core/tools/__tests__/executeCommandTool.spec.ts
import type { ToolUsage } from "@roo-code/types"
import * as vscode from "vscode"
import { Task } from "../../task/Task"
import { formatResponse } from "../../prompts/responses"
import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools"
import { unescapeHtmlEntities } from "../../../utils/text-normalization"
import { Terminal } from "../../../integrations/terminal/Terminal"
import type { RooTerminalCallbacks, RooTerminalProcess } from "../../../integrations/terminal/types"
// Mock dependencies
vitest.mock("execa", () => ({
execa: vitest.fn(),
}))
vitest.mock("fs/promises", () => ({
default: {
access: vitest.fn().mockResolvedValue(undefined),
},
}))
vitest.mock("vscode", () => ({
workspace: {
getConfiguration: vitest.fn(),
},
}))
vitest.mock("../../../integrations/terminal/TerminalRegistry", () => ({
TerminalRegistry: {
getOrCreateTerminal: vitest.fn().mockResolvedValue({
runCommand: vitest.fn().mockImplementation((_cmd: string, callbacks: any) => {
// Invoke onCompleted so onCompletedPromise resolves and the tool returns.
callbacks?.onCompleted?.("")
const p = Promise.resolve()
// Attach promise-like properties so mergePromise callers don't throw.
return Object.assign(p, { continue: () => {}, abort: () => {} })
}),
getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"),
}),
},
}))
vitest.mock("../../task/Task")
vitest.mock("../../prompts/responses")
const mockRunDcg = vitest.fn()
const mockGetDcgBinaryPath = vitest.fn()
vitest.mock("../../../services/destructive-command-guard", () => ({
runDcg: mockRunDcg,
getDcgBinaryPath: mockGetDcgBinaryPath,
}))
// Import the module
import * as executeCommandModule from "../ExecuteCommandTool"
const { executeCommandTool } = executeCommandModule
describe("executeCommandTool", () => {
// Setup common test variables
let mockCline: any & { consecutiveMistakeCount: number; didRejectTool: boolean }
let mockAskApproval: any
let mockHandleError: any
let mockPushToolResult: any
let mockToolUse: ToolUse<"execute_command">
const originalCliRuntime = process.env.ROO_CLI_RUNTIME
beforeEach(() => {
// Reset mocks
vitest.clearAllMocks()
vitest.useRealTimers()
// Spy on executeCommandInTerminal and mock its return value
vitest.spyOn(executeCommandModule, "executeCommandInTerminal").mockResolvedValue([false, "Command executed"])
// Create mock implementations with eslint directives to handle the type issues
mockCline = {
ask: vitest.fn().mockResolvedValue(undefined),
say: vitest.fn().mockResolvedValue(undefined),
sayAndCreateMissingParamError: vitest.fn().mockResolvedValue("Missing parameter error"),
consecutiveMistakeCount: 0,
didRejectTool: false,
rooIgnoreController: {
validateCommand: vitest.fn().mockReturnValue(null),
},
recordToolUsage: vitest.fn().mockReturnValue({} as ToolUsage),
recordToolError: vitest.fn(),
supersedePendingAsk: vitest.fn(),
providerRef: {
deref: vitest.fn().mockResolvedValue({
getState: vitest.fn().mockResolvedValue({
terminalOutputLineLimit: 500,
terminalOutputCharacterLimit: 100000,
terminalShellIntegrationDisabled: true,
}),
postMessageToWebview: vitest.fn(),
}),
},
lastMessageTs: Date.now(),
cwd: "/test/workspace",
}
mockAskApproval = vitest.fn().mockResolvedValue(true)
mockHandleError = vitest.fn().mockResolvedValue(undefined)
mockPushToolResult = vitest.fn()
mockRunDcg.mockResolvedValue({ decision: "allow" })
mockGetDcgBinaryPath.mockReturnValue("/test/storage/dcg")
// Setup vscode config mock
const mockConfig = {
get: vitest.fn().mockImplementation((key: string, defaultValue: any) => {
return defaultValue
}),
}
;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig)
// Create a mock tool use object
mockToolUse = {
type: "tool_use",
name: "execute_command",
params: {
command: "echo test",
},
nativeArgs: {
command: "echo test",
},
partial: false,
}
})
afterEach(() => {
process.env.ROO_CLI_RUNTIME = originalCliRuntime
vitest.useRealTimers()
})
/**
* Tests for HTML entity unescaping in commands
* This verifies that HTML entities are properly converted to their actual characters
*/
describe("HTML entity unescaping", () => {
it("should unescape < to < character", () => {
const input = "echo <test>"
const expected = "echo <test>"
expect(unescapeHtmlEntities(input)).toBe(expected)
})
it("should unescape > to > character", () => {
const input = "echo test > output.txt"
const expected = "echo test > output.txt"
expect(unescapeHtmlEntities(input)).toBe(expected)
})
it("should unescape & to & character", () => {
const input = "echo foo && echo bar"
const expected = "echo foo && echo bar"
expect(unescapeHtmlEntities(input)).toBe(expected)
})
it("should handle multiple mixed HTML entities", () => {
const input = "grep -E 'pattern' <file.txt >output.txt 2>&1"
const expected = "grep -E 'pattern' <file.txt >output.txt 2>&1"
expect(unescapeHtmlEntities(input)).toBe(expected)
})
})
// Now we can run these tests
describe("Basic functionality", () => {
it("should execute a command normally", async () => {
// Setup
mockToolUse.params.command = "echo test"
mockToolUse.nativeArgs = { command: "echo test" }
// Execute using the class-based handle method
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
})
// Verify
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test")
expect(mockPushToolResult).toHaveBeenCalled()
// The exact message depends on the terminal mock's behavior
const result = mockPushToolResult.mock.calls[0][0]
expect(result).toContain("Command")
})
it("should pass along custom working directory if provided", async () => {
// Setup
mockToolUse.params.command = "echo test"
mockToolUse.params.cwd = "/custom/path"
mockToolUse.nativeArgs = { command: "echo test", cwd: "/custom/path" }
// Execute
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
})
// Verify - command approved, result pushed, and custom cwd passed to terminal
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test")
expect(mockPushToolResult).toHaveBeenCalled()
const { TerminalRegistry } = await import("../../../integrations/terminal/TerminalRegistry")
const firstArg = (TerminalRegistry.getOrCreateTerminal as ReturnType<typeof vitest.fn>).mock.calls[0][0]
expect(firstArg).toBe("/custom/path")
})
})
describe("Error handling", () => {
it.each([
[undefined, undefined, "executeCommand.destructiveCommandGuard.blocked"],
["matches a destructive pattern", undefined, "executeCommand.destructiveCommandGuard.blockedWithReason"],
[undefined, "recursive-delete", "executeCommand.destructiveCommandGuard.blockedWithRule"],
[
"matches a destructive pattern",
"recursive-delete",
"executeCommand.destructiveCommandGuard.blockedWithReasonAndRule",
],
])("selects the localized DCG block message for reason %s and rule %s", (reason, ruleId, expected) => {
expect(executeCommandModule.formatDcgBlockedMessage(reason, ruleId)).toBe(expected)
})
it("shows a DCG block message as an error before requesting explicit approval", async () => {
const provider = await mockCline.providerRef.deref()
provider.context = { globalStorageUri: { fsPath: "/test/storage" } }
provider.getState.mockResolvedValue({
destructiveCommandGuardEnabled: true,
terminalShellIntegrationDisabled: true,
})
mockRunDcg.mockResolvedValue({
decision: "deny",
reason: "matches a destructive pattern",
ruleId: "recursive-delete",
})
mockAskApproval.mockResolvedValue(false)
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
})
expect(mockCline.say).toHaveBeenCalledWith(
"error",
"executeCommand.destructiveCommandGuard.blockedWithReasonAndRule",
)
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true)
})
it("should handle missing command parameter", async () => {
// Setup
mockToolUse.params.command = undefined
// Native tool calls must still supply a value; simulate a missing value with an empty string.
mockToolUse.nativeArgs = { command: "" }
// Execute
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
})
// Verify
expect(mockCline.consecutiveMistakeCount).toBe(1)
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("execute_command", "command")
expect(mockPushToolResult).toHaveBeenCalledWith("Missing parameter error")
expect(mockAskApproval).not.toHaveBeenCalled()
expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled()
})
it("should handle command rejection", async () => {
// Setup
mockToolUse.params.command = "echo test"
mockAskApproval.mockResolvedValue(false)
mockToolUse.nativeArgs = { command: "echo test" }
// Execute
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
})
// Verify
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test")
// executeCommandInTerminal should not be called since approval was denied
expect(mockPushToolResult).not.toHaveBeenCalled()
})
it("should handle rooignore validation failures", async () => {
// Setup
mockToolUse.params.command = "cat .env"
mockToolUse.nativeArgs = { command: "cat .env" }
// Override the validateCommand mock to return a filename
const validateCommandMock = vitest.fn().mockReturnValue(".env")
mockCline.rooIgnoreController = {
validateCommand: validateCommandMock,
}
const mockRooIgnoreError = "RooIgnore error"
;(formatResponse.rooIgnoreError as any).mockReturnValue(mockRooIgnoreError)
// Execute
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
})
// Verify
expect(validateCommandMock).toHaveBeenCalledWith("cat .env")
expect(mockCline.say).toHaveBeenCalledWith("rooignore_error", ".env")
expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith(".env")
expect(mockPushToolResult).toHaveBeenCalledWith(mockRooIgnoreError)
expect(mockAskApproval).not.toHaveBeenCalled()
// executeCommandInTerminal should not be called since rooignore blocked it
})
it("allows Execa retry when shell integration fails before command submission", () => {
const error = new executeCommandModule.ShellIntegrationError("startup failed", false)
expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(true)
})
it("prevents Execa retry when shell integration fails after command submission", () => {
const error = new executeCommandModule.ShellIntegrationError("stream missing", true)
expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(false)
})
it("selects the Execa fallback provider for cmd.exe shell integration", () => {
vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(true)
expect(executeCommandModule.getTerminalProviderForExecution(false)).toEqual({
terminalProvider: "execa",
isCmdExeFallback: true,
})
})
})
describe("Command execution timeout configuration", () => {
it("should include timeout parameter in ExecuteCommandOptions", () => {
// This test verifies that the timeout configuration is properly typed
// The actual timeout logic is tested in integration tests
// Note: timeout is stored internally in milliseconds but configured in seconds
const timeoutSeconds = 15
const options = {
executionId: "test-id",
command: "echo test",
commandExecutionTimeout: timeoutSeconds * 1000, // Convert to milliseconds
}
// Verify the options object has the expected structure
expect(options.commandExecutionTimeout).toBe(15000)
expect(typeof options.commandExecutionTimeout).toBe("number")
})
it("should handle timeout parameter in function signature", () => {
// Test that the executeCommandInTerminal function accepts timeout parameter
// This is a compile-time check that the types are correct
const mockOptions = {
executionId: "test-id",
command: "echo test",
customCwd: undefined,
terminalShellIntegrationDisabled: false,
terminalOutputLineLimit: 500,
commandExecutionTimeout: 0,
}
// Verify all required properties exist
expect(mockOptions.executionId).toBeDefined()
expect(mockOptions.command).toBeDefined()
expect(mockOptions.commandExecutionTimeout).toBeDefined()
})
it("should ignore model timeout in CLI runtime", () => {
process.env.ROO_CLI_RUNTIME = "1"
expect(executeCommandModule.resolveAgentTimeoutMs(30)).toBe(0)
})
it("should honor model timeout outside CLI runtime", () => {
delete process.env.ROO_CLI_RUNTIME
expect(executeCommandModule.resolveAgentTimeoutMs(30)).toBe(30_000)
})
})
describe("command_output ask policy", () => {
type MockProcess = Promise<void> & {
continue: ReturnType<typeof vitest.fn>
abort: ReturnType<typeof vitest.fn>
}
interface ControllableTerminal {
callbacks: RooTerminalCallbacks | undefined
proc: MockProcess
resolveProcess: () => void
}
const setupControllableTerminal = async (): Promise<ControllableTerminal> => {
const { TerminalRegistry } = await import("../../../integrations/terminal/TerminalRegistry")
const state: ControllableTerminal = {
callbacks: undefined,
proc: undefined as unknown as MockProcess,
resolveProcess: () => {},
}
const processPromise = new Promise<void>((resolve) => {
state.resolveProcess = resolve
})
// Mirror real terminal behavior: continue() resolves the wait early
// while the command keeps running in the background.
state.proc = Object.assign(processPromise, {
continue: vitest.fn(() => state.resolveProcess()),
abort: vitest.fn(),
})
;(TerminalRegistry.getOrCreateTerminal as ReturnType<typeof vitest.fn>).mockResolvedValue({
runCommand: vitest.fn((_cmd: string, callbacks: RooTerminalCallbacks) => {
state.callbacks = callbacks
return state.proc
}),
getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"),
})
return state
}
const handleCommand = (command: string, timeout?: number) => {
mockToolUse.params.command = command
mockToolUse.params.timeout = timeout === undefined ? undefined : String(timeout)
mockToolUse.nativeArgs = timeout === undefined ? { command } : { command, timeout }
return executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
})
}
it("does not ask about command output when a short command emits output and exits normally", async () => {
vitest.useFakeTimers()
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("echo hello")
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
callbacks.onShellExecutionStarted!(1234, proc)
await callbacks.onLine("hello\n", proc)
await callbacks.onCompleted!("hello\n", proc)
callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc)
terminal.resolveProcess()
// Advance past the ask delay to prove the scheduled ask was cancelled
// on completion, not merely deferred beyond the test's runtime.
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS + 1_000)
await handlePromise
expect(mockCline.ask).not.toHaveBeenCalled()
expect(mockPushToolResult).toHaveBeenCalled()
const result = mockPushToolResult.mock.calls[0][0]
expect(result).toContain("hello")
expect(result).toContain("Exit code: 0")
})
it("asks about command output when the command is still running after the ask delay", async () => {
vitest.useFakeTimers()
mockCline.ask.mockResolvedValue({ response: "messageResponse", text: "keep going", images: undefined })
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("sleep 60")
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
callbacks.onShellExecutionStarted!(1234, proc)
await callbacks.onLine("working...\n", proc)
// First output alone must not trigger the ask.
expect(mockCline.ask).not.toHaveBeenCalled()
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS)
expect(mockCline.ask).toHaveBeenCalledWith("command_output", "")
expect(terminal.proc.continue).toHaveBeenCalled()
// Further output after the ask must not schedule another ask.
await callbacks.onLine("still working...\n", proc)
expect(mockCline.ask).toHaveBeenCalledTimes(1)
// Let the command finish so the tool can resolve.
await callbacks.onCompleted!("working...\n", proc)
callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc)
terminal.resolveProcess()
await vitest.advanceTimersByTimeAsync(100)
await handlePromise
expect(mockPushToolResult).toHaveBeenCalled()
})
it("anchors the ask delay to execution start so shell integration startup does not consume it", async () => {
vitest.useFakeTimers()
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("echo hello")
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
// Simulate a cold terminal spending most of the grace period waiting
// for shell integration before the command actually starts.
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS - 2_000)
callbacks.onShellExecutionStarted!(1234, proc)
await callbacks.onLine("hello\n", proc)
// Past the pre-runCommand anchor deadline but well within the window
// measured from execution start: still no ask.
await vitest.advanceTimersByTimeAsync(2_500)
expect(mockCline.ask).not.toHaveBeenCalled()
await callbacks.onCompleted!("hello\n", proc)
callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc)
terminal.resolveProcess()
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS + 1_000)
await handlePromise
expect(mockCline.ask).not.toHaveBeenCalled()
expect(mockPushToolResult).toHaveBeenCalled()
})
it("re-anchors a pending ask when execution start is reported after early output", async () => {
vitest.useFakeTimers()
mockCline.ask.mockResolvedValue({ response: "messageResponse", text: "keep going", images: undefined })
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("sleep 60")
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
// Output arrives before the execution-started event (defensive case).
await callbacks.onLine("working...\n", proc)
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS - 2_000)
callbacks.onShellExecutionStarted!(1234, proc)
// The pending ask was rescheduled against the new anchor, so the old
// deadline passing must not fire it.
await vitest.advanceTimersByTimeAsync(2_500)
expect(mockCline.ask).not.toHaveBeenCalled()
// The ask must still fire at the re-anchored deadline — a version
// that cleared the old timer without rescheduling would fail here.
await vitest.advanceTimersByTimeAsync(2_500)
expect(mockCline.ask).toHaveBeenCalledWith("command_output", "")
// Let the command finish so the tool can resolve.
await callbacks.onCompleted!("working...\n", proc)
callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc)
await vitest.advanceTimersByTimeAsync(100)
await handlePromise
expect(mockPushToolResult).toHaveBeenCalled()
})
it("cancels a pending ask when the agent timeout moves the command to the background", async () => {
vitest.useFakeTimers()
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("npm run dev", 2)
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
callbacks.onShellExecutionStarted!(1234, proc)
await callbacks.onLine("server starting...\n", proc)
// Agent timeout (2s) fires before the ask delay (5s).
await vitest.advanceTimersByTimeAsync(2_000)
expect(terminal.proc.continue).toHaveBeenCalled()
expect(mockCline.supersedePendingAsk).toHaveBeenCalled()
// Output after the background transition must never schedule an ask.
await callbacks.onLine("listening...\n", proc)
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS + 1_000)
expect(mockCline.ask).not.toHaveBeenCalled()
await handlePromise
expect(mockPushToolResult).toHaveBeenCalled()
expect(mockPushToolResult.mock.calls[0][0]).toContain("still running")
})
it("falls back to the command dispatch time when execution start is never reported", async () => {
vitest.useFakeTimers()
mockCline.ask.mockResolvedValue({ response: "messageResponse", text: "keep going", images: undefined })
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("sleep 60")
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
// No onShellExecutionStarted: the pre-runCommand anchor applies.
await callbacks.onLine("working...\n", proc)
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS)
expect(mockCline.ask).toHaveBeenCalledWith("command_output", "")
await callbacks.onCompleted!("working...\n", proc)
callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc)
terminal.resolveProcess()
await vitest.advanceTimersByTimeAsync(100)
await handlePromise
expect(mockPushToolResult).toHaveBeenCalled()
})
it("swallows ask errors without failing the command", async () => {
vitest.useFakeTimers()
mockCline.ask.mockRejectedValue(new Error("Current ask promise was ignored"))
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("sleep 60")
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
callbacks.onShellExecutionStarted!(1234, proc)
await callbacks.onLine("working...\n", proc)
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS)
expect(mockCline.ask).toHaveBeenCalledWith("command_output", "")
expect(terminal.proc.continue).not.toHaveBeenCalled()
await callbacks.onCompleted!("working...\n", proc)
callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc)
terminal.resolveProcess()
await vitest.advanceTimersByTimeAsync(100)
await handlePromise
expect(mockPushToolResult).toHaveBeenCalled()
expect(mockPushToolResult.mock.calls[0][0]).toContain("Exit code: 0")
})
it("resolves before completion when the ask is answered without a message", async () => {
// Note: in production only messageResponse answers reach a
// command_output ask (Proceed/Kill route through terminalOperation);
// yesButtonClicked is synthetic here to pin the non-message branch.
vitest.useFakeTimers()
mockCline.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined })
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("sleep 60")
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
callbacks.onShellExecutionStarted!(1234, proc)
await callbacks.onLine("working...\n", proc)
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS)
// Any ask answer backgrounds the command: the process is continued and
// the tool resolves without waiting for the command to complete.
// Note the process promise is never resolved in this test.
await vitest.advanceTimersByTimeAsync(100)
await handlePromise
expect(mockCline.ask).toHaveBeenCalledWith("command_output", "")
expect(terminal.proc.continue).toHaveBeenCalled()
expect(mockPushToolResult).toHaveBeenCalled()
expect(mockPushToolResult.mock.calls[0][0]).toContain("still running")
// Cleanup: let the command finish.
await callbacks.onCompleted!("working...\n", proc)
callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc)
})
it("supersedes a pending ask when the command completes", async () => {
vitest.useFakeTimers()
mockCline.ask.mockReturnValue(new Promise(() => {}))
const terminal = await setupControllableTerminal()
const handlePromise = handleCommand("sleep 5")
await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined())
const callbacks = terminal.callbacks!
const proc = terminal.proc as unknown as RooTerminalProcess
callbacks.onShellExecutionStarted!(1234, proc)
await callbacks.onLine("working...\n", proc)
await vitest.advanceTimersByTimeAsync(executeCommandModule.COMMAND_OUTPUT_ASK_DELAY_MS)
expect(mockCline.ask).toHaveBeenCalledWith("command_output", "")
// The command completes while the ask is still pending.
await callbacks.onCompleted!("working...\n", proc)
callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc)
terminal.resolveProcess()
await vitest.advanceTimersByTimeAsync(100)
await handlePromise
expect(mockCline.supersedePendingAsk).toHaveBeenCalled()
expect(mockPushToolResult).toHaveBeenCalled()
expect(mockPushToolResult.mock.calls[0][0]).toContain("Exit code: 0")
})
})
})