Long-poll command_output on a structured idle flag

The wait loop decided whether to keep waiting by matching the tool's own wording. Split the check into poll_command_output, which returns the text plus an idle flag (still running, nothing new), and loop on that instead. Co-Authored-By: siGit Code v1.5.11 <noreply@sigit.si> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Seto Elkahfi committed Sep 30, 2026 at 07:18 UTC c310815a5834af09a1d02c816634f18e73f250dc
1 file changed +60 -13
src/tools.rs
+60 -13
@@ -2513,9 +2513,28 @@ 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
2517 +}
2518 +
2519 +/// One `command_output` check, with the tool text and whether anything
2520 +/// changed, so the long-poll loop never has to parse its own wording.
2521 +struct CommandPoll {
2522 + text: String,
2523 + /// Still running and nothing new printed since the last check.
2524 + idle: bool,
2525 +}
2526 +
2527 +impl CommandPoll {
2528 + /// A result that ends the wait: an error, or a task that isn't ours.
2529 + fn done(text: String) -> Self {
2530 + Self { text, idle: false }
2531 + }
2532 +}
2533 +
2534 +fn poll_command_output(arguments: &str, owner: Option<&str>) -> CommandPoll {
2535 let task_id = match parse_task_id(arguments) {
2536 Ok(id) => id,
2518 - Err(err) => return err,
2537 + Err(err) => return CommandPoll::done(err),
2538 };
2539
2540 let mut map = lock_tasks();
@@ -2523,7 +2542,7 @@ fn exec_command_output(arguments: &str, owner: Option<&str>) -> String {
2542 .get_mut(&task_id)
2543 .filter(|task| task.owner.as_deref() == owner)
2544 else {
2526 - return unknown_task(task_id);
2545 + return CommandPoll::done(unknown_task(task_id));
2546 };
2547
2548 let was_running = task.exit_code.is_none();
@@ -2543,7 +2562,8 @@ fn exec_command_output(arguments: &str, owner: Option<&str>) -> String {
2562 Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
2563 };
2564
2546 - if new_output.is_empty() {
2565 + let idle = exit_code.is_none() && new_output.is_empty();
2566 + let text = if new_output.is_empty() {
2567 format!(
2568 "{status} No new output since the last check.{}",
2569 dropped_note(dropped)
@@ -2553,7 +2573,8 @@ fn exec_command_output(arguments: &str, owner: Option<&str>) -> String {
2573 "{status} New output since the last check:{}\n{new_output}",
2574 dropped_note(dropped)
2575 )
2556 - }
2576 + };
2577 + CommandPoll { text, idle }
2578 }
2579
2580 /// Long-poll a background task without occupying Tokio's blocking pool while
@@ -2575,17 +2596,15 @@ async fn exec_command_output_wait(arguments: &str) -> String {
2596 loop {
2597 let arguments = arguments.to_owned();
2598 let owner = owner.clone();
2578 - let output =
2579 - tokio::task::spawn_blocking(move || exec_command_output(&arguments, owner.as_deref()))
2599 + let poll =
2600 + tokio::task::spawn_blocking(move || poll_command_output(&arguments, owner.as_deref()))
2601 .await
2581 - .unwrap_or_else(|err| format!("Error: command_output task failed: {err}"));
2602 + .unwrap_or_else(|err| {
2603 + CommandPoll::done(format!("Error: command_output task failed: {err}"))
2604 + });
2605
2583 - if wait_seconds == 0
2584 - || !output.contains(" is still running.")
2585 - || !output.contains(" No new output since the last check.")
2586 - || tokio::time::Instant::now() >= deadline
2587 - {
2588 - return output;
2606 + if wait_seconds == 0 || !poll.idle || tokio::time::Instant::now() >= deadline {
2607 + return poll.text;
2608 }
2609
2610 tokio::time::sleep(POLL_INTERVAL).await;
@@ -3854,6 +3873,34 @@ mod tests {
3873 assert!(poll.contains("done"), "got: {poll}");
3874 }
3875
3876 + #[tokio::test]
3877 + async fn test_command_output_waits_out_a_quiet_running_task() {
3878 + #[cfg(unix)]
3879 + let command = "sleep 30";
3880 + #[cfg(windows)]
3881 + let command = "ping -n 31 127.0.0.1 > nul";
3882 +
3883 + let args = serde_json::json!({
3884 + "command": command,
3885 + "cwd": std::env::temp_dir(),
3886 + "run_in_background": true
3887 + })
3888 + .to_string();
3889 + let task_id = background_task_id(&exec_run_command(&args, None));
3890 +
3891 + let poll_args = serde_json::json!({ "task_id": task_id, "wait_seconds": 1 }).to_string();
3892 + let started = std::time::Instant::now();
3893 + let poll = exec_command_output_wait(&poll_args).await;
3894 +
3895 + assert!(poll.contains("still running"), "got: {poll}");
3896 + assert!(
3897 + started.elapsed() >= std::time::Duration::from_millis(900),
3898 + "an idle task should hold the poll for the whole window, returned after {:?}",
3899 + started.elapsed()
3900 + );
3901 + exec_kill_command(&serde_json::json!({ "task_id": task_id }).to_string(), None);
3902 + }
3903 +
3904 #[test]
3905 fn test_kill_command_stops_background_task() {
3906 #[cfg(unix)]