From 3c7ae4a81204cbee45084b4fb43b3630c79550c9 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 15 Aug 2026 10:52:25 +0000 Subject: [PATCH] Scope TUI app directory state to the active context (#38743) ## Why App directory data and in-flight requests can outlive the account, workspace, or thread that produced them, allowing stale apps to appear in the current TUI context. ## What changed - Invalidate cached app data, dismiss the app picker, and start a fresh fetch when the account, workspace, or thread changes. - Tag app directory fetches with their originating thread, workspace, and scope generation, and ignore queued requests or results that no longer match. - Treat `AppListUpdated` notifications as revalidation signals instead of directly adopting their unscoped contents, with deduplication and a bounded error retry. ## Testing Added coverage for stale request and result rejection, context invalidation, picker dismissal, and notification revalidation. GitOrigin-RevId: f20930a0598a6abbdfb3c7826ec955346fe2da7a --- codex-rs/tui/src/app/app_server_events.rs | 23 +- codex-rs/tui/src/app/background_requests.rs | 19 +- codex-rs/tui/src/app/event_dispatch.rs | 24 +- codex-rs/tui/src/app/tests.rs | 2 + .../tui/src/app/tests/connector_policy.rs | 253 ++++++++++++++++++ codex-rs/tui/src/app_event.rs | 7 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/connectors.rs | 86 +++++- codex-rs/tui/src/chatwidget/session_flow.rs | 5 + codex-rs/tui/src/chatwidget/settings.rs | 6 + ...__tests__connector_scope_invalidation.snap | 18 ++ .../tui/src/chatwidget/tests/app_server.rs | 19 +- .../chatwidget/tests/popups_and_settings.rs | 70 +++++ 13 files changed, 505 insertions(+), 28 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/connector_policy.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__connector_scope_invalidation.snap diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 65536d6a65..b3605b3691 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -6,7 +6,6 @@ use super::app_server_event_targets::server_notification_thread_target; use super::app_server_event_targets::server_request_thread_id; use crate::app_command::AppCommand; use crate::app_event::AppEvent; -use crate::app_event::ConnectorsSnapshot; use crate::app_info::app_info_from_api; use crate::app_server_session::AppServerSession; use crate::app_server_session::status_account_display_from_auth_mode; @@ -162,17 +161,17 @@ impl App { return; } ServerNotification::AppListUpdated(notification) => { - self.chat_widget.on_connectors_loaded( - Ok(ConnectorsSnapshot { - connectors: notification - .data - .iter() - .cloned() - .map(app_info_from_api) - .collect(), - }), - /*is_final*/ false, - ); + if self.current_displayed_thread_id().is_some() { + self.chat_widget + .refresh_connector_directory_after_notification( + notification + .data + .iter() + .cloned() + .map(app_info_from_api) + .collect(), + ); + } return; } _ => {} diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 8b1c39f023..4f14c69070 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -246,14 +246,21 @@ impl App { ) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); - let thread_id = self - .current_displayed_thread_id() - .map(|thread_id| thread_id.to_string()); + let thread_id = self.current_displayed_thread_id(); + let cwd = self.chat_widget.config_ref().cwd.to_path_buf(); + let generation = self.chat_widget.connector_scope_generation(); tokio::spawn(async move { - let result = fetch_connectors_list(request_handle, force_refetch, thread_id) - .await - .map_err(|err| err.to_string()); + let result = fetch_connectors_list( + request_handle, + force_refetch, + thread_id.map(|thread_id| thread_id.to_string()), + ) + .await + .map_err(|err| err.to_string()); app_event_tx.send(AppEvent::ConnectorsLoaded { + thread_id, + cwd, + generation, result, is_final: true, }); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index eb24dda995..1a23f6f06a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -765,8 +765,13 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } - AppEvent::FetchConnectorsList { force_refetch } => { - self.fetch_connectors_list(app_server, force_refetch); + AppEvent::FetchConnectorsList { + force_refetch, + generation, + } => { + if generation == self.chat_widget.connector_scope_generation() { + self.fetch_connectors_list(app_server, force_refetch); + } } AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { if refresh_connectors { @@ -1285,8 +1290,19 @@ impl App { AppEvent::CommitPendingUsageOutputAfterStreamShutdown => { self.insert_pending_usage_output_after_stream_shutdown(tui); } - AppEvent::ConnectorsLoaded { result, is_final } => { - self.chat_widget.on_connectors_loaded(result, is_final); + AppEvent::ConnectorsLoaded { + thread_id, + cwd, + generation, + result, + is_final, + } => { + if thread_id == self.current_displayed_thread_id() + && cwd.as_path() == self.chat_widget.config_ref().cwd.as_path() + && generation == self.chat_widget.connector_scope_generation() + { + self.chat_widget.on_connectors_loaded(result, is_final); + } } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort.clone()); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 8069a02172..95fbd2f4b1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4,6 +4,8 @@ mod advanced_reasoning_tests; #[path = "tests/background_exit_tests.rs"] mod background_exit_tests; +#[path = "tests/connector_policy.rs"] +mod connector_policy; #[path = "tests/key_chords.rs"] mod key_chords; #[path = "tests/mcp_startup.rs"] diff --git a/codex-rs/tui/src/app/tests/connector_policy.rs b/codex-rs/tui/src/app/tests/connector_policy.rs new file mode 100644 index 0000000000..2a9ab3cb53 --- /dev/null +++ b/codex-rs/tui/src/app/tests/connector_policy.rs @@ -0,0 +1,253 @@ +use super::*; +use crate::app_event::ConnectorsSnapshot; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::AppListUpdatedNotification; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn connector_directory_results_ignore_stale_thread_workspace_and_account() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget + .set_feature_enabled(Feature::Apps, /*enabled*/ true); + let thread_id = ThreadId::new(); + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + app.chat_widget + .handle_thread_session(test_thread_session(thread_id, cwd.clone())); + let previous_generation = app.chat_widget.connector_scope_generation(); + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + app.chat_widget.insert_str("$"); + + let generation = app.chat_widget.connector_scope_generation(); + let snapshot = ConnectorsSnapshot { + connectors: vec![serde_json::from_str( + r#"{"id":"scoped-app","name":"Scoped App","isAccessible":true}"#, + )?], + }; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + for (candidate_thread, candidate_cwd, candidate_generation, is_current) in [ + (Some(ThreadId::new()), cwd.clone(), generation, false), + ( + Some(thread_id), + cwd.join("other-workspace"), + generation, + false, + ), + (Some(thread_id), cwd.clone(), previous_generation, false), + (Some(thread_id), cwd.clone(), generation, true), + ] { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ConnectorsLoaded { + thread_id: candidate_thread, + cwd: candidate_cwd, + generation: candidate_generation, + result: Ok(snapshot.clone()), + is_final: true, + }, + ) + .await?; + assert_eq!( + render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Scoped App"), + is_current + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn queued_connector_fetches_are_bound_to_their_original_account() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + app.chat_widget + .handle_thread_session(test_thread_session(ThreadId::new(), cwd)); + while app_event_rx.try_recv().is_ok() {} + + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + let stale_fetch = app_event_rx.try_recv()?; + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + let current_fetch = app_event_rx.try_recv()?; + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let metrics = tokio::runtime::Handle::current().metrics(); + let initial_tasks = metrics.num_alive_tasks(); + + app.handle_event(&mut tui, &mut app_server, stale_fetch) + .await?; + assert_eq!(metrics.num_alive_tasks(), initial_tasks); + app.handle_event(&mut tui, &mut app_server, current_fetch) + .await?; + assert_eq!(metrics.num_alive_tasks(), initial_tasks + 1); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn unscoped_app_list_notifications_only_revalidate_the_current_directory() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + app.chat_widget + .handle_thread_session(test_thread_session(thread_id, cwd.clone())); + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + let scoped_snapshot = ConnectorsSnapshot { + connectors: vec![serde_json::from_str( + r#"{"id":"scoped-app","name":"Scoped App","isAccessible":true}"#, + )?], + }; + app.chat_widget + .on_connectors_loaded(Ok(scoped_snapshot.clone()), /*is_final*/ true); + app.chat_widget.insert_str("$"); + while app_event_rx.try_recv().is_ok() {} + + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + for (id, name, should_refresh) in [ + ("scoped-app", "Scoped App", false), + ("old-workspace", "Old Workspace", true), + ("old-workspace", "Old Workspace", false), + ] { + let notification = ServerNotification::AppListUpdated(AppListUpdatedNotification { + data: vec![serde_json::from_value(serde_json::json!({ + "id": id, "name": name, "isAccessible": true + }))?], + }); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(notification)), + ) + .await; + + let refresh_requested = matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchConnectorsList { + force_refetch: false, + .. + }) + ); + assert_eq!(refresh_requested, should_refresh); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 80); + assert!(popup.contains("Scoped App")); + assert!(!popup.contains("Old Workspace")); + } + + let ready_app = r#"{"id":"newly-ready","name":"Newly Ready","isAccessible":true}"#; + let ready_notification = ServerNotification::AppListUpdated(AppListUpdatedNotification { + data: vec![serde_json::from_str(ready_app)?], + }); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ready_notification.clone())), + ) + .await; + assert!(app_event_rx.try_recv().is_err()); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Newly Ready")); + + let mut tui = crate::tui::test_support::make_test_tui()?; + let generation = app.chat_widget.connector_scope_generation(); + for should_retry in [true, false, true, false] { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ConnectorsLoaded { + thread_id: Some(thread_id), + cwd: cwd.clone(), + generation, + result: Err("app directory failed".to_string()), + is_final: true, + }, + ) + .await?; + assert_eq!( + matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchConnectorsList { + force_refetch: false, + .. + }) + ), + should_retry + ); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Newly Ready")); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ready_notification.clone())), + ) + .await; + assert_eq!(app_event_rx.try_recv().is_ok(), !should_retry); + if !should_retry { + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ready_notification.clone())), + ) + .await; + assert!(app_event_rx.try_recv().is_err()); + } + } + + for (snapshot, should_revalidate) in [ + (scoped_snapshot, true), + ( + ConnectorsSnapshot { + connectors: vec![serde_json::from_str(ready_app)?], + }, + false, + ), + ] { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ConnectorsLoaded { + thread_id: Some(thread_id), + cwd: cwd.clone(), + generation, + result: Ok(snapshot), + is_final: true, + }, + ) + .await?; + assert_eq!( + matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchConnectorsList { + force_refetch: false, + .. + }) + ), + should_revalidate + ); + if should_revalidate { + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ready_notification.clone())), + ) + .await; + assert!(app_event_rx.try_recv().is_err()); + } + } + app.chat_widget.add_connectors_output(); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Newly Ready")); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 01dc7549a0..6fde0759d6 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -47,6 +47,7 @@ use crate::app_server_session::AppServerStartedThread; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; +use crate::chatwidget::ConnectorScopeGeneration; use crate::chatwidget::ThreadUsageOutcome; use crate::chatwidget::UserMessage; use crate::goal_files::GoalDraft; @@ -495,6 +496,9 @@ pub(crate) enum AppEvent { /// Result of prefetching connectors. ConnectorsLoaded { + thread_id: Option, + cwd: PathBuf, + generation: ConnectorScopeGeneration, result: Result, is_final: bool, }, @@ -560,9 +564,10 @@ pub(crate) enum AppEvent { force_refetch: bool, }, - /// Fetch app connector state from the app server after the widget accepts a refresh request. + /// Fetch apps only while the originating account, workspace, and thread remain current. FetchConnectorsList { force_refetch: bool, + generation: ConnectorScopeGeneration, }, /// Fetch plugin marketplace state for the provided working directory. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 03b51a17cc..2e31278911 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -336,6 +336,7 @@ use crate::tui::FrameRequester; mod command_lifecycle; mod connectors; mod constructor; +pub(crate) use self::connectors::ConnectorScopeGeneration; use self::connectors::ConnectorsState; mod exec_state; use self::exec_state::RunningCommand; diff --git a/codex-rs/tui/src/chatwidget/connectors.rs b/codex-rs/tui/src/chatwidget/connectors.rs index 683bff135b..ed6d89e9df 100644 --- a/codex-rs/tui/src/chatwidget/connectors.rs +++ b/codex-rs/tui/src/chatwidget/connectors.rs @@ -2,6 +2,14 @@ use super::*; use crate::app_event::ConnectorsSnapshot; +use std::sync::atomic::AtomicU64; + +/// Prevents stale directory requests from matching a replacement widget for the same thread. +static NEXT_CONNECTOR_SCOPE_GENERATION: AtomicU64 = AtomicU64::new(1); + +/// Identifies one account-, workspace-, and thread-scoped connector state. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct ConnectorScopeGeneration(u64); #[derive(Debug, Clone, Default)] pub(super) enum ConnectorsCacheState { @@ -18,6 +26,12 @@ pub(super) struct ConnectorsState { pub(super) partial_snapshot: Option, pub(super) prefetch_in_flight: bool, pub(super) force_refetch_pending: bool, + /// Unowned notification retained only to decide whether an owned response needs revalidation. + pub(super) pending_notification: Option>, + /// Prevents a recovery request from scheduling another retry if it also fails. + pub(super) notification_error_retry_in_flight: bool, + /// Process-unique identity for this widget's account, workspace, and thread. + pub(super) generation: ConnectorScopeGeneration, } impl ChatWidget { @@ -29,10 +43,59 @@ impl ChatWidget { self.queue_connectors_refresh(/*force_refetch*/ false); } + /// Revalidate directory notifications without granting their unscoped contents to this thread. + pub(crate) fn refresh_connector_directory_after_notification( + &mut self, + connectors: Vec, + ) { + if self.thread_id.is_none() + || !self.connectors_enabled() + || self + .connectors + .partial_snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.connectors == connectors) + || matches!( + &self.connectors.cache, + ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == connectors + ) + { + return; + } + + if self.connectors.prefetch_in_flight { + self.connectors.pending_notification = Some(connectors); + return; + } + + self.queue_connectors_refresh(/*force_refetch*/ false); + } + + /// Identifies the active account, workspace, and thread across widget replacements. + pub(crate) fn connector_scope_generation(&self) -> ConnectorScopeGeneration { + self.connectors.generation + } + + /// Revoke cached apps and pending requests before their account or workspace changes. + pub(crate) fn invalidate_connector_scope(&mut self) { + let generation = ConnectorScopeGeneration( + NEXT_CONNECTOR_SCOPE_GENERATION.fetch_add(1, Ordering::Relaxed), + ); + self.connectors = ConnectorsState { + generation, + ..Default::default() + }; + self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None); + self.bottom_pane + .dismiss_view_by_id(CONNECTORS_SELECTION_VIEW_ID); + } + fn queue_connectors_refresh(&mut self, force_refetch: bool) { if self.begin_connectors_refresh(force_refetch) { - self.app_event_tx - .send(AppEvent::FetchConnectorsList { force_refetch }); + self.app_event_tx.send(AppEvent::FetchConnectorsList { + force_refetch, + generation: self.connectors.generation, + }); } } @@ -293,8 +356,23 @@ impl ChatWidget { is_final: bool, ) { let mut trigger_pending_force_refetch = false; + let mut trigger_pending_revalidation = false; if is_final { self.connectors.prefetch_in_flight = false; + let was_error_retry = + std::mem::take(&mut self.connectors.notification_error_retry_in_flight); + trigger_pending_revalidation = + self.connectors + .pending_notification + .take() + .is_some_and(|connectors| match result.as_ref() { + Ok(snapshot) => snapshot.connectors != connectors, + Err(_) if !was_error_retry => { + self.connectors.notification_error_retry_in_flight = true; + true + } + Err(_) => false, + }); if self.connectors.force_refetch_pending { self.connectors.force_refetch_pending = false; trigger_pending_force_refetch = true; @@ -344,8 +422,8 @@ impl ChatWidget { } } - if trigger_pending_force_refetch { - self.queue_connectors_refresh(/*force_refetch*/ true); + if trigger_pending_force_refetch || trigger_pending_revalidation { + self.queue_connectors_refresh(trigger_pending_force_refetch); } } diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 00ba854a85..5fda605cba 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -19,6 +19,8 @@ impl ChatWidget { self.set_skills(/*skills*/ None); self.session_network_proxy = session.network_proxy.clone(); let previous_thread_id = self.thread_id; + let connector_scope_changed = previous_thread_id != Some(session.thread_id) + || self.config.cwd.as_path() != session.cwd.as_path(); self.thread_id = Some(session.thread_id); self.bottom_pane .set_queue_submissions(/*queue_submissions*/ false); @@ -37,6 +39,9 @@ impl ChatWidget { self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); self.config.cwd = session.cwd.clone(); + if connector_scope_changed { + self.invalidate_connector_scope(); + } let runtime_workspace_roots = session.runtime_workspace_roots.clone(); self.config.workspace_roots = runtime_workspace_roots.clone(); self.config diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 779c2d98a0..3808dcd849 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -216,6 +216,7 @@ impl ChatWidget { ) { // Account-update notifications are the identity boundary. The visible account fields can // be identical across two accounts, so always invalidate account-scoped requests and data. + self.invalidate_connector_scope(); self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); self.codex_rate_limit_reached_type = None; @@ -243,6 +244,9 @@ impl ChatWidget { self.has_codex_backend_auth = has_codex_backend_auth; self.bottom_pane .set_connectors_enabled(self.connectors_enabled()); + if self.thread_id.is_some() { + self.prefetch_connectors(); + } self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); self.refresh_status_surfaces(); @@ -532,7 +536,9 @@ impl ChatWidget { self.sync_service_tier_commands(); self.sync_personality_command_enabled(); if cwd_changed { + self.invalidate_connector_scope(); self.refresh_skills_for_current_cwd(/*force_reload*/ true); + self.prefetch_connectors(); } self.refresh_plugin_mentions(); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__connector_scope_invalidation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__connector_scope_invalidation.snap new file mode 100644 index 0000000000..b6ac615744 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__connector_scope_invalidation.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: "format!(\"Before account change:\\n{before}\\n\\nAfter account change:\\n{after}\")" +--- +Before account change: + Apps + Use $ to insert an installed app into your prompt. + Installed 1 of 1 available apps. + + Type to search apps +› Previous Account App Installed. App link unavailable. + + Press enter to confirm or esc to go back + +After account change: +› Ask Codex to do anything + + gpt-5.6-sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 3edeacf40d..13ddcbbff6 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -342,9 +342,23 @@ async fn invalid_url_elicitation_is_declined() { #[tokio::test] async fn thread_settings_updated_updates_visible_state_without_transcript() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); let thread_id = ThreadId::new(); - chat.handle_thread_session(configured_thread_session(thread_id)); + let mut session = configured_thread_session(thread_id); + session.cwd = test_path_buf("/tmp/original-workspace").abs(); + chat.handle_thread_session(session); + let generation = chat.connector_scope_generation(); + chat.on_connectors_loaded( + Ok(crate::app_event::ConnectorsSnapshot { + connectors: vec![ + serde_json::from_str(r#"{"id":"old","name":"Old","isAccessible":true}"#) + .expect("valid app"), + ], + }), + /*is_final*/ true, + ); + chat.prefetch_connectors(); let _ = drain_insert_history(&mut rx); chat.handle_server_notification( @@ -352,6 +366,9 @@ async fn thread_settings_updated_updates_visible_state_without_transcript() { /*replay_kind*/ None, ); + assert_ne!(chat.connector_scope_generation(), generation); + assert!(chat.connectors_for_mentions().is_none()); + assert!(chat.connectors.prefetch_in_flight); assert_eq!(chat.current_model(), "gpt-5.4"); assert_eq!( chat.current_reasoning_effort(), diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index a2b7d63be8..f0235ab246 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2260,6 +2260,48 @@ async fn apps_notification_update_excludes_inaccessible_apps_from_mentions() { ); } +#[tokio::test] +async fn account_changes_revoke_app_directory_and_restart_scoped_requests() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + set_chatgpt_auth(&mut chat); + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![ + serde_json::from_str( + r#"{"id":"old-account","name":"Old Account","isAccessible":true}"#, + ) + .expect("valid app"), + ], + }), + /*is_final*/ true, + ); + let generation = chat.connector_scope_generation(); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + + assert_ne!(chat.connector_scope_generation(), generation); + assert!(chat.connectors_for_mentions().is_none()); + assert!(chat.connectors.prefetch_in_flight); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchConnectorsList { + force_refetch: false, + generation + }) if generation == chat.connector_scope_generation() + ); + + let (mut replacement, _, _) = make_chatwidget_manual(/*model_override*/ None).await; + replacement.invalidate_connector_scope(); + assert_ne!( + chat.connector_scope_generation(), + replacement.connector_scope_generation() + ); +} + #[tokio::test] async fn apps_refresh_failure_keeps_existing_full_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -3797,3 +3839,31 @@ async fn reasoning_popup_escape_returns_to_model_popup() { assert!(after_escape.contains("Select Model")); assert!(!after_escape.contains("Select Reasoning Level")); } + +#[tokio::test] +async fn account_change_dismisses_the_previous_app_directory_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + set_chatgpt_auth(&mut chat); + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![serde_json::from_str( + r#"{"id":"previous-account","name":"Previous Account App","isAccessible":true}"#, + ) + .expect("valid app")], + }), + /*is_final*/ true, + ); + chat.add_connectors_output(); + let before = render_bottom_popup(&chat, /*width*/ 80); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + let after = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)); + assert_chatwidget_snapshot!( + "connector_scope_invalidation", + format!("Before account change:\n{before}\n\nAfter account change:\n{after}") + ); +}