Use installed callable apps for TUI mentions (#39051)

## Why

The app directory is a discovery catalog and can include apps that are not
callable in the active thread. Using it for `$` suggestions can therefore
offer mentions that cannot be used.

## What changed

- Load mention candidates from `app/installed`, enrich callable apps through
  bounded `app/read` batches, and retain compatibility with app servers that
  do not support `app/installed`.
- Keep the mention catalog separate from the `/apps` directory cache and scope
  results to the current thread, workspace, and account.
- Refresh mentions after app notifications, enablement changes, configuration
  updates, and apps MCP readiness, while immediately removing disabled apps.

## Testing

Add coverage for batching and fallback behavior, stale scoped results,
notification refreshes, enablement changes, MCP startup races, and filtering
non-callable apps from mentions.

GitOrigin-RevId: 4d9e20794aed30cc97120170792fb555b86fe1e7
This commit is contained in:
Charlie Marsh authored and copyberry committed 2026-08-17 19:11:34 +00:00
1 parent d7d526b81d
commit fe5889928c
17 files changed
+759 -116

No files matched your search

@@ -0,0 +1,62 @@
//! Thread-authorized installed-app mentions, independent from the discovery directory.
use super::*;
use crate::app_event::ConnectorsSnapshot;
impl ChatWidget {
/// Refresh thread-authorized installed apps, coalescing pending readiness retries.
pub(crate) fn refresh_connector_mentions(&mut self, force_refresh: bool) {
if !self.connectors_enabled() || self.thread_id.is_none() {
return;
}
if self.connectors.mention_refresh_in_flight {
self.connectors.mention_refresh_pending =
Some(self.connectors.mention_refresh_pending.unwrap_or_default() || force_refresh);
return;
}
self.connectors.locally_disabled.clear();
self.connectors.mention_refresh_in_flight = true;
self.app_event_tx
.send(AppEvent::FetchInstalledConnectorMentions {
force_refresh,
generation: self.connectors.generation,
});
}
/// Apply authorized installed apps while preserving only newer local revocations.
pub(crate) fn on_connector_mentions_loaded(
&mut self,
generation: ConnectorScopeGeneration,
result: Result<ConnectorsSnapshot, String>,
) {
if generation != self.connectors.generation {
return;
}
self.connectors.mention_refresh_in_flight = false;
self.connectors.notified_installed_app_ids = None;
match result {
Ok(mut snapshot) => {
self.connectors.installed_app_ids = snapshot
.connectors
.iter()
.filter(|connector| connector.is_accessible)
.map(|connector| connector.id.clone())
.collect();
snapshot.connectors.retain(|connector| {
connector.is_accessible
&& connector.is_enabled
&& !self.connectors.locally_disabled.contains(&connector.id)
});
self.connectors.mention_snapshot = Some(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
Err(err) => warn!("failed to refresh installed app mentions: {err}"),
}
if let Some(force_refresh) = self.connectors.mention_refresh_pending.take() {
self.refresh_connector_mentions(force_refresh);
}
}
}
+74 -48
View File
@@ -4,7 +4,7 @@ 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.
/// Prevents stale 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.
@@ -22,6 +22,7 @@ pub(super) enum ConnectorsCacheState {
#[derive(Debug, Default)]
pub(super) struct ConnectorsState {
/// Discovery-only catalog; its entries are not authorized composer mentions.
pub(super) cache: ConnectorsCacheState,
pub(super) partial_snapshot: Option<ConnectorsSnapshot>,
pub(super) prefetch_in_flight: bool,
@@ -30,31 +31,54 @@ pub(super) struct ConnectorsState {
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,
/// Installed apps that are callable in the current thread and account.
pub(super) mention_snapshot: Option<ConnectorsSnapshot>,
/// Installed identities include disabled apps omitted from composer mentions.
pub(super) installed_app_ids: HashSet<String>,
/// Suppress repeated notifications while their installed-app refresh is in flight.
pub(super) notified_installed_app_ids: Option<HashSet<String>>,
pub(super) mention_refresh_in_flight: bool,
/// `Some(false)` retains a cache-only retry when MCP readiness races another lookup.
pub(super) mention_refresh_pending: Option<bool>,
/// Revocations override only installed-app requests that were already in flight.
pub(super) locally_disabled: HashSet<String>,
/// Process-unique identity for this widget's account, workspace, and thread.
pub(super) generation: ConnectorScopeGeneration,
}
impl ChatWidget {
/// Refresh both discovery data and the independently authorized mention catalog.
pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) {
self.queue_connectors_refresh(force_refetch);
self.refresh_connector_mentions(force_refetch);
}
pub(super) fn prefetch_connectors(&mut self) {
self.queue_connectors_refresh(/*force_refetch*/ false);
}
/// Revalidate directory notifications without granting their unscoped contents to this thread.
/// Revalidate app 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)
if self.thread_id.is_none() || !self.connectors_enabled() {
return;
}
let installed_app_ids = connectors
.iter()
.filter(|connector| connector.is_accessible)
.map(|connector| connector.id.clone())
.collect::<HashSet<_>>();
if installed_app_ids != self.connectors.installed_app_ids
&& self.connectors.notified_installed_app_ids.as_ref() != Some(&installed_app_ids)
{
self.connectors.notified_installed_app_ids = Some(installed_app_ids);
self.refresh_connector_mentions(/*force_refresh*/ true);
}
if self
.connectors
.partial_snapshot
.as_ref()
.is_some_and(|snapshot| snapshot.connectors == connectors)
|| matches!(
&self.connectors.cache,
ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == connectors
@@ -76,7 +100,7 @@ impl ChatWidget {
self.connectors.generation
}
/// Revoke cached apps and pending requests before their account or workspace changes.
/// Revoke cached app data and pending work before its account or thread changes.
pub(crate) fn invalidate_connector_scope(&mut self) {
let generation = ConnectorScopeGeneration(
NEXT_CONNECTOR_SCOPE_GENERATION.fetch_add(1, Ordering::Relaxed),
@@ -121,19 +145,16 @@ impl ChatWidget {
self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account
}
/// Return only authorized installed apps, never entries from the discovery directory.
pub(super) fn connectors_for_mentions(&self) -> Option<&[AppInfo]> {
if !self.connectors_enabled() {
return None;
}
if let Some(snapshot) = &self.connectors.partial_snapshot {
return Some(snapshot.connectors.as_slice());
}
match &self.connectors.cache {
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()),
_ => None,
}
self.connectors
.mention_snapshot
.as_ref()
.map(|snapshot| snapshot.connectors.as_slice())
}
pub(crate) fn add_connectors_output(&mut self) {
@@ -149,6 +170,7 @@ impl ChatWidget {
let should_force_refetch = !self.connectors.prefetch_in_flight
|| matches!(connectors_cache, ConnectorsCacheState::Ready(_));
self.queue_connectors_refresh(should_force_refetch);
self.refresh_connector_mentions(/*force_refresh*/ true);
match connectors_cache {
ConnectorsCacheState::Ready(snapshot) => {
@@ -350,6 +372,7 @@ impl ChatWidget {
.map(str::to_string)
}
/// Update the `/apps` discovery catalog without changing composer authorization.
pub(crate) fn on_connectors_loaded(
&mut self,
result: Result<ConnectorsSnapshot, String>,
@@ -396,28 +419,23 @@ impl ChatWidget {
if is_final {
self.connectors.partial_snapshot = None;
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
self.connectors.cache = ConnectorsCacheState::Ready(snapshot);
} else {
self.connectors.partial_snapshot = Some(snapshot.clone());
self.connectors.partial_snapshot = Some(snapshot);
}
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
Err(err) => {
let partial_snapshot = self.connectors.partial_snapshot.take();
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache {
if matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) {
warn!("failed to refresh apps list; retaining current apps snapshot: {err}");
self.bottom_pane
.set_connectors_snapshot(Some(snapshot.clone()));
} else if let Some(snapshot) = partial_snapshot {
warn!(
"failed to load full apps list; falling back to installed apps snapshot: {err}"
);
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
self.connectors.cache = ConnectorsCacheState::Ready(snapshot);
} else {
self.connectors.cache = ConnectorsCacheState::Failed(err);
self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None);
}
}
}
@@ -427,26 +445,34 @@ impl ChatWidget {
}
}
/// Revoke disabled apps immediately and revalidate permissions before enabling them.
pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) {
let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else {
return;
};
let mut changed = false;
for connector in &mut snapshot.connectors {
if connector.id == connector_id {
changed = connector.is_enabled != enabled;
connector.is_enabled = enabled;
break;
}
if let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone()
&& let Some(connector) = snapshot
.connectors
.iter_mut()
.find(|connector| connector.id == connector_id)
&& connector.is_enabled != enabled
{
connector.is_enabled = enabled;
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot);
}
if !changed {
return;
if enabled {
self.connectors.locally_disabled.remove(connector_id);
} else {
self.connectors
.locally_disabled
.insert(connector_id.to_string());
}
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
if !enabled && let Some(mentions) = self.connectors.mention_snapshot.as_mut() {
mentions
.connectors
.retain(|connector| connector.id != connector_id);
self.bottom_pane
.set_connectors_snapshot(Some(mentions.clone()));
}
self.refresh_connector_mentions(/*force_refresh*/ enabled);
}
}
@@ -262,10 +262,13 @@ impl ChatWidget {
.starts_with(MCP_STARTUP_MULTI_HEADER_PREFIX)
}
/// Update startup state and retry installed-app discovery when its MCP server becomes ready.
pub(super) fn on_mcp_server_status_updated(
&mut self,
notification: McpServerStatusUpdatedNotification,
) {
let refresh_connector_mentions = notification.name == "codex_apps"
&& notification.status == McpServerStartupState::Ready;
let status = match notification.status {
McpServerStartupState::Starting => McpStartupStatus::Starting,
McpServerStartupState::Ready => McpStartupStatus::Ready,
@@ -281,5 +284,8 @@ impl ChatWidget {
status,
/*complete_when_settled*/ true,
);
if refresh_connector_mentions {
self.refresh_connector_mentions(/*force_refresh*/ false);
}
}
}
+13 -3
View File
@@ -953,11 +953,21 @@ impl ChatWidget {
}
fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool {
self.connectors_for_mentions().is_some_and(|connectors| {
connectors
if !self.connectors_enabled() {
return false;
}
let connectors = &self.connectors;
connectors.installed_app_ids.contains(app_id)
|| connectors
.partial_snapshot
.iter()
.chain(match &connectors.cache {
super::connectors::ConnectorsCacheState::Ready(snapshot) => Some(snapshot),
_ => None,
})
.flat_map(|snapshot| &snapshot.connectors)
.any(|connector| connector.id == app_id && connector.is_accessible)
})
}
fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) {
+1 -3
View File
@@ -148,9 +148,7 @@ impl ChatWidget {
}
self.transcript.saw_copy_source_this_turn = false;
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
if self.connectors_enabled() {
self.prefetch_connectors();
}
self.refresh_connector_mentions(/*force_refresh*/ false);
self.submit_initial_user_message_if_pending();
if display == SessionConfiguredDisplay::Normal
&& let Some(forked_from_id) = forked_from_id
+2 -4
View File
@@ -244,9 +244,7 @@ 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.refresh_connector_mentions(/*force_refresh*/ false);
self.bottom_pane
.set_token_activity_command_enabled(has_codex_backend_auth);
self.refresh_status_surfaces();
@@ -538,7 +536,7 @@ impl ChatWidget {
if cwd_changed {
self.invalidate_connector_scope();
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
self.prefetch_connectors();
self.refresh_connector_mentions(/*force_refresh*/ false);
}
self.refresh_plugin_mentions();
self.request_redraw();
@@ -0,0 +1,22 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: snapshot
---
› $
Google Drive [App] Connected files
Press enter to insert or esc to close
--- plugin authentication ---
Plugins
Plugin plugin installed.
App setup 1/1: % Arabica UAE
Already installed in this session.
ChatGPT apps link unavailable This app did not provide an install/manage
URL.
› 1. Continue Advance to the next app.
2. Skip remaining app setup Stop this follow-up flow for this plugin.
Press esc to close.
@@ -344,21 +344,17 @@ 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);
chat.set_feature_enabled(Feature::Apps, /*enabled*/ true);
let thread_id = ThreadId::new();
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 previous_generation = chat.connector_scope_generation();
let old_app = serde_json::from_str(r#"{"id":"old","name":"Old","isAccessible":true}"#)
.expect("valid app");
chat.connectors.mention_snapshot = Some(crate::app_event::ConnectorsSnapshot {
connectors: vec![old_app],
});
let _ = drain_insert_history(&mut rx);
chat.handle_server_notification(
@@ -366,9 +362,9 @@ async fn thread_settings_updated_updates_visible_state_without_transcript() {
/*replay_kind*/ None,
);
assert_ne!(chat.connector_scope_generation(), generation);
assert_ne!(chat.connector_scope_generation(), previous_generation);
assert!(chat.connectors_for_mentions().is_none());
assert!(chat.connectors.prefetch_in_flight);
assert!(chat.connectors.mention_refresh_in_flight);
assert_eq!(chat.current_model(), "gpt-5.4");
assert_eq!(
chat.current_reasoning_effort(),
@@ -69,6 +69,28 @@ async fn mcp_startup_ignores_status_for_other_thread() {
);
}
#[tokio::test]
async fn mcp_apps_readiness_retries_an_in_flight_installed_mention_lookup() {
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.set_feature_enabled(Feature::Apps, /*enabled*/ true);
chat.refresh_connector_mentions(/*force_refresh*/ false);
chat.on_mcp_server_status_updated(McpServerStatusUpdatedNotification {
thread_id: None,
name: "codex_apps".to_string(),
status: McpServerStartupState::Ready,
error: None,
failure_reason: None,
});
assert_eq!(chat.connectors.mention_refresh_pending, Some(false));
let snapshot = crate::app_event::ConnectorsSnapshot { connectors: vec![] };
chat.on_connector_mentions_loaded(chat.connector_scope_generation(), Ok(snapshot));
assert!(chat.connectors.mention_refresh_in_flight);
}
#[tokio::test]
async fn mcp_startup_dedupes_same_round_duplicate_failure_warning() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -2087,7 +2087,8 @@ async fn plugins_popup_search_no_matches_and_backspace_restores_results() {
#[tokio::test]
async fn apps_popup_stays_loading_until_final_snapshot_updates() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
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.config
.features
@@ -2124,6 +2125,14 @@ async fn apps_popup_stays_loading_until_final_snapshot_updates() {
chat.connectors.prefetch_in_flight,
"expected /apps to trigger a forced connectors refresh"
);
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchConnectorsList { force_refetch, .. }) if force_refetch
);
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchInstalledConnectorMentions { force_refresh, .. }) if force_refresh
);
let before = render_bottom_popup(&chat, /*width*/ 80);
assert!(
@@ -2187,7 +2196,8 @@ async fn apps_popup_stays_loading_until_final_snapshot_updates() {
#[tokio::test]
async fn apps_notification_update_excludes_inaccessible_apps_from_mentions() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
chat.config
.features
@@ -2230,7 +2240,7 @@ async fn apps_notification_update_excludes_inaccessible_apps_from_mentions() {
app_metadata: None,
labels: None,
install_url: Some("https://example.test/arabica".to_string()),
is_accessible: false,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
},
@@ -2239,44 +2249,116 @@ async fn apps_notification_update_excludes_inaccessible_apps_from_mentions() {
/*is_final*/ false,
);
assert_matches!(
&chat.connectors.partial_snapshot,
Some(snapshot)
if snapshot
.connectors
.iter()
.find(|connector| connector.id == "arabica_uae")
.is_some_and(|connector| !connector.is_accessible)
assert!(chat.connectors_for_mentions().is_none());
let mut installed = chat
.connectors
.partial_snapshot
.as_ref()
.expect("directory notification should remain available to /apps")
.connectors
.clone();
installed[1].is_enabled = false;
chat.on_connector_mentions_loaded(
chat.connector_scope_generation(),
Ok(ConnectorsSnapshot {
connectors: installed,
}),
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Google Drive"),
"expected accessible apps to appear in the mention popup, got:\n{popup}"
"expected callable installed apps to appear in the mention popup, got:\n{popup}"
);
assert!(
!popup.contains("% Arabica UAE"),
"did not expect an inaccessible directory app in the mention popup, got:\n{popup}"
"directory accessibility must not make an app callable, got:\n{popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.insert_str("$arabica-uae ");
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
next_submit_op(&mut op_rx),
Op::UserTurn { items, .. }
if matches!(items.as_slice(), [
UserInput::Text { .. },
UserInput::Mention { name, path },
] if name == "Google Drive" && path == "app://google_drive")
);
chat.connectors.partial_snapshot = None;
assert_matches!(&chat.connectors.cache, ConnectorsCacheState::Uninitialized);
for (app_id, app_name) in [
("arabica_uae", "% Arabica UAE"),
("google_drive", "Google Drive"),
] {
chat.on_plugin_install_loaded(
chat.config.cwd.to_path_buf(),
crate::app_event::PluginLocation::Remote {
marketplace_name: "marketplace".to_string(),
},
"plugin".to_string(),
"Plugin".to_string(),
Ok(serde_json::from_value(serde_json::json!({
"authPolicy": "ON_INSTALL",
"appsNeedingAuth": [{ "id": app_id, "name": app_name }],
}))
.expect("valid plugin installation response")),
);
let auth_popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(auth_popup.contains("Already installed") && auth_popup.contains("Continue"));
if app_id == "arabica_uae" {
let snapshot = normalize_snapshot_paths(format!(
"{popup}\n\n--- plugin authentication ---\n{auth_popup}"
));
assert_chatwidget_snapshot!("apps_mentions_only_callable_installed", snapshot);
}
}
}
#[tokio::test]
async fn account_changes_revoke_app_directory_and_restart_scoped_requests() {
async fn apps_installed_mentions_revoke_access_and_reject_stale_account_results() {
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,
);
chat.set_feature_enabled(Feature::Apps, /*enabled*/ true);
let generation = chat.connector_scope_generation();
let connector_id = "account-app";
let connector =
serde_json::from_str(r#"{"id":"account-app","name":"Account app","isAccessible":true}"#)
.expect("valid installed app");
let snapshot = ConnectorsSnapshot {
connectors: vec![connector],
};
chat.on_connector_mentions_loaded(generation, Ok(snapshot.clone()));
assert!(chat.connectors.installed_app_ids.contains(connector_id));
chat.refresh_connector_mentions(/*force_refresh*/ false);
rx.try_recv().expect("pre-disable mention refresh");
chat.update_connector_enabled(connector_id, /*enabled*/ false);
chat.on_connector_mentions_loaded(generation, Ok(snapshot.clone()));
assert_eq!(chat.connectors_for_mentions(), Some([].as_slice()));
rx.try_recv().expect("post-disable mention refresh");
chat.on_connector_mentions_loaded(generation, Ok(snapshot.clone()));
assert_eq!(
chat.connectors_for_mentions(),
Some(snapshot.connectors.as_slice())
);
chat.on_connectors_loaded(Ok(snapshot.clone()), /*is_final*/ true);
for enabled in [false, true] {
chat.update_connector_enabled(connector_id, enabled);
assert_eq!(chat.connectors_for_mentions(), Some([].as_slice()));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchInstalledConnectorMentions { force_refresh, .. })
if force_refresh == enabled
);
let mut refreshed = snapshot.clone();
refreshed.connectors[0].is_enabled = enabled;
chat.on_connector_mentions_loaded(generation, Ok(refreshed));
}
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
@@ -2284,15 +2366,14 @@ async fn account_changes_revoke_app_directory_and_restart_scoped_requests() {
);
assert_ne!(chat.connector_scope_generation(), generation);
assert_matches!(&chat.connectors.cache, ConnectorsCacheState::Uninitialized);
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()
);
assert!(chat.connectors.mention_refresh_in_flight);
chat.on_connector_mentions_loaded(generation, Ok(snapshot));
assert!(chat.connectors_for_mentions().is_none());
assert!(chat.connectors.mention_refresh_in_flight);
assert!(chat.connectors.installed_app_ids.is_empty());
let (mut replacement, _, _) = make_chatwidget_manual(/*model_override*/ None).await;
replacement.invalidate_connector_scope();