Skip to content

Commit 2c987fc

Browse files
[Fix] Zoo prompts about command output when short foreground commands emit output (#1043)
* fix(terminal): delay command_output ask for short foreground commands The command_output ask fired on the first output chunk of any foreground command, prompting users even for commands about to complete. Schedule the ask instead so it only fires when the command is still running after a 5s delay, preserving the interrupt/feedback path for long-running commands while letting short commands finish without prompting. Closes #1042 * fix(terminal): anchor command_output ask delay to execution start Anchor the ask-delay timer to onShellExecutionStarted (falling back to the pre-runCommand timestamp) so shell-integration startup on cold terminals does not consume the grace period, and expand ask-policy tests to cover the agent-timeout cancel, re-anchor reschedule, ask error handling, and non-message responses to restore patch coverage. * test(terminal): cover fallback anchor for command_output ask delay * fix(terminal): clean up pending command_output ask on completion - Supersede a still-pending command_output ask in onCompleted so it resolves immediately instead of lingering until the next interactive message, and clear the Proceed/Kill controls in the webview when the final non-partial command_output say arrives. - Continue the process for any ask answer, not just typed messages, so a non-message response actually backgrounds the command and the tool resolves before the process completes. - Strengthen ask-policy tests: assert the rescheduled ask still fires at the re-anchored deadline, assert early resolution for non-message answers, and cover ask supersession on completion. * test(terminal): note synthetic yesButtonClicked in non-message ask test --------- Co-authored-by: Roomote <roomote@roomote.dev>
1 parent ded7575 commit 2c987fc

4 files changed

Lines changed: 500 additions & 19 deletions

File tree

src/core/tools/ExecuteCommandTool.ts

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ export function canRetryShellIntegrationError(error: unknown): error is ShellInt
3535
return error instanceof ShellIntegrationError && !error.commandSubmitted
3636
}
3737

38+
/**
39+
* Grace period before a foreground command may trigger a `command_output` ask.
40+
* Short commands that emit output and exit within this window never prompt the
41+
* user; the ask only fires when the command is still running once the delay
42+
* elapses, so users can still interrupt or provide feedback on long-running
43+
* commands.
44+
*/
45+
export const COMMAND_OUTPUT_ASK_DELAY_MS = 5_000
46+
3847
export function getTerminalProviderForExecution(terminalShellIntegrationDisabled: boolean): {
3948
terminalProvider: RooTerminalProvider
4049
isCmdExeFallback: boolean
@@ -340,6 +349,58 @@ export async function executeCommandInTerminal(
340349
resolveOnCompleted = resolve
341350
})
342351

352+
// Delay the `command_output` ask so short foreground commands that emit
353+
// output and exit normally never prompt the user. The ask only fires if the
354+
// command is still running once COMMAND_OUTPUT_ASK_DELAY_MS has elapsed
355+
// since execution started, preserving the interrupt/feedback path for
356+
// long-running commands. The anchor is re-based to onShellExecutionStarted
357+
// (falling back to the pre-runCommand timestamp when that event never
358+
// fires) so shell-integration startup on cold terminals does not consume
359+
// the grace period.
360+
let commandStartedAt = 0
361+
let commandOutputAskTimer: NodeJS.Timeout | undefined
362+
363+
const askForCommandOutput = async (process: RooTerminalProcess): Promise<void> => {
364+
if (runInBackground || hasAskedForCommandOutput || completed) {
365+
return
366+
}
367+
368+
// Mark that we've asked to prevent multiple concurrent asks
369+
hasAskedForCommandOutput = true
370+
371+
try {
372+
const { response, text, images } = await task.ask("command_output", "")
373+
runInBackground = true
374+
375+
if (response === "messageResponse") {
376+
message = { text, images }
377+
}
378+
379+
// Any answer means the command should keep running in the background;
380+
// continue the process so the tool resolves now instead of blocking
381+
// until the command actually completes.
382+
process.continue()
383+
} catch (_error) {
384+
// Silently handle ask errors (e.g., "Current ask promise was ignored")
385+
}
386+
}
387+
388+
const scheduleCommandOutputAsk = (process: RooTerminalProcess): void => {
389+
if (runInBackground || hasAskedForCommandOutput || completed || commandOutputAskTimer) {
390+
return
391+
}
392+
393+
const remainingDelay = COMMAND_OUTPUT_ASK_DELAY_MS - (Date.now() - commandStartedAt)
394+
395+
commandOutputAskTimer = setTimeout(
396+
() => {
397+
commandOutputAskTimer = undefined
398+
void askForCommandOutput(process)
399+
},
400+
Math.max(remainingDelay, 0),
401+
)
402+
}
403+
343404
const callbacks: RooTerminalCallbacks = {
344405
onLine: async (lines: string, process: RooTerminalProcess) => {
345406
accumulatedOutput += lines
@@ -359,26 +420,19 @@ export async function executeCommandInTerminal(
359420
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
360421
schedulePartialCommandOutputUpdate()
361422

362-
if (runInBackground || hasAskedForCommandOutput) {
363-
return
364-
}
365-
366-
// Mark that we've asked to prevent multiple concurrent asks
367-
hasAskedForCommandOutput = true
368-
369-
try {
370-
const { response, text, images } = await task.ask("command_output", "")
371-
runInBackground = true
372-
373-
if (response === "messageResponse") {
374-
message = { text, images }
375-
process.continue()
376-
}
377-
} catch (_error) {
378-
// Silently handle ask errors (e.g., "Current ask promise was ignored")
379-
}
423+
scheduleCommandOutputAsk(process)
380424
},
381425
onCompleted: async (output: string | undefined) => {
426+
clearTimeout(commandOutputAskTimer)
427+
commandOutputAskTimer = undefined
428+
429+
// If an interactive command_output ask is still pending, supersede it
430+
// so it resolves immediately instead of lingering until the next
431+
// interactive message bumps lastMessageTs.
432+
if (hasAskedForCommandOutput && !runInBackground) {
433+
task.supersedePendingAsk()
434+
}
435+
382436
clearTimeout(pendingCommandOutputEmitTimer)
383437
pendingCommandOutputEmitTimer = undefined
384438

@@ -412,9 +466,21 @@ export async function executeCommandInTerminal(
412466
console.error("[ExecuteCommandTool] Failed to flush final command_output:", error)
413467
})
414468
},
415-
onShellExecutionStarted: (pid: number | undefined) => {
469+
onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => {
416470
const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
417471
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
472+
473+
// Re-anchor the ask delay to actual execution start so the shell
474+
// integration startup wait does not count against the grace period.
475+
commandStartedAt = Date.now()
476+
477+
// Output should not precede this event, but if it did, reschedule
478+
// the pending ask against the corrected anchor.
479+
if (commandOutputAskTimer) {
480+
clearTimeout(commandOutputAskTimer)
481+
commandOutputAskTimer = undefined
482+
scheduleCommandOutputAsk(process)
483+
}
418484
},
419485
onShellExecutionComplete: (details: ExitCodeDetails) => {
420486
const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode }
@@ -441,6 +507,8 @@ export async function executeCommandInTerminal(
441507
workingDir = terminal.getCurrentWorkingDirectory()
442508
}
443509

510+
// Fallback anchor for providers that never fire onShellExecutionStarted.
511+
commandStartedAt = Date.now()
444512
const process = terminal.runCommand(command, callbacks)
445513
task.terminalProcess = process
446514

@@ -462,6 +530,8 @@ export async function executeCommandInTerminal(
462530
new Promise<void>((resolve) => {
463531
agentTimeoutId = setTimeout(() => {
464532
runInBackground = true
533+
clearTimeout(commandOutputAskTimer)
534+
commandOutputAskTimer = undefined
465535
process.continue()
466536
task.supersedePendingAsk()
467537
resolve()
@@ -501,6 +571,7 @@ export async function executeCommandInTerminal(
501571
} finally {
502572
clearTimeout(agentTimeoutId)
503573
clearTimeout(userTimeoutId)
574+
clearTimeout(commandOutputAskTimer)
504575
clearTimeout(pendingCommandOutputEmitTimer)
505576
task.terminalProcess = undefined
506577
}

0 commit comments

Comments
 (0)