diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 93f94642b9..a8650e70fb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -206,6 +206,7 @@ mod agent_navigation; mod agent_picker; mod agent_status_feed; mod agents_overview; +mod agents_overview_threads; mod agents_overview_view; pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID; mod app_server_event_targets; @@ -232,6 +233,7 @@ mod recap; mod reconnect; mod replay_filter; mod resize_reflow; +mod resume_config; mod safety_buffering; mod session_lifecycle; mod side; @@ -544,7 +546,7 @@ pub(crate) struct App { harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, cloud_config_bundle: CloudConfigBundleLoader, - runtime_approval_policy_override: Option, + runtime_approval_policy_override: Option, runtime_permission_profile_override: Option, pub(crate) file_search: FileSearchManager, @@ -642,9 +644,31 @@ struct RuntimePermissionProfileOverride { permission_profile: PermissionProfile, active_permission_profile: Option, network: Option, + approvals_reviewer: ApprovalsReviewer, turn_override: RuntimePermissionProfileTurnOverride, } +/// Separates user choices from settings inherited when attaching to another task. +#[derive(Debug, Clone, Copy, PartialEq)] +enum RuntimeApprovalPolicyOverride { + Explicit(AskForApproval), + Restored(AskForApproval), +} + +impl RuntimeApprovalPolicyOverride { + fn policy(self) -> AskForApproval { + match self { + Self::Explicit(policy) | Self::Restored(policy) => policy, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum RuntimePolicyOverrideScope { + All, + ExplicitOnly, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RuntimePermissionProfileTurnOverride { Preserve, @@ -657,6 +681,7 @@ impl RuntimePermissionProfileOverride { permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), network: config.permissions.network.clone(), + approvals_reviewer: config.approvals_reviewer, turn_override: RuntimePermissionProfileTurnOverride::LegacySandbox, } } @@ -672,6 +697,7 @@ impl RuntimePermissionProfileOverride { self.permission_profile == *config.permissions.permission_profile() && self.active_permission_profile == config.permissions.active_permission_profile() && self.network == config.permissions.network + && self.approvals_reviewer == config.approvals_reviewer } fn turn_permission_profile(&self) -> Option<&PermissionProfile> { diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 3f15e934fc..e61b914894 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -1,9 +1,10 @@ -//! Daemon-wide overview of loaded root sessions and their subagents. +//! Daemon-wide overview of recent and locally retained sessions and their subagents. use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewRow; use super::agents_overview_view::AgentsOverviewView; use super::*; +use crate::app_event::AgentsOverviewThreadRefresh; use crate::bottom_pane::SelectionDescriptionLayout; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; @@ -13,14 +14,6 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadLoadedListParams; -use codex_app_server_protocol::ThreadLoadedListResponse; -use codex_app_server_protocol::ThreadReadParams; -use codex_app_server_protocol::ThreadReadResponse; -use codex_app_server_protocol::ThreadStatus; -use codex_app_server_protocol::ThreadTurnsListParams; -use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; @@ -30,17 +23,30 @@ pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview"; #[derive(Default)] pub(super) struct AgentsOverviewState { + /// Missing metadata records a local resume until the next metadata refresh. + pub(super) threads: HashMap>, + pub(super) initialized: bool, pub(super) request_id: Option, pub(super) refresh_pending: bool, + pub(super) refresh_thread_ids: HashSet, + pub(super) refresh_task: Option, + pub(super) refresh_notifications: HashMap>, 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, pub(super) dispatched_requests: HashMap>, } +impl Drop for AgentsOverviewState { + fn drop(&mut self) { + if let Some(task) = self.refresh_task.take() { + task.abort(); + } + } +} + impl App { pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) { if matches!(self.app_server_target, AppServerTarget::Embedded) { @@ -92,140 +98,76 @@ impl App { return; } - self.agents_overview.request_id = None; - self.agents_overview.refresh_pending = false; - let view = self.agents_overview_view( - self.agents_overview.threads.clone(), - /*selected_thread_id*/ None, - ); + let threads = self + .agents_overview + .threads + .values() + .flatten() + .cloned() + .collect(); + let view = self.agents_overview_view(threads, /*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); } - pub(super) fn refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) { - if self - .chat_widget - .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) - .is_none() - { - return; - } - if self.agents_overview.request_id.is_some() { - self.agents_overview.refresh_pending = true; - return; - } - - let request_id = Uuid::new_v4(); - self.agents_overview.request_id = Some(request_id); - let request_handle = app_server.request_handle(); - let app_event_tx = self.app_event_tx.clone(); - let refresh_task = tokio::spawn(async move { - let result = async { - let mut threads = Vec::new(); - let mut cursor = None; - while threads.len() < 1_000 { - let page = request_handle - .request_typed::(ClientRequest::ThreadLoadedList { - request_id: RequestId::String(Uuid::new_v4().to_string()), - params: ThreadLoadedListParams { - cursor, - limit: Some(100), - }, - }) - .await - .map_err(|error| error.to_string())?; - let mut loaded_threads = tokio::task::JoinSet::new(); - for thread_id in page.data.into_iter().take(1_000 - threads.len()) { - let request_handle = request_handle.clone(); - loaded_threads.spawn(async move { - match request_handle - .request_typed::(ClientRequest::ThreadRead { - request_id: RequestId::String(Uuid::new_v4().to_string()), - params: ThreadReadParams { - thread_id: thread_id.clone(), - include_turns: false, - }, - }) - .await - { - Ok(mut response) => { - if let Ok(turns) = request_handle - .request_typed::( - ClientRequest::ThreadTurnsList { - request_id: RequestId::String( - Uuid::new_v4().to_string(), - ), - params: ThreadTurnsListParams { - thread_id, - cursor: None, - limit: Some(1), - sort_direction: None, - items_view: None, - }, - }, - ) - .await - && let Some(ThreadItem::UserMessage { content, .. }) = turns - .data - .first() - .and_then(|turn| turn.items.first()) - { - response.thread.preview = - ChatWidget::user_message_display_from_inputs(content) - .message; - } - Some(response.thread) - } - Err(error) => { - tracing::warn!(thread_id, %error, "failed to read loaded agent thread"); - None - } - } - }); - if loaded_threads.len() >= 16 - && let Some(Ok(Some(thread))) = loaded_threads.join_next().await - { - threads.push(thread); - } - } - while let Some(result) = loaded_threads.join_next().await { - if let Ok(Some(thread)) = result { - threads.push(thread); - } - } - let Some(next_cursor) = page.next_cursor else { - break; - }; - cursor = Some(next_cursor); - } - threads.sort_by_key(|thread| std::cmp::Reverse(thread.updated_at)); - Ok(threads) - } - .await; - - app_event_tx.send(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }); - }); - if let Ok(mut state) = self.agents_overview.view_state.lock() { - state.refresh_task = Some(refresh_task.abort_handle()); - } - } - pub(super) fn apply_agents_overview_thread_refresh( &mut self, app_server: &AppServerSession, request_id: Uuid, - result: Result, String>, + result: Result, ) { if self.agents_overview.request_id != Some(request_id) { return; } self.agents_overview.request_id = None; + self.agents_overview.refresh_task = None; + match result { + Ok(refresh) => { + self.agents_overview.initialized = refresh.recent_seed_complete; + for (thread_id, thread) in refresh.threads { + if let Some(mut thread) = thread { + if thread.ephemeral { + self.agents_overview.threads.remove(&thread_id); + continue; + } + thread.turns.clear(); + self.agents_overview.threads.insert(thread_id, Some(thread)); + } else { + self.agents_overview.threads.entry(thread_id).or_default(); + } + } + } + Err(error) => { + tracing::warn!(%error, "failed to refresh shared agents"); + if self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + self.chat_widget + .add_error_message(format!("Failed to load shared agents: {error}")); + } + } + } + for notifications in + std::mem::take(&mut self.agents_overview.refresh_notifications).into_values() + { + for notification in notifications { + self.track_agents_overview_notification(¬ification); + } + } + if std::mem::take(&mut self.agents_overview.refresh_pending) { + self.refresh_changed_agents_overview_threads(app_server); + } + self.repaint_agents_overview(); + } + + pub(super) fn repaint_agents_overview(&mut self) { let Some(selected) = self .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) else { - self.agents_overview.refresh_pending = false; return; }; let selected_thread_id = self @@ -233,18 +175,13 @@ impl App { .visible_thread_ids .get(selected) .copied(); - let threads = match result { - Ok(threads) => threads, - Err(error) => { - self.chat_widget - .add_error_message(format!("Failed to load shared agents: {error}")); - if std::mem::take(&mut self.agents_overview.refresh_pending) { - self.refresh_agents_overview_threads(app_server); - } - return; - } - }; - self.agents_overview.threads = threads.clone(); + let threads = self + .agents_overview + .threads + .values() + .flatten() + .cloned() + .collect(); let view = self.agents_overview_view(threads, selected_thread_id); self.agents_overview.visible_thread_ids = view.thread_ids(); if selected_thread_id @@ -264,9 +201,6 @@ impl App { } self.chat_widget .replace_bottom_pane_view_if_present(AGENTS_OVERVIEW_VIEW_ID, Box::new(view)); - if std::mem::take(&mut self.agents_overview.refresh_pending) { - self.refresh_agents_overview_threads(app_server); - } } pub(super) fn agents_overview_view( @@ -296,13 +230,15 @@ impl App { let mut roots = threads .iter() - .filter(|thread| { - thread.parent_thread_id.is_none() - && !matches!(thread.status, ThreadStatus::NotLoaded) - }) + .filter(|thread| thread.parent_thread_id.is_none()) .map(|root| (root, agents_overview_group(root, &children))) .collect::>(); - roots.sort_by_key(|(root, group)| (*group, std::cmp::Reverse(root.updated_at))); + roots.sort_by(|(left, left_group), (right, right_group)| { + left_group + .cmp(right_group) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)) + }); let mut rows = Vec::new(); for (root, group) in roots { let Ok(thread_id) = ThreadId::from_string(&root.id) else { @@ -396,32 +332,79 @@ impl App { return Ok(AppRunControl::Continue); } }; - let config_cwd = if self.app_server_target.uses_remote_workspace() { - self.config.cwd.to_path_buf() + let unloaded = matches!( + target_thread.status, + codex_app_server_protocol::ThreadStatus::NotLoaded + ); + let mut resume_config = if unloaded { + let target_session = SessionTarget { + path: target_thread.path.clone(), + thread_id: root_thread_id, + history_mode: Some(target_thread.history_mode), + }; + match self + .resume_config_for_target(tui, app_server, &target_session) + .await + { + Ok(config) => config, + Err(control) => return Ok(control), + } } else { - target_thread.cwd.to_path_buf() - }; - let current_cwd = self.config.cwd.to_path_buf(); - let resume_config = match self - .rebuild_config_for_resume_or_fallback(¤t_cwd, config_cwd) - .await - { - Ok(config) => config, - Err(error) => { - self.chat_widget - .add_error_message(format!("Failed to load task settings: {error}")); - return Ok(AppRunControl::Continue); + let config_cwd = if self.app_server_target.uses_remote_workspace() { + self.config.cwd.to_path_buf() + } else { + target_thread.cwd.to_path_buf() + }; + let current_cwd = self.config.cwd.to_path_buf(); + match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, config_cwd) + .await + { + Ok(config) => config, + Err(error) => { + self.chat_widget + .add_error_message(format!("Failed to load task settings: {error}")); + return Ok(AppRunControl::Continue); + } } }; let baseline_approval = resume_config.permissions.approval_policy.value(); let baseline_permissions = RuntimePermissionProfileOverride::from_config(&resume_config); + let resume_model_settings = match target_thread.status { + codex_app_server_protocol::ThreadStatus::NotLoaded => { + self.apply_runtime_policy_overrides( + &mut resume_config, + RuntimePolicyOverrideScope::ExplicitOnly, + ); + if matches!(self.runtime_approval_policy_override, + Some(RuntimeApprovalPolicyOverride::Explicit(policy)) + if policy.to_core() != resume_config.permissions.approval_policy.value()) + || self + .runtime_permission_profile_override + .as_ref() + .is_some_and(|profile| { + profile.turn_override + == RuntimePermissionProfileTurnOverride::LegacySandbox + && !profile.matches_config(&resume_config) + }) + { + self.chat_widget.add_error_message( + "Cannot resume task without preserving the selected permissions." + .to_string(), + ); + return Ok(AppRunControl::Continue); + } + self.resume_model_settings() + } + codex_app_server_protocol::ThreadStatus::Idle + | codex_app_server_protocol::ThreadStatus::Active { .. } + | codex_app_server_protocol::ThreadStatus::SystemError => { + crate::app_server_session::ResumeModelSettings::PreserveExistingThread + } + }; let resumed = match app_server - .resume_thread( - resume_config.clone(), - root_thread_id, - crate::app_server_session::ResumeModelSettings::PreserveExistingThread, - ) + .resume_thread(resume_config.clone(), root_thread_id, resume_model_settings) .await { Ok(resumed) => resumed, @@ -459,8 +442,19 @@ impl App { if previous_running_thread_ids.is_empty() { self.shutdown_current_thread(app_server).await; } - self.runtime_approval_policy_override = None; - self.runtime_permission_profile_override = None; + // Explicit user choices carry across cold resumes; inherited task settings do not. + self.runtime_approval_policy_override = + self.runtime_approval_policy_override.filter(|policy| { + unloaded && matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_)) + }); + self.runtime_permission_profile_override = self + .runtime_permission_profile_override + .take() + .filter(|profile| { + unloaded + && profile.turn_override + == RuntimePermissionProfileTurnOverride::LegacySandbox + }); self.config = resume_config; tui.set_notification_settings( self.config.tui_notifications.method, @@ -492,14 +486,22 @@ impl App { .set_workspace_roots(self.config.permissions.workspace_roots().to_vec()); } self.config = destination_config; - if self.config.permissions.approval_policy.value() != baseline_approval { - self.runtime_approval_policy_override = - Some(self.config.permissions.approval_policy.value().into()); + let approval = self.config.permissions.approval_policy.value(); + if self + .runtime_approval_policy_override + .is_none_or(|policy| policy.policy().to_core() != approval) + { + self.runtime_approval_policy_override = (approval != baseline_approval) + .then_some(RuntimeApprovalPolicyOverride::Restored(approval.into())); } - if !baseline_permissions.matches_config(&self.config) { - self.runtime_permission_profile_override = Some( - RuntimePermissionProfileOverride::from_restored_config(&self.config), - ); + if self + .runtime_permission_profile_override + .as_ref() + .is_none_or(|profile| !profile.matches_config(&self.config)) + { + self.runtime_permission_profile_override = (!baseline_permissions + .matches_config(&self.config)) + .then(|| RuntimePermissionProfileOverride::from_restored_config(&self.config)); } if !self .backfill_loaded_subagent_threads(app_server) @@ -614,7 +616,7 @@ impl App { .chat_widget .add_error_message("Permission profile has different settings.".to_string()); } - self.apply_runtime_policy_overrides(&mut config); + self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); apply_managed_new_thread_defaults( &mut config, app_server.managed_new_thread_defaults(), diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 9b35bc270e..1598b60a97 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::app::test_support::make_test_app; +use crate::app_event::AgentsOverviewThreadRefresh; use crate::bottom_pane::BottomPaneView; use crate::chatwidget::tests::helpers::render_bottom_popup; use crate::render::renderable::Renderable; @@ -11,8 +12,13 @@ use codex_app_server_protocol::CurrentTimeReadParams; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadActiveFlag; +use codex_app_server_protocol::ThreadArchivedNotification; +use codex_app_server_protocol::ThreadClosedNotification; +use codex_app_server_protocol::ThreadDeletedNotification; +use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::ThreadUnsubscribeStatus; @@ -21,6 +27,7 @@ use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SubAgentSource; +use pretty_assertions::assert_eq; static OVERVIEW_TIMESTAMP: std::sync::LazyLock = std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120); @@ -70,6 +77,247 @@ fn overview_thread( } } +#[tokio::test] +async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Result<()> { + let mut app = make_test_app().await; + let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let [retained, archived, deleted, created] = std::array::from_fn(|_| ThreadId::new()); + let [ + mut retained_thread, + archived_thread, + deleted_thread, + created_thread, + ] = [ + (retained, "Old name"), + (archived, "Archived"), + (deleted, "Deleted"), + (created, "Created in another client"), + ] + .map(|(id, name)| { + overview_thread(id, /*parent_thread_id*/ None, name, ThreadStatus::Idle) + }); + let request_id = Uuid::new_v4(); + app.agents_overview.request_id = Some(request_id); + // The seed is still in flight and the command center has never been opened. + for notification in [ + ServerNotification::ThreadStarted(ThreadStartedNotification { + thread: created_thread.clone(), + }), + ServerNotification::ThreadNameUpdated(ThreadNameUpdatedNotification { + thread_id: retained.to_string(), + thread_name: Some("New name".to_string()), + }), + ServerNotification::ThreadClosed(ThreadClosedNotification { + thread_id: retained.to_string(), + }), + ServerNotification::ThreadArchived(ThreadArchivedNotification { + thread_id: archived.to_string(), + }), + ServerNotification::ThreadDeleted(ThreadDeletedNotification { + thread_id: deleted.to_string(), + }), + ] { + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(notification)), + ) + .await; + } + app.apply_agents_overview_thread_refresh( + &app_server, + request_id, + Ok(AgentsOverviewThreadRefresh { + threads: HashMap::from([ + (retained, Some(retained_thread.clone())), + (archived, Some(archived_thread)), + (deleted, Some(deleted_thread)), + ]), + recent_seed_complete: true, + }), + ); + retained_thread.name = Some("New name".to_string()); + retained_thread.status = ThreadStatus::NotLoaded; + let expected = HashMap::from([ + (retained, Some(retained_thread)), + (created, Some(created_thread)), + ]); + assert_eq!(app.agents_overview.threads, expected); + + let request_id = Uuid::new_v4(); + app.agents_overview.request_id = Some(request_id); + app.apply_agents_overview_thread_refresh( + &app_server, + request_id, + Ok(AgentsOverviewThreadRefresh { + threads: HashMap::from([(retained, None)]), + recent_seed_complete: true, + }), + ); + assert_eq!(app.agents_overview.threads, expected); + let request_id = Uuid::new_v4(); + app.agents_overview.request_id = Some(request_id); + app.apply_agents_overview_thread_refresh( + &app_server, + request_id, + Err("temporarily unavailable".to_string()), + ); + assert_eq!(app.agents_overview.threads, expected); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Result<()> { + let mut app = make_test_app().await; + let mut ids = Vec::new(); + for day in 1..=22 { + let source = match day { + 3 => codex_protocol::protocol::SessionSource::Custom("atlas".to_string()), + 4 => codex_protocol::protocol::SessionSource::Custom("chatgpt".to_string()), + 5 => codex_protocol::protocol::SessionSource::Exec, + 6 => codex_protocol::protocol::SessionSource::Mcp, + _ => codex_protocol::protocol::SessionSource::Cli, + }; + ids.push(ThreadId::from_string( + &app_test_support::create_fake_rollout_with_source( + &app.config.codex_home, + &format!("2025-01-{day:02}T12-00-00"), + &format!("2025-01-{day:02}T12:00:00Z"), + &format!("Task {day}"), + Some(if day == 10 { + "other-provider" + } else { + &app.config.model_provider_id + }), + /*git_info*/ None, + source, + ) + .expect("materialize historical session"), + )?); + } + let config = app.config.clone(); + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + for thread_id in [ids[0], ids[21]] { + app_server + .resume_thread( + config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await?; + } + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: test_path_buf("/tmp/unused.sock").abs(), + }, + }; + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); + app.refresh_agents_overview_threads(&app_server); + finish_overview_refresh(&mut app, &app_server, &mut event_rx).await; + let recent_ids: HashSet<_> = ids[2..].iter().copied().collect(); + let mut expected = recent_ids.clone(); + expected.insert(ids[0]); + let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect(); + assert_eq!(retained, expected); + + let created = app_server.start_thread(&config).await?.session.thread_id; + // Closing the view must not cancel a metadata refresh or forget unloaded entries. + app.primary_thread_id = Some(ids[0]); + app.open_agents_overview(&app_server); + let visible: HashSet<_> = app + .agents_overview + .visible_thread_ids + .iter() + .copied() + .collect(); + assert_eq!(visible, expected); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + finish_overview_refresh(&mut app, &app_server, &mut event_rx).await; + let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect(); + assert_eq!(retained, expected); + + // A creation notification adds a session without attaching a local view. + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let event = app_server.next_event().await.expect("server notification"); + let is_created = matches!(&event, AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::ThreadStarted(started) if started.thread.id == created.to_string())); + app.handle_app_server_event(&app_server, event).await; + if is_created { break; } + } + }).await.expect("creation notification received"); + expected.insert(created); + let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect(); + assert_eq!(retained, expected); + + // Opening older history is a local addition, even without starting a turn. + let resumed = app_server + .resume_thread( + config.clone(), + ids[1], + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await?; + app.enqueue_primary_thread_session(resumed.session, resumed.turns) + .await?; + app.open_agents_overview(&app_server); + finish_overview_refresh(&mut app, &app_server, &mut event_rx).await; + expected.insert(ids[1]); + let visible: HashSet<_> = app + .agents_overview + .visible_thread_ids + .iter() + .copied() + .collect(); + assert_eq!(visible, expected); + + // An unloaded row can be selected through the same flow as a loaded row. + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"session\"\n", + )?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.select_agents_overview_thread(&mut tui, &mut app_server, ids[2]) + .await?; + assert_eq!(app.primary_thread_id, Some(ids[2])); + app_server.shutdown().await?; + + // A fresh TUI/server has no in-memory additions; read-only resumes did not promote history. + let mut restarted = make_test_app().await; + restarted.app_server_target = app.app_server_target.clone(); + let app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + restarted.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); + restarted.refresh_agents_overview_threads(&app_server); + finish_overview_refresh(&mut restarted, &app_server, &mut event_rx).await; + let retained: HashSet<_> = restarted.agents_overview.threads.keys().copied().collect(); + assert_eq!(retained, recent_ids); + app_server.shutdown().await?; + Ok(()) +} + +async fn finish_overview_refresh( + app: &mut App, + app_server: &AppServerSession, + events: &mut tokio::sync::mpsc::UnboundedReceiver, +) { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let AppEvent::AgentsOverviewThreadsLoaded { request_id, result } = + events.recv().await.expect("overview event") + { + assert!(result.is_ok(), "{result:?}"); + app.apply_agents_overview_thread_refresh(app_server, request_id, result); + return; + } + } + }) + .await + .expect("overview refresh completed"); +} + #[tokio::test] async fn hidden_system_thread_does_not_refresh_shared_overview() { let mut app = make_test_app().await; @@ -164,6 +412,7 @@ async fn shared_overview_shows_only_root_sessions() { ); let mut app = make_test_app().await; let first_root = ThreadId::from_string("00000000-0000-0000-0000-000000000101").unwrap(); + let unloaded_root = ThreadId::from_string("00000000-0000-0000-0000-000000000102").unwrap(); let [child, second_root, side_thread] = std::array::from_fn(|_| ThreadId::new()); app.primary_thread_id = Some(first_root); @@ -190,6 +439,12 @@ async fn shared_overview_shows_only_root_sessions() { active_flags: Vec::new(), }, ), + overview_thread( + unloaded_root, + /*parent_thread_id*/ None, + "Review yesterday's changes", + ThreadStatus::NotLoaded, + ), ]; let mut side = overview_thread( side_thread, @@ -271,14 +526,14 @@ async fn shared_overview_shows_only_root_sessions() { app.chat_widget.show_bottom_pane_view(Box::new(view)); let project = test_path_display("/tmp/project"); let normalized_project_group = format!( - "/tmp/project 2{}", + "/tmp/project 3{}", " ".repeat(project.len().saturating_sub("/tmp/project".len())) ); insta::with_settings!({snapshot_path => "../snapshots"}, { insta::assert_snapshot!( "agents_overview", render_bottom_popup(&app.chat_widget, /*width*/ 96) - .replace(&format!("{project} 2"), &normalized_project_group) + .replace(&format!("{project} 3"), &normalized_project_group) .replace(&project, "/tmp/project") ); }); @@ -395,8 +650,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { ], Some(first), ) - .rows - .clone(), + .rows, Some(first), /*exit_on_cancel*/ false, crate::app_event_sender::AppEventSender::new(event_tx), @@ -513,6 +767,213 @@ async fn root_switch_preserves_idle_root_with_running_subagent() -> Result<()> { Ok(()) } +#[tokio::test] +async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() -> Result<()> { + let mut app = make_test_app().await; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"session\"\n", + )?; + let mut server_config = app.config.clone(); + server_config + .permissions + .set_permission_profile(PermissionProfile::workspace_write())?; + server_config + .permissions + .approval_policy + .set(codex_protocol::protocol::AskForApproval::Never)?; + let mut thread_ids = Vec::new(); + for day in 1..=5 { + thread_ids.push(ThreadId::from_string( + &app_test_support::create_fake_rollout( + &server_config.codex_home, + &format!("2025-01-{day:02}T12-00-00"), + &format!("2025-01-{day:02}T12:00:00Z"), + "Historical session", + Some(&server_config.model_provider_id), + /*git_info*/ None, + ) + .expect("create historical session"), + )?); + } + let mut app_server = crate::start_embedded_app_server_for_picker(&server_config).await?; + let loaded = app_server + .resume_thread( + server_config.clone(), + thread_ids[0], + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await? + .session; + app.harness_overrides.sandbox_mode = Some(codex_protocol::config_types::SandboxMode::ReadOnly); + app.harness_overrides.approval_policy = + Some(codex_protocol::protocol::AskForApproval::UnlessTrusted); + app.config + .permissions + .set_permission_profile(PermissionProfile::read_only())?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + AskForApproval::UnlessTrusted, + )); + let mut tui = crate::tui::test_support::make_test_tui()?; + for (thread_id, expected_permissions, expected_approval) in [ + ( + loaded.thread_id, + loaded.permission_profile, + loaded.approval_policy, + ), + ( + thread_ids[1], + PermissionProfile::read_only(), + codex_app_server_protocol::AskForApproval::UnlessTrusted, + ), + ( + thread_ids[2], + PermissionProfile::read_only(), + codex_app_server_protocol::AskForApproval::UnlessTrusted, + ), + ( + thread_ids[3], + PermissionProfile::read_only(), + codex_app_server_protocol::AskForApproval::UnlessTrusted, + ), + ] { + if thread_id == thread_ids[2] { + // A session-only /permissions choice must survive subsequent cold resumes. + std::fs::write( + app.config.codex_home.join("config.toml"), + "approvals_reviewer = \"auto_review\"\n[tui]\nresume_cwd = \"session\"\n", + )?; + app.harness_overrides.sandbox_mode = + Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite); + app.harness_overrides.approval_policy = + Some(codex_protocol::protocol::AskForApproval::Never); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + AskForApproval::UnlessTrusted, + )); + } + app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id) + .await?; + let observed = app_server + .resume_thread( + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await? + .session; + assert_eq!( + ( + app.primary_thread_id, + observed.permission_profile, + observed.approval_policy, + observed.approvals_reviewer, + ), + ( + Some(thread_id), + expected_permissions, + expected_approval, + ApprovalsReviewer::User + ), + ); + } + let requirements = app.config.codex_home.join("requirements.toml"); + std::fs::write( + &requirements, + "allowed_approvals_reviewers = [\"auto_review\"]\n", + )?; + app.loader_overrides.system_requirements_path = Some(requirements.to_path_buf()); + app.select_agents_overview_thread(&mut tui, &mut app_server, thread_ids[4]) + .await?; + assert_eq!( + ( + app.primary_thread_id, + app_server + .thread_read(thread_ids[4], /*include_turns*/ false) + .await? + .status + ), + ( + Some(thread_ids[3]), + codex_app_server_protocol::ThreadStatus::NotLoaded + ), + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn overview_cold_resume_honors_working_directory_selection() -> Result<()> { + for (mode, cli_cwd, runtime_cwd) in [ + ("current", false, false), + ("session", true, false), + ("session", true, true), + ("session", false, false), + ] { + let mut app = make_test_app().await; + let chosen = app.config.codex_home.join("chosen"); + let overridden = app.config.codex_home.join("overridden"); + std::fs::create_dir(&chosen)?; + std::fs::create_dir(&overridden)?; + std::fs::write( + app.config.codex_home.join("config.toml"), + format!("[tui]\nresume_cwd = \"{mode}\"\n"), + )?; + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + &chosen, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + let thread_id = ThreadId::from_string( + &app_test_support::create_fake_rollout( + &app.config.codex_home, + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Historical session", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("create historical session"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + app.launch_cwd = chosen.to_path_buf(); + app.harness_overrides.cwd = cli_cwd.then(|| { + if runtime_cwd { + overridden.to_path_buf() + } else { + chosen.to_path_buf() + } + }); + app.runtime_working_directory_override = runtime_cwd.then(|| chosen.to_path_buf()); + let expected_cwd = if mode == "current" || cli_cwd || runtime_cwd { + chosen + } else { + test_path_buf("/").abs() + }; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id) + .await?; + let observed = app_server + .resume_thread( + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await? + .session; + assert_eq!( + (app.config.cwd.clone(), observed.cwd), + (expected_cwd.clone(), expected_cwd), + ); + app_server.shutdown().await?; + } + Ok(()) +} + #[tokio::test] async fn restored_server_permission_profile_survives_cd_without_turn_override() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs new file mode 100644 index 0000000000..8a3e86aa6d --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -0,0 +1,336 @@ +//! Retains overview membership for this TUI, independently of server subscriptions. +//! The loaded/recent seed runs at startup and after reconnect; other reads only refresh metadata. +//! Discovery merges into retained membership without evicting rows. + +use super::App; +use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; +use super::app_server_event_targets::ServerNotificationThreadTarget; +use super::app_server_event_targets::server_notification_thread_target; +use crate::AppServerTarget; +use crate::app_event::AgentsOverviewThreadRefresh; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::chatwidget::ChatWidget; +use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SubAgentSource; +use std::collections::HashMap; +use uuid::Uuid; + +impl App { + pub(super) fn track_agents_overview_notification(&mut self, notification: &ServerNotification) { + let ServerNotificationThreadTarget::Thread(thread_id) = + server_notification_thread_target(notification) + else { + return; + }; + let thread = self + .agents_overview + .threads + .get_mut(&thread_id) + .and_then(Option::as_mut); + match notification { + ServerNotification::ThreadStarted(started) => { + if started.thread.ephemeral { + return; + } + let mut thread = started.thread.clone(); + thread.turns.clear(); + self.agents_overview.threads.insert(thread_id, Some(thread)); + } + ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => { + self.agents_overview.threads.remove(&thread_id); + self.agents_overview.refresh_thread_ids.remove(&thread_id); + } + ServerNotification::ThreadClosed(_) => { + if let Some(thread) = thread { + thread.status = ThreadStatus::NotLoaded; + } + } + ServerNotification::ThreadStatusChanged(status) => { + if let Some(thread) = thread { + thread.status = status.status.clone(); + } + } + ServerNotification::ThreadNameUpdated(name) => { + if let Some(thread) = thread { + thread.name.clone_from(&name.thread_name); + } + } + ServerNotification::ThreadSettingsUpdated(settings) => { + if let Some(thread) = thread { + thread.cwd.clone_from(&settings.thread_settings.cwd); + thread + .model_provider + .clone_from(&settings.thread_settings.model_provider); + } + } + _ => return, + } + if self.agents_overview.threads.contains_key(&thread_id) { + self.agents_overview.refresh_thread_ids.insert(thread_id); + } + if self.agents_overview.request_id.is_some() { + // Replay the latest notification of each kind after the read, in arrival order. + // This also protects sessions whose metadata has not arrived in the initial seed. + let pending = self + .agents_overview + .refresh_notifications + .entry(thread_id) + .or_default(); + pending.retain(|previous| { + std::mem::discriminant(previous) != std::mem::discriminant(notification) + }); + pending.push(notification.clone()); + } + } + + pub(super) fn refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) { + self.agents_overview + .refresh_thread_ids + .extend(self.agents_overview.threads.keys()); + self.start_agents_overview_refresh(app_server); + } + + pub(super) fn refresh_changed_agents_overview_threads( + &mut self, + app_server: &AppServerSession, + ) { + if self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_none() + || (!self.agents_overview.initialized && self.agents_overview.request_id.is_none()) + { + return; + } + self.start_agents_overview_refresh(app_server); + } + + fn start_agents_overview_refresh(&mut self, app_server: &AppServerSession) { + let visible = self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some(); + if !visible + && (self.agents_overview.initialized + || matches!(self.app_server_target, AppServerTarget::Embedded)) + { + return; + } + if self.agents_overview.request_id.is_some() { + self.agents_overview.refresh_pending = true; + return; + } + if self.agents_overview.initialized && self.agents_overview.refresh_thread_ids.is_empty() { + return; + } + + let request_id = Uuid::new_v4(); + self.agents_overview.request_id = Some(request_id); + let initialized = self.agents_overview.initialized; + let mut thread_ids = std::mem::take(&mut self.agents_overview.refresh_thread_ids); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + let refresh_task = tokio::spawn(async move { + let result = async { + let mut threads = HashMap::new(); + let mut recent_seed_complete = true; + if !initialized { + let loaded = request_handle.request_typed::( + ClientRequest::ThreadLoadedList { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadLoadedListParams { + cursor: None, + limit: None, + }, + }, + ); + let list_recent = async |source_kinds: Vec| { + let mut recent = Vec::new(); + let mut cursor = None; + let mut sort_key = ThreadSortKey::RecencyAt; + while recent.len() < 20 { + let page = match request_handle + .request_typed::(ClientRequest::ThreadList { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadListParams { + cursor, + limit: Some(20), + sort_key: Some(sort_key), + sort_direction: None, + model_providers: Some(Vec::new()), + source_kinds: Some(source_kinds.clone()), + archived: Some(false), + section_id: None, + project_id: None, + parent_thread_id: None, + ancestor_thread_id: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }, + }) + .await + { + Err(TypedRequestError::Server { source, .. }) + if sort_key == ThreadSortKey::RecencyAt + && matches!(source.code, -32600 | -32602) + && source.message.contains("recency_at") => + { + // Older servers can still provide their activity-sorted history. + sort_key = ThreadSortKey::UpdatedAt; + cursor = None; + recent.clear(); + continue; + } + result => result?, + }; + recent.extend( + page.data + .into_iter() + .filter(|thread| { + !thread.ephemeral + && thread.parent_thread_id.is_none() + && !matches!( + thread.source, + SessionSource::SubAgent( + SubAgentSource::ThreadSpawn { .. } + ) + ) + }) + .take(20 - recent.len()), + ); + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + Ok::<_, TypedRequestError>(recent) + }; + let recent = async { + // Default interactive sources include Atlas/ChatGPT, which have no + // explicit source kind. Exec/AppServer require a separate query. + let (interactive, non_interactive) = tokio::join!( + list_recent(Vec::new()), + list_recent(vec![ThreadSourceKind::Exec, ThreadSourceKind::AppServer]), + ); + let mut recent = interactive?; + recent.extend(non_interactive?); + recent.sort_by(|left, right| { + right + .recency_at + .unwrap_or(right.updated_at) + .cmp(&left.recency_at.unwrap_or(left.updated_at)) + .then_with(|| right.id.cmp(&left.id)) + }); + recent.truncate(20); + Ok::<_, TypedRequestError>(recent) + }; + let (loaded, recent) = tokio::join!(loaded, recent); + let loaded = loaded.map_err(|error| error.to_string())?; + let recent = recent.unwrap_or_else(|error| { + tracing::warn!(%error, "failed to list recent agent threads"); + recent_seed_complete = false; + Vec::new() + }); + thread_ids.extend( + loaded + .data + .into_iter() + .filter_map(|id| ThreadId::from_string(&id).ok()), + ); + // Keep list metadata even if a subsequent read fails transiently. + for thread in recent { + if let Ok(thread_id) = ThreadId::from_string(&thread.id) { + threads.insert(thread_id, Some(thread)); + thread_ids.insert(thread_id); + } + } + } + + let mut reads = tokio::task::JoinSet::new(); + for thread_id in thread_ids { + threads.entry(thread_id).or_default(); + let request_handle = request_handle.clone(); + reads.spawn(async move { + match request_handle + .request_typed::(ClientRequest::ThreadRead { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadReadParams { + thread_id: thread_id.to_string(), + include_turns: false, + }, + }) + .await + { + Ok(mut response) => { + if let Ok(turns) = request_handle + .request_typed::( + ClientRequest::ThreadTurnsList { + request_id: RequestId::String( + Uuid::new_v4().to_string(), + ), + params: ThreadTurnsListParams { + thread_id: thread_id.to_string(), + cursor: None, + limit: Some(1), + sort_direction: None, + items_view: None, + }, + }, + ) + .await + && let Some(ThreadItem::UserMessage { content, .. }) = + turns.data.first().and_then(|turn| turn.items.first()) + { + response.thread.preview = + ChatWidget::user_message_display_from_inputs(content) + .message; + } + Some((thread_id, response.thread)) + } + Err(error) => { + tracing::warn!(%thread_id, %error, "failed to read agent thread"); + None + } + } + }); + if reads.len() >= 16 + && let Some(Ok(Some((thread_id, thread)))) = reads.join_next().await + { + threads.insert(thread_id, Some(thread)); + } + } + while let Some(result) = reads.join_next().await { + if let Ok(Some((thread_id, thread))) = result { + threads.insert(thread_id, Some(thread)); + } + } + Ok(AgentsOverviewThreadRefresh { + threads, + recent_seed_complete, + }) + } + .await; + app_event_tx.send(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }); + }); + self.agents_overview.refresh_task = Some(refresh_task.abort_handle()); + } +} diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index b0e4742c48..62d5f6dfd4 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -1,4 +1,4 @@ -//! Dashboard for inspecting and managing the daemon's active tasks. +//! Dashboard for inspecting and managing the TUI's retained daemon tasks. use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use crate::app_event::AppEvent; @@ -89,7 +89,6 @@ pub(super) struct AgentsOverviewViewState { search: String, searching: bool, pub(super) status_grouping: bool, - pub(super) refresh_task: Option, pub(super) renaming: bool, } @@ -104,20 +103,6 @@ pub(super) struct AgentsOverviewView { agents_keymap: AgentsKeymap, } -impl Drop for AgentsOverviewView { - fn drop(&mut self) { - if let Some(task) = self - .state - .lock() - .unwrap_or_else(PoisonError::into_inner) - .refresh_task - .take() - { - task.abort(); - } - } -} - impl AgentsOverviewView { pub(super) fn new( rows: Vec, diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index f5e398a6da..5783787dd5 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -137,6 +137,7 @@ impl App { .entry(thread_id) .or_default(); } + self.track_agents_overview_notification(¬ification); if matches!( ¬ification, ServerNotification::ThreadStarted(_) @@ -147,7 +148,8 @@ impl App { | ServerNotification::ThreadDeleted(_) | ServerNotification::ThreadClosed(_) ) { - self.refresh_agents_overview_threads(app_server_client); + self.repaint_agents_overview(); + self.refresh_changed_agents_overview_threads(app_server_client); } match ¬ification { ServerNotification::ServerRequestResolved(notification) => { diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 2b01c7a010..861c4f6328 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -183,7 +183,8 @@ impl App { self.config = config; if let Some(policy) = approval_policy { - self.runtime_approval_policy_override = Some(policy); + self.runtime_approval_policy_override = + Some(RuntimeApprovalPolicyOverride::Explicit(policy)); self.chat_widget.set_approval_policy(policy); } if let Err(err) = self.chat_widget.set_permission_profile_with_active_profile( @@ -236,7 +237,7 @@ impl App { let mut config = self .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; - self.apply_runtime_policy_overrides(&mut config); + self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); self.config = config; self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) @@ -297,16 +298,40 @@ impl App { } } - pub(super) fn apply_runtime_policy_overrides(&mut self, config: &mut Config) { - if let Some(policy) = self.runtime_approval_policy_override.as_ref() - && let Err(err) = config.permissions.approval_policy.set(policy.to_core()) + pub(super) fn apply_runtime_policy_overrides( + &mut self, + config: &mut Config, + scope: RuntimePolicyOverrideScope, + ) { + if let Some(policy) = self.runtime_approval_policy_override + && (scope == RuntimePolicyOverrideScope::All + || matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_))) + && let Err(err) = config + .permissions + .approval_policy + .set(policy.policy().to_core()) { tracing::warn!(%err, "failed to carry forward approval policy override"); self.chat_widget.add_error_message(format!( "Failed to carry forward approval policy override: {err}" )); } - if let Some(profile_override) = self.runtime_permission_profile_override.as_ref() { + if let Some(profile_override) = self.runtime_permission_profile_override.as_ref() + && (scope == RuntimePolicyOverrideScope::All + || profile_override.turn_override + == RuntimePermissionProfileTurnOverride::LegacySandbox) + { + match config + .config_layer_stack + .requirements() + .approvals_reviewer + .can_set(&profile_override.approvals_reviewer) + { + Ok(()) => config.approvals_reviewer = profile_override.approvals_reviewer, + Err(error) => self.chat_widget.add_error_message(format!( + "Failed to carry forward approvals reviewer: {error}" + )), + } match config .permissions .set_permission_profile_from_session_snapshot( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 693892539f..d2ea7c5204 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2293,7 +2293,8 @@ impl App { self.config = config; let approval_policy = AskForApproval::from(self.config.permissions.approval_policy.value()); - self.runtime_approval_policy_override = Some(approval_policy); + self.runtime_approval_policy_override = + Some(RuntimeApprovalPolicyOverride::Explicit(approval_policy)); self.chat_widget.set_approval_policy(approval_policy); self.sync_active_thread_permission_settings_to_cached_session() .await; diff --git a/codex-rs/tui/src/app/permission_shortcuts.rs b/codex-rs/tui/src/app/permission_shortcuts.rs index 8922636d3d..4b1526df51 100644 --- a/codex-rs/tui/src/app/permission_shortcuts.rs +++ b/codex-rs/tui/src/app/permission_shortcuts.rs @@ -51,7 +51,7 @@ impl App { self.chat_widget.set_approval_policy(AskForApproval::from(config.permissions.approval_policy.value())); self.config.permissions = config.permissions.clone(); self.set_approvals_reviewer_in_app_and_widget(config.approvals_reviewer); - self.runtime_approval_policy_override = selection.approval_policy; + self.runtime_approval_policy_override = selection.approval_policy.map(RuntimeApprovalPolicyOverride::Explicit); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&config)); self.sync_active_thread_permission_settings_to_cached_session().await; self.insert_history_cell(tui, Box::new(history_cell::new_info_event(format!("Permissions updated to {}", selection.display_label), /*hint*/ None))); diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 19b724e9a5..f10c1352cb 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -126,7 +126,9 @@ impl App { } // 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 + if let Some(policy) = self + .runtime_approval_policy_override + .map(RuntimeApprovalPolicyOverride::policy) && policy == cached.approval_policy { session.approval_policy = policy; @@ -169,6 +171,12 @@ impl App { self.overlay = None; self.commit_animation = None; self.clear_recap_request(crate::app_event::RecapTrigger::Manual); + if let Some(task) = self.agents_overview.refresh_task.take() { + task.abort(); + } + self.agents_overview.request_id = None; + self.agents_overview.refresh_pending = false; + self.agents_overview.refresh_notifications.clear(); self.reconnect.presentation = if self .chat_widget .selected_index_for_active_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID) @@ -176,12 +184,7 @@ impl App { { if let Ok(mut state) = self.agents_overview.view_state.lock() { state.connection_notice = Some("Reconnecting — agent list is stale"); - if let Some(task) = state.refresh_task.take() { - task.abort(); - } } - self.agents_overview.request_id = None; - self.agents_overview.refresh_pending = false; ReconnectPresentation::Overview } else { self.chat_widget @@ -342,11 +345,21 @@ impl App { self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.chat_widget.restore_reconnected_input(input); } + // Discover tasks whose notifications were missed, without clearing retained rows. + // A hidden overview performs this discovery when it is next opened. + self.agents_overview.initialized = false; 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); + let threads = self + .agents_overview + .threads + .values() + .flatten() + .cloned() + .collect(); + let view = self.agents_overview_view(threads, selected); self.chat_widget.show_bottom_pane_view(Box::new(view)); self.refresh_agents_overview_threads(app_server); } diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs new file mode 100644 index 0000000000..a4fed62540 --- /dev/null +++ b/codex-rs/tui/src/app/resume_config.rs @@ -0,0 +1,101 @@ +//! Shared configuration and working-directory resolution for ordinary and overview cold resumes. +//! Keeps CLI/runtime cwd precedence, remote-workspace checks, and interactive prompts aligned. + +use super::*; +use codex_config::types::ResumeCwdMode; + +impl App { + pub(super) async fn resume_config_for_target( + &mut self, + tui: &mut tui::Tui, + app_server: &AppServerSession, + target_session: &SessionTarget, + ) -> std::result::Result { + self.refresh_in_memory_config_from_disk_best_effort("resuming a thread") + .await; + let cwd_override = self + .runtime_working_directory_override + .as_deref() + .or(self.harness_overrides.cwd.as_deref()) + .or_else(|| app_server.remote_cwd_override()); + let resume_cwd_mode = crate::session_resume::effective_resume_cwd_mode( + self.config.tui_resume_cwd, + cwd_override, + ); + let remembered_current_cwd = cwd_override.unwrap_or(self.launch_cwd.as_path()); + let current_cwd = if matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) { + remembered_current_cwd.to_path_buf() + } else { + self.config.cwd.to_path_buf() + }; + let uses_remote_workspace_or_environment = crate::uses_remote_workspace_or_environment( + &self.app_server_target, + &self.environment_manager, + ); + if uses_remote_workspace_or_environment + && self.harness_overrides.cwd.is_none() + && app_server.remote_cwd_override().is_none() + && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) + { + self.chat_widget.add_error_message( + "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" + .to_string(), + ); + return Err(AppRunControl::Continue); + } + let resume_cwd = if self.app_server_target.uses_remote_workspace() { + current_cwd.clone() + } else { + let outcome = crate::session_resume::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + self.state_db.as_deref(), + target_session, + CwdPromptAction::Resume, + crate::session_resume::ResumeCwdContext { + current_cwd: ¤t_cwd, + remembered_current_cwd, + allow_remember_current: !uses_remote_workspace_or_environment + || cwd_override.is_some(), + mode: resume_cwd_mode, + }, + ) + .await; + match outcome { + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to determine working directory for resume: {err}" + )); + return Err(AppRunControl::Continue); + } + Ok(crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd))) + | Ok(crate::session_resume::ResolveCwdOutcome::ContinueAfterPrompt(cwd)) => cwd, + Ok(crate::session_resume::ResolveCwdOutcome::Continue(None)) => current_cwd.clone(), + Ok(crate::session_resume::ResolveCwdOutcome::Exit) => { + return Err(AppRunControl::Exit(ExitReason::UserRequested)); + } + } + }; + + let (config_current_cwd, config_resume_cwd) = + if self.app_server_target.uses_remote_workspace() { + let local_config_cwd = self.config.cwd.to_path_buf(); + (local_config_cwd.clone(), local_config_cwd) + } else { + (current_cwd, resume_cwd) + }; + let resume_config = match self + .rebuild_config_for_resume_or_fallback(&config_current_cwd, config_resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Err(AppRunControl::Continue); + } + }; + Ok(resume_config) + } +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 41105a1b3b..8d78551255 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -13,7 +13,6 @@ use super::app_server_event_targets::server_request_thread_id; 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)] @@ -1087,92 +1086,14 @@ impl App { return Ok(AppRunControl::Continue); } - self.refresh_in_memory_config_from_disk_best_effort("resuming a thread") - .await; - let cwd_override = self - .runtime_working_directory_override - .as_deref() - .or(self.harness_overrides.cwd.as_deref()) - .or_else(|| app_server.remote_cwd_override()); - let resume_cwd_mode = crate::session_resume::effective_resume_cwd_mode( - self.config.tui_resume_cwd, - cwd_override, - ); - let remembered_current_cwd = cwd_override.unwrap_or(self.launch_cwd.as_path()); - let current_cwd = if matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) { - remembered_current_cwd.to_path_buf() - } else { - self.config.cwd.to_path_buf() - }; - let uses_remote_workspace_or_environment = crate::uses_remote_workspace_or_environment( - &self.app_server_target, - &self.environment_manager, - ); - if uses_remote_workspace_or_environment - && self.harness_overrides.cwd.is_none() - && app_server.remote_cwd_override().is_none() - && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) - { - self.chat_widget.add_error_message( - "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" - .to_string(), - ); - return Ok(AppRunControl::Continue); - } - let resume_cwd = if self.app_server_target.uses_remote_workspace() { - current_cwd.clone() - } else { - let outcome = crate::session_resume::resolve_cwd_for_resume_or_fork( - tui, - &self.config, - self.state_db.as_deref(), - &target_session, - CwdPromptAction::Resume, - crate::session_resume::ResumeCwdContext { - current_cwd: ¤t_cwd, - remembered_current_cwd, - allow_remember_current: !uses_remote_workspace_or_environment - || cwd_override.is_some(), - mode: resume_cwd_mode, - }, - ) - .await; - match outcome { - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to determine working directory for resume: {err}" - )); - return Ok(AppRunControl::Continue); - } - Ok(crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd))) - | Ok(crate::session_resume::ResolveCwdOutcome::ContinueAfterPrompt(cwd)) => cwd, - Ok(crate::session_resume::ResolveCwdOutcome::Continue(None)) => current_cwd.clone(), - Ok(crate::session_resume::ResolveCwdOutcome::Exit) => { - return Ok(AppRunControl::Exit(ExitReason::UserRequested)); - } - } - }; - - let (config_current_cwd, config_resume_cwd) = - if self.app_server_target.uses_remote_workspace() { - let local_config_cwd = self.config.cwd.to_path_buf(); - (local_config_cwd.clone(), local_config_cwd) - } else { - (current_cwd, resume_cwd) - }; let mut resume_config = match self - .rebuild_config_for_resume_or_fallback(&config_current_cwd, config_resume_cwd) + .resume_config_for_target(tui, app_server, &target_session) .await { - Ok(cfg) => cfg, - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to rebuild configuration for resume: {err}" - )); - return Ok(AppRunControl::Continue); - } + Ok(config) => config, + Err(control) => return Ok(control), }; - self.apply_runtime_policy_overrides(&mut resume_config); + self.apply_runtime_policy_overrides(&mut resume_config, RuntimePolicyOverrideScope::All); let summary = session_summary( self.chat_widget.token_usage(), diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 90db6714f8..06c9402ce6 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -551,6 +551,8 @@ See the Codex keymap documentation for supported actions and examples." } if start_in_agents_overview { app.open_agents_overview(&app_server); + } else if !matches!(app.app_server_target, AppServerTarget::Embedded) { + app.refresh_agents_overview_threads(&app_server); } if let Some(entry) = startup_hooks_browser { app.chat_widget.open_hooks_browser(entry); diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index 8ebf868eeb..c1a81a9e07 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -21,7 +21,9 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> app.config .permissions .set_permission_profile(PermissionProfile::read_only())?; - app.runtime_approval_policy_override = Some(AskForApproval::OnRequest); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + AskForApproval::OnRequest, + )); app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&app.config)); app.active_thread_id = Some(id); diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 2da167a445..d8b59813c2 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -5,7 +5,9 @@ use super::disconnect::serve_reconnect_requests; use super::*; use crate::app::reconnect::ReconnectPresentation; use crate::app::reconnect::reconnect; +use crate::app_event::AgentsOverviewThreadRefresh; use crate::app_server_session::ThreadParamsMode; +use codex_app_server_client::AppServerEvent; use pretty_assertions::assert_eq; use serde_json::json; @@ -18,10 +20,11 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv 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), + for (previous_thread, changed_child_permissions, overview_initialized) in [ + (None, false, false), + (None, false, true), + (Some(ThreadId::new()), false, true), + (Some(ThreadId::new()), true, true), ] { let (mut app, mut events, _) = make_test_app_with_channels().await; app.keymap.app.open_agents = vec![crate::key_hint::alt(KeyCode::Char('a'))]; @@ -37,7 +40,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv app.config .permissions .set_permission_profile(PermissionProfile::read_only())?; - app.runtime_approval_policy_override = Some(AskForApproval::OnRequest); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + 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()); @@ -118,7 +123,12 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv }, )?, ]; - app.agents_overview.threads = stale.clone(); + let stale_threads = stale + .iter() + .map(|thread| Ok((ThreadId::from_string(&thread.id)?, Some(thread.clone())))) + .collect::>>()?; + app.agents_overview.threads = stale_threads.clone(); + app.agents_overview.initialized = overview_initialized; let view = app.agents_overview_view( stale.clone(), Some(if previous_thread.is_some() { @@ -135,7 +145,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv 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()); + app.agents_overview.refresh_task = Some(refresh.abort_handle()); let directory = tempfile::tempdir()?; let socket_path = directory.path().join("daemon.sock"); let listener = UnixListener::bind(&socket_path)?; @@ -187,6 +197,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv Some(json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}})) } "thread/loaded/list" => Some(json!({"result": {"data": [selected, added], "nextCursor": null}})), + "thread/list" => Some(json!({"result": {"data": [], "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}})), @@ -196,6 +207,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv 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" if request.params.as_ref().unwrap()["threadId"] == vanished.to_string() => Some(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}"), } @@ -232,7 +244,14 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv ), (None, false) ); - app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(Vec::new())); + app.apply_agents_overview_thread_refresh( + &session, + stale_request, + Ok(AgentsOverviewThreadRefresh { + threads: HashMap::new(), + recent_seed_complete: false, + }), + ); assert_eq!(app.agents_overview.visible_thread_ids.len(), 2); let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_tui_event( @@ -293,6 +312,18 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv Ok::<_, color_eyre::Report>(()) }) .await??; + assert!(app.agents_overview.visible_thread_ids.contains(&vanished)); + + // Missing metadata alone does not evict a retained row; an archive notification does. + app.handle_app_server_event( + &session, + AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadArchived( + ThreadArchivedNotification { + thread_id: vanished.to_string(), + }, + ))), + ) + .await; let index = app .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) @@ -336,7 +367,14 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv .connection_notice .is_none() ); - app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(stale)); + app.apply_agents_overview_thread_refresh( + &session, + stale_request, + Ok(AgentsOverviewThreadRefresh { + threads: stale_threads, + recent_seed_complete: true, + }), + ); assert!(!app.agents_overview.visible_thread_ids.contains(&vanished)); if let Some(id) = previous_thread { @@ -407,7 +445,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv &PermissionProfile::Disabled ); if changed_child_permissions { - app.runtime_approval_policy_override = Some(AskForApproval::Never); + app.runtime_approval_policy_override = Some( + RuntimeApprovalPolicyOverride::Explicit(AskForApproval::Never), + ); app.runtime_permission_profile_override = Some( RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), ); 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 dfdb77af32..55e1dc34e9 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -4,6 +4,7 @@ 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; use app_test_support::rollout_path; +use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::JSONRPCError; @@ -47,6 +48,7 @@ enum HistoryCapabilities { LegacyOnlyUnsupportedVariant, LegacyDynamicToolsAndHistory, ForkHydrationFails, + ThreadListFails, } /// Returns and resets `(thread/loaded/list, thread/read)` request counts. @@ -127,6 +129,7 @@ async fn start_recording_app_server_with_history( let mut websocket = accept_async(stream).await?; let mut inventories = usize::from(failed_thread_name == Some("background")); let mut reject_detach = false; + let mut reject_thread_list = history_capabilities == HistoryCapabilities::ThreadListFails; while let Some(frame) = websocket.next().await { let Message::Text(text) = frame? else { continue; @@ -182,7 +185,30 @@ async fn start_recording_app_server_with_history( .is_some_and(|tools| { tools.iter().any(|tool| tool["type"] == "namespace") }); - let response = if reject_dynamic_tools { + let response = if request.method == "thread/list" + && std::mem::take(&mut reject_thread_list) + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32603, + data: None, + message: "thread listing unavailable".to_string(), + }, + }) + } else if history_capabilities == HistoryCapabilities::LegacyOnly + && request.method == "thread/list" + && params.is_some_and(|params| params["sortKey"] == "recency_at") + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32602, + data: None, + message: "unknown variant `recency_at`".to_string(), + }, + }) + } else if reject_dynamic_tools { JSONRPCMessage::Error(JSONRPCError { id: request_id, error: JSONRPCErrorError { @@ -2367,6 +2393,88 @@ async fn agents_overview_stop_uses_history_mode_for_turn_lookup() -> Result<()> Ok(()) } +#[tokio::test] +async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable() -> Result<()> { + for (capabilities, expected_sort_keys) in [ + ( + HistoryCapabilities::LegacyOnly, + vec!["recency_at", "recency_at", "updated_at", "updated_at"], + ), + ( + HistoryCapabilities::ThreadListFails, + vec!["recency_at", "recency_at", "recency_at", "recency_at"], + ), + ] { + let (mut app, _codex_home) = make_history_test_app().await?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + capabilities, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + ) + .await?; + let started = app_server.start_thread(&app.config).await?; + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: test_path_buf("/tmp/unused.sock").abs(), + }, + }; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + for attempt in 0..2 { + if attempt == 0 { + app.refresh_agents_overview_threads(&app_server); + } else { + app.open_agents_overview(&app_server); + } + let Some(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }) = + tokio::time::timeout(Duration::from_secs(10), rx.recv()).await? + else { + panic!("expected overview result") + }; + app.apply_agents_overview_thread_refresh(&app_server, request_id, result); + assert_eq!( + app.agents_overview + .threads + .keys() + .copied() + .collect::>(), + vec![started.session.thread_id] + ); + assert_eq!( + app.agents_overview.initialized, + capabilities != HistoryCapabilities::ThreadListFails || attempt > 0 + ); + if attempt == 0 { + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new( + ServerNotification::ThreadStatusChanged( + codex_app_server_protocol::ThreadStatusChangedNotification { + thread_id: started.session.thread_id.to_string(), + status: codex_app_server_protocol::ThreadStatus::Idle, + }, + ), + )), + ) + .await; + assert!(app.agents_overview.request_id.is_none()); + } + } + let list_requests = recorded_params(&requests, "thread/list"); + let mut sort_keys = list_requests + .iter() + .map(|params| params["sortKey"].as_str().unwrap()) + .collect::>(); + sort_keys.sort_unstable(); + assert_eq!(sort_keys, expected_sort_keys); + app_server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + #[tokio::test] async fn agents_overview_stop_uses_full_history_after_legacy_negotiation() -> Result<()> { let (mut app, _codex_home) = make_history_test_app().await?; @@ -2698,8 +2806,9 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook matches!(kind, "approval" | "profile" | "reviewer").then_some(requirements.clone()); app.harness_overrides.permission_profile = (kind != "named").then_some(PermissionProfile::workspace_write()); - app.runtime_approval_policy_override = - (kind == "approval").then_some(AskForApproval::OnRequest); + app.runtime_approval_policy_override = (kind == "approval").then_some( + RuntimeApprovalPolicyOverride::Explicit(AskForApproval::OnRequest), + ); let mut profile = RuntimePermissionProfileOverride::from_config(&app.config); profile.active_permission_profile = (kind == "named").then(|| ActivePermissionProfile::new("dev")); 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 index f932ed13ef..88842c333e 100644 --- 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 @@ -6,6 +6,7 @@ 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. +• The rename target disappeared. Unsubmitted title: Keep this task draft! › latest draft gpt-test default · /tmp/project diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index dcce3111c3..c0b15fe886 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1309,6 +1309,7 @@ impl App { self.recap.reset_for_new_thread(Instant::now()); } self.primary_thread_id = Some(thread_id); + self.agents_overview.threads.entry(thread_id).or_default(); self.primary_session_configured = Some(session.clone()); self.upsert_agent_picker_thread( thread_id, /*agent_nickname*/ None, /*agent_role*/ None, diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index c5d96291db..18bff26695 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -73,7 +73,7 @@ impl App { { return self.working_directory_error("Permission profile cannot be preserved by /cd."); } - self.apply_runtime_policy_overrides(&mut config); + self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); if self.runtime_permission_profile_override.is_some() { let reviewer = self.config.approvals_reviewer; let reviewers = &config.config_layer_stack.requirements().approvals_reviewer; @@ -83,7 +83,9 @@ impl App { config.approvals_reviewer = reviewer; } let actual = config.permissions.approval_policy.value(); - let approval = self.runtime_approval_policy_override; + let approval = self + .runtime_approval_policy_override + .map(RuntimeApprovalPolicyOverride::policy); let profile = self.runtime_permission_profile_override.as_ref(); if approval.is_some_and(|p| actual != p.to_core()) || profile.is_some_and(|profile| !profile.matches_config(&config)) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0e10c5cdac..1592aef9af 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -213,15 +213,21 @@ pub(crate) enum RecapTrigger { Manual, } +#[derive(Debug)] +pub(crate) struct AgentsOverviewThreadRefresh { + pub(crate) threads: std::collections::HashMap>, + pub(crate) recent_seed_complete: bool, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug, IntoStaticStr)] pub(crate) enum AppEvent { - /// Open the daemon-wide overview of loaded root sessions. + /// Open the daemon-wide overview of recent and locally retained root sessions. OpenAgentsOverview, /// Update the daemon-wide overview after a background thread listing finishes. AgentsOverviewThreadsLoaded { request_id: Uuid, - result: Result, String>, + result: Result, }, /// Switch to a root session selected from the shared dashboard. SelectAgentsOverviewThread { diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap index 19c033e869..5b81642a76 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap @@ -1,14 +1,14 @@ --- source: tui/src/app/agents_overview_tests.rs -expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&test_path_display(\"/tmp/project\"), \"/tmp/project\")" +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 3\"),\n&normalized_project_group).replace(&project, \"/tmp/project\")" --- Agent command center 1 need input 1 working 0 ready ──────────────────────────────────────────────────────────────────────────────────────────── - /tmp/project 2 │ Task details + /tmp/project 3 │ Task details › ● Build the dashboard current Needs input │ ● Repair authentication Working │ Build the dashboard - │ ● Needs input + ✓ Review yesterday's changes Finished │ ● Needs input │ │ Project │ /tmp/project