diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index a4fff209ab..d6e030c9a8 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -372,11 +372,7 @@ impl App { .thread_event_channels .get(&thread_id) .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter); - if self - .thread_event_channels - .get(&thread_id) - .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::Live) - { + if !self.should_attach_live_thread_for_selection(thread_id) { return Ok(true); } @@ -613,11 +609,6 @@ impl App { return Ok(()); } } - } else if !self.thread_event_channels.contains_key(&thread_id) && is_replay_only { - self.add_agents_overview_error(format!( - "Agent thread {thread_id} is no longer available." - )); - return Ok(()); } let previous_thread_id = self.active_thread_id; // Notifications already routed to the active channel must reach the old @@ -781,13 +772,13 @@ impl App { } pub(super) fn should_attach_live_thread_for_selection(&self, thread_id: ThreadId) -> bool { - self.thread_event_channels + self.agent_navigation .get(&thread_id) - .is_none_or(|channel| channel.attachment() != ThreadEventAttachment::Live) - && self - .agent_navigation + .is_some_and(|entry| entry.is_closed) + || self + .thread_event_channels .get(&thread_id) - .is_none_or(|entry| !entry.is_closed || self.thread_unavailable(thread_id)) + .is_none_or(|channel| channel.attachment() != ThreadEventAttachment::Live) } pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 4fb6b8ac08..6ffec87971 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -128,6 +128,7 @@ use codex_utils_absolute_path::test_support::PathExt; use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::create_initial_user_message; +use crate::chatwidget::tests::helpers::normalize_snapshot_paths; use crate::chatwidget::tests::helpers::render_bottom_popup; use crate::chatwidget::tests::helpers::set_active_cell; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; @@ -2733,13 +2734,31 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() - "type": "session_meta", "payload": serde_json::to_value(session_meta)?, }); - std::fs::write(rollout_path, format!("{session_meta_line}\n"))?; + let saved_message = format!("Saved work from child-{index}"); + let response_item = serde_json::json!({ + "timestamp": timestamp, + "type": "response_item", + "payload": { + "type": "message", "role": "user", + "content": [{"type": "input_text", "text": saved_message}] + } + }); + let user_message = serde_json::json!({ + "timestamp": timestamp, + "type": "event_msg", + "payload": {"type": "user_message", "message": saved_message, "kind": "plain"} + }); + std::fs::write( + rollout_path, + format!("{session_meta_line}\n{response_item}\n{user_message}\n"), + )?; child_thread_ids.push(child_thread_id); } // Cold-resume the persisted V2 root so its children are registered before selection. + app.config.thread_unload_delay = Duration::ZERO; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( - app.chat_widget.config_ref(), + &app.config, )) .await?; let root = Box::pin(app_server.resume_thread( @@ -2751,20 +2770,82 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() - .await?; app.enqueue_primary_thread_session(root.session, root.turns) .await?; - for (child_thread_id, multi_agent_version) in child_thread_ids + let mut tui = crate::tui::test_support::make_test_tui()?; + for (index, (child_thread_id, multi_agent_version)) in child_thread_ids .iter() .copied() .zip([MultiAgentVersion::V1, MultiAgentVersion::V2]) + .enumerate() { - assert!( - app.attach_live_thread_for_selection(&mut app_server, child_thread_id) - .await? + while app_event_rx.try_recv().is_ok() {} + assert_eq!( + app_server.thread_read(child_thread_id, /*include_turns*/ false).await?.status, + codex_app_server_protocol::ThreadStatus::NotLoaded ); + app.select_agent_thread(&mut tui, &mut app_server, child_thread_id) + .await?; + assert_eq!(app.active_thread_id, Some(child_thread_id)); assert_eq!( app.agent_navigation.is_parent_owned(child_thread_id), multi_agent_version == MultiAgentVersion::V2 ); + assert_app_snapshot!( + format!("selected_unloaded_subagent_{multi_agent_version:?}"), + normalize_snapshot_paths(render_bottom_popup(&app.chat_widget, /*width*/ 80)) + ); + let saved_message = format!("Saved work from child-{index}"); + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + app.transcript_cells.push(Arc::from(cell)); + } + } + assert!( + app.render_transcript_lines_for_reflow(/*width*/ 80) + .lines + .iter() + .any(|line| rendered_line_text(line).contains(&saved_message)) + ); + app.select_agent_thread(&mut tui, &mut app_server, root_thread_id) + .await?; + app_server.thread_unsubscribe(child_thread_id).await?; + let closed = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + while let Some(event) = app_server.next_event().await { + if matches!(&event, + codex_app_server_client::AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::ThreadClosed(closed) + if closed.thread_id == child_thread_id.to_string())) + { + return event; + } + } + panic!("server event stream ended before the child closed"); + }) + .await?; + while app_event_rx.try_recv().is_ok() {} + app.select_agent_thread(&mut tui, &mut app_server, child_thread_id) + .await?; + // A queued close from the old runtime must not undo the new attachment. + app.handle_app_server_event(&app_server, closed).await; + app.drain_active_thread_events(&mut tui).await?; + assert_eq!(app.active_thread_id, Some(child_thread_id)); + assert!(!matches!( + app_server.thread_read(child_thread_id, /*include_turns*/ false).await?.status, + codex_app_server_protocol::ThreadStatus::NotLoaded + )); + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + app.transcript_cells.push(Arc::from(cell)); + } + } + assert!( + app.render_transcript_lines_for_reflow(/*width*/ 80) + .lines + .iter() + .any(|line| rendered_line_text(line).contains(&saved_message)) + ); } + app.select_agent_thread(&mut tui, &mut app_server, root_thread_id) + .await?; app.agent_navigation .record_sub_agent_activity(SubAgentActivityDisplay { @@ -2792,7 +2873,6 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() - assert!(!app.agent_navigation.is_parent_owned(child_thread_ids[0])); assert!(app.agent_navigation.is_parent_owned(child_thread_ids[1])); - let mut tui = crate::tui::test_support::make_test_tui()?; app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[0]) .await?; while app_event_rx.try_recv().is_ok() {} @@ -2932,32 +3012,6 @@ fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> }) } -#[tokio::test] -async fn should_attach_live_thread_for_selection_skips_closed_metadata_only_threads() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - app.agent_navigation.upsert( - thread_id, - Some("Ghost".to_string()), - Some("worker".to_string()), - /*is_closed*/ true, - ); - - assert!(!app.should_attach_live_thread_for_selection(thread_id)); - - app.agent_navigation.upsert( - thread_id, - Some("Ghost".to_string()), - Some("worker".to_string()), - /*is_closed*/ false, - ); - assert!(app.should_attach_live_thread_for_selection(thread_id)); - - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); - assert!(!app.should_attach_live_thread_for_selection(thread_id)); -} - #[tokio::test] async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> { let mut app = Box::pin(make_test_app()).await; diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V1.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V1.snap new file mode 100644 index 0000000000..1252049489 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V1.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/tests.rs +assertion_line: 2769 +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- +› Ask Codex to do anything + + GPT-6.1-Sol default · /tmp/project · child-0 [worker] diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V2.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V2.snap new file mode 100644 index 0000000000..b36b6636f4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__selected_unloaded_subagent_V2.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/tests.rs +assertion_line: 2769 +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- +› Viewing sub-agent — direct input is disabled + + GPT-6.1-Sol default · /tmp/project · child-1 [worker]