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
This commit is contained in:
Charlie Marsh authored and copyberry committed 2026-08-15 10:57:15 +00:00
1 parent 85fc4def35
commit 3c7ae4a812
13 files changed
+505 -28

No files matched your search

+11 -12
View File
@@ -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 super::app_server_event_targets::server_request_thread_id;
use crate::app_command::AppCommand; use crate::app_command::AppCommand;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_info::app_info_from_api; use crate::app_info::app_info_from_api;
use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerSession;
use crate::app_server_session::status_account_display_from_auth_mode; use crate::app_server_session::status_account_display_from_auth_mode;
@@ -162,17 +161,17 @@ impl App {
return; return;
} }
ServerNotification::AppListUpdated(notification) => { ServerNotification::AppListUpdated(notification) => {
self.chat_widget.on_connectors_loaded( if self.current_displayed_thread_id().is_some() {
Ok(ConnectorsSnapshot { self.chat_widget
connectors: notification .refresh_connector_directory_after_notification(
.data notification
.iter() .data
.cloned() .iter()
.map(app_info_from_api) .cloned()
.collect(), .map(app_info_from_api)
}), .collect(),
/*is_final*/ false, );
); }
return; return;
} }
_ => {} _ => {}
+13 -6
View File
@@ -246,14 +246,21 @@ impl App {
) { ) {
let request_handle = app_server.request_handle(); let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone(); let app_event_tx = self.app_event_tx.clone();
let thread_id = self let thread_id = self.current_displayed_thread_id();
.current_displayed_thread_id() let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
.map(|thread_id| thread_id.to_string()); let generation = self.chat_widget.connector_scope_generation();
tokio::spawn(async move { tokio::spawn(async move {
let result = fetch_connectors_list(request_handle, force_refetch, thread_id) let result = fetch_connectors_list(
.await request_handle,
.map_err(|err| err.to_string()); force_refetch,
thread_id.map(|thread_id| thread_id.to_string()),
)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::ConnectorsLoaded { app_event_tx.send(AppEvent::ConnectorsLoaded {
thread_id,
cwd,
generation,
result, result,
is_final: true, is_final: true,
}); });
+20 -4
View File
@@ -765,8 +765,13 @@ impl App {
AppEvent::RefreshConnectors { force_refetch } => { AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch); self.chat_widget.refresh_connectors(force_refetch);
} }
AppEvent::FetchConnectorsList { force_refetch } => { AppEvent::FetchConnectorsList {
self.fetch_connectors_list(app_server, force_refetch); force_refetch,
generation,
} => {
if generation == self.chat_widget.connector_scope_generation() {
self.fetch_connectors_list(app_server, force_refetch);
}
} }
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
if refresh_connectors { if refresh_connectors {
@@ -1285,8 +1290,19 @@ impl App {
AppEvent::CommitPendingUsageOutputAfterStreamShutdown => { AppEvent::CommitPendingUsageOutputAfterStreamShutdown => {
self.insert_pending_usage_output_after_stream_shutdown(tui); self.insert_pending_usage_output_after_stream_shutdown(tui);
} }
AppEvent::ConnectorsLoaded { result, is_final } => { AppEvent::ConnectorsLoaded {
self.chat_widget.on_connectors_loaded(result, is_final); 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) => { AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort.clone()); self.on_update_reasoning_effort(effort.clone());
+2
View File
@@ -4,6 +4,8 @@
mod advanced_reasoning_tests; mod advanced_reasoning_tests;
#[path = "tests/background_exit_tests.rs"] #[path = "tests/background_exit_tests.rs"]
mod background_exit_tests; mod background_exit_tests;
#[path = "tests/connector_policy.rs"]
mod connector_policy;
#[path = "tests/key_chords.rs"] #[path = "tests/key_chords.rs"]
mod key_chords; mod key_chords;
#[path = "tests/mcp_startup.rs"] #[path = "tests/mcp_startup.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(())
}
+6 -1
View File
@@ -47,6 +47,7 @@ use crate::app_server_session::AppServerStartedThread;
use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleItem;
use crate::chatwidget::ConnectorScopeGeneration;
use crate::chatwidget::ThreadUsageOutcome; use crate::chatwidget::ThreadUsageOutcome;
use crate::chatwidget::UserMessage; use crate::chatwidget::UserMessage;
use crate::goal_files::GoalDraft; use crate::goal_files::GoalDraft;
@@ -495,6 +496,9 @@ pub(crate) enum AppEvent {
/// Result of prefetching connectors. /// Result of prefetching connectors.
ConnectorsLoaded { ConnectorsLoaded {
thread_id: Option<ThreadId>,
cwd: PathBuf,
generation: ConnectorScopeGeneration,
result: Result<ConnectorsSnapshot, String>, result: Result<ConnectorsSnapshot, String>,
is_final: bool, is_final: bool,
}, },
@@ -560,9 +564,10 @@ pub(crate) enum AppEvent {
force_refetch: bool, 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 { FetchConnectorsList {
force_refetch: bool, force_refetch: bool,
generation: ConnectorScopeGeneration,
}, },
/// Fetch plugin marketplace state for the provided working directory. /// Fetch plugin marketplace state for the provided working directory.
+1
View File
@@ -336,6 +336,7 @@ use crate::tui::FrameRequester;
mod command_lifecycle; mod command_lifecycle;
mod connectors; mod connectors;
mod constructor; mod constructor;
pub(crate) use self::connectors::ConnectorScopeGeneration;
use self::connectors::ConnectorsState; use self::connectors::ConnectorsState;
mod exec_state; mod exec_state;
use self::exec_state::RunningCommand; use self::exec_state::RunningCommand;
+82 -4
View File
@@ -2,6 +2,14 @@
use super::*; use super::*;
use crate::app_event::ConnectorsSnapshot; 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)] #[derive(Debug, Clone, Default)]
pub(super) enum ConnectorsCacheState { pub(super) enum ConnectorsCacheState {
@@ -18,6 +26,12 @@ pub(super) struct ConnectorsState {
pub(super) partial_snapshot: Option<ConnectorsSnapshot>, pub(super) partial_snapshot: Option<ConnectorsSnapshot>,
pub(super) prefetch_in_flight: bool, pub(super) prefetch_in_flight: bool,
pub(super) force_refetch_pending: bool, pub(super) force_refetch_pending: bool,
/// Unowned notification retained only to decide whether an owned response needs revalidation.
pub(super) pending_notification: Option<Vec<AppInfo>>,
/// 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 { impl ChatWidget {
@@ -29,10 +43,59 @@ impl ChatWidget {
self.queue_connectors_refresh(/*force_refetch*/ false); 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<AppInfo>,
) {
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) { fn queue_connectors_refresh(&mut self, force_refetch: bool) {
if self.begin_connectors_refresh(force_refetch) { if self.begin_connectors_refresh(force_refetch) {
self.app_event_tx self.app_event_tx.send(AppEvent::FetchConnectorsList {
.send(AppEvent::FetchConnectorsList { force_refetch }); force_refetch,
generation: self.connectors.generation,
});
} }
} }
@@ -293,8 +356,23 @@ impl ChatWidget {
is_final: bool, is_final: bool,
) { ) {
let mut trigger_pending_force_refetch = false; let mut trigger_pending_force_refetch = false;
let mut trigger_pending_revalidation = false;
if is_final { if is_final {
self.connectors.prefetch_in_flight = false; 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 { if self.connectors.force_refetch_pending {
self.connectors.force_refetch_pending = false; self.connectors.force_refetch_pending = false;
trigger_pending_force_refetch = true; trigger_pending_force_refetch = true;
@@ -344,8 +422,8 @@ impl ChatWidget {
} }
} }
if trigger_pending_force_refetch { if trigger_pending_force_refetch || trigger_pending_revalidation {
self.queue_connectors_refresh(/*force_refetch*/ true); self.queue_connectors_refresh(trigger_pending_force_refetch);
} }
} }
@@ -19,6 +19,8 @@ impl ChatWidget {
self.set_skills(/*skills*/ None); self.set_skills(/*skills*/ None);
self.session_network_proxy = session.network_proxy.clone(); self.session_network_proxy = session.network_proxy.clone();
let previous_thread_id = self.thread_id; 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.thread_id = Some(session.thread_id);
self.bottom_pane self.bottom_pane
.set_queue_submissions(/*queue_submissions*/ false); .set_queue_submissions(/*queue_submissions*/ false);
@@ -37,6 +39,9 @@ impl ChatWidget {
self.current_rollout_path = session.rollout_path.clone(); self.current_rollout_path = session.rollout_path.clone();
self.current_cwd = Some(session.cwd.to_path_buf()); self.current_cwd = Some(session.cwd.to_path_buf());
self.config.cwd = session.cwd.clone(); self.config.cwd = session.cwd.clone();
if connector_scope_changed {
self.invalidate_connector_scope();
}
let runtime_workspace_roots = session.runtime_workspace_roots.clone(); let runtime_workspace_roots = session.runtime_workspace_roots.clone();
self.config.workspace_roots = runtime_workspace_roots.clone(); self.config.workspace_roots = runtime_workspace_roots.clone();
self.config self.config
+6
View File
@@ -216,6 +216,7 @@ impl ChatWidget {
) { ) {
// Account-update notifications are the identity boundary. The visible account fields can // 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. // 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_token_activity_refreshes();
self.clear_pending_rate_limit_reset_requests(); self.clear_pending_rate_limit_reset_requests();
self.codex_rate_limit_reached_type = None; self.codex_rate_limit_reached_type = None;
@@ -243,6 +244,9 @@ impl ChatWidget {
self.has_codex_backend_auth = has_codex_backend_auth; self.has_codex_backend_auth = has_codex_backend_auth;
self.bottom_pane self.bottom_pane
.set_connectors_enabled(self.connectors_enabled()); .set_connectors_enabled(self.connectors_enabled());
if self.thread_id.is_some() {
self.prefetch_connectors();
}
self.bottom_pane self.bottom_pane
.set_token_activity_command_enabled(has_codex_backend_auth); .set_token_activity_command_enabled(has_codex_backend_auth);
self.refresh_status_surfaces(); self.refresh_status_surfaces();
@@ -532,7 +536,9 @@ impl ChatWidget {
self.sync_service_tier_commands(); self.sync_service_tier_commands();
self.sync_personality_command_enabled(); self.sync_personality_command_enabled();
if cwd_changed { if cwd_changed {
self.invalidate_connector_scope();
self.refresh_skills_for_current_cwd(/*force_reload*/ true); self.refresh_skills_for_current_cwd(/*force_reload*/ true);
self.prefetch_connectors();
} }
self.refresh_plugin_mentions(); self.refresh_plugin_mentions();
self.request_redraw(); self.request_redraw();
@@ -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
@@ -342,9 +342,23 @@ async fn invalid_url_elicitation_is_declined() {
#[tokio::test] #[tokio::test]
async fn thread_settings_updated_updates_visible_state_without_transcript() { 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; 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); set_fast_mode_test_catalog(&mut chat);
let thread_id = ThreadId::new(); 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); let _ = drain_insert_history(&mut rx);
chat.handle_server_notification( chat.handle_server_notification(
@@ -352,6 +366,9 @@ async fn thread_settings_updated_updates_visible_state_without_transcript() {
/*replay_kind*/ None, /*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_model(), "gpt-5.4");
assert_eq!( assert_eq!(
chat.current_reasoning_effort(), chat.current_reasoning_effort(),
@@ -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] #[tokio::test]
async fn apps_refresh_failure_keeps_existing_full_snapshot() { async fn apps_refresh_failure_keeps_existing_full_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; 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 Model"));
assert!(!after_escape.contains("Select Reasoning Level")); 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}")
);
}