Skip to content
Closed
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
1 change: 1 addition & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ 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::loaded_threads::thread_spawn_parent_thread_id;
use self::pending_interactive_replay::PendingInteractiveReplayState;
use self::platform_actions::*;
use self::session_recap::SessionRecapScheduler;
Expand Down
7 changes: 6 additions & 1 deletion codex-rs/tui/src/app/loaded_threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
loaded_threads
}

fn thread_spawn_parent_thread_id(
/// Returns the spawn parent thread id when `source` is a genuine `SubAgent(ThreadSpawn { .. })`
/// edge, or `None` for any other origin (CLI, VSCode, scheduled top-level threads, etc.).
///
/// Shared by the resume-time spawn-tree walk and the realtime `ThreadStarted` picker guard so
/// both agree on what counts as subagent lineage.
pub(crate) fn thread_spawn_parent_thread_id(
source: &codex_app_server_protocol::SessionSource,
) -> Option<ThreadId> {
let value = serde_json::to_value(source).ok()?;
Expand Down
141 changes: 140 additions & 1 deletion codex-rs/tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2872,6 +2872,18 @@ async fn inactive_thread_approval_badge_clears_after_turn_completion_notificatio
Ok(())
}

/// Builds a genuine `SubAgent(ThreadSpawn { .. })` source chaining to `parent_thread_id`, the
/// lineage the realtime agent-picker guard requires before registering a `ThreadStarted` thread.
fn subagent_thread_spawn_source(parent_thread_id: ThreadId) -> SessionSource {
SessionSource::SubAgent(codex_protocol::protocol::SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})
}

#[tokio::test]
async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> {
let mut app = make_test_app().await;
Expand Down Expand Up @@ -2931,7 +2943,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
path: Some(rollout_path.clone()),
cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
source: subagent_thread_spawn_source(main_thread_id),
thread_source: None,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
Expand Down Expand Up @@ -3052,6 +3064,133 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
Ok(())
}

/// Builds a `ThreadStarted` notification with an explicit `source`, used to distinguish scheduled
/// top-level threads from genuine subagents when exercising the realtime agent-picker guard.
fn thread_started_with_source(
thread_id: ThreadId,
source: SessionSource,
agent_nickname: Option<String>,
agent_role: Option<String>,
) -> ServerNotification {
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
parent_thread_id: None,
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
status: codex_app_server_protocol::ThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(),
source,
thread_source: None,
agent_nickname,
agent_role,
git_info: None,
auth_profile: None,
auth_profile_kind: AuthProfileKind::Unknown,
name: None,
turns: Vec::new(),
},
})
}

/// A global scheduled prompt resumes its own top-level thread and the app server broadcasts that
/// thread's `ThreadStarted` to every connected client. An unrelated TUI session must still buffer
/// the thread channel (so schedule runtime and the owning session are unaffected) but must not
/// expose the scheduled thread as a subagent in its agent picker, while genuine subagents spawned
/// beneath the primary — including deeper ones that chain through a registered entry — are kept.
#[tokio::test]
async fn scheduled_top_level_thread_started_is_isolated_from_agent_picker() -> Result<()> {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000501").expect("valid thread");
let scheduled_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000502").expect("valid thread");
let subagent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000503").expect("valid thread");
let deep_subagent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000504").expect("valid thread");

let primary_cwd = test_path_buf("/tmp/main").abs();
let primary_session = test_thread_session(main_thread_id, primary_cwd.to_path_buf());
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.primary_session_configured = Some(primary_session.clone());
app.thread_event_channels.insert(
main_thread_id,
ThreadEventChannel::new_with_session(
/*capacity*/ 4,
primary_session.clone(),
Vec::new(),
),
);

app.enqueue_thread_notification(
scheduled_thread_id,
thread_started_with_source(
scheduled_thread_id,
SessionSource::Cli,
Some("Nightly".to_string()),
Some("scheduler".to_string()),
),
)
.await?;

assert!(
app.thread_event_channels.contains_key(&scheduled_thread_id),
"scheduled thread must still get a buffered channel so schedule runtime is unaffected"
);
assert_eq!(
app.agent_navigation.get(&scheduled_thread_id),
None,
"a scheduled top-level thread must not be surfaced as a subagent in an unrelated session"
);

app.enqueue_thread_notification(
subagent_thread_id,
thread_started_with_source(
subagent_thread_id,
subagent_thread_spawn_source(main_thread_id),
Some("Scout".to_string()),
Some("explorer".to_string()),
),
)
.await?;
assert_eq!(
app.agent_navigation.get(&subagent_thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Scout".to_string()),
agent_role: Some("explorer".to_string()),
thread_name: None,
is_closed: false,
}),
"a genuine subagent spawned beneath the primary must still be registered"
);

app.enqueue_thread_notification(
deep_subagent_thread_id,
thread_started_with_source(
deep_subagent_thread_id,
subagent_thread_spawn_source(subagent_thread_id),
Some("Atlas".to_string()),
Some("worker".to_string()),
),
)
.await?;
assert!(
app.agent_navigation.get(&deep_subagent_thread_id).is_some(),
"a deeper subagent chaining to the primary via a registered entry must be registered"
);

Ok(())
}

/// `thread/read` is metadata/replay hydration and does not return a fresh
/// server-authored `PermissionProfile`, so it must not reuse the cached primary
/// session profile after swapping in the read thread's cwd.
Expand Down
47 changes: 38 additions & 9 deletions codex-rs/tui/src/app/thread_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,18 +1052,47 @@ impl App {
}
session.message_history = None;
session.rollout_path = rollout_path;
self.upsert_agent_picker_thread(
thread_id,
notification.thread.agent_nickname.clone(),
notification.thread.agent_role.clone(),
/*is_closed*/ false,
);
self.agent_navigation
.set_thread_name(thread_id, notification.thread.name.clone());
self.sync_active_agent_label();
if self.thread_started_belongs_to_agent_picker(thread_id, &notification.thread) {
self.upsert_agent_picker_thread(
thread_id,
notification.thread.agent_nickname.clone(),
notification.thread.agent_role.clone(),
/*is_closed*/ false,
);
self.agent_navigation
.set_thread_name(thread_id, notification.thread.name.clone());
self.sync_active_agent_label();
}
Some(session)
}

/// Returns whether a `ThreadStarted` thread should be surfaced in this session's agent picker.
///
/// Only the primary thread and genuine subagents spawned beneath it belong in the picker. A
/// global scheduled prompt resumes its owning top-level thread and the app server broadcasts
/// that thread's `ThreadStarted` to every connected client; such a thread has no `ThreadSpawn`
/// lineage (its `source` is not `SubAgent`, and `parent_thread_id` is unset), so unrelated
/// sessions must not register it as a subagent. Its thread channel/session buffering is still
/// established by the caller, so schedule runtime and the owning session are unaffected.
///
/// Deep subagents are accepted when their spawn parent chains to the primary through an
/// already-registered navigation entry, matching the resume-time walk in
/// `find_loaded_subagent_threads_for_primary`.
fn thread_started_belongs_to_agent_picker(
&self,
thread_id: ThreadId,
thread: &codex_app_server_protocol::Thread,
) -> bool {
if self.primary_thread_id == Some(thread_id) {
return true;
}
let Some(parent_thread_id) = thread_spawn_parent_thread_id(&thread.source) else {
return false;
};
self.primary_thread_id == Some(parent_thread_id)
|| self.agent_navigation.get(&parent_thread_id).is_some()
}

pub(super) async fn enqueue_thread_request(
&mut self,
thread_id: ThreadId,
Expand Down
Loading