mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+22
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user