Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions codex-rs/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,12 @@ core_test_support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
assert_matches = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
futures = { workspace = true }
insta = { workspace = true }
pretty_assertions = { workspace = true }
rand = { workspace = true }
serial_test = { workspace = true }
tokio-tungstenite = { workspace = true }
vt100 = { workspace = true }
uuid = { workspace = true }
wiremock = { workspace = true }
2 changes: 0 additions & 2 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,6 @@ impl App {
match self
.replace_chat_widget_with_app_server_thread(
tui,
app_server,
forked,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
Expand Down Expand Up @@ -283,7 +282,6 @@ impl App {
match self
.replace_chat_widget_with_app_server_thread(
tui,
app_server,
forked,
ThreadAttachPresentation::PromptEdit,
/*initial_user_message*/ None,
Expand Down
13 changes: 13 additions & 0 deletions codex-rs/tui/src/app/loaded_threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use crate::app_server_session::thread_blocks_direct_input;
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 std::collections::HashMap;
Expand All @@ -31,6 +32,8 @@ pub(crate) struct LoadedSubagentThread {
pub(crate) agent_role: Option<String>,
pub(crate) agent_path: Option<String>,
pub(crate) blocks_direct_input: bool,
pub(crate) is_running: bool,
pub(crate) is_closed: bool,
}

/// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent.
Expand Down Expand Up @@ -87,6 +90,8 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
.remove(&thread_id)
.map(|thread| LoadedSubagentThread {
blocks_direct_input: thread_blocks_direct_input(&thread),
is_running: matches!(&thread.status, ThreadStatus::Active { .. }),
is_closed: matches!(&thread.status, ThreadStatus::NotLoaded),
thread_id,
agent_nickname: thread.agent_nickname,
agent_role: thread.agent_role,
Expand Down Expand Up @@ -196,6 +201,9 @@ mod tests {
child.agent_nickname = Some("Scout".to_string());
child.agent_role = Some("explorer".to_string());
child.can_accept_direct_input = Some(true);
child.status = ThreadStatus::Active {
active_flags: Vec::new(),
};

let mut grandchild = test_thread(
grandchild_thread_id,
Expand All @@ -204,6 +212,7 @@ mod tests {
grandchild.agent_nickname = Some("Atlas".to_string());
grandchild.agent_role = Some("worker".to_string());
grandchild.can_accept_direct_input = Some(false);
grandchild.status = ThreadStatus::NotLoaded;
let unrelated_child = test_thread(
unrelated_child_id,
thread_spawn_source(unrelated_parent_id, /*depth*/ 1, "Other", "researcher"),
Expand All @@ -228,13 +237,17 @@ mod tests {
agent_nickname: Some("Scout".to_string()),
agent_role: Some("explorer".to_string()),
agent_path: None,
is_running: true,
is_closed: false,
},
LoadedSubagentThread {
blocks_direct_input: true,
thread_id: grandchild_thread_id,
agent_nickname: Some("Atlas".to_string()),
agent_role: Some("worker".to_string()),
agent_path: None,
is_running: false,
is_closed: true,
},
]
);
Expand Down
1 change: 0 additions & 1 deletion codex-rs/tui/src/app/safety_buffering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ impl App {
if let Err(err) = self
.replace_chat_widget_with_app_server_thread(
tui,
app_server,
started,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
Expand Down
61 changes: 46 additions & 15 deletions codex-rs/tui/src/app/session_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,25 @@ use super::*;
use crate::app_server_session::source_agent_path;
use crate::app_server_session::thread_blocks_direct_input;
use codex_config::types::ResumeCwdMode;
use std::collections::HashSet;

#[derive(Clone, Copy)]
pub(super) enum ThreadAttachPresentation {
SessionLineage,
PromptEdit,
}

/// Reports whether a loaded-thread backfill completed and which descendants already had their
/// liveness metadata refreshed, allowing the picker to skip duplicate `thread/read` requests.
#[derive(Default)]
pub(super) struct LoadedSubagentBackfill {
pub(super) completed: bool,
pub(super) refreshed_thread_ids: HashSet<ThreadId>,
}

impl App {
pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) {
self.backfill_loaded_subagent_threads(app_server).await;
let backfill = self.backfill_loaded_subagent_threads(app_server).await;
// V2 subagents are identified by canonical paths observed from activity events or loaded
// thread metadata. A buffered active turn is positive liveness evidence; a completed
// snapshot is terminal evidence. An empty store does not clear a successful spawn hint.
Expand Down Expand Up @@ -46,7 +55,7 @@ impl App {
} else if has_terminal_snapshot {
self.agent_navigation.mark_stopped(thread_id);
}
} else {
} else if !backfill.refreshed_thread_ids.contains(&thread_id) {
self.refresh_agent_picker_thread_liveness(app_server, thread_id)
.await;
}
Expand Down Expand Up @@ -92,6 +101,7 @@ impl App {
for thread_id in thread_ids {
if path_backed_thread_ids.contains(&thread_id)
|| self.side_threads.contains_key(&thread_id)
|| backfill.refreshed_thread_ids.contains(&thread_id)
{
continue;
}
Expand Down Expand Up @@ -631,7 +641,6 @@ impl App {
if let Err(err) = self
.replace_chat_widget_with_app_server_thread(
tui,
app_server,
started,
ThreadAttachPresentation::SessionLineage,
initial_user_message,
Expand Down Expand Up @@ -666,7 +675,6 @@ impl App {
pub(super) async fn replace_chat_widget_with_app_server_thread(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
started: AppServerStartedThread,
presentation: ThreadAttachPresentation,
initial_user_message: Option<crate::chatwidget::UserMessage>,
Expand All @@ -690,16 +698,15 @@ impl App {
presentation,
)
.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.
/// Called when opening the `/agent` picker and after resuming a thread so that the picker and
/// keyboard navigation are pre-populated even if the TUI did not witness the original spawn
/// events. Fresh and forked threads cannot have pre-existing descendants.
///
/// 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
Expand All @@ -708,9 +715,9 @@ impl App {
pub(super) async fn backfill_loaded_subagent_threads(
&mut self,
app_server: &mut AppServerSession,
) -> bool {
) -> LoadedSubagentBackfill {
let Some(primary_thread_id) = self.primary_thread_id else {
return false;
return LoadedSubagentBackfill::default();
};

let loaded_thread_ids = match app_server
Expand All @@ -723,7 +730,7 @@ impl App {
Ok(response) => response.data,
Err(err) => {
tracing::warn!(%err, "failed to list loaded threads for subagent backfill");
return false;
return LoadedSubagentBackfill::default();
}
};

Expand Down Expand Up @@ -751,23 +758,43 @@ impl App {
}
}

let mut refreshed_thread_ids = HashSet::new();
for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) {
let agent_path = thread.agent_path;
let has_live_channel = self
.thread_event_channels
.get(&thread.thread_id)
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::Live);
let is_closed = !has_live_channel && thread.is_closed;
if thread.blocks_direct_input {
self.agent_navigation.mark_parent_owned(thread.thread_id);
}
self.upsert_agent_picker_thread(
thread.thread_id,
thread.agent_nickname,
thread.agent_role,
/*is_closed*/ false,
is_closed,
);
self.agent_navigation
.set_agent_path(thread.thread_id, agent_path);
// A live channel can have an empty store after a successful spawn. Only apply server
// status for channels that would otherwise need another liveness read.
if !has_live_channel {
if thread.is_running {
self.agent_navigation.mark_running(thread.thread_id);
} else {
self.agent_navigation
.set_running(thread.thread_id, /*is_running*/ false);
}
refreshed_thread_ids.insert(thread.thread_id);
}
}
self.sync_active_agent_label();

!had_read_error
LoadedSubagentBackfill {
completed: !had_read_error,
refreshed_thread_ids,
}
}

/// Returns the adjacent thread id for keyboard navigation, backfilling from the server if the
Expand Down Expand Up @@ -796,7 +823,11 @@ impl App {
return None;
}

if self.backfill_loaded_subagent_threads(app_server).await {
if self
.backfill_loaded_subagent_threads(app_server)
.await
.completed
{
self.last_subagent_backfill_attempt = Some(primary_thread_id);
}
self.agent_navigation
Expand Down Expand Up @@ -932,14 +963,14 @@ impl App {
match self
.replace_chat_widget_with_app_server_thread(
tui,
app_server,
resumed,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
)
.await
{
Ok(()) => {
self.backfill_loaded_subagent_threads(app_server).await;
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
Expand Down
27 changes: 25 additions & 2 deletions codex-rs/tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ mod model_catalog;
mod plugin_catalog;
mod rate_limits;
mod safety_buffering;
#[path = "tests/session_lifecycle_requests.rs"]
mod session_lifecycle_requests;
mod session_summary;
mod startup;

Expand Down Expand Up @@ -1791,7 +1793,29 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
child_thread_ids.push(child_thread_id);
}

assert!(app.backfill_loaded_subagent_threads(&mut app_server).await);
app.agent_navigation
.record_sub_agent_activity(SubAgentActivityDisplay {
thread_id: child_thread_ids[0],
agent_path: "/root/child-0".to_string(),
is_running_hint: true,
});
app.thread_event_channels.remove(&child_thread_ids[1]);
let backfill = app.backfill_loaded_subagent_threads(&mut app_server).await;
assert!(backfill.completed);
assert_eq!(
backfill.refreshed_thread_ids,
[child_thread_ids[1]].into_iter().collect()
);
assert_eq!(
app.agent_navigation.get(&child_thread_ids[0]),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("child-0".to_string()),
agent_role: Some("worker".to_string()),
agent_path: Some("/root/child-0".to_string()),
is_running: true,
is_closed: false,
})
);
assert!(!app.agent_navigation.is_parent_owned(child_thread_ids[0]));
assert!(app.agent_navigation.is_parent_owned(child_thread_ids[1]));

Expand Down Expand Up @@ -1833,7 +1857,6 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
assert!(resumed.blocks_direct_input);
app.replace_chat_widget_with_app_server_thread(
&mut tui,
&mut app_server,
resumed,
crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
Expand Down
1 change: 0 additions & 1 deletion codex-rs/tui/src/app/tests/safety_buffering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,6 @@ goals = true
let source_thread_id = started.session.thread_id;
app.replace_chat_widget_with_app_server_thread(
&mut tui,
&mut app_server,
started,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
Expand Down
Loading
Loading