fix(tui_app_server): fix remote subagent switching and agent names (#15513)

## TL;DR

This PR changes the `tui_app_server` _path_ in the following ways:

- add missing feature to show agent names (shows only UUIDs today) 
- add `Cmd/Alt+Arrows` navigation between agent conversations

## Problem

When the TUI connects to a remote app server, collab agent tool-call
items (spawn, wait, delegate, etc.) render thread UUIDs instead of
human-readable agent names because the `ChatWidget` never receives
nickname/role metadata for receiver threads. Separately, keyboard
next/previous agent navigation silently does nothing when the local
`AgentNavigationState` cache has not yet been populated with subagent
threads that the remote server already knows about.

Both issues share a root cause: in the remote (app-server) code path the
TUI never proactively fetches thread metadata. In the local code path
this metadata arrives naturally via spawn events the TUI itself
orchestrates, but in the remote path those events were processed by a
different client and the TUI only sees the resulting collab tool-call
notifications.

## Mental model

Collab agent tool-call notifications reference receiver threads by id,
but carry no nickname or role. The TUI needs that metadata in two
places:

1. **Rendering** -- `ChatWidget` converts `CollabAgentToolCall` items
into history cells. Without metadata, agent status lines show raw UUIDs.
2. **Navigation** -- `AgentNavigationState` tracks known threads for the
`/agent` picker and keyboard cycling. Without entries for remote
subagents, next/previous has nowhere to go.

This change closes the gap with two complementary strategies:

- **Eager hydration**: when any notification carries
`receiver_thread_ids`, the TUI fetches metadata (`thread/read`) for
threads it has not yet cached before the notification is rendered.
- **Backfill on thread switch**: when the user resumes, forks, or starts
a new app-server thread, the TUI fetches the full `thread/loaded/list`,
walks the parent-child spawn tree, and registers every descendant
subagent in both the navigation cache and the `ChatWidget` metadata map.

A new `collab_agent_metadata` side-table in `ChatWidget` stores
nickname/role keyed by `ThreadId`, kept in sync by `App` whenever it
calls `upsert_agent_picker_thread`. The `replace_chat_widget` helper
re-seeds this map from `AgentNavigationState` so that thread switches
(which reconstruct the widget) do not lose previously discovered
metadata.

## Non-goals

- This change does not alter the local (non-app-server) collab code
path. That path already receives metadata via spawn events and is
unaffected.
- No new protocol messages are introduced. The change uses existing
`thread/read` and `thread/loaded/list` RPCs.
- No changes to how `AgentNavigationState` orders or cycles through
threads. The traversal logic is unchanged; only the population of
entries is extended.

## Tradeoffs

- **Extra RPCs on notification path**:
`hydrate_collab_agent_metadata_for_notification` issues a `thread/read`
for each unknown receiver thread before the notification is forwarded to
rendering. This adds latency on the notification path but only fires
once per thread (the result is cached). The alternative -- rendering
first and backfilling names later -- would cause visible flicker as
UUIDs are replaced with names.
- **Backfill fetches all loaded threads**:
`backfill_loaded_subagent_threads` fetches the full loaded-thread list
and walks the spawn tree even when the user may only care about one
subagent. This is simple and correct but O(loaded_threads) per thread
switch. For typical session sizes this is negligible; it could become a
concern for sessions with hundreds of subagents.
- **Metadata duplication**: agent nickname/role is now stored in both
`AgentNavigationState` (for picker/label) and
`ChatWidget::collab_agent_metadata` (for rendering). The two are kept in
sync through `upsert_agent_picker_thread` and `replace_chat_widget`, but
there is no compile-time enforcement of this coupling.

## Architecture

### New module: `app::loaded_threads`

Pure function `find_loaded_subagent_threads_for_primary` that takes a
flat list of `Thread` objects and a primary thread id, then walks the
`SessionSource::SubAgent` parent-child edges to collect all transitive
descendants. Returns a sorted vec of `LoadedSubagentThread` (thread_id +
nickname + role). No async, no side effects -- designed for unit
testing.

### New methods on `App`

| Method | Purpose |
|--------|---------|
| `collab_receiver_thread_ids` | Extracts `receiver_thread_ids` from
`ItemStarted` / `ItemCompleted` collab notifications |
| `hydrate_collab_agent_metadata_for_notification` | Fetches and caches
metadata for unknown receiver threads before a notification is rendered
|
| `backfill_loaded_subagent_threads` | Bulk-fetches all loaded threads
and registers descendants of the primary thread |
| `adjacent_thread_id_with_backfill` | Attempts navigation, falls back
to backfill if the cache has no adjacent entry |
| `replace_chat_widget` | Replaces the widget and re-seeds its metadata
map from `AgentNavigationState` |

### New state in `ChatWidget`

`collab_agent_metadata: HashMap<ThreadId, CollabAgentMetadata>` -- a
lookup table that rendering functions consult to attach human-readable
names to collab tool-call items. Populated externally by `App` via
`set_collab_agent_metadata`.

### New method on `AppServerSession`

`thread_loaded_list` -- thin wrapper around
`ClientRequest::ThreadLoadedList`.

## Observability

- `tracing::warn` on invalid thread ids during hydration and backfill.
- `tracing::warn` on failed `thread/read` or `thread/loaded/list` RPCs
(with thread id and error).
- No new metrics or feature flags.

## Tests

-
**`loaded_threads::tests::finds_loaded_subagent_tree_for_primary_thread`**
-- unit test for the spawn-tree walk: verifies child and grandchild are
included, unrelated threads are excluded, and metadata is carried
through.
-
**`app::tests::replace_chat_widget_reseeds_collab_agent_metadata_for_replay`**
-- integration test that creates a `ChatWidget`, replaces it via
`replace_chat_widget`, replays a collab wait notification, and asserts
the rendered history cell contains the agent name rather than a UUID.
- **Updated snapshot** `app_server_collab_wait_items_render_history` --
the existing collab wait rendering test now sets metadata before sending
notifications, so the snapshot shows `Robie [explorer]` / `Ada
[reviewer]` instead of raw thread ids.

---------

Co-authored-by: Eric Traut <etraut@openai.com>
This commit is contained in:
Felipe Coury
2026-03-25 15:50:42 -03:00
committed by GitHub
Unverified
parent 6566ab7e02
commit 79359fb5e7
6 changed files with 672 additions and 32 deletions
+316 -15
View File
@@ -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 &notification.item {
ThreadItem::CollabAgentToolCall {
receiver_thread_ids,
..
} => Some(receiver_thread_ids),
_ => None,
},
ServerNotification::ItemCompleted(notification) => match &notification.item {
ThreadItem::CollabAgentToolCall {
receiver_thread_ids,
..
} => Some(receiver_thread_ids),
_ => None,
},
_ => None,
}
}
fn convert_via_json<T, U>(value: T) -> Option<U>
where
T: serde::Serialize,
@@ -951,6 +979,7 @@ pub(crate) struct App {
active_thread_id: Option<ThreadId>,
active_thread_rx: Option<mpsc::Receiver<ThreadBufferedEvent>>,
primary_thread_id: Option<ThreadId>,
last_subagent_backfill_attempt: Option<ThreadId>,
primary_session_configured: Option<ThreadSessionState>,
pending_primary_events: VecDeque<ThreadBufferedEvent>,
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<String>,
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<ThreadId> {
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;
@@ -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<String>,
pub(crate) agent_role: Option<String>,
}
/// 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<Thread>,
primary_thread_id: ThreadId,
) -> Vec<LoadedSubagentThread> {
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<LoadedSubagentThread> = 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()),
},
]
);
}
}
@@ -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<ThreadLoadedListResponse> {
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,
+115 -13
View File
@@ -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<String>,
running_commands: HashMap<String, RunningCommand>,
collab_agent_metadata: HashMap<ThreadId, CollabAgentMetadata>,
pending_collab_spawn_requests: HashMap<String, multi_agents::SpawnRequestSummary>,
suppressed_exec_calls: HashSet<String>,
skills_all: Vec<ProtocolSkillMetadata>,
@@ -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<String>,
agent_role: Option<String>,
}
#[cfg_attr(not(test), allow(dead_code))]
enum CodexOpTarget {
Direct(UnboundedSender<Op>),
@@ -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<String, AppServerCollabAgentState>,
collab_agent_metadata: &HashMap<ThreadId, CollabAgentMetadata>,
) -> (Vec<CollabAgentStatusEntry>, HashMap<ThreadId, AgentStatus>) {
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<ThreadId, CollabAgentMetadata>,
) -> Vec<CollabAgentRef> {
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<String>,
agent_role: Option<String>,
) {
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,
@@ -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
@@ -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 {