diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index d946e5c6..cf51f347 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -146,6 +146,18 @@ The dock above the composer holds everything alive outside the current turn: a ` Press `Ctrl+R` or enter the exact local command `/agents` while idle to toggle the agent roster. The roster keeps its own visibility and scroll position. The roster covers every subagent observable to the current top-level Kit process tree, whether its call is foreground or background and regardless of the focused transcript block or tool call. Direct children are tree roots, and nested Kit descendants appear immediately beneath their parent at arbitrary depth in an always-expanded tree. Siblings retain lifecycle/creation/ID ordering within each parent, so an active child remains grouped beneath an idle parent instead of moving across subtrees. A descendant whose parent event has not arrived temporarily appears as a root with `Name · via Parent` and automatically reparents when the parent arrives. Generic ACP has no portable child-session enumeration, so agents created privately inside a generic harness cannot appear unless the harness forwards compatible Kit runtime events. +Press `Ctrl+G` to move keyboard focus to or from the root agent roster. Use `Up` and `Down` to select an agent and `Enter` to inspect it, or click its row. The selected row is highlighted. On narrow terminals, keyboard roster focus exposes a compact, selectable roster. + +The inspected child's view shows its name, handle, generation, and capability or lifecycle notice above its transcript. The root roster stays visible so you can select another child. Press `Esc` or click **Back to main** to return without interrupting either turn. Use `PageUp` / `PageDown`, `Home` / `End`, or the mouse wheel over the child transcript to scroll; the roster has independent scrolling. + +The child composer accepts **text steering only**, and only for an active direct child that supports compatible steering. Descendants remain visible in the root roster, but their transcript route is currently unsupported and the inspection view says so; Kit does not substitute a parent's transcript. Steering is routed only to direct children. Idle, closed, unsupported, or unavailable children are also read-only; the notice explains availability and errors. There are no attachments or slash commands in the child composer. Root session commands, model changes, cancellation, and other root actions are not available from this view. Return to main to use them; your root draft is retained while you inspect children. + +Kit loads **only the focused child's transcript**. Opening a child reads existing inspection history from the runtime that owns it, then continues from the same ordered cursor for live updates. Switching children or returning to main stops those reads and releases the previous child's transcript; it does not retain a collection of child transcript views. The root transcript, draft, and scroll position remain independent. Reopening a child reloads its available history rather than relying on updates previously seen by the TUI. There is no 2 MiB transcript freeze cutoff. + +Inspection history is an ephemeral runtime-owned disk spool, not another in-memory transcript cache or a durable session archive. Reads use bounded pages and pause when you leave the child. Unavailable history, unsupported updates, or inspection failures are shown explicitly; return to main and reopen the child to retry a failed read. A failed history spool cannot recover omitted records merely by reopening it. Inspection failures do not stop child execution or disable root roster updates. + +The spool retains observed turns across prompt generations while the owning runtime is alive. A reconnect can import the child's supplied replay, but that replay may be partial: supplied assistant text and bounded rich updates do not preserve every earlier user/thought message or their original ordering. Native forks do not supply inherited transcript replay. These cases are marked partial rather than presented as complete history. ACP does not reliably identify echoes of submitted prompts, so submitted input and harness-reported user messages are preserved separately with an explicit warning that they may repeat; Kit does not discard messages merely because their text matches. Individual inspection updates larger than 1 MiB are replaced by an omission notice; later updates continue loading. The writer queue is bounded, and queue overflow or storage failure makes the spool explicitly unavailable. + Each row uses three lines, with tree connectors and indentation continuing across all of them. The first line holds a status glyph, the vendor mark when harnesses differ, the display name, and, right-aligned, the harness, model, and generation (`claude · opus g2`), because the generation is the value the parent can `prompt` or `fork` again. The second line holds the live activity excerpt or bounded task summary; an idle reusable agent shows `idle · resumable` instead. The third line holds a six-cell context gauge, tokens used, the elapsed time of the current generation, and any reported cost. When starting or forking a subagent, the parent model preferably supplies a concise role-oriented name such as `Round 2 Implementer` or `Reviewer`; omitted or invalid names fall back to `Agent N`, and case-insensitive sibling collisions receive a numeric suffix. The glyph palette is yellow `Pulse::Child` for `starting`, cyan `Pulse::Tool` for `working`, and dim `○` for ordinary or successful `idle`. A failed reusable idle row shows a red `✗` for four seconds after its failure timestamp, then returns to dim `○`; a failed terminal or removed tombstone shows the red `✗` for four seconds, then its row is deleted. Active durations update on animation ticks, freeze when the generation becomes idle or fails, and restart for a later prompt. A fixed footer remains visible while rows scroll, for example `3 agents · 2 working · 1 idle`; it includes the total and only nonzero `starting`, `working`, and `idle` buckets. Foreground and background are not separate buckets. Footer accounting remains lifecycle-based during the four-second grace: reusable failures count as idle immediately, while closed and terminally retired handles leave the live total immediately even while a tombstone remains visible. Idle rows remain until their handles are closed. diff --git a/src/acp_child.rs b/src/acp_child.rs index 2ee1e694..498b6425 100644 --- a/src/acp_child.rs +++ b/src/acp_child.rs @@ -16,6 +16,7 @@ use agent_client_protocol::{ByteStreams, UntypedMessage}; mod messages; mod protocol; +pub(crate) mod transcript; use agentkit_acp::{ CloseSessionRequest, ConfigOptionUpdate, ContentBlock, DeleteSessionRequest, ForkSessionRequest, PermissionOption, PermissionOptionKind, PromptResponse, @@ -36,6 +37,7 @@ use tokio::{ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use crate::tools::mcp::CredentialStorage; +use transcript::Transcript; pub(crate) mod prompt; use prompt::ChildPrompt; @@ -508,6 +510,8 @@ struct Prompt { session_id: SessionId, /// The subagent id that owns this turn, so usage events name the roster row. owner: String, + generation: u64, + transcript: Option, content: Vec, cancellation: TurnCancellation, reply: oneshot::Sender>, @@ -526,6 +530,7 @@ struct Close { reply: oneshot::Sender, ChildError>>, } struct Steer { + generation: Option, session_id: SessionId, content: Vec, reply: oneshot::Sender>, @@ -595,13 +600,97 @@ struct Ready { replay: ChildOutput, } +/// Normalize the message-ID fields shared by v1 chunks and v2 inspection. +/// Never infer user-message identity from content: an identical message can be +/// a later accepted steer, and a prompt echo can be fragmented arbitrarily. +fn inspection_event(owner: &str, generation: u64, mut update: Value) -> Option { + if owner.is_empty() || !update.is_object() { + return None; + } + let kind = update["sessionUpdate"].as_str()?.to_owned(); + if matches!( + kind.as_str(), + "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk" + ) && update.get("messageId").is_none() + { + update["messageId"] = Value::String(format!("kit-inspect-{generation}-{kind}")); + } + Some(update) +} + +/// One route owns this bounded segment cursor. Only the notification callback +/// writes it; initialization occurs before publication, and cleanup drops the Arc. +/// No callbacks, I/O, or awaits occur under its lock. Poison invalidates only +/// inspection, never execution, rather than recovering uncertain ordering. Native v2 IDs are never rewritten. +#[derive(Default)] +struct InspectionSegments { + kind: Option<&'static str>, + sequence: u64, +} +impl InspectionSegments { + fn normalize( + &mut self, + generation: u64, + update: &mut Value, + ) -> agent_client_protocol::Result<()> { + if update["sessionUpdate"] == "tool_call" { + // v2 unifies announcement and subsequent patches in ToolCallUpdate. + update["sessionUpdate"] = Value::String("tool_call_update".into()); + } + let kind = match update["sessionUpdate"].as_str() { + Some("agent_message_chunk") => Some("agent_message_chunk"), + Some("agent_thought_chunk") => Some("agent_thought_chunk"), + Some("user_message_chunk") => Some("user_message_chunk"), + _ => None, + }; + if update.get("messageId").is_some() || kind.is_none() { + self.kind = None; + return Ok(()); + } + if self.kind != kind { + self.sequence = self + .sequence + .checked_add(1) + .ok_or_else(agent_client_protocol::Error::internal_error)?; + self.kind = kind; + } + update["messageId"] = Value::String(format!( + "kit-inspect-{generation}-segment-{}", + self.sequence + )); + Ok(()) + } +} + /// Where one in-flight prompt's session updates land. #[derive(Clone)] struct Route { + transcript: Option, + inspection_segments: Arc>, owner: String, + generation: u64, output: Arc>, idle: watch::Sender, } +impl Route { + fn record_inspection(&self, mut update: Value) { + let Some(transcript) = &self.transcript else { + return; + }; + let normalized = self + .inspection_segments + .lock() + .ok() + .is_some_and(|mut segments| segments.normalize(self.generation, &mut update).is_ok()); + if !normalized { + transcript.fail(); + return; + } + if let Some(update) = inspection_event(&self.owner, self.generation, update) { + transcript.record(&update); + } + } +} /// Complete child-advertised snapshots, including notifications between prompts. /// Writers replace one session atomically; no lock is held across protocol calls, @@ -1169,11 +1258,16 @@ impl ChildSession { /// Steer the current turn without acquiring its prompt/fork serialization gate. /// Dropping the caller does not cancel the foreground turn or revoke acceptance. - pub async fn steer(&self, prompt: ChildPrompt) -> Result { + pub async fn steer_generation( + &self, + prompt: ChildPrompt, + generation: Option, + ) -> Result { let content = prompt.into_blocks(&self.capabilities.prompt_capabilities)?; let (reply, response) = oneshot::channel(); self.tx .send(Request::Steer(Steer { + generation, session_id: self.session_id.clone(), content, reply, @@ -1189,11 +1283,13 @@ impl ChildSession { })? } - pub async fn prompt( + pub async fn prompt_generation( &self, owner: String, + generation: u64, prompt: ChildPrompt, cancellation: TurnCancellation, + transcript: Option, ) -> Result { let content = prompt.into_blocks(&self.capabilities.prompt_capabilities)?; // A one-shot admission race: an available gate may win concurrent @@ -1212,6 +1308,8 @@ impl ChildSession { serial, session_id: self.session_id.clone(), owner, + generation, + transcript, content, cancellation: cancellation.clone(), reply, @@ -1404,6 +1502,7 @@ async fn run( route.idle.send_if_modified(|current| current.advance(state)); return Ok(()); } + route.record_inspection(params["update"].clone()); if let Some(update) = messages::parse(¶ms["update"])? { if let Ok(mut output) = route.output.lock() { output.record_message(update); } return Ok(()); @@ -1504,7 +1603,7 @@ async fn run( let id = SessionId::new(id.clone()); let (idle, _) = watch::channel(protocol::Foreground::Waiting); routes.lock().map_err(|_| agent_client_protocol::Error::internal_error())? - .insert(id.clone(), Route { owner: String::new(), output: Arc::clone(&replay_output), idle }); + .insert(id.clone(), Route { transcript: None, inspection_segments: Arc::new(Mutex::new(InspectionSegments::default())), owner: String::new(), generation: 0, output: Arc::clone(&replay_output), idle }); let result = protocol::resume(&connection, version, id.clone(), root.clone(), additional_directories.clone()).await; routes.lock().map_err(|_| agent_client_protocol::Error::internal_error())?.remove(&id); result @@ -1591,30 +1690,39 @@ async fn run( // Routes are installed/removed only by prompt tasks. Read under // their lock, then release it before any protocol I/O. Injection // never owns or changes the foreground output, gate, or idle state. - let active = match routes.lock() { - Ok(routes) => routes.contains_key(&steer.session_id), - Err(_) => { - let _ = steer.reply.send(Err(ChildError::Failed("subagent route lock was poisoned".into()))); + let request = { + let routes = match routes.lock() { + Ok(routes) => routes, + Err(_) => { + let _ = steer.reply.send(Err(ChildError::Failed("subagent route lock was poisoned".into()))); + continue; + } + }; + let route = routes.get(&steer.session_id); + let rejection = if !supports_steer { + Some("ACP harness does not advertise v2 steer injection") + } else if route.is_none() { + Some("subagent has no active prompt to steer") + } else if route.is_some_and(|route| steer.generation.is_some_and(|generation| generation != route.generation)) { + Some("stale subagent generation") + } else { None }; + if let Some(message) = rejection { + let _ = steer.reply.send(Err(ChildError::Failed(message.into()))); continue; } + // The SDK enqueue is synchronous and does not invoke route callbacks. + // Route removal/replacement cannot interleave admission and enqueue. + // Only receipt waiting is spawned, with no route guard alive. + protocol::steer(&connection, steer.session_id, steer.content) }; - let rejection = if !supports_steer { - Some("ACP harness does not advertise v2 steer injection") - } else if !active { - Some("subagent has no active prompt to steer") - } else { None }; - if let Some(message) = rejection { - let _ = steer.reply.send(Err(ChildError::Failed(message.into()))); - continue; - } - let connection = connection.clone(); let auth_methods = auth_methods.clone(); tasks.spawn(async move { - let result = tokio::time::timeout(CANCEL_SETTLE, - protocol::steer(&connection, steer.session_id, steer.content)).await; - let result = match result { - Ok(result) => result.map_err(|error| ChildError::Failed(child_request_error(error, &auth_methods))), - Err(_) => Err(ChildError::Failed("steer acknowledgement timed out; delivery is unknown".into())), + let result = match request { + Ok(request) => match tokio::time::timeout(CANCEL_SETTLE, request).await { + Ok(result) => result.map_err(|error| ChildError::Failed(child_request_error(error, &auth_methods))), + Err(_) => Err(ChildError::Failed("steer acknowledgement timed out; delivery is unknown".into())), + }, + Err(error) => Err(ChildError::Failed(child_request_error(error, &auth_methods))), }; let _ = steer.reply.send(result); }); @@ -1832,7 +1940,15 @@ async fn run( let output = Arc::new(Mutex::new(ChildOutput::default())); let (idle_tx, mut idle_rx) = watch::channel(protocol::Foreground::Waiting); if let Ok(mut routes) = routes.lock() { - routes.insert(session_id.clone(), Route { owner: prompt.owner, output: Arc::clone(&output), idle: idle_tx }); + routes.insert(session_id.clone(), Route { transcript: prompt.transcript.clone(), inspection_segments: Arc::new(Mutex::new(InspectionSegments::default())), owner: prompt.owner.clone(), generation: prompt.generation, output: Arc::clone(&output), idle: idle_tx }); + } + if !prompt.owner.is_empty() { + crate::events::emit(&crate::events::RuntimeEvent::SubagentCapabilities { + id: prompt.owner.clone(), generation: prompt.generation, can_steer: supports_steer, + }); + if let Some(transcript) = &prompt.transcript { + transcript.record_submitted_prompt(&prompt.owner, prompt.generation, &prompt.content); + } } let request = async { let response = protocol::prompt(&connection, version, session_id.clone(), prompt.content).await?; @@ -2143,6 +2259,22 @@ mod tests { use super::*; + impl ChildSession { + pub async fn steer(&self, prompt: ChildPrompt) -> Result { + self.steer_generation(prompt, None).await + } + + pub async fn prompt( + &self, + owner: String, + prompt: ChildPrompt, + cancellation: TurnCancellation, + ) -> Result { + self.prompt_generation(owner, 0, prompt, cancellation, None) + .await + } + } + fn update(value: Value) -> SessionUpdate { serde_json::from_value(value).unwrap() } @@ -2393,6 +2525,8 @@ mod tests { serial, session_id: child.session_id.clone(), owner: "s-test".into(), + generation: 0, + transcript: None, content: vec![ContentBlock::Text(agentkit_acp::TextContent::new("queued"))], cancellation: controller.handle().checkpoint(), reply, @@ -3827,6 +3961,70 @@ for line in sys.stdin: } } + #[test] + fn inspection_v1_segments_preserve_text_tool_text_order() { + let text = |text: &str| { + SessionUpdate::AgentMessageChunk(agentkit_acp::ContentChunk::new(ContentBlock::Text( + agentkit_acp::TextContent::new(text), + ))) + }; + let tool: SessionUpdate = serde_json::from_value(serde_json::json!({ + "sessionUpdate":"tool_call", "toolCallId":"tool-1", "title":"Read file", "status":"in_progress" + })).unwrap(); + let sequence = [text("A"), text(" continuation"), tool, text("B")]; + let mut segments = InspectionSegments::default(); + let mut updates = Vec::new(); + for update in sequence { + let mut update = serde_json::to_value(update).unwrap(); + segments.normalize(9, &mut update).unwrap(); + let event = inspection_event("child", 9, update).unwrap(); + let update = event; + assert!( + serde_json::from_value::( + update.clone() + ) + .is_ok() + ); + updates.push(update); + } + assert_eq!(updates[0]["messageId"], updates[1]["messageId"]); + assert_ne!(updates[0]["messageId"], updates[3]["messageId"]); + assert_eq!(updates[2]["sessionUpdate"], "tool_call_update"); + assert_eq!(updates[3]["content"]["text"], "B"); + let mut native = serde_json::json!({"sessionUpdate":"agent_message_chunk", "messageId":"native-v2", "content":{"type":"text","text":"native"}}); + let original = native.clone(); + segments.normalize(9, &mut native).unwrap(); + assert_eq!(native, original); + } + + #[test] + fn inspection_normalizes_chunks_and_preserves_large_updates() { + for kind in [ + "user_message_chunk", + "agent_message_chunk", + "agent_thought_chunk", + ] { + let update = + serde_json::json!({"sessionUpdate":kind, "content":{"type":"text","text":"hello"}}); + let event = inspection_event("child", 3, update.clone()).unwrap(); + let normalized = event; + assert!( + serde_json::from_value::( + normalized + ) + .is_ok() + ); + } + let event = inspection_event( + "child", + 3, + serde_json::json!({"sessionUpdate":"tool_call", "rawOutput":"x".repeat(65536)}), + ) + .unwrap(); + let update = event; + assert_eq!(update["rawOutput"].as_str().unwrap().len(), 65536); + } + #[tokio::test] async fn steering_preserves_in_flight_prompt_and_rejection_is_nonterminal() { let root = tempfile::tempdir().unwrap(); @@ -3835,14 +4033,26 @@ for line in sys.stdin: let child = base.clone(); let turn = tokio::spawn(async move { child - .prompt( + .prompt_generation( "s-test".into(), + 7, "original turn".into(), TurnCancellation::default(), + None, ) .await }); steering_test_support::wait_request(&root, "session/prompt").await; + assert!( + base.steer_generation("stale instruction".into(), Some(6)) + .await + .is_err() + ); + assert!( + base.steer_generation("future instruction".into(), Some(8)) + .await + .is_err() + ); assert!(base.steer("MOCK_REJECT_INJECT".into()).await.is_err()); let auth_error = base .steer("MOCK_AUTH_INJECT".into()) @@ -3866,7 +4076,10 @@ for line in sys.stdin: assert!(!auth_error.contains(secret), "{auth_error}"); } - let receipt = base.steer("change direction".into()).await.unwrap(); + let receipt = base + .steer_generation("change direction".into(), Some(7)) + .await + .unwrap(); assert_eq!(receipt["messageId"], "injected-1"); assert!( !turn.is_finished(), diff --git a/src/acp_child/protocol.rs b/src/acp_child/protocol.rs index ffc717f1..5ae60a44 100644 --- a/src/acp_child/protocol.rs +++ b/src/acp_child/protocol.rs @@ -252,19 +252,19 @@ where } /// Injection is acknowledged independently of the foreground prompt's settlement. -pub(super) async fn steer( +pub(super) fn steer( connection: &ConnectionTo, session_id: v1::SessionId, content: Vec, -) -> Result { +) -> Result> + Send + 'static, Error> { let request = v2::InjectSessionRequest::new( session_id.to_string(), v2::SessionInjectMode::Steer, serde_json::from_value(serde_json::to_value(content)?)?, ); - Ok(serde_json::to_value( - connection.send_request(request).block_task().await?, - )?) + // send_request enqueues synchronously; callers can guard generation admission. + let sent = connection.send_request(request); + Ok(async move { Ok(serde_json::to_value(sent.block_task().await?)?) }) } pub(super) fn fork( diff --git a/src/acp_child/transcript.rs b/src/acp_child/transcript.rs new file mode 100644 index 00000000..3769ffdb --- /dev/null +++ b/src/acp_child/transcript.rs @@ -0,0 +1,797 @@ +//! Runtime-owned ephemeral disk inspection history, never reopened across runs. +//! The registry lock publishes session handles and generation gates; `start` is its +//! sole writer. Poison isolates inspection, not execution. One blocking writer +//! owns each child session, with bounded nonblocking admission. Readers open their +//! own descriptors, never lock the writer, and only see committed JSON lines. +use serde::{Serialize, Serializer, ser::SerializeMap}; +use serde_json::Value; +use std::{ + collections::HashMap, + fs::File, + io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + mpsc, + }, +}; +const QUEUE: usize = 256; +const FRAME_BYTES: usize = 1024 * 1024; +const QUEUE_BYTES: usize = 8 * 1024 * 1024; +const PAGE_UPDATES: usize = 64; +const PAGE_BYTES: u64 = 256 * 1024; + +#[derive(Default)] +pub(crate) struct Transcripts { + generations: Mutex>, +} +impl Transcripts { + pub(crate) fn start(&self, id: &str, generation: u64) { + { + let Ok(mut generations) = self.generations.lock() else { + return; + }; + if let Some((current, _)) = generations.get_mut(id) { + *current = (*current).max(generation); + return; + } + } + // One disk log survives all prompt generations of this child session. + let transcript = Transcript::start(); + let Ok(mut generations) = self.generations.lock() else { + return; + }; + if let Some((current, _)) = generations.get_mut(id) { + *current = (*current).max(generation); + return; + } + generations.insert(id.into(), (generation, transcript)); + } + pub(crate) fn get(&self, id: &str, generation: u64) -> Result { + let generations = self + .generations + .lock() + .map_err(|_| "transcript registry poisoned")?; + let (current, transcript) = generations.get(id).ok_or( + "transcript unavailable: unknown direct child; descendant inspection is unsupported", + )?; + if *current != generation { + return Err("stale transcript generation".into()); + } + Ok(transcript.clone()) + } +} +#[derive(Clone)] +pub(crate) struct Transcript { + sender: mpsc::SyncSender>, + shared: Arc, +} +#[derive(Default)] +struct Shared { + // Owns deletion until both writer and outstanding readers finish. + path: OnceLock, + committed: AtomicU64, + pending: AtomicUsize, + pending_bytes: AtomicUsize, + failed: AtomicBool, + // Serializes only queue admission versus sealing. No reader, IO, callback, + // or await uses this lock; writers are record/finish. Poison fails inspection. + finished: Mutex, +} +impl Drop for Shared { + fn drop(&mut self) { + let Some(mut path) = self.path.take() else { + return; + }; + // Last-reader/generation teardown can run on an execution future. Never + // perform filesystem cleanup there, even if thread creation fails. In + // that exceptional case leave an ephemeral file for OS temp cleanup. + path.disable_cleanup(true); + let _ = std::thread::Builder::new() + .name("kit-transcript-cleanup".into()) + .spawn(move || { + let _ = std::fs::remove_file(&path); + }); + } +} +#[derive(Debug)] +pub(crate) struct Page { + pub updates: Vec, + pub next_cursor: u64, + pub caught_up: bool, +} +impl Transcript { + fn start() -> Self { + let (sender, receiver) = mpsc::sync_channel::>(QUEUE); + let shared = Arc::new(Shared::default()); + let writer = Arc::clone(&shared); + if std::thread::Builder::new() + .name("kit-transcript".into()) + .spawn(move || { + struct Guard(Arc, bool); + impl Drop for Guard { + fn drop(&mut self) { + if !self.1 { + self.0.failed.store(true, Ordering::Release); + } + } + } + let mut guard = Guard(writer.clone(), false); + let Ok(file) = tempfile::Builder::new() + .prefix("kit-transcript-") + .tempfile() + else { + return; + }; + let (mut file, path) = file.into_parts(); + if writer.path.set(path).is_err() { + return; + } + for bytes in receiver { + if bytes.is_empty() { + guard.1 = true; + return; + } + if writer.failed.load(Ordering::Acquire) { + return; + } + if file.write_all(&bytes).is_err() { + return; + } + writer + .committed + .fetch_add(bytes.len() as u64, Ordering::Release); + writer + .pending_bytes + .fetch_sub(bytes.len(), Ordering::Release); + writer.pending.fetch_sub(1, Ordering::Release); + } + guard.1 = true; + }) + .is_err() + { + shared.failed.store(true, Ordering::Release); + } + Self { sender, shared } + } + /// Release the writer thread after all admitted records, retaining only disk + /// history for later focus. Repeated terminal roster events are harmless. + pub(crate) fn finish(&self) { + let Ok(mut finished) = self.shared.finished.lock() else { + self.fail(); + return; + }; + if !*finished { + *finished = true; + if self.sender.try_send(Vec::new()).is_err() { + self.fail(); + } + } + } + + /// ACP prompt acceptance has no native user-message ID, even in v2. Keep + /// submitted input distinct from all reported updates; never guess matches. + pub(crate) fn record_submitted_prompt( + &self, + owner: &str, + generation: u64, + content: &[super::ContentBlock], + ) { + self.record(&Value::Object(serde_json::Map::from_iter([ + ("sessionUpdate".into(), Value::String("kit_transcript_partial".into())), + ("reason".into(), Value::String("Submitted prompt is shown separately. ACP does not identify its echo; reported user messages are preserved and may repeat that prompt".into())), + ]))); + for content in content { + let Ok(content) = serde_json::to_value(content) else { + self.fail(); + continue; + }; + let update = Value::Object(serde_json::Map::from_iter([ + ( + "sessionUpdate".into(), + Value::String("user_message_chunk".into()), + ), + ("content".into(), content), + ])); + if let Some(update) = super::inspection_event(owner, generation, update) { + self.record(&update); + } + } + } + + /// Import the replay that ACP startup already supplies. ChildOutput is not + /// an exact wire transcript: text is aggregated and rich updates are bounded. + /// Keep that limitation visible instead of silently discarding available data. + pub(crate) fn replay(&self, owner: &str, generation: u64, output: &super::ChildOutput) { + self.record_at(&Value::Object(serde_json::Map::from_iter([ + ("sessionUpdate".into(), Value::String("kit_transcript_partial".into())), + ("reason".into(), Value::String("Historical ACP replay is partial: assistant text is aggregated, rich updates are bounded, and original user/thought ordering is unavailable".into())), + ])), None); + let mut text = output.text.as_str(); + while !text.is_empty() { + let end = text.floor_char_boundary((32 * 1024).min(text.len())); + let (chunk, rest) = text.split_at(end); + self.record_at( + &Value::Object(serde_json::Map::from_iter([ + ( + "sessionUpdate".into(), + Value::String("agent_message_chunk".into()), + ), + ( + "messageId".into(), + Value::String(format!("kit-replay-{generation}")), + ), + ( + "content".into(), + Value::Object(serde_json::Map::from_iter([ + ("type".into(), Value::String("text".into())), + ("text".into(), Value::String(chunk.into())), + ])), + ), + ])), + None, + ); + text = rest; + } + let mut segments = super::InspectionSegments::default(); + for update in &output.updates { + let mut update = update.clone(); + if segments.normalize(generation, &mut update).is_err() { + self.fail(); + return; + } + if let Some(update) = super::inspection_event(owner, generation, update) { + self.record_at(&update, None); + } + } + } + + pub(crate) fn fail(&self) { + self.shared.failed.store(true, Ordering::Release); + } + pub(crate) fn record(&self, update: &Value) { + let observed_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|elapsed| u64::try_from(elapsed.as_millis()).ok()); + self.record_at(update, observed_at); + } + + fn record_at(&self, update: &Value, observed_at: Option) { + if self.shared.failed.load(Ordering::Acquire) { + return; + } + // Serialization itself is bounded; an enormous tool/image update does + // not allocate an equally enormous second copy or invalidate the log. + let mut buffer = BoundedRecord(Vec::new()); + let record = TimedRecord::new(update, observed_at); + if serde_json::to_writer(&mut buffer, &record).is_err() { + let notice = Value::Object(serde_json::Map::from_iter([ + ("sessionUpdate".into(), Value::String("kit_transcript_truncated".into())), + ("reason".into(), Value::String("One inspection update exceeded 1 MiB and was omitted; later updates remain available".into())), + ])); + buffer.0.clear(); + if serde_json::to_writer(&mut buffer, &TimedRecord::new(¬ice, record.observed_at)) + .is_err() + { + self.fail(); + return; + } + } + buffer.0.push(b'\n'); + let bytes = buffer.0; + let Ok(finished) = self.shared.finished.lock() else { + self.fail(); + return; + }; + if *finished { + // Removed seals history before child cleanup completes. In-flight + // updates are no longer admitted, but do not invalidate the snapshot. + return; + } + let size = bytes.len(); + if self.shared.pending_bytes.load(Ordering::Acquire) + size > QUEUE_BYTES { + self.fail(); + return; + } + self.shared.pending_bytes.fetch_add(size, Ordering::Release); + self.shared.pending.fetch_add(1, Ordering::Release); + if self.sender.try_send(bytes).is_err() { + self.shared.pending_bytes.fetch_sub(size, Ordering::Release); + self.fail(); + self.shared.pending.fetch_sub(1, Ordering::Release); + } + } + pub(crate) async fn read(&self, cursor: u64) -> Result { + let shared = self.shared.clone(); + tokio::task::spawn_blocking(move || shared.read(cursor)) + .await + .map_err(|error| format!("transcript reader failed: {error}"))? + } +} +/// Additive ephemeral record metadata. Every writer emits an unsigned Unix +/// millisecond timestamp or explicit null. Files are never reopened across runs, +/// so there is no persistent migration. Missing legacy metadata means unknown. +/// ACP message updates have no native event timestamp; preserve supplied Kit +/// timing when available rather than replacing it with a replay observation. +/// A supplied value uses the source's wall clock; otherwise this is Kit's local +/// receipt time. Neither is an exact remote execution start. Clock skew or clock +/// adjustments can therefore make a boundary pair unusable, which the UI treats +/// as unknown rather than synthesizing a duration. +struct TimedRecord<'a> { + update: &'a Value, + observed_at: Option, +} +impl<'a> TimedRecord<'a> { + fn new(update: &'a Value, observed_at: Option) -> Self { + Self { + update, + observed_at: match update.get("kitObservedAtUnixMs") { + Some(value) => value.as_u64(), + None => observed_at, + }, + } + } +} +impl Serialize for TimedRecord<'_> { + fn serialize(&self, serializer: S) -> Result { + let object = self + .update + .as_object() + .ok_or_else(|| serde::ser::Error::custom("inspection update must be an object"))?; + let mut map = serializer.serialize_map(None)?; + for (key, value) in object { + if key != "kitObservedAtUnixMs" { + map.serialize_entry(key, value)?; + } + } + map.serialize_entry("kitObservedAtUnixMs", &self.observed_at)?; + map.end() + } +} + +/// A production serialization boundary: memory cannot grow with arbitrary +/// incoming content. Larger records become an explicit, nonfatal notice. +struct BoundedRecord(Vec); +impl Write for BoundedRecord { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > FRAME_BYTES.saturating_sub(self.0.len()) { + return Err(std::io::Error::other( + "inspection update exceeds record limit", + )); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Shared { + fn read(&self, cursor: u64) -> Result { + let unavailable = + || "transcript spool unavailable (IO, queue overflow, or writer failure)".to_string(); + if self.failed.load(Ordering::Acquire) { + return Err(unavailable()); + } + let end = self.committed.load(Ordering::Acquire); + if cursor > end { + return Err("invalid transcript cursor".into()); + } + let Some(path) = self.path.get() else { + return Ok(Page { + updates: vec![], + next_cursor: cursor, + caught_up: false, + }); + }; + let mut file = File::open(path).map_err(|_| unavailable())?; + if cursor != 0 { + file.seek(SeekFrom::Start(cursor - 1)) + .map_err(|_| unavailable())?; + let mut byte = [0]; + file.read_exact(&mut byte).map_err(|_| unavailable())?; + if byte[0] != b'\n' { + return Err("invalid transcript cursor boundary".into()); + } + } + file.seek(SeekFrom::Start(cursor)) + .map_err(|_| unavailable())?; + let mut reader = BufReader::new(file.take(end - cursor)); + let mut updates = Vec::new(); + let mut next_cursor = cursor; + for _ in 0..PAGE_UPDATES { + let mut line = Vec::new(); + let size = reader + .by_ref() + .take((FRAME_BYTES + 2) as u64) + .read_until(b'\n', &mut line) + .map_err(|_| unavailable())?; + if size == 0 { + break; + } + if line.last() != Some(&b'\n') || size > FRAME_BYTES + 1 { + return Err(unavailable()); + } + if !updates.is_empty() && next_cursor - cursor + size as u64 > PAGE_BYTES { + break; + } + updates.push(serde_json::from_slice(&line).map_err(|_| unavailable())?); + next_cursor += size as u64; + } + if self.failed.load(Ordering::Acquire) { + return Err(unavailable()); + } + let caught_up = next_cursor == self.committed.load(Ordering::Acquire) + && self.pending.load(Ordering::Acquire) == 0; + Ok(Page { + updates, + next_cursor, + caught_up, + }) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::disallowed_methods, + clippy::disallowed_macros +)] +mod tests { + use super::*; + use serde_json::json; + + fn update(n: usize) -> Value { + json!({"n": n, "kitObservedAtUnixMs": 123}) + } + async fn drain(transcript: &Transcript, mut cursor: u64) -> Page { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let mut updates = vec![]; + loop { + let page = transcript.read(cursor).await.unwrap(); + assert!(page.updates.len() <= PAGE_UPDATES); + assert!(page.next_cursor - cursor <= (FRAME_BYTES + 1) as u64); + cursor = page.next_cursor; + updates.extend(page.updates); + if page.caught_up { + return Page { + updates, + next_cursor: cursor, + caught_up: true, + }; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap() + } + + #[test] + fn timing_schema_preserves_source_and_normalizes_unknown() { + let legacy = json!({"sessionUpdate": "agent_message_chunk"}); + assert_eq!( + serde_json::to_value(TimedRecord::new(&legacy, None)).unwrap(), + json!({"sessionUpdate": "agent_message_chunk", "kitObservedAtUnixMs": null}) + ); + assert_eq!( + serde_json::to_value(TimedRecord::new(&legacy, Some(1_700_000_000_123))).unwrap()["kitObservedAtUnixMs"], + 1_700_000_000_123_u64 + ); + for source in [json!(123), json!(null), json!("bad"), json!(-1), json!(1.5)] { + let current = json!({"kitObservedAtUnixMs": source}); + let encoded = serde_json::to_value(TimedRecord::new(¤t, Some(999))).unwrap(); + assert_eq!( + encoded["kitObservedAtUnixMs"], + source.as_u64().map(Value::from).unwrap_or(Value::Null) + ); + } + } + + #[tokio::test] + async fn original_observation_survives_disk_pages_and_truncation() { + let transcript = Transcript::start(); + transcript.record_at( + &json!({"sessionUpdate":"agent_message_chunk"}), + Some(1_700_000_000_123), + ); + transcript.record_at( + &json!({"text":"x".repeat(FRAME_BYTES + 1)}), + Some(1_700_000_000_456), + ); + let first = drain(&transcript, 0).await; + assert_eq!( + first.updates[0]["kitObservedAtUnixMs"], + 1_700_000_000_123_u64 + ); + assert_eq!( + first.updates[1]["kitObservedAtUnixMs"], + 1_700_000_000_456_u64 + ); + assert_eq!( + first.updates[1]["sessionUpdate"], + "kit_transcript_truncated" + ); + transcript.record_at( + &json!({"sessionUpdate":"agent_message_chunk"}), + Some(1_700_000_000_789), + ); + let tail = drain(&transcript, first.next_cursor).await; + assert_eq!( + tail.updates[0]["kitObservedAtUnixMs"], + 1_700_000_000_789_u64 + ); + let repeated = drain(&transcript, 0).await; + assert_eq!(repeated.updates[..2], first.updates); + } + + #[tokio::test] + async fn historical_import_preserves_source_time_and_marks_missing_unknown() { + let transcript = Transcript::start(); + transcript.replay("child", 1, &super::super::ChildOutput { + text: "historical text".into(), + updates: vec![ + json!({"sessionUpdate":"agent_thought_chunk", "content":{"type":"text","text":"unknown"}}), + json!({"sessionUpdate":"agent_thought_chunk", "content":{"type":"text","text":"known"}, "kitObservedAtUnixMs":123}), + ], + ..Default::default() + }); + let history = drain(&transcript, 0).await; + assert_eq!(history.updates.len(), 4); + for update in &history.updates[..3] { + assert_eq!(update.get("kitObservedAtUnixMs"), Some(&Value::Null)); + } + assert_eq!(history.updates[3]["kitObservedAtUnixMs"], 123); + } + + #[tokio::test] + async fn disk_replay_then_live_handoff_is_ordered_without_duplicates() { + let transcript = Transcript::start(); + for n in 0..150 { + transcript.record(&update(n)); + } + let snapshot = drain(&transcript, 0).await; + assert_eq!(snapshot.updates, (0..150).map(update).collect::>()); + assert!( + transcript + .read(snapshot.next_cursor) + .await + .unwrap() + .updates + .is_empty() + ); + for n in 150..200 { + transcript.record(&update(n)); + } + let live = drain(&transcript, snapshot.next_cursor).await; + assert_eq!(live.updates, (150..200).map(update).collect::>()); + assert_eq!(drain(&transcript, 0).await.updates.len(), 200); + // A real file backs replay, not an in-memory transcript vector. + let path = transcript.shared.path.get().unwrap(); + assert_eq!(std::fs::metadata(path).unwrap().len(), live.next_cursor); + assert!(transcript.read(1).await.unwrap_err().contains("boundary")); + assert!( + transcript + .read(live.next_cursor + 1) + .await + .unwrap_err() + .contains("cursor") + ); + } + + #[tokio::test] + async fn generation_and_root_isolation_and_sticky_failure() { + let first = Transcripts::default(); + let second = Transcripts::default(); + first.start("child", 1); + let old = first.get("child", 1).unwrap(); + old.record(&update(1)); + drain(&old, 0).await; + assert!(second.get("child", 1).is_err()); + assert!(first.get("descendant", 1).is_err()); + first.start("child", 2); + first.start("child", 1); // delayed lifecycle event cannot restore generation 1 + assert!(first.get("child", 1).is_err()); + let current = first.get("child", 2).unwrap(); + assert_eq!(drain(¤t, 0).await.updates, vec![update(1)]); + current.record(&update(2)); + assert_eq!(drain(¤t, 0).await.updates, vec![update(1), update(2)]); + current.fail(); + assert!(current.read(0).await.is_err()); + current.record(&update(3)); + assert!(current.read(0).await.is_err()); + } + + #[tokio::test] + async fn large_updates_replay_and_oversized_notice_does_not_freeze_history() { + let transcript = Transcript::start(); + let medium = json!({"text": "m".repeat(16 * 1024), "kitObservedAtUnixMs": 123}); + let large = json!({"text": "l".repeat(512 * 1024), "kitObservedAtUnixMs": 123}); + transcript.record(&medium); + transcript.record(&large); + transcript.record(&json!({"text": "x".repeat(FRAME_BYTES + 1)})); + transcript.record(&update(4)); + let history = drain(&transcript, 0).await; + assert_eq!(history.updates.len(), 4); + assert_eq!(history.updates[0], medium); + assert_eq!(history.updates[1], large); + assert_eq!( + history.updates[2]["sessionUpdate"], + "kit_transcript_truncated" + ); + assert_eq!(history.updates[3], update(4)); + } + + #[tokio::test] + async fn supplied_replay_imports_text_and_rich_updates_with_partial_notice() { + let transcript = Transcript::start(); + let output = super::super::ChildOutput { + text: "historic reply".into(), + updates: vec![ + json!({"sessionUpdate":"tool_call", "toolCallId":"old-tool", "title":"Read"}), + ], + updates_truncated: true, + ..Default::default() + }; + transcript.replay("child", 1, &output); + transcript.record(&update(1)); + let history = drain(&transcript, 0).await; + assert_eq!( + history.updates[0]["sessionUpdate"], + "kit_transcript_partial" + ); + assert_eq!(history.updates[1]["content"]["text"], "historic reply"); + assert_eq!(history.updates[2]["sessionUpdate"], "tool_call_update"); + assert_eq!(history.updates[3], update(1)); + } + + fn route(transcript: Transcript) -> super::super::Route { + super::super::Route { + transcript: Some(transcript), + inspection_segments: Arc::default(), + owner: "child".into(), + generation: 1, + output: Arc::default(), + idle: tokio::sync::watch::channel(super::super::protocol::Foreground::Waiting).0, + } + } + + #[tokio::test] + async fn fragmented_echo_and_identical_steer_are_preserved_with_uncertainty() { + let transcript = Transcript::start(); + transcript.record_submitted_prompt( + "child", + 1, + &[super::super::ContentBlock::Text( + agentkit_acp::TextContent::new("hello"), + )], + ); + let route = route(transcript.clone()); + for text in ["hel", "lo"] { + route.record_inspection(json!({"sessionUpdate":"user_message_chunk", "content":{"type":"text","text":text}})); + } + route.record_inspection(json!({"sessionUpdate":"user_message", "messageId":"steer-accepted", "content":[{"type":"text","text":"hello"}]})); + let history = drain(&transcript, 0).await; + assert_eq!(history.updates.len(), 5); + assert_eq!( + history.updates[0]["sessionUpdate"], + "kit_transcript_partial" + ); + assert!( + history.updates[0]["reason"] + .as_str() + .unwrap() + .contains("echo") + ); + assert_eq!(history.updates[1]["content"]["text"], "hello"); + assert_eq!(history.updates[2]["content"]["text"], "hel"); + assert_eq!(history.updates[3]["content"]["text"], "lo"); + assert_eq!( + history.updates[2]["messageId"], + history.updates[3]["messageId"] + ); + assert_ne!( + history.updates[1]["messageId"], + history.updates[2]["messageId"] + ); + assert_eq!(history.updates[4]["messageId"], "steer-accepted"); + assert_eq!(history.updates[4]["content"][0]["text"], "hello"); + } + + #[tokio::test] + async fn poisoned_segments_and_admission_fail_only_inspection() { + let transcript = Transcript::start(); + let route = route(transcript.clone()); + let _ = std::panic::catch_unwind(|| { + let _guard = route.inspection_segments.lock().unwrap(); + panic!("segment writer unwind"); + }); + route.record_inspection(json!({"sessionUpdate":"agent_message_chunk", "content":{"type":"text","text":"reply"}})); + assert!(transcript.read(0).await.is_err()); + // The execution accumulator and completion signal remain usable. + route + .output + .lock() + .unwrap() + .text + .push_str("execution continues"); + assert_eq!(route.output.lock().unwrap().text, "execution continues"); + let mut completion = route.idle.subscribe(); + route + .idle + .send_replace(super::super::protocol::Foreground::Idle(None)); + completion.changed().await.unwrap(); + + let other = Transcript::start(); + let _ = std::panic::catch_unwind(|| { + let _guard = other.shared.finished.lock().unwrap(); + panic!("admission writer unwind"); + }); + other.record(&update(0)); + other.finish(); + assert!(other.read(0).await.is_err()); + assert!(other.shared.finished.is_poisoned()); + } + + #[tokio::test] + async fn deleted_spool_is_an_explicit_read_error() { + let transcript = Transcript::start(); + transcript.record(&update(0)); + drain(&transcript, 0).await; + std::fs::remove_file(transcript.shared.path.get().unwrap()).unwrap(); + assert!(transcript.read(0).await.is_err()); + } + + #[tokio::test] + async fn finished_history_survives_writer_exit_and_reader_cancellation() { + let transcript = Transcript::start(); + for n in 0..10 { + transcript.record(&update(n)); + } + transcript.finish(); + transcript.finish(); + let reader = transcript.clone(); + let cancelled = tokio::spawn(async move { reader.read(0).await }); + cancelled.abort(); + let _ = cancelled.await; + let retained = drain(&transcript, 0).await; + assert_eq!(retained.updates.len(), 10); + transcript.record(&update(11)); + let after = drain(&transcript, 0).await; + assert_eq!(after.updates, retained.updates); + assert_eq!(after.next_cursor, retained.next_cursor); + } + + #[tokio::test] + async fn full_or_disconnected_admission_fails_closed() { + for disconnected in [false, true] { + let (sender, receiver) = mpsc::sync_channel(1); + let transcript = Transcript { + sender, + shared: Arc::default(), + }; + if disconnected { + drop(receiver); + } else { + transcript.record(&update(0)); + transcript.record(&update(1)); + drop(receiver); + } + transcript.record(&update(2)); + assert!(transcript.read(0).await.is_err()); + } + } + + #[test] + fn poisoned_registry_is_isolated() { + let transcripts = Transcripts::default(); + let _ = std::panic::catch_unwind(|| { + let _guard = transcripts.generations.lock().unwrap(); + panic!("writer unwind"); + }); + transcripts.start("child", 1); + assert!(transcripts.get("child", 1).is_err()); + } +} diff --git a/src/events.rs b/src/events.rs index b6be0bf3..42507574 100644 --- a/src/events.rs +++ b/src/events.rs @@ -60,6 +60,12 @@ pub enum RuntimeEvent { #[serde(default, skip_serializing_if = "Option::is_none")] cost: Option, }, + /// Steering support advertised by the child's negotiated ACP connection. + SubagentCapabilities { + id: String, + generation: u64, + can_steer: bool, + }, /// A child ACP update relevant to its roster excerpt. SubagentActivity { id: String, @@ -159,6 +165,7 @@ impl RuntimeEvent { Self::SubagentStateChanged { .. } | Self::SubagentUsage { .. } | Self::SubagentActivity { .. } + | Self::SubagentCapabilities { .. } | Self::SubagentDescendantsRemoved { .. } ) } @@ -307,6 +314,26 @@ mod tests { } } + #[test] + fn subagent_inspection_round_trips_and_forwards_from_children() { + for event in [ + RuntimeEvent::SubagentCapabilities { + id: "nested-child".into(), + generation: 7, + can_steer: false, + }, + RuntimeEvent::SubagentCapabilities { + id: "nested-child".into(), + generation: 7, + can_steer: true, + }, + ] { + let line = format!("{EVENT_MARKER}{}", serde_json::to_string(&event).unwrap()); + assert_eq!(parse(&line), Some(event.clone())); + assert!(event.forward_from_child()); + } + } + #[test] fn subagent_activity_round_trips_and_forwards_from_children() { for activity in [ diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 0119592c..3127bb97 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -423,6 +423,40 @@ fn hex_value(byte: u8) -> Option { } } +/// Bounded polling of runtime-owned disk history. Cursor zero starts replay; +/// next_cursor is an opaque byte offset. Reuse it to transition into live reads. +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] +#[request(method = "kit/subagent/transcript/read", response = ReadSubagentTranscriptResponse)] +pub(crate) struct ReadSubagentTranscriptRequest { + pub session_id: agentkit_acp::SessionId, + pub id: String, + pub generation: u64, + pub cursor: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] +pub(crate) struct ReadSubagentTranscriptResponse { + pub updates: Vec, + pub next_cursor: u64, + pub generation: u64, + pub caught_up: bool, +} + +/// Generation-checked steering of one live direct subagent. +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] +#[request(method = "kit/subagent/steer", response = SteerSubagentResponse)] +pub(crate) struct SteerSubagentRequest { + pub session_id: agentkit_acp::SessionId, + pub id: String, + pub generation: u64, + pub prompt: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] +pub(crate) struct SteerSubagentResponse { + pub receipt: serde_json::Value, +} + /// Kit-private ACP extension used by the bundled TUI to stop one detached call. #[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] #[request(method = "kit/background/cancel", response = CancelBackgroundResponse)] @@ -527,6 +561,7 @@ enum Command { } struct SessionHandle { + subagents: Option, voice_state: crate::runtime::voice_state::VoiceState, token: u64, commands: mpsc::Sender, @@ -1643,6 +1678,7 @@ impl Server { &mut admission, registered, SessionHandle { + subagents: Some(driver.subagents), voice_state, token, commands: tx, @@ -1780,6 +1816,47 @@ impl Server { .ok_or_else(|| AcpRuntimeError::SessionNotFound(session_id.to_string())) } + async fn read_subagent_transcript( + &self, + request: ReadSubagentTranscriptRequest, + ) -> Result { + let subagents = self + .sessions + .lock() + .map_err(|_| AcpRuntimeError::ClientClosed)? + .get(&request.session_id.to_string().into()) + .and_then(|session| session.subagents.clone()) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?; + let page = subagents + .read_transcript(&request.id, request.generation, request.cursor) + .await + .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; + Ok(ReadSubagentTranscriptResponse { + updates: page.updates, + next_cursor: page.next_cursor, + generation: request.generation, + caught_up: page.caught_up, + }) + } + + async fn steer_subagent( + &self, + request: SteerSubagentRequest, + ) -> Result { + let subagents = self + .sessions + .lock() + .map_err(|_| AcpRuntimeError::ClientClosed)? + .get(&request.session_id.to_string().into()) + .and_then(|session| session.subagents.clone()) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?; + let receipt = subagents + .steer_generation(&request.id, Some(request.generation), request.prompt.into()) + .await + .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; + Ok(SteerSubagentResponse { receipt }) + } + async fn detach_compose( &self, request: DetachComposeRequest, @@ -2772,6 +2849,39 @@ fn component( }, agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: ReadSubagentTranscriptRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + responder.respond_with_result( + state + .read_subagent_transcript(request) + .await + .map_err(sdk_error), + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: SteerSubagentRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + responder.respond_with_result( + state.steer_subagent(request).await.map_err(sdk_error), + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) .on_receive_request( { let state = Arc::clone(&state); @@ -2926,8 +3036,100 @@ async fn drain_client_messages( clippy::disallowed_methods, clippy::disallowed_macros )] -mod test_support { +pub(crate) mod test_support { use super::*; + use agentkit_task_manager::TaskManager as _; + + /// Exercises the real request type and handler over ACP, with an actual + /// child's runtime-owned manager supplied by the subprocess regression test. + pub(crate) async fn assert_transcript_route( + root: &std::path::Path, + manager: crate::tools::Subagents, + child: String, + generation: u64, + ) { + let server = Arc::new(Server::new( + Runtime::new(root, "test-model").unwrap(), + AcpIntegration::builder() + .name("transcript-route-test") + .approval_resolver(AutoDenyResolver) + .build() + .unwrap(), + SessionRegistry::new(), + )); + for (id, subagents) in [("owner", manager.clone()), ("other", manager.fresh())] { + server.sessions.lock().unwrap().insert( + agentkit_acp::SessionId::new(id), + SessionHandle { + subagents: Some(subagents), + voice_state: Default::default(), + token: 1, + commands: mpsc::channel(1).0, + background_jobs: BackgroundJobs::default(), + structured_completion: false, + tasks: agentkit_task_manager::AsyncTaskManager::new().handle(), + }, + ); + } + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + let service = agent_client_protocol::Agent.builder().on_receive_request( + { + let server = server.clone(); + async move |request: ReadSubagentTranscriptRequest, responder, _cx| { + responder.respond_with_result( + server + .read_subagent_transcript(request) + .await + .map_err(sdk_error), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ); + let actor = tokio::spawn(service.connect_to(agent_transport)); + agent_client_protocol::Client + .builder() + .connect_with(client_transport, async move |connection| { + let request = ReadSubagentTranscriptRequest { + session_id: agentkit_acp::SessionId::new("owner"), + id: child, + generation, + cursor: 0, + }; + let page = connection + .send_request(request.clone()) + .block_task() + .await?; + assert_eq!(page.generation, generation); + assert!(!page.updates.is_empty()); + assert!(page.next_cursor > 0); + for invalid in [ + ReadSubagentTranscriptRequest { + session_id: agentkit_acp::SessionId::new("other"), + ..request.clone() + }, + ReadSubagentTranscriptRequest { + session_id: agentkit_acp::SessionId::new("missing"), + ..request.clone() + }, + ReadSubagentTranscriptRequest { + generation: generation + 1, + ..request.clone() + }, + ReadSubagentTranscriptRequest { + cursor: u64::MAX, + ..request + }, + ] { + assert!(connection.send_request(invalid).block_task().await.is_err()); + } + Ok(()) + }) + .await + .unwrap(); + actor.abort(); + let _ = actor.await; + } impl SessionRegistry { pub(super) async fn reset_authentication(&self) -> bool { @@ -3348,6 +3550,7 @@ pub(super) mod tests { }); let (commands, received) = mpsc::channel(1); let session = SessionHandle { + subagents: None, voice_state: Default::default(), token, commands, @@ -5308,6 +5511,7 @@ pub(super) mod tests { server.sessions.lock().unwrap().insert( session_id.clone(), SessionHandle { + subagents: None, voice_state: Default::default(), token: 1, commands, @@ -5373,6 +5577,7 @@ pub(super) mod tests { server.sessions.lock().unwrap().insert( session_id.clone(), SessionHandle { + subagents: None, voice_state: Default::default(), token: 1, commands, diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index 8adaa750..9045977d 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -529,6 +529,7 @@ enum Command { } struct SessionHandle { + subagents: Option, voice_state: crate::runtime::voice_state::VoiceState, token: u64, commands: mpsc::Sender, @@ -1068,6 +1069,7 @@ impl Server { completed: completion, session_id: session_id.clone(), session: SessionHandle { + subagents: Some(driver.subagents), voice_state, token, commands: tx, @@ -1240,6 +1242,47 @@ impl Server { Ok(wire::CloseSessionResponse::new()) } + async fn read_subagent_transcript( + &self, + request: super::ReadSubagentTranscriptRequest, + ) -> Result { + let subagents = self + .sessions + .lock() + .map_err(|_| AcpRuntimeError::ClientClosed)? + .get(&request.session_id.to_string().into()) + .and_then(|session| session.subagents.clone()) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?; + let page = subagents + .read_transcript(&request.id, request.generation, request.cursor) + .await + .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; + Ok(super::ReadSubagentTranscriptResponse { + updates: page.updates, + next_cursor: page.next_cursor, + generation: request.generation, + caught_up: page.caught_up, + }) + } + + async fn steer_subagent( + &self, + request: super::SteerSubagentRequest, + ) -> Result { + let subagents = self + .sessions + .lock() + .map_err(|_| AcpRuntimeError::ClientClosed)? + .get(&request.session_id.to_string().into()) + .and_then(|session| session.subagents.clone()) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?; + let receipt = subagents + .steer_generation(&request.id, Some(request.generation), request.prompt.into()) + .await + .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; + Ok(super::SteerSubagentResponse { receipt }) + } + async fn detach_compose( &self, request: DetachComposeRequest, @@ -2394,6 +2437,39 @@ pub(crate) fn component( }, agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: super::ReadSubagentTranscriptRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + responder.respond_with_result( + state + .read_subagent_transcript(request) + .await + .map_err(sdk_error), + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: super::SteerSubagentRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + responder.respond_with_result( + state.steer_subagent(request).await.map_err(sdk_error), + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) .on_receive_request( { let state = Arc::clone(&state); @@ -4518,6 +4594,7 @@ mod tests { server.sessions.lock().unwrap().insert( id.clone(), SessionHandle { + subagents: None, voice_state: state, token: 1, commands, @@ -4585,6 +4662,7 @@ mod tests { server.sessions.lock().unwrap().insert( session_id.clone(), SessionHandle { + subagents: None, voice_state: Default::default(), token: 1, commands, @@ -4727,6 +4805,7 @@ mod tests { completed: completion, session_id, session: SessionHandle { + subagents: None, voice_state: Default::default(), token, commands, @@ -5047,6 +5126,7 @@ mod tests { completed: completion, session_id: published_session_id, session: SessionHandle { + subagents: None, voice_state: Default::default(), token, commands, diff --git a/src/runtime.rs b/src/runtime.rs index eb3759e7..9df547d6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -468,6 +468,7 @@ impl Drop for SessionClaim { } pub(crate) struct AcpDriver { + pub subagents: Subagents, pub driver: LoopDriver, pub skills: Vec, pub tasks: TaskManagerHandle, @@ -1713,7 +1714,7 @@ impl Runtime { .telemetry(self.agentkit_telemetry()) .add_tool_source(self.compose_with_jobs_and_mcp( self.base_depth, - subagents, + subagents.clone(), background_jobs.clone(), skills, mcp, @@ -1730,6 +1731,7 @@ impl Runtime { .await .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; let driver = AcpDriver { + subagents, driver, skills: skill_catalog, tasks, diff --git a/src/tools/subagent.rs b/src/tools/subagent.rs index 75a4df3f..20d22c42 100644 --- a/src/tools/subagent.rs +++ b/src/tools/subagent.rs @@ -1,4 +1,5 @@ mod recovery; +use crate::acp_child::transcript; use std::{ collections::{HashMap, HashSet}, @@ -107,6 +108,7 @@ pub struct Subagents { sessions: Arc>>, capacity: Arc, event_sink: EventSink, + transcripts: Arc, observer: Option, } @@ -288,6 +290,7 @@ impl Subagents { sessions: Arc::default(), capacity: Arc::new(Semaphore::new(MAX_LIVE_SUBAGENTS)), observer: None, + transcripts: Arc::default(), event_sink: Arc::new(|event| { events::emit(event); Ok(()) @@ -316,7 +319,34 @@ impl Subagents { Self::new(config, self.max_depth) } + pub(crate) async fn read_transcript( + &self, + id: &str, + generation: u64, + cursor: u64, + ) -> Result { + let transcript = self.transcripts.get(id, generation)?; + let page = transcript.read(cursor).await?; + // Reject a generation switch that happened while disk IO was in flight. + self.transcripts.get(id, generation)?; + Ok(page) + } + fn emit_event(&self, mut event: events::RuntimeEvent) { + if let events::RuntimeEvent::SubagentStateChanged { + id, + generation, + status, + .. + } = &event + { + self.transcripts.start(id, *generation); + if matches!(status, SubagentStatus::Removed) + && let Ok(transcript) = self.transcripts.get(id, *generation) + { + transcript.finish(); + } + } if let events::RuntimeEvent::SubagentStateChanged { parent_id, parent_name, @@ -467,10 +497,12 @@ impl Subagents { } self.monitor_child_exit(id.clone(), &state, &child); let output = match child - .prompt( + .prompt_generation( id.clone(), + 1, structured_prompt(prompt, contract), cancellation, + self.transcripts.get(&id, 1).ok(), ) .await { @@ -509,6 +541,15 @@ impl Subagents { } async fn steer(&self, id: &str, prompt: ChildPrompt) -> Result { + self.steer_generation(id, None, prompt).await + } + + pub(crate) async fn steer_generation( + &self, + id: &str, + generation: Option, + prompt: ChildPrompt, + ) -> Result { let state = self .sessions .lock() @@ -516,9 +557,12 @@ impl Subagents { .get(id) .map(|entry| Arc::clone(&entry.state)) .ok_or_else(|| ChildError::Failed(format!("unknown subagent session {id:?}")))?; - let child = { + let (child, generation) = { let locked = state.lock().await; self.check_active(&locked)?; + if generation.is_some_and(|generation| generation != locked.generation) { + return Err(ChildError::Failed("stale subagent generation".into())); + } if locked.forking.is_some() { return Err(ChildError::Failed( "subagent session is being forked".into(), @@ -537,14 +581,16 @@ impl Subagents { )); } } - locked - .child - .clone() - .ok_or_else(|| ChildError::Failed("subagent session is still starting".into()))? + ( + locked.child.clone().ok_or_else(|| { + ChildError::Failed("subagent session is still starting".into()) + })?, + locked.generation, + ) }; // Steering owns no lifecycle transition or generation. Do not hold state // across the child request: completion and close must remain independent. - child.steer(prompt).await + child.steer_generation(prompt, Some(generation)).await } async fn prompt( @@ -593,10 +639,12 @@ impl Subagents { drop(locked); self.emit_event(event); match child - .prompt( + .prompt_generation( prior.id.clone(), + generation, structured_prompt(prompt, contract), cancellation, + self.transcripts.get(&prior.id, generation).ok(), ) .await { @@ -860,8 +908,9 @@ impl Subagents { source_child .fork(model.as_deref(), parent, &cancellation) .await + .map(|child| (child, None)) } else { - ChildSession::start( + ChildSession::start_with_output( child_config, harness.clone(), Some((id.clone(), true)), @@ -870,8 +919,9 @@ impl Subagents { cancellation.clone(), ) .await + .map(|(child, replay)| (child, Some(replay))) }; - let child = match child_result { + let (child, replay) = match child_result { Ok(child) => child, Err(error) => { self.fail_removed_and_remove(&id, &state).await; @@ -922,11 +972,31 @@ impl Subagents { .cleanup_installed_child(&id, &state, &child, ChildError::Cancelled) .await); } + if let Ok(transcript) = self.transcripts.get(&id, generation) { + if let Some(replay) = &replay { + transcript.replay(&id, generation, replay); + } else { + transcript.record(&Value::Object(Map::from_iter([ + ( + "sessionUpdate".into(), + Value::String("kit_transcript_partial".into()), + ), + ( + "reason".into(), + Value::String( + "Inherited transcript before this fork is unavailable".into(), + ), + ), + ]))); + } + } let output = match child - .prompt( + .prompt_generation( id.clone(), + generation, structured_prompt(prompt, contract.as_deref()), cancellation, + self.transcripts.get(&id, generation).ok(), ) .await { @@ -1345,6 +1415,7 @@ impl Subagents { let sessions = Arc::downgrade(&self.sessions); let state = Arc::downgrade(state); let event_sink = Arc::clone(&self.event_sink); + let transcripts = Arc::downgrade(&self.transcripts); let parent_id = self.config.parent_id.clone(); let parent_name = self.config.parent_name.clone(); let mut closed = child.closed_signal(); @@ -1368,7 +1439,13 @@ impl Subagents { .generation_finished_at_unix_ms .get_or_insert_with(events::now_millis); let mut event = locked.runtime_event(id.clone()); + let generation = locked.generation; drop(locked); + if let Some(transcripts) = transcripts.upgrade() + && let Ok(transcript) = transcripts.get(&id, generation) + { + transcript.finish(); + } if let Some(sessions) = sessions.upgrade() && let Ok(mut sessions) = sessions.lock() && sessions @@ -2392,7 +2469,7 @@ mod steer_tests { ) }; let capacity = manager.capacity.available_permits(); - let receipt = manager.steer(id, "change direction".into()).await.unwrap(); + let receipt = manager.steer(id, "original turn".into()).await.unwrap(); assert_eq!(receipt, json!({"messageId": "injected-1"})); assert!( !turn.is_finished(), @@ -2435,6 +2512,41 @@ mod steer_tests { assert_eq!(continued.generation, before.0 + 1); assert_eq!(continued.output, json!("next turn")); assert_eq!(state.lock().await.status, SubagentStatus::Idle); + assert!(manager.read_transcript(id, before.0, 0).await.is_err()); + let mut cursor = 0; + let mut history = Vec::new(); + loop { + let page = manager + .read_transcript(id, continued.generation, cursor) + .await + .unwrap(); + cursor = page.next_cursor; + history.extend(page.updates); + if page.caught_up { + break; + } + tokio::task::yield_now().await; + } + let user_texts: Vec<_> = history + .iter() + .filter(|update| update["sessionUpdate"] == "user_message_chunk") + .filter_map(|update| update["content"]["text"].as_str()) + .collect(); + assert!(user_texts.contains(&"original turn")); + assert!(user_texts.contains(&"next turn")); + assert!( + history + .iter() + .any(|update| update["messageId"] == "injected-1" + && update["content"][0]["text"] == "original turn") + ); + crate::protocols::acp::test_support::assert_transcript_route( + root.path(), + manager.clone(), + id.clone(), + continued.generation, + ) + .await; manager .close(id, &TurnCancellation::default()) .await diff --git a/src/tools/subagent/recovery.rs b/src/tools/subagent/recovery.rs index f2db1b9d..bb940c22 100644 --- a/src/tools/subagent/recovery.rs +++ b/src/tools/subagent/recovery.rs @@ -178,7 +178,7 @@ impl Subagents { let cancellation = cancellation.clone(); let id = id.to_owned(); // The task owns completion/rollback even if the caller drops its future. - // Replay stays private; it never replaces the public last-turn output. + // Replay feeds inspection only; it never replaces public last-turn output. tokio::spawn(async move { let result = ChildSession::start_with_output( config, @@ -191,7 +191,7 @@ impl Subagents { .await; let mut locked = state.lock().await; match result { - Ok((child, _replay)) => { + Ok((child, replay)) => { if let Err(error) = manager.check_active(&locked) { drop(locked); return Err(manager @@ -201,7 +201,12 @@ impl Subagents { locked.child = Some(child.clone()); locked.permit = Some(permit); locked.status = SubagentStatus::Idle; + let generation = locked.generation; drop(locked); + manager.transcripts.start(&id, generation); + if let Ok(transcript) = manager.transcripts.get(&id, generation) { + transcript.replay(&id, generation, &replay); + } manager.monitor_child_exit(id, &state, &child); Ok(()) } diff --git a/src/tools/subagent/tests.rs b/src/tools/subagent/tests.rs index aed917a3..d8f12d6f 100644 --- a/src/tools/subagent/tests.rs +++ b/src/tools/subagent/tests.rs @@ -877,6 +877,7 @@ struct ScenarioOptions { fail_delete: bool, gate_new: bool, gate_fork: bool, + gate_close: bool, gate_prompt: Option<&'static str>, fail_close_session: Option<&'static str>, } @@ -888,6 +889,7 @@ struct MockAcpScenario { new_release: std::path::PathBuf, fork_release: std::path::PathBuf, prompt_release: std::path::PathBuf, + close_release: std::path::PathBuf, } impl MockAcpScenario { @@ -897,6 +899,7 @@ impl MockAcpScenario { let new_release = root.path().join("release-new"); let fork_release = root.path().join("release-fork"); let prompt_release = root.path().join("release-prompt"); + let close_release = root.path().join("release-close"); let mut args = vec![fixture_path_arg("--request-log", &requests)]; if options.fail_delete { args.extend(["--delete".into(), "--fail-delete".into()]); @@ -907,6 +910,9 @@ impl MockAcpScenario { if options.gate_fork { args.push(fixture_path_arg("--fork-release", &fork_release)); } + if options.gate_close { + args.push(fixture_path_arg("--close-release", &close_release)); + } if let Some(text) = options.gate_prompt { args.push(fixture_path_arg("--prompt-release", &prompt_release)); args.push(format!("--prompt-release-text={text}")); @@ -922,6 +928,7 @@ impl MockAcpScenario { new_release, fork_release, prompt_release, + close_release, } } @@ -1633,6 +1640,75 @@ async fn successful_fork_handoff_cleans_up_if_receipt_is_not_acknowledged() { wait_for_available_permits(&scenario.manager, MAX_LIVE_SUBAGENTS).await; } +#[tokio::test] +async fn close_streaming_child_retains_sealed_transcript() { + let scenario = MockAcpScenario::new(ScenarioOptions { + gate_prompt: Some("late reply"), + gate_close: true, + ..Default::default() + }); + let source = scenario.create("source").await; + let manager = scenario.manager.clone(); + let prior = source.clone(); + let prompt = tokio::spawn(async move { + manager + .prompt( + prior, + "late reply".into(), + TurnCancellation::default(), + None, + ) + .await + }); + scenario + .wait_for( + |request| matches!(request, LoggedRequest::Prompt { text, .. } if text == "late reply"), + ) + .await; + let manager = scenario.manager.clone(); + let id = source.id.clone(); + let close = tokio::spawn(async move { manager.close(&id, &TurnCancellation::default()).await }); + // The manager has emitted Removed and sealed history before sending close. + scenario + .wait_for(|request| matches!(request, LoggedRequest::Close { .. })) + .await; + let generation = source.generation + 1; + let retained = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let page = scenario + .manager + .read_transcript(&source.id, generation, 0) + .await + .unwrap(); + if page.caught_up { + break page; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(!retained.updates.is_empty()); + + // Keep the child route live while its final streamed update arrives after seal. + MockAcpScenario::release(&scenario.prompt_release); + // Retirement is checked after the child consumes its successful response. + assert!(matches!( + prompt.await.unwrap(), + Err(ChildError::Failed(message)) if message == "subagent session is retired" + )); + MockAcpScenario::release(&scenario.close_release); + close.await.unwrap().unwrap(); + let after = scenario + .manager + .read_transcript(&source.id, generation, 0) + .await + .unwrap(); + assert!(after.caught_up); + assert_eq!(after.updates, retained.updates); + assert_eq!(after.next_cursor, retained.next_cursor); +} + #[tokio::test] async fn prompt_error_after_close_emits_no_ghost_idle_row() { let scenario = MockAcpScenario::new(ScenarioOptions { diff --git a/src/tui/app.rs b/src/tui/app.rs index e3528505..1afa05c5 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -42,9 +42,17 @@ use super::{ wrap::LinkHit, }; +mod focus; +pub use focus::ChildView; + /// Everything the client learns from the agent or its own runtime channel. #[derive(Debug)] pub enum Update { + ChildSteerFinished { + id: String, + generation: u64, + result: Result<(), String>, + }, /// Ordered ACP prompt receipt, before the accepted turn's user message. VoicePromptAccepted { id: String, @@ -423,6 +431,11 @@ pub(super) enum ClipboardMode { } pub enum Action { + SteerChild { + id: String, + generation: u64, + text: String, + }, OpenUserImage(UserImage), Voice(String), None, @@ -476,6 +489,11 @@ pub enum ComposeView { Script, } +pub(super) fn observed_duration(start: Option, end: Option) -> Option { + let duration = end?.checked_duration_since(start?)?; + Some(u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) +} + /// A model-visible tool call and, for compose, the program running inside it. pub struct ToolCall { /// Opaque ACP identity, independent of the tool name and Runlet source. @@ -485,8 +503,10 @@ pub struct ToolCall { pub title: String, pub kind: ToolKind, pub status: ToolCallStatus, - pub started: Instant, + pub started: Option, pub finished: Option, + /// The clock stopped, possibly without an observed terminal outcome. + pub timing_closed: bool, /// Runlet source shown inline while this compose call is running. pub script: String, /// Raw tool output, kept whole but folded away until asked for. @@ -527,17 +547,26 @@ impl ToolCall { ) } - pub fn elapsed(&self) -> u64 { - let end = self.finished.unwrap_or_else(Instant::now); - u64::try_from(end.duration_since(self.started).as_millis()).unwrap_or(u64::MAX) + pub fn elapsed(&self) -> Option { + self.elapsed_at(Instant::now()) + } + + fn elapsed_at(&self, now: Instant) -> Option { + let end = if self.running() && !self.timing_closed { + Some(now) + } else { + self.finished + }; + observed_duration(self.started, end) } - fn finalize_terminal_state(&mut self) { + fn finalize_terminal_state(&mut self, at: Option) { if self.is_compose() && !self.expansion_explicit { self.expanded = false; self.compose_view = ComposeView::Output; } - self.finished = Some(Instant::now()); + self.finished = at; + self.timing_closed = true; } } @@ -632,8 +661,9 @@ pub enum Block { AgentParts(Vec), Thought { text: String, - started: Instant, + started: Option, millis: Option, + closed: bool, }, Tool(Box), /// A turn ended. `background` counts detached programs still running @@ -641,12 +671,12 @@ pub enum Block { /// spanning the autonomous turns that follow a background result. TurnDuration { background: usize, - since_prompt: u64, + since_prompt: Option, }, /// A detached program finished after its turn had already ended. BackgroundResult { title: String, - millis: u64, + millis: Option, failed: bool, }, /// Transcript history was compacted into a note. @@ -689,6 +719,7 @@ enum MessageRole { #[derive(Clone, Debug, PartialEq)] pub struct AgentRow { + pub focus_can_steer: bool, pub id: String, pub name: String, pub status: SubagentStatus, @@ -787,6 +818,12 @@ pub struct AgentCounts { } pub struct App { + pub child_focus: Option, + child_ui: HashMap, + pub child_views: HashMap, + pub agents_selected: Option, + pub agents_keyboard_focus: bool, + pub child_back_area: Rect, runtime_last_frame: Option, runtime_status_unavailable: bool, pub root: PathBuf, @@ -842,6 +879,9 @@ pub struct App { pub turn_started: Option, /// When the user last started something new, as opposed to steering. prompt_started: Option, + prompt_seen: bool, + /// Scoped event clock: outer None is live, inner None is unknown replay timing. + observation_time: Option>, pub can_steer: bool, pub can_replace_steer: bool, pub(super) selected_steer: Option, @@ -1058,6 +1098,12 @@ fn agent_status_rank(status: SubagentStatus) -> u8 { impl App { pub fn new(root: PathBuf, provider: String, model: String, a2a: String) -> Self { Self { + child_focus: None, + child_ui: HashMap::new(), + child_views: HashMap::new(), + agents_selected: None, + agents_keyboard_focus: false, + child_back_area: Rect::default(), runtime_last_frame: None, runtime_status_unavailable: false, root, @@ -1109,6 +1155,8 @@ impl App { phase: Phase::Idle, turn_started: None, prompt_started: None, + prompt_seen: false, + observation_time: None, can_steer: false, can_replace_steer: false, selected_steer: None, @@ -1262,8 +1310,8 @@ impl App { fn block_is_dynamic(block: &Block) -> bool { match block { - Block::Thought { millis, .. } => millis.is_none(), - Block::Tool(call) => call.running(), + Block::Thought { closed, .. } => !closed, + Block::Tool(call) => call.running() && !call.timing_closed, _ => false, } } @@ -1628,16 +1676,17 @@ impl App { self.agents_scroll = self.agents_scroll.saturating_add_signed(rows).min(top); } - pub fn elapsed(&self) -> u64 { - self.turn_started.map_or(0, |started| { - u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX) - }) + pub fn elapsed(&self) -> Option { + observed_duration(self.turn_started, Some(Instant::now())) + } + + fn observed_at(&self) -> Option { + self.observation_time + .unwrap_or_else(|| Some(Instant::now())) } fn stop_turn_timer(&mut self) -> Option { - self.turn_started - .take() - .map(|started| u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)) + observed_duration(self.turn_started.take(), self.observed_at()) } pub fn toast_text(&self) -> Option<&str> { @@ -1931,8 +1980,9 @@ impl App { } MessageRole::Thought => self.push_block(Block::Thought { text, - started: Instant::now(), + started: self.observed_at(), millis: None, + closed: false, }), } self.message_blocks.insert(id, self.blocks.len() - 1); @@ -1971,6 +2021,7 @@ impl App { } self.close_thought(); self.agent_stream_sealed = true; + let at = self.observed_at(); let turn_millis = self.stop_turn_timer(); self.phase = Phase::Idle; self.compacting = false; @@ -1991,7 +2042,7 @@ impl App { } else { ToolCallStatus::Failed }; - call.finalize_terminal_state(); + call.finalize_terminal_state(at); finished.push(index); } } @@ -2002,16 +2053,16 @@ impl App { if let Some(notice) = notice { self.note(notice); } - if let Some(millis) = turn_millis { - let background = self.background_calls().len(); - let since_prompt = self.prompt_started.map_or(millis, |started| { - u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX) - }); - self.push_block(Block::TurnDuration { - background, - since_prompt, - }); - } + let background = self.background_calls().len(); + let since_prompt = if self.prompt_seen { + observed_duration(self.prompt_started, at) + } else { + turn_millis + }; + self.push_block(Block::TurnDuration { + background, + since_prompt, + }); } /// Top-level calls that detached from their turn and are still running. @@ -2077,7 +2128,61 @@ impl App { } pub fn apply(&mut self, update: Update) { + self.apply_at(update, self.observed_at()); + } + + /// Apply a transcript event at its original observation time. Missing timing + /// remains unknown; it must never be substituted with the replay clock. + pub fn apply_at(&mut self, update: Update, at: Option) { + let previous = self.observation_time.replace(at); + self.apply_observed(update); + self.observation_time = previous; + } + + /// Stop clocks after a caught-up, inactive replay that lacks terminal events. + /// This records neither a successful outcome nor a fabricated finish time. + /// A later real terminal update can still supply the missing boundary. + pub fn finish_replay_incomplete(&mut self) { + let mut changed = Vec::new(); + for (index, block) in self.blocks.iter_mut().enumerate() { + match block { + Block::Thought { millis, closed, .. } if !*closed => { + *millis = None; + *closed = true; + changed.push(index); + } + Block::Tool(call) if call.running() && !call.timing_closed => { + call.finished = None; + call.timing_closed = true; + changed.push(index); + } + _ => {} + } + } + for index in changed { + self.mark_block_dirty(index); + self.reclassify_dynamic(index); + } + if self.phase != Phase::Idle { + self.push_block(Block::TurnDuration { + background: self.background_calls().len(), + since_prompt: None, + }); + } + self.turn_started = None; + self.phase = Phase::Idle; + self.compacting = false; + self.agent_stream_sealed = true; + } + + fn apply_observed(&mut self, update: Update) { + let at = self.observed_at(); match update { + Update::ChildSteerFinished { + id, + generation, + result, + } => self.child_steer_finished(&id, generation, result), Update::OpenUserImage(_) => {} Update::VoicePromptAccepted { .. } => {} Update::A2aAddress(address) => self.a2a = address, @@ -2190,7 +2295,8 @@ impl App { let new_message = !self.message_blocks.contains_key(&id); self.remove_pending_steer(&id); if new_message && !steer { - self.prompt_started = Some(Instant::now()); + self.prompt_seen = true; + self.prompt_started = at; } self.apply_message(id, text, images, append, MessageRole::User); } @@ -2254,8 +2360,9 @@ impl App { title, kind, status: ToolCallStatus::Pending, - started: Instant::now(), + started: at, finished: None, + timing_closed: false, script: super::source::bounded_source(script.unwrap_or_default()), output: Vec::new(), images: Vec::new(), @@ -2286,6 +2393,17 @@ impl App { script: script.clone(), backgrounded, }); + // A terminal-only observation proves the end, not the start. + // Keep the event clock for other boundaries (such as closing + // reasoning), but do not manufacture a zero-length tool run. + if matches!( + status, + Some(ToolCallStatus::Completed | ToolCallStatus::Failed) + ) && let Some(index) = self.call_index(&id) + && let Block::Tool(call) = &mut self.blocks[index] + { + call.started = None; + } } if let Some(images) = images && let Some(index) = self.call_index(&id) @@ -2361,8 +2479,8 @@ impl App { call.backgrounded |= backgrounded; if let Some(status) = status { call.status = status; - if !call.running() { - call.finalize_terminal_state(); + if was_running && !call.running() { + call.finalize_terminal_state(at); } } let identity_changed = was_compose != call.is_compose(); @@ -2415,7 +2533,7 @@ impl App { StateUpdate::Running(_) | StateUpdate::RequiresAction(_) => { if self.phase == Phase::Idle { self.agent_stream_sealed = true; - self.turn_started = Some(Instant::now()); + self.turn_started = at; } if self.phase != Phase::Cancelling { self.phase = if matches!(state, StateUpdate::Running(_)) { @@ -2455,6 +2573,12 @@ impl App { return; } self.runtime_status_unavailable = true; + for view in self.child_views.values_mut() { + view.disable("Child connection unavailable; retained transcript is read-only"); + } + for row in self.agents.values_mut() { + row.focus_can_steer = false; + } // All these fields depend on the same lossy side channel. Absence is // unknown, not idle/success/healthy; the UI exposes unavailability. self.agent_versions.clear(); @@ -2543,6 +2667,11 @@ impl App { self.push_block(Block::Compacted { reason, millis }); } } + RuntimeEvent::SubagentCapabilities { + id, + generation, + can_steer, + } => self.child_capabilities(&id, generation, can_steer), RuntimeEvent::SubagentStateChanged { id, name, @@ -2605,9 +2734,13 @@ impl App { activity.clear_transient(); } + let focus_can_steer = previous + .is_some_and(|row| row.generation == generation && row.focus_can_steer) + && status == SubagentStatus::Working; self.agents.insert( id.clone(), AgentRow { + focus_can_steer, id: id.clone(), name, status, @@ -2628,6 +2761,7 @@ impl App { }, ); } + self.child_lifecycle(&id, generation, status); self.clamp_agents_scroll(); } RuntimeEvent::SubagentActivity { id, activity } => { @@ -2674,6 +2808,11 @@ impl App { break; } } + for id in &removed { + if let Some(view) = self.child_views.get_mut(id) { + view.disable("Child removed; retained transcript is read-only"); + } + } self.agents.retain(|id, _| !removed.contains(id)); self.cleaned_agent_ancestors.insert(ancestor_id); self.cleaned_agent_ids.extend(removed); @@ -2885,13 +3024,17 @@ impl App { } fn close_thought(&mut self) { + let at = self.observed_at(); if let Some(Block::Thought { started, - millis: millis @ None, + millis, + closed, .. }) = self.blocks.last_mut() + && !*closed { - *millis = Some(u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)); + *millis = observed_duration(*started, at); + *closed = true; let index = self.blocks.len() - 1; self.mark_block_dirty(index); self.reclassify_dynamic(index); @@ -2937,6 +3080,8 @@ impl App { self.latest_agent_source.clear(); self.phase = Phase::Idle; self.turn_started = None; + self.prompt_started = None; + self.prompt_seen = false; self.message_blocks.clear(); self.pending_steers.clear(); self.compacting = false; @@ -2945,6 +3090,12 @@ impl App { self.scroll = usize::MAX; self.follow = true; self.focused_call_id = None; + self.child_focus = None; + self.child_views.clear(); + self.child_ui.clear(); + self.agents_selected = None; + self.agents_keyboard_focus = false; + self.child_back_area = Rect::default(); self.agents_auto_opened = false; self.cost = None; self.agent_costs.clear(); @@ -3201,7 +3352,8 @@ impl App { } pub(super) fn clipboard_route(&self) -> ClipboardRoute { - if self.model_switch.is_some() + if self.child_focus.is_some() + || self.model_switch.is_some() || self.model_dialog.is_some() || self.effort_dialog.is_some() || (self.session_dialog.is_some() && !self.session_rename_active()) @@ -3227,6 +3379,10 @@ impl App { } pub fn paste(&mut self, text: &str) { + if self.child_focus.is_some() { + self.paste_child(text); + return; + } if self.model_switch.is_some() { return; } @@ -3834,6 +3990,9 @@ impl App { if key.kind != KeyEventKind::Press { return Action::None; } + if let Some(action) = self.handle_focus_key(key) { + return action; + } if let Some(pending) = self.model_switch.as_mut() { use crate::protocols::acp::model_switch::Decision; let cancel = key.code == KeyCode::Esc @@ -4372,6 +4531,9 @@ impl App { } pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Action { + if let Some(action) = self.handle_focus_mouse(mouse) { + return action; + } if self.model_switch.is_some() { return Action::None; } @@ -6077,6 +6239,234 @@ mod tests { ); } + #[test] + fn replay_terminal_only_tool_keeps_start_and_duration_unknown() { + let base = Instant::now(); + for status in [ToolCallStatus::Completed, ToolCallStatus::Failed] { + let mut app = app(); + app.apply_at( + Update::AgentThought { + id: "thought".into(), + text: "reasoning".into(), + append: false, + }, + Some(base), + ); + app.apply_at( + Update::ToolPatched { + id: "tool".into(), + title: Some("shell".into()), + kind: None, + status: Some(status.clone()), + script: None, + output: None, + images: None, + append_output: false, + intent: None, + backgrounded: false, + }, + Some(base + Duration::from_secs(7)), + ); + let call = app.tool_call("tool").unwrap(); + assert_eq!(call.status, status); + assert_eq!(call.started, None); + assert_eq!(call.finished, Some(base + Duration::from_secs(7))); + assert_eq!(call.elapsed_at(base + Duration::from_secs(40)), None); + assert!(app.blocks.iter().any(|block| matches!( + block, + Block::Thought { + millis: Some(7_000), + closed: true, + .. + } + ))); + } + } + + #[test] + fn replay_incomplete_stops_clocks_without_fabricating_outcomes() { + let base = Instant::now(); + let mut app = app(); + app.apply_at( + Update::State(StateUpdate::Running(RunningStateUpdate::new())), + Some(base), + ); + app.apply_at( + Update::ToolStarted { + id: "tool".into(), + title: "shell".into(), + kind: ToolKind::Other, + script: None, + backgrounded: false, + }, + Some(base), + ); + app.apply_at( + Update::AgentThought { + id: "thought".into(), + text: "reasoning".into(), + append: false, + }, + Some(base), + ); + let call = app.tool_call("tool").unwrap(); + assert_eq!(call.elapsed_at(base + Duration::from_secs(4)), Some(4_000)); + assert_eq!(call.elapsed_at(base - Duration::from_secs(1)), None); + assert_eq!( + super::observed_duration(Some(base), Some(base - Duration::from_secs(1))), + None + ); + + app.finish_replay_incomplete(); + let call = app.tool_call("tool").unwrap(); + assert_eq!(call.status, ToolCallStatus::Pending); + assert_eq!(call.started, Some(base)); + assert_eq!(call.finished, None); + assert_eq!(call.elapsed_at(base + Duration::from_secs(40)), None); + assert!(call.timing_closed); + assert!(!app.working()); + assert_eq!(app.elapsed(), None); + assert!(app.blocks.iter().any(|block| matches!( + block, + Block::Thought { + millis: None, + closed: true, + .. + } + ))); + assert!(matches!( + app.blocks.last(), + Some(Block::TurnDuration { + since_prompt: None, + .. + }) + )); + assert!(app.transcript_dynamic.is_empty()); + let count = app.blocks.len(); + app.finish_replay_incomplete(); + assert_eq!(app.blocks.len(), count); + + // Real terminal evidence may arrive later without losing the known start. + app.apply_at( + Update::ToolPatched { + id: "tool".into(), + title: None, + kind: None, + status: Some(ToolCallStatus::Completed), + script: None, + output: None, + images: None, + append_output: false, + intent: None, + backgrounded: false, + }, + Some(base + Duration::from_secs(7)), + ); + assert_eq!(app.tool_call("tool").unwrap().elapsed(), Some(7_000)); + } + + #[test] + fn replay_timing_uses_original_boundaries_and_preserves_unknowns() { + let base = Instant::now() - Duration::from_secs(600); + for (start, end, expected) in [ + (Some(base), Some(base + Duration::from_secs(7)), Some(7_000)), + (None, Some(base + Duration::from_secs(7)), None), + (Some(base), None, None), + (None, None, None), + (Some(base), Some(base - Duration::from_secs(1)), None), + ] { + let mut app = app(); + app.apply_at( + Update::UserMessage { + id: "prompt".into(), + text: "hello".into(), + images: vec![], + append: false, + }, + start, + ); + app.apply_at( + Update::State(StateUpdate::Running(RunningStateUpdate::new())), + start, + ); + app.apply_at( + Update::AgentThought { + id: "thought".into(), + text: "reasoning".into(), + append: false, + }, + start, + ); + app.apply_at( + Update::ToolStarted { + id: "tool".into(), + title: "shell".into(), + kind: ToolKind::Other, + script: None, + backgrounded: false, + }, + start, + ); + app.apply_at( + Update::State(StateUpdate::Idle(IdleStateUpdate::new())), + end, + ); + assert_eq!(app.tool_call("tool").unwrap().elapsed(), expected); + assert!( + matches!(app.blocks.last(), Some(Block::TurnDuration { since_prompt, .. }) if *since_prompt == expected) + ); + // Reasoning closes when the tool starts, at the original observation time. + assert!(app.blocks.iter().any(|block| matches!(block, + Block::Thought { millis, closed: true, .. } if *millis == start.map(|_| 0)))); + } + } + + #[test] + fn replay_reasoning_and_prompt_have_independent_original_boundaries() { + let base = Instant::now() - Duration::from_secs(600); + let mut app = app(); + app.apply_at( + Update::UserMessage { + id: "prompt".into(), + text: "hello".into(), + images: vec![], + append: false, + }, + Some(base), + ); + app.apply_at( + Update::State(StateUpdate::Running(RunningStateUpdate::new())), + Some(base + Duration::from_secs(2)), + ); + app.apply_at( + Update::AgentThought { + id: "thought".into(), + text: "reasoning".into(), + append: false, + }, + Some(base + Duration::from_secs(3)), + ); + app.apply_at( + Update::State(StateUpdate::Idle(IdleStateUpdate::new())), + Some(base + Duration::from_secs(9)), + ); + assert!(app.blocks.iter().any(|block| matches!( + block, + Block::Thought { + millis: Some(6_000), + closed: true, + .. + } + ))); + assert!(matches!( + app.blocks.last(), + Some(Block::TurnDuration { + since_prompt: Some(9_000), + .. + }) + )); + } + #[test] fn completed_turn_duration_is_recorded_at_the_end() { let mut app = app(); @@ -6090,7 +6480,7 @@ mod tests { assert!(matches!( app.blocks.last(), - Some(Block::TurnDuration { since_prompt, .. }) if *since_prompt >= 65_000 + Some(Block::TurnDuration { since_prompt, .. }) if since_prompt.is_some_and(|ms| ms >= 65_000) )); } @@ -6183,7 +6573,7 @@ mod tests { ))); assert!(matches!( app.blocks.last(), - Some(Block::TurnDuration { background: 1, since_prompt, .. }) if *since_prompt < 5_000 + Some(Block::TurnDuration { background: 1, since_prompt, .. }) if since_prompt.is_some_and(|ms| ms < 5_000) )); app.apply(Update::ToolPatched { diff --git a/src/tui/app/focus.rs b/src/tui/app/focus.rs new file mode 100644 index 00000000..e41c2bf4 --- /dev/null +++ b/src/tui/app/focus.rs @@ -0,0 +1,1350 @@ +//! Only the focused child owns a transcript. Other children retain small UI state. +use super::*; +use crate::protocols::acp::ReadSubagentTranscriptResponse; + +const MAX_CHILD_DRAFT: usize = 16 * 1024; +const MAX_PARTIAL_REASONS: usize = 4; +const MAX_PARTIAL_REASON_BYTES: usize = 512; + +pub(super) struct ChildUiState { + editor: Editor, + generation: u64, + pending_text: Option, + steer_notice: Option, + scroll: usize, + follow: bool, +} + +/// One wall/monotonic anchor per view keeps replay pages and live updates on +/// the same clock. These are observation times, not exact remote start times. +struct ReplayClock { + unix_ms: u64, + instant: Instant, +} + +impl ReplayClock { + fn now() -> Self { + let unix_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + Self { + unix_ms: u64::try_from(unix_ms).unwrap_or(u64::MAX), + instant: Instant::now(), + } + } + + fn observed_at(&self, value: &serde_json::Value) -> Option { + let unix_ms = value["kitObservedAtUnixMs"].as_u64()?; + if unix_ms <= self.unix_ms { + self.instant + .checked_sub(Duration::from_millis(self.unix_ms - unix_ms)) + } else { + self.instant + .checked_add(Duration::from_millis(unix_ms - self.unix_ms)) + } + } +} + +pub struct ChildView { + clock: ReplayClock, + pub app: Box, + pub notice: String, + pub generation: u64, + pub can_steer: bool, + pub cursor: u64, + /// Delay reads when caught up or waiting for the spool writer to commit. + pub read_backoff: bool, + pub read_enabled: bool, + active: bool, + loading: bool, + restore_scroll: Option<(usize, bool)>, + partial: bool, + partial_reasons: Vec, + partial_reasons_omitted: bool, + pending_text: Option, + steer_notice: Option, +} + +impl ChildView { + pub(super) fn disable(&mut self, notice: &str) { + self.active = false; + self.can_steer = false; + self.read_enabled = false; + self.pending_text = None; + self.steer_notice = None; + self.app.finish_replay_incomplete(); + self.notice = notice.into(); + } + + fn refresh_notice(&mut self) { + if !self.read_enabled { + return; + } + self.notice = if self.pending_text.is_some() { + "Sending steer to child…" + } else if self.loading { + "Loading child transcript…" + } else if !self.active { + "Child idle or closed; transcript is read-only" + } else if !self.can_steer { + "Read-only: compatible steering is not available for this child" + } else { + "Text steering available · Enter to send · Esc back to main" + } + .into(); + if self.partial { + let mut reason = if self.partial_reasons.is_empty() { + "child sent an unsupported update".to_owned() + } else { + self.partial_reasons.join("; ") + }; + if self.partial_reasons_omitted { + reason.push_str("; additional limitations omitted"); + } + self.notice = format!("Partial transcript: {reason} · {}", self.notice); + } + if let Some(notice) = &self.steer_notice { + self.notice = format!("{notice} · {}", self.notice); + } + } +} + +impl App { + pub fn leave_child(&mut self) { + for (id, mut view) in self.child_views.drain() { + self.child_ui.insert( + id, + ChildUiState { + editor: std::mem::take(&mut view.app.editor), + generation: view.generation, + pending_text: view.pending_text, + steer_notice: view.steer_notice, + scroll: view.restore_scroll.map_or(view.app.scroll, |state| state.0), + follow: view.restore_scroll.map_or(view.app.follow, |state| state.1), + }, + ); + } + self.child_focus = None; + self.agents_keyboard_focus = false; + self.agents_selected = None; + self.clipboard_route_epoch = self.clipboard_route_epoch.wrapping_add(1); + self.child_back_area = Rect::default(); + } + + pub fn focus_child(&mut self, id: String) { + let Some(row) = self.agents.get(&id) else { + return; + }; + let generation = row.generation; + let active = row.status == SubagentStatus::Working; + let direct = row.parent_id.is_none(); + let can_steer = active && direct && row.focus_can_steer; + self.leave_child(); + let mut app = App::new( + self.root.clone(), + String::new(), + String::new(), + String::new(), + ); + app.show_thoughts = self.show_thoughts; + app.phase = if active { Phase::Working } else { Phase::Idle }; + let mut pending_text = None; + let mut steer_notice = None; + let mut restore_scroll = None; + if let Some(ui) = self.child_ui.remove(&id) { + if ui.generation == generation { + pending_text = ui.pending_text; + steer_notice = ui.steer_notice; + } + app.editor = ui.editor; + restore_scroll = Some((ui.scroll, ui.follow)); + app.scroll = ui.scroll; + app.follow = ui.follow; + } + let mut view = ChildView { + clock: ReplayClock::now(), + app: Box::new(app), + generation, + can_steer, + active, + notice: if direct { + String::new() + } else { + "Transcript unavailable: descendant inspection is not supported".into() + }, + cursor: 0, + read_backoff: false, + read_enabled: direct, + loading: true, + partial: false, + partial_reasons: Vec::new(), + partial_reasons_omitted: false, + pending_text, + steer_notice, + restore_scroll, + }; + view.refresh_notice(); + self.child_views.insert(id.clone(), view); + self.agents_selected = Some(id.clone()); + self.child_focus = Some(id); + self.agents_keyboard_focus = false; + self.selection = None; + self.press = None; + } + + pub(super) fn child_capabilities(&mut self, id: &str, generation: u64, can_steer: bool) { + let Some(row) = self.agents.get_mut(id) else { + return; + }; + if row.generation != generation { + return; + } + row.focus_can_steer = + can_steer && row.parent_id.is_none() && row.status == SubagentStatus::Working; + if let Some(view) = self.child_views.get_mut(id) + && view.generation == generation + { + view.can_steer = row.focus_can_steer; + view.refresh_notice(); + } + } + + // Epoch also rejects responses after switching away and back to the same child. + pub fn child_read_target(&self) -> Option<(String, u64, u64, u64)> { + let id = self.child_focus.as_ref()?; + let view = self.child_views.get(id)?; + view.read_enabled.then(|| { + ( + id.clone(), + view.generation, + self.clipboard_route_epoch, + view.cursor, + ) + }) + } + + pub fn child_read_finished( + &mut self, + target: &(String, u64, u64, u64), + result: Result, + ) { + if self.child_read_target().as_ref() != Some(target) { + return; + } + let (id, generation, _, cursor) = target; + let Some(view) = self.child_views.get_mut(id) else { + return; + }; + let response = match result { + Ok(response) => response, + Err(error) => { + view.read_enabled = false; + view.notice = format!("Transcript unavailable: {error}. Reopen child to retry."); + return; + } + }; + if response.generation != *generation { + view.read_enabled = false; + view.notice = "Transcript generation changed; reopen child to resync".into(); + return; + } + if response.next_cursor < *cursor + || (!response.updates.is_empty() && response.next_cursor == *cursor) + { + view.read_enabled = false; + view.notice = "Invalid transcript cursor; reopen child to resync".into(); + return; + } + // Empty pending-writer pages are valid even before the first record. + // Do not spin on them while the disk writer is starting or catching up. + view.read_backoff = response.caught_up || response.next_cursor == *cursor; + for value in response.updates { + if matches!( + value["sessionUpdate"].as_str(), + Some("kit_transcript_partial" | "kit_transcript_truncated") + ) { + view.partial = true; + let reason = value["reason"] + .as_str() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .unwrap_or("backend reported incomplete transcript history"); + let end = reason.floor_char_boundary(reason.len().min(MAX_PARTIAL_REASON_BYTES)); + let mut reason_text: String = reason[..end] + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect(); + if end < reason.len() { + reason_text.push('…'); + } + if !view.partial_reasons.contains(&reason_text) { + if view.partial_reasons.len() < MAX_PARTIAL_REASONS { + view.partial_reasons.push(reason_text); + } else { + view.partial_reasons_omitted = true; + } + } + continue; + } + let observed_at = view.clock.observed_at(&value); + let Ok(update) = serde_json::from_value(value) else { + view.partial = true; + continue; + }; + // Session metadata is intentionally not transcript content. Other + // accepted wire variants that translate to nothing must not vanish + // silently (notably v2 terminal output and terminal lifecycle). + use agent_client_protocol::schema::v2::SessionUpdate; + let metadata = matches!( + &update, + SessionUpdate::SessionInfoUpdate(_) + | SessionUpdate::AvailableCommandsUpdate(_) + | SessionUpdate::ConfigOptionUpdate(_) + | SessionUpdate::UsageUpdate(_) + | SessionUpdate::StateUpdate(_) + ); + let (_, updates) = super::super::translate( + agent_client_protocol::schema::v2::UpdateSessionNotification::new( + id.clone(), + update, + ), + ); + if updates.is_empty() && !metadata { + view.partial = true; + } + for update in updates { + view.app.apply_at(update, observed_at); + } + } + view.cursor = response.next_cursor; + if response.caught_up { + view.loading = false; + if !view.active { + view.app.finish_replay_incomplete(); + } + if let Some((scroll, follow)) = view.restore_scroll.take() { + view.app.scroll = scroll; + view.app.follow = follow; + } + } + view.refresh_notice(); + } + + pub(super) fn child_lifecycle(&mut self, id: &str, generation: u64, status: SubagentStatus) { + let Some(view) = self.child_views.get(id) else { + if let Some(ui) = self.child_ui.get_mut(id) + && (generation > ui.generation + || (generation == ui.generation && status != SubagentStatus::Working)) + { + ui.steer_notice = None; + } + return; + }; + if generation < view.generation { + return; + } + if generation != view.generation { + if self.agents.contains_key(id) { + self.focus_child(id.to_owned()); + } else { + if let Some(view) = self.child_views.get_mut(id) { + view.disable("Child removed; transcript is read-only"); + } + return; + } + } + let Some(view) = self.child_views.get_mut(id) else { + return; + }; + if view.active != (status == SubagentStatus::Working) { + // Reject an in-flight read sampled before this lifecycle boundary. + // A fresh read must drain the terminal tail before closing clocks. + self.clipboard_route_epoch = self.clipboard_route_epoch.wrapping_add(1); + view.steer_notice = None; + } + view.active = status == SubagentStatus::Working; + if view.active { + view.app.phase = Phase::Working; + } else { + view.can_steer = false; + // Lifecycle can precede the final spool page. Keep timing boundaries + // open until a subsequent caught-up read consumes that tail. + view.read_backoff = false; + } + view.refresh_notice(); + } + + pub(super) fn child_steer_finished( + &mut self, + id: &str, + generation: u64, + result: Result<(), String>, + ) { + let notice = match &result { + Ok(()) => "Steer accepted; waiting for child delivery".to_owned(), + Err(error) => format!("Steer result: {error}"), + }; + let Some(view) = self.child_views.get_mut(id) else { + if let Some(ui) = self.child_ui.get_mut(id) + && ui.generation == generation + && let Some(text) = ui.pending_text.take() + { + if result.is_ok() && ui.editor.text() == text { + ui.editor.clear(); + } + ui.steer_notice = Some(notice); + } + return; + }; + if view.generation != generation { + return; + } + let Some(text) = view.pending_text.take() else { + return; + }; + if result.is_ok() && view.app.editor.text() == text { + view.app.editor.clear(); + } + view.notice.clone_from(¬ice); + view.steer_notice = Some(notice); + } + + pub(super) fn paste_child(&mut self, text: &str) { + let Some(view) = self + .child_focus + .as_ref() + .and_then(|id| self.child_views.get_mut(id)) + else { + return; + }; + if view.active && view.can_steer && view.pending_text.is_none() { + let remaining = MAX_CHILD_DRAFT.saturating_sub(view.app.editor.text().len()); + let mut end = text.len().min(remaining); + while !text.is_char_boundary(end) { + end -= 1; + } + view.app.editor.insert_str(&text[..end]); + } + } + + fn select_agent(&mut self, down: bool) { + let ids: Vec<_> = self + .agent_tree_rows() + .iter() + .map(|entry| entry.row.id.clone()) + .collect(); + if ids.is_empty() { + self.agents_selected = None; + return; + } + let index = self + .agents_selected + .as_ref() + .and_then(|id| ids.iter().position(|candidate| candidate == id)); + let next = match index { + Some(index) if down => (index + 1).min(ids.len() - 1), + Some(index) => index.saturating_sub(1), + None => 0, + }; + self.agents_selected = Some(ids[next].clone()); + if next < self.agents_scroll { + self.agents_scroll = next; + } + if next >= self.agents_scroll + self.agents_viewport.max(1) { + self.agents_scroll = next + 1 - self.agents_viewport.max(1); + } + } + + pub(super) fn handle_focus_key(&mut self, key: KeyEvent) -> Option { + if key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.agents_keyboard_focus = !self.agents_keyboard_focus; + self.agents_visible = true; + if self.agents_selected.is_none() { + self.select_agent(true); + } + return Some(Action::Redraw); + } + if self.agents_keyboard_focus { + match key.code { + KeyCode::Esc => { + self.agents_keyboard_focus = false; + if self.child_focus.is_some() { + self.leave_child(); + } + } + KeyCode::Up => self.select_agent(false), + KeyCode::Down | KeyCode::Tab => self.select_agent(true), + KeyCode::Enter => { + if let Some(id) = self.agents_selected.clone() { + self.focus_child(id); + } + } + _ => {} + } + return Some(Action::Redraw); + } + let id = self.child_focus.clone()?; + if key.code == KeyCode::Esc { + self.leave_child(); + return Some(Action::Redraw); + } + let Some(view) = self.child_views.get_mut(&id) else { + return Some(Action::None); + }; + match key.code { + KeyCode::PageUp => view.app.scroll_by(-(view.app.viewport.max(2) as isize - 1)), + KeyCode::PageDown => view.app.scroll_by(view.app.viewport.max(2) as isize - 1), + KeyCode::Home => view.app.scroll_to_top(), + KeyCode::End => view.app.scroll_to_bottom(), + KeyCode::Up => view.app.scroll_by(-1), + KeyCode::Down => view.app.scroll_by(1), + _ if !view.active || !view.can_steer => { + view.refresh_notice(); + } + _ if view.pending_text.is_some() => {} + KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => { + if view.app.editor.text().len() < MAX_CHILD_DRAFT { + view.app.editor.insert_char('\n'); + } + } + KeyCode::Enter => { + let text = view.app.editor.text().to_owned(); + if !text.trim().is_empty() { + view.steer_notice = None; + view.pending_text = Some(text.clone()); + view.notice = "Sending steer to child…".into(); + return Some(Action::SteerChild { + id, + generation: view.generation, + text, + }); + } + } + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + if view.app.editor.text().len() + character.len_utf8() <= MAX_CHILD_DRAFT { + view.app.editor.insert_char(character); + } + } + KeyCode::Backspace => view.app.editor.backspace(), + KeyCode::Delete => view.app.editor.delete_forward(), + KeyCode::Left => view.app.editor.move_left(), + KeyCode::Right => view.app.editor.move_right(), + _ => {} + } + Some(Action::Redraw) + } + + pub(super) fn handle_focus_mouse(&mut self, mouse: MouseEvent) -> Option { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if self.child_focus.is_some() + && self + .child_back_area + .contains((mouse.column, mouse.row).into()) + { + self.leave_child(); + self.agents_keyboard_focus = false; + return Some(Action::Redraw); + } + if self.agents_area.contains((mouse.column, mouse.row).into()) { + let offset = mouse.row.saturating_sub(self.agents_area.y + 1); + let row = usize::from(offset / 3); + if mouse.row <= self.agents_area.y || row >= self.agents_viewport { + return Some(Action::None); + } + let index = self.agents_scroll + row; + let id = self + .agent_tree_rows() + .get(index) + .map(|entry| entry.row.id.clone()); + if let Some(id) = id { + self.focus_child(id); + } + return Some(Action::Redraw); + } + } + self.child_focus.as_ref()?; + if self.agents_area.contains((mouse.column, mouse.row).into()) { + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_agents_by(-3), + MouseEventKind::ScrollDown => self.scroll_agents_by(3), + _ => {} + } + return Some(Action::Redraw); + } + let Some(view) = self.child_views.get_mut(self.child_focus.as_ref()?) else { + return Some(Action::None); + }; + // Reuse selection, links and local card expansion, but do not permit a + // tool action (cancel/detach, session navigation, etc.) to escape to root. + let action = view.app.handle_mouse(mouse); + Some(match action { + Action::None | Action::Redraw | Action::Copy(_) => action, + _ => Action::None, + }) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::disallowed_methods, + clippy::disallowed_macros +)] +mod tests { + use super::*; + fn app() -> App { + App::new( + PathBuf::from("."), + "test".into(), + "test".into(), + String::new(), + ) + } + + fn lifecycle(app: &mut App, id: &str, generation: u64, status: SubagentStatus) { + app.apply(Update::Runtime(RuntimeEvent::SubagentStateChanged { + id: id.into(), + name: id.into(), + status, + outcome: None, + generation, + task: "task".into(), + parent_id: None, + parent_name: None, + harness: "acp.kit".into(), + vendor: HarnessVendor::Kit, + model: None, + created_at_unix_ms: 1, + generation_started_at_unix_ms: 1, + generation_finished_at_unix_ms: None, + })); + } + + fn page( + generation: u64, + next_cursor: u64, + caught_up: bool, + text: &str, + ) -> ReadSubagentTranscriptResponse { + ReadSubagentTranscriptResponse { + generation, + next_cursor, + caught_up, + updates: vec![ + serde_json::json!({"sessionUpdate": "agent_message_chunk", "messageId": "message", "content": {"type": "text", "text": text}}), + ], + } + } + + fn read(app: &mut App, text: &str, caught_up: bool) { + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Ok(page(target.1, target.3 + 1, caught_up, text))); + } + + fn key(app: &mut App, code: KeyCode) -> Action { + app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + #[test] + fn replay_clock_preserves_offsets_and_unknown_observations() { + let instant = Instant::now(); + let clock = ReplayClock { + unix_ms: 10_000, + instant, + }; + let value = |ms| serde_json::json!({"kitObservedAtUnixMs": ms}); + assert_eq!( + clock.observed_at(&value(8_000)), + instant.checked_sub(Duration::from_secs(2)) + ); + assert_eq!( + clock.observed_at(&value(12_000)), + instant.checked_add(Duration::from_secs(2)) + ); + assert_eq!(clock.observed_at(&serde_json::json!({})), None); + assert_eq!( + clock.observed_at(&serde_json::json!({"kitObservedAtUnixMs": null})), + None + ); + } + + #[test] + fn completed_tool_duration_is_stable_across_late_load_and_reopen() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + let instant = Instant::now(); + for opened_at in [10_000, 30_000] { + app.focus_child("child".into()); + app.child_views.get_mut("child").unwrap().clock = ReplayClock { + unix_ms: opened_at, + instant, + }; + for (cursor, update) in [ + serde_json::json!({"sessionUpdate": "tool_call_update", "toolCallId": "tool", "title": "shell", "status": "in_progress", "kitObservedAtUnixMs": 2_000}), + serde_json::json!({"sessionUpdate": "tool_call_update", "toolCallId": "tool", "status": "completed", "kitObservedAtUnixMs": 5_000}), + ].into_iter().enumerate() { + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Ok(ReadSubagentTranscriptResponse { + generation: 1, next_cursor: cursor as u64 + 1, caught_up: true, updates: vec![update], + })); + if cursor == 0 { + let call = app.child_views["child"].app.tool_call("tool").unwrap(); + assert!(call.running()); + assert_eq!(call.started, instant.checked_sub(Duration::from_millis(opened_at - 2_000))); + } + } + assert_eq!( + app.child_views["child"] + .app + .tool_call("tool") + .unwrap() + .elapsed(), + Some(3_000) + ); + app.leave_child(); + } + } + + #[test] + fn terminal_lifecycle_waits_for_tail_before_closing_reasoning() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.focus_child("child".into()); + app.child_views.get_mut("child").unwrap().clock = ReplayClock { + unix_ms: 10_000, + instant: Instant::now(), + }; + let thought = serde_json::json!({"sessionUpdate": "agent_thought_chunk", "messageId": "thought", "content": {"type": "text", "text": "thinking"}, "kitObservedAtUnixMs": 2_000}); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 1, + caught_up: true, + updates: vec![thought], + }), + ); + let stale_target = app.child_read_target().unwrap(); + lifecycle(&mut app, "child", 1, SubagentStatus::Idle); + assert_ne!(app.child_read_target().unwrap(), stale_target); + app.child_read_finished( + &stale_target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 1, + caught_up: true, + updates: vec![], + }), + ); + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Ok(ReadSubagentTranscriptResponse { generation: 1, next_cursor: 2, caught_up: true, updates: vec![serde_json::json!({"sessionUpdate": "agent_message_chunk", "messageId": "answer", "content": {"type": "text", "text": "done"}, "kitObservedAtUnixMs": 5_000})] })); + assert!( + app.child_views["child"] + .app + .blocks + .iter() + .any(|block| matches!( + block, + Block::Thought { + closed: true, + millis: Some(3_000), + .. + } + )) + ); + } + + #[test] + fn inactive_child_without_terminal_records_does_not_keep_aging() { + for initially_active in [false, true] { + let mut app = app(); + lifecycle( + &mut app, + "child", + 1, + if initially_active { + SubagentStatus::Working + } else { + SubagentStatus::Idle + }, + ); + app.focus_child("child".into()); + app.child_views.get_mut("child").unwrap().clock = ReplayClock { + unix_ms: 10_000, + instant: Instant::now(), + }; + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Ok(ReadSubagentTranscriptResponse { + generation: 1, next_cursor: 2, caught_up: true, updates: vec![ + serde_json::json!({"sessionUpdate": "tool_call_update", "toolCallId": "tool", "title": "shell", "status": "in_progress", "kitObservedAtUnixMs": 2_000}), + serde_json::json!({"sessionUpdate": "agent_thought_chunk", "messageId": "thought", "content": {"type": "text", "text": "thinking"}, "kitObservedAtUnixMs": 3_000}), + ], + })); + if initially_active { + lifecycle(&mut app, "child", 1, SubagentStatus::Idle); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 2, + caught_up: true, + updates: vec![], + }), + ); + } + let child = &app.child_views["child"].app; + let call = child.tool_call("tool").unwrap(); + assert!(call.running(), "missing outcome must not become success"); + assert_eq!(call.elapsed(), None); + assert!(child.blocks.iter().any(|block| matches!( + block, + Block::Thought { + closed: true, + millis: None, + .. + } + ))); + } + } + + #[test] + fn focus_navigation_preserves_root_roster_draft_and_scroll() { + let mut app = app(); + lifecycle(&mut app, "alpha", 1, SubagentStatus::Working); + lifecycle(&mut app, "beta", 1, SubagentStatus::Working); + assert!(app.child_views.is_empty()); + assert!(app.child_read_target().is_none()); + app.editor.insert_str("root draft"); + app.follow = false; + app.scroll = 7; + app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)); + key(&mut app, KeyCode::Down); + assert_eq!(app.agents_selected.as_deref(), Some("beta")); + key(&mut app, KeyCode::Up); + key(&mut app, KeyCode::Enter); + assert_eq!(app.child_focus.as_deref(), Some("alpha")); + assert!(app.child_views["alpha"].notice.contains("Loading")); + read(&mut app, "snapshot", false); + assert!(app.child_views["alpha"].notice.contains("Loading")); + read(&mut app, " live", true); + assert!(!app.child_views["alpha"].notice.contains("Loading")); + assert_eq!(app.child_read_target().unwrap().3, 2); + read(&mut app, " continued live", true); + assert_eq!(app.child_read_target().unwrap().3, 3); + assert_eq!(app.child_views["alpha"].app.blocks.len(), 1); + app.apply(Update::AgentMessage { + id: "root".into(), + text: "root runs".into(), + append: true, + }); + assert_eq!(app.blocks.len(), 1); + assert_eq!(app.agent_tree_rows().len(), 2); + key(&mut app, KeyCode::Esc); + assert!(app.child_views.is_empty()); + assert!(app.child_read_target().is_none()); + assert_eq!(app.editor.text(), "root draft"); + assert_eq!(app.scroll, 7); + assert!(!app.follow); + } + + #[test] + fn only_focus_retains_transcript_and_reopening_replays_without_size_cutoff() { + let mut app = app(); + for i in 0..32 { + let id = format!("child-{i}"); + lifecycle(&mut app, &id, 1, SubagentStatus::Working); + app.child_capabilities(&id, 1, true); + } + assert!(app.child_views.is_empty()); + app.focus_child("child-0".into()); + app.paste("small draft"); + read(&mut app, &"x".repeat(2 * 1024 * 1024 + 1), true); + assert!(!app.child_views["child-0"].app.blocks.is_empty()); + app.child_views.get_mut("child-0").unwrap().app.scroll = 3; + app.child_views.get_mut("child-0").unwrap().app.follow = false; + for i in 1..32 { + app.focus_child(format!("child-{i}")); + read(&mut app, "snapshot", true); + assert_eq!(app.child_views.len(), 1); + assert!(!app.child_views.contains_key("child-0")); + } + app.focus_child("child-0".into()); + assert!(app.child_views["child-0"].app.blocks.is_empty()); + assert_eq!(app.child_read_target().unwrap().3, 0); + assert_eq!(app.child_views["child-0"].app.editor.text(), "small draft"); + read(&mut app, "replayed", true); + assert_eq!(app.child_views["child-0"].app.scroll, 3); + assert!(!app.child_views["child-0"].app.follow); + } + + #[test] + fn stale_pages_are_rejected_across_focus_generation_and_session_changes() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.focus_child("child".into()); + let first = app.child_read_target().unwrap(); + app.leave_child(); + app.focus_child("child".into()); + app.child_read_finished(&first, Ok(page(1, 1, true, "stale"))); + assert!(app.child_views["child"].app.blocks.is_empty()); + let second = app.child_read_target().unwrap(); + lifecycle(&mut app, "child", 2, SubagentStatus::Working); + assert_eq!(app.child_read_target().unwrap().1, 2); + app.child_read_finished(&second, Ok(page(1, 1, true, "stale"))); + assert!(app.child_views["child"].app.blocks.is_empty()); + let third = app.child_read_target().unwrap(); + app.start_session("new-root".into()); + app.child_read_finished(&third, Ok(page(2, 1, true, "stale"))); + assert!(app.child_views.is_empty()); + assert!(app.child_ui.is_empty()); + } + + #[test] + fn errors_and_invalid_cursors_are_explicit_and_reopening_resyncs() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.focus_child("child".into()); + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Err("disk read failed".into())); + assert!(app.child_views["child"].notice.contains("disk read failed")); + assert!(app.child_read_target().is_none()); + app.focus_child("child".into()); + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Ok(page(1, 0, false, "invalid"))); + assert!(app.child_views["child"].notice.contains("cursor")); + assert!(app.child_views["child"].app.blocks.is_empty()); + app.focus_child("child".into()); + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Ok(page(2, 1, true, "wrong generation"))); + assert!(app.child_views["child"].notice.contains("generation")); + assert!(app.child_views["child"].app.blocks.is_empty()); + } + + #[test] + fn steering_stays_generation_safe_even_when_focus_moves() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.child_capabilities("child", 1, true); + app.focus_child("child".into()); + app.paste("steer"); + assert!(matches!( + key(&mut app, KeyCode::Enter), + Action::SteerChild { generation: 1, .. } + )); + app.leave_child(); + app.focus_child("child".into()); + assert!(matches!(key(&mut app, KeyCode::Enter), Action::Redraw)); + app.child_steer_finished("child", 0, Ok(())); + assert_eq!(app.child_views["child"].app.editor.text(), "steer"); + app.child_steer_finished("child", 1, Ok(())); + assert!(app.child_views["child"].app.editor.is_empty()); + app.paste("next"); + key(&mut app, KeyCode::Enter); + lifecycle(&mut app, "child", 2, SubagentStatus::Working); + app.child_steer_finished("child", 1, Ok(())); + assert_eq!(app.child_views["child"].app.editor.text(), "next"); + assert!(!app.child_views["child"].can_steer); + assert!(app.blocks.is_empty()); + } + + #[test] + fn descendants_have_clear_unsupported_notice_and_no_reads() { + let mut app = app(); + lifecycle(&mut app, "nested", 1, SubagentStatus::Working); + app.agents.get_mut("nested").unwrap().parent_id = Some("parent".into()); + app.child_capabilities("nested", 1, true); + app.focus_child("nested".into()); + assert!(app.child_read_target().is_none()); + assert!(!app.child_views["nested"].can_steer); + assert!(app.child_views["nested"].notice.contains("descendant")); + } + + #[test] + fn roster_escape_and_back_button_release_the_focused_transcript() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.focus_child("child".into()); + read(&mut app, "snapshot", true); + app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)); + key(&mut app, KeyCode::Esc); + assert!(app.child_focus.is_none()); + assert!(app.child_views.is_empty()); + assert!(!app.agents_keyboard_focus); + assert!(app.agents_selected.is_none()); + app.focus_child("child".into()); + read(&mut app, "snapshot", true); + // Back must release roster focus even while keyboard navigation owns it. + app.agents_keyboard_focus = true; + app.child_back_area = Rect::new(0, 0, 20, 1); + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 0, + modifiers: KeyModifiers::NONE, + }); + assert!(app.child_focus.is_none()); + assert!(app.child_views.is_empty()); + assert!(!app.agents_keyboard_focus); + assert!(app.agents_selected.is_none()); + + app.focus_child("child".into()); + key(&mut app, KeyCode::Esc); + assert!(app.child_focus.is_none()); + assert!(!app.agents_keyboard_focus); + assert!(app.agents_selected.is_none()); + } + + #[test] + fn pending_writer_pages_keep_loading_and_back_off_until_records_arrive() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.focus_child("child".into()); + assert!(!app.child_views["child"].read_backoff); + // The spool path may not exist yet; subsequent reads can also wait for + // pending records without advancing the cursor. + for _ in 0..2 { + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 0, + caught_up: false, + updates: vec![], + }), + ); + assert_eq!(app.child_read_target().unwrap(), target); + assert!(app.child_views["child"].read_backoff); + assert!(app.child_views["child"].notice.contains("Loading")); + assert!(app.child_views["child"].app.blocks.is_empty()); + } + read(&mut app, "snapshot", false); + assert!(!app.child_views["child"].read_backoff); + assert!(app.child_views["child"].notice.contains("Loading")); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: target.3, + caught_up: false, + updates: vec![], + }), + ); + assert!(app.child_views["child"].read_backoff); + read(&mut app, " caught up", true); + assert!(app.child_views["child"].read_backoff); + assert!(!app.child_views["child"].notice.contains("Loading")); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: target.3, + caught_up: true, + updates: vec![], + }), + ); + assert!(app.child_views["child"].read_backoff); + read(&mut app, " live", false); + assert!(!app.child_views["child"].read_backoff); + assert_eq!(app.child_views["child"].app.blocks.len(), 1); + } + + #[test] + fn unchanged_nonempty_and_regressing_pages_are_rejected() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + for caught_up in [false, true] { + app.focus_child("child".into()); + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Ok(page(1, 0, caught_up, "invalid"))); + assert!(app.child_read_target().is_none()); + assert!(app.child_views["child"].notice.contains("cursor")); + assert!(app.child_views["child"].app.blocks.is_empty()); + } + app.focus_child("child".into()); + read(&mut app, "snapshot", false); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 0, + caught_up: false, + updates: vec![], + }), + ); + assert!(app.child_read_target().is_none()); + assert!(app.child_views["child"].notice.contains("cursor")); + } + + #[test] + fn unsupported_terminal_content_marks_replay_partial_but_metadata_does_not() { + use agent_client_protocol::schema::v2::SessionUpdate; + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.focus_child("child".into()); + let metadata = + serde_json::json!({"sessionUpdate": "session_info_update", "title": "Child title"}); + assert!(matches!( + serde_json::from_value::(metadata.clone()).unwrap(), + SessionUpdate::SessionInfoUpdate(_) + )); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 1, + caught_up: true, + updates: vec![metadata], + }), + ); + assert!(!app.child_views["child"].notice.contains("Partial")); + let output = serde_json::json!({"sessionUpdate": "terminal_output_chunk", "terminalId": "shell", "data": "aGkK"}); + assert!(matches!( + serde_json::from_value::(output.clone()).unwrap(), + SessionUpdate::TerminalOutputChunk(_) + )); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 2, + caught_up: true, + updates: vec![output], + }), + ); + assert_eq!(app.child_read_target().unwrap().3, 2); + assert!( + app.child_views["child"] + .notice + .contains("Partial transcript") + ); + read(&mut app, "later supported text", true); + assert!( + app.child_views["child"] + .notice + .contains("Partial transcript") + ); + } + + #[test] + fn steer_results_survive_empty_pages_and_focus_switches_until_explicit_send() { + for result in [ + Ok(()), + Err("child rejected the steer".to_owned()), + Err("child steering timed out; delivery is unknown".to_owned()), + ] { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.child_capabilities("child", 1, true); + app.focus_child("child".into()); + read(&mut app, "snapshot", true); + app.paste("steer draft"); + key(&mut app, KeyCode::Enter); + app.child_steer_finished("child", 1, result.clone()); + let expected = match &result { + Ok(()) => "Steer accepted", + Err(error) => error.as_str(), + }; + for _ in 0..2 { + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: target.3, + caught_up: true, + updates: vec![], + }), + ); + assert!(app.child_views["child"].notice.contains(expected)); + } + app.leave_child(); + app.focus_child("child".into()); + assert!(app.child_views["child"].notice.contains(expected)); + read(&mut app, "replayed", true); + assert!(app.child_views["child"].notice.contains(expected)); + if result.is_err() { + assert_eq!(app.child_views["child"].app.editor.text(), "steer draft"); + } else { + app.paste("next steer"); + } + assert!(matches!( + key(&mut app, KeyCode::Enter), + Action::SteerChild { .. } + )); + assert!(!app.child_views["child"].notice.contains(expected)); + } + } + + #[test] + fn parked_steer_result_is_restored_with_draft_and_cleared_on_generation_change() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.child_capabilities("child", 1, true); + app.focus_child("child".into()); + app.paste("draft"); + key(&mut app, KeyCode::Enter); + app.leave_child(); + app.child_steer_finished("child", 1, Err("delivery is unknown".into())); + app.focus_child("child".into()); + assert!( + app.child_views["child"] + .notice + .contains("delivery is unknown") + ); + assert_eq!(app.child_views["child"].app.editor.text(), "draft"); + lifecycle(&mut app, "child", 2, SubagentStatus::Working); + assert!( + !app.child_views["child"] + .notice + .contains("delivery is unknown") + ); + assert_eq!(app.child_views["child"].app.editor.text(), "draft"); + } + + #[test] + fn backend_partial_reasons_remain_visible_with_sticky_steer_results_and_live_pages() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.child_capabilities("child", 1, true); + app.focus_child("child".into()); + app.paste("steer"); + key(&mut app, KeyCode::Enter); + app.child_steer_finished("child", 1, Err("delivery is unknown".into())); + let reasons = [ + "ACP does not identify its echo; reported messages may repeat the prompt", + "Inherited transcript before this fork is unavailable", + ]; + let target = app.child_read_target().unwrap(); + app.child_read_finished(&target, Ok(ReadSubagentTranscriptResponse { + generation: 1, next_cursor: 1, caught_up: false, + updates: reasons.iter().map(|reason| serde_json::json!({"sessionUpdate": "kit_transcript_partial", "reason": reason})).collect(), + })); + for reason in reasons { + assert!(app.child_views["child"].notice.contains(reason)); + } + assert!( + app.child_views["child"] + .notice + .contains("delivery is unknown") + ); + assert!(app.child_views["child"].notice.contains("Loading")); + read(&mut app, "supported live text", true); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: target.3, + caught_up: true, + updates: vec![], + }), + ); + for reason in reasons { + assert!(app.child_views["child"].notice.contains(reason)); + } + assert!( + app.child_views["child"] + .notice + .contains("delivery is unknown") + ); + assert!( + !app.child_views["child"] + .notice + .contains("child sent an unsupported update") + ); + } + + #[test] + fn oversized_record_omission_reason_survives_live_and_empty_pages() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.focus_child("child".into()); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 1, + caught_up: false, + updates: vec![serde_json::json!({ + "sessionUpdate": "kit_transcript_truncated", + "reason": "One inspection update exceeded 1 MiB and was omitted; later updates remain available" + })], + }), + ); + read(&mut app, "supported live text", true); + let target = app.child_read_target().unwrap(); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: target.3, + caught_up: true, + updates: vec![], + }), + ); + let notice = &app.child_views["child"].notice; + assert!(notice.contains("exceeded 1 MiB")); + assert!(notice.contains("later updates remain available")); + } + + #[test] + fn backend_partial_reasons_are_bounded_deduplicated_and_utf8_safe() { + let mut app = app(); + lifecycle(&mut app, "child", 1, SubagentStatus::Working); + app.focus_child("child".into()); + let target = app.child_read_target().unwrap(); + let mut updates = vec![ + serde_json::json!({"sessionUpdate": "kit_transcript_partial", "reason": "known limitation"}); + 2 + ]; + updates.extend((0..8).map(|index| serde_json::json!({"sessionUpdate": "kit_transcript_partial", "reason": format!("{index}\n{}", "界".repeat(600))}))); + app.child_read_finished( + &target, + Ok(ReadSubagentTranscriptResponse { + generation: 1, + next_cursor: 1, + caught_up: true, + updates, + }), + ); + let view = &app.child_views["child"]; + assert_eq!(view.partial_reasons.len(), MAX_PARTIAL_REASONS); + assert_eq!( + view.partial_reasons + .iter() + .filter(|reason| *reason == "known limitation") + .count(), + 1 + ); + assert!( + view.partial_reasons + .iter() + .all(|reason| reason.len() <= MAX_PARTIAL_REASON_BYTES + '…'.len_utf8()) + ); + assert!(!view.notice.contains('\n')); + assert!(view.notice.contains("additional limitations omitted")); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f2b7df36..ade5280d 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1845,6 +1845,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( Voice(crate::voice::VoiceEvent), StorageShutdown, + ChildTranscript(Result), Terminal(Option>), ModelSwitch(ModelSwitchCompletion), Background(Option), @@ -1857,7 +1858,32 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( let mut pending_update = None; let mut clipboard_pastes = ClipboardPastes::default(); let mut submit_after_paste = false; + let mut child_read: Option = None; loop { + let target = app.child_read_target().map(|target| (session_id.to_string(), target)); + if child_read.as_ref().is_some_and(|read| Some(&read.target) != target.as_ref()) { + // Dropping the future cancels the local request and frees its page. + child_read = None; + } + if child_read.is_none() && let Some(target) = target { + let backoff = app.child_views[&target.1.0].read_backoff; + let request = crate::protocols::acp::ReadSubagentTranscriptRequest { + session_id: target.0.clone().into(), + id: target.1.0.clone(), generation: target.1.1, cursor: target.1.3, + }; + let connection = connection.clone(); + child_read = Some(ChildTranscriptRead { + target, + future: Box::pin(async move { + if backoff { tokio::time::sleep(Duration::from_millis(150)).await; } + match tokio::time::timeout(HANDSHAKE, connection.send_request(request).block_task()).await { + Ok(result) => result.map_err(|error| error.message.to_string()), + Err(_) => Err("child transcript read timed out".into()), + } + }), + }); + } + // Reconcile after every prior event, including failures in // result/observe, before accepting any next user input. voice.notify_state(&connection, &session_id); @@ -1895,7 +1921,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( // until this wait ends. This scope drops all losers and // releases input borrows before handlers drain/reset them. poll_fn(|cx| { - let sources = 8; + let sources = 9; for offset in 0..sources { let branch = (next_priority + offset) % sources; let ready = match branch { @@ -1917,6 +1943,9 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( 6 => stopped.as_mut().poll(cx).map(|()| SessionEvent::Stop), 7 => voice.poll(cx).map(SessionEvent::Voice), + 8 => child_read.as_mut().map_or(Poll::Pending, |read| { + read.future.as_mut().poll(cx).map(SessionEvent::ChildTranscript) + }), _ => Poll::Pending, }; if ready.is_ready() { @@ -1929,6 +1958,12 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( }; match event { + SessionEvent::ChildTranscript(result) => { + if let Some(read) = child_read.take() + && read.target.0 == session_id.to_string() { + app.child_read_finished(&read.target.1, result); + } + } SessionEvent::Voice(event) => match event { crate::voice::VoiceEvent::Ready => voice.mark_ready(&mut app), crate::voice::VoiceEvent::Transcript { speaker, text } => { @@ -1937,7 +1972,9 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( app.note(format!("voice {}: {}", voice_display_text(&speaker), voice_display_text(&text))); } crate::voice::VoiceEvent::Delegation { id, text } => { - if app.working() || app.model_switch.is_some() || voice.handoff.is_some() { + if app.child_focus.is_some() { + voice.result(id, "Task was NOT submitted: return to the root session before delegating voice work.".into(), &mut app); + } else if app.working() || app.model_switch.is_some() || voice.handoff.is_some() { voice.result(id, "Kit is busy. Task was NOT submitted; ask the user to try again when idle.".into(), &mut app); } else if text.trim().is_empty() || text.len() > 32_768 || id.len() > 1024 { voice.result(id, "Task was NOT submitted: empty or oversized delegation.".into(), &mut app); @@ -2064,6 +2101,31 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( } } } + Action::SteerChild { id, generation, text } => { + // transition_route is the only route writer. Snapshot its + // generation synchronously with the root ACP identity, and + // drop the guard before starting any asynchronous work. + let route_generation = match transition_session.lock() { + Ok(route) => route.generation, + Err(_) => { + app.apply(Update::ChildSteerFinished { + id, generation, + result: Err("active session route unavailable; child steering was not submitted".into()), + }); + continue; + } + }; + let request = crate::protocols::acp::SteerSubagentRequest { + session_id: session_id.to_string().into(), + id: id.clone(), + generation, + prompt: text, + }; + let connection = connection.clone(); + spawn_child_steer(route_generation, id, generation, updates_tx.clone(), async move { + connection.send_request(request).block_task().await.map(|_| ()) + }); + } Action::ReplaceSteer { id, text } => { let Ok(route) = transition_session.lock() else { app.note("could not start pending-message edit"); @@ -2795,6 +2857,43 @@ fn osc52(text: &str) -> String { format!("\x1b]52;c;{}\x07", STANDARD.encode(text)) } +/// One cancellable page request, scoped to the root session and focus epoch. +struct ChildTranscriptRead { + target: (String, (String, u64, u64, u64)), + future: std::pin::Pin< + Box< + dyn Future< + Output = Result, + > + Send, + >, + >, +} + +/// Child steering never enters the root prompt path. Its completion is scoped +/// independently to the captured root route and the selected child generation. +fn spawn_child_steer( + route_generation: u64, + id: String, + generation: u64, + updates: mpsc::UnboundedSender, + request: impl Future> + Send + 'static, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let result = match tokio::time::timeout(HANDSHAKE, request).await { + Ok(result) => result.map_err(|error| error.message.to_string()), + Err(_) => Err("child steering timed out; delivery is unknown".into()), + }; + let _ = updates.send(QueuedUpdate::for_session( + route_generation, + Update::ChildSteerFinished { + id, + generation, + result, + }, + )); + }) +} + /// Await delivery-sensitive ACP mutations off the terminal event loop. Both /// session generation and the app's mutation token must match at completion. fn spawn_steer_mutation( @@ -3002,6 +3101,10 @@ fn apply_clipboard_completion( } fn handle_paste(app: &mut App, text: &str) -> bool { + if app.child_focus.is_some() { + app.paste(text); + return true; + } if paste_blocked(app) { return false; } @@ -3217,18 +3320,28 @@ fn enable_tui_modes() { } } -fn draw_frame( - terminal: &mut DefaultTerminal, +fn draw_frame( + terminal: &mut ratatui::Terminal>, app: &mut app::App, images: &mut image::ImageRuntime, ) -> std::io::Result<()> { hyperlinks::draw(terminal, |frame| { ui::draw(frame, app, images); + // Child views render no overlays, including command completions for + // literal slash drafts. Only the root view uses those controls. + let (app, obscured) = app + .child_focus + .as_ref() + .and_then(|id| app.child_views.get(id)) + .map_or_else( + || (&*app, ui::native_links_obscured(app)), + |child| (&*child.app, false), + ); hyperlinks::FrameLinks { rows: app.row_links.clone(), left: app.transcript_left, top: app.transcript_top, - obscured: ui::native_links_obscured(app), + obscured, } }) } @@ -5704,6 +5817,60 @@ mod tests { assert!(accept_queued_update(&route, completion).is_none()); } + #[tokio::test] + async fn child_steer_completion_preserves_child_and_root_generations() { + let (updates, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + super::spawn_child_steer(3, "child".into(), 9, updates, async { Ok(()) }) + .await + .unwrap(); + let completion = receiver.recv().await.unwrap(); + assert_eq!(completion.generation, Some(3)); + assert!(matches!( + &completion.update, + Update::ChildSteerFinished { id, generation: 9, result: Ok(()) } + if id == "child" + )); + let route = Arc::new(Mutex::new(super::ActiveSessionRoute { + id: "root".into(), + generation: 3, + })); + super::transition_route(&route, "other-root".into()); + assert!(accept_queued_update(&route, completion).is_none()); + } + + #[tokio::test(start_paused = true)] + async fn child_steer_request_is_bounded() { + let (updates, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let task = super::spawn_child_steer(3, "child".into(), 9, updates, std::future::pending()); + let completion = receiver.recv().await.unwrap(); + task.await.unwrap(); + assert!(matches!( + completion.update, + Update::ChildSteerFinished { result: Err(message), .. } + if message.contains("timed out") && message.contains("delivery is unknown") + )); + } + + #[tokio::test] + async fn child_steer_failure_is_reported_without_root_fallback() { + let (updates, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + super::spawn_child_steer(3, "child".into(), 9, updates, async { + Err(agent_client_protocol::Error::invalid_params()) + }) + .await + .unwrap(); + let completion = receiver.recv().await.unwrap(); + assert!(matches!( + completion.update, + Update::ChildSteerFinished { + generation: 9, + result: Err(_), + .. + } + )); + assert!(receiver.recv().await.is_none()); + } + #[test] fn queued_media_editability_uses_actual_submitted_content() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 93033ff7..403af17a 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -55,6 +55,14 @@ type TaggedTranscriptLine = (LinkedLine, TranscriptTag); pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { images.poll(); + app.child_back_area = Rect::default(); + if let Some(id) = app.child_focus.clone() + && let Some(mut child) = app.child_views.remove(&id) + { + draw_child(frame, app, &id, &mut child, images); + app.child_views.insert(id, child); + return; + } // Two border columns plus the `›` gutter; the prompt grows as the wrapped // text needs more rows, up to the cap. let start_width = frame @@ -75,6 +83,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { .min(available_start_prompt_rows); let show_start = frame.area().width >= 20 && available_start_prompt_rows >= START_MIN_PROMPT_ROWS + && !app.agents_keyboard_focus && app.blocks.is_empty() && app.pending_steers.is_empty() && !app.editing_steer() @@ -162,6 +171,114 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { } } +/// Keep the root roster interactive even when the inspected transcript is empty. +fn child_body_layout(area: Rect) -> (Rect, Rect) { + if area.width >= SIDE_BY_SIDE_WIDTH { + let [transcript, agents] = + Layout::horizontal([Constraint::Min(40), Constraint::Length(AGENTS_WIDTH)]).areas(area); + (transcript, agents) + } else { + // One complete, selectable three-line row plus borders and footer. + let [transcript, agents] = + Layout::vertical([Constraint::Min(0), Constraint::Length(6)]).areas(area); + (transcript, agents) + } +} + +fn draw_child( + frame: &mut Frame<'_>, + root: &mut App, + id: &str, + child: &mut super::app::ChildView, + images: &mut ImageRuntime, +) { + let name = root + .agent_tree_rows() + .into_iter() + .find(|row| row.row.id == id) + .map(|row| row.row.name.clone()) + .unwrap_or_else(|| "Subagent".into()); + let app = &mut child.app; + app.prompt_width = frame.area().width.saturating_sub(4).max(1) as usize; + let prompt_rows = app + .editor + .display_rows(app.prompt_width) + .clamp(1, MAX_PROMPT_ROWS) as u16 + + 2; + let [back, title, notice, body, prompt] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Min(0), + Constraint::Length(prompt_rows), + ]) + .areas(frame.area()); + root.child_back_area = Rect { + width: back.width.min(23), + ..back + }; + frame.render_widget( + Paragraph::new("← Back to main (Esc)").style(theme::accent()), + back, + ); + frame.render_widget( + Paragraph::new(format!("{name} · {id} · g{}", child.generation)).style(theme::accent()), + title, + ); + frame.render_widget( + Paragraph::new(child.notice.as_str()) + .style(theme::dim()) + .wrap(ratatui::widgets::Wrap { trim: false }), + notice, + ); + let (transcript, agents) = child_body_layout(body); + draw_transcript(frame, app, images, transcript); + draw_agents(frame, root, agents); + + // This is deliberately not the root prompt: no commands, attachments, + // queue actions, session controls, or idle-message submission affordance. + let can_steer = app.phase == Phase::Working && child.can_steer; + let block = Panel::bordered() + .border_type(BorderType::Rounded) + .border_style(if can_steer { + theme::accent() + } else { + theme::faint() + }) + .title(if can_steer { + " text steer · Enter send " + } else { + " text steer unavailable " + }); + let inner = block.inner(prompt); + frame.render_widget(block, prompt); + if can_steer { + let [gutter, field] = + Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).areas(inner); + frame.render_widget(Paragraph::new("›").style(theme::accent()), gutter); + let (rows, (cursor_row, cursor_column)) = app.editor.wrapped(field.width.max(1) as usize); + let first = cursor_row.saturating_sub(usize::from(field.height.saturating_sub(1))); + let lines: Vec> = rows + .into_iter() + .skip(first) + .take(field.height as usize) + .map(Line::from) + .collect(); + frame.render_widget(Paragraph::new(lines), field); + if !root.agents_keyboard_focus && field.width > 0 && field.height > 0 { + frame.set_cursor_position(Position::new( + field.x + (cursor_column as u16).min(field.width - 1), + field.y + (cursor_row - first) as u16, + )); + } + } else { + frame.render_widget( + Paragraph::new("Read-only · Esc returns to main").style(theme::faint()), + inner, + ); + } +} + #[derive(Clone, Copy)] struct PromptViewport { field: Rect, @@ -992,7 +1109,12 @@ fn body_layout(area: Rect, show_agents: bool, transcript_empty: bool) -> (Rect, } fn draw_body(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime, area: Rect) { - let (transcript, agents) = body_layout(area, app.show_agents(), app.blocks.is_empty()); + let (transcript, agents) = if app.agents_keyboard_focus { + let (transcript, agents) = child_body_layout(area); + (transcript, Some(agents)) + } else { + body_layout(area, app.show_agents(), app.blocks.is_empty()) + }; draw_transcript(frame, app, images, transcript); if let Some(agents) = agents { draw_agents(frame, app, agents); @@ -1270,8 +1392,8 @@ fn refresh_transcript_cache_with_images(app: &mut App, images: &mut ImageRuntime let mut first_changed_count = app.blocks.len(); for block_index in dirty { let dynamic = match &app.blocks[block_index] { - Block::Thought { millis, .. } => millis.is_none(), - Block::Tool(call) => call.running(), + Block::Thought { closed, .. } => !closed, + Block::Tool(call) => call.running() && !call.timing_closed, _ => false, }; let revision = app.transcript_revisions[block_index]; @@ -1563,12 +1685,14 @@ fn single_transcript_block_rows( text, started, millis, + closed, } => ( uncopyable(plain_lines(thought_lines( app, text, - started.elapsed().as_millis(), + super::app::observed_duration(*started, Some(std::time::Instant::now())), *millis, + *closed, ))), None, ), @@ -1605,7 +1729,7 @@ fn single_transcript_block_rows( ], vec![ Span::styled( - format!("background result · {}", theme::duration(*millis)), + format!("background result · {}", timing_label(*millis)), theme::dim(), ), Span::styled( @@ -1743,19 +1867,25 @@ fn spread(mut left: Vec>, right: Vec>, width: usize) /// still running in the background. Turns restart on their own after a /// detached result lands, so per-turn figures would say little; the clock /// keeps running until nothing is left in the background. -fn turn_end_line(background: usize, since_prompt: u64) -> Line<'static> { +fn timing_label(millis: Option) -> String { + millis + .map(theme::duration) + .unwrap_or_else(|| "timing unknown".into()) +} + +fn turn_end_line(background: usize, since_prompt: Option) -> Line<'static> { let mut spans = vec![Span::styled("· ", theme::faint())]; if background > 0 { spans.push(Span::styled("◔ ", Style::default().fg(theme::warn_color()))); spans.push(Span::styled( format!( "{background} in background · {} so far", - theme::duration(since_prompt) + timing_label(since_prompt) ), theme::faint(), )); } else { - spans.push(Span::styled(theme::duration(since_prompt), theme::faint())); + spans.push(Span::styled(timing_label(since_prompt), theme::faint())); } Line::from(spans) } @@ -1772,16 +1902,17 @@ fn thought_heading(text: &str) -> Option { fn thought_lines( app: &App, text: &str, - running_millis: u128, + running_millis: Option, millis: Option, + closed: bool, ) -> Vec> { - let elapsed = millis.unwrap_or(u64::try_from(running_millis).unwrap_or(u64::MAX)); + let elapsed = if closed { millis } else { running_millis }; if !app.show_thoughts { - return vec![if millis.is_some() { + return vec![if closed { Line::from(vec![ Span::styled("⋮ ", theme::faint()), Span::styled( - format!("thought {} · ^t", theme::duration(elapsed)), + format!("thought {} · ^t", timing_label(elapsed)), theme::faint(), ), ]) @@ -1793,7 +1924,7 @@ fn thought_lines( thought_heading(text).map_or(String::new(), |heading| format!(" · {heading}")), theme::text(), ), - Span::styled(format!(" · {}", theme::duration(elapsed)), theme::dim()), + Span::styled(format!(" · {}", timing_label(elapsed)), theme::dim()), ]) }]; } @@ -1851,23 +1982,25 @@ struct LaneClock { } fn lane_clock(app: &App, call: &ToolCall) -> Option { + call.started?; + call.elapsed()?; let parent = app.tool_call(call.parent_id.as_deref()?)?; + call.started?.checked_duration_since(parent.started?)?; Some(LaneClock { - started: parent.started, - total_millis: parent.elapsed().max(1), + started: parent.started?, + total_millis: parent.elapsed()?.max(1), }) } /// A bar on the program's clock: where the call started, how long it ran, /// and whether it is still going. fn lane_bar(call: &ToolCall, clock: &LaneClock, track: usize) -> Vec> { - let offset = u64::try_from( - call.started - .saturating_duration_since(clock.started) - .as_millis(), - ) - .unwrap_or(u64::MAX); - let end = offset.saturating_add(call.elapsed()); + let (Some(started), Some(elapsed)) = (call.started, call.elapsed()) else { + return Vec::new(); + }; + let offset = u64::try_from(started.saturating_duration_since(clock.started).as_millis()) + .unwrap_or(u64::MAX); + let end = offset.saturating_add(elapsed); let total = clock.total_millis.max(end).max(1); let cell = |millis: u64| ((millis as f64 / total as f64) * track as f64) as usize; let mut start = cell(offset).min(track.saturating_sub(2)); @@ -1945,7 +2078,7 @@ fn lane_line( right.extend(lane_bar(call, &clock, TRACK)); right.push(Span::raw(" ")); } - right.push(Span::styled(theme::duration(call.elapsed()), theme::dim())); + right.push(Span::styled(timing_label(call.elapsed()), theme::dim())); if !call.running() && call.status == ToolCallStatus::Failed { right.push(Span::styled( " failed", @@ -2107,7 +2240,7 @@ fn tool_header( if calls > 0 { meta.push(format!("{calls} {}", plural("call", calls))); } - meta.push(theme::duration(call.elapsed())); + meta.push(timing_label(call.elapsed())); let mut right = Vec::new(); if call.backgrounded { if call.running() { @@ -2184,10 +2317,7 @@ fn working_line(app: &App) -> Line<'static> { theme::bold(theme::accent_color()), ), Span::styled(label.to_string(), theme::accent()), - Span::styled( - format!(" · {}", theme::duration(app.elapsed())), - theme::dim(), - ), + Span::styled(format!(" · {}", timing_label(app.elapsed())), theme::dim()), ]) } @@ -2401,7 +2531,14 @@ fn draw_agents(frame: &mut Frame<'_>, app: &mut App, area: Rect) { let block = Panel::bordered() .border_type(BorderType::Rounded) .border_style(theme::faint()) - .title(Span::styled(" agents ", theme::accent())); + .title(Span::styled( + if app.agents_keyboard_focus { + " agents · ↑↓ Enter · Ctrl+G " + } else { + " agents " + }, + theme::accent(), + )); let inner = block.inner(area); frame.render_widget(block, area); @@ -2415,7 +2552,16 @@ fn draw_agents(frame: &mut Frame<'_>, app: &mut App, area: Rect) { .into_iter() .skip(app.agents_scroll()) .take(visible_rows) - .flat_map(|row| agent_lines(&row, show_vendor, app.tick, now, inner.width as usize)) + .flat_map(|row| { + let mut lines = agent_lines(&row, show_vendor, app.tick, now, inner.width as usize); + if app.agents_selected.as_deref() == Some(row.row.id.as_str()) { + for line in &mut lines { + *line = std::mem::take(line) + .style(Style::default().add_modifier(Modifier::REVERSED)); + } + } + lines + }) .collect::>(); let rows_area = Rect { height: row_area_height, @@ -2526,7 +2672,7 @@ fn draw_dock(frame: &mut Frame<'_>, app: &App, area: Rect, narrow: bool) { let mut entries: Vec<(Line<'static>, bool)> = Vec::new(); for call in app.background_calls() { let focused = app.focus_call().is_some_and(|focus| focus.id == call.id); - let mut right = vec![Span::styled(theme::duration(call.elapsed()), theme::dim())]; + let mut right = vec![Span::styled(timing_label(call.elapsed()), theme::dim())]; if focused { right.push(Span::styled(" ^k stop", theme::faint())); } @@ -2814,7 +2960,7 @@ fn draw_prompt_editor( .collect() }; frame.render_widget(Paragraph::new(lines), field); - if !app.queue_focused { + if !app.queue_focused && !app.agents_keyboard_focus { frame.set_cursor_position(Position::new( field.x + u16::try_from(cursor_column) @@ -2911,7 +3057,7 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { Style::default().fg(theme::accent_color()), ), Span::styled("working", theme::bold(theme::accent_color())), - Span::styled(format!(" {}", theme::duration(app.elapsed())), theme::dim()), + Span::styled(format!(" {}", timing_label(app.elapsed())), theme::dim()), ], }; let counts = app.agent_counts(); @@ -3119,6 +3265,7 @@ mod tests { usage: None, cost: None, activity: Default::default(), + focus_can_steer: false, } } @@ -3731,6 +3878,91 @@ mod tests { app } + #[test] + fn focused_child_media_paste_stays_text_without_changing_root() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("image.png"); + std::fs::write(&path, b"png").unwrap(); + let text = path.to_str().unwrap(); + for can_steer in [true, false] { + let mut app = panel_app(1); + app.paste("root draft"); + app.apply(Update::Runtime(RuntimeEvent::SubagentStateChanged { + id: "agent-0".into(), + name: "Scout 0".into(), + status: SubagentStatus::Working, + outcome: None, + generation: 2, + task: "task".into(), + parent_id: None, + parent_name: None, + harness: "acp.kit".into(), + vendor: crate::events::HarnessVendor::Kit, + model: None, + created_at_unix_ms: 1, + generation_started_at_unix_ms: 1, + generation_finished_at_unix_ms: None, + })); + app.apply(Update::Runtime(RuntimeEvent::SubagentCapabilities { + id: "agent-0".into(), + generation: 2, + can_steer, + })); + app.focus_child("agent-0".into()); + + super::super::handle(&mut app, crossterm::event::Event::Paste(text.into())); + + assert_eq!(app.editor.text(), "root draft"); + assert!(app.attachments.is_empty()); + let child = &app.child_views["agent-0"].app; + assert_eq!(child.editor.text(), if can_steer { text } else { "" }); + assert!(child.attachments.is_empty()); + } + } + + #[test] + fn focused_child_frame_emits_visible_link_destinations() { + let mut app = panel_app(1); + app.blocks + .push(Block::Agent("[root link](https://example.com/root)".into())); + app.phase = Phase::Working; + let capture = Capture::default(); + let mut terminal = Terminal::with_options( + HyperlinkBackend::new(capture.clone()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, 80, 24)), + }, + ) + .unwrap(); + let mut images = ImageRuntime::disabled(); + super::super::draw_frame(&mut terminal, &mut app, &mut images).unwrap(); + let output = String::from_utf8(capture.bytes()).unwrap(); + assert!(output.contains("https://example.com/root\x1b\\")); + capture.clear(); + + // Hidden root controls must not suppress the child's native links. + app.paste("/"); + assert!(native_links_obscured(&app)); + app.focus_child("agent-0".into()); + // A retained slash draft has root-style completions, but the child + // renders no completion popup, even when the draft is read-only. + let child = &mut app.child_views.get_mut("agent-0").unwrap().app; + child.editor.insert_str("/model"); + assert!(native_links_obscured(child)); + app.child_views + .get_mut("agent-0") + .unwrap() + .app + .blocks + .push(Block::Agent( + "[child link](https://example.com/child)".into(), + )); + super::super::draw_frame(&mut terminal, &mut app, &mut images).unwrap(); + let output = String::from_utf8(capture.bytes()).unwrap(); + assert!(output.contains("https://example.com/child\x1b\\")); + assert!(!output.contains("https://example.com/root\x1b\\")); + } + fn buffer_row(buffer: &ratatui::buffer::Buffer, row: u16) -> String { (0..buffer.area.width) .map(|column| buffer[(column, row)].symbol()) @@ -3767,6 +3999,83 @@ mod tests { }) } + #[test] + fn focused_child_draw_uses_child_composer_and_root_roster() { + for width in [60, 120] { + let mut app = panel_app(2); + app.editor.insert_str("ROOT DRAFT MUST STAY HIDDEN"); + app.focus_child("agent-0".into()); + let child = app.child_views.get_mut("agent-0").expect("child view"); + child.app.phase = Phase::Working; + child.can_steer = true; + child.app.editor.insert_str("child steering draft"); + child.notice = "Child capability notice".into(); + let mut terminal = Terminal::new(TestBackend::new(width, 25)).expect("terminal"); + let mut images = ImageRuntime::disabled(); + terminal + .draw(|frame| draw(frame, &mut app, &mut images)) + .expect("draw succeeds"); + let buffer = terminal.backend().buffer(); + let text = (0..25) + .map(|row| buffer_row(buffer, row)) + .collect::>() + .join("\n"); + assert!(text.contains("Back to main (Esc)")); + assert!(text.contains("Child capability notice")); + assert!(text.contains("child steering draft")); + assert!(text.contains("text steer · Enter send")); + assert!(text.contains("Scout 0")); + assert!(text.contains("2 agents")); + assert!(!text.contains("ROOT DRAFT")); + assert!(app.child_back_area.height > 0); + assert!(app.child_views.contains_key("agent-0")); + } + } + + #[test] + fn focused_child_layout_keeps_selectable_root_roster() { + for width in [40, 107, 108, 160] { + let area = ratatui::layout::Rect::new(0, 4, width, 20); + let (transcript, roster) = super::child_body_layout(area); + assert!(transcript.height > 0); + assert!( + roster.height >= 6, + "one three-line row, borders, and footer" + ); + assert!(roster.width > 2); + assert!(!transcript.intersects(roster)); + assert_eq!(roster.bottom(), area.bottom()); + if width >= 108 { + assert_eq!(roster.width, 46); + } else { + assert_eq!(roster.width, width); + } + } + } + + #[test] + fn agents_panel_highlights_selected_three_line_row() { + let mut app = panel_app(2); + app.agents_selected = Some("agent-1".into()); + let mut terminal = Terminal::new(TestBackend::new(80, 9)).expect("terminal"); + terminal + .draw(|frame| draw_agents(frame, &mut app, frame.area())) + .expect("draw succeeds"); + let buffer = terminal.backend().buffer(); + for row in 4..7 { + assert!( + buffer[(1, row)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + } + assert!( + !buffer[(1, 1)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + } + #[test] fn agents_panel_shows_accumulated_reported_cost_by_currency() { let mut app = panel_app(3); @@ -4520,6 +4829,49 @@ mod tests { assert!(!frame.contains("esc back"), "{frame}"); } + #[test] + fn unknown_tool_timing_is_explicit_and_has_no_lane_clock() { + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + for (id, title) in [("parent", "compose"), ("child", "shell")] { + app.apply_at( + crate::tui::app::Update::ToolStarted { + id: id.into(), + title: title.into(), + kind: Default::default(), + script: None, + backgrounded: false, + }, + None, + ); + } + app.apply_at( + crate::tui::app::Update::ToolParent { + id: "child".into(), + parent: Some("parent".into()), + }, + None, + ); + let child = app.tool_call("child").unwrap(); + assert!(super::lane_clock(&app, child).is_none()); + let header = super::tool_header(&app, child, false, 100, 1); + let text = header + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(text.contains("timing unknown"), "{text}"); + assert!(!text.contains("0ms"), "{text}"); + assert_eq!( + super::timing_label(Some(7_000)), + super::theme::duration(7_000) + ); + } + #[test] fn completed_turn_duration_is_rendered() { let mut app = App::new( @@ -4530,7 +4882,7 @@ mod tests { ); app.blocks.push(Block::TurnDuration { background: 0, - since_prompt: 788_645_000, + since_prompt: Some(788_645_000), }); let frame = render(&mut app, 80, 12); @@ -4540,7 +4892,7 @@ mod tests { app.blocks.push(Block::TurnDuration { background: 2, - since_prompt: 788_646_000, + since_prompt: Some(788_646_000), }); let frame = render(&mut app, 80, 12); assert!( @@ -4576,19 +4928,30 @@ mod tests { } #[test] - fn ctrl_g_does_not_toggle_a_runtime_graph_layout() { - let mut app = App::new( - PathBuf::from("/Users/dev/projects/kit"), - "openai-subscription".into(), - "gpt-5.4".into(), - "127.0.0.1:7331".into(), - ); - app.push_user("keep the transcript full width".into()); + fn ctrl_g_focuses_roster_without_hiding_root_transcript() { + for width in [60, 120] { + let mut app = panel_app(2); + app.push_user("keep the root transcript visible".into()); + app.editor.insert_str("retained root draft"); - app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)); - render(&mut app, 120, 20); + app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)); + let frame = render(&mut app, width, 24); - assert!(app.transcript_width > 100, "{}", app.transcript_width); + assert!(app.agents_keyboard_focus); + assert!(app.child_focus.is_none()); + assert!( + frame.contains("keep the root transcript visible"), + "{frame}" + ); + assert!(frame.contains("Scout 0"), "{frame}"); + assert!(frame.contains("↑↓ Enter"), "{frame}"); + assert!(frame.contains("retained root draft"), "{frame}"); + assert!(app.transcript_width > 0); + + app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)); + assert!(!app.agents_keyboard_focus); + assert_eq!(app.editor.text(), "retained root draft"); + } } #[test]