From 746798b2f752888935a137fe0328dd4cf2b7a735 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 31 Aug 2026 21:25:27 +0000 Subject: [PATCH] Restore agent navigation after TUI reconnects (#41918) ## What changed - Keep the agent command center visible with its cached inventory and draft while reconnecting, then refresh it without losing the selected task. - Allow cached, unavailable conversations to reattach when selected while preserving their drafts, recap progress, and runtime permissions. - Report an unsubmitted rename when its target disappears during refresh, and continue to quarantine queued input instead of resending it automatically. ## Testing - Add a Unix socket replacement test covering command-center recovery with and without an active conversation, failed reconnects, inventory changes, and subsequent live task input. GitOrigin-RevId: 774aca2dfb8358e9abd6a2e2ef0d509646648f66 --- codex-rs/tui/src/app/agents_overview.rs | 22 +- codex-rs/tui/src/app/reconnect.rs | 70 ++- codex-rs/tui/src/app/session_lifecycle.rs | 85 ++- codex-rs/tui/src/app/startup.rs | 6 +- codex-rs/tui/src/app/tests.rs | 4 + .../app/tests/navigation_reconnect_tests.rs | 511 ++++++++++++++++++ ...s__disconnect__offline_command_center.snap | 2 +- ...connect__daemon_command_center_failed.snap | 28 + ...t__daemon_command_center_reconnecting.snap | 28 + ...daemon_command_center_vanished_rename.snap | 28 + ..._reconnected_unavailable_conversation.snap | 11 + 11 files changed, 752 insertions(+), 43 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index b43a2cd00c..3f15e934fc 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -34,6 +34,7 @@ pub(super) struct AgentsOverviewState { pub(super) refresh_pending: bool, pub(super) rendered_full_screen: bool, pub(super) visible_thread_ids: Vec, + pub(super) threads: Vec, pub(super) view_state: Arc>, pub(super) input_states: HashMap, @@ -93,7 +94,10 @@ impl App { self.agents_overview.request_id = None; self.agents_overview.refresh_pending = false; - let view = self.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + let view = self.agents_overview_view( + self.agents_overview.threads.clone(), + /*selected_thread_id*/ None, + ); self.agents_overview.visible_thread_ids = view.thread_ids(); self.chat_widget.show_bottom_pane_view(Box::new(view)); self.refresh_agents_overview_threads(app_server); @@ -240,6 +244,7 @@ impl App { return; } }; + self.agents_overview.threads = threads.clone(); let view = self.agents_overview_view(threads, selected_thread_id); self.agents_overview.visible_thread_ids = view.thread_ids(); if selected_thread_id @@ -247,6 +252,13 @@ impl App { && let Ok(mut state) = self.agents_overview.view_state.lock() && state.renaming { + self.chat_widget.add_info_message( + format!( + "The rename target disappeared. Unsubmitted title: {}", + state.input + ), + /*hint*/ None, + ); state.renaming = false; state.input.clear(); } @@ -320,7 +332,9 @@ impl App { app_server: &mut AppServerSession, root_thread_id: ThreadId, ) -> color_eyre::Result { - if self.current_displayed_thread_id() == Some(root_thread_id) { + if self.current_displayed_thread_id() == Some(root_thread_id) + && !self.thread_unavailable(root_thread_id) + { return Ok(AppRunControl::Continue); } @@ -505,7 +519,9 @@ impl App { } } - if self.current_displayed_thread_id() != Some(root_thread_id) { + if self.current_displayed_thread_id() != Some(root_thread_id) + || self.thread_unavailable(root_thread_id) + { self.select_agent_thread_and_discard_side(tui, app_server, root_thread_id) .await?; } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 3f6cf928ef..59b997e507 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -1,7 +1,6 @@ //! TUI-only out-of-process reconnection. Each attempt initializes a fresh client and rejoins existing //! threads using ordinary resume/history semantics; no user operation is retried. -//! Offline input and old async completions are quarantined. Cached conversations stay read-only -//! until they can be reattached; command-center recovery is handled separately. +//! Offline input and old async completions are quarantined. use super::*; use crate::app_server_session::ResumeModelSettings; @@ -113,6 +112,36 @@ pub(super) async fn reconnect( } impl App { + // Preserve local choices for future input, without replaying failed settings writes or + // changing the server's authorization for work that was already admitted. + pub(super) fn restore_runtime_permissions( + &self, + session: &mut ThreadSessionState, + cached: &ThreadSessionState, + ) { + if self.current_displayed_thread_id() != Some(session.thread_id) + && self.primary_thread_id != Some(session.thread_id) + { + return; + } + // Side conversations can replace the app-wide overrides. Only copy choices + // that still match this conversation's own cached settings. + if let Some(policy) = self.runtime_approval_policy_override + && policy == cached.approval_policy + { + session.approval_policy = policy; + } + if let Some(profile) = &self.runtime_permission_profile_override + && profile.permission_profile == cached.permission_profile + && profile.active_permission_profile == cached.active_permission_profile + && self.config.approvals_reviewer == cached.approvals_reviewer + { + session.permission_profile = profile.permission_profile.clone(); + session.active_permission_profile = profile.active_permission_profile.clone(); + session.approvals_reviewer = self.config.approvals_reviewer; + } + } + pub(super) fn thread_unavailable(&self, id: ThreadId) -> bool { !matches!(self.app_server_target, AppServerTarget::Embedded) && self @@ -146,7 +175,7 @@ impl App { .is_some() { if let Ok(mut state) = self.agents_overview.view_state.lock() { - state.connection_notice = Some("Disconnected — agent list is stale"); + state.connection_notice = Some("Reconnecting — agent list is stale"); if let Some(task) = state.refresh_task.take() { task.abort(); } @@ -181,6 +210,10 @@ impl App { bootstrap, thread, } = connected; + let selected = self + .chat_widget + .selected_index_for_present_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID) + .and_then(|index| self.agents_overview.visible_thread_ids.get(index).copied()); let displayed = self.current_displayed_thread_id(); let mut input = self.chat_widget.capture_thread_input_state(); if let Some(input) = input.as_mut() { @@ -249,29 +282,10 @@ impl App { } if let Some(mut started) = thread { let id = started.session.thread_id; - // A lost settings response can leave the server snapshot behind the user's choice. - // Side conversations can replace the app-wide overrides, so only restore choices - // that still match this conversation's own cached settings. - if (self.current_displayed_thread_id() == Some(id) - || self.primary_thread_id == Some(id)) - && let Some(channel) = self.thread_event_channels.get(&id) + if let Some(channel) = self.thread_event_channels.get(&id) && let Some(cached) = channel.store.lock().await.session.as_ref() { - if let Some(policy) = self.runtime_approval_policy_override - && policy == cached.approval_policy - { - started.session.approval_policy = policy; - } - if let Some(profile) = &self.runtime_permission_profile_override - && profile.permission_profile == cached.permission_profile - && profile.active_permission_profile == cached.active_permission_profile - && self.config.approvals_reviewer == cached.approvals_reviewer - { - started.session.permission_profile = profile.permission_profile.clone(); - started.session.active_permission_profile = - profile.active_permission_profile.clone(); - started.session.approvals_reviewer = self.config.approvals_reviewer; - } + self.restore_runtime_permissions(&mut started.session, cached); } self.agents_overview.input_states.remove(&id); if started @@ -329,6 +343,14 @@ impl App { self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.chat_widget.restore_reconnected_input(input); } + if self.reconnect.presentation == ReconnectPresentation::Overview { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.connection_notice = None; + } + let view = self.agents_overview_view(self.agents_overview.threads.clone(), selected); + self.chat_widget.show_bottom_pane_view(Box::new(view)); + self.refresh_agents_overview_threads(app_server); + } // Only accept fresh task-tool calls once this connection and its event queue are adopted. if let ThreadToolTransport::Mcp(server) = app_server.thread_tool_transport() { server.reconnect(app_server.request_handle(), self.app_event_tx.clone()); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 87dafc9351..6b670d63cf 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -367,7 +367,11 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result { - if self.thread_event_channels.contains_key(&thread_id) { + if self + .thread_event_channels + .get(&thread_id) + .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::Live) + { return Ok(true); } @@ -380,6 +384,14 @@ impl App { .await { Ok(started) => { + if let Some(entry) = self.agent_navigation.get(&thread_id).cloned() { + self.upsert_agent_picker_thread( + thread_id, + entry.agent_nickname, + entry.agent_role, + /*is_closed*/ false, + ); + } if started.blocks_direct_input { self.agent_navigation.mark_parent_owned(thread_id); } @@ -417,18 +429,40 @@ impl App { )); } let mut session = self.session_state_for_thread_read(thread_id, &thread).await; + // Reads have no settings. Keep this cached thread's permissions rather than + // inferring them from the conversation that is currently displayed. + if let Some(channel) = self.thread_event_channels.get(&thread_id) + && let Some(cached) = channel.store.lock().await.session.as_ref() + { + session.approval_policy = cached.approval_policy; + session.permission_profile = cached.permission_profile.clone(); + session.active_permission_profile = cached.active_permission_profile.clone(); + session.approvals_reviewer = cached.approvals_reviewer; + } // `thread/read` can seed replay state, but it does not attach the app-server // listener that `thread/resume` establishes, so treat this path as replay-only. session.model.clear(); (session, turns, false) } }; + let recap_progress = + if live_attached && let Some(channel) = self.thread_event_channels.remove(&thread_id) { + let store = channel.store.lock().await; + if let Some(input) = store.input_state.clone() { + self.agents_overview.input_states.insert(thread_id, input); + } + store.recap_progress() + } else { + Default::default() + }; let channel = self.ensure_thread_channel(thread_id); if !live_attached { channel.mark_replay_only(); } let mut store = channel.store.lock().await; store.set_session(session, turns); + store.merge_recap_progress(recap_progress); + store.rebase_buffer_after_session_refresh(); Ok(live_attached) } @@ -471,8 +505,22 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result<()> { + let cached_session = if self.thread_unavailable(thread_id) { + self.thread_event_channels[&thread_id] + .store + .lock() + .await + .session + .clone() + } else { + None + }; if self.active_thread_id == Some(thread_id) { - return Ok(()); + if !self.thread_unavailable(thread_id) { + return Ok(()); + } + // Detach the cached receiver before a successful attachment replaces its channel. + self.store_active_thread_receiver().await; } // A tracked side thread stays loaded until it is explicitly discarded and already has a @@ -491,7 +539,6 @@ impl App { .agent_navigation .get(&thread_id) .is_some_and(|entry| entry.is_closed); - is_replay_only |= self.thread_unavailable(thread_id); let mut attached_replay_only = false; if self.should_attach_live_thread_for_selection(thread_id) { match self @@ -500,7 +547,11 @@ impl App { { Ok(live_attached) => { attached_replay_only = !live_attached; - is_replay_only |= attached_replay_only; + is_replay_only = attached_replay_only; + } + Err(_) if self.thread_event_channels.contains_key(&thread_id) => { + is_replay_only = true; + attached_replay_only = true; } Err(err) => { self.chat_widget.add_error_message(format!( @@ -541,10 +592,22 @@ impl App { .add_error_message(format!("Agent thread {thread_id} is no longer available.")); return Ok(()); }; - let recap_progress = channel.store.lock().await.recap_progress(); - if snapshot.input_state.is_none() { - snapshot.input_state = self.agents_overview.input_states.remove(&thread_id); - } + let recap_progress = { + let mut store = channel.store.lock().await; + if let (Some(cached), Some(session)) = + (cached_session.as_ref(), snapshot.session.as_mut()) + { + self.restore_runtime_permissions(session, cached); + store.session = Some(session.clone()); + if !is_replay_only && self.primary_thread_id == Some(thread_id) { + self.primary_session_configured = Some(session.clone()); + } + } + store.recap_progress() + }; + snapshot.input_state = snapshot + .input_state + .or(self.agents_overview.input_states.remove(&thread_id)); self.active_thread_id = Some(thread_id); self.active_thread_rx = Some(receiver); @@ -604,11 +667,13 @@ impl App { } pub(super) fn should_attach_live_thread_for_selection(&self, thread_id: ThreadId) -> bool { - !self.thread_event_channels.contains_key(&thread_id) + self.thread_event_channels + .get(&thread_id) + .is_none_or(|channel| channel.attachment() != ThreadEventAttachment::Live) && self .agent_navigation .get(&thread_id) - .is_none_or(|entry| !entry.is_closed) + .is_none_or(|entry| !entry.is_closed || self.thread_unavailable(thread_id)) } pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 453ed90bd0..90db6714f8 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -716,11 +716,7 @@ See the Codex keymap documentation for supported actions and examples." Ok(exit_reason) } else { loop { - if app.reconnect.offline - && !app.reconnect.failed - && reconnect.is_none() - && app.reconnect.presentation == reconnect::ReconnectPresentation::Conversation - { + if app.reconnect.offline && !app.reconnect.failed && reconnect.is_none() { reconnect = Some(Box::pin(reconnect::reconnect( app.app_server_target.clone(), app.config.clone(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a95b17749b..df1a1f20b7 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -8638,3 +8638,7 @@ async fn start_config_write_test_app_server(app: &App) -> Result Result<()> { + use super::super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; + use codex_app_server_protocol::Thread; + use codex_app_server_protocol::ThreadStatus; + use tokio::net::UnixListener; + + // Losing an optional, previously opened thread must not strand the command center either. + for (previous_thread, changed_child_permissions) in [ + (None, false), + (Some(ThreadId::new()), false), + (Some(ThreadId::new()), true), + ] { + let (mut app, mut events, _) = make_test_app_with_channels().await; + let child = ThreadId::new(); + let child_recap = recap::RecapProgress { + completed_turns: 3, + last_recapped_turn_count: Some(3), + }; + app.config.model = Some("gpt-test".into()); + app.active_thread_id = previous_thread; + app.primary_thread_id = previous_thread; + if let Some(id) = previous_thread { + app.config + .permissions + .set_permission_profile(PermissionProfile::read_only())?; + app.runtime_approval_policy_override = Some(AskForApproval::OnRequest); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + let mut session = test_thread_session(id, app.config.cwd.to_path_buf()); + session.approval_policy = AskForApproval::OnRequest; + session.permission_profile = PermissionProfile::read_only(); + app.primary_session_configured = Some(session.clone()); + app.chat_widget.handle_thread_session(session.clone()); + app.ensure_thread_channel(id) + .store + .lock() + .await + .set_session( + session, + vec![test_turn( + "cached", + TurnStatus::Completed, + vec![ThreadItem::Plan { + id: "cached-plan".into(), + text: "Cached previous conversation".into(), + }], + )], + ); + app.chat_widget + .restore_user_message_to_composer("old draft".into()); + app.store_active_thread_receiver().await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(app.chat_widget.composer_is_empty()); + app.chat_widget + .restore_user_message_to_composer("latest draft".into()); + } + if previous_thread.is_some() { + let child_session = test_thread_session(child, app.config.cwd.to_path_buf()); + app.ensure_thread_channel(child) + .store + .lock() + .await + .set_session(child_session, Vec::new()); + app.thread_event_channels[&child] + .store + .lock() + .await + .merge_recap_progress(child_recap); + app.upsert_agent_picker_thread( + child, /*agent_nickname*/ None, /*agent_role*/ None, + /*is_closed*/ false, + ); + } + let selected = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?; + let vanished = ThreadId::new(); + let added = ThreadId::new(); + let make_thread = |id: ThreadId, name: &str, status: ThreadStatus| -> Result { + Ok(serde_json::from_value(json!({ + "id": id.to_string(), "sessionId": id.to_string(), "preview": name, "name": name, + "ephemeral": false, "modelProvider": "test-provider", "createdAt": 1, + "updatedAt": chrono::Utc::now().timestamp() - 120, "status": status, + "cwd": app.config.cwd, "cliVersion": "0.0.0", "source": "cli", "turns": [] + }))?) + }; + let child_thread = make_thread(child, "Cached child", ThreadStatus::NotLoaded)?; + let stale = vec![ + make_thread( + selected, + "Selected task", + ThreadStatus::Active { + active_flags: Vec::new(), + }, + )?, + make_thread(vanished, "Vanished task", ThreadStatus::Idle)?, + ]; + let fresh = [ + make_thread(selected, "Selected task", ThreadStatus::Idle)?, + make_thread( + added, + "New task", + ThreadStatus::Active { + active_flags: Vec::new(), + }, + )?, + ]; + app.agents_overview.threads = stale.clone(); + let view = app.agents_overview_view( + stale.clone(), + Some(if previous_thread.is_some() { + vanished + } else { + selected + }), + ); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into(); + app.agents_overview.view_state.lock().unwrap().renaming = previous_thread.is_some(); + let stale_request = Uuid::new_v4(); + app.agents_overview.request_id = Some(stale_request); + app.agents_overview.refresh_pending = true; + let refresh = tokio::spawn(std::future::pending::<()>()); + app.agents_overview.view_state.lock().unwrap().refresh_task = Some(refresh.abort_handle()); + let directory = tempfile::tempdir()?; + let socket_path = directory.path().join("daemon.sock"); + let listener = UnixListener::bind(&socket_path)?; + let endpoint = crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: codex_utils_absolute_path::AbsolutePathBuf::try_from(socket_path.clone())?, + }; + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }; + let available = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_available = Arc::clone(&available); + let restored_previous = previous_thread + .map(|id| make_thread(id, "Recovered task", ThreadStatus::NotLoaded)) + .transpose()?; + let server = tokio::spawn(async move { + let mut listener = listener; + let mut methods = Vec::new(); + let mut child_opened = false; + for connection in 0..(2 + usize::from(previous_thread.is_some())) { + let (stream, _) = listener.accept().await?; + let socket = tokio_tungstenite::accept_async(stream).await?; + methods.extend(serve_reconnect_requests(socket, |request| std::future::ready({ + match request.method.as_str() { + "thread/loaded/list" if connection == 0 => None, + "thread/resume" if connection == 1 && previous_thread.is_some() => None, + "thread/resume" if request.params.as_ref().unwrap()["threadId"] != child.to_string() && !server_available.load(std::sync::atomic::Ordering::SeqCst) => Some(json!({"error": {"code": -32600, "message": "thread no longer exists"}})), + "thread/resume" => { + let mut thread = if request.params.as_ref().unwrap()["threadId"] == child.to_string() { + child_opened = true; + child_thread.clone() + } else { + restored_previous.clone().unwrap() + }; + if changed_child_permissions && thread.id != child.to_string() { + thread.turns = vec![test_turn("saved", TurnStatus::InProgress, Vec::new())]; + } + let (approval, sandbox) = if changed_child_permissions && thread.id != child.to_string() { + ("on-request", "readOnly") + } else { + ("never", "dangerFullAccess") + }; + Some(json!({"result": {"thread": thread, "model": "gpt-test", "modelProvider": "test-provider", "cwd": thread.cwd, "approvalPolicy": approval, "approvalsReviewer": "user", "sandbox": {"type": sandbox}, "reasoningEffort": null}})) + } + "turn/start" => { + let params = request.params.as_ref().unwrap(); + assert_eq!(params["approvalPolicy"], "on-request"); + assert_eq!(params["sandboxPolicy"]["type"], if changed_child_permissions { json!(null) } else { json!("readOnly") }); + assert_eq!(params["input"][0]["text"], "latest draft!"); + Some(json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}})) + } + "thread/loaded/list" => Some(json!({"result": {"data": [selected, added], "nextCursor": null}})), + "thread/turns/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), + "thread/goal/get" => Some(json!({"result": {"goal": null}})), + "thread/read" if request.params.as_ref().unwrap()["threadId"] == child.to_string() => Some(json!({"result": {"thread": child_thread}})), + "thread/read" if previous_thread.is_some_and(|id| request.params.as_ref().unwrap()["threadId"] == id.to_string()) => Some(if changed_child_permissions && child_opened { + let mut thread = restored_previous.clone().unwrap(); + let status = if server_available.load(std::sync::atomic::Ordering::SeqCst) { TurnStatus::InProgress } else { TurnStatus::Completed }; + thread.turns = vec![test_turn("saved", status, Vec::new())]; + json!({"result": {"thread": thread}}) + } else if server_available.load(std::sync::atomic::Ordering::SeqCst) { json!({"result": {"thread": restored_previous}}) } else { json!({"error": {"code": -32600, "message": "thread no longer exists"}}) }), + "thread/read" => Some(json!({"result": {"thread": fresh.iter().find(|thread| request.params.as_ref().unwrap()["threadId"] == thread.id).unwrap()}})), + method => panic!("unexpected daemon reconnect request: {method}"), + } + })).await?); + if connection == 0 { + drop(listener); + std::fs::remove_file(&socket_path)?; + listener = UnixListener::bind(&socket_path)?; + } + } + Ok::<_, color_eyre::Report>(methods) + }); + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Embedded, + ); + assert!( + session + .thread_loaded_list(ThreadLoadedListParams { + cursor: None, + limit: Some(100) + }) + .await + .is_err() + ); + let disconnected = session.next_event().await.unwrap(); + app.handle_app_server_event(&session, disconnected).await; + assert!(app.reconnect.offline); + assert!(refresh.await.unwrap_err().is_cancelled()); + assert_eq!( + ( + app.agents_overview.request_id, + app.agents_overview.refresh_pending + ), + (None, false) + ); + app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(Vec::new())); + assert_eq!(app.agents_overview.visible_thread_ids.len(), 2); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into())) + .await?; + assert_eq!( + app.agents_overview.view_state.lock().unwrap().input, + "Keep this task draft!" + ); + if previous_thread.is_none() { + assert_snapshot!( + "daemon_command_center_reconnecting", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + app.agents_overview + .view_state + .lock() + .unwrap() + .connection_notice = + Some("Reconnect failed — agent list is stale; relaunch to retry"); + assert_snapshot!( + "daemon_command_center_failed", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + } + let connected = reconnect( + app.app_server_target.clone(), + app.config.clone(), + previous_thread, + /*remote_cwd*/ None, + session.thread_tool_transport(), + ReconnectPresentation::Overview, + ) + .await?; + app.finish_reconnect(&mut tui, &mut session, &mut events, connected) + .await?; + assert!(!app.reconnect.offline); + assert_eq!(app.current_displayed_thread_id(), previous_thread); + + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + let mut deferred = Vec::new(); + while app.agents_overview.request_id.is_some() { + let event = events.recv().await.expect("inventory refresh event"); + if matches!(event, AppEvent::AgentsOverviewThreadsLoaded { .. }) { + app.handle_event(&mut tui, &mut session, event).await?; + } else { + deferred.push(event); + } + } + for event in deferred { + app.app_event_tx.send(event); + } + Ok::<_, color_eyre::Report>(()) + }) + .await??; + let index = app + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .unwrap(); + if previous_thread.is_none() { + assert_eq!(app.agents_overview.visible_thread_ids[index], selected); + } + assert!(!app.agents_overview.visible_thread_ids.contains(&vanished)); + assert!(app.agents_overview.visible_thread_ids.contains(&added)); + assert_eq!( + app.agents_overview.view_state.lock().unwrap().input, + if previous_thread.is_some() { + "" + } else { + "Keep this task draft!" + } + ); + if previous_thread.is_some() { + let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + assert!(history.contains("Unsubmitted title: Keep this task draft!")); + assert_snapshot!( + "daemon_command_center_vanished_rename", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. })) + ); + } + assert!( + app.agents_overview + .view_state + .lock() + .unwrap() + .connection_notice + .is_none() + ); + app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(stale)); + assert!(!app.agents_overview.visible_thread_ids.contains(&vanished)); + + if let Some(id) = previous_thread { + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ) + .await?; + + assert!(!app.chat_widget.has_active_view()); + assert_eq!(app.current_displayed_thread_id(), Some(id)); + let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + assert!(history.contains("Cached previous conversation")); + + let content = &history[history.find("Cached previous conversation").unwrap()..]; + assert_snapshot!( + "reconnected_unavailable_conversation", + format!( + "{content}\n{}", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + ); + + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert_eq!(app.chat_widget.composer_text_with_pending(), "latest draft"); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + ); + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into())) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "latest draft!" + ); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)), + ) + .await?; + assert!(app.chat_widget.has_active_view()); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "latest draft!" + ); + available.store( + !changed_child_permissions, + std::sync::atomic::Ordering::SeqCst, + ); + app.select_agent_thread(&mut tui, &mut session, child) + .await?; + assert_eq!(app.recap.progress(), child_recap); + assert_eq!(app.primary_thread_id, Some(id)); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + &PermissionProfile::Disabled + ); + if changed_child_permissions { + app.runtime_approval_policy_override = Some(AskForApproval::Never); + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + ); + app.select_agent_thread(&mut tui, &mut session, id).await?; + assert!(app.thread_unavailable(id)); + assert_eq!(app.current_displayed_thread_id(), Some(id)); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + &PermissionProfile::read_only() + ); + } + available.store(true, std::sync::atomic::Ordering::SeqCst); + app.handle_event( + &mut tui, + &mut session, + AppEvent::SelectAgentsOverviewThread { thread_id: id }, + ) + .await?; + assert!(!app.thread_unavailable(id)); + assert_eq!( + app.primary_session_configured, + app.thread_event_channels[&id].store.lock().await.session + ); + assert!(!app.agent_navigation.get(&id).unwrap().is_closed); + app.agent_navigation.mark_running(id); + assert!(app.agent_navigation.get(&id).unwrap().is_running); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "latest draft!" + ); + if changed_child_permissions { + assert!(app.chat_widget.is_task_running_for_test()); + // From an empty draft, daemon Ctrl+C offers the existing Cancel task action. + for key in [ + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ] { + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) + .await?; + } + assert!(std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::RunningTaskExit { action: RunningTaskExitAction::CancelTask, thread_id } if thread_id == id))); + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + turn_completed_notification(id, "saved", TurnStatus::Completed), + )), + ) + .await; + app.drain_active_thread_events(&mut tui).await?; + app.chat_widget + .restore_user_message_to_composer("latest draft!".into()); + } + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + while let Ok(event) = events.try_recv() { + if matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })) { + app.handle_event(&mut tui, &mut session, event).await?; + } + } + let channel = app.thread_event_channels.get(&id).unwrap(); + let event = ThreadBufferedEvent::Notification(Box::new( + agent_message_delta_notification(id, "new-turn", "new-item", "fresh delivery"), + )); + channel.sender.send(event).await?; + assert!(app.active_thread_rx.as_mut().unwrap().try_recv().is_ok()); + } + session.shutdown().await?; + let methods = server.await??; + assert_eq!( + methods + .iter() + .filter(|method| *method == "turn/start") + .count(), + usize::from(previous_thread.is_some()) + ); + assert_eq!( + methods.iter().any(|method| method == "thread/resume"), + previous_thread.is_some() + ); + assert_eq!( + methods + .iter() + .filter(|method| *method == "initialize") + .count(), + 2 + usize::from(previous_thread.is_some()) + ); + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap index 17214d7a48..150820831c 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap @@ -3,7 +3,7 @@ source: tui/src/app/tests/disconnect_tests.rs expression: "render_bottom_popup(&app.chat_widget, 100)" --- Agent command center - Disconnected — agent list is stale + Reconnecting — agent list is stale ──────────────────────────────────────────────────────────────────────────────────────────────── │ │ diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap new file mode 100644 index 0000000000..88929f315e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/navigation_reconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + Reconnect failed — agent list is stale; relaunch to retry + ──────────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● Selected task Working │ + ○ Vanished task Ready │ Selected task + │ ● Working + │ + │ Project + │ /tmp/project + │ + │ Latest activity + │ Selected task + │ + │ + │ + │ + │ + │ + │ + │ + │ + New task › Keep this task draft! + ctrl+c quit · actions paused until the list is refreshed diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap new file mode 100644 index 0000000000..2a7f38cd3e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/navigation_reconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + Reconnecting — agent list is stale + ──────────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● Selected task Working │ + ○ Vanished task Ready │ Selected task + │ ● Working + │ + │ Project + │ /tmp/project + │ + │ Latest activity + │ Selected task + │ + │ + │ + │ + │ + │ + │ + │ + │ + New task › Keep this task draft! + ctrl+c quit · actions paused until the list is refreshed diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap new file mode 100644 index 0000000000..0fadcdd1e2 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/navigation_reconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + 0 need input 1 working 1 ready + ──────────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● New task Working │ + ○ Selected task Ready │ New task + │ ● Working + │ + │ Project + │ /tmp/project + │ + │ Latest activity + │ New task + │ + │ + │ + │ + │ + │ + │ + │ + │ + New task › Describe a task and press enter to dispatch it + ↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap new file mode 100644 index 0000000000..f932ed13ef --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/navigation_reconnect_tests.rs +expression: "format!(\"{content}\\n{}\", render_bottom_popup(&app.chat_widget, 80))" +--- +Cached previous conversation + +• This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue. +• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused. +› latest draft + + gpt-test default · /tmp/project