mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
85fc4def35
commit
3c7ae4a812
13 files changed
+505
-28
No files matched your search
@@ -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;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+18
@@ -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}")
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user