Keep output printed while command_output is waiting

The wait loop polls every 200 ms and each poll drains the task's buffer, but only the last poll's text is returned. Since the loop stopped returning on new output, anything a task printed before its final poll was thrown away: a command that printed and then exited came back with an empty output section, and a running one reported "No new output" after printing. A poll that isn't the loop's last look now leaves a running task's output in the buffer, so the poll that reports is the one that drains it. The buffer keeps its existing 50000-byte cap. The tests matched "done" against the whole reply, which quotes the command line, so they passed either way. They now check the output section only. The changelog entry also said the wait ends on new output, which is no longer true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Oct 1, 2026 at 11:50 UTC 1d721a8762312852dec80375cec67d209b5d7872
2 files changed +86 -13
CHANGELOG.md
+3 -3
@@ -15,9 +15,9 @@
15 Polling `command_output` on the same task id three times tripped the
16 repeated-tool-call guard, which refused the call and dropped tools for the
17 rest of the turn. `command_output` now long-polls — it waits up to
18 - `wait_seconds` (default 10, max 30) for new output or an exit before
19 - returning — and the repeat guard exempts it, since polling the same task is
20 - the whole point of the tool.
18 + `wait_seconds` (default 10, max 30) for the task to exit, then returns
19 + everything printed in the meantime — and the repeat guard exempts it, since
20 + polling the same task is the whole point of the tool.
21
22 - **A turn that ends with no visible text says why.** When the
23 repeated-tool-call guard blocked a call and the forced no-tools round came
src/tools.rs
+83 -10
@@ -2513,7 +2513,7 @@ fn dropped_note(dropped: bool) -> &'static str {
2513
2514 /// `command_output` tool: output since the last poll + running/exited status.
2515 fn exec_command_output(arguments: &str, owner: Option<&str>) -> String {
2516 - poll_command_output(arguments, owner).text
2516 + poll_command_output(arguments, owner, false).text
2517 }
2518
2519 /// One `command_output` check, with the tool text and whether the task is
@@ -2534,7 +2534,14 @@ impl CommandPoll {
2534 }
2535 }
2536
2537 -fn poll_command_output(arguments: &str, owner: Option<&str>) -> CommandPoll {
2537 +/// `hold_while_running` is the long-poll's "not my last look" flag: a task
2538 +/// that is still running keeps its output buffered and the returned text is
2539 +/// empty, so the call that finally reports is the one that drains it.
2540 +fn poll_command_output(
2541 + arguments: &str,
2542 + owner: Option<&str>,
2543 + hold_while_running: bool,
2544 +) -> CommandPoll {
2545 let task_id = match parse_task_id(arguments) {
2546 Ok(id) => id,
2547 Err(err) => return CommandPoll::done(err),
@@ -2550,6 +2557,12 @@ fn poll_command_output(arguments: &str, owner: Option<&str>) -> CommandPoll {
2557
2558 let was_running = task.exit_code.is_none();
2559 let exit_code = poll_exit_code(task);
2560 + if hold_while_running && exit_code.is_none() {
2561 + return CommandPoll {
2562 + text: String::new(),
2563 + running: true,
2564 + };
2565 + }
2566 if was_running && exit_code.is_some() {
2567 // The child just exited; give the reader threads a moment to flush
2568 // the final output through the pipes before draining the buffer.
@@ -2598,14 +2611,18 @@ async fn exec_command_output_wait(arguments: &str, owner: Option<String>) -> Str
2611 loop {
2612 let arguments = arguments.to_owned();
2613 let owner = owner.clone();
2601 - let poll =
2602 - tokio::task::spawn_blocking(move || poll_command_output(&arguments, owner.as_deref()))
2603 - .await
2604 - .unwrap_or_else(|err| {
2605 - CommandPoll::done(format!("Error: command_output task failed: {err}"))
2606 - });
2614 + // Every poll drains the task's buffer, so only the last look may take
2615 + // the output of a running task; an earlier one would throw it away.
2616 + let last_look = wait_seconds == 0 || tokio::time::Instant::now() >= deadline;
2617 + let poll = tokio::task::spawn_blocking(move || {
2618 + poll_command_output(&arguments, owner.as_deref(), !last_look)
2619 + })
2620 + .await
2621 + .unwrap_or_else(|err| {
2622 + CommandPoll::done(format!("Error: command_output task failed: {err}"))
2623 + });
2624
2608 - if wait_seconds == 0 || !poll.running || tokio::time::Instant::now() >= deadline {
2625 + if last_look || !poll.running {
2626 return poll.text;
2627 }
2628
@@ -3791,6 +3808,14 @@ mod tests {
3808 }
3809 }
3810
3811 + /// The output part of a `command_output` reply. The status line quotes
3812 + /// the command, so matching the whole reply finds "echo done" whether or
3813 + /// not `done` was ever printed.
3814 + fn polled_output(poll: &str) -> &str {
3815 + poll.split_once("New output since the last check:")
3816 + .map_or("", |(_, output)| output)
3817 + }
3818 +
3819 /// Wait (bounded) for a background task's child to exit.
3820 fn wait_for_background_exit(task_id: u64) {
3821 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
@@ -3872,7 +3897,55 @@ mod tests {
3897 let poll = exec_command_output_wait(&poll_args, None).await;
3898
3899 assert!(poll.contains("exited with code 0"), "got: {poll}");
3875 - assert!(poll.contains("done"), "got: {poll}");
3900 + assert!(polled_output(&poll).contains("done"), "got: {poll}");
3901 + }
3902 +
3903 + #[tokio::test]
3904 + async fn test_command_output_wait_keeps_output_printed_before_exit() {
3905 + #[cfg(unix)]
3906 + let command = "echo start; sleep 1; echo done";
3907 + #[cfg(windows)]
3908 + let command = "echo start&& ping -n 2 127.0.0.1 > nul&& echo done";
3909 +
3910 + let args = serde_json::json!({
3911 + "command": command,
3912 + "cwd": std::env::temp_dir(),
3913 + "run_in_background": true
3914 + })
3915 + .to_string();
3916 + let task_id = background_task_id(&exec_run_command(&args, None));
3917 +
3918 + let poll_args = serde_json::json!({ "task_id": task_id, "wait_seconds": 5 }).to_string();
3919 + let poll = exec_command_output_wait(&poll_args, None).await;
3920 +
3921 + // "start" is printed well before the exit the wait returns on; the
3922 + // polls in between must not have drained it away.
3923 + assert!(poll.contains("exited with code 0"), "got: {poll}");
3924 + assert!(polled_output(&poll).contains("start"), "got: {poll}");
3925 + assert!(polled_output(&poll).contains("done"), "got: {poll}");
3926 + }
3927 +
3928 + #[tokio::test]
3929 + async fn test_command_output_wait_reports_output_of_a_running_task() {
3930 + #[cfg(unix)]
3931 + let command = "echo start; sleep 30";
3932 + #[cfg(windows)]
3933 + let command = "echo start&& ping -n 31 127.0.0.1 > nul";
3934 +
3935 + let args = serde_json::json!({
3936 + "command": command,
3937 + "cwd": std::env::temp_dir(),
3938 + "run_in_background": true
3939 + })
3940 + .to_string();
3941 + let task_id = background_task_id(&exec_run_command(&args, None));
3942 +
3943 + let poll_args = serde_json::json!({ "task_id": task_id, "wait_seconds": 1 }).to_string();
3944 + let poll = exec_command_output_wait(&poll_args, None).await;
3945 +
3946 + assert!(poll.contains("still running"), "got: {poll}");
3947 + assert!(polled_output(&poll).contains("start"), "got: {poll}");
3948 + exec_kill_command(&serde_json::json!({ "task_id": task_id }).to_string(), None);
3949 }
3950
3951 #[tokio::test]