diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index d13acf7b76..3cb0ae2832 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -1,4 +1,5 @@ //! Daemon-wide overview of recent and locally retained sessions and their subagents. +//! Tasks owned by another app server open as frozen, read-only history snapshots. #[path = "agents_overview_composer.rs"] mod composer; @@ -494,7 +495,7 @@ impl App { crate::app_server_session::ResumeModelSettings::PreserveExistingThread } }; - let resumed = match app_server + let (resumed, read_only) = match app_server .resume_thread( &local_settings, resume_config.clone(), @@ -503,15 +504,23 @@ impl App { ) .await { - Ok(resumed) => resumed, + Ok(resumed) => (resumed, false), + Err(error) if crate::app_server_session::is_active_writer_error(&error) => { + match app_server + .read_thread_for_viewing(&resume_config, &local_settings, root_thread_id) + .await + { + Ok(thread) => (thread, true), + Err(error) => { + self.add_agents_overview_error(format!( + "Failed to view task open elsewhere: {error}" + )); + return Ok(AppRunControl::Continue); + } + } + } Err(error) => { - let message = if crate::app_server_session::is_active_writer_error(&error) { - tracing::warn!("failed to attach to task managed by another server"); - "Task is open elsewhere. Another app server is managing this task, so it can’t be attached here.".to_string() - } else { - format!("Failed to attach to task: {error}") - }; - self.add_agents_overview_error(message); + self.add_agents_overview_error(format!("Failed to attach to task: {error}")); return Ok(AppRunControl::Continue); } }; @@ -577,6 +586,11 @@ impl App { self.add_agents_overview_error(format!("Failed to attach to task: {error}")); return Ok(AppRunControl::Continue); } + if read_only { + self.ensure_thread_channel(root_thread_id) + .mark_external_writer(); + self.chat_widget.show_external_writer_thread(); + } let mut destination_config = self.chat_widget.config_ref().clone(); if self.app_server_target.uses_remote_workspace() { destination_config.cwd.clone_from(&self.config.cwd); @@ -624,20 +638,25 @@ impl App { } if self.current_displayed_thread_id() != Some(root_thread_id) - || self.thread_unavailable(root_thread_id) + || (self.thread_unavailable(root_thread_id) + && !self.chat_widget.is_external_writer_view()) { self.select_agent_thread_and_discard_side(tui, app_server, root_thread_id) .await?; } - self.replay_agents_overview_requests(app_server, root_thread_id) - .await; + let read_only = self.chat_widget.is_external_writer_view(); + if !read_only { + self.replay_agents_overview_requests(app_server, root_thread_id) + .await; + } if self.current_displayed_thread_id() == Some(root_thread_id) && let Some(input_state) = self.agents_overview.input_states.remove(&root_thread_id) { - let preserve_in_flight_turn = self - .active_turn_id_for_thread(root_thread_id) - .await - .is_some(); + let preserve_in_flight_turn = !read_only + && self + .active_turn_id_for_thread(root_thread_id) + .await + .is_some(); self.chat_widget.restore_thread_input_state( Some(input_state), ThreadInputStateRestoreMode { @@ -648,8 +667,10 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } } - self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id) - .await; + if !read_only { + self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id) + .await; + } Ok(AppRunControl::Continue) } diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 7479011e46..41c7ff054f 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -2644,31 +2644,82 @@ async fn command_center_handles_resume_failure_and_success() -> Result<()> { } #[tokio::test] -async fn command_center_attach_conflict_preserves_selection_and_draft() -> Result<()> { +async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result<()> { let mut app = Box::pin(make_test_app()).await; trust_fixture_folders(&mut app); std::fs::write( app.config.codex_home.join("config.toml"), "[tui]\nresume_cwd = \"current\"\n", )?; + for cwd in [test_path_buf("/"), app.config.cwd.to_path_buf()] { + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + &cwd, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + } + let thread_id = ThreadId::from_string( + &app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved task", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("saved task"), + )?; let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; - let started = Box::pin(owner.start_thread(&app.config)).await?; - let thread_id = started.session.thread_id; - // Materialize the lazy rollout so the second server can discover the locked task. - owner.thread_inject_items(thread_id, vec![serde_json::from_value(serde_json::json!({ - "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Saved task"}] - }))?]).await?; + Box::pin(owner.resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + )) + .await?; let thread = owner .thread_read(thread_id, /*include_turns*/ false) .await?; let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, + }; + app.chat_widget.remote_connection = + crate::status::remote_connection::remote_connection_status_value( + &app.app_server_target, + /*server_version*/ None, + ); + app.agents_overview + .threads + .insert(thread_id, Some(thread.clone())); + app.chat_widget.insert_str("Retained task draft"); + app.agents_overview.input_states.insert( + thread_id, + app.chat_widget + .capture_thread_input_state() + .expect("task draft"), + ); + app.agents_overview.dispatched_requests.insert( + thread_id, + vec![ServerRequest::ToolRequestUserInput { + request_id: RequestId::Integer(42), + params: ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn".into(), + item_id: "question".into(), + questions: Vec::new(), + is_blocking: true, + auto_resolution_ms: None, + }, + }], + ); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = AppEventSender::new(tx); let mut view = app.agents_overview_view(vec![thread], Some(thread_id)); view.handle_paste("Keep this draft".into()); view.handle_key_event(KeyCode::Esc.into()); app.chat_widget.show_bottom_pane_view(Box::new(view)); - let before = render_bottom_popup(&app.chat_widget, /*width*/ 96); let draft = overview_draft(&app); let selection = app .chat_widget @@ -2681,34 +2732,101 @@ async fn command_center_attach_conflict_preserves_selection_and_draft() -> Resul matches!(event, AppEvent::SelectAgentsOverviewThread { thread_id: id } if id == thread_id) ); Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; - + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + app.thread_event_channels[&thread_id].attachment(), + ThreadEventAttachment::ExternalWriter + ); + assert_eq!(app.agents_overview.dispatched_requests[&thread_id].len(), 1); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained task draft" + ); + let turns = app.thread_event_channels[&thread_id] + .store + .lock() + .await + .snapshot() + .turns; + assert!(serde_json::to_string(&turns)?.contains("Saved task")); insta::with_settings!({snapshot_path => "../snapshots"}, { insta::assert_snapshot!("agents_overview_attach_conflict", render_bottom_popup(&app.chat_widget, /*width*/ 96)); }); - app.chat_widget.handle_key_event(KeyCode::Esc.into()); - assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before); + + app.handle_key_event(&mut tui, &mut server, KeyCode::Esc.into()) + .await; assert_eq!(overview_draft(&app), draft); assert_eq!( app.chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), selection ); - owner.shutdown().await?; - // Once the owner releases the task, the same keyboard path closes the dashboard. - app.chat_widget.handle_key_event(KeyCode::Right.into()); - Box::pin(app.handle_event(&mut tui, &mut server, rx.try_recv()?)).await?; - assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + app.chat_widget.handle_paste(" and this paste".into()); + assert_eq!(overview_draft(&app).0, "Keep this draft and this paste"); + // Opening the displayed task returns to the same frozen snapshot without retrying. + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::SelectAgentsOverviewThread { thread_id }, + )) + .await?; assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(app.chat_widget.is_external_writer_view()); + app.chat_widget.handle_paste(" should be ignored".into()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained task draft" + ); + assert_eq!( + app.thread_event_channels[&thread_id] + .store + .lock() + .await + .snapshot() + .turns, + turns + ); - // Opening the already displayed task also returns to its conversation. - let thread = server - .thread_read(thread_id, /*include_turns*/ false) - .await?; - let view = app.agents_overview_view(vec![thread], Some(thread_id)); - app.chat_widget.show_bottom_pane_view(Box::new(view)); - while rx.try_recv().is_ok() {} - app.chat_widget.handle_key_event(KeyCode::Right.into()); - Box::pin(app.handle_event(&mut tui, &mut server, rx.try_recv()?)).await?; + // An explicit retry remains read-only while the other server owns the task. + Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await; + assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained task draft" + ); + assert!( + server + .thread_loaded_list(codex_app_server_protocol::ThreadLoadedListParams { + cursor: None, + limit: None, + }) + .await? + .data + .is_empty() + ); + + // Buffered requests stay untouched while viewing; remove the synthetic request before retry. + assert_eq!( + app.agents_overview + .dispatched_requests + .remove(&thread_id) + .unwrap() + .len(), + 1 + ); + owner.shutdown().await?; + Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await; + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + assert!(!app.chat_widget.is_external_writer_view()); + assert_eq!( + app.thread_event_channels[&thread_id].attachment(), + ThreadEventAttachment::Live + ); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained task draft" + ); assert!(app.chat_widget.no_modal_or_popup_active()); server.shutdown().await?; Ok(()) diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 848717be52..0b0d64159f 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1,5 +1,6 @@ use super::*; use crate::app_event::TranscriptExportDestination; +use crate::bottom_pane::BottomPaneView; use app_test_support::create_fake_paginated_rollout; use app_test_support::create_fake_parented_rollout_with_source; use app_test_support::create_fake_rollout; @@ -149,6 +150,7 @@ pub(super) enum HistoryCapabilities { LegacyOnlyUnsupportedVariant, LegacyDynamicToolsAndHistory, ForkHydrationFails, + ReadAfterResumeFails, ThreadListFails, ThreadStartFails, ConfigReadUnsupported(i64), @@ -358,6 +360,22 @@ pub(super) async fn start_recording_app_server_with_realtime_speech( id: request_id, result: serde_json::json!({}), }) + } else if history_capabilities == HistoryCapabilities::ReadAfterResumeFails + && request.method == "thread/read" + && request_sink + .lock() + .expect("request recorder lock") + .iter() + .any(|recorded| recorded.method == "thread/resume") + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32603, + data: None, + message: "saved history unavailable".to_string(), + }, + }) } else if let HistoryCapabilities::ConfigReadUnsupported(code) = history_capabilities && request.method == "config/read" @@ -4460,3 +4478,86 @@ async fn external_writer_escape_preserves_snapshot_and_explicit_quits() -> Resul } Ok(()) } + +#[tokio::test] +async fn command_center_read_only_open_requests_and_failure_preservation() -> Result<()> { + for history_capabilities in [ + HistoryCapabilities::Current, + HistoryCapabilities::ReadAfterResumeFails, + ] { + let (mut app, _codex_home) = Box::pin(make_history_test_app()).await?; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + for cwd in [test_path_buf("/"), app.config.cwd.to_path_buf()] { + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + &cwd, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + } + let thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "Locked task")?; + let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + Box::pin(owner.resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + )) + .await?; + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + history_capabilities, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, + }; + let current = Box::pin(server.start_thread(&app.config)).await?; + let current_id = current.session.thread_id; + app.enqueue_primary_thread_session(current.session, current.turns) + .await?; + let thread = owner + .thread_read(thread_id, /*include_turns*/ false) + .await?; + let mut view = app.agents_overview_view(vec![thread], Some(thread_id)); + view.handle_paste("Keep this draft".into()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 96); + requests.lock().unwrap().clear(); + let mut tui = crate::tui::test_support::make_test_tui()?; + + Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, thread_id)).await?; + assert_eq!(recorded_params(&requests, "thread/resume").len(), 1); + assert!(recorded_params(&requests, "turn/start").is_empty()); + if history_capabilities == HistoryCapabilities::ReadAfterResumeFails { + let error = render_bottom_popup(&app.chat_widget, /*width*/ 96); + assert!( + error.contains("Failed to view task open elsewhere"), + "{error}" + ); + assert_eq!(app.current_displayed_thread_id(), Some(current_id)); + assert!(recorded_params(&requests, "thread/unsubscribe").is_empty()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before); + } else { + assert_eq!(app.current_displayed_thread_id(), Some(thread_id)); + assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + app.thread_event_channels[&thread_id].attachment(), + ThreadEventAttachment::ExternalWriter + ); + } + owner.shutdown().await?; + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 959a5771d3..c56a0011a0 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -516,7 +516,7 @@ impl ChatWidget { } pub(crate) fn handle_paste(&mut self, text: String) { - if self.external_writer_view { + if self.external_writer_view && !self.bottom_pane.has_active_view() { return; } self.bottom_pane.handle_paste(text); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6a0c65565e..1b3198ae8d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1014,12 +1014,13 @@ pub async fn run_main( explicit_remote_endpoint: Option, ) -> std::io::Result { system_motion::initialize().await; - match startup_orchestration::run_main_inner( + // Keep the startup future out of the CLI caller's frame while the TUI is running. + match Box::pin(startup_orchestration::run_main_inner( cli, arg0_paths, loader_overrides, explicit_remote_endpoint, - ) + )) .await { Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo { @@ -2194,6 +2195,7 @@ pub(crate) mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; + use clap::Parser; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::RequestId; @@ -2205,6 +2207,19 @@ pub(crate) mod tests { use serial_test::serial; use tempfile::TempDir; + #[test] + fn tui_startup_future_stays_bounded() { + let future = run_main( + Cli::parse_from(["codex"]), + Arg0DispatchPaths::default(), + LoaderOverrides::default(), + /*explicit_remote_endpoint*/ None, + ); + let size = std::mem::size_of_val(&future); + + assert!(size < 64 * 1024, "TUI startup future is {size} bytes"); + } + async fn build_config(temp_dir: &TempDir) -> std::io::Result { ConfigBuilder::default() .codex_home(temp_dir.path().to_path_buf()) diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attach_conflict.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attach_conflict.snap index 5ae8b25aa6..ee2d76bc5f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attach_conflict.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attach_conflict.snap @@ -1,12 +1,8 @@ --- source: tui/src/app/agents_overview_tests.rs -assertion_line: 2231 expression: "render_bottom_popup(&app.chat_widget, 96)" --- - Unable to complete action - Task is open elsewhere. Another app server is managing this task, so it can’t be attached - here. + 🔒 This conversation is open in another app R to Retry + Close it there and press R to continue here. -› 1. Return to command center - - Press enter to confirm or esc to go back + r retry esc command center ctrl+c/q exit ctrl+t transcript