diff --git a/cmux-tui/crates/acpmux/src/hub/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index eb1086fae91e..643bdca699b0 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -482,6 +482,9 @@ impl Hub { return Ok(()); } let resume_after = self.last_host_seq(session, &record.incarnation); + // The subagents the host still runs, before its first replayed line reaches the tap: + // a child's line is tagged with its session from the first one (`work_below.rs`). + self.seed_subagents_from_log(session); let tap = self.session_tap(session); let incarnation = record.incarnation.clone(); let caller_token = record.caller_token.clone(); @@ -1968,18 +1971,8 @@ fn open_work_reads(head: &RecordHead<'_>, current: bool, turn_open: bool) -> boo "in" => { // An open turn's records: the model's signs of work, its tools, steers' answers. turn_open - // Every subagent the log starts, which a later turn's update may belong to - // (`SubagentTree::annotate`, replayed history included; Codex's legacy ones are - // a `spawnAgent` call or a `_meta.codex.subagent` object). - || matches!( - head.kind.strip_suffix(".replay").unwrap_or(head.kind), - "subagent_spawned" | "subagent_state_update" | "subagent_progress" - ) - || (matches!( - head.kind.strip_suffix(".replay").unwrap_or(head.kind), - "tool_call" | "tool_call_update" - ) && (head.may_contain(b"\"spawnAgent\"") - || head.may_contain(b"\"subagent\":{"))) + // Every subagent the log starts, which a later turn's update may belong to. + || super::work_below::names_subagents(head) // An agent request (an `id` and a `method`), answered or not. || (current && head.may_contain(b"\"id\":") && head.may_contain(b"\"method\":")) } diff --git a/cmux-tui/crates/acpmux/src/hub/work_below.rs b/cmux-tui/crates/acpmux/src/hub/work_below.rs index 79d4adae45ec..b471c3535163 100644 --- a/cmux-tui/crates/acpmux/src/hub/work_below.rs +++ b/cmux-tui/crates/acpmux/src/hub/work_below.rs @@ -109,6 +109,57 @@ impl Hub { self.refresh_work_below(session); } + /// Before a daemon adopts `session`'s running host (`hosts.rs` `adopt_held`), its subagents + /// are rebuilt from the log, as the tap followed them live: which subagents the chat started, + /// so a child's next line is still tagged with its session id, and which still run, so the + /// summary still counts them. A new daemon started with none, and until each subagent's next + /// progress event its lines reached clients untagged, as the chat's own (a helper's grep read + /// as the chat still running grep after its turn ended). A harness start ends the background + /// subagents of the one before, and a turn start the foreground ones, as live. + pub(super) fn seed_subagents_from_log(&self, session: &Session) { + let meta = session.meta(); + let mut tree = crate::subagents::SubagentTree::default(); + tree.set_own(meta.agent_session_id.as_deref()); + tree.set_roots( + meta.agent_session_id.iter().chain(&meta.retired_agent_session_ids).map(String::as_str), + ); + let _ = self.store.scan_heads(&session.id, &mut |head| { + if head.dir == "mux" { + match head.kind { + "host_started" | "stopped" | "exited" => tree.end_background(), + "turn_started" => tree.end_foreground(), + _ => {} + } + return true; + } + if head.dir != "in" || !names_subagents(head) { + return true; + } + let replay = head.kind.ends_with(".replay"); + if let Some(mut e) = head.record() + && let Some(params) = e.msg.get_mut("params") + { + tree.annotate_live(params, !replay); + } + true + }); + let (running_before, running_after) = { + let mut live = session.subagents.lock().unwrap_or_else(|e| e.into_inner()); + let before = live.running_count(); + live.adopt(tree); + (before, live.running_count()) + }; + if running_after != running_before { + self.append( + session, + "mux", + super::views::SUBAGENTS_RUNNING, + json!({"count": running_after}), + ); + } + self.refresh_work_below(session); + } + /// Whether a background subagent of `session` runs, or one of its open sub-sessions works. fn works_below(&self, session: &Session) -> bool { if session.subagents.lock().unwrap_or_else(|e| e.into_inner()).background_running() > 0 { @@ -136,6 +187,16 @@ impl Hub { } } +/// Whether an agent's logged line may start, change or end a subagent (`SubagentTree::annotate`, +/// replayed history included): a native subagent record, or Codex's legacy `spawnAgent` call or +/// `_meta.codex.subagent` activity. Judged from the head, so a scan parses no other line. +pub(super) fn names_subagents(head: &crate::store::RecordHead<'_>) -> bool { + let kind = head.kind.strip_suffix(".replay").unwrap_or(head.kind); + matches!(kind, "subagent_spawned" | "subagent_state_update" | "subagent_progress") + || (matches!(kind, "tool_call" | "tool_call_update") + && (head.may_contain(b"\"spawnAgent\"") || head.may_contain(b"\"subagent\":{"))) +} + #[cfg(test)] #[path = "work_below_tests.rs"] mod work_below_tests; diff --git a/cmux-tui/crates/acpmux/src/subagents.rs b/cmux-tui/crates/acpmux/src/subagents.rs index a30e112ca8f5..91576bc4bce7 100644 --- a/cmux-tui/crates/acpmux/src/subagents.rs +++ b/cmux-tui/crates/acpmux/src/subagents.rs @@ -203,6 +203,29 @@ impl SubagentTree { owner } + /// Takes in what `log` (a tree rebuilt from the chat's log) knows: every subagent it started, + /// and which of them still run. A subagent this tree already knows keeps its own state, since + /// a live update may have ended it after the log was read. + pub fn adopt(&mut self, log: SubagentTree) { + let known: HashSet = self.parents.keys().cloned().collect(); + for (id, parent) in log.parents { + self.parents.entry(id).or_insert(parent); + } + for (sender, children) in log.pending { + self.pending.entry(sender).or_default().extend(children); + } + for (id, preview) in log.previews { + self.previews.entry(id).or_insert(preview); + } + self.background.extend(log.background.into_iter().filter(|id| !known.contains(id))); + for (id, label) in log.running { + if !known.contains(&id) && !self.running.iter().any(|(r, _)| *r == id) { + self.running.push((id, label)); + } + } + self.root_threads.extend(log.root_threads); + } + /// How many background subagents still run. pub fn background_running(&self) -> usize { self.background.len() @@ -1307,126 +1330,5 @@ mod codex_tests { } #[cfg(test)] -mod background_tests { - use super::*; - - fn annotate(tree: &mut SubagentTree, update: Value, live: bool) { - let mut params = json!({"sessionId": "s1", "update": update}); - tree.annotate_live(&mut params, live); - } - - /// manaflow-ai/cmux#25230: the background subagents a chat runs past its turn, from their - /// start to their end; a foreground one ends with its turn, and a replay changes nothing. - #[test] - fn background_subagents_run_from_their_start_to_their_end() { - let mut tree = SubagentTree::default(); - tree.set_own(Some("s1")); - let spawn = |id: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "background": background}); - let end = |id: &str| json!({"sessionUpdate": "subagent_state_update", "subagentSessionId": id, "state": "completed"}); - annotate(&mut tree, spawn("fg", false), true); - assert_eq!(tree.background_running(), 0); - annotate(&mut tree, spawn("replayed", true), false); - assert_eq!(tree.background_running(), 0, "a replay starts nothing"); - annotate(&mut tree, spawn("bg", true), true); - // Moved to the background later (Claude's task_started is_backgrounded). - annotate( - &mut tree, - json!({"sessionUpdate": "subagent_progress", "subagentSessionId": "fg", "background": true}), - true, - ); - assert_eq!(tree.background_running(), 2); - // Progress without the mark keeps it. - annotate( - &mut tree, - json!({"sessionUpdate": "subagent_progress", "subagentSessionId": "bg"}), - true, - ); - assert_eq!(tree.background_running(), 2); - annotate(&mut tree, end("bg"), true); - annotate(&mut tree, end("fg"), true); - assert_eq!(tree.background_running(), 0); - annotate(&mut tree, spawn("bg2", true), true); - tree.end_background(); - assert_eq!(tree.background_running(), 0, "its harness exited"); - } - - /// The hover card's live count (cmux NEXT sidebar, Leo 2026-11-01: "this hover preview being - /// able to see the # of associated subagents"): every running subagent, foreground or - /// background, nested ones too, with its name; a replay counts none, and without a running - /// turn only the background ones still run. - #[test] - fn running_subagents_are_counted_with_their_names_from_start_to_end() { - let mut tree = SubagentTree::default(); - tree.set_own(Some("s1")); - let spawn = |id: &str, name: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "name": name, "background": background}); - let end = |id: &str| json!({"sessionUpdate": "subagent_state_update", "subagentSessionId": id, "state": "completed"}); - annotate(&mut tree, spawn("replayed", "Old", false), false); - assert_eq!(tree.running_count(), 0, "a replay starts nothing"); - annotate(&mut tree, spawn("a", "Review the sidebar", false), true); - annotate(&mut tree, spawn("b", "Fix the\nbuild", true), true); - annotate(&mut tree, spawn("c", &"x".repeat(80), false), true); - assert_eq!(tree.running_count(), 3); - let names: Vec<_> = - tree.running_now(true).into_iter().map(|(_, l)| l.unwrap().to_owned()).collect(); - assert_eq!(names[0], "Review the sidebar"); - assert_eq!(names[1], "Fix the build", "one line, single spaces"); - assert_eq!(names[2].chars().count(), LABEL_CHARS, "a long name is cut"); - assert_eq!(tree.running_now(false).len(), 1, "between turns only the background one runs"); - annotate(&mut tree, end("a"), true); - assert_eq!(tree.running_count(), 2); - assert_eq!(tree.running_now(true)[0].0, "b", "oldest first"); - tree.end_background(); - assert_eq!(tree.running_count(), 0, "its harness exited"); - } - - /// A subagent named by its task (its prompt) never carries a credential out to the summary. - #[test] - fn a_running_subagents_task_label_has_its_secrets_masked() { - let mut tree = SubagentTree::default(); - tree.set_own(Some("s1")); - let token: String = format!("gh{}_{}", "p", "aZ3kQ9mX7pL2vB8nR4tW6yC1dF5gH0jSaZ3k"); - let task = format!("Push with GITHUB_TOKEN={token} and open the PR"); - annotate( - &mut tree, - json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": "a", "task": task}), - true, - ); - let label = tree.running_now(true)[0].1.unwrap().to_owned(); - assert!(!label.contains(&token), "the token survives in {label:?}"); - assert!(label.starts_with("Push with"), "{label:?}"); - } - - /// Two turns in a row: the first turn's foreground subagents, whose ends the harness never - /// sent, are gone once it ends, and only its background one runs on into the next. - #[test] - fn a_turns_foreground_subagents_do_not_show_again_in_the_next_turn() { - let mut tree = SubagentTree::default(); - tree.set_own(Some("s1")); - let spawn = |id: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "name": id, "background": background}); - annotate(&mut tree, spawn("fg1", false), true); - annotate(&mut tree, spawn("bg1", true), true); - assert_eq!(tree.running_now(true).len(), 2, "turn 1"); - tree.end_foreground(); - annotate(&mut tree, spawn("fg2", false), true); - let ids: Vec<_> = tree.running_now(true).into_iter().map(|(id, _)| id).collect(); - assert_eq!(ids, vec!["bg1", "fg2"], "turn 2 shows its own and the background one"); - } - - /// A legacy activity echo of the native root that arrives before the root is known counts as - /// running background work; recognizing the root afterwards must stop counting it. - #[test] - fn a_late_root_seed_stops_counting_its_echo_as_background_work() { - let mut tree = SubagentTree::default(); - tree.set_own(Some("s1")); - annotate( - &mut tree, - json!({"sessionUpdate": "tool_call_update", "toolCallId": "echo-root", - "_meta": {"codex": {"subagent": {"threadId": "root-native", - "path": "/root", "activity": "working"}}}}), - true, - ); - assert_eq!(tree.background_running(), 1, "the echo was counted before the root was known"); - tree.set_root("root-native"); - assert_eq!(tree.background_running(), 0, "the native root is not background work"); - } -} +#[path = "subagents_background_tests.rs"] +mod background_tests; diff --git a/cmux-tui/crates/acpmux/src/subagents_background_tests.rs b/cmux-tui/crates/acpmux/src/subagents_background_tests.rs new file mode 100644 index 000000000000..b3e3f4d94bee --- /dev/null +++ b/cmux-tui/crates/acpmux/src/subagents_background_tests.rs @@ -0,0 +1,123 @@ +//! `SubagentTree` background and running subagents (`subagents.rs`). + +use super::*; + +fn annotate(tree: &mut SubagentTree, update: Value, live: bool) { + let mut params = json!({"sessionId": "s1", "update": update}); + tree.annotate_live(&mut params, live); +} + +/// manaflow-ai/cmux#25230: the background subagents a chat runs past its turn, from their +/// start to their end; a foreground one ends with its turn, and a replay changes nothing. +#[test] +fn background_subagents_run_from_their_start_to_their_end() { + let mut tree = SubagentTree::default(); + tree.set_own(Some("s1")); + let spawn = |id: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "background": background}); + let end = |id: &str| json!({"sessionUpdate": "subagent_state_update", "subagentSessionId": id, "state": "completed"}); + annotate(&mut tree, spawn("fg", false), true); + assert_eq!(tree.background_running(), 0); + annotate(&mut tree, spawn("replayed", true), false); + assert_eq!(tree.background_running(), 0, "a replay starts nothing"); + annotate(&mut tree, spawn("bg", true), true); + // Moved to the background later (Claude's task_started is_backgrounded). + annotate( + &mut tree, + json!({"sessionUpdate": "subagent_progress", "subagentSessionId": "fg", "background": true}), + true, + ); + assert_eq!(tree.background_running(), 2); + // Progress without the mark keeps it. + annotate( + &mut tree, + json!({"sessionUpdate": "subagent_progress", "subagentSessionId": "bg"}), + true, + ); + assert_eq!(tree.background_running(), 2); + annotate(&mut tree, end("bg"), true); + annotate(&mut tree, end("fg"), true); + assert_eq!(tree.background_running(), 0); + annotate(&mut tree, spawn("bg2", true), true); + tree.end_background(); + assert_eq!(tree.background_running(), 0, "its harness exited"); +} + +/// The hover card's live count (cmux NEXT sidebar, Leo 2026-11-01: "this hover preview being +/// able to see the # of associated subagents"): every running subagent, foreground or +/// background, nested ones too, with its name; a replay counts none, and without a running +/// turn only the background ones still run. +#[test] +fn running_subagents_are_counted_with_their_names_from_start_to_end() { + let mut tree = SubagentTree::default(); + tree.set_own(Some("s1")); + let spawn = |id: &str, name: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "name": name, "background": background}); + let end = |id: &str| json!({"sessionUpdate": "subagent_state_update", "subagentSessionId": id, "state": "completed"}); + annotate(&mut tree, spawn("replayed", "Old", false), false); + assert_eq!(tree.running_count(), 0, "a replay starts nothing"); + annotate(&mut tree, spawn("a", "Review the sidebar", false), true); + annotate(&mut tree, spawn("b", "Fix the\nbuild", true), true); + annotate(&mut tree, spawn("c", &"x".repeat(80), false), true); + assert_eq!(tree.running_count(), 3); + let names: Vec<_> = + tree.running_now(true).into_iter().map(|(_, l)| l.unwrap().to_owned()).collect(); + assert_eq!(names[0], "Review the sidebar"); + assert_eq!(names[1], "Fix the build", "one line, single spaces"); + assert_eq!(names[2].chars().count(), LABEL_CHARS, "a long name is cut"); + assert_eq!(tree.running_now(false).len(), 1, "between turns only the background one runs"); + annotate(&mut tree, end("a"), true); + assert_eq!(tree.running_count(), 2); + assert_eq!(tree.running_now(true)[0].0, "b", "oldest first"); + tree.end_background(); + assert_eq!(tree.running_count(), 0, "its harness exited"); +} + +/// A subagent named by its task (its prompt) never carries a credential out to the summary. +#[test] +fn a_running_subagents_task_label_has_its_secrets_masked() { + let mut tree = SubagentTree::default(); + tree.set_own(Some("s1")); + let token: String = format!("gh{}_{}", "p", "aZ3kQ9mX7pL2vB8nR4tW6yC1dF5gH0jSaZ3k"); + let task = format!("Push with GITHUB_TOKEN={token} and open the PR"); + annotate( + &mut tree, + json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": "a", "task": task}), + true, + ); + let label = tree.running_now(true)[0].1.unwrap().to_owned(); + assert!(!label.contains(&token), "the token survives in {label:?}"); + assert!(label.starts_with("Push with"), "{label:?}"); +} + +/// Two turns in a row: the first turn's foreground subagents, whose ends the harness never +/// sent, are gone once it ends, and only its background one runs on into the next. +#[test] +fn a_turns_foreground_subagents_do_not_show_again_in_the_next_turn() { + let mut tree = SubagentTree::default(); + tree.set_own(Some("s1")); + let spawn = |id: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "name": id, "background": background}); + annotate(&mut tree, spawn("fg1", false), true); + annotate(&mut tree, spawn("bg1", true), true); + assert_eq!(tree.running_now(true).len(), 2, "turn 1"); + tree.end_foreground(); + annotate(&mut tree, spawn("fg2", false), true); + let ids: Vec<_> = tree.running_now(true).into_iter().map(|(id, _)| id).collect(); + assert_eq!(ids, vec!["bg1", "fg2"], "turn 2 shows its own and the background one"); +} + +/// A legacy activity echo of the native root that arrives before the root is known counts as +/// running background work; recognizing the root afterwards must stop counting it. +#[test] +fn a_late_root_seed_stops_counting_its_echo_as_background_work() { + let mut tree = SubagentTree::default(); + tree.set_own(Some("s1")); + annotate( + &mut tree, + json!({"sessionUpdate": "tool_call_update", "toolCallId": "echo-root", + "_meta": {"codex": {"subagent": {"threadId": "root-native", + "path": "/root", "activity": "working"}}}}), + true, + ); + assert_eq!(tree.background_running(), 1, "the echo was counted before the root was known"); + tree.set_root("root-native"); + assert_eq!(tree.background_running(), 0, "the native root is not background work"); +} diff --git a/cmux-tui/crates/acpmux/tests/durable_agents.rs b/cmux-tui/crates/acpmux/tests/durable_agents.rs index e6e96243b268..ffe4243aacdf 100644 --- a/cmux-tui/crates/acpmux/tests/durable_agents.rs +++ b/cmux-tui/crates/acpmux/tests/durable_agents.rs @@ -498,6 +498,33 @@ async fn a_subagents_tool_call_across_a_restart_is_not_its_parent_turns() { ends_its_turn(&daemon, &session, &gate); } +/// A subagent that runs across a restart is still the chat's subagent once the next daemon adopts +/// its host: its next line is tagged with its session id and the summary still counts it. The +/// adopting daemon started with no subagents, so the child's line after `host_adopted` reached +/// clients untagged and read as the chat's own call (Leo: "why does it say you're still running +/// grep" after a finished turn). +#[tokio::test] +async fn a_subagent_running_across_a_restart_keeps_its_tag_after_adoption() { + let (daemon, session, gate, entry) = adopted_after("adopt-sub", "child-gate", |e| { + e["kind"] == "tool_call" && e["msg"].pointer("/params/_meta/acpmux/subagent").is_some() + }) + .await; + assert_eq!(entry["subagentsRunning"], 1, "its subagent still runs: {entry}"); + assert_eq!(entry["subagentNames"], json!(["Child"]), "{entry}"); + std::fs::write(&gate, b"go").unwrap(); + let update = daemon.wait_event(&session, "the child's call ending", |e| { + e["kind"] == "tool_call_update" + && e["msg"].pointer("/params/update/toolCallId") == Some(&json!("cg-1")) + }); + assert_eq!( + update["msg"].pointer("/params/_meta/acpmux/subagent"), + Some(&json!("child-1")), + "the subagent's line after adoption is tagged with its session: {update}" + ); + let result = daemon.wait_event(&session, "turn_result", |e| e["kind"] == "turn_result"); + assert_eq!(result["msg"]["status"], "completed", "{result}"); +} + /// An update restarts acpmux while a turn runs that took a steer (the Chief's `tell`). Claude /// Code answers a steer "steered" at once and the turn runs on. The adopting daemon must keep /// the turn open until the turn's own prompt is answered; it settled the turn on the steer's