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)]