From e02cbe7ef161b15811a7751e018156269ba76d16 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 16:55:29 -0700 Subject: [PATCH 01/24] test(acpmux): a parent's message waits behind a working child's turn (failing) send_message always queued behind the child's running turn, so a parent's correction could sit unread for the whole turn, and the tool said only "Sent.". get_session said "running" but not what the child was doing, and start_session's reply carried shown:false, which a parent read as "the user cannot see it". Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15ba9-9614-7832-8e39-d8ca0407c193&host=cosmic-gray --- cmux-tui/crates/acpmux/tests/fake_agent.py | 15 ++ .../acpmux/tests/subsessions_delivery.rs | 214 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 cmux-tui/crates/acpmux/tests/subsessions_delivery.rs diff --git a/cmux-tui/crates/acpmux/tests/fake_agent.py b/cmux-tui/crates/acpmux/tests/fake_agent.py index ce84c6d6af2a..9d3189dd3694 100644 --- a/cmux-tui/crates/acpmux/tests/fake_agent.py +++ b/cmux-tui/crates/acpmux/tests/fake_agent.py @@ -11,6 +11,7 @@ "deaf-to-cancel" -> streams "running", ignores session/cancel, answers end_turn after 20 s "answer-then-exit" -> answers, then the agent exits between turns "gate:

" -> streams before-gate, waits for a write to FIFO

, then after-gate + "work-gate:

" -> a plan step in progress and a running tool call, then as "gate:" anything -> echoes the text as one agent_message_chunk FAKE_CODEX_STEER=1: steering as codex-acp 1.10.0 does it. The agent @@ -346,6 +347,20 @@ def handle_prompt(rid, params): update(sid, {"sessionUpdate":"agent_message_chunk", "content":{"type":"text", "text":json.dumps(choices)}}) send({"jsonrpc":"2.0", "id":rid, "result":{"stopReason":"end_turn"}}) return + # "work-gate: PATH" reports a plan step in progress and a running tool + # call, then blocks on the FIFO at PATH (as "gate:") and ends. + if text.startswith("work-gate:"): + update(sid, {"sessionUpdate": "plan", "entries": [ + {"content": "Read the brief", "status": "completed", "priority": "high"}, + {"content": "Fix the queue", "status": "in_progress", "priority": "high"}]}) + update(sid, {"sessionUpdate": "tool_call", "toolCallId": "wg-1", "title": "Run cargo test", + "kind": "execute", "status": "in_progress"}) + with open(text[len("work-gate:"):].strip()) as gate: + gate.read() + update(sid, {"sessionUpdate": "tool_call_update", "toolCallId": "wg-1", "status": "completed"}) + update(sid, {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "worked"}}) + send({"jsonrpc": "2.0", "id": rid, "result": {"stopReason": "end_turn"}}) + return # "gate: PATH" streams "before-gate", blocks until the test writes to # the FIFO at PATH (no timers), then streams "after-gate" and ends. if text.startswith("gate:"): diff --git a/cmux-tui/crates/acpmux/tests/subsessions_delivery.rs b/cmux-tui/crates/acpmux/tests/subsessions_delivery.rs new file mode 100644 index 000000000000..2c8eba631ce4 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/subsessions_delivery.rs @@ -0,0 +1,214 @@ +//! How a parent's `send_message` reaches a working sub-session, and what +//! the parent reads back about it (`hub/subsessions.rs`, `hub/activity.rs`): +//! by default a message goes into the child's running turn (read at its next +//! tool step, as Claude Code reads a person's mid-turn message), and to a +//! child whose agent cannot take one mid-turn it queues; either way the +//! answer says which, with the queue's length. While the child works, +//! `get_session` says what it does right now: its plan step and latest tool. +//! +//! Stand-in agents (fake_agent.py): `fake` takes no prompt mid-turn, `steer` +//! steers as Claude Code does (`FAKE_CLAUDE_STEER`). One test function: the +//! settings file is read from this process's environment. + +#![cfg(unix)] + +#[path = "support/daemon_env.rs"] +mod daemon_env; +// This process runs stand-in agents (the fake agents under tests/). +const HERMETIC: daemon_env::Process = daemon_env::Process::stand_in(); + +use acpmux::config::{Config, StoreMode}; +use acpmux::hub::Hub; +use acpmux::rpc::Message; +use acpmux::server::{Origin, serve_connection_with}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +const FAKE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); +const START: &str = "_acpmux/subsession/start"; +const SEND: &str = "_acpmux/subsession/send"; +const GET: &str = "_acpmux/subsession/get"; + +fn hub() -> Arc { + let mut cfg: Config = serde_json::from_value(json!({ + "harnesses": { + "fake": {"argv": ["python3", FAKE]}, + "steer": {"argv": ["/usr/bin/env", "FAKE_CLAUDE_STEER=1", "python3", FAKE]}, + }, + "defaultHarness": "fake", + "permissionPolicy": "approve-all", + })) + .unwrap(); + cfg.store.mode = StoreMode::Memory; + let store = acpmux::store::open(&cfg.store, Path::new("/nonexistent")).unwrap(); + Hub::new(cfg, store) +} + +struct Client(mpsc::Sender, mpsc::Receiver, i64); + +impl Client { + fn new(hub: &Arc) -> Self { + let (in_tx, in_rx) = mpsc::channel(64); + let (out_tx, out_rx) = mpsc::channel(4096); + tokio::spawn(serve_connection_with(hub.clone(), in_rx, out_tx, Origin::Local)); + Client(in_tx, out_rx, 0) + } + + async fn as_session(hub: &Arc, id: &str) -> Self { + let mut c = Self::new(hub); + let token = acpmux::hub::person::caller_token(id); + let r = c + .call( + "initialize", + json!({"protocolVersion": 1, + "_meta": {"acpmux": {"callerSession": {"sessionId": id, "token": token}}}}), + ) + .await; + assert!(r.get("error").is_none(), "{r}"); + c + } + + async fn call(&mut self, m: &str, params: Value) -> Value { + self.2 += 1; + let id = self.2; + self.0.send(Message::request(id, m, params).to_line()).await.unwrap(); + loop { + let line = tokio::time::timeout(Duration::from_secs(30), self.1.recv()) + .await + .unwrap() + .unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + if v.get("id") == Some(&json!(id)) { + return v; + } + } + } + + async fn ok(&mut self, m: &str, params: Value) -> Value { + let r = self.call(m, params).await; + assert!(r.get("error").is_none(), "{m}: {r}"); + r["result"].clone() + } + + async fn refused(&mut self, m: &str, params: Value) -> Value { + let r = self.call(m, params).await; + assert!(r.get("error").is_some(), "{m} should be refused: {r}"); + r["error"].clone() + } + + /// The child's row once its running turn has said what it does: the + /// agent's updates reach the hub after the prompt's acceptance. + async fn doing(&mut self, id: &str) -> Value { + for _ in 0..200 { + let row = self.ok(GET, json!({"sessionId": id})).await; + if row["turn"]["activity"].is_string() && row["turn"]["planStep"].is_string() { + return row; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("{id} never reported its activity"); + } +} + +fn fifo(root: &Path, name: &str) -> PathBuf { + let path = root.join(name); + let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: valid NUL-terminated path. + assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0); + path +} + +#[tokio::test] +async fn a_parent_reaches_a_working_child_and_reads_what_it_does() { + let root = std::env::temp_dir().join(format!("acpmux-subsessions-dl-{}", uuid::Uuid::now_v7())); + std::fs::create_dir_all(&root).unwrap(); + let settings = root.join("cmux-next.json"); + std::fs::write(&settings, r#"{"agents": {"subsessions": {"maxChildren": 4}}}"#).unwrap(); + // SAFETY: the only test in this binary; set before any session starts. + unsafe { + std::env::set_var("CMUX_NEXT_CONFIG_FILE", &settings); + std::env::set_var("ACPMUX_HOME", root.join("home")); + } + let hub = hub(); + let mut app = Client::new(&hub); + let new = json!({"cwd": std::env::temp_dir(), "mcpServers": [], "_meta": {"acpmux": {"harness": "fake"}}}); + let parent = app.ok("session/new", new).await["sessionId"].as_str().unwrap().to_owned(); + let mut p = Client::as_session(&hub, &parent).await; + + // The start answers with the row get_session reads, not a summary whose + // `shown: false` read as "the user cannot see it". + let gate = fifo(&root, "steer-gate"); + let started = p + .ok(START, json!({"prompt": format!("work-gate: {}", gate.display()), "name": "steerer", "harness": "steer"})) + .await; + assert!(started["session"].get("shown").is_none(), "{started}"); + let s = started["sessionId"].as_str().unwrap().to_owned(); + + // The child's chat labels its first prompt as its parent's message. + let page = app.ok("_acpmux/events", json!({"sessionId": s, "kinds": "transcript"})).await; + let text = page.to_string(); + assert!(text.contains(r#""fromRole":"parent""#), "no parent label: {page}"); + assert!(text.contains(r#""kind":"subsession_message""#), "{page}"); + + // While it works, the parent reads what it does: plan step and tool. + let row = p.doing(&s).await; + assert_eq!(row["state"], "working", "{row}"); + assert_eq!(row["doing"], "Fix the queue · Run cargo test", "{row}"); + + // A message goes into its running turn by default. + let sent = p.ok(SEND, json!({"sessionId": s, "text": "also check the docs"})).await; + assert_eq!(sent["delivery"], "next_step", "{sent}"); + assert_eq!(sent["queued"], false, "{sent}"); + assert_eq!(sent["steering"], true, "{sent}"); + // after_turn queues it, and says where it waits. + let sent = + p.ok(SEND, json!({"sessionId": s, "text": "echo after", "deliver": "after_turn"})).await; + assert_eq!(sent["delivery"], "queued", "{sent}"); + assert_eq!(sent["position"], 1, "{sent}"); + assert_eq!(sent["queueLength"], 1, "{sent}"); + let row = p.ok(GET, json!({"sessionId": s})).await; + assert_eq!(row["queued"], 1, "{row}"); + // A contradictory request is refused. + let e = p + .refused( + SEND, + json!({"sessionId": s, "text": "x", "deliver": "after_turn", "interrupt": true}), + ) + .await; + assert!(e["message"].as_str().unwrap().contains("contradicts"), "{e}"); + let e = p.refused(SEND, json!({"sessionId": s, "text": "x", "deliver": "soon"})).await; + assert!(e["message"].as_str().unwrap().contains("next_step"), "{e}"); + std::fs::write(&gate, b"go").unwrap(); + p.ok( + "_acpmux/wait", + json!({"sessions": [s], "until": ["ready"], "all": true, "timeoutMs": 20000}), + ) + .await; + + // A child whose agent takes no prompt mid-turn gets it when the turn + // ends: the answer says it queued, and that it cannot steer. + let gate = fifo(&root, "plain-gate"); + let q = p + .ok(START, json!({"prompt": format!("work-gate: {}", gate.display()), "name": "plain"})) + .await["sessionId"] + .as_str() + .unwrap() + .to_owned(); + p.doing(&q).await; + let sent = p.ok(SEND, json!({"sessionId": q, "text": "echo later"})).await; + assert_eq!(sent["delivery"], "queued", "{sent}"); + assert_eq!(sent["steering"], false, "{sent}"); + assert_eq!(sent["queueLength"], 1, "{sent}"); + std::fs::write(&gate, b"go").unwrap(); + p.ok("_acpmux/wait", json!({"sessions": [q], "until": ["ready"], "timeoutMs": 20000})).await; + // A finished turn says nothing about what it does. + let row = p.ok(GET, json!({"sessionId": q})).await; + if row["turn"].is_null() { + assert!(row["doing"].is_null(), "{row}"); + } + + let _ = std::fs::remove_dir_all(&root); +} From e7a776be648dd8157239d1e583cd5707758eae16 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 16:55:31 -0700 Subject: [PATCH 02/24] feat(acpmux): sub-session messages reach a working child at its next step - send_message delivers into the child's running turn by default (deliver: next_step, read at its next tool step, the way Claude Code reads a person's mid-turn message); after_turn queues, interrupt stops the turn. An agent that takes no prompt mid-turn gets it when the turn ends. The result says how it went (delivery, position, queueLength, steering), and the tool's text says when it is read. - The running turn records what it does: its latest tool call's title and its plan's step in progress (turn.activity, turn.planStep). get_session/list_sessions rows carry state, stateSince and a doing line, masked like the other text. - start_session answers with the row get_session reads, not a session summary whose shown:false meant only that no client had it open. - A family message's provenance says who the sender is to the receiver (fromRole parent, subsession, ancestor, descendant) and its short name, for the receiver chat's label. - send_message moves to hub/subsession_send.rs. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15ba9-9614-7832-8e39-d8ca0407c193&host=cosmic-gray --- .../crates/acpmux/spec/acpmux-protocol.json | 16 ++ cmux-tui/crates/acpmux/src/hub/activity.rs | 123 +++++++++++++++ cmux-tui/crates/acpmux/src/hub/auto_resume.rs | 2 + cmux-tui/crates/acpmux/src/hub/hosts.rs | 2 + cmux-tui/crates/acpmux/src/hub/mod.rs | 8 + cmux-tui/crates/acpmux/src/hub/permissions.rs | 1 + .../crates/acpmux/src/hub/subsession_send.rs | 144 ++++++++++++++++++ cmux-tui/crates/acpmux/src/hub/subsessions.rs | 70 +++------ cmux-tui/crates/acpmux/src/hub/views.rs | 2 + .../crates/acpmux/src/protocol/session.rs | 5 + .../crates/acpmux/src/server/subsessions.rs | 3 +- cmux-tui/crates/acpmux/src/subsession_mcp.rs | 37 ++++- .../acpmux/protocol/generated/types.ts | 7 + 13 files changed, 365 insertions(+), 55 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/hub/activity.rs create mode 100644 cmux-tui/crates/acpmux/src/hub/subsession_send.rs diff --git a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json index cc77ab6615c8..62c39edf3f3e 100644 --- a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json +++ b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json @@ -2479,9 +2479,23 @@ "TurnSummary": { "description": "The running turn.", "properties": { + "activity": { + "description": "What the turn does right now: its latest tool call's title; null\nuntil it calls one (`hub/activity.rs`).", + "type": [ + "string", + "null" + ] + }, "client": { "type": "string" }, + "planStep": { + "description": "Its plan's entry in progress; null without one.", + "type": [ + "string", + "null" + ] + }, "prompt": { "type": "string" }, @@ -2503,7 +2517,9 @@ } }, "required": [ + "activity", "client", + "planStep", "prompt", "promptId", "startedAt", diff --git a/cmux-tui/crates/acpmux/src/hub/activity.rs b/cmux-tui/crates/acpmux/src/hub/activity.rs new file mode 100644 index 000000000000..bffa4bfe314a --- /dev/null +++ b/cmux-tui/crates/acpmux/src/hub/activity.rs @@ -0,0 +1,123 @@ +//! Part of `Hub`; see `hub/mod.rs`. What a running turn does right now, one +//! line, for a parent session that reports its sub-sessions' progress +//! (`get_session`'s `doing`) and for any client's summary (`turn.activity`, +//! `turn.planStep`). +//! +//! The turn's latest tool call titles it ("Run cargo test -p acpmux"); its +//! plan's entry in progress is the step around it. Both live on the running +//! turn only (`TurnInfo`) and end with it, so a finished turn never reads as +//! still doing its last tool. + +use super::*; + +/// Longest activity or plan step kept, in characters. +const ACTIVITY_CHARS: usize = 160; + +/// What one `session/update` says the turn does now. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Activity { + /// A tool call started or was retitled: its title. + Tool(String), + /// The plan changed: its entry in progress, or None when none is. + PlanStep(Option), +} + +/// The activity in `update` (a `session/update`'s `update`), if any. A tool +/// call without a title says nothing new; an update to an existing call +/// counts only when it retitles it. +pub(super) fn activity_of(update: &Value) -> Option { + let line = + |v: &Value| v.as_str().map(|t| short_text(t, ACTIVITY_CHARS)).filter(|t| !t.is_empty()); + match update.get("sessionUpdate").and_then(Value::as_str)? { + "tool_call" | "tool_call_update" => line(&update["title"]).map(Activity::Tool), + "plan" => { + let entries = update.get("entries").and_then(Value::as_array)?; + let step = entries + .iter() + .find(|e| e["status"] == "in_progress") + .and_then(|e| line(&e["content"])); + Some(Activity::PlanStep(step)) + } + _ => None, + } +} + +/// The one "currently doing" line of a turn: the plan step and the tool in +/// it, either alone, or None when the turn has said neither yet. +pub(crate) fn doing_line(plan_step: Option<&str>, activity: Option<&str>) -> Option { + match (plan_step, activity) { + (Some(step), Some(tool)) if step != tool => Some(format!("{step} · {tool}")), + (Some(step), _) => Some(step.to_owned()), + (None, Some(tool)) => Some(tool.to_owned()), + (None, None) => None, + } +} + +impl Hub { + /// Notes `update`'s activity on the running turn. Nothing while no turn + /// runs: an update after the turn ended does not outlive it. + pub(super) fn note_activity(&self, session: &Session, update: &Value) { + let Some(activity) = activity_of(update) else { + return; + }; + let mut turn = session.turn.lock().unwrap_or_else(|e| e.into_inner()); + let Some(turn) = turn.as_mut() else { + return; + }; + match activity { + Activity::Tool(title) => turn.activity = Some(title), + Activity::PlanStep(step) => turn.plan_step = step, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_tool_call_names_the_activity() { + let u = json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Run cargo test\n-p acpmux", "status": "pending"}); + assert_eq!(activity_of(&u), Some(Activity::Tool("Run cargo test -p acpmux".into()))); + } + + #[test] + fn an_untitled_update_says_nothing() { + let u = + json!({"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed"}); + assert_eq!(activity_of(&u), None); + assert_eq!(activity_of(&json!({"sessionUpdate": "agent_message_chunk"})), None); + } + + #[test] + fn a_plan_names_its_step_in_progress() { + let u = json!({"sessionUpdate": "plan", "entries": [ + {"content": "Read the brief", "status": "completed", "priority": "high"}, + {"content": "Fix send_message", "status": "in_progress", "priority": "high"}, + {"content": "Open the PR", "status": "pending", "priority": "high"}, + ]}); + assert_eq!(activity_of(&u), Some(Activity::PlanStep(Some("Fix send_message".into())))); + let done = + json!({"sessionUpdate": "plan", "entries": [{"content": "x", "status": "completed"}]}); + assert_eq!(activity_of(&done), Some(Activity::PlanStep(None))); + } + + #[test] + fn a_long_title_is_cut() { + let u = json!({"sessionUpdate": "tool_call", "title": "x".repeat(500)}); + let Some(Activity::Tool(t)) = activity_of(&u) else { panic!() }; + assert_eq!(t.chars().count(), ACTIVITY_CHARS + 1); + } + + #[test] + fn the_doing_line_joins_step_and_tool() { + assert_eq!( + doing_line(Some("Fix it"), Some("Edit a.rs")).as_deref(), + Some("Fix it · Edit a.rs") + ); + assert_eq!(doing_line(Some("Fix it"), None).as_deref(), Some("Fix it")); + assert_eq!(doing_line(None, Some("Edit a.rs")).as_deref(), Some("Edit a.rs")); + assert_eq!(doing_line(Some("same"), Some("same")).as_deref(), Some("same")); + assert_eq!(doing_line(None, None), None); + } +} diff --git a/cmux-tui/crates/acpmux/src/hub/auto_resume.rs b/cmux-tui/crates/acpmux/src/hub/auto_resume.rs index 47805b02a982..073ca617abe6 100644 --- a/cmux-tui/crates/acpmux/src/hub/auto_resume.rs +++ b/cmux-tui/crates/acpmux/src/hub/auto_resume.rs @@ -203,6 +203,8 @@ impl TurnInfo { prompt_id: prompt_id.to_owned(), turn_seq: 0, control, + activity: None, + plan_step: None, } } } diff --git a/cmux-tui/crates/acpmux/src/hub/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index 90d144b5c486..7b43b5f70118 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -582,6 +582,8 @@ impl Hub { // Who prompted, as `turn_started` recorded it; an // older record without it counts as Web. control, + activity: None, + plan_step: None, }); session.floor.last_turn_web.store(control == Control::Web, Ordering::SeqCst); // A Web turn in flight in an agent the sandbox did not diff --git a/cmux-tui/crates/acpmux/src/hub/mod.rs b/cmux-tui/crates/acpmux/src/hub/mod.rs index 413b74ec9e2d..175fc9e330a0 100644 --- a/cmux-tui/crates/acpmux/src/hub/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/mod.rs @@ -4,6 +4,7 @@ //! (export, import), `views` (summaries), `handoff` (a reviewed first message to another harness), //! `adoption` (resuming a harness's own session), `models_view` (the picker's model lists). +mod activity; mod adoption; mod aside; pub(crate) mod auto_resume; @@ -87,8 +88,10 @@ mod remote_floor; mod remote_sandbox; pub use remote_sandbox::vm_host_peer_approves; mod start_gate; +mod subsession_send; pub mod subsessions; pub use permission_groups::PERMISSION_GROUP_OPERATIONS; +pub use subsession_send::Delivery; pub mod rules; mod transcript; mod transfer; @@ -177,6 +180,11 @@ pub struct TurnInfo { /// Who prompted (or steered) this turn. A Web turn never uses the chat /// allowance: each eligible permission in it still asks. pub control: Control, + /// What the turn does right now, one line: its latest tool call's title, + /// else its plan's step in progress (`activity.rs`). None until either. + pub activity: Option, + /// Its plan's entry in progress, one line (`activity.rs`). + pub plan_step: Option, } /// Options for `Hub::prompt_with`. diff --git a/cmux-tui/crates/acpmux/src/hub/permissions.rs b/cmux-tui/crates/acpmux/src/hub/permissions.rs index 6131aaa90456..ecfc68578ad2 100644 --- a/cmux-tui/crates/acpmux/src/hub/permissions.rs +++ b/cmux-tui/crates/acpmux/src/hub/permissions.rs @@ -102,6 +102,7 @@ impl Hub { return; } let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or(""); + self.note_activity(session, update); // A mode or option change goes through the one setter below. let mut mode_write = None; let mut reported_tools = None; diff --git a/cmux-tui/crates/acpmux/src/hub/subsession_send.rs b/cmux-tui/crates/acpmux/src/hub/subsession_send.rs new file mode 100644 index 000000000000..bdc33658efc2 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/hub/subsession_send.rs @@ -0,0 +1,144 @@ +//! Part of `Hub`; see `hub/mod.rs`. `send_message` (`_acpmux/subsession/send`): +//! how a family message reaches a session with a running turn (`Delivery`), +//! what the sender reads back about it, and the "currently doing" line a +//! parent reads of a child (`get_session`'s `doing`). + +use super::subsessions::refused; +use super::*; + +impl Hub { + /// `_acpmux/subsession/send`: a prompt from `caller` to a descendant or + /// to its parent, delivered as `deliver` says (`Delivery`). + pub async fn subsession_send( + self: &Arc, + caller: &Arc, + target: &Arc, + text: &str, + deliver: Option, + prompt_id: Option, + ) -> Result { + if text.trim().is_empty() { + return Err(RpcError::invalid_params("text must not be empty")); + } + if target.meta().status == SessionStatus::Closed { + return Err(refused( + "subsessions.closed", + "that session is closed; start a new one with start_session".to_owned(), + json!({"sessionId": target.id}), + )); + } + // A child reports to its parent; it never stops the parent's turn. + let to_parent = + caller.meta().subsession.as_ref().is_some_and(|l| l.started_by == target.id); + // A parent's word reaches a working child at its next step; a + // child's report waits for its parent's turn to end. + let deliver = + deliver.unwrap_or(if to_parent { Delivery::AfterTurn } else { Delivery::NextStep }); + if deliver == Delivery::Interrupt && to_parent { + return Err(RpcError::invalid_params( + "a sub-session never interrupts its parent: send without interrupt, and the message waits for the parent's turn to end", + )); + } + if deliver == Delivery::Interrupt && target.turn().is_some() { + self.cancel(target).await?; + } + let steer = deliver == Delivery::NextStep; + let accepted = self.send_family_prompt(caller, target, text, prompt_id, steer).await?; + Ok(sent_reply(target, &accepted)) + } +} + +/// How `send_message` delivers its text to a session with a running turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Delivery { + /// Into the running turn: the agent reads it at its next tool step, the + /// way a person's mid-turn message reaches Claude Code. An agent that + /// takes no prompt mid-turn gets it when the turn ends instead. + NextStep, + /// Queued behind the running turn: the agent reads it when the turn ends. + AfterTurn, + /// Stops the running turn first, then runs it. + Interrupt, +} + +impl Delivery { + /// `deliver` (`next_step`, `after_turn`, `interrupt`) or the older + /// `interrupt: true`; None leaves the default to the sender's place. + pub fn parse(deliver: Option<&str>, interrupt: bool) -> Result, RpcError> { + let named = match deliver { + None => None, + Some("next_step") => Some(Self::NextStep), + Some("after_turn") => Some(Self::AfterTurn), + Some("interrupt") => Some(Self::Interrupt), + Some(other) => { + return Err(RpcError::invalid_params(format!( + "deliver must be next_step, after_turn or interrupt, not {other:?}" + ))); + } + }; + match (named, interrupt) { + (Some(d), true) if d != Self::Interrupt => Err(RpcError::invalid_params( + "interrupt: true contradicts deliver; send one of them", + )), + (None, true) => Ok(Some(Self::Interrupt)), + (d, _) => Ok(d), + } + } +} + +/// `send_message`'s answer: how the text went (`delivery`: `started` a turn, +/// `next_step` into the running one, or `queued` behind it at `position`), +/// and the receiver's queue, so the sender knows when it will be read. +fn sent_reply(target: &Session, accepted: &Value) -> Value { + let queued = accepted["queued"].as_bool().unwrap_or(false); + let delivery = if accepted["steer"].as_bool().unwrap_or(false) { + "next_step" + } else if queued { + "queued" + } else { + "started" + }; + json!({ + "sessionId": target.id, + "delivery": delivery, + "queued": queued, + "position": accepted.get("position").cloned().unwrap_or(Value::Null), + "queueLength": target.queued(), + // Whether its agent takes a message mid-turn at all. + "steering": target.steering.load(Ordering::SeqCst), + "turnId": accepted.get("turnId"), + }) +} + +/// What the sender `from` is to the receiver `to` in their family: its +/// `parent`, its `subsession`, or a farther `ancestor` or `descendant`. +pub(super) fn family_role(from: &SessionMeta, to: &SessionMeta) -> &'static str { + let started_by = |m: &SessionMeta| m.subsession.as_ref().map(|l| l.started_by.clone()); + let depth = |m: &SessionMeta| m.subsession.as_ref().map_or(0, |l| l.depth); + if started_by(to).as_deref() == Some(from.id.as_str()) { + "parent" + } else if started_by(from).as_deref() == Some(to.id.as_str()) { + "subsession" + } else if depth(from) < depth(to) { + "ancestor" + } else { + "descendant" + } +} + +/// A sub-session's "currently doing" line for its parent: while a turn runs, +/// its plan step and latest tool (`activity.rs`), else what the turn was +/// asked; while it waits on the person, that; else null (its `state` and +/// `lastReply` say the rest). +pub(super) fn doing(summary: &Value) -> Value { + let turn = &summary["turn"]; + if summary["lane"]["state"] == "input" { + return json!("Waiting for the user to answer it"); + } + if turn.is_null() { + return Value::Null; + } + let line = super::activity::doing_line(turn["planStep"].as_str(), turn["activity"].as_str()) + .or_else(|| turn["prompt"].as_str().map(|p| format!("Working on: {}", short_text(p, 120)))); + line.map_or(Value::Null, Value::String) +} diff --git a/cmux-tui/crates/acpmux/src/hub/subsessions.rs b/cmux-tui/crates/acpmux/src/hub/subsessions.rs index e811e9a6755e..01c7e8b0e5c8 100644 --- a/cmux-tui/crates/acpmux/src/hub/subsessions.rs +++ b/cmux-tui/crates/acpmux/src/hub/subsessions.rs @@ -299,6 +299,7 @@ pub struct StartRequest { /// A sub-session's new worktree (and the folder it was made from) until its /// start succeeds: dropped before [`KeptWorktree::keep`] (a later refusal, /// failure, or the parent closing meanwhile), it is removed, off the async + /// runtime. struct KeptWorktree { folder: PathBuf, @@ -322,7 +323,7 @@ impl Drop for KeptWorktree { } } -fn refused(reason: &str, message: String, data: Value) -> RpcError { +pub(super) fn refused(reason: &str, message: String, data: Value) -> RpcError { let mut data = data; data["reason"] = json!(reason); RpcError::invalid_params(message).with_data(data) @@ -638,7 +639,7 @@ impl Hub { tracing::warn!(session = %probe.id, "sub-session start mode skipped: {}", e.message); } } - let accepted = self.send_family_prompt(parent, &probe, &req.prompt, None).await; + let accepted = self.send_family_prompt(parent, &probe, &req.prompt, None, false).await; let accepted = match accepted { Ok(v) => v, Err(e) => { @@ -646,7 +647,11 @@ impl Hub { return Err(e); } }; - let summary = self.session_summary(&probe); + // The row get_session reads, not the session summary: a summary's + // `shown` says whether a client has the chat open right now, which a + // parent read as "the user cannot see it" (it is under the parent's + // chat in the sidebar from the start). + let row = self.subsession_row(&probe, false); let mut reply = json!({ "sessionId": probe.id, "name": cm.name, @@ -655,7 +660,7 @@ impl Hub { "harness": cm.harness, "mode": crate::web_modes::mode_of(&probe.meta()), "turnId": accepted.get("turnId"), - "session": summary, + "session": row, }); if let Some(kept) = &worktree { let w = &kept.made; @@ -700,20 +705,26 @@ impl Hub { /// Sends `text` from `from` to `to` as a prompt with the family /// provenance; answers once it is accepted (started or queued). - async fn send_family_prompt( + pub(super) async fn send_family_prompt( self: &Arc, from: &Arc, to: &Arc, text: &str, prompt_id: Option, + steer: bool, ) -> Result { let fm = from.meta(); - let provenance = json!({ + let mut provenance = json!({ "kind": PROVENANCE_KIND, "from": from.id, "fromName": fm.name, "to": to.id, + // Who the sender is to the receiver, for its chat's label. + "fromRole": super::subsession_send::family_role(&fm, &to.meta()), }); + if let Some(link) = &fm.subsession { + provenance["fromShortName"] = json!(link.short_name); + } let (tx, rx) = tokio::sync::oneshot::channel::(); let opts = PromptOptions { prompt_id: Some(prompt_id.unwrap_or_else(|| format!("sub-{}", uuid::Uuid::now_v7()))), @@ -727,7 +738,7 @@ impl Hub { let (hub, to2) = (self.clone(), to.clone()); // The turn runs on: only its acceptance is awaited here. let turn = tokio::spawn(async move { - hub.prompt_with(&to2, blocks, "acpmux-subsession", false, opts).await + hub.prompt_with(&to2, blocks, "acpmux-subsession", steer, opts).await }); match rx.await { Ok(v) => Ok(v), @@ -761,13 +772,17 @@ impl Hub { "queued": pick("queued"), // Non-null from the moment a prompt is accepted to its turn's end. "turn": pick("turn"), + // working, input, failed, lost or idle, and since when (`views::lane`). + "state": summary["lane"]["state"].clone(), + "stateSince": summary["lane"]["since"].clone(), + "doing": super::subsession_send::doing(&summary), }); if with_reply { row["lastReply"] = json!(self.last_reply(s)); } // The tools hand this to another session's agent: a secret in the // child's text (one it read from a file, say) is masked. - for key in ["title", "lastReply"] { + for key in ["title", "lastReply", "doing"] { if let Some(text) = row[key].as_str() { row[key] = json!(crate::secret_scan::mask(text)); } @@ -876,45 +891,6 @@ impl Hub { Ok(target) } - /// `_acpmux/subsession/send`: a prompt from `caller` to a descendant or - /// to its parent, queued behind a running turn, or after stopping it. - pub async fn subsession_send( - self: &Arc, - caller: &Arc, - target: &Arc, - text: &str, - interrupt: bool, - prompt_id: Option, - ) -> Result { - if text.trim().is_empty() { - return Err(RpcError::invalid_params("text must not be empty")); - } - if target.meta().status == SessionStatus::Closed { - return Err(refused( - "subsessions.closed", - "that session is closed; start a new one with start_session".to_owned(), - json!({"sessionId": target.id}), - )); - } - // A child reports to its parent; it never stops the parent's turn. - let to_parent = - caller.meta().subsession.as_ref().is_some_and(|l| l.started_by == target.id); - if interrupt && to_parent { - return Err(RpcError::invalid_params( - "a sub-session never interrupts its parent: send without interrupt, and the message waits for the parent's turn to end", - )); - } - if interrupt && target.turn().is_some() { - self.cancel(target).await?; - } - let accepted = self.send_family_prompt(caller, target, text, prompt_id).await?; - Ok(json!({ - "sessionId": target.id, - "queued": accepted.get("queued").cloned().unwrap_or(json!(false)), - "turnId": accepted.get("turnId"), - })) - } - /// `_acpmux/subsession/stop`: stops a descendant's turn, or with `close` /// closes it and every session under it (deepest first), which frees /// their slots. diff --git a/cmux-tui/crates/acpmux/src/hub/views.rs b/cmux-tui/crates/acpmux/src/hub/views.rs index f12a122a1dec..4bde599b54a3 100644 --- a/cmux-tui/crates/acpmux/src/hub/views.rs +++ b/cmux-tui/crates/acpmux/src/hub/views.rs @@ -301,6 +301,8 @@ impl Hub { turn_id: t.turn_id, prompt_id: t.prompt_id, turn_seq: t.turn_seq, + activity: t.activity, + plan_step: t.plan_step, }), queue: session .queue() diff --git a/cmux-tui/crates/acpmux/src/protocol/session.rs b/cmux-tui/crates/acpmux/src/protocol/session.rs index bed613451104..2ead5719a827 100644 --- a/cmux-tui/crates/acpmux/src/protocol/session.rs +++ b/cmux-tui/crates/acpmux/src/protocol/session.rs @@ -248,6 +248,11 @@ pub struct TurnSummary { pub turn_id: String, pub prompt_id: String, pub turn_seq: u64, + /// What the turn does right now: its latest tool call's title; null + /// until it calls one (`hub/activity.rs`). + pub activity: Option, + /// Its plan's entry in progress; null without one. + pub plan_step: Option, } /// A prompt waiting for the running turn to end. diff --git a/cmux-tui/crates/acpmux/src/server/subsessions.rs b/cmux-tui/crates/acpmux/src/server/subsessions.rs index 1328c71f23fa..ba32dba22d91 100644 --- a/cmux-tui/crates/acpmux/src/server/subsessions.rs +++ b/cmux-tui/crates/acpmux/src/server/subsessions.rs @@ -108,8 +108,9 @@ async fn handle( let target = hub.subsession_write_target(&caller, key, true)?; let text = str_param(params, "text").unwrap_or_default(); let interrupt = params.get("interrupt").and_then(Value::as_bool).unwrap_or(false); + let deliver = crate::hub::Delivery::parse(str_param(params, "deliver"), interrupt)?; let prompt_id = str_param(params, "promptId").map(str::to_owned); - hub.subsession_send(&caller, &target, text, interrupt, prompt_id).await + hub.subsession_send(&caller, &target, text, deliver, prompt_id).await } method::MUX_SUBSESSION_STOP => { let key = str_param(params, "sessionId") diff --git a/cmux-tui/crates/acpmux/src/subsession_mcp.rs b/cmux-tui/crates/acpmux/src/subsession_mcp.rs index 0dd2160b5604..7b002bed4ad4 100644 --- a/cmux-tui/crates/acpmux/src/subsession_mcp.rs +++ b/cmux-tui/crates/acpmux/src/subsession_mcp.rs @@ -58,7 +58,7 @@ fn tools() -> Value { }, { "name": "list_sessions", - "description": "List your sub-sessions with their live state (status running, ready, waiting, closed; the last turn's outcome; open children) and your own limits: depth, open sub-sessions, maxChildren and maxDepth.", + "description": "List your sub-sessions with their live state (state working, input, failed or idle; doing: one line of what a working one does right now, its plan step and latest tool; queued: messages waiting for its turn to end; the last turn's outcome; open children) and your own limits: depth, open sub-sessions, maxChildren and maxDepth. Report a sub-session's progress from doing, not from status alone.", "inputSchema": { "type": "object", "properties": { @@ -68,7 +68,7 @@ fn tools() -> Value { }, { "name": "get_session", - "description": "Read one of your sub-sessions (or your parent session): its state and the text of its last reply.", + "description": "Read one of your sub-sessions (or your parent session): its state, doing (what it does right now, while a turn runs), queued messages and the text of its last reply.", "inputSchema": { "type": "object", "properties": {"sessionId": {"type": "string"}}, @@ -88,13 +88,14 @@ fn tools() -> Value { }, { "name": "send_message", - "description": "Send a message to one of your sub-sessions (or to your parent session, to report or to ask for a higher limit). It queues behind a running turn; with interrupt: true it stops that turn first. The receiver sees it came from you.", + "description": "Send a message to one of your sub-sessions (or to your parent session, to report or to ask for a higher limit). To a working sub-session it goes into its running turn by default: the agent reads it at its next tool step, without stopping its work (an agent that cannot take a message mid-turn gets it when the turn ends; the result's delivery says which). deliver: after_turn queues it behind the running turn instead, interrupt stops the turn first. A message to your parent waits for its turn to end. The receiver's chat shows it came from you.", "inputSchema": { "type": "object", "properties": { "sessionId": {"type": "string"}, "text": {"type": "string"}, - "interrupt": {"type": "boolean"} + "deliver": {"type": "string", "enum": ["next_step", "after_turn", "interrupt"], "description": "next_step (default to a sub-session): into its running turn, read at its next tool step. after_turn (default to your parent): queued behind the running turn. interrupt: stop the running turn, then run this."}, + "interrupt": {"type": "boolean", "description": "The same as deliver: interrupt."} }, "required": ["sessionId", "text"] } @@ -143,6 +144,28 @@ fn ok(text: String, structured: Value) -> Value { json!({"content": [{"type": "text", "text": text}], "structuredContent": structured, "isError": false}) } +/// `send_message`'s one line: when the receiver reads the message. +fn sent_text(r: &Value) -> String { + let queue = r["queueLength"].as_u64().unwrap_or(0); + match r["delivery"].as_str().unwrap_or_default() { + "next_step" => { + "Delivered into its running turn: it reads it at its next tool step.".to_owned() + } + "queued" => { + let sooner = if r["steering"].as_bool().unwrap_or(false) { + "To reach it sooner send with deliver: next_step, or interrupt to stop the turn." + } else { + "Its agent takes no message mid-turn; interrupt stops the turn to reach it sooner." + }; + format!( + "Queued behind its running turn (position {}, {queue} queued): it reads it when that turn ends. {sooner}", + r["position"].as_u64().unwrap_or(queue) + ) + } + _ => "Sent: it started a turn with it.".to_owned(), + } +} + /// A daemon refusal as the agent reads it: the daemon's message is the advice. fn refusal(e: anyhow::Error) -> Value { error_result(format!("{e:#}")) @@ -158,8 +181,8 @@ async fn call(daemon: &Client, params: &Value) -> Value { "start_session" => match request(method::MUX_SUBSESSION_START, args).await { Ok(r) => ok( format!( - "Started sub-session {} (sessionId {}, depth {}). It works in parallel; use wait_for_sessions to get its result.", - r["name"].as_str().unwrap_or_default(), + "Started sub-session {} (sessionId {}, depth {}). The user sees it under your chat in the sidebar. It works in parallel; use wait_for_sessions to get its result.", + r["shortName"].as_str().or(r["name"].as_str()).unwrap_or_default(), r["sessionId"].as_str().unwrap_or_default(), r["depth"] ), @@ -176,7 +199,7 @@ async fn call(daemon: &Client, params: &Value) -> Value { Err(e) => refusal(e), }, "send_message" => match request(method::MUX_SUBSESSION_SEND, args).await { - Ok(r) => ok("Sent.".to_owned(), r), + Ok(r) => ok(sent_text(&r), r), Err(e) => refusal(e), }, "stop_session" => match request(method::MUX_SUBSESSION_STOP, args).await { diff --git a/webviews/src/agent-session/acpmux/protocol/generated/types.ts b/webviews/src/agent-session/acpmux/protocol/generated/types.ts index f52cda732403..a021068cc76e 100644 --- a/webviews/src/agent-session/acpmux/protocol/generated/types.ts +++ b/webviews/src/agent-session/acpmux/protocol/generated/types.ts @@ -468,7 +468,14 @@ export type ToolSummary = { /** The running turn. */ export type TurnSummary = { + /** + * What the turn does right now: its latest tool call's title; null + * until it calls one (`hub/activity.rs`). + */ + activity: (string | null); client: string; + /** Its plan's entry in progress; null without one. */ + planStep: (string | null); prompt: string; promptId: string; startedAt: number; From 100508bafd7801d9ca1a991b53a9d369a09610ea Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 17:17:24 -0700 Subject: [PATCH 03/24] feat(acpmux): a parent reads what its sub-session waits on the user for get_session and list_sessions rows gain waitingOn (question or permission), and doing names the question. Only the person answers either, in the app; the tool text tells the parent agent to relay it. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15ba9-9614-7832-8e39-d8ca0407c193&host=cosmic-gray --- .../crates/acpmux/src/hub/subsession_send.rs | 64 +++++++++++++++++-- cmux-tui/crates/acpmux/src/hub/subsessions.rs | 5 +- cmux-tui/crates/acpmux/src/subsession_mcp.rs | 4 +- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/hub/subsession_send.rs b/cmux-tui/crates/acpmux/src/hub/subsession_send.rs index bdc33658efc2..e85ece01655f 100644 --- a/cmux-tui/crates/acpmux/src/hub/subsession_send.rs +++ b/cmux-tui/crates/acpmux/src/hub/subsession_send.rs @@ -126,14 +126,45 @@ pub(super) fn family_role(from: &SessionMeta, to: &SessionMeta) -> &'static str } } -/// A sub-session's "currently doing" line for its parent: while a turn runs, +/// What a sub-session waits on the person for, when it does: `question` +/// (an agent question, with its first prompt) or `permission`. Only the +/// person answers either (`questions.rs`, `person.rs`), in the app's card +/// or with `acpmux answer`/`allow` from their own terminal; a parent agent +/// cannot, so its tools say so instead of offering an answer. +pub(super) fn waiting_on(s: &Session) -> Option<(&'static str, Option)> { + let pending = s.pending_permissions(); + let question = pending.iter().find(|(_, r)| super::questions::needs_person(r)); + match (question, pending.first()) { + (Some((_, r)), _) => { + let prompt = super::questions::question(r) + .and_then(|q| q.pointer("/items/0/prompt")) + .and_then(Value::as_str) + .map(|p| short_text(p, 120)); + Some(("question", prompt)) + } + (None, Some(_)) => Some(("permission", None)), + (None, None) => None, + } +} + +/// A sub-session's "currently doing" line for its parent: while it waits on +/// the person, what for (`waiting`, from [`waiting_on`]); while a turn runs, /// its plan step and latest tool (`activity.rs`), else what the turn was -/// asked; while it waits on the person, that; else null (its `state` and -/// `lastReply` say the rest). -pub(super) fn doing(summary: &Value) -> Value { +/// asked; else null (its `state` and `lastReply` say the rest). +pub(super) fn doing(summary: &Value, waiting: Option<&(&str, Option)>) -> Value { let turn = &summary["turn"]; - if summary["lane"]["state"] == "input" { - return json!("Waiting for the user to answer it"); + match waiting { + Some(("question", Some(prompt))) => { + return json!(format!("Waiting for the user to answer, in the app: {prompt}")); + } + Some(("question", None)) => { + return json!("Waiting for the user to answer a question, in the app"); + } + Some(_) => return json!("Waiting for the user to allow a tool, in the app"), + None if summary["lane"]["state"] == "input" => { + return json!("Waiting for the user to answer it"); + } + None => {} } if turn.is_null() { return Value::Null; @@ -142,3 +173,24 @@ pub(super) fn doing(summary: &Value) -> Value { .or_else(|| turn["prompt"].as_str().map(|p| format!("Working on: {}", short_text(p, 120)))); line.map_or(Value::Null, Value::String) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_child_waiting_on_the_user_says_what_for() { + let summary = json!({"lane": {"state": "input"}, "turn": {"prompt": "fix it"}}); + let q = ("question", Some("Which branch?".to_owned())); + assert_eq!( + doing(&summary, Some(&q)), + "Waiting for the user to answer, in the app: Which branch?" + ); + let p = ("permission", None); + assert_eq!(doing(&summary, Some(&p)), "Waiting for the user to allow a tool, in the app"); + assert_eq!(doing(&summary, None), "Waiting for the user to answer it"); + let working = json!({"lane": {"state": "working"}, "turn": {"activity": "Run cargo test"}}); + assert_eq!(doing(&working, None), "Run cargo test"); + assert_eq!(doing(&json!({"lane": {"state": "idle"}, "turn": null}), None), Value::Null); + } +} diff --git a/cmux-tui/crates/acpmux/src/hub/subsessions.rs b/cmux-tui/crates/acpmux/src/hub/subsessions.rs index 01c7e8b0e5c8..e3f47c1738f8 100644 --- a/cmux-tui/crates/acpmux/src/hub/subsessions.rs +++ b/cmux-tui/crates/acpmux/src/hub/subsessions.rs @@ -755,6 +755,7 @@ impl Hub { pub fn subsession_row(&self, s: &Session, with_reply: bool) -> Value { let summary = self.session_summary(s); let pick = |k: &str| summary.get(k).cloned().unwrap_or(Value::Null); + let waiting = super::subsession_send::waiting_on(s); let mut row = json!({ "sessionId": s.id, "name": pick("name"), @@ -775,7 +776,9 @@ impl Hub { // working, input, failed, lost or idle, and since when (`views::lane`). "state": summary["lane"]["state"].clone(), "stateSince": summary["lane"]["since"].clone(), - "doing": super::subsession_send::doing(&summary), + "doing": super::subsession_send::doing(&summary, waiting.as_ref()), + // question or permission: only the person answers it, in the app. + "waitingOn": waiting.as_ref().map(|w| w.0), }); if with_reply { row["lastReply"] = json!(self.last_reply(s)); diff --git a/cmux-tui/crates/acpmux/src/subsession_mcp.rs b/cmux-tui/crates/acpmux/src/subsession_mcp.rs index 7b002bed4ad4..0c175cdbcf80 100644 --- a/cmux-tui/crates/acpmux/src/subsession_mcp.rs +++ b/cmux-tui/crates/acpmux/src/subsession_mcp.rs @@ -58,7 +58,7 @@ fn tools() -> Value { }, { "name": "list_sessions", - "description": "List your sub-sessions with their live state (state working, input, failed or idle; doing: one line of what a working one does right now, its plan step and latest tool; queued: messages waiting for its turn to end; the last turn's outcome; open children) and your own limits: depth, open sub-sessions, maxChildren and maxDepth. Report a sub-session's progress from doing, not from status alone.", + "description": "List your sub-sessions with their live state (state working, input, failed or idle; doing: one line of what a working one does right now, its plan step and latest tool; queued: messages waiting for its turn to end; the last turn's outcome; open children) and your own limits: depth, open sub-sessions, maxChildren and maxDepth. Report a sub-session's progress from doing, not from status alone. waitingOn question or permission means it waits on the user: only the user answers it, in the app; you cannot, so tell the user what it asks.", "inputSchema": { "type": "object", "properties": { @@ -68,7 +68,7 @@ fn tools() -> Value { }, { "name": "get_session", - "description": "Read one of your sub-sessions (or your parent session): its state, doing (what it does right now, while a turn runs), queued messages and the text of its last reply.", + "description": "Read one of your sub-sessions (or your parent session): its state, doing (what it does right now, while a turn runs, or what it waits on the user for), waitingOn (question or permission: only the user answers it, in the app), queued messages and the text of its last reply.", "inputSchema": { "type": "object", "properties": {"sessionId": {"type": "string"}}, From 2629b05fff742add3cd1b6d4678e983ac8da6ff6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 17:18:38 -0700 Subject: [PATCH 04/24] fix(acpmux): start_session's reply names where the user sees the child Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15ba9-9614-7832-8e39-d8ca0407c193&host=cosmic-gray --- cmux-tui/crates/acpmux/src/subsession_mcp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/src/subsession_mcp.rs b/cmux-tui/crates/acpmux/src/subsession_mcp.rs index 0c175cdbcf80..6621c72bf729 100644 --- a/cmux-tui/crates/acpmux/src/subsession_mcp.rs +++ b/cmux-tui/crates/acpmux/src/subsession_mcp.rs @@ -181,7 +181,7 @@ async fn call(daemon: &Client, params: &Value) -> Value { "start_session" => match request(method::MUX_SUBSESSION_START, args).await { Ok(r) => ok( format!( - "Started sub-session {} (sessionId {}, depth {}). The user sees it under your chat in the sidebar. It works in parallel; use wait_for_sessions to get its result.", + "Started sub-session {} (sessionId {}, depth {}). The user sees it in your chat's Subagents summary. It works in parallel; use wait_for_sessions to get its result.", r["shortName"].as_str().or(r["name"].as_str()).unwrap_or_default(), r["sessionId"].as_str().unwrap_or_default(), r["depth"] From 340765e201819f5580034024d99b2d32c66e990a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 17:27:40 -0700 Subject: [PATCH 05/24] feat(agent-pane): a sub-session's chat names who wrote to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parent's message to its sub-session (and the child's first prompt, and a report from a child) arrives with provenance kind subsession_message. The chat now draws it as an incoming message card from its sender: its short name (else its full name), who it is to this chat ("Parent · coordinator", "Sub-session · tests"), and an Open that opens that chat beside this one. The catalog also gains the strings the sub-session cards and summary rows use, in all 21 pane locales. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15ba9-9614-7832-8e39-d8ca0407c193&host=cosmic-gray --- .../acpmux/Localizable.xcstrings | 2772 +++++++++++++++++ .../IncomingMessageCard.gallery.ts | 51 + .../conversation/IncomingMessageCard.tsx | 21 +- .../conversation/incomingMessage.test.ts | 55 + .../acpmux/conversation/incomingMessage.ts | 31 + 5 files changed, 2925 insertions(+), 5 deletions(-) diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index 4a693b60dddf..515de40e302a 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -183629,6 +183629,2778 @@ } } } + }, + "message.from.parent": { + "comment": "A message card's sender: the chat that started this one, then its name", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Parent · {name}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأصل · {name}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Roditelj · {name}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Overordnet · {name}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übergeordnet · {name}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Principal · {name}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Parent · {name}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Padre · {name}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "親 · {name}" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មេ · {name}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상위 · {name}" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Overordnet · {name}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nadrzędna · {name}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Principal · {name}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Родительская · {name}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หลัก · {name}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Üst · {name}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Батьківська · {name}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cha · {name}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "父会话 · {name}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "父工作階段 · {name}" + } + } + } + }, + "message.from.subsession": { + "comment": "A message card's sender: a sub-session this chat started, then its name", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sub-session · {name}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة فرعية · {name}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podsesija · {name}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Undersession · {name}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Untersitzung · {name}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Subsesión · {name}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sous-session · {name}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sottosessione · {name}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブセッション · {name}" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "វគ្គរង · {name}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 세션 · {name}" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Underøkt · {name}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podsesja · {name}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Subsessão · {name}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсессия · {name}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซสชันย่อย · {name}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Alt oturum · {name}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підсесія · {name}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Phiên con · {name}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "子会话 · {name}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "子工作階段 · {name}" + } + } + } + }, + "message.from.ancestor": { + "comment": "A message card's sender: a session further up this chat's tree of sub-sessions, then its name", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ancestor · {name}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة أعلى · {name}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nadređena · {name}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Overliggende · {name}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiter oben · {name}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Superior · {name}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ascendant · {name}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Antenato · {name}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上位 · {name}" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ខាងលើ · {name}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상위 계층 · {name}" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lenger opp · {name}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyżej · {name}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Superior · {name}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выше · {name}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระดับบน · {name}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Üst düzey · {name}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вище · {name}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cấp trên · {name}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上级会话 · {name}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上層工作階段 · {name}" + } + } + } + }, + "message.from.descendant": { + "comment": "A message card's sender: another session in this chat's tree of sub-sessions, then its name", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Related · {name}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة مرتبطة · {name}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Povezana · {name}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Relateret · {name}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwandt · {name}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Relacionada · {name}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liée · {name}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Correlata · {name}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "関連 · {name}" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ពាក់ព័ន្ធ · {name}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관련 · {name}" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Relatert · {name}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiązana · {name}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Relacionada · {name}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Связанная · {name}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ที่เกี่ยวข้อง · {name}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İlgili · {name}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Пов’язана · {name}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Liên quan · {name}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相关会话 · {name}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "相關工作階段 · {name}" + } + } + } + }, + "subsession.state.working": { + "comment": "A sub-session's state: its turn is running", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Working" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejder" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Trabajando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In corso" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作業中" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងធ្វើការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeider" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pracuje" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Trabalhando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работает" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังทำงาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Працює" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang làm việc" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作中" + } + } + } + }, + "subsession.queued": { + "comment": "How many messages wait behind a sub-session's running turn", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "{n} queued" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "في الانتظار: {n}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Na čekanju: {n}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "I kø: {n}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wartend: {n}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En cola: {n}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente : {n}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In coda: {n}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機 {n} 件" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងរង់ចាំ៖ {n}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 {n}개" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "I kø: {n}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "W kolejce: {n}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Na fila: {n}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В очереди: {n}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รอคิว {n}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sırada: {n}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У черзі: {n}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang chờ: {n}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排队 {n} 条" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排隊 {n} 則" + } + } + } + }, + "subsession.starting": { + "comment": "A start_session call still running, with the sub-session's name", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting sub-session {name}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ بدء الجلسة الفرعية {name}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokretanje podsesije {name}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Starter undersession {name}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Untersitzung {name} wird gestartet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciando la subsesión {name}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrage de la sous-session {name}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avvio della sottosessione {name}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブセッション {name} を開始中" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងចាប់ផ្ដើមវគ្គរង {name}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 세션 {name} 시작 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Starter underøkt {name}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchamianie podsesji {name}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Iniciando a subsessão {name}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запуск подсессии {name}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังเริ่มเซสชันย่อย {name}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "{name} alt oturumu başlatılıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запуск підсесії {name}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang bắt đầu phiên con {name}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动子会话 {name}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在啟動子工作階段 {name}" + } + } + } + }, + "subsession.startingBare": { + "comment": "A start_session call still running, before the sub-session has a name", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting a sub-session" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ بدء جلسة فرعية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokretanje podsesije" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Starter en undersession" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Untersitzung wird gestartet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciando una subsesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrage d’une sous-session" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avvio di una sottosessione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブセッションを開始中" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងចាប់ផ្ដើមវគ្គរង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 세션 시작 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Starter en underøkt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchamianie podsesji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Iniciando uma subsessão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запуск подсессии" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังเริ่มเซสชันย่อย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Alt oturum başlatılıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запуск підсесії" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang bắt đầu một phiên con" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动子会话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在啟動子工作階段" + } + } + } + }, + "subsession.started": { + "comment": "A start_session call: the sub-session it started, by name", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Started sub-session {name}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بدأت الجلسة الفرعية {name}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokrenuta podsesija {name}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Startede undersession {name}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Untersitzung {name} gestartet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Subsesión {name} iniciada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sous-session {name} démarrée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sottosessione {name} avviata" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブセッション {name} を開始" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានចាប់ផ្ដើមវគ្គរង {name}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 세션 {name} 시작함" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Startet underøkt {name}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchomiono podsesję {name}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Subsessão {name} iniciada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запущена подсессия {name}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เริ่มเซสชันย่อย {name} แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "{name} alt oturumu başlatıldı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запущено підсесію {name}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đã bắt đầu phiên con {name}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已启动子会话 {name}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已啟動子工作階段 {name}" + } + } + } + }, + "subsession.sending": { + "comment": "A send_message call still running: the sub-session's name, then the message's first line", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sending to {name}: {text}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الإرسال إلى {name}: {text}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Slanje za {name}: {text}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sender til {name}: {text}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird an {name} gesendet: {text}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviando a {name}: {text}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoi à {name} : {text}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invio a {name}: {text}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "{name} に送信中: {text}" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងផ្ញើទៅ {name}៖ {text}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "{name}에 보내는 중: {text}" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sender til {name}: {text}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wysyłanie do {name}: {text}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Enviando para {name}: {text}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправка в {name}: {text}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังส่งถึง {name}: {text}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "{name} oturumuna gönderiliyor: {text}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надсилання до {name}: {text}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang gửi tới {name}: {text}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在发送给 {name}:{text}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在傳送給 {name}:{text}" + } + } + } + }, + "subsession.sent": { + "comment": "A send_message call: the sub-session's name, then the message's first line", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sent to {name}: {text}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أُرسلت إلى {name}: {text}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Poslano za {name}: {text}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sendt til {name}: {text}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "An {name} gesendet: {text}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviado a {name}: {text}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyé à {name} : {text}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inviato a {name}: {text}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "{name} に送信: {text}" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានផ្ញើទៅ {name}៖ {text}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "{name}에 보냄: {text}" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sendt til {name}: {text}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wysłano do {name}: {text}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Enviado para {name}: {text}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправлено в {name}: {text}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่งถึง {name} แล้ว: {text}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "{name} oturumuna gönderildi: {text}" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надіслано до {name}: {text}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đã gửi tới {name}: {text}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已发送给 {name}:{text}" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已傳送給 {name}:{text}" + } + } + } + }, + "subsession.delivery.nextStep": { + "comment": "How a message reached a working sub-session: into its running turn, read at its next tool step", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "read at its next step" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقرؤها في خطوتها التالية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "čita se u sljedećem koraku" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "læses ved næste trin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "beim nächsten Schritt gelesen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "se lee en su siguiente paso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "lu à sa prochaine étape" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "letto al passo successivo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のステップで読まれます" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អានក្នុងជំហានបន្ទាប់" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 단계에서 읽음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "leses ved neste steg" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "odczytana w następnym kroku" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "lida na próxima etapa" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "прочтёт на следующем шаге" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อ่านในขั้นถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "sonraki adımda okunur" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "прочитає на наступному кроці" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "đọc ở bước tiếp theo" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一步时读取" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一步時讀取" + } + } + } + }, + "subsession.delivery.queued": { + "comment": "How a message reached a sub-session: queued behind its running turn, at a position", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "queued, position {n}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "في قائمة الانتظار، الموضع {n}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "na čekanju, pozicija {n}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "i kø, plads {n}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "wartet, Position {n}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "en cola, posición {n}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "en attente, position {n}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "in coda, posizione {n}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中、{n} 番目" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងរង់ចាំ ទីតាំង {n}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중, {n}번째" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "i kø, plass {n}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "w kolejce, pozycja {n}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "na fila, posição {n}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "в очереди, позиция {n}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รอคิว ลำดับที่ {n}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "sırada, {n}. konum" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "у черзі, позиція {n}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "đang chờ, vị trí {n}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已排队,第 {n} 位" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已排隊,第 {n} 位" + } + } + } + }, + "subsession.delivery.queuedBare": { + "comment": "How a message reached a sub-session: queued behind its running turn", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "queued" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "في قائمة الانتظار" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "na čekanju" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "i kø" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "wartet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "en cola" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "en attente" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "in coda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងរង់ចាំ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "i kø" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "w kolejce" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "na fila" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "в очереди" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รอคิว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "sırada" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "у черзі" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "đang chờ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已排队" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已排隊" + } + } + } + }, + "subsession.delivery.started": { + "comment": "How a message reached an idle sub-session: it started a turn with it", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "started a turn" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بدأت دورًا" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "pokrenut je potez" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "startede en tur" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Durchgang gestartet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "inició un turno" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "a lancé un tour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "ha avviato un turno" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターンを開始" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានចាប់ផ្ដើមវេន" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "턴을 시작함" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "startet en runde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "rozpoczęła turę" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "iniciou um turno" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "начала ход" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เริ่มเทิร์นแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "bir tur başlattı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "розпочала хід" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "đã bắt đầu một lượt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已开始一轮" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已開始一輪" + } + } + } + }, + "subsession.waiting": { + "comment": "A wait_for_sessions call still running", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for sub-sessions" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بانتظار الجلسات الفرعية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Čekanje podsesija" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Venter på undersessioner" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten auf Untersitzungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando subsesiones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente des sous-sessions" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In attesa delle sottosessioni" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブセッションを待機中" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងរង់ចាំវគ្គរង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하위 세션 기다리는 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Venter på underøkter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oczekiwanie na podsesje" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aguardando subsessões" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание подсессий" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังรอเซสชันย่อย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Alt oturumlar bekleniyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Очікування підсесій" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang chờ các phiên con" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待子会话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在等待子工作階段" + } + } + } + }, + "subsession.waitNone": { + "comment": "A wait_for_sessions call that found no sub-session running", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sub-session was running" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم تكن أي جلسة فرعية قيد العمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nijedna podsesija nije radila" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen undersession kørte" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Untersitzung lief" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ninguna subsesión estaba en ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune sous-session n’était en cours" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna sottosessione era in corso" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中のサブセッションはありません" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "គ្មានវគ្គរងកំពុងដំណើរការទេ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 중인 하위 세션 없음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen underøkt kjørte" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Żadna podsesja nie działała" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma subsessão estava em execução" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ни одна подсессия не работала" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีเซสชันย่อยที่กำลังทำงาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışan alt oturum yoktu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Жодна підсесія не працювала" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Không có phiên con nào đang chạy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有正在运行的子会话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有正在執行的子工作階段" + } + } + } + }, + "subsession.reply": { + "comment": "A wait_for_sessions call: a finished sub-session's reply, by name", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply from {name}" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رد من {name}" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odgovor od {name}" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Svar fra {name}" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Antwort von {name}" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Respuesta de {name}" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réponse de {name}" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Risposta da {name}" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "{name} からの返信" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការឆ្លើយតបពី {name}" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "{name}의 답장" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Svar fra {name}" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odpowiedź od {name}" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Resposta de {name}" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ответ от {name}" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำตอบจาก {name}" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "{name} yanıtı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відповідь від {name}" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Trả lời từ {name}" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "来自 {name} 的回复" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "來自 {name} 的回覆" + } + } + } + }, + "subsession.stillRunning": { + "comment": "A sub-session a wait_for_sessions call left still working", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Still running" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا تزال تعمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Još radi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kører stadig" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft noch" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sigue en ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toujours en cours" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ancora in corso" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだ実行中" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "នៅតែដំណើរការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 실행 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kjører fortsatt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nadal działa" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ainda em execução" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ещё работает" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังทำงานอยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hâlâ çalışıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ще працює" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Vẫn đang chạy" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仍在运行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "仍在執行" + } + } + } + }, + "subsession.waitingQuestion": { + "comment": "A sub-session waits on the person to answer its agent's question, which only the person can answer, in the app", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting on a question · answer in the app" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بانتظار إجابة سؤال · أجب في التطبيق" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Čeka odgovor na pitanje · odgovorite u aplikaciji" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Venter på svar på et spørgsmål · svar i appen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wartet auf eine Antwort · in der App antworten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando una respuesta · responde en la app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente d’une réponse · répondez dans l’app" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In attesa di una risposta · rispondi nell’app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "質問の回答待ち · アプリで回答" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងរង់ចាំចម្លើយ · ឆ្លើយក្នុងកម្មវិធី" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "질문 답변 대기 중 · 앱에서 답변" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Venter på svar på et spørsmål · svar i appen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Czeka na odpowiedź · odpowiedz w aplikacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aguardando uma resposta · responda no app" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ждёт ответа на вопрос · ответьте в приложении" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รอคำตอบคำถาม · ตอบในแอป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir soruya yanıt bekliyor · uygulamada yanıtlayın" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Чекає відповіді на запитання · дайте відповідь у застосунку" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang chờ trả lời câu hỏi · trả lời trong ứng dụng" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待回答问题 · 请在应用中回答" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待回答問題 · 請在 App 中回答" + } + } + } + }, + "subsession.waitingPermission": { + "comment": "A sub-session waits on the person to allow one of its agent's tool calls", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for permission" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بانتظار الإذن" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Čeka dozvolu" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Venter på tilladelse" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wartet auf Erlaubnis" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando permiso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente d’autorisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In attesa di autorizzazione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "許可待ち" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងរង់ចាំការអនុញ្ញាត" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "권한 대기 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Venter på tillatelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Czeka na zgodę" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aguardando permissão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ждёт разрешения" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รอการอนุญาต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İzin bekliyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Чекає дозволу" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang chờ cấp quyền" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待授权" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待授權" + } + } + } } }, "version": "1.0" diff --git a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.gallery.ts b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.gallery.ts index 0bdc8f5cce65..86a562d9a661 100644 --- a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.gallery.ts @@ -40,6 +40,57 @@ export default agentPaneEntry({ summary(3.5, { status: "completed" }), ]), }, + subsession: { + note: "A sub-session's chat: its first prompt and a later message from the chat that started it read Parent · coordinator, and a report from its own sub-session reads Sub-session · fixture; each name opens that chat beside this one.", + snapshot: chat( + [ + user("Run the net tests and report which fail.", 9, { + incoming: { + kind: "message", + message: { + name: "claude-sr2/coordinator", + sessionId: "parent-1", + relation: "parent", + text: "Run the net tests and report which fail.", + raw: "Run the net tests and report which fail.", + }, + }, + untyped: true, + }), + assistant("Running them now; a sub-session is rebuilding the fixtures.", 8.5), + summary(8.5, { status: "completed" }), + user("Also check the retry tests.", 6, { + incoming: { + kind: "message", + message: { + name: "claude-sr2/coordinator", + sessionId: "parent-1", + relation: "parent", + text: "Also check the retry tests.", + raw: "Also check the retry tests.", + }, + }, + untyped: true, + }), + user("Fixtures rebuilt; 3 changed.", 4, { + incoming: { + kind: "message", + message: { + name: "fixture", + sessionId: "grandchild-1", + relation: "subsession", + text: "Fixtures rebuilt; 3 changed.", + raw: "Fixtures rebuilt; 3 changed.", + }, + }, + untyped: true, + }), + assistant("All 41 net tests pass with the new fixtures.", 3.5), + summary(3.5, { status: "completed" }), + ], + { title: "tests" }, + ), + }, notice: { note: "A background task's end in an adopted Claude session's history: one quiet line, no raw XML.", snapshot: chat([ diff --git a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx index a394e4638e03..7af53743473f 100644 --- a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx +++ b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx @@ -1,14 +1,23 @@ // Another agent's message to this chat (incomingMessage.ts) as MessageCard draws one this agent // sent: sender to this agent, the message's first two lines, and the whole message once opened. // Opened, a Claude Code envelope also shows the prompt as the agent got it; a sender that is a -// chat here opens beside this one. +// chat here opens beside this one. A sub-session family's sender reads with who it is to this +// chat: "Parent · coordinator → This agent". import { useId, useLayoutEffect, useRef, useState } from "react"; -import { useT } from "../i18n"; +import { useT, type StringKey } from "../i18n"; import type { PromptImage } from "../model"; -import type { IncomingMessage } from "./incomingMessage"; +import type { IncomingMessage, IncomingRelation } from "./incomingMessage"; import { SentImages } from "./UserBubble"; import { ArrowRight, ChevronDown, ChevronRight, Envelope, TerminalSquare } from "./icons"; +/// A family sender's label, its relation before its name. +const RELATION_LABEL: Record = { + parent: "message.from.parent", + subsession: "message.from.subsession", + ancestor: "message.from.ancestor", + descendant: "message.from.descendant", +}; + export function IncomingMessageCard({ message, images, @@ -22,6 +31,8 @@ export function IncomingMessageCard({ const t = useT(); const [open, setOpen] = useState(false); const to = t("message.self"); + // A sub-session family's sender says who it is to this chat: "Parent · coordinator". + const from = message.relation ? t(RELATION_LABEL[message.relation], { name: message.name }) : message.name; // As MessageCard: a first guess, then the clamped body measures itself. const [long, setLong] = useState(() => message.text.split("\n").length > 2 || message.text.length > 140); const body = useRef(null); @@ -45,14 +56,14 @@ export function IncomingMessageCard({ className="cv-message__head" aria-expanded={open} aria-controls={bodyId} - aria-label={t("message.label", { from: message.name, to })} + aria-label={t("message.label", { from, to })} onClick={() => setOpen((value) => !value)} > - {message.name} + {from} {to} { ); expect(message?.kind === "message" && message.message.text).toBe("\u00a9 \u{1F600} �"); }); + +// As hub/subsessions.rs `send_family_prompt` records it: `from` is the sender's raw session id. +const family = { + kind: "subsession_message", + from: "parent-1", + fromName: "claude-sr2/coordinator", + to: "child-1", + fromRole: "parent", +}; + +test("a sub-session family's message names its sender, who it is to this chat, and opens it", () => { + expect(incomingPrompt(" Run the tests. ", { _meta: { provenance: family }, client: "acpmux-subsession" })).toEqual({ + kind: "message", + message: { + name: "claude-sr2/coordinator", + sessionId: "parent-1", + relation: "parent", + text: "Run the tests.", + raw: " Run the tests. ", + }, + }); + // A sub-session reporting to its parent goes by its short name. + const report = incomingPrompt("Done.", { + _meta: { + provenance: { + ...family, + from: "child-1", + fromName: "claude-sr2/coordinator/tests", + fromShortName: "tests", + to: "parent-1", + fromRole: "subsession", + }, + }, + }); + expect(report).toEqual({ + kind: "message", + message: { name: "tests", sessionId: "child-1", relation: "subsession", text: "Done.", raw: "Done." }, + }); +}); + +test("a family message without a name or a known relation still names and opens its sender", () => { + const bare = incomingPrompt("hi", { + _meta: { provenance: { kind: "subsession_message", from: "acp:child-2", fromShortName: " ", fromRole: "cousin" } }, + }); + expect(bare).toEqual({ + kind: "message", + message: { name: "acp:child-2", sessionId: "child-2", text: "hi", raw: "hi" }, + }); + for (const role of ["ancestor", "descendant"] as const) { + const message = incomingPrompt("hi", { _meta: { provenance: { ...family, fromRole: role } } }); + expect(message?.kind === "message" && message.message.relation).toBe(role); + } + // Without a sender it is the person's prompt. + expect(incomingPrompt("hi", { _meta: { provenance: { ...family, from: "" } } })).toBeUndefined(); +}); diff --git a/webviews/src/agent-session/acpmux/conversation/incomingMessage.ts b/webviews/src/agent-session/acpmux/conversation/incomingMessage.ts index ed9dc60c9d66..64c9bb05eead 100644 --- a/webviews/src/agent-session/acpmux/conversation/incomingMessage.ts +++ b/webviews/src/agent-session/acpmux/conversation/incomingMessage.ts @@ -7,12 +7,24 @@ // person never typed. The transcript draws a message as a card from its sender // (IncomingMessageCard.tsx), a notification as one quiet line, and bookkeeping not at all; a typed // prompt behind bookkeeping draws without it. A live prompt's text is the person's as typed. +// A message within a family of sub-sessions (kind `subsession_message`, hub/subsessions.rs +// `send_family_prompt`: a parent's message to its child, the child's first prompt from +// `start_session`, and a child's report to its parent) names its sender as the receiver's chat +// knows it, its short name, and who it is to this chat. + +/// Who a family message's sender is to this chat (acpmux `fromRole`, subsession_send.rs +/// `family_role`): the chat that started it, one it started, one further up, or another below or +/// beside it. +export type IncomingRelation = "parent" | "subsession" | "ancestor" | "descendant"; +const RELATIONS: readonly string[] = ["parent", "subsession", "ancestor", "descendant"]; export type IncomingMessage = { /// The sender's name, or its address when it gives none. name: string; /// The sending chat, when it is one here that can be opened. sessionId?: string; + /// Who the sender is to this chat, for a sub-session family's message. + relation?: IncomingRelation; /// What the sender wrote. text: string; /// The prompt as the agent got it. @@ -33,6 +45,20 @@ export function incomingPrompt( msg: { _meta?: { provenance?: unknown }; client?: unknown } | undefined, ): IncomingPrompt | undefined { const provenance = msg?._meta?.provenance as Record | undefined; + if (provenance?.kind === "subsession_message" && typeof provenance.from === "string" && provenance.from) { + // `from` is the sender's raw session id, always a chat here; its short name, when it is a + // sub-session itself, reads better than its full name (`claude-sr2/coordinator`). + const sessionId = provenance.from.replace(/^acp:/, "") || undefined; + const name = trimmed(provenance.fromShortName) ?? trimmed(provenance.fromName) ?? provenance.from; + const relation = + typeof provenance.fromRole === "string" && RELATIONS.includes(provenance.fromRole) + ? (provenance.fromRole as IncomingRelation) + : undefined; + return { + kind: "message", + message: { name, ...(sessionId && { sessionId }), ...(relation && { relation }), text: text.trim(), raw: text }, + }; + } if (provenance?.kind === "agent_message" && typeof provenance.from === "string" && provenance.from) { const name = typeof provenance.fromName === "string" && provenance.fromName.trim() @@ -106,6 +132,11 @@ function withoutBookkeeping(text: string): string { return rest; } +/// A non-blank string, trimmed, else undefined. +function trimmed(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + function attribute(attributes: string, name: string): string { return decodeXml(new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(attributes)?.[1] ?? "").trim(); } From 8339bb91fc0b87588e7be45e2495dea3c89d612d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 17:28:23 -0700 Subject: [PATCH 06/24] feat(agent-pane): sub-session cards in the transcript and the summary Calls to the cmux-subsessions tools draw as their own cards instead of the generic MCP row (a new "subsession" tool group, checked before the MCP one): start_session as "Started sub-session tests" with its live state from the session list (working with what it does now, needs input with what it waits on, failed, done, N queued); send_message as "Sent to tests: " with how it was delivered; wait_for_sessions as each sub-session's reply or state. Running calls are one pending line; refused calls and the server's other tools keep the generic row. A sub-session's name opens its chat beside this one through chat.offer.open. The summary card's Subagents section also lists the chat's sub-sessions and theirs, indented, with state, unread dot, queue count and the doing or waiting line; the card shows itself for a chat with sub-sessions. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15ba9-9614-7832-8e39-d8ca0407c193&host=cosmic-gray --- webviews/src/agent-session/acpmux/App.tsx | 110 +++++---- .../conversation/SubsessionCallCard.css | 54 ++++ .../SubsessionCallCard.gallery.ts | 194 +++++++++++++++ .../conversation/SubsessionCallCard.tsx | 231 ++++++++++++++++++ .../acpmux/conversation/TurnRows.tsx | 5 +- .../conversation/subsessionCalls.test.ts | 163 ++++++++++++ .../acpmux/conversation/subsessionCalls.ts | 165 +++++++++++++ .../acpmux/conversation/toolGroups.ts | 12 +- .../src/agent-session/acpmux/sessionList.ts | 48 ++++ .../acpmux/subagents/subsessions.test.ts | 126 ++++++++++ .../acpmux/subagents/subsessions.ts | 184 ++++++++++++++ .../acpmux/summary/SubagentStatusList.tsx | 78 +++++- .../acpmux/summary/SummaryCard.tsx | 26 +- .../agent-session/acpmux/summary/summary.css | 2 + 14 files changed, 1340 insertions(+), 58 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.css create mode 100644 webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.gallery.ts create mode 100644 webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.tsx create mode 100644 webviews/src/agent-session/acpmux/conversation/subsessionCalls.test.ts create mode 100644 webviews/src/agent-session/acpmux/conversation/subsessionCalls.ts create mode 100644 webviews/src/agent-session/acpmux/subagents/subsessions.test.ts create mode 100644 webviews/src/agent-session/acpmux/subagents/subsessions.ts diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 71279a324a99..1fa29eac4aac 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -305,6 +305,7 @@ import { } from "./started/StartedSessionCard"; import { type SessionHover, SessionHoverContext } from "./started/SessionHoverCard"; import { SessionHoverStore, withSessionIds } from "./started/sessionHover"; +import { SubsessionContext, SubsessionStore, type Subsessions, subsessionRows } from "./subagents/subsessions"; import { STARTED_SESSIONS, type StartedCache, @@ -3377,6 +3378,14 @@ function AcpmuxPane() { () => (startedOn ? { store: hoverStore.current!, open: openOfferChat } : null), [startedOn], ); + // The transcript's sub-session cards read each sub-session's state from one store, so a session + // changing never redraws a turn (subagents/subsessions.ts); a name opens its chat beside this one. + const subsessionStore = useRef(null); + subsessionStore.current ??= new SubsessionStore(); + useLayoutEffect(() => { + subsessionStore.current!.set(snapshot.sessions); + }, [snapshot.sessions]); + const subsessions = useMemo(() => ({ store: subsessionStore.current!, open: openOfferChat }), []); // The last snapshot's turns and subagent rows: the next builds on them (turnViewFrom). const lastTurns = useRef<{ turns: TurnView; subagents: SubagentRows } | undefined>(undefined); /// Debug Settings `agent.sessionStatus`: a running turn's activity line under its todo list. @@ -3418,7 +3427,15 @@ function AcpmuxPane() { useLayoutEffect(() => { shownChat.current = shownSessionId; }, [shownSessionId]); - const summaryHasContent = useMemo(() => chatHasSummaryContent(snapshot.rows), [snapshot.rows]); + // The sub-sessions this chat's agent started, and theirs, for the summary card's Subagents list. + const chatSubsessions = useMemo( + () => ({ rows: subsessionRows(snapshot.sessions, shownSessionId), onOpen: openOfferChat }), + [snapshot.sessions, shownSessionId], + ); + const summaryHasContent = useMemo( + () => chatHasSummaryContent(snapshot.rows) || chatSubsessions.rows.length > 0, + [snapshot.rows, chatSubsessions], + ); const [shell, setShell] = useState(null); /// The chat's bottom stack (bottomStack.css): the transcript runs under it and keeps its end above it. const [dock, setDock] = useState(null); @@ -5690,50 +5707,52 @@ function AcpmuxPane() { value={knownMark(snapshot.summary?.harness, snapshot.summary?.family)} > - - - {(find) => ( - composerHandle.current?.focus()} - // Quick Chat's panel stacks its composer below the transcript. - dock={quick ? undefined : dock} - find={quick ? undefined : find} - sessionId={ - childRows ? `${shownSessionId}:subagent:${subagentView}` : shownSessionId - } - canLoadOlder={!childRows && snapshot.canLoadOlder} - onLoadOlder={() => directClient.current?.loadOlder() ?? Promise.resolve()} - openAt={ - !childRows && shownSessionId && scrollAnchor?.sessionId === shownSessionId - ? scrollAnchor - : undefined - } - anchorNow={quick || childRows ? undefined : transcriptAnchor} - onAnchor={ - quick || childRows || !shownSessionId - ? undefined - : (anchor) => postScrollAnchor(shownSessionId, anchor) - } - expanded={expanded} - reveal={childRows ? undefined : revealRow} - registry={registry} - githubRepository={githubRepository} - // The Quick Composer has no room for the changes view; its file rows stay plain. - onOpenDiff={quick ? undefined : openDiff} - onToggleActivity={toggleActivity} - /> - )} - - + + + + {(find) => ( + composerHandle.current?.focus()} + // Quick Chat's panel stacks its composer below the transcript. + dock={quick ? undefined : dock} + find={quick ? undefined : find} + sessionId={ + childRows ? `${shownSessionId}:subagent:${subagentView}` : shownSessionId + } + canLoadOlder={!childRows && snapshot.canLoadOlder} + onLoadOlder={() => directClient.current?.loadOlder() ?? Promise.resolve()} + openAt={ + !childRows && shownSessionId && scrollAnchor?.sessionId === shownSessionId + ? scrollAnchor + : undefined + } + anchorNow={quick || childRows ? undefined : transcriptAnchor} + onAnchor={ + quick || childRows || !shownSessionId + ? undefined + : (anchor) => postScrollAnchor(shownSessionId, anchor) + } + expanded={expanded} + reveal={childRows ? undefined : revealRow} + registry={registry} + githubRepository={githubRepository} + // The Quick Composer has no room for the changes view; its file rows stay plain. + onOpenDiff={quick ? undefined : openDiff} + onToggleActivity={toggleActivity} + /> + )} + + + @@ -6323,6 +6342,7 @@ function AcpmuxPane() { onOpenOutput={quick ? undefined : openOutput} onOpenImage={quick ? undefined : openImage} subagents={cardSubagents} + subsessions={chatSubsessions} /> diff --git a/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.css b/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.css new file mode 100644 index 000000000000..df4ebfa24b10 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.css @@ -0,0 +1,54 @@ +/* A cmux-subsessions call (SubsessionCallCard.tsx): tool rows in their font, height and rhythm, with + nothing on hover (Leo 2026-10-13). States take the Ghostty theme's colours: a sub-session waiting + on the person the warning yellow, a failed one the red; a working one keeps the row's colour and + the working spinner. */ +.cv-subsession__name { + all: unset; + color: var(--cv-text); + font-weight: 500; +} +/* The name opens the sub-session's chat: the pointer and an underline on keyboard focus only, as + neighbouring transcript rows show focus, never a box. */ +.cv-subsession__name.is-link { + cursor: pointer; +} +.cv-subsession__name.is-link:focus-visible { + text-decoration: underline; + text-underline-offset: 2px; +} +.cv-subsession__state[data-state="input"] { + color: var(--acpmux-warning); +} +.cv-subsession__state[data-state="failed"] { + color: var(--agent-danger); +} +.cv-subsession[data-state="failed"] > .cv-tool__icon { + color: var(--agent-danger); +} +.cv-subsession[data-state="input"] > .cv-tool__icon { + color: var(--acpmux-warning); +} +/* What a working sub-session does now, and a reply's first lines: under the row's text, at its + indent (the icon's 16px, less its -1.5px margin, plus the 8px gap). */ +.cv-subsession__under { + margin: -3px 0 4px 22.5px; + color: var(--cv-text-muted); + font-size: var(--text-detail); + line-height: var(--text-detail--line-height); + min-width: 0; +} +.cv-subsession__doing { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cv-subsession__reply { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; + -webkit-user-select: text; +} diff --git a/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.gallery.ts b/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.gallery.ts new file mode 100644 index 000000000000..6ee2ef0720ad --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.gallery.ts @@ -0,0 +1,194 @@ +// l10n-allow-file: gallery fixtures (sample prompts, sub-session names and replies), not shipped UI. +// A chat's calls to the cmux-subsessions tools as sub-session cards (SubsessionCallCard.tsx, +// subsessionCalls.ts): a started sub-session with its live state from the session list (working +// with what it does now and its queue, waiting on a question or a permission, failed, done), a +// message sent to one with how it was delivered, a wait's replies, and calls still running. The +// summary card's Subagents list shows the same sub-sessions (summary/SubagentStatusList.tsx). +import { agentPaneEntry } from "../../../gallery/format"; +import { activity, assistant, chat, session, summary, tool, user, SESSION_ID } from "../../../gallery/fixtures/acpmux"; +import type { AcpmuxSessionEntry } from "../sessionList"; + +/// Claude Code's shape for a call to the server: `mcp__cmux-subsessions__`, the arguments as +/// its input and the result's text as its output. +const call = (name: string, args: unknown, output?: string, status = "completed") => + tool(`mcp__cmux-subsessions__${name}`, "other", status, { + inputSummary: JSON.stringify(args), + ...(output !== undefined && { output }), + }); + +const started = (shortName: string, id: string) => + call( + "start_session", + { prompt: `Own the ${shortName} lane`, name: shortName }, + `Started sub-session ${shortName} (sessionId ${id}, depth 1). The user sees it under your chat in the sidebar. It works in parallel; use wait_for_sessions to get its result.`, + ); + +const sent = (id: string, text: string, said: string) => call("send_message", { sessionId: id, text }, said); + +/// A sub-session of the gallery's chat. +const child = (id: string, shortName: string, fields: Partial = {}) => + session({ + sessionId: id, + title: `Own the ${shortName} lane`, + name: `claude-sr2/${shortName}`, + shortName, + startedBy: SESSION_ID, + lane: "idle", + indicator: "done", + ...fields, + }); + +const CHILDREN: AcpmuxSessionEntry[] = [ + child("sub-tests", "tests", { + lane: "working", + indicator: "working", + status: "running", + queued: 2, + doing: "Check the retry tests · bun test src/net/retry.test.ts", + }), + child("sub-docs", "docs", { + lane: "input", + indicator: "input", + status: "waiting", + waitingOn: { on: "question", prompt: "Which branch should the docs target, main or the release branch?" }, + }), + child("sub-deps", "deps", { lane: "input", indicator: "input", status: "waiting", waitingOn: { on: "permission" } }), + child("sub-bench", "bench", { lane: "failed", indicator: "failed" }), + child("sub-lint", "lint", { unread: true }), + child("sub-fixture", "fixture", { + startedBy: "sub-tests", + lane: "working", + indicator: "working", + doing: "Rebuild the net fixtures", + }), +]; + +/// The gallery's chat, with its sub-sessions in the session list. +const withChildren = (rows: Parameters[0], fields: Parameters[1] = {}) => { + const snapshot = chat(rows, { title: "Split the net audit into lanes", ...fields }); + return { ...snapshot, sessions: [...snapshot.sessions, ...CHILDREN] }; +}; + +const prompt = "Split the net audit into lanes: tests, docs, deps, bench and lint."; + +export default agentPaneEntry({ + id: "agent-pane.subsession-cards", + title: "Sub-session cards", + area: "Agent pane", + height: 560, + covers: ["agent-session/acpmux/conversation/SubsessionCallCard.tsx"], + variants: { + started: { + note: "Five sub-sessions started, each with its state now: tests working (what it does under it, 2 queued), docs waiting on a question (its prompt after), deps waiting for permission, bench failed, lint done. A name opens that chat beside this one.", + snapshot: withChildren( + [ + user(prompt, 6), + activity( + [ + started("tests", "sub-tests"), + started("docs", "sub-docs"), + started("deps", "sub-deps"), + started("bench", "sub-bench"), + started("lint", "sub-lint"), + ], + 5.8, + ), + assistant("Started five sub-sessions; I'll collect their reports.", 5.5), + ], + { isWorking: true }, + ), + }, + messages: { + note: "Messages to sub-sessions and how each was delivered: read at its next step (a working one), queued at a position, or a turn of its own (an idle one).", + snapshot: withChildren([ + user("Tell tests to cover the retries, and lint to also check the docs folder.", 3), + activity( + [ + sent( + "sub-tests", + "Also cover the retry backoff.\nReport the failing cases first.", + "Delivered into its running turn: it reads it at its next tool step.", + ), + sent( + "sub-tests", + "Then rerun the whole suite.", + "Queued behind its running turn (position 2, 2 queued): it reads it when that turn ends. To reach it sooner send with deliver: next_step, or interrupt to stop the turn.", + ), + sent("sub-lint", "Lint the docs folder too.", "Sent: it started a turn with it."), + ], + 2.8, + ), + assistant("Sent both.", 2.5), + summary(2.5, { status: "completed" }), + ]), + }, + replies: { + note: "A wait's result: lint's reply (its first lines), docs still waiting on its question, and tests still running when the wait timed out.", + snapshot: withChildren([ + user("Wait for the lanes and summarize.", 2), + activity( + [ + call( + "wait_for_sessions", + {}, + `Still running after 45 s; call wait_for_sessions again to keep waiting.\n${JSON.stringify({ + timedOut: true, + sessions: [ + { + sessionId: "sub-lint", + shortName: "lint", + state: "idle", + lastReply: + "Lint is clean in src/net and docs/.\nTwo warnings remain in the generated client; they come from the codegen template and are left as they are.", + }, + { + sessionId: "sub-docs", + shortName: "docs", + state: "input", + waitingOn: "question", + doing: "Waiting for the user to answer, in the app: Which branch should the docs target?", + }, + { sessionId: "sub-tests", shortName: "tests", state: "working", doing: "Run bun test" }, + ], + })}`, + ), + ], + 1.8, + ), + assistant("Lint is clean; docs needs your answer on its branch; tests are still running.", 1.5), + summary(1.5, { status: "completed" }), + ]), + }, + pending: { + note: "Calls still running: one compact line each with the working spinner.", + snapshot: withChildren( + [ + user(prompt, 1), + activity( + [ + call("start_session", { prompt: "Own the perf lane", name: "perf" }, undefined, "in_progress"), + call("send_message", { sessionId: "sub-tests", text: "Status?" }, undefined, "in_progress"), + call("wait_for_sessions", { sessionIds: ["sub-tests"] }, undefined, "in_progress"), + ], + 0.8, + ), + ], + { isWorking: true }, + ), + }, + summary: { + note: "The summary card's Subagents list with the chat's sub-sessions: docs and deps waiting on the person first (what for under each), then tests working with 2 queued and its own sub-session fixture indented under it, then bench failed and lint done with its unread dot.", + snapshot: withChildren([ + user(prompt, 6), + activity([started("tests", "sub-tests")], 5.8), + assistant("Started the lanes.", 5.5), + ]), + play: async (ctx) => { + await ctx.waitFor(() => ctx.document.querySelector(".acpmux-summary-card .acpmux-summary-subsession")); + const rows = ctx.document.querySelectorAll(".acpmux-summary-card .acpmux-summary-subsession"); + if (rows.length !== CHILDREN.length) + throw new Error(`summary lists ${rows.length} sub-sessions, expected ${CHILDREN.length}`); + }, + }, + }, +}); diff --git a/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.tsx b/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.tsx new file mode 100644 index 000000000000..f7bf24707551 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/SubsessionCallCard.tsx @@ -0,0 +1,231 @@ +// A call to the cmux-subsessions tools (subsessionCalls.ts) as what it did to which sub-session, +// drawn as tool rows are, in their font, height and rhythm, with nothing on hover: +// "Started sub-session tests · Working · 2 queued" with what its turn does now under it, live +// from the session list (subagents/subsessions.ts); "Sent to tests: · read at its next +// step"; and for a wait, "Reply from tests" over the reply's first lines, or the sub-session's +// state while it still runs. A sub-session waiting on the person says what for: a question (its +// first prompt after) or a permission, which only the person answers, in its chat. Its name opens its chat beside this one without taking +// focus (`chat.offer.open`). A call still running is one line with the working spinner. +import type { ReactNode } from "react"; +import { useContext } from "react"; +import { type Translate, useT } from "../i18n"; +import { Icon } from "../icons/Icon"; +import { + SUBSESSION_STATE_LABEL, + SubsessionContext, + type SubsessionState, + useSubsessionInfo, + waitingLine, +} from "../subagents/subsessions"; +import type { SessionWaiting } from "../sessionList"; +import { Envelope, Spinner } from "./icons"; +import type { SubsessionCall, SubsessionDelivery, SubsessionReply } from "./subsessionCalls"; +import "./SubsessionCallCard.css"; + +export function SubsessionCallCard({ call }: { call: SubsessionCall }) { + switch (call.tool) { + case "start": + return ; + case "send": + return ; + case "wait": + return ; + } +} + +/// Stands in for the name in a translated line, so the name can be drawn as its link. +const NAME = "\u2063name\u2063"; + +/// `line` (translated with `{name}` as ``NAME``) with the name drawn as `node`. +function withName(line: string, node: ReactNode): ReactNode { + const at = line.indexOf(NAME); + if (at < 0) return line; + return ( + <> + {line.slice(0, at)} + {node} + {line.slice(at + NAME.length)} + + ); +} + +/// A sub-session's name: a link that opens its chat beside this one when the pane can open it. +function SessionName({ sessionId, name }: { sessionId?: string; name: string }) { + const t = useT(); + const open = useContext(SubsessionContext)?.open; + if (!sessionId || !open) return {name}; + return ( + + ); +} + +/// What a sub-session's state reads as after its name: its state, and how many messages wait. +function StateDetail({ state, queued }: { state: SubsessionState; queued?: number }) { + const t = useT(); + return ( + + {" · "} + {t(SUBSESSION_STATE_LABEL[state])} + {queued ? ` · ${t("subsession.queued", { n: queued })}` : ""} + + ); +} + +function StartedLine({ call }: { call: Extract }) { + const t = useT(); + const live = useSubsessionInfo(call.sessionId); + const name = live?.name ?? call.name; + if (call.running) + return ( +

+ + + + + {name ? t("subsession.starting", { name }) : t("subsession.startingBare")} + +
+ ); + return ( + <> +
+ + {live?.state === "working" ? : } + + + {withName( + t("subsession.started", { name: NAME }), + , + )} + {live && } + +
+ + + ); +} + +/// The line under a sub-session's row: what its turn does now, or what it waits on the person for +/// (a question or a permission, answered in its chat, which its name opens). +function UnderLine({ doing, waiting }: { doing?: string; waiting?: SessionWaiting }) { + const t = useT(); + const line = waiting ? waitingLine(t, waiting) : doing; + if (!line) return null; + return ( +
+ {line} +
+ ); +} + +/// When the receiver reads the message, after the line. +function deliveryText(t: Translate, delivery: SubsessionDelivery): string { + switch (delivery.kind) { + case "next_step": + return t("subsession.delivery.nextStep"); + case "queued": + return delivery.position !== undefined + ? t("subsession.delivery.queued", { n: delivery.position }) + : t("subsession.delivery.queuedBare"); + case "started": + return t("subsession.delivery.started"); + } +} + +function SentLine({ call }: { call: Extract }) { + const t = useT(); + const live = useSubsessionInfo(call.sessionId); + const name = live?.name ?? shortId(call.sessionId); + const first = firstLine(call.text); + return ( +
+ {call.running ? : } + + {withName( + t(call.running ? "subsession.sending" : "subsession.sent", { name: NAME, text: first }), + , + )} + {call.delivery && {` · ${deliveryText(t, call.delivery)}`}} + +
+ ); +} + +function WaitLines({ call }: { call: Extract }) { + const t = useT(); + if (call.running) + return ( +
+ + + + {t("subsession.waiting")} +
+ ); + if (call.sessions.length === 0) + return ( +
+ + + + {t("subsession.waitNone")} +
+ ); + return ( + <> + {call.sessions.map((session) => ( + + ))} + + ); +} + +/// One sub-session a wait read: its reply once it finished, else its state then ("Still running"). +function ReplyLine({ session }: { session: SubsessionReply }) { + const t = useT(); + const live = useSubsessionInfo(session.sessionId); + const name = live?.name ?? session.name ?? shortId(session.sessionId); + const link = ; + const replied = session.state === "done" || (session.state === "failed" && session.reply); + return ( + <> +
+ + + + + {replied ? withName(t("subsession.reply", { name: NAME }), link) : link} + {session.state === "working" ? ( + {` · ${t("subsession.stillRunning")}`} + ) : ( + session.state !== "done" && + )} + +
+ {replied && session.reply && ( +
{session.reply}
+ )} + {session.state === "input" && } + + ); +} + +/// A message's first line that says something. +function firstLine(text: string): string { + return ( + text + .split("\n") + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +/// A session id short enough for a line, for a session the list does not have. +const shortId = (sessionId: string) => (sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId); diff --git a/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx b/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx index 5cf8dfb32e43..952ef9d5f4d5 100644 --- a/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx +++ b/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx @@ -7,6 +7,7 @@ import type { AcpmuxActivity, AcpmuxRow } from "../model"; import { copyText } from "./clipboard"; import { MessageCard } from "./MessageCard"; import { McpCallRows } from "./McpCallRows"; +import { SubsessionCallCard } from "./SubsessionCallCard"; import { mcpNames, McpNamesContext } from "./mcpCalls"; import { ToolGroupRow } from "./ToolGroupRow"; import { ToolRow } from "./ToolRow"; @@ -54,7 +55,7 @@ export function WorkedFor({ ); } -/// One entry of a run: a group, a message card, a lone call or a thought. `live`: the entry is the +/// One entry of a run: a group, a message or sub-session card, a lone call or a thought. `live`: the entry is the /// last of the live turn's latest row, so a thought there may still stream. function groupNode(group: ToolGroup, index: number, rowId: string, live: boolean): ReactNode { switch (group.type) { @@ -77,6 +78,8 @@ function groupNode(group: ToolGroup, index: number, rowId: string, live: boolean ); case "message": return ; + case "subsession": + return ; case "item": return group.item.tool ? ( diff --git a/webviews/src/agent-session/acpmux/conversation/subsessionCalls.test.ts b/webviews/src/agent-session/acpmux/conversation/subsessionCalls.test.ts new file mode 100644 index 000000000000..f6df1e1e72b4 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/subsessionCalls.test.ts @@ -0,0 +1,163 @@ +// l10n-allow-file: test fixtures. +// Calls to the cmux-subsessions tools as the transcript's sub-session cards (subsessionCalls.ts), +// read from either result a harness hands the pane: Claude Code's text, or Codex's whole MCP result. +import { expect, test } from "bun:test"; +import type { AcpmuxActivity } from "../model"; +import { subsessionCall } from "./subsessionCalls"; +import { toolGroups } from "./toolGroups"; + +type Tool = NonNullable; + +let ids = 0; +/// Claude Code's shape: `mcp____`, the arguments as the input, the result's text as +/// the output. +function claude(name: string, args: unknown, output?: string, status = "completed"): Tool { + return { + id: `call-${(ids += 1)}`, + title: `mcp__cmux-subsessions__${name}`, + status, + inputSummary: JSON.stringify(args), + ...(output !== undefined && { output }), + }; +} + +/// Codex's shape: `{server, tool, arguments}`, the whole MCP result as the output. +function codex(name: string, args: unknown, structured: unknown, text = ""): Tool { + return { + id: `call-${(ids += 1)}`, + title: `cmux-subsessions.${name} call`, + kind: "execute", + status: "completed", + inputSummary: JSON.stringify({ server: "cmux-subsessions", tool: name, arguments: args }), + output: JSON.stringify({ content: [{ type: "text", text }], structuredContent: structured, isError: false }), + }; +} + +const item = (tool: Tool): AcpmuxActivity => ({ kind: "tool", text: tool.title, tool }); + +test("start_session names the sub-session it started, from its text or its structured result", () => { + const said = + "Started sub-session tests (sessionId 0199a-child, depth 1). The user sees it under your chat in the sidebar."; + expect(subsessionCall(claude("start_session", { prompt: "Run the tests", name: "tests" }, said))).toEqual({ + tool: "start", + running: false, + sessionId: "0199a-child", + name: "tests", + }); + const structured = { sessionId: "0199b-child", name: "claude-sr2/lint", shortName: "lint", depth: 1 }; + expect(subsessionCall(codex("start_session", { prompt: "Lint" }, structured, said))).toEqual({ + tool: "start", + running: false, + sessionId: "0199b-child", + name: "lint", + }); + // Still running: the name it asked for, if any. + expect(subsessionCall(claude("start_session", { prompt: "x", name: "docs" }, undefined, "in_progress"))).toEqual({ + tool: "start", + running: true, + name: "docs", + }); +}); + +test("send_message says how the message was delivered", () => { + const args = { sessionId: "child-1", text: "Also check the retries.\nThen report." }; + const cases: [string, unknown][] = [ + ["Delivered into its running turn: it reads it at its next tool step.", { kind: "next_step" }], + [ + "Queued behind its running turn (position 2, 2 queued): it reads it when that turn ends. To reach it sooner …", + { kind: "queued", position: 2 }, + ], + ["Sent: it started a turn with it.", { kind: "started" }], + ]; + for (const [said, delivery] of cases) + expect(subsessionCall(claude("send_message", args, said))).toEqual({ + tool: "send", + running: false, + sessionId: "child-1", + text: args.text, + delivery, + } as never); + const structured = { sessionId: "child-1", delivery: "queued", position: 3, queueLength: 3 }; + expect(subsessionCall(codex("send_message", args, structured))).toMatchObject({ + delivery: { kind: "queued", position: 3 }, + }); + expect(subsessionCall(claude("send_message", args, undefined, "pending"))).toMatchObject({ + tool: "send", + running: true, + }); +}); + +test("wait_for_sessions lists each sub-session's state and reply, and what one waiting on the person waits for", () => { + const rows = { + timedOut: true, + sessions: [ + { sessionId: "child-1", shortName: "tests", state: "idle", lastReply: " All 41 tests pass. " }, + { sessionId: "child-2", shortName: "lint", state: "working", doing: "Run oxlint" }, + { + sessionId: "child-3", + name: "claude-sr2/docs", + state: "input", + waitingOn: "question", + doing: "Waiting for the user to answer, in the app: Which branch?", + }, + { sessionId: "child-4", shortName: "deps", state: "input", waitingOn: "permission" }, + { sessionId: "child-5", shortName: "bench", state: "lost" }, + ], + }; + const expected = { + tool: "wait", + running: false, + timedOut: true, + sessions: [ + { sessionId: "child-1", name: "tests", state: "done", reply: "All 41 tests pass." }, + { sessionId: "child-2", name: "lint", state: "working" }, + { + sessionId: "child-3", + name: "claude-sr2/docs", + state: "input", + waiting: { on: "question", prompt: "Which branch?" }, + }, + { sessionId: "child-4", name: "deps", state: "input", waiting: { on: "permission" } }, + { sessionId: "child-5", name: "bench", state: "failed" }, + ], + }; + // Claude Code's text: a line when it timed out, then the JSON. + const said = `Still running after 45 s; call wait_for_sessions again to keep waiting.\n${JSON.stringify(rows, null, 2)}`; + expect(subsessionCall(claude("wait_for_sessions", {}, said))).toEqual(expected as never); + expect(subsessionCall(codex("wait_for_sessions", {}, rows))).toEqual(expected as never); + expect(subsessionCall(claude("wait_for_sessions", {}, "None of your sub-sessions is running."))).toEqual({ + tool: "wait", + running: false, + timedOut: false, + sessions: [], + }); +}); + +test("a failed or refused call, another tool or another server keeps the generic MCP row", () => { + expect(subsessionCall(claude("start_session", { prompt: "x" }, "too many sub-sessions", "failed"))).toBeUndefined(); + // A refusal the harness reports as completed: no session started, no delivery. + expect( + subsessionCall(claude("start_session", { prompt: "x" }, "start_session refused: no worktree")), + ).toBeUndefined(); + expect(subsessionCall(claude("send_message", { sessionId: "c", text: "hi" }, "not one of yours"))).toBeUndefined(); + expect(subsessionCall(claude("list_sessions", {}, "{}"))).toBeUndefined(); + expect( + subsessionCall({ id: "x", title: "mcp__gmail__send_message", status: "completed", inputSummary: "{}" }), + ).toBeUndefined(); +}); + +test("each sub-session call is its own card, between the calls around it", () => { + const read: AcpmuxActivity = { + kind: "tool", + text: "Read", + tool: { id: "read", title: "Read", kind: "read", status: "completed" }, + }; + const start = claude("start_session", { prompt: "x" }, "Started sub-session a (sessionId s-a, depth 1)."); + const send = claude("send_message", { sessionId: "s-a", text: "hi" }, "Sent: it started a turn with it."); + const list = claude("list_sessions", {}, "{}"); + const groups = toolGroups([read, item(start), item(send), item(list), item(claude("list_sessions", {}, "{}"))]); + expect(groups.map((group) => group.type)).toEqual(["item", "subsession", "subsession", "mcp"]); + expect(groups[1]).toMatchObject({ type: "subsession", call: { tool: "start", sessionId: "s-a" } }); + // The two list_sessions calls still fold into one MCP line. + expect(groups[3]).toMatchObject({ type: "mcp", items: [{ tool: list }, {}] }); +}); diff --git a/webviews/src/agent-session/acpmux/conversation/subsessionCalls.ts b/webviews/src/agent-session/acpmux/conversation/subsessionCalls.ts new file mode 100644 index 000000000000..4c7f0c7f826d --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/subsessionCalls.ts @@ -0,0 +1,165 @@ +// l10n-allow-file: it matches the cmux-subsessions server's own text (subsession_mcp.rs) and shows none. +// Calls to the cmux-subsessions MCP server's tools (acpmux subsession_mcp.rs) that the transcript +// draws as sub-session cards (SubsessionCallCard.tsx) instead of the generic MCP row: a +// `start_session` as the sub-session it started, a `send_message` as the message and how it was +// delivered, a `wait_for_sessions` as each sub-session's reply. The server answers with a line of +// text for the agent and the same facts as `structuredContent`; a harness hands the pane the text +// (Claude Code) or the whole result (Codex), so both are read. A failed call, a call to its other +// tools, and a result in neither form keep the generic MCP row (McpCallRows.tsx), which shows it. +import type { AcpmuxActivity } from "../model"; +import { type SessionWaiting, sessionWaiting } from "../sessionList"; +import { mcpCall, type McpCall } from "./mcpCalls"; + +type Tool = NonNullable; + +/// When the receiver reads a message (subsession_mcp.rs `sent_text`): in its running turn at its +/// next tool step, queued behind that turn at `position`, or at once in a turn of its own. +export type SubsessionDelivery = { kind: "next_step" } | { kind: "queued"; position?: number } | { kind: "started" }; + +/// One sub-session `wait_for_sessions` read: its state then, and its last reply once it finished. +export type SubsessionReply = { + sessionId: string; + /// Its short name, else its full name, as the result gives them. + name?: string; + state: "working" | "input" | "failed" | "done"; + reply?: string; + /// What it waited on the person for, when it did (the row's `waitingOn`). + waiting?: SessionWaiting; +}; + +export type SubsessionCall = + | { tool: "start"; running: boolean; sessionId?: string; name?: string } + | { tool: "send"; running: boolean; sessionId: string; text: string; delivery?: SubsessionDelivery } + | { tool: "wait"; running: boolean; timedOut: boolean; sessions: SubsessionReply[] }; + +/// A row's doing line while its session waits on a question, with the question's first prompt. +const QUESTION_DOING = /^Waiting for the user to answer, in the app: (.+)$/s; + +/// The server's name as a harness names it (`cmux-subsessions`, or with an underscore). +const SERVER = /^cmux[-_]subsessions$/; + +/// The sub-session card a call draws as, or nil for every other call. `call` is its MCP call, when +/// the caller has it read already. +export function subsessionCall(tool: Tool, call: McpCall | undefined = mcpCall(tool)): SubsessionCall | undefined { + if (!call || !SERVER.test(call.server) || tool.status === "failed") return undefined; + const args = isRecord(call.args) ? call.args : {}; + const running = tool.status === "pending" || tool.status === "in_progress"; + const result = running ? undefined : resultOf(tool.output); + switch (call.tool) { + case "start_session": { + const askedName = text(args.name); + if (running) return { tool: "start", running, ...(askedName && { name: askedName }) }; + // "Started sub-session tests (sessionId 0199…, depth 1). …" + const said = /^Started sub-session (.*?) \(sessionId ([^,\s)]+)/.exec(result?.text ?? ""); + const sessionId = text(result?.value?.sessionId) ?? said?.[2]; + if (!sessionId) return undefined; + const name = text(result?.value?.shortName) ?? text(result?.value?.name) ?? text(said?.[1]) ?? askedName; + return { tool: "start", running, sessionId, ...(name && { name }) }; + } + case "send_message": { + const sessionId = text(args.sessionId); + if (!sessionId) return undefined; + const message = typeof args.text === "string" ? args.text : ""; + const delivery = result && deliveryOf(result); + // An ended call that does not say how it was delivered was refused: the generic row shows why. + if (!running && !delivery) return undefined; + return { tool: "send", running, sessionId, text: message, ...(delivery && { delivery }) }; + } + case "wait_for_sessions": { + if (running) return { tool: "wait", running, timedOut: false, sessions: [] }; + if (result?.text.trim() === "None of your sub-sessions is running.") + return { tool: "wait", running, timedOut: false, sessions: [] }; + const rows = result?.value?.sessions; + if (!Array.isArray(rows)) return undefined; + const sessions = rows.filter(isRecord).flatMap((row): SubsessionReply[] => { + const id = text(row.sessionId); + if (!id) return []; + const name = text(row.shortName) ?? text(row.name); + const reply = text(typeof row.lastReply === "string" ? row.lastReply.trim() : undefined); + const state = rowState(row); + // A question's first prompt is in the row's doing line (subsession_send.rs `doing`). + const asked = typeof row.doing === "string" ? QUESTION_DOING.exec(row.doing)?.[1] : undefined; + const waiting = + state === "input" ? sessionWaiting({ waitingOn: row.waitingOn, waitingPrompt: asked }) : undefined; + return [{ sessionId: id, ...(name && { name }), state, ...(reply && { reply }), ...(waiting && { waiting }) }]; + }); + return { tool: "wait", running, timedOut: result!.value!.timedOut === true, sessions }; + } + default: + return undefined; + } +} + +/// A family row's state (`subsession_row`): its lane, `working`, `input`, `failed`, `lost` or `idle`. +function rowState(row: Record): SubsessionReply["state"] { + switch (row.state) { + case "working": + return "working"; + case "input": + return "input"; + case "failed": + case "lost": + return "failed"; + default: + return "done"; + } +} + +function deliveryOf(result: { value?: Record; text: string }): SubsessionDelivery | undefined { + const value = result.value; + const position = typeof value?.position === "number" ? value.position : undefined; + switch (value?.delivery) { + case "next_step": + return { kind: "next_step" }; + case "queued": + return { kind: "queued", ...(position !== undefined && { position }) }; + case "started": + return { kind: "started" }; + } + // The text alone (subsession_mcp.rs `sent_text`). + const said = result.text.trim(); + if (said.startsWith("Delivered into its running turn")) return { kind: "next_step" }; + const queued = /^Queued behind its running turn \(position (\d+)/.exec(said); + if (queued) return { kind: "queued", position: Number(queued[1]) }; + if (said.startsWith("Sent: it started a turn")) return { kind: "started" }; + return undefined; +} + +/// A call's result: its structured facts when the pane has them, and its text for the agent. +/// The output is the text (a sentence, or JSON, `wait_for_sessions`' after a line of its own), or +/// the whole MCP result (`{content, structuredContent}`, perhaps under `result`). +function resultOf(output: string | undefined): { value?: Record; text: string } | undefined { + if (!output) return undefined; + const whole = parseJSON(output); + if (isRecord(whole)) { + const result = isRecord(whole.result) ? whole.result : whole; + const content = Array.isArray(result.content) ? result.content : undefined; + const said = content + ?.filter((block) => isRecord(block) && block.type === "text") + .map((block) => String((block as { text?: unknown }).text ?? "")) + .join("\n"); + if (isRecord(result.structuredContent)) return { value: result.structuredContent, text: said ?? "" }; + if (said !== undefined) return resultOf(said) ?? { text: said }; + return { value: result, text: output }; + } + // "Still running after 45 s; …\n{…}". + const brace = output.indexOf("\n{"); + const after = brace >= 0 ? parseJSON(output.slice(brace + 1)) : undefined; + return isRecord(after) ? { value: after, text: output } : { text: output }; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function parseJSON(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/webviews/src/agent-session/acpmux/conversation/toolGroups.ts b/webviews/src/agent-session/acpmux/conversation/toolGroups.ts index 13ad7b7e5948..e7e7736d97a1 100644 --- a/webviews/src/agent-session/acpmux/conversation/toolGroups.ts +++ b/webviews/src/agent-session/acpmux/conversation/toolGroups.ts @@ -1,10 +1,11 @@ // A run of tool calls split into what the transcript draws: consecutive calls of one kind // under one line ("Ran 3 commands", "Read 4 files", "Edited App.tsx +12 -3"), messages to -// other agents as cards, and everything else as its own row. +// other agents and calls to the cmux-subsessions tools as cards, and everything else as its own row. import type { AcpmuxActivity } from "../model"; import type { Translate } from "../i18n"; import { agentMessage, type AgentMessage } from "./agentMessages"; import { mcpCall, mcpKey } from "./mcpCalls"; +import { subsessionCall, type SubsessionCall } from "./subsessionCalls"; /// The kinds of call that group with their neighbors. export type ToolGroupKind = "commands" | "reads" | "edits" | "searches"; @@ -12,6 +13,8 @@ export type ToolGroupKind = "commands" | "reads" | "edits" | "searches"; export type ToolGroup = | { type: "group"; kind: ToolGroupKind; items: AcpmuxActivity[] } | { type: "message"; item: AcpmuxActivity; message: AgentMessage } + /// A sub-session started, sent a message or waited on (subsessionCalls.ts), each its own card. + | { type: "subsession"; item: AcpmuxActivity; call: SubsessionCall } /// Consecutive calls to one MCP server's tool (mcpCalls.ts), one or more. | { type: "mcp"; items: AcpmuxActivity[] } | { type: "item"; item: AcpmuxActivity }; @@ -59,19 +62,22 @@ export function toolGroups(items: readonly AcpmuxActivity[]): ToolGroup[] { for (const item of items) { const tool = item.tool; const message = tool && agentMessage(tool); - const call = tool && !message ? mcpCall(tool) : undefined; + const named = tool && !message ? mcpCall(tool) : undefined; + const subsession = named && subsessionCall(tool!, named); + const call = subsession ? undefined : named; const key = call && mcpKey(call); if (key && mcp?.key === key) { mcp.items.push(item); continue; } - const kind = tool && !message && !call ? toolGroupKind(tool) : undefined; + const kind = tool && !message && !subsession && !call ? toolGroupKind(tool) : undefined; if (kind && run?.kind === kind) { run.items.push(item); continue; } close(); if (message) groups.push({ type: "message", item, message }); + else if (subsession) groups.push({ type: "subsession", item, call: subsession }); else if (key) mcp = { key, items: [item] }; else if (kind) run = { kind, items: [item] }; else groups.push({ type: "item", item }); diff --git a/webviews/src/agent-session/acpmux/sessionList.ts b/webviews/src/agent-session/acpmux/sessionList.ts index 8a68715932de..8de8f5e3c653 100644 --- a/webviews/src/agent-session/acpmux/sessionList.ts +++ b/webviews/src/agent-session/acpmux/sessionList.ts @@ -46,6 +46,22 @@ export type AcpmuxSessionEntry = { parentId?: string; /** Started by another session's agent (acpmux `spawnedBy`): it folds under that chat, off every list. */ spawnedBy?: string; + /** A sub-session: the chat whose agent started it with its `start_session` tool (acpmux + * `startedBy`, kept at any depth and after that chat closes). */ + startedBy?: string; + /** A sub-session's name among its parent's (acpmux `shortName`, "tests"). */ + shortName?: string; + /** What it is doing (acpmux `lane.state`): working, waiting on the person, failed, lost or idle. */ + lane?: SessionLane; + /** Prompts waiting behind its running turn, when any. */ + queued?: number; + /** One line of what its running turn does now: its plan step and latest tool, as acpmux says it + * to the parent's agent (`doing`). */ + doing?: string; + /** What it waits on the person for, while it does: an agent question (with its first prompt) + * or a tool's permission. acpmux names it on a sub-session's family rows (`waitingOn`, + * subsession_send.rs `waiting_on`); a summary that does not leaves it unknown. */ + waitingOn?: SessionWaiting; forkSeq?: number; /** Completed turns, the copied ones of a fork included. */ turnCount?: number; @@ -124,6 +140,32 @@ function tagged(tags: unknown, tag: string): boolean { export type SessionIndicator = "input" | "working" | "failed" | "lost" | "done"; const INDICATORS: readonly string[] = ["input", "working", "failed", "lost", "done"]; +/** acpmux's lane states (`SessionSummary.lane.state`). */ +export type SessionLane = "working" | "input" | "failed" | "lost" | "idle"; +const LANES: readonly string[] = ["working", "input", "failed", "lost", "idle"]; + +/** A running turn's line as acpmux's `doing_line` (hub/activity.rs) writes it: its plan step and + * latest tool, either alone; else what the turn was asked (`lane.step`). */ +function doingLine(session: Record): string | undefined { + if (session.lane?.state !== "working") return undefined; + const step = text(session.turn?.planStep); + const tool = text(session.turn?.activity); + if (step && tool && step !== tool) return `${step} · ${tool}`; + return step ?? tool ?? text(session.lane?.step); +} + +/** What a session waits on the person for: only the person answers either, in the app. */ +export type SessionWaiting = { on: "question"; prompt?: string } | { on: "permission" }; + +/** `waitingOn` while the session waits on the person, with a question's first prompt + * (`waitingPrompt`) when acpmux gives it. */ +export function sessionWaiting(session: Record): SessionWaiting | undefined { + if (session.waitingOn === "permission") return { on: "permission" }; + if (session.waitingOn !== "question") return undefined; + const prompt = text(session.waitingPrompt); + return prompt ? { on: "question", prompt } : { on: "question" }; +} + /** What a row draws at its right edge, most urgent first. */ export type SessionMark = "input" | "running" | "error" | "unread" | undefined; @@ -162,6 +204,12 @@ export function sessionEntry(session: Record & { sessionId: string preview: text(session.preview), parentId: text(session.parentId), spawnedBy: text(session.spawnedBy), + startedBy: text(session.startedBy), + shortName: text(session.shortName), + lane: LANES.includes(session.lane?.state) ? session.lane.state : undefined, + queued: Number(session.queued) > 0 ? Number(session.queued) : undefined, + doing: doingLine(session), + waitingOn: sessionWaiting(session), forkSeq: typeof session.forkSeq === "number" ? session.forkSeq : undefined, turnCount: typeof session.turnCount === "number" ? session.turnCount : undefined, unsent: diff --git a/webviews/src/agent-session/acpmux/subagents/subsessions.test.ts b/webviews/src/agent-session/acpmux/subagents/subsessions.test.ts new file mode 100644 index 000000000000..79a9191c77f5 --- /dev/null +++ b/webviews/src/agent-session/acpmux/subagents/subsessions.test.ts @@ -0,0 +1,126 @@ +// l10n-allow-file: test fixtures. +// A chat's sub-sessions as the summary card lists them and the transcript's cards read them +// (subsessions.ts), from acpmux's session summaries as the pane cuts them (sessionList.ts). +import { expect, test } from "bun:test"; +import { sessionEntry } from "../sessionList"; +import { SubsessionStore, subsessionInfo, subsessionRows, waitingLine } from "./subsessions"; +import { translate } from "../i18n"; + +/// A session summary as acpmux sends it, with the fields the sub-session rows read. +function summary(sessionId: string, fields: Record = {}) { + return sessionEntry({ + sessionId, + name: `claude-sr2/${sessionId}`, + status: "idle", + pendingPermissions: 0, + queued: 0, + lane: { state: "idle", step: null, title: null, since: null, error: null }, + ...fields, + }); +} + +test("a summary's sub-session fields: its parent, short name, lane, queue and what its turn does", () => { + const entry = summary("tests", { + startedBy: "chat", + shortName: "tests", + queued: 2, + lane: { state: "working", step: "Run the tests", title: null, since: 1, error: null }, + turn: { planStep: "Check retries", activity: "bun test src/net", prompt: "Run the tests" }, + }); + expect(entry).toMatchObject({ + startedBy: "chat", + shortName: "tests", + lane: "working", + queued: 2, + doing: "Check retries · bun test src/net", + }); + // Without a plan step or a tool, what the turn was asked; not working, nothing. + expect(summary("a", { lane: { state: "working", step: "Fix the grid" }, turn: {} }).doing).toBe("Fix the grid"); + expect(summary("b", { turn: { activity: "Read" } }).doing).toBeUndefined(); + expect(summary("c", { queued: 0 }).queued).toBeUndefined(); + expect(summary("d", { waitingOn: "question", waitingPrompt: "Which branch?" }).waitingOn).toEqual({ + on: "question", + prompt: "Which branch?", + }); + expect(summary("e", { waitingOn: "permission" }).waitingOn).toEqual({ on: "permission" }); + expect(summary("f", { waitingOn: "nothing" }).waitingOn).toBeUndefined(); +}); + +test("a sub-session's state: working, needs input, failed (or lost) or done, with its unread mark when done", () => { + const lane = (state: string) => ({ lane: { state } }); + expect(subsessionInfo(summary("a", { ...lane("working"), shortName: "a", queued: 1 }))).toEqual({ + sessionId: "a", + name: "a", + state: "working", + queued: 1, + }); + expect(subsessionInfo(summary("b", { ...lane("input"), waitingOn: "permission" }))).toMatchObject({ + state: "input", + waiting: { on: "permission" }, + }); + expect(subsessionInfo(summary("c", lane("lost"))).state).toBe("failed"); + expect(subsessionInfo(summary("d", { ...lane("failed"), unread: true })).unread).toBeUndefined(); + expect(subsessionInfo(summary("e", { ...lane("idle"), unread: true })).unread).toBe(true); + // An older acpmux without a lane: its status dot. + expect(subsessionInfo(summary("f", { lane: null, indicator: "input" })).state).toBe("input"); + // Without a short name, its title (the name acpmux made for it says nothing). + const titled = summary("g", { name: "claude-2", harness: "claude", title: "Audit the fetch helper" }); + expect(subsessionInfo(titled).name).toBe("Audit the fetch helper"); +}); + +test("the chat's sub-sessions, theirs indented under each, waiting first, then working, then ended", () => { + const sessions = [ + summary("chat"), + summary("done-old", { startedBy: "chat", shortName: "old", createdAt: 1 }), + summary("working", { startedBy: "chat", shortName: "tests", createdAt: 2, lane: { state: "working" } }), + summary("grandchild", { startedBy: "working", shortName: "fixture", createdAt: 3, lane: { state: "input" } }), + summary("asking", { startedBy: "chat", shortName: "docs", createdAt: 4, lane: { state: "input" } }), + summary("done-new", { startedBy: "chat", shortName: "new", createdAt: 5 }), + summary("archived", { startedBy: "chat", shortName: "gone", tags: { archived: "1" } }), + summary("other", { startedBy: "another-chat", shortName: "theirs" }), + // A spawned chat (acpmux new) is a started chat, not a sub-session. + summary("spawned", { spawnedBy: "chat" }), + ]; + expect(subsessionRows(sessions, "chat").map((row) => [row.name, row.depth, row.state])).toEqual([ + ["docs", 0, "input"], + ["tests", 0, "working"], + ["fixture", 1, "input"], + ["new", 0, "done"], + ["old", 0, "done"], + ]); + expect(subsessionRows(sessions, undefined)).toEqual([]); + // A loop in the links draws each once. + const loop = [summary("x", { startedBy: "y" }), summary("y", { startedBy: "x" })]; + expect(subsessionRows(loop, "x").map((row) => row.sessionId)).toEqual(["y"]); +}); + +test("what a sub-session waits on the person for, in one line", () => { + const t = translate; + expect(waitingLine(t, { on: "permission" })).toBe("Waiting for permission"); + expect(waitingLine(t, { on: "question" })).toBe("Waiting on a question · answer in the app"); + expect(waitingLine(t, { on: "question", prompt: "Which branch?" })).toBe( + "Waiting on a question · answer in the app · Which branch?", + ); +}); + +test("the store follows sub-sessions only, and tells its readers only when one's view changes", () => { + const store = new SubsessionStore(); + let calls = 0; + store.subscribe(() => (calls += 1)); + const a = summary("a", { shortName: "a", startedBy: "chat" }); + store.set([summary("chat"), a]); + expect(calls).toBe(1); + expect(store.get("chat")).toBeUndefined(); + const first = store.get("a"); + // The same entry, or a new one that reads the same, keeps its view and tells no one. + store.set([a]); + store.set([summary("a", { shortName: "a", startedBy: "chat", updatedAt: 9 })]); + expect(calls).toBe(1); + expect(store.get("a")).toBe(first!); + store.set([summary("a", { shortName: "a", startedBy: "chat", lane: { state: "working" } })]); + expect(calls).toBe(2); + expect(store.get("a")?.state).toBe("working"); + store.set([]); + expect(calls).toBe(3); + expect(store.get("a")).toBeUndefined(); +}); diff --git a/webviews/src/agent-session/acpmux/subagents/subsessions.ts b/webviews/src/agent-session/acpmux/subagents/subsessions.ts new file mode 100644 index 000000000000..23a54f01e4e7 --- /dev/null +++ b/webviews/src/agent-session/acpmux/subagents/subsessions.ts @@ -0,0 +1,184 @@ +// The chats this chat's agent started with the cmux-subsessions `start_session` tool (acpmux +// `startedBy`, hub/subsessions.rs), as the pane follows them: each one's name among its parent's +// (`shortName`), its live state (working, waiting on the person, failed, done), whether it has news, +// how many messages wait behind its turn, what its turn does now, and while it waits on the +// person, what for (a question or a permission, which only the person answers). The summary card's Subagents +// section lists them under the chat's own subagents (summary/SubagentStatusList.tsx), and the +// transcript's sub-session cards (conversation/SubsessionCallCard.tsx) read one's state live from +// a small store fed from the session list, so a card never redraws its turn when a session changes. +import { createContext, useContext, useSyncExternalStore } from "react"; +import type { StringKey, Translate } from "../i18n"; +import type { AcpmuxSessionEntry, SessionWaiting } from "../sessionList"; + +/// What a sub-session is doing: its turn runs, it waits on the person (a permission or a question), +/// its turn failed or its agent went away, or it is done until it is sent more. +export type SubsessionState = "working" | "input" | "failed" | "done"; + +export type SubsessionInfo = { + sessionId: string; + /// Its short name, else its title. + name: string; + state: SubsessionState; + /// Its last turn ended and nobody has looked at it yet. + unread?: true; + /// Messages waiting behind its running turn. + queued?: number; + /// What its running turn does now, while it works. + doing?: string; + /// What it waits on the person for, while it does and acpmux says. + waiting?: SessionWaiting; +}; + +/// What a sub-session waiting on the person waits for, in one line: a question (and its first +/// prompt, which the line cuts when there is no room) or a tool's permission. Only the person +/// answers it, in its chat (person proof): its parent's agent cannot, so the line says where. +export function waitingLine(t: Translate, waiting: SessionWaiting): string { + if (waiting.on === "permission") return t("subsession.waitingPermission"); + const line = t("subsession.waitingQuestion"); + return waiting.prompt ? `${line} · ${waiting.prompt}` : line; +} + +/// Each state's word. +export const SUBSESSION_STATE_LABEL: Record = { + working: "subsession.state.working", + input: "home.needsInput", + failed: "subagents.state.failed", + done: "subagents.state.completed", +}; + +/// acpmux's lane (`lane.state`) when it gives one, else its status dot, else its status. +export function subsessionState(entry: AcpmuxSessionEntry): SubsessionState { + switch (entry.lane ?? entry.indicator) { + case "working": + return "working"; + case "input": + return "input"; + case "failed": + case "lost": + return "failed"; + case "idle": + case "done": + return "done"; + } + if (entry.status === "running") return "working"; + if (entry.status === "waiting") return "input"; + return "done"; +} + +export function subsessionInfo(entry: AcpmuxSessionEntry): SubsessionInfo { + const state = subsessionState(entry); + return { + sessionId: entry.sessionId, + name: entry.shortName || entry.displayTitle || entry.title || entry.sessionId, + state, + ...(state === "done" && entry.unread ? { unread: true as const } : {}), + ...(entry.queued ? { queued: entry.queued } : {}), + ...(state === "working" && entry.doing ? { doing: entry.doing } : {}), + ...(state === "input" && entry.waitingOn ? { waiting: entry.waitingOn } : {}), + }; +} + +/// A sub-session's row in the summary card: `depth` 0 for one this chat started, 1 for one that +/// one started, and so on. +export type SubsessionRow = SubsessionInfo & { depth: number }; + +/// Each state's place on one level: what needs the person first, then what runs, then what ended. +const RANK: Record = { input: 0, working: 1, failed: 2, done: 2 }; + +/// The sub-sessions chat `chatId` started, each followed by its own, depth first; archived ones +/// left out. On each level, those waiting on the person first, then the working ones, then the +/// ended ones, each newest first. +export function subsessionRows(sessions: readonly AcpmuxSessionEntry[], chatId: string | undefined): SubsessionRow[] { + if (!chatId) return []; + const children = new Map(); + for (const entry of sessions) { + if (!entry.startedBy || entry.archived) continue; + const list = children.get(entry.startedBy); + if (list) list.push(entry); + else children.set(entry.startedBy, [entry]); + } + const out: SubsessionRow[] = []; + // A session listed twice (a peer's copy) or a loop in the links draws once. + const seen = new Set([chatId]); + const visit = (parent: string, depth: number) => { + const level = (children.get(parent) ?? []) + .filter((entry) => !seen.has(entry.sessionId)) + .map((entry) => ({ entry, info: subsessionInfo(entry) })) + .sort((a, b) => RANK[a.info.state] - RANK[b.info.state] || (b.entry.createdAt ?? 0) - (a.entry.createdAt ?? 0)); + for (const { entry, info } of level) { + if (seen.has(entry.sessionId)) continue; + seen.add(entry.sessionId); + out.push({ ...info, depth }); + visit(entry.sessionId, depth + 1); + } + }; + visit(chatId, 0); + return out; +} + +/// Every listed session's sub-session view by id, which cards read with ``useSubsessionInfo``. +export class SubsessionStore { + private infos = new Map(); + private keys = new Map(); + private readonly listeners = new Set<() => void>(); + + private entries = new Map(); + + /// Replaces the views with those of the sub-sessions among `sessions`; one whose content did not + /// change keeps its object. The pane sends the list with every snapshot, reusing the entry of a + /// session that did not change, so an entry seen last time is not read again. + set(sessions: readonly AcpmuxSessionEntry[]): void { + const infos = new Map(); + const keys = new Map(); + const entries = new Map(); + let changed = false; + for (const entry of sessions) { + if (!entry.startedBy) continue; + const id = entry.sessionId; + const before = this.infos.get(id); + let info: SubsessionInfo; + let key: string; + if (before && this.entries.get(id) === entry) { + info = before; + key = this.keys.get(id)!; + } else { + const next = subsessionInfo(entry); + key = JSON.stringify(next); + info = before && this.keys.get(id) === key ? before : next; + if (info !== before) changed = true; + } + infos.set(id, info); + keys.set(id, key); + entries.set(id, entry); + } + if (infos.size !== this.infos.size) changed = true; + this.infos = infos; + this.keys = keys; + this.entries = entries; + if (changed) for (const listener of this.listeners) listener(); + } + + get(sessionId: string): SubsessionInfo | undefined { + return this.infos.get(sessionId); + } + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; +} + +/// Where the cards read sessions from, and how a click opens one beside this chat. Absent (a +/// gallery stage, a test), a card draws its name without its state and without a link. +export type Subsessions = { store: SubsessionStore; open(sessionId: string): void }; +export const SubsessionContext = createContext(null); + +const EMPTY = new SubsessionStore(); + +/// Session `sessionId`'s view, live while the component shows; undefined while the list does not +/// have it. +export function useSubsessionInfo(sessionId: string | undefined): SubsessionInfo | undefined { + const store = useContext(SubsessionContext)?.store ?? EMPTY; + const read = () => (sessionId ? store.get(sessionId) : undefined); + return useSyncExternalStore(store.subscribe, read, read); +} diff --git a/webviews/src/agent-session/acpmux/summary/SubagentStatusList.tsx b/webviews/src/agent-session/acpmux/summary/SubagentStatusList.tsx index 3a7708737dc9..acc4cd90db4c 100644 --- a/webviews/src/agent-session/acpmux/summary/SubagentStatusList.tsx +++ b/webviews/src/agent-session/acpmux/summary/SubagentStatusList.tsx @@ -1,7 +1,10 @@ // The summary card's Subagents section (SummaryCard.tsx): every subagent of the chat, nested ones // indented under the agent that spawned them, with its live state (running, waiting on the user, // done, failed) and its time. A row shows that subagent (`onOpen`): its pane when it has one. Its -// title opens the card's Subagents tab (`onShowAll`, SubagentsTab.tsx). +// title opens the card's Subagents tab (`onShowAll`, SubagentsTab.tsx). The sub-sessions the chat's +// agent started with its `start_session` tool (subagents/subsessions.ts) follow, theirs indented under +// each, with their live state, an unread dot, how many messages wait for them and what a working +// one does now; a click opens its chat beside this one (`onOpenSession`). import type { AcpmuxRow, AcpmuxSnapshot } from "../model"; import { formatDuration } from "../conversation/turns"; import { useT, type StringKey } from "../i18n"; @@ -9,6 +12,12 @@ import { Icon } from "../icons/Icon"; import { rowIconSize } from "../icons/iconSize"; import { elapsed, running, STATE_LABEL, useNow } from "../subagents/SubagentGroup"; import type { Subagent } from "../subagents/subagentFold"; +import { + SUBSESSION_STATE_LABEL, + type SubsessionRow, + type SubsessionState, + waitingLine, +} from "../subagents/subsessions"; const ROW_ICON = rowIconSize(12); @@ -89,6 +98,14 @@ function ordered(agents: readonly Subagent[], waiting: ReadonlySet): { a return out; } +/// A sub-session's state as a subagent row's status, for the row's dot and the card's counts. +export const SUBSESSION_STATUS: Record = { + working: "running", + input: "waiting", + failed: "failed", + done: "done", +}; + export function SubagentStatusList({ agents, waiting, @@ -96,9 +113,15 @@ export function SubagentStatusList({ onOpen, autoOpen, onShowAll, + subsessions = [], + onOpenSession, }: { agents: readonly Subagent[]; waiting: ReadonlySet; + /// The chat's sub-sessions, after its subagents (subagents/subsessions.ts `subsessionRows`). + subsessions?: readonly SubsessionRow[]; + /// Opens a sub-session's chat beside this one. + onOpenSession?: (sessionId: string) => void; /// The subagent this pane shows, if any. current?: string; onOpen?: (id: string) => void; @@ -110,7 +133,9 @@ export function SubagentStatusList({ const t = useT(); const now = useNow(agents.some(running)); const title = t("summary.subagents"); - const live = agents.filter((agent) => subagentStatus(agent, waiting) === "running").length; + const live = + agents.filter((agent) => subagentStatus(agent, waiting) === "running").length + + subsessions.filter((session) => session.state === "working").length; const heading = live > 0 ? `${title} · ${t("summary.subagents.running", { n: live })}` : title; return (
@@ -125,7 +150,9 @@ export function SubagentStatusList({ )}
    - {agents.length === 0 &&
  • {t("summary.none")}
  • } + {agents.length === 0 && subsessions.length === 0 && ( +
  • {t("summary.none")}
  • + )} {ordered(agents, waiting).map(({ agent, depth }) => { const status = subagentStatus(agent, waiting); return ( @@ -158,6 +185,11 @@ export function SubagentStatusList({ ); })} + {subsessions.map((session) => ( +
  • + +
  • + ))}
{autoOpen && (
); } + +/// A sub-session's row: its name (and what it does now while it works, or what it waits on the +/// person for), its unread dot, how many messages wait for it, and its state. +function SubsessionStatusRow({ session, onOpen }: { session: SubsessionRow; onOpen?: (sessionId: string) => void }) { + const t = useT(); + const status = SUBSESSION_STATUS[session.state]; + // What a working one does now, or what one waiting on the person waits for. + const line = session.waiting ? waitingLine(t, session.waiting) : session.doing; + return ( + + ); +} diff --git a/webviews/src/agent-session/acpmux/summary/SummaryCard.tsx b/webviews/src/agent-session/acpmux/summary/SummaryCard.tsx index 1de7a7820dbe..3013bd6209fb 100644 --- a/webviews/src/agent-session/acpmux/summary/SummaryCard.tsx +++ b/webviews/src/agent-session/acpmux/summary/SummaryCard.tsx @@ -9,11 +9,14 @@ import { GalleryDialog } from "./GalleryDialog"; import { chatGallery } from "./chatGallery"; import { useScrollEdges } from "./scrollEdges"; import { sessionSummary } from "./sessionSummary"; -import { SubagentStatusList, subagentStatus, waitingSubagents } from "./SubagentStatusList"; +import type { SubsessionRow } from "../subagents/subsessions"; +import { SUBSESSION_STATUS, SubagentStatusList, subagentStatus, waitingSubagents } from "./SubagentStatusList"; import { SubagentsTab } from "./SubagentsTab"; import { SummaryBody, type SummaryChanges } from "./SummaryBody"; import type { SummaryCardControl } from "./cardState"; +const NO_SUBSESSIONS: readonly SubsessionRow[] = []; + /// What the chat has produced so far. A wide pane reserves a quiet right column; a narrow pane /// uses a popover that dismisses on outside click or Escape. Folded popovers keep their header line, /// with the subagents' live counts. The summary is read from the transcript only while the card is @@ -29,6 +32,7 @@ export function SummaryCard({ changes, onOpenChanges, subagents, + subsessions, }: { card: SummaryCardControl; rows: readonly AcpmuxRow[]; @@ -49,6 +53,9 @@ export function SummaryCard({ /// Whether new subagents open in panes of their own, and its switch; none where no pane opens. autoOpen?: { on: boolean; set: (on: boolean) => void }; }; + /// The chat's sub-sessions (subagents/subsessions.ts `subsessionRows`), listed after its + /// subagents, and how a row opens one's chat beside this one. + subsessions?: { rows: readonly SubsessionRow[]; onOpen: (sessionId: string) => void }; }) { const t = useT(); const [gallery, setGallery] = useState(false); @@ -107,7 +114,11 @@ export function SummaryCard({