From ba5b94287e21bbe3da565d2f070a14f8d4971328 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 11 Mar 2026 22:06:59 -0700 Subject: [PATCH] [apps] Add tool_suggest tool. (#14287) - [x] Add tool_suggest tool. - [x] Move chatgpt/src/connectors.rs and core/src/connectors.rs into a dedicated mod so that we have all the logic and global cache in one place. - [x] Update TUI app link view to support rendering the installation view for mcp elicitation. --------- Co-authored-by: Shaqayeq Co-authored-by: Eric Traut Co-authored-by: pakrym-oai Co-authored-by: Ahmed Ibrahim Co-authored-by: guinness-oai Co-authored-by: Eugene Brevdo Co-authored-by: Charlie Guo Co-authored-by: Fouad Matin Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Co-authored-by: xl-openai Co-authored-by: alexsong-oai Co-authored-by: Owen Lin Co-authored-by: sdcoffey Co-authored-by: Codex Co-authored-by: Won Park Co-authored-by: Dylan Hurd Co-authored-by: celia-oai Co-authored-by: gabec-openai Co-authored-by: joeytrasatti-openai Co-authored-by: Leo Shimonaka Co-authored-by: Rasmus Rygaard Co-authored-by: maja-openai <163171781+maja-openai@users.noreply.github.com> Co-authored-by: pash-openai Co-authored-by: Josh McKinney --- codex-rs/Cargo.lock | 15 +- codex-rs/Cargo.toml | 2 + codex-rs/chatgpt/Cargo.toml | 2 +- codex-rs/chatgpt/src/connectors.rs | 386 +------------ codex-rs/connectors/BUILD.bazel | 6 + codex-rs/connectors/Cargo.toml | 18 + codex-rs/connectors/src/lib.rs | 534 ++++++++++++++++++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/codex.rs | 62 +- codex-rs/core/src/codex_tests.rs | 28 +- codex-rs/core/src/connectors.rs | 341 ++++++++++- codex-rs/core/src/features.rs | 14 + codex-rs/core/src/mcp_connection_manager.rs | 12 +- codex-rs/core/src/tools/code_mode.rs | 10 +- codex-rs/core/src/tools/discoverable.rs | 111 ++++ codex-rs/core/src/tools/handlers/mod.rs | 3 + .../src/tools/handlers/search_tool_bm25.rs | 0 .../core/src/tools/handlers/tool_suggest.rs | 465 +++++++++++++++ codex-rs/core/src/tools/js_repl/mod.rs | 19 +- codex-rs/core/src/tools/mod.rs | 1 + codex-rs/core/src/tools/router.rs | 70 ++- codex-rs/core/src/tools/spec.rs | 301 +++++++++- .../search_tool/tool_suggest_description.md | 25 + .../core/tests/common/apps_test_server.rs | 34 ++ codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/bottom_pane/app_link_view.rs | 348 ++++++++++++ .../src/bottom_pane/mcp_server_elicitation.rs | 128 ++++- codex-rs/tui/src/bottom_pane/mod.rs | 48 ++ ...nk_view_enable_suggestion_with_reason.snap | 20 + ...k_view_install_suggestion_with_reason.snap | 18 + 31 files changed, 2594 insertions(+), 437 deletions(-) create mode 100644 codex-rs/connectors/BUILD.bazel create mode 100644 codex-rs/connectors/Cargo.toml create mode 100644 codex-rs/connectors/src/lib.rs create mode 100644 codex-rs/core/src/tools/discoverable.rs delete mode 100644 codex-rs/core/src/tools/handlers/search_tool_bm25.rs create mode 100644 codex-rs/core/src/tools/handlers/tool_suggest.rs create mode 100644 codex-rs/core/templates/search_tool/tool_suggest_description.md create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index addd5ea83..097fef2af 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1619,6 +1619,7 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", + "codex-connectors", "codex-core", "codex-git", "codex-utils-cargo-bin", @@ -1628,7 +1629,6 @@ dependencies = [ "serde_json", "tempfile", "tokio", - "urlencoding", ] [[package]] @@ -1790,6 +1790,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "codex-connectors" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-app-server-protocol", + "pretty_assertions", + "serde", + "tokio", + "urlencoding", +] + [[package]] name = "codex-core" version = "0.0.0" @@ -1814,6 +1826,7 @@ dependencies = [ "codex-async-utils", "codex-client", "codex-config", + "codex-connectors", "codex-execpolicy", "codex-file-search", "codex-git", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 681487c09..77ffb6120 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -16,6 +16,7 @@ members = [ "cloud-tasks", "cloud-tasks-client", "cli", + "connectors", "config", "shell-command", "shell-escalation", @@ -98,6 +99,7 @@ codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } codex-client = { path = "codex-client" } codex-cloud-requirements = { path = "cloud-requirements" } +codex-connectors = { path = "connectors" } codex-config = { path = "config" } codex-core = { path = "core" } codex-exec = { path = "exec" } diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 823b63cad..17a6f97ad 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } +codex-connectors = { workspace = true } codex-core = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-cargo-bin = { workspace = true } @@ -17,7 +18,6 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } codex-git = { workspace = true } -urlencoding = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index dfc05fe31..54e2590c6 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -1,25 +1,18 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::sync::LazyLock; -use std::sync::Mutex as StdMutex; - use codex_core::AuthManager; use codex_core::config::Config; use codex_core::token_data::TokenData; -use serde::Deserialize; +use std::collections::HashSet; 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; +use codex_connectors::AllConnectorsCacheKey; +use codex_connectors::DirectoryListResponse; + 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; @@ -30,51 +23,8 @@ 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)); - async fn apps_enabled(config: &Config) -> bool { let auth_manager = AuthManager::shared( config.codex_home.clone(), @@ -83,7 +33,6 @@ async fn apps_enabled(config: &Config) -> bool { ); config.features.apps_enabled(Some(&auth_manager)).await } - pub async fn list_connectors(config: &Config) -> anyhow::Result> { if !apps_enabled(config).await { return Ok(Vec::new()); @@ -117,7 +66,7 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> } 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| { + codex_connectors::cached_all_connectors(&cache_key).map(|connectors| { let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); filter_disallowed_connectors(connectors) }) @@ -136,76 +85,31 @@ pub async fn list_all_connectors_with_options( 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 = codex_connectors::list_all_connectors_with_options( + cache_key, + token_data.id_token.is_workspace_account(), + force_refetch, + |path| async move { + chatgpt_get_request_with_timeout::( + config, + path, + Some(DIRECTORY_CONNECTORS_TIMEOUT), + ) + .await + }, + ) + .await?; 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(), - }); + AllConnectorsCacheKey::new( + config.chatgpt_base_url.clone(), + token_data.account_id.clone(), + token_data.id_token.chatgpt_user_id.clone(), + token_data.id_token.is_workspace_account(), + ) } fn plugin_apps_for_config(config: &Config) -> Vec { @@ -235,248 +139,10 @@ pub fn merge_connectors_with_accessible( 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 codex_core::connectors::connector_install_url; use pretty_assertions::assert_eq; fn app(id: &str) -> AppInfo { diff --git a/codex-rs/connectors/BUILD.bazel b/codex-rs/connectors/BUILD.bazel new file mode 100644 index 000000000..c4cb9ebde --- /dev/null +++ b/codex-rs/connectors/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "connectors", + crate_name = "codex_connectors", +) diff --git a/codex-rs/connectors/Cargo.toml b/codex-rs/connectors/Cargo.toml new file mode 100644 index 000000000..9cd2428a7 --- /dev/null +++ b/codex-rs/connectors/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-connectors" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-app-server-protocol = { workspace = true } +serde = { workspace = true, features = ["derive"] } +urlencoding = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs new file mode 100644 index 000000000..1d3a72923 --- /dev/null +++ b/codex-rs/connectors/src/lib.rs @@ -0,0 +1,534 @@ +use std::collections::HashMap; +use std::future::Future; +use std::sync::LazyLock; +use std::sync::Mutex as StdMutex; +use std::time::Duration; +use std::time::Instant; + +use codex_app_server_protocol::AppBranding; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppMetadata; +use serde::Deserialize; + +pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AllConnectorsCacheKey { + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +impl AllConnectorsCacheKey { + pub fn new( + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, + ) -> Self { + Self { + chatgpt_base_url, + account_id, + chatgpt_user_id, + is_workspace_account, + } + } +} + +#[derive(Clone)] +struct CachedAllConnectors { + key: AllConnectorsCacheKey, + expires_at: Instant, + connectors: Vec, +} + +static ALL_CONNECTORS_CACHE: LazyLock>> = + LazyLock::new(|| StdMutex::new(None)); + +#[derive(Debug, Deserialize)] +pub struct DirectoryListResponse { + apps: Vec, + #[serde(alias = "nextToken")] + next_token: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub 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, +} + +pub fn 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 +} + +pub async fn list_all_connectors_with_options( + cache_key: AllConnectorsCacheKey, + is_workspace_account: bool, + force_refetch: bool, + mut fetch_page: F, +) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + if !force_refetch && let Some(cached_connectors) = cached_all_connectors(&cache_key) { + return Ok(cached_connectors); + } + + let mut apps = list_directory_connectors(&mut fetch_page).await?; + if is_workspace_account { + apps.extend(list_workspace_connectors(&mut fetch_page).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)) + }); + write_cached_all_connectors(cache_key, &connectors); + Ok(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(), + }); +} + +async fn list_directory_connectors(fetch_page: &mut F) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + 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 = fetch_page(path).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(fetch_page: &mut F) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + let response = + fetch_page("/connectors/directory/list_workspace?external_logos=true".to_string()).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 connector_install_url(name: &str, connector_id: &str) -> String { + let slug = connector_name_slug(name); + format!("https://chatgpt.com/apps/{slug}/{connector_id}") +} + +fn connector_name_slug(name: &str) -> String { + let mut normalized = String::with_capacity(name.len()); + for character in name.chars() { + if character.is_ascii_alphanumeric() { + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push('-'); + } + } + let normalized = normalized.trim_matches('-'); + if normalized.is_empty() { + "app".to_string() + } else { + normalized.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() + } +} + +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; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + fn cache_key(id: &str) -> AllConnectorsCacheKey { + AllConnectorsCacheKey::new( + "https://chatgpt.example".to_string(), + Some(format!("account-{id}")), + Some(format!("user-{id}")), + true, + ) + } + + fn app(id: &str, name: &str) -> DirectoryApp { + DirectoryApp { + id: id.to_string(), + name: name.to_string(), + description: None, + app_metadata: None, + branding: None, + labels: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + visibility: None, + } + } + + #[tokio::test] + async fn list_all_connectors_uses_shared_cache() -> anyhow::Result<()> { + let calls = Arc::new(AtomicUsize::new(0)); + let call_counter = Arc::clone(&calls); + let key = cache_key("shared"); + + let first = list_all_connectors_with_options(key.clone(), false, false, move |_path| { + let call_counter = Arc::clone(&call_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok(DirectoryListResponse { + apps: vec![app("alpha", "Alpha")], + next_token: None, + }) + } + }) + .await?; + + let second = list_all_connectors_with_options(key, false, false, move |_path| async move { + anyhow::bail!("cache should have been used"); + }) + .await?; + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(first, second); + Ok(()) + } + + #[tokio::test] + async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> { + let key = cache_key("merged"); + let calls = Arc::new(AtomicUsize::new(0)); + let call_counter = Arc::clone(&calls); + + let connectors = list_all_connectors_with_options(key, true, true, move |path| { + let call_counter = Arc::clone(&call_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + if path.starts_with("/connectors/directory/list_workspace") { + Ok(DirectoryListResponse { + apps: vec![ + DirectoryApp { + description: Some("Merged description".to_string()), + branding: Some(AppBranding { + category: Some("calendar".to_string()), + developer: None, + website: None, + privacy_policy: None, + terms_of_service: None, + is_discoverable_app: true, + }), + ..app("alpha", "") + }, + DirectoryApp { + visibility: Some("HIDDEN".to_string()), + ..app("hidden", "Hidden") + }, + ], + next_token: None, + }) + } else { + Ok(DirectoryListResponse { + apps: vec![app("alpha", " Alpha "), app("beta", "Beta")], + next_token: None, + }) + } + } + }) + .await?; + + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(connectors.len(), 2); + assert_eq!(connectors[0].id, "alpha"); + assert_eq!(connectors[0].name, "Alpha"); + assert_eq!( + connectors[0].description.as_deref(), + Some("Merged description") + ); + assert_eq!( + connectors[0].install_url.as_deref(), + Some("https://chatgpt.com/apps/alpha/alpha") + ); + assert_eq!( + connectors[0] + .branding + .as_ref() + .and_then(|branding| branding.category.as_deref()), + Some("calendar") + ); + assert_eq!(connectors[1].id, "beta"); + assert_eq!(connectors[1].name, "Beta"); + Ok(()) + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index e40658652..ef6b8a013 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -32,6 +32,7 @@ codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } codex-client = { workspace = true } +codex-connectors = { workspace = true } codex-config = { workspace = true } codex-shell-command = { workspace = true } codex-skills = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 79632344e..3b7395f48 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -471,6 +471,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_suggest": { + "type": "boolean" + }, "undo": { "type": "boolean" }, @@ -1973,6 +1976,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_suggest": { + "type": "boolean" + }, "undo": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d7070981c..5fa4cffa3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -287,12 +287,14 @@ use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::discoverable::DiscoverableTool; use crate::tools::js_repl::JsReplHandle; use crate::tools::js_repl::resolve_compatible_node; use crate::tools::network_approval::NetworkApprovalService; use crate::tools::network_approval::build_blocked_request_observer; use crate::tools::network_approval::build_network_policy_decider; use crate::tools::parallel::ToolCallRuntime; +use crate::tools::router::ToolRouterParams; use crate::tools::sandboxing::ApprovalStore; use crate::tools::spec::ToolsConfig; use crate::tools::spec::ToolsConfigParams; @@ -6246,7 +6248,7 @@ async fn run_sampling_request( } } -async fn built_tools( +pub(crate) async fn built_tools( sess: &Session, turn_context: &TurnContext, input: &[ResponseItem], @@ -6269,10 +6271,17 @@ async fn built_tools( let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone(); effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await); - let connectors = if turn_context.apps_enabled() { + let apps_enabled = turn_context.apps_enabled(); + let accessible_connectors = + apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(&mcp_tools)); + let accessible_connectors_with_enabled_state = + accessible_connectors.as_ref().map(|connectors| { + connectors::with_app_enabled_state(connectors.clone(), &turn_context.config) + }); + let connectors = if apps_enabled { let connectors = connectors::merge_plugin_apps_with_accessible( loaded_plugins.effective_apps(), - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + accessible_connectors.clone().unwrap_or_default(), ); Some(connectors::with_app_enabled_state( connectors, @@ -6281,6 +6290,34 @@ async fn built_tools( } else { None }; + let auth = sess.services.auth_manager.auth().await; + let discoverable_tools = if apps_enabled + && turn_context.tools_config.search_tool + && turn_context.tools_config.tool_suggest + { + if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { + match connectors::list_tool_suggest_discoverable_tools_with_auth( + &turn_context.config, + auth.as_ref(), + accessible_connectors.as_slice(), + ) + .await + { + Ok(connectors) if connectors.is_empty() => None, + Ok(connectors) => { + Some(connectors.into_iter().map(DiscoverableTool::from).collect()) + } + Err(err) => { + warn!("failed to load discoverable tool suggestions: {err:#}"); + None + } + } + } else { + None + } + } else { + None + }; // Keep the connector-grouped app view around for the router even though // app tools only become prompt-visible after explicit selection/discovery. @@ -6312,14 +6349,17 @@ async fn built_tools( Ok(Arc::new(ToolRouter::from_config( &turn_context.tools_config, - has_mcp_servers.then(|| { - mcp_tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect() - }), - app_tools, - turn_context.dynamic_tools.as_slice(), + ToolRouterParams { + mcp_tools: has_mcp_servers.then(|| { + mcp_tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect() + }), + app_tools, + discoverable_tools, + dynamic_tools: turn_context.dynamic_tools.as_slice(), + }, ))) } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 07881b153..f1892449b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -165,9 +165,12 @@ fn default_image_save_developer_message_text() -> String { fn test_tool_runtime(session: Arc, turn_context: Arc) -> ToolCallRuntime { let router = Arc::new(ToolRouter::from_config( &turn_context.tools_config, - None, - None, - turn_context.dynamic_tools.as_slice(), + crate::tools::router::ToolRouterParams { + mcp_tools: None, + app_tools: None, + discoverable_tools: None, + dynamic_tools: turn_context.dynamic_tools.as_slice(), + }, )); let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); ToolCallRuntime::new(router, session, turn_context, tracker) @@ -3954,14 +3957,17 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { let app_tools = Some(tools.clone()); let router = ToolRouter::from_config( &turn_context.tools_config, - Some( - tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), - app_tools, - turn_context.dynamic_tools.as_slice(), + crate::tools::router::ToolRouterParams { + mcp_tools: Some( + tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + ), + app_tools, + discoverable_tools: None, + dynamic_tools: turn_context.dynamic_tools.as_slice(), + }, ); let item = ResponseItem::CustomToolCall { id: None, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 405eb1868..47d61fe98 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -9,13 +9,17 @@ use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; +use anyhow::Context; use async_channel::unbounded; pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; +use codex_connectors::AllConnectorsCacheKey; +use codex_connectors::DirectoryListResponse; use codex_protocol::protocol::SandboxPolicy; use rmcp::model::ToolAnnotations; use serde::Deserialize; +use serde::de::DeserializeOwned; use tracing::warn; use crate::AuthManager; @@ -24,6 +28,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; @@ -38,8 +43,22 @@ use crate::plugins::AppConnectorId; use crate::plugins::PluginsManager; use crate::token_data::TokenData; -pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); +pub use codex_connectors::CONNECTORS_CACHE_TTL; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); +const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); +const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[ + "connector_2128aebfecb84f64a069897515042a44", + "connector_68df038e0ba48191908c8434991bbac2", + "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", + "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "connector_6f1ec045b8fa4ced8738e32c7f74514b", + "connector_947e0d954944416db111db556030eea6", + "connector_5f3c8c41a1e54ad7a76272c89e2554fa", + "connector_686fad9b54914a35b75be6d06a0f6f31", + "connector_76869538009648d5b282a4bb21c3d157", + "connector_37316be7febe4224b3d31465bae4dbd7", +]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AppToolPolicy { @@ -90,6 +109,19 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) } +pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( + config: &Config, + auth: Option<&CodexAuth>, + accessible_connectors: &[AppInfo], +) -> anyhow::Result> { + let directory_connectors = + list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?; + Ok(filter_tool_suggest_discoverable_tools( + directory_connectors, + accessible_connectors, + )) +} + pub async fn list_cached_accessible_connectors_from_mcp_tools( config: &Config, ) -> Option> { @@ -102,6 +134,21 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors) } +pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( + config: &Config, + auth: Option<&CodexAuth>, + mcp_tools: &HashMap, +) { + if !config.features.enabled(Feature::Apps) { + return; + } + + let cache_key = accessible_connectors_cache_key(config, auth); + let accessible_connectors = + filter_disallowed_connectors(accessible_connectors_from_mcp_tools(mcp_tools)); + write_cached_accessible_connectors(cache_key, &accessible_connectors); +} + pub async fn list_accessible_connectors_from_mcp_tools_with_options( config: &Config, force_refetch: bool, @@ -172,19 +219,33 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( ) .await; - if force_refetch - && let Err(err) = mcp_connection_manager + let refreshed_tools = if force_refetch { + match mcp_connection_manager .hard_refresh_codex_apps_tools_cache() .await - { - warn!( - "failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}" - ); - } + { + Ok(tools) => Some(tools), + Err(err) => { + warn!( + "failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}" + ); + None + } + } + } else { + None + }; + let refreshed_tools_succeeded = refreshed_tools.is_some(); - let mut tools = mcp_connection_manager.list_all_tools().await; + let mut tools = if let Some(tools) = refreshed_tools { + tools + } else { + mcp_connection_manager.list_all_tools().await + }; let mut should_reload_tools = false; - let codex_apps_ready = if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { + let codex_apps_ready = if refreshed_tools_succeeded { + true + } else if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { let immediate_ready = mcp_connection_manager .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO) .await; @@ -281,6 +342,119 @@ fn write_cached_accessible_connectors( }); } +fn filter_tool_suggest_discoverable_tools( + directory_connectors: Vec, + accessible_connectors: &[AppInfo], +) -> Vec { + let accessible_connector_ids: HashSet<&str> = accessible_connectors + .iter() + .filter(|connector| connector.is_accessible && connector.is_enabled) + .map(|connector| connector.id.as_str()) + .collect(); + let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS + .iter() + .copied() + .collect(); + + let mut connectors = filter_disallowed_connectors(directory_connectors) + .into_iter() + .filter(|connector| !accessible_connector_ids.contains(connector.id.as_str())) + .filter(|connector| allowed_connector_ids.contains(connector.id.as_str())) + .collect::>(); + connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + connectors +} + +async fn list_directory_connectors_for_tool_suggest_with_auth( + config: &Config, + auth: Option<&CodexAuth>, +) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { + return Ok(Vec::new()); + } + + let token_data = if let Some(auth) = auth { + auth.get_token_data().ok() + } else { + let auth_manager = auth_manager_from_config(config); + auth_manager + .auth() + .await + .and_then(|auth| auth.get_token_data().ok()) + }; + let Some(token_data) = token_data else { + return Ok(Vec::new()); + }; + + let account_id = match token_data.account_id.as_deref() { + Some(account_id) if !account_id.is_empty() => account_id, + _ => return Ok(Vec::new()), + }; + let access_token = token_data.access_token.clone(); + let account_id = account_id.to_string(); + let is_workspace_account = token_data.id_token.is_workspace_account(); + let cache_key = AllConnectorsCacheKey::new( + config.chatgpt_base_url.clone(), + Some(account_id.clone()), + token_data.id_token.chatgpt_user_id.clone(), + is_workspace_account, + ); + + codex_connectors::list_all_connectors_with_options( + cache_key, + is_workspace_account, + false, + |path| { + let access_token = access_token.clone(); + let account_id = account_id.clone(); + async move { + chatgpt_get_request_with_token::( + config, + path, + access_token.as_str(), + account_id.as_str(), + ) + .await + } + }, + ) + .await +} + +async fn chatgpt_get_request_with_token( + config: &Config, + path: String, + access_token: &str, + account_id: &str, +) -> anyhow::Result { + let client = create_client(); + let url = format!("{}{}", config.chatgpt_base_url, path); + let response = client + .get(&url) + .bearer_auth(access_token) + .header("chatgpt-account-id", account_id) + .header("Content-Type", "application/json") + .timeout(DIRECTORY_CONNECTORS_TIMEOUT) + .send() + .await + .context("failed to send request")?; + + if response.status().is_success() { + response + .json() + .await + .context("failed to parse JSON response") + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("request failed with status {status}: {body}"); + } +} + fn auth_manager_from_config(config: &Config) -> std::sync::Arc { AuthManager::shared( config.codex_home.clone(), @@ -719,10 +893,12 @@ fn format_connector_label(name: &str, _id: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::ConfigBuilder; use crate::config::types::AppConfig; use crate::config::types::AppToolConfig; use crate::config::types::AppToolsConfig; use crate::config::types::AppsDefaultConfig; + use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; use pretty_assertions::assert_eq; @@ -730,6 +906,7 @@ mod tests { use rmcp::model::Tool; use std::collections::HashMap; use std::sync::Arc; + use tempfile::tempdir; fn annotations( destructive_hint: Option, @@ -762,6 +939,15 @@ mod tests { } } + fn named_app(id: &str, name: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: name.to_string(), + install_url: Some(connector_install_url(name, id)), + ..app(id) + } + } + fn plugin_names(names: &[&str]) -> Vec { names.iter().map(ToString::to_string).collect() } @@ -821,6 +1007,21 @@ mod tests { } } + fn with_accessible_connectors_cache_cleared(f: impl FnOnce() -> R) -> R { + let previous = { + let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cache_guard.take() + }; + let result = f(); + let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *cache_guard = previous; + result + } + #[test] fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); @@ -907,6 +1108,62 @@ mod tests { ); } + #[tokio::test] + async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() { + let codex_home = tempdir().expect("tempdir should succeed"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should load"); + let _ = config.features.set_enabled(Feature::Apps, true); + let cache_key = accessible_connectors_cache_key(&config, None); + let tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_list_events".to_string(), + codex_app_tool( + "calendar_list_events", + "calendar", + Some("Google Calendar"), + &["calendar-plugin"], + ), + ), + ( + "mcp__codex_apps__openai_hidden".to_string(), + codex_app_tool( + "openai_hidden", + "connector_openai_hidden", + Some("Hidden"), + &[], + ), + ), + ]); + + let cached = with_accessible_connectors_cache_cleared(|| { + refresh_accessible_connectors_cache_from_mcp_tools(&config, None, &tools); + read_cached_accessible_connectors(&cache_key).expect("cache should be populated") + }); + + assert_eq!( + cached, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Google Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["calendar-plugin"]), + }] + ); + } + #[test] fn merge_connectors_unions_and_dedupes_plugin_display_names() { let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); @@ -1344,4 +1601,68 @@ mod tests { vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")] ); } + + #[test] + fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() { + let filtered = filter_tool_suggest_discoverable_tools( + vec![ + named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ), + named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), + named_app("connector_other", "Other"), + ], + &[AppInfo { + is_accessible: true, + ..named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ) + }], + ); + + assert_eq!( + filtered, + vec![named_app( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + )] + ); + } + + #[test] + fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() { + let filtered = filter_tool_suggest_discoverable_tools( + vec![ + named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ), + named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), + ], + &[ + AppInfo { + is_accessible: true, + ..named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ) + }, + AppInfo { + is_accessible: true, + is_enabled: false, + ..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail") + }, + ], + ); + + assert_eq!( + filtered, + vec![named_app( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail" + )] + ); + } } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index fb08e157b..76da67c32 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -142,6 +142,8 @@ pub enum Feature { SpawnCsv, /// Enable apps. Apps, + /// Enable discoverable tool suggestions for apps. + ToolSuggest, /// Enable plugins. Plugins, /// Allow the model to invoke the built-in image generation tool. @@ -714,6 +716,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::ToolSuggest, + key: "tool_suggest", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Plugins, key: "plugins", @@ -996,6 +1004,12 @@ mod tests { assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false); } + #[test] + fn tool_suggest_is_under_development() { + assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ToolSuggest.default_enabled(), false); + } + #[test] fn image_generation_is_under_development() { assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment); diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 009b85d83..de8ad5143 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -827,9 +827,10 @@ impl McpConnectionManager { /// Force-refresh codex apps tools by bypassing the in-process cache. /// - /// On success, the refreshed tools replace the cache contents. On failure, - /// the existing cache remains unchanged. - pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<()> { + /// On success, the refreshed tools replace the cache contents and the + /// latest filtered tool map is returned directly to the caller. On + /// failure, the existing cache remains unchanged. + pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { let managed_client = self .clients .get(CODEX_APPS_MCP_SERVER_NAME) @@ -865,7 +866,10 @@ impl McpConnectionManager { list_start.elapsed(), &[("cache", "miss")], ); - Ok(()) + Ok(qualify_tools(filter_tools( + tools, + &managed_client.tool_filter, + ))) } /// Returns a single map that contains all resources. Each key is the diff --git a/codex-rs/core/src/tools/code_mode.rs b/codex-rs/core/src/tools/code_mode.rs index fd0587c71..110588469 100644 --- a/codex-rs/core/src/tools/code_mode.rs +++ b/codex-rs/core/src/tools/code_mode.rs @@ -18,6 +18,7 @@ use crate::tools::context::ToolPayload; use crate::tools::js_repl::resolve_compatible_node; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; +use crate::tools::router::ToolRouterParams; use crate::truncate::TruncationPolicy; use crate::truncate::formatted_truncate_text_content_items_with_policy; use crate::truncate::truncate_function_output_items_with_policy; @@ -408,9 +409,12 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter { ToolRouter::from_config( &nested_tools_config, - Some(mcp_tools), - None, - exec.turn.dynamic_tools.as_slice(), + ToolRouterParams { + mcp_tools: Some(mcp_tools), + app_tools: None, + discoverable_tools: None, + dynamic_tools: exec.turn.dynamic_tools.as_slice(), + }, ) } diff --git a/codex-rs/core/src/tools/discoverable.rs b/codex-rs/core/src/tools/discoverable.rs new file mode 100644 index 000000000..75de51b15 --- /dev/null +++ b/codex-rs/core/src/tools/discoverable.rs @@ -0,0 +1,111 @@ +use crate::plugins::PluginCapabilitySummary; +use codex_app_server_protocol::AppInfo; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum DiscoverableToolType { + Connector, + Plugin, +} + +impl DiscoverableToolType { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Connector => "connector", + Self::Plugin => "plugin", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum DiscoverableToolAction { + Install, + Enable, +} + +impl DiscoverableToolAction { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Install => "install", + Self::Enable => "enable", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum DiscoverableTool { + Connector(Box), + Plugin(Box), +} + +impl DiscoverableTool { + pub(crate) fn tool_type(&self) -> DiscoverableToolType { + match self { + Self::Connector(_) => DiscoverableToolType::Connector, + Self::Plugin(_) => DiscoverableToolType::Plugin, + } + } + + pub(crate) fn id(&self) -> &str { + match self { + Self::Connector(connector) => connector.id.as_str(), + Self::Plugin(plugin) => plugin.id.as_str(), + } + } + + pub(crate) fn name(&self) -> &str { + match self { + Self::Connector(connector) => connector.name.as_str(), + Self::Plugin(plugin) => plugin.name.as_str(), + } + } + + pub(crate) fn description(&self) -> Option<&str> { + match self { + Self::Connector(connector) => connector.description.as_deref(), + Self::Plugin(plugin) => plugin.description.as_deref(), + } + } +} + +impl From for DiscoverableTool { + fn from(value: AppInfo) -> Self { + Self::Connector(Box::new(value)) + } +} + +impl From for DiscoverableTool { + fn from(value: DiscoverablePluginInfo) -> Self { + Self::Plugin(Box::new(value)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DiscoverablePluginInfo { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: Option, + pub(crate) has_skills: bool, + pub(crate) mcp_server_names: Vec, + pub(crate) app_connector_ids: Vec, +} + +impl From for DiscoverablePluginInfo { + fn from(value: PluginCapabilitySummary) -> Self { + Self { + id: value.config_name, + name: value.display_name, + description: value.description, + has_skills: value.has_skills, + mcp_server_names: value.mcp_server_names, + app_connector_ids: value + .app_connector_ids + .into_iter() + .map(|connector_id| connector_id.0) + .collect(), + } + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 1bb9c7acc..217780046 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -16,6 +16,7 @@ mod request_user_input; mod shell; mod test_sync; mod tool_search; +mod tool_suggest; pub(crate) mod unified_exec; mod view_image; @@ -56,6 +57,8 @@ pub use test_sync::TestSyncHandler; pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT; pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME; pub use tool_search::ToolSearchHandler; +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 deleted file mode 100644 index e69de29bb..000000000 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 000000000..5483cac04 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -0,0 +1,465 @@ +use std::collections::BTreeMap; +use std::collections::HashSet; + +use async_trait::async_trait; +use codex_app_server_protocol::AppInfo; +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_rmcp_client::ElicitationAction; +use rmcp::model::RequestId; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use tracing::warn; + +use crate::connectors; +use crate::function_tool::FunctionCallError; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::discoverable::DiscoverableTool; +use crate::tools::discoverable::DiscoverableToolAction; +use crate::tools::discoverable::DiscoverableToolType; +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_APPROVAL_KIND_VALUE: &str = "tool_suggestion"; + +#[derive(Debug, Deserialize)] +struct ToolSuggestArgs { + tool_type: DiscoverableToolType, + action_type: DiscoverableToolAction, + tool_id: String, + suggest_reason: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct ToolSuggestResult { + completed: bool, + user_confirmed: bool, + tool_type: DiscoverableToolType, + action_type: DiscoverableToolAction, + tool_id: String, + tool_name: String, + suggest_reason: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct ToolSuggestMeta<'a> { + codex_approval_kind: &'static str, + tool_type: DiscoverableToolType, + suggest_type: DiscoverableToolAction, + suggest_reason: &'a str, + tool_id: &'a str, + tool_name: &'a str, + install_url: &'a str, +} + +#[async_trait] +impl ToolHandler for ToolSuggestHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + payload, + session, + 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 suggest_reason = args.suggest_reason.trim(); + if suggest_reason.is_empty() { + return Err(FunctionCallError::RespondToModel( + "suggest_reason must not be empty".to_string(), + )); + } + if args.tool_type == DiscoverableToolType::Plugin { + return Err(FunctionCallError::RespondToModel( + "plugin tool suggestions are not currently available".to_string(), + )); + } + if args.action_type != DiscoverableToolAction::Install { + return Err(FunctionCallError::RespondToModel( + "connector tool suggestions currently support only action_type=\"install\"" + .to_string(), + )); + } + + let auth = session.services.auth_manager.auth().await; + let manager = session.services.mcp_connection_manager.read().await; + let mcp_tools = manager.list_all_tools().await; + drop(manager); + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth( + &turn.config, + auth.as_ref(), + &accessible_connectors, + ) + .await + .map(|connectors| { + connectors + .into_iter() + .map(DiscoverableTool::from) + .collect::>() + }) + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "tool suggestions are unavailable right now: {err}" + )) + })?; + + let connector = discoverable_tools + .into_iter() + .find_map(|tool| match tool { + DiscoverableTool::Connector(connector) if connector.id == args.tool_id => { + Some(*connector) + } + DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None, + }) + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}" + )) + })?; + + let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into()); + let params = build_tool_suggestion_elicitation_request( + session.conversation_id.to_string(), + turn.sub_id.clone(), + &args, + suggest_reason, + &connector, + ); + let response = session + .request_mcp_server_elicitation(turn.as_ref(), request_id, params) + .await; + let user_confirmed = response + .as_ref() + .is_some_and(|response| response.action == ElicitationAction::Accept); + + let completed = if user_confirmed { + let manager = session.services.mcp_connection_manager.read().await; + match manager.hard_refresh_codex_apps_tools_cache().await { + Ok(mcp_tools) => { + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + connectors::refresh_accessible_connectors_cache_from_mcp_tools( + &turn.config, + auth.as_ref(), + &mcp_tools, + ); + verified_connector_suggestion_completed( + args.action_type, + connector.id.as_str(), + &accessible_connectors, + ) + } + Err(err) => { + warn!( + "failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}", + connector.id + ); + false + } + } + } else { + false + }; + + if completed { + session + .merge_connector_selection(HashSet::from([connector.id.clone()])) + .await; + } + + let content = serde_json::to_string(&ToolSuggestResult { + completed, + user_confirmed, + tool_type: args.tool_type, + action_type: args.action_type, + tool_id: connector.id, + tool_name: connector.name, + suggest_reason: suggest_reason.to_string(), + }) + .map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize {TOOL_SUGGEST_TOOL_NAME} response: {err}" + )) + })?; + + Ok(FunctionToolOutput::from_text(content, Some(true))) + } +} + +fn build_tool_suggestion_elicitation_request( + thread_id: String, + turn_id: String, + args: &ToolSuggestArgs, + suggest_reason: &str, + connector: &AppInfo, +) -> McpServerElicitationRequestParams { + let tool_name = connector.name.clone(); + let install_url = connector + .install_url + .clone() + .unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id)); + + let message = format!( + "{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.", + args.action_type.as_str() + ); + + McpServerElicitationRequestParams { + thread_id, + turn_id: Some(turn_id), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(build_tool_suggestion_meta( + args.tool_type, + args.action_type, + suggest_reason, + connector.id.as_str(), + tool_name.as_str(), + install_url.as_str(), + ))), + message, + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } +} + +fn build_tool_suggestion_meta<'a>( + tool_type: DiscoverableToolType, + action_type: DiscoverableToolAction, + suggest_reason: &'a str, + tool_id: &'a str, + tool_name: &'a str, + install_url: &'a str, +) -> ToolSuggestMeta<'a> { + ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type, + suggest_type: action_type, + suggest_reason, + tool_id, + tool_name, + install_url, + } +} + +fn verified_connector_suggestion_completed( + action_type: DiscoverableToolAction, + tool_id: &str, + accessible_connectors: &[AppInfo], +) -> bool { + accessible_connectors + .iter() + .find(|connector| connector.id == tool_id) + .is_some_and(|connector| match action_type { + DiscoverableToolAction::Install => connector.is_accessible, + DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn build_tool_suggestion_elicitation_request_uses_expected_shape() { + let args = ToolSuggestArgs { + tool_type: DiscoverableToolType::Connector, + action_type: DiscoverableToolAction::Install, + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + suggest_reason: "Plan and reference events from your calendar".to_string(), + }; + let connector = AppInfo { + id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some( + "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" + .to_string(), + ), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }; + + let request = build_tool_suggestion_elicitation_request( + "thread-1".to_string(), + "turn-1".to_string(), + &args, + "Plan and reference events from your calendar", + &connector, + ); + + assert_eq!( + request, + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Connector, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Plan and reference events from your calendar", + tool_id: "connector_2128aebfecb84f64a069897515042a44", + tool_name: "Google Calendar", + install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44", + })), + message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } + ); + } + + #[test] + fn build_tool_suggestion_meta_uses_expected_shape() { + let meta = build_tool_suggestion_meta( + DiscoverableToolType::Connector, + DiscoverableToolAction::Install, + "Find and reference emails from your inbox", + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2", + ); + + assert_eq!( + meta, + ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Connector, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Find and reference emails from your inbox", + tool_id: "connector_68df038e0ba48191908c8434991bbac2", + tool_name: "Gmail", + install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2", + } + ); + } + + #[test] + fn verified_connector_suggestion_completed_requires_installed_connector() { + let accessible_connectors = vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".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: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + + assert!(verified_connector_suggestion_completed( + DiscoverableToolAction::Install, + "calendar", + &accessible_connectors, + )); + assert!(!verified_connector_suggestion_completed( + DiscoverableToolAction::Install, + "gmail", + &accessible_connectors, + )); + } + + #[test] + fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() { + let accessible_connectors = vec![ + AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".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: true, + is_enabled: false, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "gmail".to_string(), + name: "Gmail".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: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + + assert!(!verified_connector_suggestion_completed( + DiscoverableToolAction::Enable, + "calendar", + &accessible_connectors, + )); + assert!(verified_connector_suggestion_completed( + DiscoverableToolAction::Enable, + "gmail", + &accessible_connectors, + )); + } +} diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 6fe78a728..1fb3d528f 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1360,14 +1360,17 @@ impl JsReplManager { let router = ToolRouter::from_config( &exec.turn.tools_config, - Some( - mcp_tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), - None, - exec.turn.dynamic_tools.as_slice(), + crate::tools::router::ToolRouterParams { + mcp_tools: Some( + mcp_tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + ), + app_tools: None, + discoverable_tools: None, + dynamic_tools: exec.turn.dynamic_tools.as_slice(), + }, ); let payload = if let Some((server, tool)) = exec diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 20808325b..4e495190e 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -1,6 +1,7 @@ pub mod code_mode; pub(crate) mod code_mode_description; pub mod context; +pub(crate) mod discoverable; pub mod events; pub(crate) mod handlers; pub mod js_repl; diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 4482c34bb..d311d0070 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -9,11 +9,12 @@ use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::context::ToolSearchOutput; +use crate::tools::discoverable::DiscoverableTool; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ConfiguredToolSpec; use crate::tools::registry::ToolRegistry; use crate::tools::spec::ToolsConfig; -use crate::tools::spec::build_specs; +use crate::tools::spec::build_specs_with_discoverable_tools; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseInputItem; @@ -40,14 +41,28 @@ pub struct ToolRouter { specs: Vec, } +pub(crate) struct ToolRouterParams<'a> { + pub(crate) mcp_tools: Option>, + pub(crate) app_tools: Option>, + pub(crate) discoverable_tools: Option>, + pub(crate) dynamic_tools: &'a [DynamicToolSpec], +} + impl ToolRouter { - pub fn from_config( - config: &ToolsConfig, - mcp_tools: Option>, - app_tools: Option>, - dynamic_tools: &[DynamicToolSpec], - ) -> Self { - let builder = build_specs(config, mcp_tools, app_tools, dynamic_tools); + pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self { + let ToolRouterParams { + mcp_tools, + app_tools, + discoverable_tools, + dynamic_tools, + } = params; + let builder = build_specs_with_discoverable_tools( + config, + mcp_tools, + app_tools, + discoverable_tools, + dynamic_tools, + ); let (specs, registry) = builder.build(); Self { registry, specs } @@ -287,6 +302,7 @@ mod tests { use super::ToolCall; use super::ToolCallSource; use super::ToolRouter; + use super::ToolRouterParams; #[tokio::test] async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> { @@ -305,14 +321,17 @@ mod tests { let app_tools = Some(mcp_tools.clone()); let router = ToolRouter::from_config( &turn.tools_config, - Some( - mcp_tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), - app_tools, - turn.dynamic_tools.as_slice(), + ToolRouterParams { + mcp_tools: Some( + mcp_tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + ), + app_tools, + discoverable_tools: None, + dynamic_tools: turn.dynamic_tools.as_slice(), + }, ); let call = ToolCall { @@ -359,14 +378,17 @@ mod tests { let app_tools = Some(mcp_tools.clone()); let router = ToolRouter::from_config( &turn.tools_config, - Some( - mcp_tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - ), - app_tools, - turn.dynamic_tools.as_slice(), + ToolRouterParams { + mcp_tools: Some( + mcp_tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + ), + app_tools, + discoverable_tools: None, + dynamic_tools: turn.dynamic_tools.as_slice(), + }, ); let call = ToolCall { diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 748333ea9..3364d19e8 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -10,9 +10,14 @@ use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::original_image_detail::can_request_original_image_detail; use crate::tools::code_mode::PUBLIC_TOOL_NAME; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; +use crate::tools::discoverable::DiscoverablePluginInfo; +use crate::tools::discoverable::DiscoverableTool; +use crate::tools::discoverable::DiscoverableToolAction; +use crate::tools::discoverable::DiscoverableToolType; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT; use crate::tools::handlers::TOOL_SEARCH_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; @@ -44,6 +49,8 @@ use std::collections::HashMap; const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); +const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str = + include_str!("../../templates/search_tool/tool_suggest_description.md"); const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"]; fn unified_exec_output_schema() -> JsonValue { @@ -105,6 +112,7 @@ pub(crate) struct ToolsConfig { pub image_gen_tool: bool, pub agent_roles: BTreeMap, pub search_tool: bool, + pub tool_suggest: bool, pub request_permission_enabled: bool, pub request_permissions_tool_enabled: bool, pub code_mode_enabled: bool, @@ -148,6 +156,7 @@ impl ToolsConfig { let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = features.enabled(Feature::Apps); + let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest); let include_original_image_detail = can_request_original_image_detail(features, model_info); let include_artifact_tools = features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime(); @@ -215,6 +224,7 @@ impl ToolsConfig { image_gen_tool: include_image_gen_tool, agent_roles: BTreeMap::new(), search_tool: include_search_tool, + tool_suggest: include_tool_suggest, request_permission_enabled, request_permissions_tool_enabled, code_mode_enabled: include_code_mode, @@ -1451,6 +1461,133 @@ fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { } } +fn create_tool_suggest_tool(discoverable_tools: &[DiscoverableTool]) -> ToolSpec { + let discoverable_tool_ids = discoverable_tools + .iter() + .map(DiscoverableTool::id) + .collect::>() + .join(", "); + let properties = BTreeMap::from([ + ( + "tool_type".to_string(), + JsonSchema::String { + description: Some( + "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." + .to_string(), + ), + }, + ), + ( + "action_type".to_string(), + JsonSchema::String { + description: Some( + "Suggested action for the tool. Use \"install\" or \"enable\".".to_string(), + ), + }, + ), + ( + "tool_id".to_string(), + JsonSchema::String { + description: Some(format!( + "Connector or plugin id to suggest. Must be one of: {discoverable_tool_ids}." + )), + }, + ), + ( + "suggest_reason".to_string(), + JsonSchema::String { + description: Some( + "Concise one-line user-facing reason why this tool can help with the current request." + .to_string(), + ), + }, + ), + ]); + let description = TOOL_SUGGEST_DESCRIPTION_TEMPLATE.replace( + "{{discoverable_tools}}", + format_discoverable_tools(discoverable_tools).as_str(), + ); + + ToolSpec::Function(ResponsesApiTool { + name: TOOL_SUGGEST_TOOL_NAME.to_string(), + description, + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec![ + "tool_type".to_string(), + "action_type".to_string(), + "tool_id".to_string(), + "suggest_reason".to_string(), + ]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) +} + +fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String { + let mut discoverable_tools = discoverable_tools.to_vec(); + discoverable_tools.sort_by(|left, right| { + left.name() + .cmp(right.name()) + .then_with(|| left.id().cmp(right.id())) + }); + + discoverable_tools + .into_iter() + .map(|tool| { + let description = tool + .description() + .filter(|description| !description.trim().is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| match &tool { + DiscoverableTool::Connector(_) => "No description provided.".to_string(), + DiscoverableTool::Plugin(plugin) => format_plugin_summary(plugin.as_ref()), + }); + let default_action = match tool.tool_type() { + DiscoverableToolType::Connector => DiscoverableToolAction::Install, + DiscoverableToolType::Plugin => DiscoverableToolAction::Enable, + }; + format!( + "- {} (id: `{}`, type: {}, action: {}): {}", + tool.name(), + tool.id(), + tool.tool_type().as_str(), + default_action.as_str(), + description + ) + }) + .collect::>() + .join("\n") +} + +fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String { + let mut details = Vec::new(); + if plugin.has_skills { + details.push("skills".to_string()); + } + if !plugin.mcp_server_names.is_empty() { + details.push(format!( + "MCP servers: {}", + plugin.mcp_server_names.join(", ") + )); + } + if !plugin.app_connector_ids.is_empty() { + details.push(format!( + "app connectors: {}", + plugin.app_connector_ids.join(", ") + )); + } + + if details.is_empty() { + "No description provided.".to_string() + } else { + details.join("; ") + } +} + fn create_read_file_tool() -> ToolSpec { let indentation_properties = BTreeMap::from([ ( @@ -2083,11 +2220,22 @@ fn sanitize_json_schema(value: &mut JsonValue) { } /// Builds the tool registry builder while collecting tool specs for later serialization. +#[cfg(test)] pub(crate) fn build_specs( config: &ToolsConfig, mcp_tools: Option>, app_tools: Option>, dynamic_tools: &[DynamicToolSpec], +) -> ToolRegistryBuilder { + build_specs_with_discoverable_tools(config, mcp_tools, app_tools, None, dynamic_tools) +} + +pub(crate) fn build_specs_with_discoverable_tools( + config: &ToolsConfig, + mcp_tools: Option>, + app_tools: Option>, + discoverable_tools: Option>, + dynamic_tools: &[DynamicToolSpec], ) -> ToolRegistryBuilder { use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::ArtifactsHandler; @@ -2108,6 +2256,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; + use crate::tools::handlers::ToolSuggestHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::handlers::ViewImageHandler; use std::sync::Arc; @@ -2127,6 +2276,7 @@ pub(crate) fn build_specs( let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); + let tool_suggest_handler = Arc::new(ToolSuggestHandler); let code_mode_handler = Arc::new(CodeModeHandler); let js_repl_handler = Arc::new(JsReplHandler); let js_repl_reset_handler = Arc::new(JsReplResetHandler); @@ -2135,10 +2285,11 @@ pub(crate) fn build_specs( if config.code_mode_enabled { let nested_config = config.for_code_mode_nested_tools(); - let (nested_specs, _) = build_specs( + let (nested_specs, _) = build_specs_with_discoverable_tools( &nested_config, mcp_tools.clone(), app_tools.clone(), + None, dynamic_tools, ) .build(); @@ -2304,6 +2455,15 @@ pub(crate) fn build_specs( } } + if config.tool_suggest + && let Some(discoverable_tools) = discoverable_tools + .as_ref() + .filter(|tools| !tools.is_empty()) + { + builder.push_spec_with_parallel_support(create_tool_suggest_tool(discoverable_tools), true); + builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler); + } + if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { match apply_patch_tool_type { ApplyPatchToolType::Freeform => { @@ -2565,6 +2725,7 @@ mod tests { use crate::models_manager::manager::ModelsManager; use crate::models_manager::model_info::with_config_overrides; use crate::tools::registry::ConfiguredToolSpec; + use codex_app_server_protocol::AppInfo; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; @@ -2590,6 +2751,25 @@ mod tests { } } + fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { + let slug = name.replace(' ', "-").to_lowercase(); + DiscoverableTool::Connector(Box::new(AppInfo { + id: id.to_string(), + name: name.to_string(), + description: Some(description.to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + })) + } + #[test] fn mcp_tool_to_openai_tool_inserts_empty_properties() { let mut schema = rmcp::model::JsonObject::new(); @@ -4147,7 +4327,6 @@ mod tests { }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - let mut features = Features::with_defaults(); features.enable(Feature::Apps); let available_models = Vec::new(); @@ -4162,6 +4341,41 @@ mod tests { assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); } + #[test] + fn tool_suggest_is_not_registered_without_feature_flag() { + 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 available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + None, + None, + Some(vec![discoverable_connector( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + "Plan events and schedules.", + )]), + &[], + ) + .build(); + + assert!( + !tools + .iter() + .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME) + ); + } + #[test] fn search_tool_description_handles_no_enabled_apps() { let config = test_config(); @@ -4253,6 +4467,89 @@ mod tests { assert!(registry.has_handler(alias.as_str(), None)); } + #[test] + fn tool_suggest_description_lists_discoverable_tools() { + 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); + features.enable(Feature::ToolSuggest); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let discoverable_tools = vec![ + discoverable_connector( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + "Plan events and schedules.", + ), + discoverable_connector( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + "Find and summarize email threads.", + ), + DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "sample@test".to_string(), + name: "Sample Plugin".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_sample".to_string()], + })), + ]; + + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + None, + None, + Some(discoverable_tools), + &[], + ) + .build(); + + let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { + description, + parameters, + .. + }) = &tool_suggest.spec + else { + panic!("expected function tool"); + }; + assert!(description.contains("Google Calendar")); + assert!(description.contains("Gmail")); + assert!(description.contains("Sample Plugin")); + assert!(description.contains("Plan events and schedules.")); + assert!(description.contains("Find and summarize email threads.")); + assert!(description.contains("id: `sample@test`, type: plugin, action: enable")); + assert!( + description + .contains("skills; MCP servers: sample-docs; app connectors: connector_sample") + ); + assert!( + description.contains("DO NOT explore or recommend tools that are not on this list.") + ); + let JsonSchema::Object { required, .. } = parameters else { + panic!("expected object parameters"); + }; + assert_eq!( + required.as_ref(), + Some(&vec![ + "tool_type".to_string(), + "action_type".to_string(), + "tool_id".to_string(), + "suggest_reason".to_string(), + ]) + ); + } + #[test] fn test_mcp_tool_property_missing_type_defaults_to_string() { let config = test_config(); diff --git a/codex-rs/core/templates/search_tool/tool_suggest_description.md b/codex-rs/core/templates/search_tool/tool_suggest_description.md new file mode 100644 index 000000000..fcf599c39 --- /dev/null +++ b/codex-rs/core/templates/search_tool/tool_suggest_description.md @@ -0,0 +1,25 @@ +# Tool suggestion discovery + +Suggests a discoverable connector or plugin when the user clearly wants a capability that is not currently available in the active `tools` list. + +Use this ONLY when: +- There's no available tool to handle the user's request +- And tool_search fails to find a good match +- AND the user's request strongly matches one of the discoverable tools listed below. + +Tool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list. + +Discoverable tools: +{{discoverable_tools}} + +Workflow: + +1. Match the user's request against the discoverable tools list above. +2. If one tool clearly fits, call `tool_suggest` with: + - `tool_type`: `connector` or `plugin` + - `action_type`: `install` or `enable` + - `tool_id`: exact id from the discoverable tools list above + - `suggest_reason`: concise one-line user-facing reason this tool can help with the current request +3. After the suggestion flow completes: + - if the user finished the install or enable flow, continue by searching again or using the newly available tool + - if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks you to. diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index c8ef0bd1c..83ce020be 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -12,6 +12,8 @@ use wiremock::matchers::path_regex; const CONNECTOR_ID: &str = "calendar"; const CONNECTOR_NAME: &str = "Calendar"; +const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44"; +const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar."; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; @@ -32,6 +34,7 @@ impl AppsTestServer { connector_name: &str, ) -> Result { mount_oauth_metadata(server).await; + mount_connectors_directory(server).await; mount_streamable_http_json_rpc( server, connector_name.to_string(), @@ -56,6 +59,37 @@ async fn mount_oauth_metadata(server: &MockServer) { .await; } +async fn mount_connectors_directory(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/connectors/directory/list")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apps": [ + { + "id": DISCOVERABLE_CALENDAR_ID, + "name": "Google Calendar", + "description": "Plan events and schedules.", + }, + { + "id": DISCOVERABLE_GMAIL_ID, + "name": "Gmail", + "description": "Find and summarize email threads.", + } + ], + "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; +} + async fn mount_streamable_http_json_rpc( server: &MockServer, connector_name: String, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3ad7c579c..87b2dc795 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2422,6 +2422,9 @@ impl App { url, is_installed, is_enabled, + suggest_reason: None, + suggestion_type: None, + elicitation_target: 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 2e6fab3d2..15596a9d7 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,7 @@ +use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::mcp::RequestId as McpRequestId; +use codex_protocol::protocol::Op; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -34,6 +38,19 @@ enum AppLinkScreen { InstallConfirmation, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AppLinkSuggestionType { + Install, + Enable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AppLinkElicitationTarget { + pub(crate) thread_id: ThreadId, + pub(crate) server_name: String, + pub(crate) request_id: McpRequestId, +} + pub(crate) struct AppLinkViewParams { pub(crate) app_id: String, pub(crate) title: String, @@ -42,6 +59,9 @@ pub(crate) struct AppLinkViewParams { pub(crate) url: String, pub(crate) is_installed: bool, pub(crate) is_enabled: bool, + pub(crate) suggest_reason: Option, + pub(crate) suggestion_type: Option, + pub(crate) elicitation_target: Option, } pub(crate) struct AppLinkView { @@ -52,6 +72,9 @@ pub(crate) struct AppLinkView { url: String, is_installed: bool, is_enabled: bool, + suggest_reason: Option, + suggestion_type: Option, + elicitation_target: Option, app_event_tx: AppEventSender, screen: AppLinkScreen, selected_action: usize, @@ -68,6 +91,9 @@ impl AppLinkView { url, is_installed, is_enabled, + suggest_reason, + suggestion_type, + elicitation_target, } = params; Self { app_id, @@ -77,6 +103,9 @@ impl AppLinkView { url, is_installed, is_enabled, + suggest_reason, + suggestion_type, + elicitation_target, app_event_tx, screen: AppLinkScreen::Link, selected_action: 0, @@ -113,6 +142,31 @@ impl AppLinkView { self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1); } + fn is_tool_suggestion(&self) -> bool { + self.elicitation_target.is_some() + } + + fn resolve_elicitation(&self, decision: ElicitationAction) { + let Some(target) = self.elicitation_target.as_ref() else { + return; + }; + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: target.thread_id, + op: Op::ResolveElicitation { + server_name: target.server_name.clone(), + request_id: target.request_id.clone(), + decision, + content: None, + meta: None, + }, + }); + } + + fn decline_tool_suggestion(&mut self) { + self.resolve_elicitation(ElicitationAction::Decline); + self.complete = true; + } + fn open_chatgpt_link(&mut self) { self.app_event_tx.send(AppEvent::OpenUrlInBrowser { url: self.url.clone(), @@ -127,6 +181,9 @@ impl AppLinkView { self.app_event_tx.send(AppEvent::RefreshConnectors { force_refetch: true, }); + if self.is_tool_suggestion() { + self.resolve_elicitation(ElicitationAction::Accept); + } self.complete = true; } @@ -141,9 +198,40 @@ impl AppLinkView { id: self.app_id.clone(), enabled: self.is_enabled, }); + if self.is_tool_suggestion() { + self.resolve_elicitation(ElicitationAction::Accept); + self.complete = true; + } } fn activate_selected_action(&mut self) { + if self.is_tool_suggestion() { + match self.suggestion_type { + Some(AppLinkSuggestionType::Enable) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_chatgpt_link(), + 1 if self.is_installed => self.toggle_enabled(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.refresh_connectors_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::Install) | None => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_chatgpt_link(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.refresh_connectors_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + } + return; + } + match self.screen { AppLinkScreen::Link => match self.selected_action { 0 => self.open_chatgpt_link(), @@ -181,6 +269,17 @@ impl AppLinkView { } lines.push(Line::from("")); + if let Some(suggest_reason) = self + .suggest_reason + .as_deref() + .map(str::trim) + .filter(|suggest_reason| !suggest_reason.is_empty()) + { + for line in wrap(suggest_reason, usable_width) { + lines.push(Line::from(line.into_owned().italic())); + } + lines.push(Line::from("")); + } if self.is_installed { for line in wrap("Use $ to insert this app into the prompt.", usable_width) { lines.push(Line::from(line.into_owned())); @@ -366,6 +465,9 @@ impl BottomPaneView for AppLinkView { } fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.is_tool_suggestion() { + self.resolve_elicitation(ElicitationAction::Decline); + } self.complete = true; CancellationEvent::Handled } @@ -447,8 +549,40 @@ mod tests { use super::*; use crate::app_event::AppEvent; use crate::render::renderable::Renderable; + use insta::assert_snapshot; use tokio::sync::mpsc::unbounded_channel; + fn suggestion_target() -> AppLinkElicitationTarget { + AppLinkElicitationTarget { + thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001") + .expect("valid thread id"), + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + } + } + + fn render_snapshot(view: &AppLinkView, area: Rect) -> String { + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + } + #[test] fn installed_app_has_toggle_action() { let (tx_raw, _rx) = unbounded_channel::(); @@ -462,6 +596,9 @@ mod tests { url: "https://example.test/notion".to_string(), is_installed: true, is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }, tx, ); @@ -485,6 +622,9 @@ mod tests { url: "https://example.test/notion".to_string(), is_installed: true, is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }, tx, ); @@ -521,6 +661,9 @@ mod tests { url: url_like.to_string(), is_installed: true, is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }, tx, ); @@ -561,6 +704,9 @@ mod tests { url: url.to_string(), is_installed: true, is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, }, tx, ); @@ -593,4 +739,206 @@ mod tests { "expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}" ); } + + #[test] + fn install_tool_suggestion_resolves_elicitation_after_confirmation() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::OpenUrlInBrowser { url }) => { + assert_eq!(url, "https://example.test/google-calendar".to_string()); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert_eq!(view.screen, AppLinkScreen::InstallConfirmation); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + 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 }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn declined_tool_suggestion_resolves_elicitation_decline() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: None, + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Decline, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn enable_tool_suggestion_resolves_elicitation_after_enable() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SetAppEnabled { id, enabled }) => { + assert_eq!(id, "connector_google_calendar"); + assert!(enabled); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, suggestion_target().thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "codex_apps".to_string(), + request_id: McpRequestId::String("request-1".to_string()), + decision: ElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(view.is_complete()); + } + + #[test] + fn install_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Install this app in your browser, then return here.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: false, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Install), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_install_suggestion_with_reason", + render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))) + ); + } + + #[test] + fn enable_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_enable_suggestion_with_reason", + render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 43da1c0b8..4aecbea32 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -54,9 +54,16 @@ const APPROVAL_DECLINE_VALUE: &str = "decline"; const APPROVAL_CANCEL_VALUE: &str = "cancel"; const APPROVAL_META_KIND_KEY: &str = "codex_approval_kind"; const APPROVAL_META_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; +const APPROVAL_META_KIND_TOOL_SUGGESTION: &str = "tool_suggestion"; const APPROVAL_PERSIST_KEY: &str = "persist"; const APPROVAL_PERSIST_SESSION_VALUE: &str = "session"; const APPROVAL_PERSIST_ALWAYS_VALUE: &str = "always"; +const TOOL_TYPE_KEY: &str = "tool_type"; +const TOOL_ID_KEY: &str = "tool_id"; +const TOOL_NAME_KEY: &str = "tool_name"; +const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type"; +const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason"; +const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url"; #[derive(Clone, PartialEq, Default)] struct ComposerDraft { @@ -117,6 +124,28 @@ enum McpServerElicitationResponseMode { ApprovalAction, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSuggestionToolType { + Connector, + Plugin, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSuggestionType { + Install, + Enable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ToolSuggestionRequest { + pub(crate) tool_type: ToolSuggestionToolType, + pub(crate) suggest_type: ToolSuggestionType, + pub(crate) suggest_reason: String, + pub(crate) tool_id: String, + pub(crate) tool_name: String, + pub(crate) install_url: String, +} + #[derive(Clone, Debug, PartialEq)] pub(crate) struct McpServerElicitationFormRequest { thread_id: ThreadId, @@ -125,6 +154,7 @@ pub(crate) struct McpServerElicitationFormRequest { message: String, response_mode: McpServerElicitationResponseMode, fields: Vec, + tool_suggestion: Option, } #[derive(Default)] @@ -170,6 +200,7 @@ impl McpServerElicitationFormRequest { return None; }; + let tool_suggestion = parse_tool_suggestion_request(meta.as_ref()); let is_tool_approval = meta .as_ref() .and_then(Value::as_object) @@ -186,9 +217,11 @@ impl McpServerElicitationFormRequest { let is_tool_approval_action = is_tool_approval && (requested_schema.is_null() || is_empty_object_schema); - let (response_mode, fields) = if requested_schema.is_null() - || (is_tool_approval && is_empty_object_schema) + let (response_mode, fields) = if tool_suggestion.is_some() + && (requested_schema.is_null() || is_empty_object_schema) { + (McpServerElicitationResponseMode::FormContent, Vec::new()) + } else if requested_schema.is_null() || (is_tool_approval && is_empty_object_schema) { let mut options = vec![McpServerElicitationOption { label: "Allow".to_string(), description: Some("Run the tool and continue.".to_string()), @@ -266,8 +299,63 @@ impl McpServerElicitationFormRequest { message, response_mode, fields, + tool_suggestion, }) } + + pub(crate) fn tool_suggestion(&self) -> Option<&ToolSuggestionRequest> { + self.tool_suggestion.as_ref() + } + + pub(crate) fn thread_id(&self) -> ThreadId { + self.thread_id + } + + pub(crate) fn server_name(&self) -> &str { + self.server_name.as_str() + } + + pub(crate) fn request_id(&self) -> &McpRequestId { + &self.request_id + } +} + +fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option { + let meta = meta?.as_object()?; + if meta.get(APPROVAL_META_KIND_KEY).and_then(Value::as_str) + != Some(APPROVAL_META_KIND_TOOL_SUGGESTION) + { + return None; + } + + let tool_type = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) { + Some("connector") => ToolSuggestionToolType::Connector, + Some("plugin") => ToolSuggestionToolType::Plugin, + _ => return None, + }; + let suggest_type = match meta + .get(TOOL_SUGGEST_SUGGEST_TYPE_KEY) + .and_then(Value::as_str) + { + Some("install") => ToolSuggestionType::Install, + Some("enable") => ToolSuggestionType::Enable, + _ => return None, + }; + + Some(ToolSuggestionRequest { + tool_type, + suggest_type, + suggest_reason: meta + .get(TOOL_SUGGEST_REASON_KEY) + .and_then(Value::as_str)? + .to_string(), + tool_id: meta.get(TOOL_ID_KEY).and_then(Value::as_str)?.to_string(), + tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(), + install_url: meta + .get(TOOL_SUGGEST_INSTALL_URL_KEY) + .and_then(Value::as_str)? + .to_string(), + }) } fn tool_approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str) -> bool { @@ -1550,6 +1638,7 @@ mod tests { default_idx: None, }, }], + tool_suggestion: None, } ); } @@ -1621,6 +1710,7 @@ mod tests { default_idx: Some(0), }, }], + tool_suggestion: None, } ); } @@ -1667,10 +1757,44 @@ mod tests { default_idx: Some(0), }, }], + tool_suggestion: None, } ); } + #[test] + fn tool_suggestion_meta_is_parsed_into_request_payload() { + let request = McpServerElicitationFormRequest::from_event( + ThreadId::default(), + form_request( + "Suggest Google Calendar", + empty_object_schema(), + Some(serde_json::json!({ + "codex_approval_kind": "tool_suggestion", + "tool_type": "connector", + "suggest_type": "install", + "suggest_reason": "Plan and reference events from your calendar", + "tool_id": "connector_2128aebfecb84f64a069897515042a44", + "tool_name": "Google Calendar", + "install_url": "https://example.test/google-calendar", + })), + ), + ) + .expect("expected tool suggestion form"); + + assert_eq!( + request.tool_suggestion(), + Some(&ToolSuggestionRequest { + tool_type: ToolSuggestionToolType::Connector, + suggest_type: ToolSuggestionType::Install, + suggest_reason: "Plan and reference events from your calendar".to_string(), + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + tool_name: "Google Calendar".to_string(), + install_url: "https://example.test/google-calendar".to_string(), + }) + ); + } + #[test] fn empty_unmarked_schema_falls_back() { let request = McpServerElicitationFormRequest::from_event( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f9c34222f..2d6600241 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -47,6 +47,8 @@ mod mcp_server_elicitation; mod multi_select_picker; mod request_user_input; mod status_line_setup; +pub(crate) use app_link_view::AppLinkElicitationTarget; +pub(crate) use app_link_view::AppLinkSuggestionType; pub(crate) use app_link_view::AppLinkView; pub(crate) use app_link_view::AppLinkViewParams; pub(crate) use approval_overlay::ApprovalOverlay; @@ -963,6 +965,52 @@ impl BottomPane { request }; + if let Some(tool_suggestion) = request.tool_suggestion() { + let suggestion_type = match tool_suggestion.suggest_type { + mcp_server_elicitation::ToolSuggestionType::Install => { + AppLinkSuggestionType::Install + } + mcp_server_elicitation::ToolSuggestionType::Enable => AppLinkSuggestionType::Enable, + }; + let is_installed = matches!( + tool_suggestion.suggest_type, + mcp_server_elicitation::ToolSuggestionType::Enable + ); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: tool_suggestion.tool_id.clone(), + title: tool_suggestion.tool_name.clone(), + description: None, + instructions: match suggestion_type { + AppLinkSuggestionType::Install => { + "Install this app in your browser, then return here.".to_string() + } + AppLinkSuggestionType::Enable => { + "Enable this app to use it for the current request.".to_string() + } + }, + url: tool_suggestion.install_url.clone(), + is_installed, + is_enabled: false, + suggest_reason: Some(tool_suggestion.suggest_reason.clone()), + suggestion_type: Some(suggestion_type), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id: request.thread_id(), + server_name: request.server_name().to_string(), + request_id: request.request_id().clone(), + }), + }, + self.app_event_tx.clone(), + ); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + false, + Some("Respond to the tool suggestion to continue.".to_string()), + ); + self.push_view(Box::new(view)); + return; + } + let modal = McpServerElicitationOverlay::new( request, self.app_event_tx.clone(), diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap new file mode 100644 index 000000000..94980ff65 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + Plan events and schedules. + + Plan and reference events from your calendar + + Use $ to insert this app into the prompt. + + Enable this app to use it for the current request. + Newly installed apps can take a few minutes to appear in /apps. + + + › 1. Manage on ChatGPT + 2. Enable app + 3. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap new file mode 100644 index 000000000..ac47f8741 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + Plan events and schedules. + + Plan and reference events from your calendar + + Install this app in your browser, then return here. + Newly installed apps can take a few minutes to appear in /apps. + After installed, use $ to insert this app into the prompt. + + + › 1. Install on ChatGPT + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close