From 7b56706351904a22b596d3672b4fe432ecae0666 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 6 Mar 2026 11:38:55 -0800 Subject: [PATCH] update --- codex-rs/chatgpt/src/connectors.rs | 572 +------------- codex-rs/core/src/apps/render.rs | 2 +- codex-rs/core/src/connectors.rs | 497 ++++++++++++ codex-rs/core/src/tools/handlers/mod.rs | 3 + .../src/tools/handlers/search_tool_bm25.rs | 290 +++++-- .../core/src/tools/handlers/tool_suggest.rs | 293 +++++++ codex-rs/core/src/tools/spec.rs | 106 +++ .../templates/search_tool/tool_description.md | 15 +- codex-rs/core/tests/suite/search_tool.rs | 745 +++++++++++++++++- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/bottom_pane/app_link_view.rs | 313 ++++++-- codex-rs/tui/src/bottom_pane/mod.rs | 12 + codex-rs/tui/src/chatwidget.rs | 10 +- 13 files changed, 2153 insertions(+), 706 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/tool_suggest.rs diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 2dfe6671ae..b85a6bb317 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -1,571 +1 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::sync::LazyLock; -use std::sync::Mutex as StdMutex; - -use codex_core::config::Config; -use codex_core::features::Feature; -use codex_core::token_data::TokenData; -use serde::Deserialize; -use std::time::Duration; -use std::time::Instant; - -use crate::chatgpt_client::chatgpt_get_request_with_timeout; -use crate::chatgpt_token::get_chatgpt_token_data; -use crate::chatgpt_token::init_chatgpt_token_from_auth; - -use codex_core::connectors::AppBranding; -pub use codex_core::connectors::AppInfo; -use codex_core::connectors::AppMetadata; -use codex_core::connectors::CONNECTORS_CACHE_TTL; -pub use codex_core::connectors::connector_display_label; -use codex_core::connectors::connector_install_url; -use codex_core::connectors::filter_disallowed_connectors; -pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; -pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options; -pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status; -pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools; -use codex_core::connectors::merge_connectors; -use codex_core::connectors::merge_plugin_apps; -pub use codex_core::connectors::with_app_enabled_state; -use codex_core::plugins::PluginsManager; - -#[derive(Debug, Deserialize)] -struct DirectoryListResponse { - apps: Vec, - #[serde(alias = "nextToken")] - next_token: Option, -} - -#[derive(Debug, Deserialize, Clone)] -struct DirectoryApp { - id: String, - name: String, - description: Option, - #[serde(alias = "appMetadata")] - app_metadata: Option, - branding: Option, - labels: Option>, - #[serde(alias = "logoUrl")] - logo_url: Option, - #[serde(alias = "logoUrlDark")] - logo_url_dark: Option, - #[serde(alias = "distributionChannel")] - distribution_channel: Option, - visibility: Option, -} - -const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); - -#[derive(Clone, PartialEq, Eq)] -struct AllConnectorsCacheKey { - chatgpt_base_url: String, - account_id: Option, - chatgpt_user_id: Option, - is_workspace_account: bool, -} - -#[derive(Clone)] -struct CachedAllConnectors { - key: AllConnectorsCacheKey, - expires_at: Instant, - connectors: Vec, -} - -static ALL_CONNECTORS_CACHE: LazyLock>> = - LazyLock::new(|| StdMutex::new(None)); - -pub async fn list_connectors(config: &Config) -> anyhow::Result> { - if !config.features.enabled(Feature::Apps) { - return Ok(Vec::new()); - } - let (connectors_result, accessible_result) = tokio::join!( - list_all_connectors(config), - list_accessible_connectors_from_mcp_tools(config), - ); - let connectors = connectors_result?; - let accessible = accessible_result?; - Ok(with_app_enabled_state( - merge_connectors_with_accessible(connectors, accessible, true), - config, - )) -} - -pub async fn list_all_connectors(config: &Config) -> anyhow::Result> { - list_all_connectors_with_options(config, false).await -} - -pub async fn list_cached_all_connectors(config: &Config) -> Option> { - if !config.features.enabled(Feature::Apps) { - return Some(Vec::new()); - } - - if init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) - .await - .is_err() - { - return None; - } - let token_data = get_chatgpt_token_data()?; - let cache_key = all_connectors_cache_key(config, &token_data); - read_cached_all_connectors(&cache_key).map(|connectors| { - let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); - filter_disallowed_connectors(connectors) - }) -} - -pub async fn list_all_connectors_with_options( - config: &Config, - force_refetch: bool, -) -> anyhow::Result> { - if !config.features.enabled(Feature::Apps) { - return Ok(Vec::new()); - } - init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) - .await?; - - let token_data = - get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; - let cache_key = all_connectors_cache_key(config, &token_data); - if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) { - let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config)); - return Ok(filter_disallowed_connectors(connectors)); - } - - let mut apps = list_directory_connectors(config).await?; - if token_data.id_token.is_workspace_account() { - apps.extend(list_workspace_connectors(config).await?); - } - let mut connectors = merge_directory_apps(apps) - .into_iter() - .map(directory_app_to_app_info) - .collect::>(); - for connector in &mut connectors { - let install_url = match connector.install_url.take() { - Some(install_url) => install_url, - None => connector_install_url(&connector.name, &connector.id), - }; - connector.name = normalize_connector_name(&connector.name, &connector.id); - connector.description = normalize_connector_value(connector.description.as_deref()); - connector.install_url = Some(install_url); - connector.is_accessible = false; - } - connectors.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then_with(|| left.id.cmp(&right.id)) - }); - let connectors = filter_disallowed_connectors(connectors); - write_cached_all_connectors(cache_key, &connectors); - let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); - Ok(filter_disallowed_connectors(connectors)) -} - -fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey { - AllConnectorsCacheKey { - chatgpt_base_url: config.chatgpt_base_url.clone(), - account_id: token_data.account_id.clone(), - chatgpt_user_id: token_data.id_token.chatgpt_user_id.clone(), - is_workspace_account: token_data.id_token.is_workspace_account(), - } -} - -fn read_cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option> { - let mut cache_guard = ALL_CONNECTORS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let now = Instant::now(); - - if let Some(cached) = cache_guard.as_ref() { - if now < cached.expires_at && cached.key == *cache_key { - return Some(cached.connectors.clone()); - } - if now >= cached.expires_at { - *cache_guard = None; - } - } - - None -} - -fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) { - let mut cache_guard = ALL_CONNECTORS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *cache_guard = Some(CachedAllConnectors { - key: cache_key, - expires_at: Instant::now() + CONNECTORS_CACHE_TTL, - connectors: connectors.to_vec(), - }); -} - -fn plugin_apps_for_config(config: &Config) -> Vec { - PluginsManager::new(config.codex_home.clone()) - .plugins_for_config(config) - .effective_apps() -} - -pub fn merge_connectors_with_accessible( - connectors: Vec, - accessible_connectors: Vec, - all_connectors_loaded: bool, -) -> Vec { - let accessible_connectors = if all_connectors_loaded { - let connector_ids: HashSet<&str> = connectors - .iter() - .map(|connector| connector.id.as_str()) - .collect(); - accessible_connectors - .into_iter() - .filter(|connector| connector_ids.contains(connector.id.as_str())) - .collect() - } else { - accessible_connectors - }; - let merged = merge_connectors(connectors, accessible_connectors); - filter_disallowed_connectors(merged) -} - -async fn list_directory_connectors(config: &Config) -> anyhow::Result> { - let mut apps = Vec::new(); - let mut next_token: Option = None; - loop { - let path = match next_token.as_deref() { - Some(token) => { - let encoded_token = urlencoding::encode(token); - format!( - "/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true" - ) - } - None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(), - }; - let response: DirectoryListResponse = - chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT)) - .await?; - apps.extend( - response - .apps - .into_iter() - .filter(|app| !is_hidden_directory_app(app)), - ); - next_token = response - .next_token - .map(|token| token.trim().to_string()) - .filter(|token| !token.is_empty()); - if next_token.is_none() { - break; - } - } - Ok(apps) -} - -async fn list_workspace_connectors(config: &Config) -> anyhow::Result> { - let response: anyhow::Result = chatgpt_get_request_with_timeout( - config, - "/connectors/directory/list_workspace?external_logos=true".to_string(), - Some(DIRECTORY_CONNECTORS_TIMEOUT), - ) - .await; - match response { - Ok(response) => Ok(response - .apps - .into_iter() - .filter(|app| !is_hidden_directory_app(app)) - .collect()), - Err(_) => Ok(Vec::new()), - } -} - -fn merge_directory_apps(apps: Vec) -> Vec { - let mut merged: HashMap = HashMap::new(); - for app in apps { - if let Some(existing) = merged.get_mut(&app.id) { - merge_directory_app(existing, app); - } else { - merged.insert(app.id.clone(), app); - } - } - merged.into_values().collect() -} - -fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { - let DirectoryApp { - id: _, - name, - description, - app_metadata, - branding, - labels, - logo_url, - logo_url_dark, - distribution_channel, - visibility: _, - } = incoming; - - let incoming_name_is_empty = name.trim().is_empty(); - if existing.name.trim().is_empty() && !incoming_name_is_empty { - existing.name = name; - } - - let incoming_description_present = description - .as_deref() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); - if incoming_description_present { - existing.description = description; - } - - if existing.logo_url.is_none() && logo_url.is_some() { - existing.logo_url = logo_url; - } - if existing.logo_url_dark.is_none() && logo_url_dark.is_some() { - existing.logo_url_dark = logo_url_dark; - } - if existing.distribution_channel.is_none() && distribution_channel.is_some() { - existing.distribution_channel = distribution_channel; - } - - if let Some(incoming_branding) = branding { - if let Some(existing_branding) = existing.branding.as_mut() { - if existing_branding.category.is_none() && incoming_branding.category.is_some() { - existing_branding.category = incoming_branding.category; - } - if existing_branding.developer.is_none() && incoming_branding.developer.is_some() { - existing_branding.developer = incoming_branding.developer; - } - if existing_branding.website.is_none() && incoming_branding.website.is_some() { - existing_branding.website = incoming_branding.website; - } - if existing_branding.privacy_policy.is_none() - && incoming_branding.privacy_policy.is_some() - { - existing_branding.privacy_policy = incoming_branding.privacy_policy; - } - if existing_branding.terms_of_service.is_none() - && incoming_branding.terms_of_service.is_some() - { - existing_branding.terms_of_service = incoming_branding.terms_of_service; - } - if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app { - existing_branding.is_discoverable_app = true; - } - } else { - existing.branding = Some(incoming_branding); - } - } - - if let Some(incoming_app_metadata) = app_metadata { - if let Some(existing_app_metadata) = existing.app_metadata.as_mut() { - if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() { - existing_app_metadata.review = incoming_app_metadata.review; - } - if existing_app_metadata.categories.is_none() - && incoming_app_metadata.categories.is_some() - { - existing_app_metadata.categories = incoming_app_metadata.categories; - } - if existing_app_metadata.sub_categories.is_none() - && incoming_app_metadata.sub_categories.is_some() - { - existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories; - } - if existing_app_metadata.seo_description.is_none() - && incoming_app_metadata.seo_description.is_some() - { - existing_app_metadata.seo_description = incoming_app_metadata.seo_description; - } - if existing_app_metadata.screenshots.is_none() - && incoming_app_metadata.screenshots.is_some() - { - existing_app_metadata.screenshots = incoming_app_metadata.screenshots; - } - if existing_app_metadata.developer.is_none() - && incoming_app_metadata.developer.is_some() - { - existing_app_metadata.developer = incoming_app_metadata.developer; - } - if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() { - existing_app_metadata.version = incoming_app_metadata.version; - } - if existing_app_metadata.version_id.is_none() - && incoming_app_metadata.version_id.is_some() - { - existing_app_metadata.version_id = incoming_app_metadata.version_id; - } - if existing_app_metadata.version_notes.is_none() - && incoming_app_metadata.version_notes.is_some() - { - existing_app_metadata.version_notes = incoming_app_metadata.version_notes; - } - if existing_app_metadata.first_party_type.is_none() - && incoming_app_metadata.first_party_type.is_some() - { - existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type; - } - if existing_app_metadata.first_party_requires_install.is_none() - && incoming_app_metadata.first_party_requires_install.is_some() - { - existing_app_metadata.first_party_requires_install = - incoming_app_metadata.first_party_requires_install; - } - if existing_app_metadata - .show_in_composer_when_unlinked - .is_none() - && incoming_app_metadata - .show_in_composer_when_unlinked - .is_some() - { - existing_app_metadata.show_in_composer_when_unlinked = - incoming_app_metadata.show_in_composer_when_unlinked; - } - } else { - existing.app_metadata = Some(incoming_app_metadata); - } - } - - if existing.labels.is_none() && labels.is_some() { - existing.labels = labels; - } -} - -fn is_hidden_directory_app(app: &DirectoryApp) -> bool { - matches!(app.visibility.as_deref(), Some("HIDDEN")) -} - -fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { - AppInfo { - id: app.id, - name: app.name, - description: app.description, - logo_url: app.logo_url, - logo_url_dark: app.logo_url_dark, - distribution_channel: app.distribution_channel, - branding: app.branding, - app_metadata: app.app_metadata, - labels: app.labels, - install_url: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - } -} - -fn normalize_connector_name(name: &str, connector_id: &str) -> String { - let trimmed = name.trim(); - if trimmed.is_empty() { - connector_id.to_string() - } else { - trimmed.to_string() - } -} - -fn normalize_connector_value(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn app(id: &str) -> AppInfo { - AppInfo { - id: id.to_string(), - name: id.to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - } - } - - #[test] - fn allows_asdk_connectors() { - let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); - assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); - } - - #[test] - fn allows_whitelisted_asdk_connectors() { - let filtered = filter_disallowed_connectors(vec![ - app("asdk_app_69781557cc1481919cf5e9824fa2e792"), - app("beta"), - ]); - assert_eq!( - filtered, - vec![ - app("asdk_app_69781557cc1481919cf5e9824fa2e792"), - app("beta") - ] - ); - } - - #[test] - fn filters_openai_prefixed_connectors() { - let filtered = filter_disallowed_connectors(vec![ - app("connector_openai_foo"), - app("connector_openai_bar"), - app("gamma"), - ]); - assert_eq!(filtered, vec![app("gamma")]); - } - - #[test] - fn filters_disallowed_connector_ids() { - let filtered = filter_disallowed_connectors(vec![ - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("delta"), - ]); - assert_eq!(filtered, vec![app("delta")]); - } - - fn merged_app(id: &str, is_accessible: bool) -> AppInfo { - AppInfo { - id: id.to_string(), - name: id.to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url(id, id)), - is_accessible, - is_enabled: true, - plugin_display_names: Vec::new(), - } - } - - #[test] - fn excludes_accessible_connectors_not_in_all_when_all_loaded() { - let merged = merge_connectors_with_accessible( - vec![app("alpha")], - vec![app("alpha"), app("beta")], - true, - ); - assert_eq!(merged, vec![merged_app("alpha", true)]); - } - - #[test] - fn keeps_accessible_connectors_not_in_all_while_all_loading() { - let merged = merge_connectors_with_accessible( - vec![app("alpha")], - vec![app("alpha"), app("beta")], - false, - ); - assert_eq!( - merged, - vec![merged_app("alpha", true), merged_app("beta", true)] - ); - } -} +pub use codex_core::connectors::*; diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 7f706f737e..41aed96de3 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -2,6 +2,6 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; pub(crate) fn render_apps_section() -> String { format!( - "## Apps\nApps are mentioned in the prompt in the format `[$app-name](app://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either already provided in `{CODEX_APPS_MCP_SERVER_NAME}`, or do not exist because the user did not install it.\nDo not additionally call list_mcp_resources for apps that are already mentioned." + "## Apps\nApps are mentioned in the prompt in the format `[$app-name](app://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, its tools may already be available, or the app may need to be installed first.\nUse `search_tool_bm25` with `mode: \"available\"` to search for installed tools, before trying to call hidden app tools. If the user clearly wants a specific app and it is not available, use `search_tool_bm25` with `mode: \"installable\"`, then call `tool_suggest` to prompt the user to install it.\nDo not additionally call list_mcp_resources for apps that are already mentioned." ) } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index d75b59aa14..1cad8b582c 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -16,6 +16,7 @@ pub use codex_app_server_protocol::AppMetadata; use codex_protocol::protocol::SandboxPolicy; use rmcp::model::ToolAnnotations; use serde::Deserialize; +use serde::de::DeserializeOwned; use tracing::warn; use crate::AuthManager; @@ -24,6 +25,7 @@ use crate::SandboxState; use crate::config::Config; use crate::config::types::AppToolApproval; use crate::config::types::AppsConfigToml; +use crate::default_client::create_client; use crate::default_client::is_first_party_chat_originator; use crate::default_client::originator; use crate::features::Feature; @@ -40,6 +42,7 @@ use crate::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); +const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AppToolPolicy { @@ -74,12 +77,163 @@ struct CachedAccessibleConnectors { static ACCESSIBLE_CONNECTORS_CACHE: LazyLock>> = LazyLock::new(|| StdMutex::new(None)); +#[derive(Clone, PartialEq, Eq)] +struct AllConnectorsCacheKey { + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +#[derive(Clone)] +struct CachedAllConnectors { + key: AllConnectorsCacheKey, + expires_at: Instant, + connectors: Vec, +} + +static ALL_CONNECTORS_CACHE: LazyLock>> = + LazyLock::new(|| StdMutex::new(None)); + +#[derive(Debug, Deserialize)] +struct DirectoryListResponse { + apps: Vec, + #[serde(alias = "nextToken")] + next_token: Option, +} + +#[derive(Debug, Deserialize, Clone)] +struct DirectoryApp { + id: String, + name: String, + description: Option, + #[serde(alias = "appMetadata")] + app_metadata: Option, + branding: Option, + labels: Option>, + #[serde(alias = "logoUrl")] + logo_url: Option, + #[serde(alias = "logoUrlDark")] + logo_url_dark: Option, + #[serde(alias = "distributionChannel")] + distribution_channel: Option, + visibility: Option, +} + #[derive(Debug, Clone)] pub struct AccessibleConnectorsStatus { pub connectors: Vec, pub codex_apps_ready: bool, } +pub async fn list_connectors(config: &Config) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { + return Ok(Vec::new()); + } + + let (all_result, accessible_result) = tokio::join!( + list_all_connectors(config), + list_accessible_connectors_from_mcp_tools(config) + ); + let all_connectors = all_result?; + let accessible_connectors = accessible_result?; + + Ok(with_app_enabled_state( + merge_connectors_with_accessible(all_connectors, accessible_connectors, true), + config, + )) +} + +pub async fn list_cached_connectors(config: &Config) -> Option> { + if !config.features.enabled(Feature::Apps) { + return Some(Vec::new()); + } + + let all_connectors = list_cached_all_connectors(config).await?; + let accessible_connectors = list_cached_accessible_connectors_from_mcp_tools(config).await?; + + Some(with_app_enabled_state( + merge_connectors_with_accessible(all_connectors, accessible_connectors, true), + config, + )) +} + +pub async fn list_all_connectors(config: &Config) -> anyhow::Result> { + list_all_connectors_with_options(config, false).await +} + +pub async fn list_cached_all_connectors(config: &Config) -> Option> { + if !config.features.enabled(Feature::Apps) { + return Some(Vec::new()); + } + + let auth_manager = auth_manager_from_config(config); + let auth = auth_manager.auth().await?; + let token_data = auth.get_token_data().ok()?; + let cache_key = all_connectors_cache_key(config, &token_data); + + read_cached_all_connectors(&cache_key).map(|connectors| { + let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); + filter_disallowed_connectors(connectors) + }) +} + +pub async fn list_all_connectors_with_options( + config: &Config, + force_refetch: bool, +) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { + return Ok(Vec::new()); + } + + let auth_manager = auth_manager_from_config(config); + let auth = auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?; + let token_data = auth + .get_token_data() + .map_err(anyhow::Error::from) + .map_err(|_| anyhow::anyhow!("ChatGPT token not available"))?; + let cache_key = all_connectors_cache_key(config, &token_data); + + if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) { + let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config)); + return Ok(filter_disallowed_connectors(connectors)); + } + + let mut apps = list_directory_connectors(config).await?; + if token_data.id_token.is_workspace_account() { + apps.extend(list_workspace_connectors(config).await?); + } + + let mut connectors = merge_directory_apps(apps) + .into_iter() + .map(directory_app_to_app_info) + .collect::>(); + for connector in &mut connectors { + let install_url = match connector.install_url.take() { + Some(install_url) => install_url, + None => connector_install_url(&connector.name, &connector.id), + }; + connector.name = normalize_connector_name(&connector.name, &connector.id); + connector.description = normalize_connector_value(connector.description.as_deref()); + connector.install_url = Some(install_url); + connector.is_accessible = false; + } + connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + + let connectors = filter_disallowed_connectors(connectors); + write_cached_all_connectors(cache_key, &connectors); + + let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); + Ok(filter_disallowed_connectors(connectors)) +} + pub async fn list_accessible_connectors_from_mcp_tools( config: &Config, ) -> anyhow::Result> { @@ -249,6 +403,15 @@ fn accessible_connectors_cache_key( } } +fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey { + AllConnectorsCacheKey { + chatgpt_base_url: config.chatgpt_base_url.clone(), + account_id: token_data.account_id.clone(), + chatgpt_user_id: token_data.id_token.chatgpt_user_id.clone(), + is_workspace_account: token_data.id_token.is_workspace_account(), + } +} + fn read_cached_accessible_connectors( cache_key: &AccessibleConnectorsCacheKey, ) -> Option> { @@ -269,6 +432,24 @@ fn read_cached_accessible_connectors( None } +fn read_cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option> { + let mut cache_guard = ALL_CONNECTORS_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let now = Instant::now(); + + if let Some(cached) = cache_guard.as_ref() { + if now < cached.expires_at && cached.key == *cache_key { + return Some(cached.connectors.clone()); + } + if now >= cached.expires_at { + *cache_guard = None; + } + } + + None +} + fn write_cached_accessible_connectors( cache_key: AccessibleConnectorsCacheKey, connectors: &[AppInfo], @@ -283,6 +464,17 @@ fn write_cached_accessible_connectors( }); } +fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) { + let mut cache_guard = ALL_CONNECTORS_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *cache_guard = Some(CachedAllConnectors { + key: cache_key, + expires_at: Instant::now() + CONNECTORS_CACHE_TTL, + connectors: connectors.to_vec(), + }); +} + fn auth_manager_from_config(config: &Config) -> std::sync::Arc { AuthManager::shared( config.codex_home.clone(), @@ -403,6 +595,27 @@ pub fn merge_plugin_apps( merged } +pub fn merge_connectors_with_accessible( + connectors: Vec, + accessible_connectors: Vec, + all_connectors_loaded: bool, +) -> Vec { + let accessible_connectors = if all_connectors_loaded { + let connector_ids: HashSet<&str> = connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect(); + accessible_connectors + .into_iter() + .filter(|connector| connector_ids.contains(connector.id.as_str())) + .collect() + } else { + accessible_connectors + }; + + filter_disallowed_connectors(merge_connectors(connectors, accessible_connectors)) +} + pub fn merge_plugin_apps_with_accessible( plugin_apps: Vec, accessible_connectors: Vec, @@ -690,6 +903,12 @@ fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo { } } +fn plugin_apps_for_config(config: &Config) -> Vec { + PluginsManager::new(config.codex_home.clone()) + .plugins_for_config(config) + .effective_apps() +} + fn normalize_connector_value(value: Option<&str>) -> Option { value .map(str::trim) @@ -697,6 +916,284 @@ fn normalize_connector_value(value: Option<&str>) -> Option { .map(str::to_string) } +fn normalize_connector_name(name: &str, connector_id: &str) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() { + connector_id.to_string() + } else { + trimmed.to_string() + } +} + +async fn list_directory_connectors(config: &Config) -> anyhow::Result> { + let mut apps = Vec::new(); + let mut next_token: Option = None; + + loop { + let path = match next_token.as_deref() { + Some(token) => { + let encoded = + url::form_urlencoded::byte_serialize(token.as_bytes()).collect::(); + format!( + "/connectors/directory/list?tier=categorized&token={encoded}&external_logos=true" + ) + } + None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(), + }; + let response: DirectoryListResponse = chatgpt_get_request_with_timeout( + config, + path.as_str(), + Some(DIRECTORY_CONNECTORS_TIMEOUT), + ) + .await?; + apps.extend( + response + .apps + .into_iter() + .filter(|app| !is_hidden_directory_app(app)), + ); + next_token = response + .next_token + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()); + if next_token.is_none() { + break; + } + } + + Ok(apps) +} + +async fn list_workspace_connectors(config: &Config) -> anyhow::Result> { + let response: anyhow::Result = chatgpt_get_request_with_timeout( + config, + "/connectors/directory/list_workspace?external_logos=true", + Some(DIRECTORY_CONNECTORS_TIMEOUT), + ) + .await; + + match response { + Ok(response) => Ok(response + .apps + .into_iter() + .filter(|app| !is_hidden_directory_app(app)) + .collect()), + Err(_) => Ok(Vec::new()), + } +} + +fn merge_directory_apps(apps: Vec) -> Vec { + let mut merged = HashMap::new(); + for app in apps { + if let Some(existing) = merged.get_mut(&app.id) { + merge_directory_app(existing, app); + } else { + merged.insert(app.id.clone(), app); + } + } + merged.into_values().collect() +} + +fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { + let DirectoryApp { + id: _, + name, + description, + app_metadata, + branding, + labels, + logo_url, + logo_url_dark, + distribution_channel, + visibility: _, + } = incoming; + + let incoming_name_is_empty = name.trim().is_empty(); + if existing.name.trim().is_empty() && !incoming_name_is_empty { + existing.name = name; + } + + let incoming_description_present = description + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + if incoming_description_present { + existing.description = description; + } + + if existing.logo_url.is_none() && logo_url.is_some() { + existing.logo_url = logo_url; + } + if existing.logo_url_dark.is_none() && logo_url_dark.is_some() { + existing.logo_url_dark = logo_url_dark; + } + if existing.distribution_channel.is_none() && distribution_channel.is_some() { + existing.distribution_channel = distribution_channel; + } + + if let Some(incoming_branding) = branding { + if let Some(existing_branding) = existing.branding.as_mut() { + if existing_branding.category.is_none() && incoming_branding.category.is_some() { + existing_branding.category = incoming_branding.category; + } + if existing_branding.developer.is_none() && incoming_branding.developer.is_some() { + existing_branding.developer = incoming_branding.developer; + } + if existing_branding.website.is_none() && incoming_branding.website.is_some() { + existing_branding.website = incoming_branding.website; + } + if existing_branding.privacy_policy.is_none() + && incoming_branding.privacy_policy.is_some() + { + existing_branding.privacy_policy = incoming_branding.privacy_policy; + } + if existing_branding.terms_of_service.is_none() + && incoming_branding.terms_of_service.is_some() + { + existing_branding.terms_of_service = incoming_branding.terms_of_service; + } + if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app { + existing_branding.is_discoverable_app = true; + } + } else { + existing.branding = Some(incoming_branding); + } + } + + if let Some(incoming_app_metadata) = app_metadata { + if let Some(existing_app_metadata) = existing.app_metadata.as_mut() { + if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() { + existing_app_metadata.review = incoming_app_metadata.review; + } + if existing_app_metadata.categories.is_none() + && incoming_app_metadata.categories.is_some() + { + existing_app_metadata.categories = incoming_app_metadata.categories; + } + if existing_app_metadata.sub_categories.is_none() + && incoming_app_metadata.sub_categories.is_some() + { + existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories; + } + if existing_app_metadata.seo_description.is_none() + && incoming_app_metadata.seo_description.is_some() + { + existing_app_metadata.seo_description = incoming_app_metadata.seo_description; + } + if existing_app_metadata.screenshots.is_none() + && incoming_app_metadata.screenshots.is_some() + { + existing_app_metadata.screenshots = incoming_app_metadata.screenshots; + } + if existing_app_metadata.developer.is_none() + && incoming_app_metadata.developer.is_some() + { + existing_app_metadata.developer = incoming_app_metadata.developer; + } + if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() { + existing_app_metadata.version = incoming_app_metadata.version; + } + if existing_app_metadata.version_id.is_none() + && incoming_app_metadata.version_id.is_some() + { + existing_app_metadata.version_id = incoming_app_metadata.version_id; + } + if existing_app_metadata.version_notes.is_none() + && incoming_app_metadata.version_notes.is_some() + { + existing_app_metadata.version_notes = incoming_app_metadata.version_notes; + } + if existing_app_metadata.first_party_type.is_none() + && incoming_app_metadata.first_party_type.is_some() + { + existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type; + } + if existing_app_metadata.first_party_requires_install.is_none() + && incoming_app_metadata.first_party_requires_install.is_some() + { + existing_app_metadata.first_party_requires_install = + incoming_app_metadata.first_party_requires_install; + } + if existing_app_metadata + .show_in_composer_when_unlinked + .is_none() + && incoming_app_metadata + .show_in_composer_when_unlinked + .is_some() + { + existing_app_metadata.show_in_composer_when_unlinked = + incoming_app_metadata.show_in_composer_when_unlinked; + } + } else { + existing.app_metadata = Some(incoming_app_metadata); + } + } + + if existing.labels.is_none() && labels.is_some() { + existing.labels = labels; + } +} + +fn is_hidden_directory_app(app: &DirectoryApp) -> bool { + matches!(app.visibility.as_deref(), Some("HIDDEN")) +} + +fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { + AppInfo { + id: app.id, + name: app.name, + description: app.description, + logo_url: app.logo_url, + logo_url_dark: app.logo_url_dark, + distribution_channel: app.distribution_channel, + branding: app.branding, + app_metadata: app.app_metadata, + labels: app.labels, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + } +} + +async fn chatgpt_get_request_with_timeout( + config: &Config, + path: &str, + timeout: Option, +) -> anyhow::Result { + let auth_manager = auth_manager_from_config(config); + let auth = auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?; + let token_data = auth + .get_token_data() + .map_err(anyhow::Error::from) + .map_err(|_| anyhow::anyhow!("ChatGPT token not available"))?; + let account_id = token_data.account_id.ok_or_else(|| { + anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`") + })?; + let url = format!("{}{}", config.chatgpt_base_url.trim_end_matches('/'), path); + + let mut request = create_client() + .get(url) + .bearer_auth(token_data.access_token) + .header("chatgpt-account-id", account_id) + .header("Content-Type", "application/json"); + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + + let response = request.send().await?; + if response.status().is_success() { + return Ok(response.json().await?); + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Request failed with status {status}: {body}") +} + pub fn connector_install_url(name: &str, connector_id: &str) -> String { let slug = connector_name_slug(name); format!("https://chatgpt.com/apps/{slug}/{connector_id}") diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 086701d9c2..49abaaef00 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -14,6 +14,7 @@ mod request_user_input; mod search_tool_bm25; mod shell; mod test_sync; +mod tool_suggest; pub(crate) mod unified_exec; mod view_image; @@ -49,6 +50,8 @@ pub use search_tool_bm25::SearchToolBm25Handler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; +pub(crate) use tool_suggest::TOOL_SUGGEST_TOOL_NAME; +pub use tool_suggest::ToolSuggestHandler; pub use unified_exec::UnifiedExecHandler; pub use view_image::ViewImageHandler; diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index cb54a9b569..d457d909c9 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -5,6 +5,7 @@ use bm25::SearchEngineBuilder; use codex_app_server_protocol::AppInfo; use codex_protocol::models::FunctionCallOutputBody; use serde::Deserialize; +use serde::Serialize; use serde_json::json; use std::collections::HashMap; use std::collections::HashSet; @@ -29,11 +30,21 @@ fn default_limit() -> usize { DEFAULT_LIMIT } +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum SearchToolMode { + #[default] + Available, + Installable, +} + #[derive(Deserialize)] struct SearchToolBm25Args { query: String, #[serde(default = "default_limit")] limit: usize, + #[serde(default)] + mode: SearchToolMode, } #[derive(Clone)] @@ -72,6 +83,26 @@ impl ToolEntry { } } +#[derive(Clone)] +struct ConnectorEntry { + connector_id: String, + connector_name: String, + connector_description: Option, + search_text: String, +} + +impl ConnectorEntry { + fn new(connector: AppInfo) -> Self { + let search_text = build_connector_search_text(&connector); + Self { + connector_id: connector.id, + connector_name: connector.name, + connector_description: connector.description, + search_text, + } + } +} + #[async_trait] impl ToolHandler for SearchToolBm25Handler { fn kind(&self) -> ToolKind { @@ -109,80 +140,151 @@ impl ToolHandler for SearchToolBm25Handler { )); } - let limit = args.limit; + let content = match args.mode { + SearchToolMode::Available => { + let mcp_tools = session + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .await; - let mcp_tools = session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; + let connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors); + let mcp_tools = + connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config); - let connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, - ); - let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors); - let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config); + let mut entries: Vec = mcp_tools + .into_iter() + .map(|(name, info)| ToolEntry::new(name, info)) + .collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); - let mut entries: Vec = mcp_tools - .into_iter() - .map(|(name, info)| ToolEntry::new(name, info)) - .collect(); - entries.sort_by(|a, b| a.name.cmp(&b.name)); + if entries.is_empty() { + let active_selected_tools = + session.get_mcp_tool_selection().await.unwrap_or_default(); + json!({ + "query": query, + "mode": SearchToolMode::Available, + "total_tools": 0, + "active_selected_tools": active_selected_tools, + "tools": [], + }) + .to_string() + } else { + let documents: Vec> = entries + .iter() + .enumerate() + .map(|(idx, entry)| Document::new(idx, entry.search_text.clone())) + .collect(); + let search_engine = + SearchEngineBuilder::::with_documents(Language::English, documents) + .build(); + let results = search_engine.search(query, args.limit); - if entries.is_empty() { - let active_selected_tools = session.get_mcp_tool_selection().await.unwrap_or_default(); - let content = json!({ - "query": query, - "total_tools": 0, - "active_selected_tools": active_selected_tools, - "tools": [], - }) - .to_string(); - return Ok(ToolOutput::Function { - body: FunctionCallOutputBody::Text(content), - success: Some(true), - }); - } + let mut selected_tools = Vec::new(); + let mut result_payloads = Vec::new(); + for result in results { + let Some(entry) = entries.get(result.document.id) else { + continue; + }; + selected_tools.push(entry.name.clone()); + result_payloads.push(json!({ + "name": entry.name.clone(), + "server": entry.server_name.clone(), + "title": entry.title.clone(), + "description": entry.description.clone(), + "connector_name": entry.connector_name.clone(), + "input_keys": entry.input_keys.clone(), + "score": result.score, + })); + } - let documents: Vec> = entries - .iter() - .enumerate() - .map(|(idx, entry)| Document::new(idx, entry.search_text.clone())) - .collect(); - let search_engine = - SearchEngineBuilder::::with_documents(Language::English, documents).build(); - let results = search_engine.search(query, limit); + let active_selected_tools = + session.merge_mcp_tool_selection(selected_tools).await; - let mut selected_tools = Vec::new(); - let mut result_payloads = Vec::new(); - for result in results { - let Some(entry) = entries.get(result.document.id) else { - continue; - }; - selected_tools.push(entry.name.clone()); - result_payloads.push(json!({ - "name": entry.name.clone(), - "server": entry.server_name.clone(), - "title": entry.title.clone(), - "description": entry.description.clone(), - "connector_name": entry.connector_name.clone(), - "input_keys": entry.input_keys.clone(), - "score": result.score, - })); - } + json!({ + "query": query, + "mode": SearchToolMode::Available, + "total_tools": entries.len(), + "active_selected_tools": active_selected_tools, + "tools": result_payloads, + }) + .to_string() + } + } + SearchToolMode::Installable => { + let all_connectors = match connectors::list_cached_connectors(&turn.config).await { + Some(connectors) => connectors, + None => connectors::list_connectors(&turn.config) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to load installable apps: {err}" + )) + })?, + }; + let active_selected_tools = + session.get_mcp_tool_selection().await.unwrap_or_default(); + let mut entries: Vec = all_connectors + .into_iter() + .filter(|connector| !connector.is_accessible && connector.is_enabled) + .map(ConnectorEntry::new) + .collect(); + entries.sort_by(|a, b| { + a.connector_name + .cmp(&b.connector_name) + .then_with(|| a.connector_id.cmp(&b.connector_id)) + }); - let active_selected_tools = session.merge_mcp_tool_selection(selected_tools).await; + if entries.is_empty() { + json!({ + "query": query, + "mode": SearchToolMode::Installable, + "total_connectors": 0, + "active_selected_tools": active_selected_tools, + "connectors": [], + }) + .to_string() + } else { + let documents: Vec> = entries + .iter() + .enumerate() + .map(|(idx, entry)| Document::new(idx, entry.search_text.clone())) + .collect(); + let search_engine = + SearchEngineBuilder::::with_documents(Language::English, documents) + .build(); + let results = search_engine.search(query, args.limit); - let content = json!({ - "query": query, - "total_tools": entries.len(), - "active_selected_tools": active_selected_tools, - "tools": result_payloads, - }) - .to_string(); + let mut result_payloads = Vec::new(); + for result in results { + let Some(entry) = entries.get(result.document.id) else { + continue; + }; + result_payloads.push(json!({ + "connector_id": entry.connector_id.clone(), + "connector_name": entry.connector_name.clone(), + "connector_description": entry.connector_description.clone(), + "score": result.score, + })); + } + + json!({ + "query": query, + "mode": SearchToolMode::Installable, + "total_connectors": entries.len(), + "active_selected_tools": active_selected_tools, + "connectors": result_payloads, + }) + .to_string() + } + } + }; Ok(ToolOutput::Function { body: FunctionCallOutputBody::Text(content), @@ -245,6 +347,62 @@ fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> Stri parts.join(" ") } +fn build_connector_search_text(connector: &AppInfo) -> String { + let mut parts = vec![connector.id.clone(), connector.name.clone()]; + + if let Some(description) = connector.description.as_deref() + && !description.trim().is_empty() + { + parts.push(description.to_string()); + } + + if let Some(labels) = connector.labels.as_ref() { + parts.extend( + labels + .iter() + .flat_map(|(key, value)| [key.clone(), value.clone()]), + ); + } + + if let Some(branding) = connector.branding.as_ref() { + if let Some(category) = branding.category.as_deref() + && !category.trim().is_empty() + { + parts.push(category.to_string()); + } + if let Some(developer) = branding.developer.as_deref() + && !developer.trim().is_empty() + { + parts.push(developer.to_string()); + } + } + + if let Some(app_metadata) = connector.app_metadata.as_ref() { + if let Some(categories) = app_metadata.categories.as_ref() { + parts.extend(categories.iter().cloned()); + } + if let Some(sub_categories) = app_metadata.sub_categories.as_ref() { + parts.extend(sub_categories.iter().cloned()); + } + if let Some(seo_description) = app_metadata.seo_description.as_deref() + && !seo_description.trim().is_empty() + { + parts.push(seo_description.to_string()); + } + if let Some(developer) = app_metadata.developer.as_deref() + && !developer.trim().is_empty() + { + parts.push(developer.to_string()); + } + } + + if !connector.plugin_display_names.is_empty() { + parts.extend(connector.plugin_display_names.iter().cloned()); + } + + parts.join(" ") +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs new file mode 100644 index 0000000000..5727796c2e --- /dev/null +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -0,0 +1,293 @@ +use std::collections::BTreeMap; + +use async_trait::async_trait; +use codex_app_server_protocol::McpElicitationObjectType; +use codex_app_server_protocol::McpElicitationSchema; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_protocol::models::FunctionCallOutputBody; +use codex_rmcp_client::ElicitationAction; +use codex_rmcp_client::ElicitationResponse; +use rmcp::model::RequestId; +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; + +use crate::connectors; +use crate::function_tool::FunctionCallError; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +pub struct ToolSuggestHandler; + +pub(crate) const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest"; +const TOOL_SUGGEST_DECISION_INSTALL: &str = "install"; +const TOOL_SUGGEST_DECISION_NOT_NOW: &str = "not_now"; +const TOOL_SUGGEST_META_KIND_KEY: &str = "codex_approval_kind"; +const TOOL_SUGGEST_META_KIND_VALUE: &str = "app_install_suggestion"; + +#[derive(Deserialize)] +struct ToolSuggestArgs { + connector_id: String, +} + +#[derive(Deserialize)] +struct ToolSuggestElicitationContent { + decision: String, +} + +fn tool_suggest_message( + connector_name: &str, + connector_description: Option<&str>, + install_url: &str, +) -> String { + let mut parts = vec![format!("Install {connector_name} to continue?")]; + if let Some(description) = connector_description.map(str::trim) + && !description.is_empty() + { + parts.push(description.to_string()); + } + parts.push(format!("Install URL: {install_url}")); + parts.join(" | ") +} + +fn tool_suggest_requested_schema() -> McpElicitationSchema { + McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + } +} + +fn tool_suggest_elicitation_meta( + connector_id: &str, + connector_name: &str, + connector_description: Option<&str>, + install_url: &str, +) -> Value { + json!({ + TOOL_SUGGEST_META_KIND_KEY: TOOL_SUGGEST_META_KIND_VALUE, + "connector_id": connector_id, + "connector_name": connector_name, + "connector_description": connector_description, + "install_url": install_url, + }) +} + +fn parse_tool_suggest_elicitation_response( + response: ElicitationResponse, +) -> (&'static str, String) { + let elicitation_action = match response.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }; + + let user_decision = match response.action { + ElicitationAction::Accept => response + .content + .and_then(|content| { + serde_json::from_value::(content).ok() + }) + .map(|content| content.decision) + .filter(|decision| { + matches!( + decision.as_str(), + TOOL_SUGGEST_DECISION_INSTALL | TOOL_SUGGEST_DECISION_NOT_NOW + ) + }) + .unwrap_or_else(|| TOOL_SUGGEST_DECISION_INSTALL.to_string()), + ElicitationAction::Decline | ElicitationAction::Cancel => { + TOOL_SUGGEST_DECISION_NOT_NOW.to_string() + } + }; + + (elicitation_action, user_decision) +} + +#[async_trait] +impl ToolHandler for ToolSuggestHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + payload, + turn, + call_id, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::Fatal(format!( + "{TOOL_SUGGEST_TOOL_NAME} handler received unsupported payload" + ))); + } + }; + + let args: ToolSuggestArgs = parse_arguments(&arguments)?; + let connector_id = args.connector_id.trim(); + if connector_id.is_empty() { + return Err(FunctionCallError::RespondToModel( + "connector_id must not be empty".to_string(), + )); + } + + let connectors = match connectors::list_cached_connectors(&turn.config).await { + Some(connectors) => connectors, + None => connectors::list_connectors(&turn.config) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to load installable apps: {err}" + )) + })?, + }; + + let connector = connectors + .into_iter() + .find(|connector| connector.id == connector_id) + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!("unknown connector_id `{connector_id}`")) + })?; + + if connector.is_accessible { + return Err(FunctionCallError::RespondToModel(format!( + "connector_id `{connector_id}` is already available; use available tools or search_tool_bm25 with mode `available` instead" + ))); + } + + let install_url = connector + .install_url + .clone() + .unwrap_or_else(|| connectors::connector_install_url(&connector.name, &connector.id)); + let request_id = RequestId::String(format!("{TOOL_SUGGEST_TOOL_NAME}_{call_id}").into()); + let elicitation_response = session + .request_mcp_server_elicitation( + turn.as_ref(), + request_id, + McpServerElicitationRequestParams { + thread_id: session.conversation_id.to_string(), + turn_id: Some(turn.sub_id.clone()), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(tool_suggest_elicitation_meta( + &connector.id, + &connector.name, + connector.description.as_deref(), + &install_url, + )), + message: tool_suggest_message( + &connector.name, + connector.description.as_deref(), + &install_url, + ), + requested_schema: tool_suggest_requested_schema(), + }, + }, + ) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "tool_suggest was cancelled before receiving a response".to_string(), + ) + })?; + let (elicitation_action, user_decision) = + parse_tool_suggest_elicitation_response(elicitation_response); + let assistant_instruction = if user_decision == TOOL_SUGGEST_DECISION_INSTALL { + "The user confirmed they installed this app. Treat it as installed for this request, but verify its tools appear in an available-tool search before trying to use them." + } else { + "The user did not install this app in this flow, either because they chose not to or did not finish setup. Do not try to use its tools in this turn." + }; + let content = json!({ + "connector_id": connector.id, + "connector_name": connector.name, + "connector_description": connector.description, + "install_url": install_url, + "elicitation_action": elicitation_action, + "user_decision": user_decision, + "assistant_instruction": assistant_instruction, + }) + .to_string(); + + Ok(ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + success: Some(true), + }) + } +} + +#[cfg(test)] +mod tests { + use codex_rmcp_client::ElicitationAction; + use codex_rmcp_client::ElicitationResponse; + use pretty_assertions::assert_eq; + + use super::TOOL_SUGGEST_DECISION_INSTALL; + use super::TOOL_SUGGEST_DECISION_NOT_NOW; + use super::parse_tool_suggest_elicitation_response; + use super::tool_suggest_message; + + #[test] + fn tool_suggest_message_uses_single_line_text() { + let message = tool_suggest_message( + "Docs & Notes", + Some("Install & sync"), + "https://example.com/apps?name=Docs", + ); + + assert_eq!( + message, + "Install Docs & Notes to continue? | Install & sync | Install URL: https://example.com/apps?name=Docs" + ); + } + + #[test] + fn accepted_tool_suggest_defaults_to_install_without_form_content() { + let (elicitation_action, user_decision) = + parse_tool_suggest_elicitation_response(ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: None, + }); + + assert_eq!(elicitation_action, "accept"); + assert_eq!(user_decision, TOOL_SUGGEST_DECISION_INSTALL); + } + + #[test] + fn declined_tool_suggest_maps_to_not_now() { + let (elicitation_action, user_decision) = + parse_tool_suggest_elicitation_response(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + + assert_eq!(elicitation_action, "decline"); + assert_eq!(user_decision, TOOL_SUGGEST_DECISION_NOT_NOW); + } + + #[test] + fn cancelled_tool_suggest_maps_to_not_now() { + let (elicitation_action, user_decision) = + parse_tool_suggest_elicitation_response(ElicitationResponse { + action: ElicitationAction::Cancel, + content: None, + meta: None, + }); + + assert_eq!(elicitation_action, "cancel"); + assert_eq!(user_decision, TOOL_SUGGEST_DECISION_NOT_NOW); + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index b6eb592dcc..e47fd174df 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -10,6 +10,7 @@ use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT; use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME; +use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; @@ -1248,6 +1249,15 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp )), }, ), + ( + "mode".to_string(), + JsonSchema::String { + description: Some( + "Search mode: `available` searches currently available app tools; `installable` searches apps that are not installed yet. Defaults to `available`." + .to_string(), + ), + }, + ), ]); let mut app_names = app_tools .values() @@ -1272,6 +1282,28 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp }) } +fn create_tool_suggest_tool() -> ToolSpec { + let properties = BTreeMap::from([( + "connector_id".to_string(), + JsonSchema::String { + description: Some( + "Connector ID returned by `search_tool_bm25` in `installable` mode.".to_string(), + ), + }, + )]); + + ToolSpec::Function(ResponsesApiTool { + name: TOOL_SUGGEST_TOOL_NAME.to_string(), + description: "Prompt the user to install an app that is not installed yet.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["connector_id".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_read_file_tool() -> ToolSpec { let indentation_properties = BTreeMap::from([ ( @@ -1816,6 +1848,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; + use crate::tools::handlers::ToolSuggestHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::handlers::ViewImageHandler; use std::sync::Arc; @@ -1835,6 +1868,7 @@ pub(crate) fn build_specs( default_mode_request_user_input: config.default_mode_request_user_input, }); let search_tool_handler = Arc::new(SearchToolBm25Handler); + let tool_suggest_handler = Arc::new(ToolSuggestHandler); let js_repl_handler = Arc::new(JsReplHandler); let js_repl_reset_handler = Arc::new(JsReplResetHandler); let artifacts_handler = Arc::new(ArtifactsHandler); @@ -1908,7 +1942,9 @@ pub(crate) fn build_specs( && let Some(app_tools) = app_tools { builder.push_spec_with_parallel_support(create_search_tool_bm25_tool(&app_tools), true); + builder.push_spec_with_parallel_support(create_tool_suggest_tool(), true); builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler); + builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler); } if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { @@ -3165,6 +3201,76 @@ mod tests { }; assert!(description.contains("Calendar")); assert!(!description.contains("mcp__rmcp__echo")); + assert!(description.contains("Always search `mode: \"available\"` first.")); + assert!(description.contains("call `tool_suggest`")); + } + + #[test] + fn search_tool_spec_includes_mode_and_tool_suggest_is_registered() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "mcp__codex_apps__calendar_create_event".to_string(), + mcp_tool( + "calendar_create_event", + "Create calendar event", + serde_json::json!({"type": "object"}), + ), + )])), + Some(HashMap::from([( + "mcp__codex_apps__calendar_create_event".to_string(), + ToolInfo { + server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: "calendar_create_event".to_string(), + tool: mcp_tool( + "calendar_create_event", + "Create calendar event", + serde_json::json!({"type": "object"}), + ), + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), + }, + )])), + &[], + ) + .build(); + + let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &search_tool.spec else { + panic!("expected function tool"); + }; + let JsonSchema::Object { properties, .. } = parameters else { + panic!("expected object schema"); + }; + assert!(properties.contains_key("mode")); + + let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &tool_suggest.spec else { + panic!("expected function tool"); + }; + let JsonSchema::Object { + properties, + required, + .. + } = parameters + else { + panic!("expected object schema"); + }; + assert!(properties.contains_key("connector_id")); + assert_eq!(required.as_deref(), Some(&["connector_id".to_string()][..])); } #[test] diff --git a/codex-rs/core/templates/search_tool/tool_description.md b/codex-rs/core/templates/search_tool/tool_description.md index 05667b7356..d473b5cb99 100644 --- a/codex-rs/core/templates/search_tool/tool_description.md +++ b/codex-rs/core/templates/search_tool/tool_description.md @@ -1,6 +1,6 @@ # Apps tool discovery -Searches over apps tool metadata with BM25 and exposes matching tools for the next model call. +Searches over apps metadata with BM25 and exposes matching installed tools for the next model call, or installable apps when requested. MCP tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`search_tool_bm25`). @@ -9,14 +9,17 @@ Follow this workflow: 1. Call `search_tool_bm25` with: - `query` (required): focused terms that describe the capability you need. - `limit` (optional): maximum number of tools to return (default `8`). -2. Use the returned `tools` list to decide which Apps tools are relevant. -3. Matching tools are added to available `tools` and available for the remainder of the current session/thread. -4. Repeated searches in the same session/thread are additive: new matches are unioned into `tools`. + - `mode` (optional): `available` or `installable`. Default is `available`. +2. Always search `mode: "available"` first. +3. If `available` finds the right tool, use the returned `tools` list to decide which Apps tools are relevant. +4. Matching available tools are added to available `tools` for the remainder of the current session/thread. +5. If `available` does not find the right tool and the user strongly wants a specific app, search again with `mode: "installable"`. +6. If `installable` finds the right app, call `tool_suggest` with the returned `connector_id` to prompt the user to install it. Notes: - Core tools remain available without searching. - If you are unsure, start with `limit` between 5 and 10 to see a broader set of tools. -- `query` is matched against Apps tool metadata fields: +- In `available` mode, `query` is matched against Apps tool metadata fields: - `name` - `tool_name` - `server_name` @@ -24,5 +27,7 @@ Notes: - `description` - `connector_name` - input schema property keys (`input_keys`) +- In `installable` mode, `query` is matched against app metadata such as connector id, name, description, labels, categories, and plugin display names. - If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly. +- Do not call app MCP tools for apps returned only by `installable` mode until the user installs them. - Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}. diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 00315d25f4..2b3e506c4d 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -5,12 +5,16 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; +use base64::Engine as _; +use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::NewThread; use codex_core::config::Config; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::features::Feature; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::approvals::ElicitationRequest; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -28,24 +32,34 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const SEARCH_TOOL_INSTRUCTION_SNIPPETS: [&str; 2] = [ - "MCP tools of the apps (Calendar) are hidden until you search for them with this tool", - "Matching tools are added to available `tools` and available for the remainder of the current session/thread.", + "Always search `mode: \"available\"` first.", + "If `installable` finds the right app, call `tool_suggest` with the returned `connector_id` to prompt the user to install it.", ]; const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; +const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest"; const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event"; const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events"; const RMCP_ECHO_TOOL: &str = "mcp__rmcp__echo"; const RMCP_IMAGE_TOOL: &str = "mcp__rmcp__image"; const CALENDAR_CREATE_QUERY: &str = "create calendar event"; const CALENDAR_LIST_QUERY: &str = "list calendar events"; +const NOTION_CONNECTOR_ID: &str = "notion"; +const NOTION_CONNECTOR_NAME: &str = "Notion"; +const NOTION_CONNECTOR_DESCRIPTION: &str = "Workspace docs and notes"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -81,15 +95,20 @@ fn search_tool_description(body: &Value) -> Option { } fn search_tool_output_payload(request: &ResponsesRequest, call_id: &str) -> Value { + function_tool_output_payload(request, call_id, SEARCH_TOOL_BM25_TOOL_NAME) +} + +fn function_tool_output_payload( + request: &ResponsesRequest, + call_id: &str, + tool_name: &str, +) -> Value { let (content, _success) = request .function_call_output_content_and_success(call_id) - .unwrap_or_else(|| { - panic!("{SEARCH_TOOL_BM25_TOOL_NAME} function_call_output should be present") - }); - let content = content - .unwrap_or_else(|| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} output should include content")); + .unwrap_or_else(|| panic!("{tool_name} function_call_output should be present")); + let content = content.unwrap_or_else(|| panic!("{tool_name} output should include content")); serde_json::from_str(&content) - .unwrap_or_else(|_| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} content should be valid JSON")) + .unwrap_or_else(|_| panic!("{tool_name} content should be valid JSON")) } fn active_selected_tools(payload: &Value) -> Vec { @@ -117,6 +136,83 @@ fn search_result_tools(payload: &Value) -> Vec<&Value> { .collect() } +fn search_result_connectors(payload: &Value) -> Vec<&Value> { + payload + .get("connectors") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .collect() +} + +async fn mount_directory_connectors(server: &wiremock::MockServer) { + Mock::given(method("GET")) + .and(path("/connectors/directory/list")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apps": [ + { + "id": "calendar", + "name": "Calendar", + "description": "Manage events", + "appMetadata": { + "categories": ["calendar"] + } + }, + { + "id": NOTION_CONNECTOR_ID, + "name": NOTION_CONNECTOR_NAME, + "description": NOTION_CONNECTOR_DESCRIPTION, + "appMetadata": { + "categories": ["docs"] + } + } + ], + "nextToken": null + }))) + .mount(server) + .await; + + Mock::given(method("GET")) + .and(path("/connectors/directory/list_workspace")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apps": [], + "nextToken": null + }))) + .mount(server) + .await; +} + +fn write_test_chatgpt_auth(home: &std::path::Path) { + let header = json!({ "alg": "none", "typ": "JWT" }); + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "plus", + "chatgpt_account_id": "account_id" + } + }); + let encode = |value: &Value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(value).expect("serialize test JWT section")) + }; + let fake_jwt = format!("{}.{}.{}", encode(&header), encode(&payload), "sig"); + let auth_json = json!({ + "tokens": { + "id_token": fake_jwt, + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "account_id": "account_id" + }, + "last_refresh": chrono::Utc::now() + }); + std::fs::write( + home.join("auth.json"), + serde_json::to_string_pretty(&auth_json).expect("serialize test auth.json"), + ) + .expect("write test auth.json"); +} + fn rmcp_server_config(command: String) -> McpServerConfig { McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -163,9 +259,14 @@ fn configure_apps_with_optional_rmcp( } fn configured_builder(apps_base_url: String, rmcp_server_bin: Option) -> TestCodexBuilder { - test_codex().with_config(move |config| { - configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), rmcp_server_bin); - }) + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_pre_build_hook(|home| { + write_test_chatgpt_auth(home); + }) + .with_config(move |config| { + configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), rmcp_server_bin); + }) } async fn submit_user_input(thread: &Arc, text: &str) -> Result<()> { @@ -182,6 +283,38 @@ async fn submit_user_input(thread: &Arc, text: &str) -> Result<()> Ok(()) } +async fn submit_turn_without_waiting(test: &TestCodex, text: &str) -> Result<()> { + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: test.session_configured.model.clone(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + Ok(()) +} + +async fn wait_for_elicitation_request( + thread: &Arc, +) -> codex_protocol::approvals::ElicitationRequestEvent { + wait_for_event_match(thread, |event| match event { + EventMsg::ElicitationRequest(request) => Some(request.clone()), + _ => None, + }) + .await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_flag_adds_tool() -> Result<()> { skip_if_no_network!(Ok(())); @@ -214,6 +347,10 @@ async fn search_tool_flag_adds_tool() -> Result<()> { tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME), "tools list should include {SEARCH_TOOL_BM25_TOOL_NAME} when enabled: {tools:?}" ); + assert!( + tools.iter().any(|name| name == TOOL_SUGGEST_TOOL_NAME), + "tools list should include {TOOL_SUGGEST_TOOL_NAME} when enabled: {tools:?}" + ); Ok(()) } @@ -1172,3 +1309,589 @@ async fn search_tool_selection_drops_when_fork_excludes_search_turn() -> Result< Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn installable_search_returns_inaccessible_connectors_without_selecting_tools() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_directory_connectors(&server).await; + let call_id = "installable-search"; + let args = json!({ + "query": "notion docs", + "limit": 5, + "mode": "installable", + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "find installable notion app", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests, got {}", + requests.len() + ); + + let payload = search_tool_output_payload(&requests[1], call_id); + assert_eq!( + payload.get("mode").and_then(Value::as_str), + Some("installable") + ); + assert_eq!( + payload.get("total_connectors").and_then(Value::as_u64), + Some(1), + "expected one installable connector: {payload:?}" + ); + assert_eq!( + active_selected_tools(&payload), + Vec::::new(), + "installable search should not select tools: {payload:?}" + ); + let connectors = search_result_connectors(&payload); + assert_eq!( + connectors.len(), + 1, + "expected one connector result: {payload:?}" + ); + assert_eq!( + connectors[0].get("connector_id").and_then(Value::as_str), + Some(NOTION_CONNECTOR_ID), + ); + assert_eq!( + connectors[0].get("connector_name").and_then(Value::as_str), + Some(NOTION_CONNECTOR_NAME), + ); + assert_eq!( + connectors[0] + .get("connector_description") + .and_then(Value::as_str), + Some(NOTION_CONNECTOR_DESCRIPTION), + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn installable_search_preserves_available_selection() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_directory_connectors(&server).await; + let available_call_id = "available-search"; + let installable_call_id = "installable-search"; + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + available_call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&json!({ + "query": CALENDAR_CREATE_QUERY, + "limit": 1, + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + installable_call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&json!({ + "query": "notion docs", + "limit": 5, + "mode": "installable", + }))?, + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_assistant_message("msg-2", "done"), + ev_completed("resp-4"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "find calendar tool", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + test.submit_turn_with_policies( + "find installable notion app", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 4, + "expected 4 requests, got {}", + requests.len() + ); + + let available_payload = search_tool_output_payload(&requests[1], available_call_id); + let selected_tools = active_selected_tools(&available_payload); + assert_eq!( + selected_tools, + vec![CALENDAR_CREATE_TOOL.to_string()], + "available search should select calendar create tool: {available_payload:?}" + ); + + let installable_payload = search_tool_output_payload(&requests[3], installable_call_id); + assert_eq!( + active_selected_tools(&installable_payload), + selected_tools, + "installable search should preserve active selected tools: {installable_payload:?}" + ); + assert_eq!( + search_result_connectors(&installable_payload) + .first() + .and_then(|connector| connector.get("connector_id")) + .and_then(Value::as_str), + Some(NOTION_CONNECTOR_ID), + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn installable_search_reports_catalog_load_errors() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "installable-search"; + let args = json!({ + "query": "notion docs", + "mode": "installable", + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("dummy")) + .with_config({ + let apps_base_url = apps_server.chatgpt_base_url.clone(); + move |config| { + configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), None); + } + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "find installable notion app", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests, got {}", + requests.len() + ); + + let error = requests[1] + .function_call_output_content_and_success(call_id) + .and_then(|(content, _)| content) + .expect("installable search output should contain an error"); + assert!( + error.contains("failed to load installable apps"), + "unexpected installable search error: {error:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_suggest_prompts_install_via_elicitation() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_directory_connectors(&server).await; + let call_id = "tool-suggest"; + let args = json!({ + "connector_id": NOTION_CONNECTOR_ID, + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + TOOL_SUGGEST_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let test = builder.build(&server).await?; + + submit_turn_without_waiting(&test, "suggest notion install").await?; + + let elicitation = wait_for_elicitation_request(&test.codex).await; + assert_eq!(elicitation.server_name, "codex_apps"); + assert!(elicitation.turn_id.is_some()); + let ElicitationRequest::Form { + meta, + message, + requested_schema, + } = elicitation.request + else { + panic!("tool_suggest should emit a form elicitation"); + }; + assert_eq!( + message, + format!( + "Install {NOTION_CONNECTOR_NAME} to continue? | {NOTION_CONNECTOR_DESCRIPTION} | Install URL: https://chatgpt.com/apps/notion/notion" + ) + ); + assert_eq!( + meta, + Some(json!({ + "codex_approval_kind": "app_install_suggestion", + "connector_id": NOTION_CONNECTOR_ID, + "connector_name": NOTION_CONNECTOR_NAME, + "connector_description": NOTION_CONNECTOR_DESCRIPTION, + "install_url": "https://chatgpt.com/apps/notion/notion", + })) + ); + assert_eq!( + requested_schema, + json!({ + "type": "object", + "properties": {} + }) + ); + + test.codex + .submit(Op::ResolveElicitation { + server_name: elicitation.server_name, + request_id: elicitation.id, + decision: ElicitationAction::Accept, + content: None, + meta: None, + }) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests, got {}", + requests.len() + ); + + let payload = function_tool_output_payload(&requests[1], call_id, TOOL_SUGGEST_TOOL_NAME); + assert_eq!( + payload.get("connector_id").and_then(Value::as_str), + Some(NOTION_CONNECTOR_ID), + ); + assert_eq!( + payload.get("connector_name").and_then(Value::as_str), + Some(NOTION_CONNECTOR_NAME), + ); + assert_eq!( + payload.get("connector_description").and_then(Value::as_str), + Some(NOTION_CONNECTOR_DESCRIPTION), + ); + assert_eq!( + payload.get("install_url").and_then(Value::as_str), + Some("https://chatgpt.com/apps/notion/notion"), + ); + assert_eq!( + payload.get("elicitation_action").and_then(Value::as_str), + Some("accept"), + ); + assert_eq!( + payload.get("user_decision").and_then(Value::as_str), + Some("install"), + ); + assert!( + payload + .get("assistant_instruction") + .and_then(Value::as_str) + .is_some_and(|instruction| instruction.contains("confirmed they installed")), + "unexpected tool_suggest payload: {payload:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_suggest_rejects_accessible_connector() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_directory_connectors(&server).await; + let call_id = "tool-suggest"; + let args = json!({ + "connector_id": "calendar", + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + TOOL_SUGGEST_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "suggest calendar install", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests, got {}", + requests.len() + ); + + let error = requests[1] + .function_call_output_content_and_success(call_id) + .and_then(|(content, _)| content) + .expect("tool_suggest should return an error"); + assert!( + error.contains("already available"), + "unexpected accessible connector rejection: {error:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_suggest_reports_not_now_when_user_declines_install() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_directory_connectors(&server).await; + let call_id = "tool-suggest"; + let args = json!({ + "connector_id": NOTION_CONNECTOR_ID, + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + TOOL_SUGGEST_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let test = builder.build(&server).await?; + + submit_turn_without_waiting(&test, "suggest notion install").await?; + + let elicitation = wait_for_elicitation_request(&test.codex).await; + test.codex + .submit(Op::ResolveElicitation { + server_name: elicitation.server_name, + request_id: elicitation.id, + decision: ElicitationAction::Decline, + content: None, + meta: None, + }) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests, got {}", + requests.len() + ); + + let payload = function_tool_output_payload(&requests[1], call_id, TOOL_SUGGEST_TOOL_NAME); + assert_eq!( + payload.get("elicitation_action").and_then(Value::as_str), + Some("decline"), + ); + assert_eq!( + payload.get("user_decision").and_then(Value::as_str), + Some("not_now"), + ); + assert!( + payload + .get("assistant_instruction") + .and_then(Value::as_str) + .is_some_and(|instruction| instruction.contains("did not install this app")), + "unexpected tool_suggest payload: {payload:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_suggest_rejects_unknown_connector() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_directory_connectors(&server).await; + let call_id = "tool-suggest"; + let args = json!({ + "connector_id": "unknown-app", + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + TOOL_SUGGEST_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "suggest unknown install", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests, got {}", + requests.len() + ); + + let error = requests[1] + .function_call_output_content_and_success(call_id) + .and_then(|(content, _)| content) + .expect("tool_suggest should return an error"); + assert!( + error.contains("unknown connector_id `unknown-app`"), + "unexpected unknown connector rejection: {error:?}" + ); + + Ok(()) +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 32f5d59635..d1914fa096 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2257,6 +2257,7 @@ impl App { url, is_installed, is_enabled, + elicitation_resolution: None, }); } AppEvent::OpenUrlInBrowser { url } => { diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 2e6fab3d24..a8698d7f60 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -1,3 +1,9 @@ +use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::mcp::RequestId as McpRequestId; +use codex_protocol::protocol::Op; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -11,6 +17,9 @@ use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; use textwrap::wrap; use super::CancellationEvent; @@ -34,6 +43,19 @@ enum AppLinkScreen { InstallConfirmation, } +const APP_INSTALL_SUGGESTION_META_KIND_VALUE: &str = "app_install_suggestion"; +const APP_INSTALL_SUGGESTION_DECISION_INSTALL: &str = "install"; +pub(crate) const APP_LINK_INSTALL_INSTRUCTIONS: &str = + "Install this app in your browser, then reload Codex."; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AppLinkElicitationResolution { + pub(crate) thread_id: ThreadId, + pub(crate) server_name: String, + pub(crate) request_id: McpRequestId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AppLinkViewParams { pub(crate) app_id: String, pub(crate) title: String, @@ -42,6 +64,49 @@ pub(crate) struct AppLinkViewParams { pub(crate) url: String, pub(crate) is_installed: bool, pub(crate) is_enabled: bool, + pub(crate) elicitation_resolution: Option, +} + +#[derive(Deserialize)] +struct AppInstallSuggestionMeta { + #[serde(rename = "codex_approval_kind")] + approval_kind: String, + connector_id: String, + connector_name: String, + connector_description: Option, + install_url: String, +} + +pub(crate) fn app_install_suggestion_params_from_event( + thread_id: ThreadId, + request: &ElicitationRequestEvent, +) -> Option { + let ElicitationRequest::Form { + meta: Some(meta), .. + } = &request.request + else { + return None; + }; + + let meta = serde_json::from_value::(meta.clone()).ok()?; + if meta.approval_kind != APP_INSTALL_SUGGESTION_META_KIND_VALUE { + return None; + } + + Some(AppLinkViewParams { + app_id: meta.connector_id, + title: meta.connector_name, + description: meta.connector_description, + instructions: APP_LINK_INSTALL_INSTRUCTIONS.to_string(), + url: meta.install_url, + is_installed: false, + is_enabled: false, + elicitation_resolution: Some(AppLinkElicitationResolution { + thread_id, + server_name: request.server_name.clone(), + request_id: request.id.clone(), + }), + }) } pub(crate) struct AppLinkView { @@ -52,6 +117,7 @@ pub(crate) struct AppLinkView { url: String, is_installed: bool, is_enabled: bool, + elicitation_resolution: Option, app_event_tx: AppEventSender, screen: AppLinkScreen, selected_action: usize, @@ -68,6 +134,7 @@ impl AppLinkView { url, is_installed, is_enabled, + elicitation_resolution, } = params; Self { app_id, @@ -77,6 +144,7 @@ impl AppLinkView { url, is_installed, is_enabled, + elicitation_resolution, app_event_tx, screen: AppLinkScreen::Link, selected_action: 0, @@ -127,6 +195,17 @@ impl AppLinkView { self.app_event_tx.send(AppEvent::RefreshConnectors { force_refetch: true, }); + self.resolve_elicitation( + ElicitationAction::Accept, + Some(json!({ + "decision": APP_INSTALL_SUGGESTION_DECISION_INSTALL, + })), + ); + self.complete = true; + } + + fn close_flow(&mut self) { + self.resolve_elicitation(ElicitationAction::Cancel, None); self.complete = true; } @@ -143,12 +222,29 @@ impl AppLinkView { }); } + fn resolve_elicitation(&self, decision: ElicitationAction, content: Option) { + let Some(resolution) = self.elicitation_resolution.as_ref() else { + return; + }; + + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: resolution.thread_id, + op: Op::ResolveElicitation { + server_name: resolution.server_name.clone(), + request_id: resolution.request_id.clone(), + decision, + content, + meta: None, + }, + }); + } + fn activate_selected_action(&mut self) { match self.screen { AppLinkScreen::Link => match self.selected_action { 0 => self.open_chatgpt_link(), 1 if self.is_installed => self.toggle_enabled(), - _ => self.complete = true, + _ => self.close_flow(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { 0 => self.refresh_connectors_and_close(), @@ -366,7 +462,7 @@ impl BottomPaneView for AppLinkView { } fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; + self.close_flow(); CancellationEvent::Handled } @@ -447,24 +543,35 @@ mod tests { use super::*; use crate::app_event::AppEvent; use crate::render::renderable::Renderable; + use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; + fn base_params() -> AppLinkViewParams { + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + elicitation_resolution: None, + } + } + + fn elicitation_resolution(thread_id: ThreadId) -> AppLinkElicitationResolution { + AppLinkElicitationResolution { + thread_id, + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + } + } + #[test] fn installed_app_has_toggle_action() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = AppLinkView::new( - AppLinkViewParams { - app_id: "connector_1".to_string(), - title: "Notion".to_string(), - description: None, - instructions: "Manage app".to_string(), - url: "https://example.test/notion".to_string(), - is_installed: true, - is_enabled: true, - }, - tx, - ); + let view = AppLinkView::new(base_params(), tx); assert_eq!( view.action_labels(), @@ -476,18 +583,7 @@ mod tests { fn toggle_action_sends_set_app_enabled_and_updates_label() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = AppLinkView::new( - AppLinkViewParams { - app_id: "connector_1".to_string(), - title: "Notion".to_string(), - description: None, - instructions: "Manage app".to_string(), - url: "https://example.test/notion".to_string(), - is_installed: true, - is_enabled: true, - }, - tx, - ); + let mut view = AppLinkView::new(base_params(), tx); view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); @@ -512,18 +608,9 @@ mod tests { let tx = AppEventSender::new(tx_raw); let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; - let mut view = AppLinkView::new( - AppLinkViewParams { - app_id: "connector_1".to_string(), - title: "Notion".to_string(), - description: None, - instructions: "Manage app".to_string(), - url: url_like.to_string(), - is_installed: true, - is_enabled: true, - }, - tx, - ); + let mut params = base_params(); + params.url = url_like.to_string(); + let mut view = AppLinkView::new(params, tx); view.screen = AppLinkScreen::InstallConfirmation; let rendered: Vec = view @@ -552,18 +639,9 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42"; - let mut view = AppLinkView::new( - AppLinkViewParams { - app_id: "connector_1".to_string(), - title: "Notion".to_string(), - description: None, - instructions: "Manage app".to_string(), - url: url.to_string(), - is_installed: true, - is_enabled: true, - }, - tx, - ); + let mut params = base_params(); + params.url = url.to_string(); + let mut view = AppLinkView::new(params, tx); view.screen = AppLinkScreen::InstallConfirmation; let width: u16 = 36; @@ -593,4 +671,139 @@ mod tests { "expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}" ); } + + #[test] + fn app_install_suggestion_event_builds_app_link_params() { + let thread_id = ThreadId::default(); + let request = ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), + server_name: "codex_apps".to_string(), + id: McpRequestId::String("request-1".to_string()), + request: ElicitationRequest::Form { + meta: Some(json!({ + "codex_approval_kind": "app_install_suggestion", + "connector_id": "connector_1", + "connector_name": "Notion", + "connector_description": "Docs and notes", + "install_url": "https://example.test/notion", + })), + message: "Install Notion to continue?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": {}, + }), + }, + }; + + let params = app_install_suggestion_params_from_event(thread_id, &request); + + assert_eq!( + params, + Some(AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: Some("Docs and notes".to_string()), + instructions: APP_LINK_INSTALL_INSTRUCTIONS.to_string(), + url: "https://example.test/notion".to_string(), + is_installed: false, + is_enabled: false, + elicitation_resolution: Some(elicitation_resolution(thread_id)), + }) + ); + } + + #[test] + fn install_confirmation_resolves_elicitation_after_refresh() { + let expected_thread_id = ThreadId::default(); + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut params = base_params(); + params.is_installed = false; + params.is_enabled = false; + params.instructions = APP_LINK_INSTALL_INSTRUCTIONS.to_string(); + params.elicitation_resolution = Some(elicitation_resolution(expected_thread_id)); + let mut view = AppLinkView::new(params, tx); + view.screen = AppLinkScreen::InstallConfirmation; + + view.activate_selected_action(); + + match rx.try_recv() { + Ok(AppEvent::RefreshConnectors { force_refetch }) => { + assert!(force_refetch); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { + thread_id, + op: + Op::ResolveElicitation { + server_name, + request_id, + decision, + content, + meta, + }, + }) => { + assert_eq!(thread_id, expected_thread_id); + assert_eq!(server_name, "codex_apps"); + assert_eq!(request_id, McpRequestId::String("request-1".to_string())); + assert_eq!(decision, ElicitationAction::Accept); + assert_eq!( + content, + Some(json!({ + "decision": "install", + })) + ); + assert_eq!(meta, None); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + + assert!(view.is_complete()); + } + + #[test] + fn closing_link_view_cancels_elicitation_flow() { + let expected_thread_id = ThreadId::default(); + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut params = base_params(); + params.is_installed = false; + params.is_enabled = false; + params.instructions = APP_LINK_INSTALL_INSTRUCTIONS.to_string(); + params.elicitation_resolution = Some(elicitation_resolution(expected_thread_id)); + let mut view = AppLinkView::new(params, tx); + view.selected_action = 1; + + view.activate_selected_action(); + + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { + thread_id, + op: + Op::ResolveElicitation { + server_name, + request_id, + decision, + content, + meta, + }, + }) => { + assert_eq!(thread_id, expected_thread_id); + assert_eq!(server_name, "codex_apps"); + assert_eq!(request_id, McpRequestId::String("request-1".to_string())); + assert_eq!(decision, ElicitationAction::Cancel); + assert_eq!(content, None); + assert_eq!(meta, None); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + + assert!(view.is_complete()); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7f4f7dd61a..cce2c5c544 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -46,8 +46,10 @@ mod mcp_server_elicitation; mod multi_select_picker; mod request_user_input; mod status_line_setup; +pub(crate) use app_link_view::APP_LINK_INSTALL_INSTRUCTIONS; pub(crate) use app_link_view::AppLinkView; pub(crate) use app_link_view::AppLinkViewParams; +pub(crate) use app_link_view::app_install_suggestion_params_from_event; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; @@ -874,6 +876,16 @@ impl BottomPane { self.push_view(view); } + pub(crate) fn push_app_link_view(&mut self, params: AppLinkViewParams) { + let view = AppLinkView::new(params, self.app_event_tx.clone()); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + false, + Some("Complete the app setup flow to continue.".to_string()), + ); + self.push_view(Box::new(view)); + } + /// Called when the agent requests user approval. pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { let request = if let Some(view) = self.view_stack.last_mut() { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c7b7aff7a4..ef6e0d44f4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2873,7 +2873,13 @@ impl ChatWidget { }); let thread_id = self.thread_id.unwrap_or_default(); - if let Some(request) = McpServerElicitationFormRequest::from_event(thread_id, ev.clone()) { + if let Some(params) = + crate::bottom_pane::app_install_suggestion_params_from_event(thread_id, &ev) + { + self.bottom_pane.push_app_link_view(params); + } else if let Some(request) = + McpServerElicitationFormRequest::from_event(thread_id, ev.clone()) + { self.bottom_pane .push_mcp_server_elicitation_request(request); } else { @@ -7738,7 +7744,7 @@ impl ChatWidget { let instructions = if connector.is_accessible { "Manage this app in your browser." } else { - "Install this app in your browser, then reload Codex." + crate::bottom_pane::APP_LINK_INSTALL_INSTRUCTIONS }; if let Some(install_url) = connector.install_url.clone() { let app_id = connector.id.clone();