From 7201f522e7aa7b25fa558a7cb65ac10c9be2026b Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:07:54 +0100 Subject: [PATCH] Wait for a background task with monitor_command command_output only takes a quick look, so waiting for a build, a test run or a release meant calling it over and over: one card per call, and each call spent a turn's tool round on a poll. monitor_command covers the whole wait in a single call. It follows a background task until the task exits, a line of new output matches an optional "until" pattern, or the timeout passes (300s by default, 600s at most), and reports the exit code, whether the task is still running, and the output gathered while watching. The task keeps running when the watch ends first, and a cancelled turn ends the watch too. In an editor the call is one live-updating card: tools.rs reports progress through the new ToolProgress seam, and main.rs sends it as a tool_call_update that replaces the card's content. The TUI and headless ignore it. The tool is read-only for permissions (it watches, it does not change anything) and is exempt from the repeated-tool-call guard, since waiting is the point. Cards for command_output, monitor_command and kill_command now name the task and its command. Co-Authored-By: siGit Code v1.7.5-acp --- .agents/AGENTS.md | 8 +- CHANGELOG.md | 16 ++ crates/cli/src/chat.rs | 18 ++ crates/cli/src/main.rs | 30 ++- crates/cli/src/permissions.rs | 4 +- crates/cli/src/tools.rs | 361 +++++++++++++++++++++++++++- crates/cli/tests/acp_permissions.rs | 72 ++++++ 7 files changed, 492 insertions(+), 17 deletions(-) diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 1822baf..1bfb55b 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -236,8 +236,12 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b `~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device. - **`crates/cli/src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`, `list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`, - `multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the - spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces + `multi_edit`, `delete_file`, `run_command`, `command_output`, `kill_command`, `monitor_command`, + `write_todos`, `remember`. Add a tool in both the + spec list (`all_tools`) and the execute `match` (`execute_tool`). `monitor_command` waits on a background task in one call and reports what it has so far + through the `ToolProgress` seam (`register_tool_progress`; `main.rs` registers `AcpToolProgress`, + which sends a `tool_call_update` replacing the card's content; a no-op in the TUI and headless). + `run_command` also enforces commit attribution: when a command creates a new commit that lacks the `Co-Authored-By: siGit Code` trailer (`commit_co_author_trailer()`, which reads `siGit Code v-` from the surface `main` sets via `set_surface`), it amends the trailer in — diff --git a/CHANGELOG.md b/CHANGELOG.md index 7538eaa..3cd47ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +### Added + +- **`monitor_command` follows a background task in one tool call.** It waits + until the task exits, a line of new output matches an optional `until` + pattern, or a timeout passes (300 s by default, 600 s at most). In an + editor it is a single card that updates with the task's output as it + arrives, instead of a column of `command_output` cards that also used up + the turn's tool rounds (#253). + +### Changed + +- Cards for `command_output`, `monitor_command` and `kill_command` name the + task and its command. + ## 1.7.5 A new siGit Code Cloud tier, and two fixes for signing in and switching diff --git a/crates/cli/src/chat.rs b/crates/cli/src/chat.rs index 4246f92..30a7306 100644 --- a/crates/cli/src/chat.rs +++ b/crates/cli/src/chat.rs @@ -167,6 +167,13 @@ pub(crate) fn tool_title(name: &str, arguments_json: &str) -> String { str_arg("path") } "search_files" | "glob" => str_arg("pattern"), + "command_output" | "monitor_command" | "kill_command" => { + let id = args.as_ref().and_then(|a| a.get("task_id")?.as_u64()); + id.map(|id| match crate::tools::background_task_command(id) { + Some(command) => format!("task {id} (`{command}`)"), + None => format!("task {id}"), + }) + } _ => None, }; (name.to_string(), summary) @@ -4145,6 +4152,17 @@ mod tests { ); } + #[test] + fn tool_title_task_tools_name_the_task() { + for name in ["command_output", "monitor_command", "kill_command"] { + assert_eq!( + tool_title(name, r#"{"task_id":999999}"#), + format!("{name} · task 999999") + ); + } + assert_eq!(tool_title("monitor_command", "{}"), "monitor_command"); + } + #[test] fn tool_title_file_tools_show_the_path() { for name in [ diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 34e82d3..03c4c6b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -377,7 +377,7 @@ fn tool_kind_for(tool_name: &str) -> ToolKind { ToolKind::Edit } "delete_file" => ToolKind::Delete, - "run_command" | "kill_command" => ToolKind::Execute, + "run_command" | "kill_command" | "monitor_command" => ToolKind::Execute, "read_file" | "list_directory" | "command_output" => ToolKind::Read, "search_files" | "glob" | "web_search" => ToolKind::Search, "read_website" => ToolKind::Fetch, @@ -1007,6 +1007,26 @@ impl client_fs::ClientFileSystem for AcpClientFs { } } +/// Live tool-card updates over the ACP connection (see `tools::ToolProgress`). +struct AcpToolProgress { + cx: ConnectionTo, +} + +impl tools::ToolProgress for AcpToolProgress { + fn update(&self, session_id: &str, tool_call_id: &str, text: &str) { + let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( + tool_call_id.to_string(), + ToolCallUpdateFields::new().content(vec![tool_output_content(text)]), + )); + if let Err(error) = self + .cx + .send_notification(SessionNotification::new(session_id.to_string(), update)) + { + log::warn!("could not update tool call {tool_call_id}: {error}"); + } + } +} + /// The editor's terminal, reached over the ACP connection. Registered with /// `client_terminal` at `initialize` when the client advertises `terminal`. struct AcpClientTerminal { @@ -2216,6 +2236,8 @@ impl SiGitAgent { client_terminal::register(Arc::new(AcpClientTerminal { cx: cx.clone() }), terminal); } + tools::register_tool_progress(Arc::new(AcpToolProgress { cx: cx.clone() })); + // URL-mode elicitation is where `authenticate` sends the user to sign // in. Form mode is never used: the spec forbids collecting // credentials in a form, and nothing else here asks the user for @@ -3737,10 +3759,12 @@ impl SiGitAgent { .and_modify(|count| *count += 1) .or_insert(1); // Repeated status checks are expected for a live background - // command. command_output long-polls and the overall round cap + // command. command_output and monitor_command wait, and the overall round cap // still bounds a confused model without cutting off a real // build or release while it is in progress. - let repeated = *repeat_count >= 3 && tc.name != "command_output"; + let repeated = *repeat_count >= 3 + && tc.name != "command_output" + && tc.name != "monitor_command"; if repeated { force_text = true; stopped_repeating = Some(tc.name.clone()); diff --git a/crates/cli/src/permissions.rs b/crates/cli/src/permissions.rs index b3bc92d..7c64dec 100644 --- a/crates/cli/src/permissions.rs +++ b/crates/cli/src/permissions.rs @@ -117,7 +117,8 @@ pub enum Decision { pub fn classify(tool_name: &str) -> ToolRisk { match tool_name { "read_file" | "list_directory" | "search_files" | "glob" | "read_website" - | "write_todos" | "skill" | "task" | "command_output" | "web_search" => ToolRisk::ReadOnly, + | "write_todos" | "skill" | "task" | "command_output" | "monitor_command" + | "web_search" => ToolRisk::ReadOnly, _ => { if let Some(bare) = crate::mcp::official_tool_suffix(tool_name) && (bare.starts_with("list_") @@ -544,6 +545,7 @@ mod tests { "skill", "task", "command_output", + "monitor_command", "web_search", ] { assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}"); diff --git a/crates/cli/src/tools.rs b/crates/cli/src/tools.rs index c0555ce..6110bd0 100644 --- a/crates/cli/src/tools.rs +++ b/crates/cli/src/tools.rs @@ -254,7 +254,7 @@ pub fn all_tools() -> Vec { }, "run_in_background": { "type": "boolean", - "description": "Start the command as a background task and return a task id immediately instead of waiting for it to finish. Set this to true for servers, watchers, builds, test suites, and anything that may run longer than two minutes (foreground commands are killed after 120 seconds). Poll the task's output and status with command_output, and stop it with kill_command. Background tasks are killed when sigit exits. Defaults to false." + "description": "Start the command as a background task and return a task id immediately instead of waiting for it to finish. Set this to true for servers, watchers, builds, test suites, and anything that may run longer than two minutes (foreground commands are killed after 120 seconds). Wait for it with monitor_command (or take a quick look with command_output), and stop it with kill_command. Background tasks are killed when sigit exits. Defaults to false." } }, "required": ["command"], @@ -432,6 +432,39 @@ pub fn all_tools() -> Vec { "additionalProperties": false }), }, + AgentTool { + name: "monitor_command", + description: "Follow a background task (started with run_command's \ + run_in_background) until it stops: it exits, a line of new output \ + matches `until`, or `timeout_seconds` pass. One call covers the whole \ + wait and shows up as a single live-updating card, so use this to wait \ + for a build, test run or release instead of calling command_output \ + over and over. Reports the exit code (or that the task is still \ + running) and the output gathered while watching. The task keeps \ + running if the watch ends first. Use command_output only for a quick \ + look at a server or watcher.", + parameters_schema: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "integer", + "description": "Id of the background task, as returned by run_command with run_in_background." + }, + "until": { + "type": "string", + "description": "Optional regular expression. The watch ends as soon as a line of new output matches it, e.g. \"Finished|error\\\\[\"." + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 600, + "description": "Longest the watch may last. Defaults to 300." + } + }, + "required": ["task_id"], + "additionalProperties": false + }), + }, ] } @@ -525,6 +558,9 @@ async fn execute_tool_impl(name: &str, arguments: &str, tool_call_id: Option<&st "command_output" => exec_command_output_wait(arguments, active_session()) .await .into(), + "monitor_command" => exec_monitor_command(arguments, active_session(), tool_call_id) + .await + .into(), // Tools discovered from MCP servers are namespaced `mcp____` // and forwarded to the owning server. _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await.into(), @@ -3045,9 +3081,9 @@ fn start_background_task(command_str: &str, cwd_path: &Path, owner: Option) -> String { poll_command_output(arguments, owner, false).text } +/// The sentence both `command_output` and `monitor_command` open with. +fn task_status_line(task_id: u64, task: &BackgroundTask, exit_code: Option) -> String { + let command = &task.command; + match exit_code { + None => format!("Task {task_id} (`{command}`) is still running."), + Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."), + Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."), + } +} + +/// The command of `owner`'s background task `task_id`, for a card title. +pub fn background_task_command(task_id: u64) -> Option { + let owner = active_session(); + lock_tasks() + .get(&task_id) + .filter(|task| task.owner == owner) + .map(|task| task.command.clone()) +} + /// One `command_output` check, with the tool text and whether the task is /// still running, so the long-poll loop knows when to keep waiting. struct CommandPoll { @@ -3161,12 +3216,7 @@ fn poll_command_output( let (new_output, dropped) = drain_task_output(task); - let command = &task.command; - let status = match exit_code { - None => format!("Task {task_id} (`{command}`) is still running."), - Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."), - Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."), - }; + let status = task_status_line(task_id, task, exit_code); let running = exit_code.is_none(); let text = if new_output.is_empty() { @@ -3220,6 +3270,209 @@ async fn exec_command_output_wait(arguments: &str, owner: Option) -> Str } } +// ── monitor_command ────────────────────────────────────────────────────────── + +/// Where a long-running tool reports what it has so far, so the client's card +/// for the call can update before the call returns. The seam has `client_fs`'s +/// shape: `main.rs` registers an ACP implementation at `initialize`, and with +/// nothing registered (TUI, headless) updates go nowhere. +pub trait ToolProgress: Send + Sync { + /// Replace the content of tool call `tool_call_id` with `text`. + fn update(&self, session_id: &str, tool_call_id: &str, text: &str); +} + +static TOOL_PROGRESS: RwLock>> = RwLock::new(None); + +pub fn register_tool_progress(progress: Arc) { + if let Ok(mut guard) = TOOL_PROGRESS.write() { + *guard = Some(progress); + } +} + +fn report_progress(session: Option<&str>, tool_call_id: Option<&str>, text: &str) { + let (Some(session), Some(tool_call_id)) = (session, tool_call_id) else { + return; + }; + let progress = TOOL_PROGRESS.read().ok().and_then(|guard| guard.clone()); + if let Some(progress) = progress { + progress.update(session, tool_call_id, text); + } +} + +/// One look at a task for `monitor_command`. +struct MonitorStep { + status: String, + exit_code: Option, + output: String, + dropped: bool, +} + +fn monitor_step(task_id: u64, owner: Option<&str>) -> Option { + let mut map = lock_tasks(); + let task = map + .get_mut(&task_id) + .filter(|task| task.owner.as_deref() == owner)?; + let was_running = task.exit_code.is_none(); + let exit_code = poll_exit_code(task); + if was_running && exit_code.is_some() { + // Let the reader threads flush the final output (see poll_command_output). + std::thread::sleep(std::time::Duration::from_millis(100)); + } + let (output, dropped) = drain_task_output(task); + Some(MonitorStep { + status: task_status_line(task_id, task, exit_code), + exit_code, + output, + dropped, + }) +} + +/// Keep the tail of `buf` within COMMAND_OUTPUT_LIMIT, like the reader threads. +fn cap_tail(buf: &mut String) -> bool { + if buf.len() <= COMMAND_OUTPUT_LIMIT { + return false; + } + let mut cut = buf.len() - COMMAND_OUTPUT_LIMIT; + while !buf.is_char_boundary(cut) { + cut += 1; + } + buf.drain(..cut); + true +} + +/// `monitor_command` tool: follow a background task to a stopping point. +async fn exec_monitor_command( + arguments: &str, + owner: Option, + tool_call_id: Option<&str>, +) -> String { + const DEFAULT_TIMEOUT_SECONDS: u64 = 300; + const MAX_TIMEOUT_SECONDS: u64 = 600; + const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200); + const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); + + let task_id = match parse_task_id(arguments) { + Ok(id) => id, + Err(err) => return err, + }; + let args: Value = serde_json::from_str(arguments).unwrap_or(Value::Null); + let until_source = args + .get("until") + .and_then(Value::as_str) + .filter(|pattern| !pattern.is_empty()); + let until = match until_source.map(regex::Regex::new).transpose() { + Ok(re) => re, + Err(err) => return format!("Error: invalid \"until\" pattern: {err}"), + }; + let timeout_seconds = args + .get("timeout_seconds") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_TIMEOUT_SECONDS) + .clamp(1, MAX_TIMEOUT_SECONDS); + + let started = tokio::time::Instant::now(); + let deadline = started + std::time::Duration::from_secs(timeout_seconds); + let signal = owner.as_deref().map(abort_signal); + + let mut gathered = String::new(); + let mut dropped = false; + // The unterminated last line so far, so a match is not missed when the + // line arrives in two chunks. + let mut partial = String::new(); + let mut last_report = started; + let mut reported_len = 0; + + enum Stop { + Exited(String), + Matched, + Timeout, + Aborted, + } + + let (status, stop) = loop { + let step_owner = owner.clone(); + let step = + tokio::task::spawn_blocking(move || monitor_step(task_id, step_owner.as_deref())).await; + let step = match step { + Ok(Some(step)) => step, + Ok(None) => return unknown_task(task_id), + Err(err) => return format!("Error: monitor_command task failed: {err}"), + }; + + dropped |= step.dropped; + let mut matched = false; + if !step.output.is_empty() { + gathered.push_str(&step.output); + dropped |= cap_tail(&mut gathered); + if let Some(re) = &until { + let scan = format!("{partial}{}", step.output); + matched = scan.split('\n').any(|line| re.is_match(line)); + partial = if scan.ends_with('\n') { + String::new() + } else { + scan.rsplit('\n').next().unwrap_or_default().to_string() + }; + } + } + + if step.exit_code.is_some() { + break (step.status.clone(), Stop::Exited(step.status)); + } + if matched { + break (step.status, Stop::Matched); + } + let now = tokio::time::Instant::now(); + if now >= deadline { + break (step.status, Stop::Timeout); + } + if signal.as_ref().is_some_and(|signal| signal.is_aborted()) { + break (step.status, Stop::Aborted); + } + + if now.duration_since(last_report) >= PROGRESS_INTERVAL && gathered.len() != reported_len { + last_report = now; + reported_len = gathered.len(); + let text = format!( + "{} Watching for {}s.\n{gathered}", + step.status, + now.duration_since(started).as_secs() + ); + report_progress(owner.as_deref(), tool_call_id, &text); + } + + match &signal { + Some(signal) => { + tokio::select! { + _ = tokio::time::sleep(POLL_INTERVAL) => {} + _ = signal.aborted() => {} + } + } + None => tokio::time::sleep(POLL_INTERVAL).await, + } + }; + + let headline = match stop { + Stop::Exited(status) => status, + Stop::Matched => format!( + "{} Stopped watching: a line matched \"{}\".", + status, + until_source.unwrap_or_default() + ), + Stop::Timeout => { + format!("{status} Stopped watching after {timeout_seconds}s; the task keeps running.") + } + Stop::Aborted => format!( + "{status} Stopped watching because the turn was cancelled; the task keeps running." + ), + }; + let note = dropped_note(dropped); + if gathered.is_empty() { + format!("{headline} No output while watching.{note}") + } else { + format!("{headline} Output while watching:{note}\n{gathered}") + } +} + /// Stop and forget every background task `owner` started. Called when its /// session closes: nobody is left who could poll or kill them. Returns how /// many were still running. @@ -3418,7 +3671,7 @@ mod tests { #[test] fn test_all_tools_count() { let tools = all_tools(); - assert_eq!(tools.len(), 15); + assert_eq!(tools.len(), 16); assert_eq!(tools[0].name, "read_file"); assert_eq!(tools[1].name, "create_directory"); assert_eq!(tools[2].name, "list_directory"); @@ -3434,6 +3687,7 @@ mod tests { assert_eq!(tools[12].name, "remember"); assert_eq!(tools[13].name, "command_output"); assert_eq!(tools[14].name, "kill_command"); + assert_eq!(tools[15].name, "monitor_command"); } #[test] @@ -4774,6 +5028,91 @@ mod tests { exec_kill_command(&serde_json::json!({ "task_id": task_id }).to_string(), None); } + fn start_test_task(command: &str, owner: Option<&str>) -> u64 { + let args = serde_json::json!({ + "command": command, + "cwd": std::env::temp_dir(), + "run_in_background": true + }) + .to_string(); + background_task_id(&exec_run_command(&args, owner)) + } + + #[cfg(unix)] + const SLOW_TAIL: &str = "sleep 30"; + #[cfg(windows)] + const SLOW_TAIL: &str = "ping -n 31 127.0.0.1 > nul"; + + fn kill_test_task(task_id: u64, owner: Option<&str>) { + exec_kill_command( + &serde_json::json!({ "task_id": task_id }).to_string(), + owner, + ); + } + + #[tokio::test] + async fn test_monitor_command_returns_on_exit_with_output() { + let task_id = start_test_task("echo monitored", None); + let args = serde_json::json!({ "task_id": task_id }).to_string(); + let result = exec_monitor_command(&args, None, None).await; + assert!(result.contains("exited with code 0"), "got: {result}"); + assert!(result.contains("monitored"), "got: {result}"); + } + + #[tokio::test] + async fn test_monitor_command_stops_when_until_matches() { + let task_id = start_test_task(&format!("echo build-ready && {SLOW_TAIL}"), None); + let args = + serde_json::json!({ "task_id": task_id, "until": "build-(ready|failed)" }).to_string(); + let started = std::time::Instant::now(); + let result = exec_monitor_command(&args, None, None).await; + assert!(started.elapsed().as_secs() < 20, "waited for the task"); + assert!(result.contains("still running"), "got: {result}"); + assert!(result.contains("a line matched"), "got: {result}"); + assert!(result.contains("build-ready"), "got: {result}"); + kill_test_task(task_id, None); + } + + #[tokio::test] + async fn test_monitor_command_times_out_on_a_quiet_task() { + let task_id = start_test_task(SLOW_TAIL, None); + let args = serde_json::json!({ "task_id": task_id, "timeout_seconds": 1 }).to_string(); + let result = exec_monitor_command(&args, None, None).await; + assert!(result.contains("still running"), "got: {result}"); + assert!(result.contains("after 1s"), "got: {result}"); + kill_test_task(task_id, None); + } + + #[tokio::test] + async fn test_monitor_command_ends_when_the_turn_is_aborted() { + let owner = "monitor-abort-test"; + reset_abort(owner); + let task_id = start_test_task(SLOW_TAIL, Some(owner)); + let args = serde_json::json!({ "task_id": task_id }).to_string(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + abort_session_tools(owner); + }); + let started = std::time::Instant::now(); + let result = exec_monitor_command(&args, Some(owner.to_string()), None).await; + assert!(started.elapsed().as_secs() < 20, "ignored the abort"); + assert!(result.contains("turn was cancelled"), "got: {result}"); + kill_test_task(task_id, Some(owner)); + forget_abort(owner); + } + + #[tokio::test] + async fn test_monitor_command_rejects_other_owners_and_bad_input() { + let task_id = start_test_task(SLOW_TAIL, Some("monitor-owner-a")); + let args = serde_json::json!({ "task_id": task_id }).to_string(); + let result = exec_monitor_command(&args, Some("monitor-owner-b".into()), None).await; + assert!(result.contains("no background task"), "got: {result}"); + let bad = serde_json::json!({ "task_id": task_id, "until": "(" }).to_string(); + let result = exec_monitor_command(&bad, Some("monitor-owner-a".into()), None).await; + assert!(result.contains("invalid \"until\""), "got: {result}"); + kill_test_task(task_id, Some("monitor-owner-a")); + } + #[test] fn test_kill_command_stops_background_task() { #[cfg(unix)] diff --git a/crates/cli/tests/acp_permissions.rs b/crates/cli/tests/acp_permissions.rs index 5a3849a..4136807 100644 --- a/crates/cli/tests/acp_permissions.rs +++ b/crates/cli/tests/acp_permissions.rs @@ -1889,6 +1889,78 @@ fn repeated_command_output_calls_remain_available() { let _ = std::fs::remove_dir_all(&scratch); } +#[test] +fn monitor_command_is_one_execute_card_and_exempt_from_the_repeat_guard() { + let monitor_arguments = json!({"task_id": 1}).to_string(); + let endpoint = start_fake_endpoint(vec![ + sse_tool_call( + "call_start", + "run_command", + &json!({"command": "echo built", "run_in_background": true}).to_string(), + ), + sse_tool_call("call_1", "monitor_command", &monitor_arguments), + sse_tool_call("call_2", "monitor_command", &monitor_arguments), + sse_tool_call("call_3", "monitor_command", &monitor_arguments), + sse_text("The build finished."), + ]); + + let scratch = std::env::temp_dir().join(format!("sigit_acp_monitor_{}", std::process::id())); + let config_dir = scratch.join("config"); + let cwd = scratch.join("cwd"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let mut agent = spawn_agent_with_permissions(endpoint.port, &config_dir, Some("allow")); + let id = agent.request( + "initialize", + json!({"protocolVersion": 1, "clientCapabilities": {}}), + ); + agent.wait_for_response(id); + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []})); + let session_id = agent.wait_for_response(id)["result"]["sessionId"] + .as_str() + .expect("session id") + .to_string(); + + let prompt_id = agent.request( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{"type": "text", "text": "build and wait"}], + }), + ); + let (response, updates) = agent.wait_for_response_with_updates(prompt_id); + assert_eq!(response["result"]["stopReason"], "end_turn"); + + let card = updates + .iter() + .find(|update| update["sessionUpdate"] == "tool_call" && update["toolCallId"] == "call_3") + .unwrap_or_else(|| panic!("call_3 is announced: {updates:#?}")); + assert_eq!(card["kind"], "execute"); + let final_status = updates + .iter() + .rev() + .find(|update| { + update["sessionUpdate"] == "tool_call_update" + && update["toolCallId"] == "call_3" + && !update["status"].is_null() + }) + .unwrap_or_else(|| panic!("call_3 is closed out: {updates:#?}"))["status"] + .clone(); + assert_eq!(final_status, "completed"); + + let requests = endpoint.requests.lock().unwrap(); + assert!( + requests[4].get("tools").is_some(), + "monitor_command repetition must not force tools off: {:?}", + requests[4] + ); + drop(requests); + + drop(agent); + let _ = std::fs::remove_dir_all(&scratch); +} + #[test] fn write_todos_reaches_the_client_as_an_acp_plan() { let endpoint = start_fake_endpoint(vec![