diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index e00d5ebc0..3ec29039d 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -69,6 +69,8 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -143,11 +145,13 @@ use uuid::Uuid; mod agent_navigation; mod app_server_adapter; mod app_server_requests; +mod loaded_threads; mod pending_interactive_replay; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; +use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; @@ -200,6 +204,30 @@ fn command_execution_decision_to_review_decision( } } +/// Extracts `receiver_thread_ids` from collab agent tool-call notifications. +/// +/// Only `ItemStarted` and `ItemCompleted` notifications with a `CollabAgentToolCall` item carry +/// receiver thread ids. All other notification variants return `None`. +fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[String]> { + match notification { + ServerNotification::ItemStarted(notification) => match ¬ification.item { + ThreadItem::CollabAgentToolCall { + receiver_thread_ids, + .. + } => Some(receiver_thread_ids), + _ => None, + }, + ServerNotification::ItemCompleted(notification) => match ¬ification.item { + ThreadItem::CollabAgentToolCall { + receiver_thread_ids, + .. + } => Some(receiver_thread_ids), + _ => None, + }, + _ => None, + } +} + fn convert_via_json(value: T) -> Option where T: serde::Serialize, @@ -951,6 +979,7 @@ pub(crate) struct App { active_thread_id: Option, active_thread_rx: Option>, primary_thread_id: Option, + last_subagent_backfill_attempt: Option, primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, @@ -2265,6 +2294,67 @@ impl App { Ok(()) } + /// Eagerly fetches nickname and role for receiver threads referenced by a collab notification. + /// + /// This runs on every buffered thread notification before it reaches rendering. For each + /// receiver thread id that the navigation cache does not yet have metadata for, it issues a + /// `thread/read` RPC and registers the result in both `AgentNavigationState` and the + /// `ChatWidget` metadata map. Threads that already have a nickname or role cached are skipped, + /// so the cost is at most one RPC per thread over the lifetime of a session. + /// + /// Failures are logged and silently ignored -- the worst outcome is that a rendered item shows + /// a thread id instead of a human-readable name, which is the same behavior the TUI had before + /// this change. + async fn hydrate_collab_agent_metadata_for_notification( + &mut self, + app_server: &mut AppServerSession, + notification: &ServerNotification, + ) { + let Some(receiver_thread_ids) = collab_receiver_thread_ids(notification) else { + return; + }; + + for receiver_thread_id in receiver_thread_ids { + let Ok(thread_id) = ThreadId::from_string(receiver_thread_id) else { + tracing::warn!( + thread_id = receiver_thread_id, + "ignoring collab receiver with invalid thread id during metadata hydration" + ); + continue; + }; + + if self + .agent_navigation + .get(&thread_id) + .is_some_and(|entry| entry.agent_nickname.is_some() || entry.agent_role.is_some()) + { + continue; + } + + match app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + { + Ok(thread) => { + self.ensure_thread_channel(thread_id); + self.upsert_agent_picker_thread( + thread_id, + thread.agent_nickname, + thread.agent_role, + /*is_closed*/ false, + ); + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to hydrate collab receiver thread metadata" + ); + } + } + } + } + async fn infer_session_for_thread_notification( &mut self, thread_id: ThreadId, @@ -2602,6 +2692,11 @@ impl App { agent_role: Option, is_closed: bool, ) { + self.chat_widget.set_collab_agent_metadata( + thread_id, + agent_nickname.clone(), + agent_role.clone(), + ); self.agent_navigation .upsert(thread_id, agent_nickname, agent_role, is_closed); self.sync_active_agent_label(); @@ -2616,6 +2711,24 @@ impl App { self.sync_active_agent_label(); } + /// Replaces the chat widget and re-seeds the new widget's collab metadata from the navigation + /// cache. + /// + /// Thread switches reconstruct the `ChatWidget`, which loses the `collab_agent_metadata` map. + /// This helper copies every known nickname/role from `AgentNavigationState` into the + /// replacement widget so that replayed collab items render agent names immediately. + fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + for (thread_id, entry) in self.agent_navigation.ordered_threads() { + chat_widget.set_collab_agent_metadata( + thread_id, + entry.agent_nickname.clone(), + entry.agent_role.clone(), + ); + } + self.chat_widget = chat_widget; + self.sync_active_agent_label(); + } + async fn select_agent_thread( &mut self, tui: &mut tui::Tui, @@ -2661,8 +2774,7 @@ impl App { self.active_thread_rx = Some(receiver); let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - self.chat_widget = ChatWidget::new_with_app_event(init); - self.sync_active_agent_label(); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, !is_replay_only); @@ -2697,6 +2809,7 @@ impl App { self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; + self.last_subagent_backfill_attempt = None; self.primary_session_configured = None; self.pending_primary_events.clear(); self.pending_app_server_requests.clear(); @@ -2732,7 +2845,7 @@ impl App { match app_server.start_thread(&config).await { Ok(started) => { if let Err(err) = self - .replace_chat_widget_with_app_server_thread(tui, started) + .replace_chat_widget_with_app_server_thread(tui, app_server, started) .await { self.chat_widget.add_error_message(format!( @@ -2760,13 +2873,119 @@ impl App { async fn replace_chat_widget_with_app_server_thread( &mut self, tui: &mut tui::Tui, + app_server: &mut AppServerSession, started: AppServerStartedThread, ) -> Result<()> { - let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - self.chat_widget = ChatWidget::new_with_app_event(init); self.reset_thread_event_state(); + let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.enqueue_primary_thread_session(started.session, started.turns) + .await?; + self.backfill_loaded_subagent_threads(app_server).await; + Ok(()) + } + + /// Fetches all loaded threads from the app server and registers descendants of the primary + /// thread in the navigation cache and chat widget metadata. + /// + /// Called after `replace_chat_widget_with_app_server_thread` during resume, fork, and new + /// thread creation so that the `/agent` picker and keyboard navigation are pre-populated even + /// if the TUI did not witness the original spawn events. + /// + /// The loaded-thread list is fetched in full (no pagination) and the spawn tree is walked + /// by `find_loaded_subagent_threads_for_primary`. Each discovered subagent is registered via + /// `upsert_agent_picker_thread`, which writes to both `AgentNavigationState` and the + /// `ChatWidget` metadata map. + async fn backfill_loaded_subagent_threads( + &mut self, + app_server: &mut AppServerSession, + ) -> bool { + let Some(primary_thread_id) = self.primary_thread_id else { + return false; + }; + + let loaded_thread_ids = match app_server + .thread_loaded_list(ThreadLoadedListParams { + cursor: None, + limit: None, + }) .await + { + Ok(response) => response.data, + Err(err) => { + tracing::warn!(%err, "failed to list loaded threads for subagent backfill"); + return false; + } + }; + + let mut threads = Vec::new(); + let mut had_read_error = false; + for thread_id in loaded_thread_ids { + let Ok(thread_id) = ThreadId::from_string(&thread_id) else { + tracing::warn!("ignoring loaded thread with invalid id during subagent backfill"); + continue; + }; + + if thread_id == primary_thread_id { + continue; + } + + match app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + { + Ok(thread) => threads.push(thread), + Err(err) => { + had_read_error = true; + tracing::warn!(thread_id = %thread_id, %err, "failed to read loaded thread"); + } + } + } + + for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) { + self.ensure_thread_channel(thread.thread_id); + self.upsert_agent_picker_thread( + thread.thread_id, + thread.agent_nickname, + thread.agent_role, + /*is_closed*/ false, + ); + } + + !had_read_error + } + + /// Returns the adjacent thread id for keyboard navigation, backfilling from the server if the + /// local cache has no neighbor. + /// + /// Tries the fast path first: ask `AgentNavigationState` directly. If it returns `None` (no + /// adjacent entry exists, typically because the cache was never populated with remote + /// subagents), performs a full `backfill_loaded_subagent_threads` and retries. This ensures the + /// first next/previous keypress in a resumed remote session discovers subagents on demand + /// without requiring the user to wait for a proactive fetch. + async fn adjacent_thread_id_with_backfill( + &mut self, + app_server: &mut AppServerSession, + direction: AgentNavigationDirection, + ) -> Option { + let current_thread = self.current_displayed_thread_id(); + if let Some(thread_id) = self + .agent_navigation + .adjacent_thread_id(current_thread, direction) + { + return Some(thread_id); + } + + let primary_thread_id = self.primary_thread_id?; + if self.last_subagent_backfill_attempt == Some(primary_thread_id) { + return None; + } + + if self.backfill_loaded_subagent_threads(app_server).await { + self.last_subagent_backfill_attempt = Some(primary_thread_id); + } + self.agent_navigation + .adjacent_thread_id(self.current_displayed_thread_id(), direction) } fn fresh_session_config(&self) -> Config { @@ -3116,6 +3335,7 @@ impl App { active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -3428,7 +3648,9 @@ impl App { self.file_search .update_search_dir(self.config.cwd.to_path_buf()); match self - .replace_chat_widget_with_app_server_thread(tui, resumed) + .replace_chat_widget_with_app_server_thread( + tui, app_server, resumed, + ) .await { Ok(()) => { @@ -3488,7 +3710,7 @@ impl App { Ok(forked) => { self.shutdown_current_thread(app_server).await; match self - .replace_chat_widget_with_app_server_thread(tui, forked) + .replace_chat_widget_with_app_server_thread(tui, app_server, forked) .await { Ok(()) => { @@ -4962,6 +5184,11 @@ impl App { // thread, so unrelated shutdowns cannot consume this marker. self.pending_shutdown_exit_thread_id = None; } + if let ThreadBufferedEvent::Notification(notification) = &event { + self.hydrate_collab_agent_metadata_for_notification(app_server, notification) + .await; + } + self.handle_thread_event_now(event); if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); @@ -5117,10 +5344,10 @@ impl App { && self.chat_widget.composer_text_with_pending().is_empty() && previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { - if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( - self.current_displayed_thread_id(), - AgentNavigationDirection::Previous, - ) { + if let Some(thread_id) = self + .adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Previous) + .await + { let _ = self.select_agent_thread(tui, app_server, thread_id).await; } return; @@ -5132,10 +5359,10 @@ impl App { && self.chat_widget.composer_text_with_pending().is_empty() && next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { - if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( - self.current_displayed_thread_id(), - AgentNavigationDirection::Next, - ) { + if let Some(thread_id) = self + .adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Next) + .await + { let _ = self.select_agent_thread(tui, app_server, thread_id).await; } return; @@ -7864,6 +8091,7 @@ guardian_approval = true active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -7915,6 +8143,7 @@ guardian_approval = true active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -9002,6 +9231,78 @@ guardian_approval = true ); } + #[tokio::test] + async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let receiver_thread_id = + ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread"); + app.agent_navigation.upsert( + receiver_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, + ); + + let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { + config: app.config.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), + app_event_tx: app.app_event_tx.clone(), + initial_user_message: None, + enhanced_keys_supported: app.enhanced_keys_supported, + has_chatgpt_account: app.chat_widget.has_chatgpt_account(), + model_catalog: app.model_catalog.clone(), + feedback: app.feedback.clone(), + is_first_run: false, + feedback_audience: app.feedback_audience, + status_account_display: app.chat_widget.status_account_display().cloned(), + initial_plan_type: app.chat_widget.current_plan_type(), + model: Some(app.chat_widget.current_model().to_string()), + startup_tooltip_override: None, + status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(), + session_telemetry: app.session_telemetry.clone(), + }); + app.replace_chat_widget(replacement); + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: None, + turns: Vec::new(), + events: vec![ThreadBufferedEvent::Notification( + ServerNotification::ItemStarted(codex_app_server_protocol::ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: ThreadItem::CollabAgentToolCall { + id: "wait-1".to_string(), + tool: codex_app_server_protocol::CollabAgentTool::Wait, + status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress, + sender_thread_id: ThreadId::new().to_string(), + receiver_thread_ids: vec![receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }, + }), + )], + input_state: None, + }, + false, + ); + + let mut saw_named_wait = false; + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let transcript = lines_to_single_string(&cell.transcript_lines(80)); + saw_named_wait |= transcript.contains("Robie [explorer]"); + } + } + + assert!( + saw_named_wait, + "expected replayed wait item to keep agent name" + ); + } + #[tokio::test] async fn refreshed_snapshot_session_persists_resumed_turns() { let mut app = make_test_app().await; diff --git a/codex-rs/tui_app_server/src/app/loaded_threads.rs b/codex-rs/tui_app_server/src/app/loaded_threads.rs new file mode 100644 index 000000000..c6ac09890 --- /dev/null +++ b/codex-rs/tui_app_server/src/app/loaded_threads.rs @@ -0,0 +1,208 @@ +//! Discovers subagent threads that belong to a primary thread by walking spawn-tree edges. +//! +//! When the TUI resumes or switches to an existing thread, it needs to populate +//! `AgentNavigationState` and `ChatWidget` metadata for every subagent that was spawned during +//! that thread's lifetime. The app server exposes a flat list of currently loaded threads via +//! `thread/loaded/list`, but the TUI must figure out which of those are descendants of the +//! primary thread. +//! +//! This module provides the pure, synchronous tree-walk that turns that flat list into the filtered +//! set of descendants. It intentionally has no async, no I/O, and no side effects so it can be +//! unit-tested in isolation. +//! +//! The walk starts from `primary_thread_id` and repeatedly follows +//! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are +//! found. The primary thread itself is never included in the output. + +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::Thread; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SubAgentSource; +use std::collections::HashMap; +use std::collections::HashSet; + +/// A subagent thread discovered by the spawn-tree walk, carrying just enough metadata for the +/// TUI to register it in the navigation cache and rendering metadata map. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedSubagentThread { + pub(crate) thread_id: ThreadId, + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, +} + +/// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent. +/// +/// The walk is breadth-first over `SessionSource::SubAgent(ThreadSpawn { parent_thread_id })` edges. +/// Threads whose `source` is not a `ThreadSpawn`, or whose `parent_thread_id` does not chain back +/// to `primary_thread_id`, are excluded. The primary thread itself is never included. +/// +/// Results are sorted by stringified thread id for deterministic output in tests and in the +/// navigation cache. Callers should not rely on this ordering for anything semantic; it exists +/// purely to make snapshot assertions stable. +/// +/// If two threads claim the same parent, both are included. Cycles in the parent chain are not +/// possible because `ThreadId`s are server-assigned UUIDs and the server enforces acyclicity, but +/// the `included` set guards against re-visiting regardless. +pub(crate) fn find_loaded_subagent_threads_for_primary( + threads: Vec, + primary_thread_id: ThreadId, +) -> Vec { + let mut threads_by_id = HashMap::new(); + for thread in threads { + let Ok(thread_id) = ThreadId::from_string(&thread.id) else { + continue; + }; + threads_by_id.insert(thread_id, thread); + } + + let mut included = HashSet::new(); + let mut pending = vec![primary_thread_id]; + while let Some(parent_thread_id) = pending.pop() { + for (thread_id, thread) in &threads_by_id { + if included.contains(thread_id) { + continue; + } + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: source_parent_thread_id, + .. + }) = &thread.source + else { + continue; + }; + + if *source_parent_thread_id != parent_thread_id { + continue; + } + + included.insert(*thread_id); + pending.push(*thread_id); + } + } + + let mut loaded_threads: Vec = included + .into_iter() + .filter_map(|thread_id| { + threads_by_id + .remove(&thread_id) + .map(|thread| LoadedSubagentThread { + thread_id, + agent_nickname: thread.agent_nickname, + agent_role: thread.agent_role, + }) + }) + .collect(); + loaded_threads.sort_by_key(|thread| thread.thread_id.to_string()); + loaded_threads +} + +#[cfg(test)] +mod tests { + use super::LoadedSubagentThread; + use super::find_loaded_subagent_threads_for_primary; + use codex_app_server_protocol::SessionSource; + use codex_app_server_protocol::Thread; + use codex_app_server_protocol::ThreadStatus; + use codex_protocol::ThreadId; + use codex_protocol::protocol::SubAgentSource; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { + Thread { + id: thread_id.to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + } + } + + #[test] + fn finds_loaded_subagent_tree_for_primary_thread() { + let primary_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread"); + let child_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread"); + let grandchild_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread"); + let unrelated_parent_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000004").expect("valid thread"); + let unrelated_child_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000005").expect("valid thread"); + + let mut child = test_thread( + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: primary_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("Scout".to_string()), + agent_role: Some("explorer".to_string()), + }), + ); + child.agent_nickname = Some("Scout".to_string()); + child.agent_role = Some("explorer".to_string()); + + let mut grandchild = test_thread( + grandchild_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: Some("Atlas".to_string()), + agent_role: Some("worker".to_string()), + }), + ); + grandchild.agent_nickname = Some("Atlas".to_string()); + grandchild.agent_role = Some("worker".to_string()); + + let unrelated_child = test_thread( + unrelated_child_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: unrelated_parent_id, + depth: 1, + agent_path: None, + agent_nickname: Some("Other".to_string()), + agent_role: Some("researcher".to_string()), + }), + ); + + let loaded = find_loaded_subagent_threads_for_primary( + vec![ + test_thread(primary_thread_id, SessionSource::Cli), + child, + grandchild, + unrelated_child, + ], + primary_thread_id, + ); + + assert_eq!( + loaded, + vec![ + LoadedSubagentThread { + thread_id: child_thread_id, + agent_nickname: Some("Scout".to_string()), + agent_role: Some("explorer".to_string()), + }, + LoadedSubagentThread { + thread_id: grandchild_thread_id, + agent_nickname: Some("Atlas".to_string()), + agent_role: Some("worker".to_string()), + }, + ] + ); + } +} diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index ecdea588e..bd3440491 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -27,6 +27,8 @@ use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; @@ -354,6 +356,22 @@ impl AppServerSession { .wrap_err("thread/list failed during TUI session lookup") } + /// Lists thread ids that the app server currently holds in memory. + /// + /// Used by `App::backfill_loaded_subagent_threads` to discover subagent threads that were + /// spawned before the TUI connected. The caller then fetches full metadata per thread via + /// `thread_read` and walks the spawn tree. + pub(crate) async fn thread_loaded_list( + &mut self, + params: ThreadLoadedListParams, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadLoadedList { request_id, params }) + .await + .wrap_err("failed to list loaded threads from app server") + } + pub(crate) async fn thread_read( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index f3c272d62..0aea665b6 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -140,6 +140,7 @@ use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::BackgroundEventEvent; #[cfg(test)] use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::CollabAgentRef; #[cfg(test)] use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentStatusEntry; @@ -757,6 +758,7 @@ pub(crate) struct ChatWidget { // Latest completed user-visible Codex output that `/copy` should place on the clipboard. last_copyable_output: Option, running_commands: HashMap, + collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, suppressed_exec_calls: HashSet, skills_all: Vec, @@ -904,6 +906,18 @@ pub(crate) struct ChatWidget { last_non_retry_error: Option<(String, String)>, } +/// Cached nickname and role for a collab agent thread, used to attach human-readable labels to +/// rendered tool-call items. +/// +/// Populated externally by `App` via `set_collab_agent_metadata` and consulted by the +/// notification-to-core-event conversion helpers. Defaults to empty so that missing metadata +/// degrades to the previous behavior of showing raw thread ids. +#[derive(Clone, Debug, Default)] +struct CollabAgentMetadata { + agent_nickname: Option, + agent_role: Option, +} + #[cfg_attr(not(test), allow(dead_code))] enum CodexOpTarget { Direct(UnboundedSender), @@ -1379,9 +1393,12 @@ fn app_server_collab_state_to_core(state: &AppServerCollabAgentState) -> AgentSt } } +/// Converts app-server collab agent states into the core protocol representation, enriching each +/// entry with cached nickname and role metadata so rendered items show human-readable names. fn app_server_collab_agent_statuses_to_core( receiver_thread_ids: &[String], agents_states: &HashMap, + collab_agent_metadata: &HashMap, ) -> (Vec, HashMap) { let mut agent_statuses = Vec::new(); let mut statuses = HashMap::new(); @@ -1394,10 +1411,14 @@ fn app_server_collab_agent_statuses_to_core( continue; }; let status = app_server_collab_state_to_core(agent_state); + let metadata = collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default(); agent_statuses.push(CollabAgentStatusEntry { thread_id, - agent_nickname: None, - agent_role: None, + agent_nickname: metadata.agent_nickname, + agent_role: metadata.agent_role, status: status.clone(), }); statuses.insert(thread_id, status); @@ -1406,6 +1427,31 @@ fn app_server_collab_agent_statuses_to_core( (agent_statuses, statuses) } +/// Builds `CollabAgentRef` entries for every valid receiver thread, attaching cached metadata. +/// +/// Used when converting collab `Wait` tool-call items so the rendered waiting list shows agent +/// names instead of bare thread ids. +fn app_server_collab_receiver_agent_refs( + receiver_thread_ids: &[String], + collab_agent_metadata: &HashMap, +) -> Vec { + receiver_thread_ids + .iter() + .filter_map(|thread_id| { + let thread_id = app_server_collab_thread_id_to_core(thread_id)?; + let metadata = collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default(); + Some(CollabAgentRef { + thread_id, + agent_nickname: metadata.agent_nickname, + agent_role: metadata.agent_role, + }) + }) + .collect() +} + fn request_permissions_from_params( params: codex_app_server_protocol::PermissionsRequestApprovalParams, ) -> RequestPermissionsEvent { @@ -1489,6 +1535,35 @@ fn web_search_action_to_core( } impl ChatWidget { + /// Stores or overwrites the cached nickname and role for a collab agent thread. + /// + /// Called by `App::upsert_agent_picker_thread` and `App::replace_chat_widget` to keep the + /// rendering metadata in sync with the navigation cache. Must be called before any + /// notification referencing this thread is processed, otherwise the rendered item will fall + /// back to showing the raw thread id. + pub(crate) fn set_collab_agent_metadata( + &mut self, + thread_id: ThreadId, + agent_nickname: Option, + agent_role: Option, + ) { + self.collab_agent_metadata.insert( + thread_id, + CollabAgentMetadata { + agent_nickname, + agent_role, + }, + ); + } + + /// Returns the cached metadata for a thread, defaulting to empty if none has been registered. + fn collab_agent_metadata(&self, thread_id: ThreadId) -> CollabAgentMetadata { + self.collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default() + } + fn realtime_conversation_enabled(&self) -> bool { self.config.features.enabled(Feature::RealtimeConversation) && cfg!(not(target_os = "linux")) @@ -3443,6 +3518,8 @@ impl ChatWidget { let first_receiver = receiver_thread_ids .first() .and_then(|thread_id| app_server_collab_thread_id_to_core(thread_id)); + let first_receiver_metadata = + first_receiver.map(|thread_id| self.collab_agent_metadata(thread_id)); match tool { CollabAgentTool::SpawnAgent => { @@ -3473,8 +3550,12 @@ impl ChatWidget { call_id: id, sender_thread_id, new_thread_id: first_receiver, - new_agent_nickname: None, - new_agent_role: None, + new_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + new_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), prompt: prompt.unwrap_or_default(), model: String::new(), reasoning_effort: ReasoningEffortConfig::Medium, @@ -3499,8 +3580,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), prompt: prompt.unwrap_or_default(), status: receiver_thread_ids .iter() @@ -3521,8 +3606,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), }, )); } else { @@ -3531,8 +3620,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), status: receiver_thread_ids .iter() .find_map(|thread_id| agents_states.get(thread_id)) @@ -3556,7 +3649,10 @@ impl ChatWidget { app_server_collab_thread_id_to_core(thread_id) }) .collect(), - receiver_agents: Vec::new(), + receiver_agents: app_server_collab_receiver_agent_refs( + &receiver_thread_ids, + &self.collab_agent_metadata, + ), call_id: id, }, )); @@ -3564,6 +3660,7 @@ impl ChatWidget { let (agent_statuses, statuses) = app_server_collab_agent_statuses_to_core( &receiver_thread_ids, &agents_states, + &self.collab_agent_metadata, ); self.on_collab_event(multi_agents::waiting_end( codex_protocol::protocol::CollabWaitingEndEvent { @@ -3584,8 +3681,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), status: receiver_thread_ids .iter() .find_map(|thread_id| agents_states.get(thread_id)) @@ -4319,6 +4420,7 @@ impl ChatWidget { plan_stream_controller: None, last_copyable_output: None, running_commands: HashMap::new(), + collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap index a5b90d0e9..17c08589a 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap @@ -3,10 +3,10 @@ source: tui_app_server/src/chatwidget/tests.rs expression: combined --- • Waiting for 2 agents - └ 019cff70-2599-75e2-af72-b958ce5dc1cc - 019cff70-2599-75e2-af72-b96db334332d + └ Robie [explorer] + Ada [reviewer] • Finished waiting - └ 019cff70-2599-75e2-af72-b958ce5dc1cc: Completed - Done - 019cff70-2599-75e2-af72-b96db334332d: Running + └ Robie [explorer]: Completed - Done + Ada [reviewer]: Running diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 98509303e..9b54d7035 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -2046,6 +2046,7 @@ async fn make_chatwidget_manual( pending_guardian_review_status: PendingGuardianReviewStatus::default(), last_copyable_output: None, running_commands: HashMap::new(), + collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), suppressed_exec_calls: HashSet::new(), skills_all: Vec::new(), @@ -4677,6 +4678,16 @@ async fn live_app_server_collab_wait_items_render_history() { ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread id"); let other_receiver_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b96db334332d").expect("valid thread id"); + chat.set_collab_agent_metadata( + receiver_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + ); + chat.set_collab_agent_metadata( + other_receiver_thread_id, + Some("Ada".to_string()), + Some("reviewer".to_string()), + ); chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification {