From cf119966f418ecaae75636b41088d007c0b8d3a3 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 16 Mar 2026 22:45:39 -0700 Subject: [PATCH 1/5] update --- codex-rs/core/src/codex.rs | 7 +- codex-rs/core/src/connectors.rs | 54 ++-- codex-rs/core/src/connectors_tests.rs | 27 +- codex-rs/core/src/plugins/discoverable.rs | 67 ++++ .../core/src/plugins/discoverable_tests.rs | 168 ++++++++++ codex-rs/core/src/plugins/manager.rs | 2 +- codex-rs/core/src/plugins/mod.rs | 3 + codex-rs/core/src/tools/discoverable.rs | 25 ++ .../core/src/tools/handlers/tool_suggest.rs | 155 ++++----- .../src/tools/handlers/tool_suggest_tests.rs | 294 +++++++++++++----- codex-rs/core/src/tools/spec.rs | 2 +- codex-rs/core/src/tools/spec_tests.rs | 3 +- 12 files changed, 620 insertions(+), 187 deletions(-) create mode 100644 codex-rs/core/src/plugins/discoverable.rs create mode 100644 codex-rs/core/src/plugins/discoverable_tests.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9787e10e9c4..1ccaf9c3061 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -292,7 +292,6 @@ 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; @@ -6466,10 +6465,8 @@ pub(crate) async fn built_tools( ) .await { - Ok(connectors) if connectors.is_empty() => None, - Ok(connectors) => { - Some(connectors.into_iter().map(DiscoverableTool::from).collect()) - } + Ok(discoverable_tools) if discoverable_tools.is_empty() => None, + Ok(discoverable_tools) => Some(discoverable_tools), Err(err) => { warn!("failed to load discoverable tool suggestions: {err:#}"); None diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 12a02e03a1a..875d1999d9a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -42,24 +42,14 @@ use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::plugins::AppConnectorId; use crate::plugins::PluginsManager; +use crate::plugins::list_tool_suggest_discoverable_plugins; use crate::token_data::TokenData; +use crate::tools::discoverable::DiscoverablePluginInfo; +use crate::tools::discoverable::DiscoverableTool; 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 { @@ -116,13 +106,24 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], -) -> anyhow::Result> { +) -> anyhow::Result> { let directory_connectors = list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?; - Ok(filter_tool_suggest_discoverable_tools( + let connector_ids = tool_suggest_connector_ids(config); + let discoverable_connectors = filter_tool_suggest_discoverable_connectors( directory_connectors, accessible_connectors, - )) + &connector_ids, + ) + .into_iter() + .map(DiscoverableTool::from); + let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)? + .into_iter() + .map(DiscoverablePluginInfo::from) + .map(DiscoverableTool::from); + Ok(discoverable_connectors + .chain(discoverable_plugins) + .collect()) } pub async fn list_cached_accessible_connectors_from_mcp_tools( @@ -350,24 +351,21 @@ fn write_cached_accessible_connectors( }); } -fn filter_tool_suggest_discoverable_tools( +fn filter_tool_suggest_discoverable_connectors( directory_connectors: Vec, accessible_connectors: &[AppInfo], + discoverable_connector_ids: &HashSet, ) -> Vec { let accessible_connector_ids: HashSet<&str> = accessible_connectors .iter() - .filter(|connector| connector.is_accessible && connector.is_enabled) + .filter(|connector| connector.is_accessible) .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())) + .filter(|connector| discoverable_connector_ids.contains(connector.id.as_str())) .collect::>(); connectors.sort_by(|left, right| { left.name @@ -377,6 +375,16 @@ fn filter_tool_suggest_discoverable_tools( connectors } +fn tool_suggest_connector_ids(config: &Config) -> HashSet { + PluginsManager::new(config.codex_home.clone()) + .plugins_for_config(config) + .capability_summaries() + .iter() + .flat_map(|plugin| plugin.app_connector_ids.iter()) + .map(|connector_id| connector_id.0.clone()) + .collect() +} + async fn list_directory_connectors_for_tool_suggest_with_auth( config: &Config, auth: Option<&CodexAuth>, diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 3b731e50865..97a4f4597aa 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::types::AppConfig; use crate::config::types::AppToolConfig; @@ -13,13 +14,13 @@ use crate::config_loader::ConfigRequirementsToml; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; -use codex_config::CONFIG_TOML_FILE; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; use std::collections::BTreeMap; use std::collections::HashMap; +use std::collections::HashSet; use std::sync::Arc; use tempfile::tempdir; @@ -979,8 +980,8 @@ fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { } #[test] -fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() { - let filtered = filter_tool_suggest_discoverable_tools( +fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { + let filtered = filter_tool_suggest_discoverable_connectors( vec![ named_app( "connector_2128aebfecb84f64a069897515042a44", @@ -996,6 +997,10 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app "Google Calendar", ) }], + &HashSet::from([ + "connector_2128aebfecb84f64a069897515042a44".to_string(), + "connector_68df038e0ba48191908c8434991bbac2".to_string(), + ]), ); assert_eq!( @@ -1008,8 +1013,8 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app } #[test] -fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() { - let filtered = filter_tool_suggest_discoverable_tools( +fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_when_disabled() { + let filtered = filter_tool_suggest_discoverable_connectors( vec![ named_app( "connector_2128aebfecb84f64a069897515042a44", @@ -1031,13 +1036,11 @@ fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() { ..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail") }, ], + &HashSet::from([ + "connector_2128aebfecb84f64a069897515042a44".to_string(), + "connector_68df038e0ba48191908c8434991bbac2".to_string(), + ]), ); - assert_eq!( - filtered, - vec![named_app( - "connector_68df038e0ba48191908c8434991bbac2", - "Gmail" - )] - ); + assert_eq!(filtered, Vec::::new()); } diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs new file mode 100644 index 00000000000..d69a897128d --- /dev/null +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -0,0 +1,67 @@ +use anyhow::Context; +use tracing::warn; + +use super::OPENAI_CURATED_MARKETPLACE_NAME; +use super::PluginDetailSummary; +use super::PluginReadRequest; +use super::PluginsManager; +use crate::config::Config; + +const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ + "github@openai-curated", + "notion@openai-curated", + "slack@openai-curated", + "gmail@openai-curated", + "google-calendar@openai-curated", + "google-docs@openai-curated", + "google-drive@openai-curated", + "google-sheets@openai-curated", + "google-slides@openai-curated", +]; + +pub(crate) fn list_tool_suggest_discoverable_plugins( + config: &Config, +) -> anyhow::Result> { + let plugins_manager = PluginsManager::new(config.codex_home.clone()); + let marketplaces = plugins_manager + .list_marketplaces_for_config(config, &[]) + .context("failed to list plugin marketplaces for tool suggestions")?; + let Some(curated_marketplace) = marketplaces + .into_iter() + .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + else { + return Ok(Vec::new()); + }; + + let mut discoverable_plugins = Vec::new(); + for plugin in curated_marketplace.plugins { + if plugin.installed + || !TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()) + { + continue; + } + let plugin_id = plugin.id.clone(); + let plugin_name = plugin.name.clone(); + + match plugins_manager.read_plugin_for_config( + config, + &PluginReadRequest { + plugin_name, + marketplace_path: curated_marketplace.path.clone(), + }, + ) { + Ok(plugin) => discoverable_plugins.push(plugin.plugin), + Err(err) => warn!("failed to load curated plugin suggestion {plugin_id}: {err:#}"), + } + } + discoverable_plugins.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(discoverable_plugins) +} + +#[cfg(test)] +#[path = "discoverable_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs new file mode 100644 index 00000000000..3b9f52fb481 --- /dev/null +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -0,0 +1,168 @@ +use super::*; +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigBuilder; +use crate::plugins::PluginInstallRequest; +use crate::tools::discoverable::DiscoverablePluginInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +fn write_curated_plugin(root: &Path, plugin_name: &str) { + let plugin_root = root.join("plugins").join(plugin_name); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ + "name": "{plugin_name}", + "description": "Plugin that includes skills, MCP servers, and app connectors" +}}"# + ), + ); + write_file( + &plugin_root.join("skills/SKILL.md"), + "---\nname: sample\ndescription: sample\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample-docs": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "connector_calendar" + } + } +}"#, + ); +} + +fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ +{plugins} + ] +}}"# + ), + ); + for plugin_name in plugin_names { + write_curated_plugin(root, plugin_name); + } +} + +fn write_curated_plugin_sha(codex_home: &Path) { + write_file( + &codex_home.join(".tmp/plugins.sha"), + "0123456789abcdef0123456789abcdef01234567\n", + ); +} + +fn write_plugins_feature_config(codex_home: &Path) { + write_file( + &codex_home.join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); +} + +async fn load_plugins_config(codex_home: &Path) -> crate::config::Config { + ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .fallback_cwd(Some(codex_home.to_path_buf())) + .build() + .await + .expect("config should load") +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample", "slack"]); + write_plugins_feature_config(codex_home.path()); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!( + discoverable_plugins, + vec![DiscoverablePluginInfo { + id: "slack@openai-curated".to_string(), + name: "slack".to_string(), + description: Some( + "Plugin that includes skills, MCP servers, and app connectors".to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }] + ); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_curated_plugin_sha(codex_home.path()); + write_plugins_feature_config(codex_home.path()); + + PluginsManager::new(codex_home.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "slack".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + curated_root.join(".agents/plugins/marketplace.json"), + ) + .expect("marketplace path"), + }) + .await + .expect("plugin should install"); + + let refreshed_config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!(discoverable_plugins, Vec::::new()); +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index d9d719e50eb..a1e8fc68834 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -63,7 +63,7 @@ use tracing::warn; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; -const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; +pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; const REMOTE_PLUGIN_SYNC_TIMEOUT: Duration = Duration::from_secs(30); static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 7eb76798ada..25472f7fd16 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,4 +1,5 @@ mod curated_repo; +mod discoverable; mod injection; mod manager; mod manifest; @@ -10,11 +11,13 @@ mod toggles; pub(crate) use curated_repo::curated_plugins_repo_path; pub(crate) use curated_repo::read_curated_plugins_sha; pub(crate) use curated_repo::sync_openai_plugins_repo; +pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; pub use manager::ConfiguredMarketplacePluginSummary; pub use manager::ConfiguredMarketplaceSummary; pub use manager::LoadedPlugin; +pub use manager::OPENAI_CURATED_MARKETPLACE_NAME; pub use manager::PluginCapabilitySummary; pub use manager::PluginDetailSummary; pub use manager::PluginInstallError; diff --git a/codex-rs/core/src/tools/discoverable.rs b/codex-rs/core/src/tools/discoverable.rs index 75de51b150a..ae249084532 100644 --- a/codex-rs/core/src/tools/discoverable.rs +++ b/codex-rs/core/src/tools/discoverable.rs @@ -1,4 +1,5 @@ use crate::plugins::PluginCapabilitySummary; +use crate::plugins::PluginDetailSummary; use codex_app_server_protocol::AppInfo; use serde::Deserialize; use serde::Serialize; @@ -69,6 +70,13 @@ impl DiscoverableTool { Self::Plugin(plugin) => plugin.description.as_deref(), } } + + pub(crate) fn install_url(&self) -> Option<&str> { + match self { + Self::Connector(connector) => connector.install_url.as_deref(), + Self::Plugin(_) => None, + } + } } impl From for DiscoverableTool { @@ -109,3 +117,20 @@ impl From for DiscoverablePluginInfo { } } } + +impl From for DiscoverablePluginInfo { + fn from(value: PluginDetailSummary) -> Self { + Self { + id: value.id, + name: value.name, + description: value.description, + has_skills: !value.skills.is_empty(), + mcp_server_names: value.mcp_server_names, + app_connector_ids: value + .apps + .into_iter() + .map(|connector_id| connector_id.0) + .collect(), + } + } +} diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index 311f191bd06..9576a2047ea 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -59,7 +59,8 @@ struct ToolSuggestMeta<'a> { suggest_reason: &'a str, tool_id: &'a str, tool_name: &'a str, - install_url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + install_url: Option<&'a str>, } #[async_trait] @@ -95,15 +96,9 @@ impl ToolHandler for ToolSuggestHandler { "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(), + "tool suggestions currently support only action_type=\"install\"".to_string(), )); } @@ -121,26 +116,15 @@ impl ToolHandler for ToolSuggestHandler { &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 + let tool = 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, - }) + .find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id) .ok_or_else(|| { FunctionCallError::RespondToModel(format!( "tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}" @@ -153,7 +137,7 @@ impl ToolHandler for ToolSuggestHandler { turn.sub_id.clone(), &args, suggest_reason, - &connector, + &tool, ); let response = session .request_mcp_server_elicitation(turn.as_ref(), request_id, params) @@ -163,37 +147,12 @@ impl ToolHandler for ToolSuggestHandler { .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 - } - } + verify_tool_suggestion_completed(&session, &turn, &tool, auth.as_ref()).await } else { false }; - if completed { + if completed && let DiscoverableTool::Connector(connector) = &tool { session .merge_connector_selection(HashSet::from([connector.id.clone()])) .await; @@ -204,8 +163,8 @@ impl ToolHandler for ToolSuggestHandler { user_confirmed, tool_type: args.tool_type, action_type: args.action_type, - tool_id: connector.id, - tool_name: connector.name, + tool_id: tool.id().to_string(), + tool_name: tool.name().to_string(), suggest_reason: suggest_reason.to_string(), }) .map_err(|err| { @@ -223,18 +182,18 @@ fn build_tool_suggestion_elicitation_request( turn_id: String, args: &ToolSuggestArgs, suggest_reason: &str, - connector: &AppInfo, + tool: &DiscoverableTool, ) -> 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() - ); + let tool_name = tool.name().to_string(); + let install_url = tool.install_url().map(ToString::to_string); + let message = match install_url.as_deref() { + Some(_) => format!( + "{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to install it, then confirm here if you finish." + ), + None => format!( + "{tool_name} could help with this request.\n\n{suggest_reason}\n\nInstall it here to continue." + ), + }; McpServerElicitationRequestParams { thread_id, @@ -245,9 +204,9 @@ fn build_tool_suggestion_elicitation_request( args.tool_type, args.action_type, suggest_reason, - connector.id.as_str(), + tool.id(), tool_name.as_str(), - install_url.as_str(), + install_url.as_deref(), ))), message, requested_schema: McpElicitationSchema { @@ -266,7 +225,7 @@ fn build_tool_suggestion_meta<'a>( suggest_reason: &'a str, tool_id: &'a str, tool_name: &'a str, - install_url: &'a str, + install_url: Option<&'a str>, ) -> ToolSuggestMeta<'a> { ToolSuggestMeta { codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, @@ -279,18 +238,74 @@ fn build_tool_suggestion_meta<'a>( } } +async fn verify_tool_suggestion_completed( + session: &crate::codex::Session, + turn: &crate::codex::TurnContext, + tool: &DiscoverableTool, + auth: Option<&crate::CodexAuth>, +) -> bool { + match tool { + DiscoverableTool::Connector(connector) => { + 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, + &mcp_tools, + ); + verified_connector_suggestion_completed( + connector.id.as_str(), + &accessible_connectors, + ) + } + Err(err) => { + warn!( + "failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}", + connector.id + ); + false + } + } + } + DiscoverableTool::Plugin(plugin) => { + session.reload_user_config_layer().await; + let config = session.get_config().await; + verified_plugin_suggestion_completed( + plugin.id.as_str(), + config.as_ref(), + session.services.plugins_manager.as_ref(), + ) + } + } +} + 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, - }) + .is_some_and(|connector| connector.is_accessible) +} + +fn verified_plugin_suggestion_completed( + tool_id: &str, + config: &crate::config::Config, + plugins_manager: &crate::plugins::PluginsManager, +) -> bool { + plugins_manager + .list_marketplaces_for_config(config, &[]) + .ok() + .into_iter() + .flatten() + .flat_map(|marketplace| marketplace.plugins.into_iter()) + .any(|plugin| plugin.id == tool_id && plugin.installed) } #[cfg(test)] diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs index a8c4541e917..8fcfa8a3e8c 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -1,5 +1,107 @@ use super::*; +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigBuilder; +use crate::plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::plugins::PluginInstallRequest; +use crate::plugins::PluginsManager; +use crate::tools::discoverable::DiscoverablePluginInfo; +use codex_app_server_protocol::AppInfo; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +fn write_curated_plugin(root: &Path, plugin_name: &str) { + let plugin_root = root.join("plugins").join(plugin_name); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ + "name": "{plugin_name}", + "description": "Plugin that includes skills, MCP servers, and app connectors" +}}"# + ), + ); + write_file( + &plugin_root.join("skills/SKILL.md"), + "---\nname: sample\ndescription: sample\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample-docs": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "connector_calendar" + } + } +}"#, + ); +} + +fn write_openai_curated_marketplace(root: &Path, plugin_name: &str) { + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ + {{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }} + ] +}}"# + ), + ); + write_curated_plugin(root, plugin_name); +} + +fn write_curated_plugin_sha(codex_home: &Path) { + write_file( + &codex_home.join(".tmp/plugins.sha"), + "0123456789abcdef0123456789abcdef01234567\n", + ); +} + +fn write_plugins_feature_config(codex_home: &Path) { + write_file( + &codex_home.join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); +} + +async fn load_plugin_config(codex_home: &Path) -> crate::config::Config { + ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .fallback_cwd(Some(codex_home.to_path_buf())) + .build() + .await + .expect("config should load") +} #[test] fn build_tool_suggestion_elicitation_request_uses_expected_shape() { @@ -9,7 +111,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() { tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), suggest_reason: "Plan and reference events from your calendar".to_string(), }; - let connector = AppInfo { + let connector = DiscoverableTool::Connector(Box::new(AppInfo { id: "connector_2128aebfecb84f64a069897515042a44".to_string(), name: "Google Calendar".to_string(), description: Some("Plan events and schedules.".to_string()), @@ -26,7 +128,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() { is_accessible: false, is_enabled: true, plugin_display_names: Vec::new(), - }; + })); let request = build_tool_suggestion_elicitation_request( "thread-1".to_string(), @@ -37,31 +139,86 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() { ); 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, - }, + 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: Some( + "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_elicitation_request_for_plugin_omits_install_url() { + let args = ToolSuggestArgs { + tool_type: DiscoverableToolType::Plugin, + action_type: DiscoverableToolAction::Install, + tool_id: "sample@openai-curated".to_string(), + suggest_reason: "Use the sample plugin's skills and MCP server".to_string(), + }; + let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "sample@openai-curated".to_string(), + name: "Sample Plugin".to_string(), + description: Some("Includes skills, MCP servers, and apps.".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + })); + + let request = build_tool_suggestion_elicitation_request( + "thread-1".to_string(), + "turn-1".to_string(), + &args, + "Use the sample plugin's skills and MCP server", + &plugin, + ); + + 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::Plugin, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Use the sample plugin's skills and MCP server", + tool_id: "sample@openai-curated", + tool_name: "Sample Plugin", + install_url: None, + })), + message: "Sample Plugin could help with this request.\n\nUse the sample plugin's skills and MCP server\n\nInstall it here to continue.".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, }, - } - ); + }, + } + ); } #[test] @@ -72,7 +229,7 @@ fn build_tool_suggestion_meta_uses_expected_shape() { "Find and reference emails from your inbox", "connector_68df038e0ba48191908c8434991bbac2", "Gmail", - "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2", + Some("https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"), ); assert_eq!( @@ -84,13 +241,15 @@ fn build_tool_suggestion_meta_uses_expected_shape() { 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", + install_url: Some( + "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2" + ), } ); } #[test] -fn verified_connector_suggestion_completed_requires_installed_connector() { +fn verified_connector_suggestion_completed_requires_accessible_connector() { let accessible_connectors = vec![AppInfo { id: "calendar".to_string(), name: "Google Calendar".to_string(), @@ -103,65 +262,52 @@ fn verified_connector_suggestion_completed_requires_installed_connector() { labels: None, install_url: None, is_accessible: true, - is_enabled: true, + is_enabled: false, 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(), - }, - ]; +#[tokio::test] +async fn verified_plugin_suggestion_completed_requires_installed_plugin() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, "sample"); + write_curated_plugin_sha(codex_home.path()); + write_plugins_feature_config(codex_home.path()); - assert!(!verified_connector_suggestion_completed( - DiscoverableToolAction::Enable, - "calendar", - &accessible_connectors, + let config = load_plugin_config(codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + assert!(!verified_plugin_suggestion_completed( + "sample@openai-curated", + &config, + &plugins_manager, )); - assert!(verified_connector_suggestion_completed( - DiscoverableToolAction::Enable, - "gmail", - &accessible_connectors, + + plugins_manager + .install_plugin(PluginInstallRequest { + plugin_name: "sample".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + curated_root.join(".agents/plugins/marketplace.json"), + ) + .expect("marketplace path"), + }) + .await + .expect("plugin should install"); + + let refreshed_config = load_plugin_config(codex_home.path()).await; + assert!(verified_plugin_suggestion_completed( + "sample@openai-curated", + &refreshed_config, + &plugins_manager, )); } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 09c2c50d6a6..f62ce1501c5 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1824,7 +1824,7 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String }); let default_action = match tool.tool_type() { DiscoverableToolType::Connector => DiscoverableToolAction::Install, - DiscoverableToolType::Plugin => DiscoverableToolAction::Enable, + DiscoverableToolType::Plugin => DiscoverableToolAction::Install, }; format!( "- {} (id: `{}`, type: {}, action: {}): {}", diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index c3c228703ae..b58f2ae5383 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2097,7 +2097,8 @@ fn tool_suggest_description_lists_discoverable_tools() { 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("id: `sample@test`, type: plugin, action: install")); + assert!(description.contains("`action_type`: `install` or `enable`")); assert!( description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample") ); From fb88b513dc17cf8859b55a06af0828b8fb98fede Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 16 Mar 2026 23:06:37 -0700 Subject: [PATCH 2/5] update --- codex-rs/core/src/plugins/discoverable.rs | 5 +++++ codex-rs/core/src/plugins/discoverable_tests.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index d69a897128d..f5d17bd0b1f 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -6,6 +6,7 @@ use super::PluginDetailSummary; use super::PluginReadRequest; use super::PluginsManager; use crate::config::Config; +use crate::features::Feature; const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ "github@openai-curated", @@ -22,6 +23,10 @@ const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ pub(crate) fn list_tool_suggest_discoverable_plugins( config: &Config, ) -> anyhow::Result> { + if !config.features.enabled(Feature::Plugins) { + return Ok(Vec::new()); + } + let plugins_manager = PluginsManager::new(config.codex_home.clone()); let marketplaces = plugins_manager .list_marketplaces_for_config(config, &[]) diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index 3b9f52fb481..ffb7e986201 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -138,6 +138,22 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug ); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!(discoverable_plugins, Vec::::new()); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() { let codex_home = tempdir().expect("tempdir should succeed"); From 27217b4c91032e7e9a9557c7ffa95e6d8475c139 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 16 Mar 2026 23:20:14 -0700 Subject: [PATCH 3/5] update --- codex-rs/core/src/tools/handlers/tool_suggest.rs | 9 +-------- codex-rs/core/src/tools/handlers/tool_suggest_tests.rs | 4 ++-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index 9576a2047ea..970c803a451 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -186,14 +186,7 @@ fn build_tool_suggestion_elicitation_request( ) -> McpServerElicitationRequestParams { let tool_name = tool.name().to_string(); let install_url = tool.install_url().map(ToString::to_string); - let message = match install_url.as_deref() { - Some(_) => format!( - "{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to install it, then confirm here if you finish." - ), - None => format!( - "{tool_name} could help with this request.\n\n{suggest_reason}\n\nInstall it here to continue." - ), - }; + let message = suggest_reason.to_string(); McpServerElicitationRequestParams { thread_id, diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs index 8fcfa8a3e8c..9f6149d66ad 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -156,7 +156,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() { "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(), + message: "Plan and reference events from your calendar".to_string(), requested_schema: McpElicitationSchema { schema_uri: None, type_: McpElicitationObjectType::Object, @@ -209,7 +209,7 @@ fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() { tool_name: "Sample Plugin", install_url: None, })), - message: "Sample Plugin could help with this request.\n\nUse the sample plugin's skills and MCP server\n\nInstall it here to continue.".to_string(), + message: "Use the sample plugin's skills and MCP server".to_string(), requested_schema: McpElicitationSchema { schema_uri: None, type_: McpElicitationObjectType::Object, From c634ffe9024998b26f1be6c0acf0e9d6cc638140 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 16 Mar 2026 23:55:48 -0700 Subject: [PATCH 4/5] update --- codex-rs/core/src/connectors.rs | 1 + codex-rs/core/src/connectors_tests.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 875d1999d9a..3221e340895 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -683,6 +683,7 @@ pub(crate) fn codex_app_tool_is_enabled( const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ "asdk_app_6938a94a61d881918ef32cb999ff937c", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", + "connector_3f8d1a79f27c4c7ba1a897ab13bf37dc", "connector_68de829bf7648191acd70a907364c67c", "connector_68e004f14af881919eb50893d3d9f523", "connector_69272cb413a081919685ec3c88d1744e", diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 97a4f4597aa..f0ec1309cc3 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -958,6 +958,7 @@ fn filter_disallowed_connectors_filters_openai_prefix() { fn filter_disallowed_connectors_filters_disallowed_connector_ids() { let filtered = filter_disallowed_connectors(vec![ app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"), app("delta"), ]); assert_eq!(filtered, vec![app("delta")]); From b05298fb544e46a1432b7e1d48c35587cf6da4e8 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 17 Mar 2026 11:58:24 -0700 Subject: [PATCH 5/5] update --- codex-rs/core/src/codex.rs | 7 +- codex-rs/core/src/plugins/discoverable.rs | 14 +- .../core/src/plugins/discoverable_tests.rs | 143 +++++----------- codex-rs/core/src/plugins/manager.rs | 13 ++ codex-rs/core/src/plugins/manager_tests.rs | 49 +----- codex-rs/core/src/plugins/mod.rs | 2 + codex-rs/core/src/plugins/test_support.rs | 109 +++++++++++++ codex-rs/core/src/tools/discoverable.rs | 34 ++-- .../core/src/tools/handlers/tool_suggest.rs | 14 ++ .../src/tools/handlers/tool_suggest_tests.rs | 153 +++++++----------- .../src/bottom_pane/mcp_server_elicitation.rs | 40 ++++- codex-rs/tui/src/bottom_pane/mod.rs | 6 +- .../src/bottom_pane/mcp_server_elicitation.rs | 40 ++++- .../tui_app_server/src/bottom_pane/mod.rs | 6 +- 14 files changed, 346 insertions(+), 284 deletions(-) create mode 100644 codex-rs/core/src/plugins/test_support.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 12bc613e4eb..6b7b6702598 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6467,7 +6467,12 @@ pub(crate) async fn built_tools( accessible_connectors.as_slice(), ) .await - { + .map(|discoverable_tools| { + crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client( + discoverable_tools, + turn_context.app_server_client_name.as_deref(), + ) + }) { Ok(discoverable_tools) if discoverable_tools.is_empty() => None, Ok(discoverable_tools) => Some(discoverable_tools), Err(err) => { diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index f5d17bd0b1f..ddadd749e67 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -2,7 +2,7 @@ use anyhow::Context; use tracing::warn; use super::OPENAI_CURATED_MARKETPLACE_NAME; -use super::PluginDetailSummary; +use super::PluginCapabilitySummary; use super::PluginReadRequest; use super::PluginsManager; use crate::config::Config; @@ -22,7 +22,7 @@ const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ pub(crate) fn list_tool_suggest_discoverable_plugins( config: &Config, -) -> anyhow::Result> { +) -> anyhow::Result> { if !config.features.enabled(Feature::Plugins) { return Ok(Vec::new()); } @@ -38,7 +38,7 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( return Ok(Vec::new()); }; - let mut discoverable_plugins = Vec::new(); + let mut discoverable_plugins = Vec::::new(); for plugin in curated_marketplace.plugins { if plugin.installed || !TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()) @@ -55,14 +55,14 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( marketplace_path: curated_marketplace.path.clone(), }, ) { - Ok(plugin) => discoverable_plugins.push(plugin.plugin), + Ok(plugin) => discoverable_plugins.push(plugin.plugin.into()), Err(err) => warn!("failed to load curated plugin suggestion {plugin_id}: {err:#}"), } } discoverable_plugins.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then_with(|| left.id.cmp(&right.id)) + left.display_name + .cmp(&right.display_name) + .then_with(|| left.config_name.cmp(&right.config_name)) }); Ok(discoverable_plugins) } diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index ffb7e986201..f624172ed12 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -1,114 +1,15 @@ use super::*; -use crate::config::CONFIG_TOML_FILE; -use crate::config::ConfigBuilder; use crate::plugins::PluginInstallRequest; +use crate::plugins::test_support::load_plugins_config; +use crate::plugins::test_support::write_curated_plugin_sha; +use crate::plugins::test_support::write_file; +use crate::plugins::test_support::write_openai_curated_marketplace; +use crate::plugins::test_support::write_plugins_feature_config; use crate::tools::discoverable::DiscoverablePluginInfo; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -use std::fs; -use std::path::Path; use tempfile::tempdir; -fn write_file(path: &Path, contents: &str) { - fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); - fs::write(path, contents).unwrap(); -} - -fn write_curated_plugin(root: &Path, plugin_name: &str) { - let plugin_root = root.join("plugins").join(plugin_name); - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - &format!( - r#"{{ - "name": "{plugin_name}", - "description": "Plugin that includes skills, MCP servers, and app connectors" -}}"# - ), - ); - write_file( - &plugin_root.join("skills/SKILL.md"), - "---\nname: sample\ndescription: sample\n---\n", - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ - "mcpServers": { - "sample-docs": { - "type": "http", - "url": "https://sample.example/mcp" - } - } -}"#, - ); - write_file( - &plugin_root.join(".app.json"), - r#"{ - "apps": { - "calendar": { - "id": "connector_calendar" - } - } -}"#, - ); -} - -fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { - let plugins = plugin_names - .iter() - .map(|plugin_name| { - format!( - r#"{{ - "name": "{plugin_name}", - "source": {{ - "source": "local", - "path": "./plugins/{plugin_name}" - }} - }}"# - ) - }) - .collect::>() - .join(",\n"); - write_file( - &root.join(".agents/plugins/marketplace.json"), - &format!( - r#"{{ - "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", - "plugins": [ -{plugins} - ] -}}"# - ), - ); - for plugin_name in plugin_names { - write_curated_plugin(root, plugin_name); - } -} - -fn write_curated_plugin_sha(codex_home: &Path) { - write_file( - &codex_home.join(".tmp/plugins.sha"), - "0123456789abcdef0123456789abcdef01234567\n", - ); -} - -fn write_plugins_feature_config(codex_home: &Path) { - write_file( - &codex_home.join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); -} - -async fn load_plugins_config(codex_home: &Path) -> crate::config::Config { - ConfigBuilder::default() - .codex_home(codex_home.to_path_buf()) - .fallback_cwd(Some(codex_home.to_path_buf())) - .build() - .await - .expect("config should load") -} - #[tokio::test] async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -154,6 +55,40 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu assert_eq!(discoverable_plugins, Vec::::new()); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_normalizes_description() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_plugins_feature_config(codex_home.path()); + write_file( + &curated_root.join("plugins/slack/.codex-plugin/plugin.json"), + r#"{ + "name": "slack", + "description": " Plugin\n with extra spacing " +}"#, + ); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!( + discoverable_plugins, + vec![DiscoverablePluginInfo { + id: "slack@openai-curated".to_string(), + name: "slack".to_string(), + description: Some("Plugin with extra spacing".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }] + ); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() { let codex_home = tempdir().expect("tempdir should succeed"); diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 57fed99633d..60c375c9cd4 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -219,6 +219,19 @@ impl PluginCapabilitySummary { } } +impl From for PluginCapabilitySummary { + fn from(value: PluginDetailSummary) -> Self { + Self { + config_name: value.id, + display_name: value.name, + description: prompt_safe_plugin_description(value.description.as_deref()), + has_skills: !value.skills.is_empty(), + mcp_server_names: value.mcp_server_names, + app_connector_ids: value.apps, + } + } +} + fn prompt_safe_plugin_description(description: Option<&str>) -> Option { let description = description? .split_whitespace() diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 2a327f4e43f..926f07cb294 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -7,6 +7,10 @@ use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; +use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; +use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; +use crate::plugins::test_support::write_file; +use crate::plugins::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::ConfigLayerSource; use pretty_assertions::assert_eq; use std::fs; @@ -19,13 +23,6 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; -const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; - -fn write_file(path: &Path, contents: &str) { - fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); - fs::write(path, contents).unwrap(); -} - fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { let plugin_root = root.join(dir_name); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); @@ -39,44 +36,6 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); } -fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { - fs::create_dir_all(root.join(".agents/plugins")).unwrap(); - let plugins = plugin_names - .iter() - .map(|plugin_name| { - format!( - r#"{{ - "name": "{plugin_name}", - "source": {{ - "source": "local", - "path": "./plugins/{plugin_name}" - }} - }}"# - ) - }) - .collect::>() - .join(",\n"); - fs::write( - root.join(".agents/plugins/marketplace.json"), - format!( - r#"{{ - "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", - "plugins": [ -{plugins} - ] -}}"# - ), - ) - .unwrap(); - for plugin_name in plugin_names { - write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name); - } -} - -fn write_curated_plugin_sha(codex_home: &Path, sha: &str) { - write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); -} - fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { let mut root = toml::map::Map::new(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 31fb5e357e6..8b45954abe1 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -7,6 +7,8 @@ mod marketplace; mod remote; mod render; mod store; +#[cfg(test)] +pub(crate) mod test_support; mod toggles; pub(crate) use curated_repo::curated_plugins_repo_path; diff --git a/codex-rs/core/src/plugins/test_support.rs b/codex-rs/core/src/plugins/test_support.rs new file mode 100644 index 00000000000..8624d408104 --- /dev/null +++ b/codex-rs/core/src/plugins/test_support.rs @@ -0,0 +1,109 @@ +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigBuilder; +use std::fs; +use std::path::Path; + +use super::OPENAI_CURATED_MARKETPLACE_NAME; + +pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +pub(crate) fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) { + let plugin_root = root.join("plugins").join(plugin_name); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ + "name": "{plugin_name}", + "description": "Plugin that includes skills, MCP servers, and app connectors" +}}"# + ), + ); + write_file( + &plugin_root.join("skills/SKILL.md"), + "---\nname: sample\ndescription: sample\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample-docs": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "connector_calendar" + } + } +}"#, + ); +} + +pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ +{plugins} + ] +}}"# + ), + ); + for plugin_name in plugin_names { + write_curated_plugin(root, plugin_name); + } +} + +pub(crate) fn write_curated_plugin_sha(codex_home: &Path) { + write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA); +} + +pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) { + write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); +} + +pub(crate) fn write_plugins_feature_config(codex_home: &Path) { + write_file( + &codex_home.join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); +} + +pub(crate) async fn load_plugins_config(codex_home: &Path) -> crate::config::Config { + ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .fallback_cwd(Some(codex_home.to_path_buf())) + .build() + .await + .expect("config should load") +} diff --git a/codex-rs/core/src/tools/discoverable.rs b/codex-rs/core/src/tools/discoverable.rs index ae249084532..fc1c66847ba 100644 --- a/codex-rs/core/src/tools/discoverable.rs +++ b/codex-rs/core/src/tools/discoverable.rs @@ -1,9 +1,10 @@ use crate::plugins::PluginCapabilitySummary; -use crate::plugins::PluginDetailSummary; use codex_app_server_protocol::AppInfo; use serde::Deserialize; use serde::Serialize; +const TUI_APP_SERVER_CLIENT_NAME: &str = "codex-tui"; + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub(crate) enum DiscoverableToolType { @@ -91,6 +92,20 @@ impl From for DiscoverableTool { } } +pub(crate) fn filter_tool_suggest_discoverable_tools_for_client( + discoverable_tools: Vec, + app_server_client_name: Option<&str>, +) -> Vec { + if app_server_client_name != Some(TUI_APP_SERVER_CLIENT_NAME) { + return discoverable_tools; + } + + discoverable_tools + .into_iter() + .filter(|tool| !matches!(tool, DiscoverableTool::Plugin(_))) + .collect() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DiscoverablePluginInfo { pub(crate) id: String, @@ -117,20 +132,3 @@ impl From for DiscoverablePluginInfo { } } } - -impl From for DiscoverablePluginInfo { - fn from(value: PluginDetailSummary) -> Self { - Self { - id: value.id, - name: value.name, - description: value.description, - has_skills: !value.skills.is_empty(), - mcp_server_names: value.mcp_server_names, - app_connector_ids: value - .apps - .into_iter() - .map(|connector_id| connector_id.0) - .collect(), - } - } -} diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index 970c803a451..533f12c4f1a 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -23,6 +23,7 @@ use crate::tools::context::ToolPayload; use crate::tools::discoverable::DiscoverableTool; use crate::tools::discoverable::DiscoverableToolAction; use crate::tools::discoverable::DiscoverableToolType; +use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; @@ -101,6 +102,13 @@ impl ToolHandler for ToolSuggestHandler { "tool suggestions currently support only action_type=\"install\"".to_string(), )); } + if args.tool_type == DiscoverableToolType::Plugin + && turn.app_server_client_name.as_deref() == Some("codex-tui") + { + return Err(FunctionCallError::RespondToModel( + "plugin tool suggestions are not available in codex-tui yet".to_string(), + )); + } let auth = session.services.auth_manager.auth().await; let manager = session.services.mcp_connection_manager.read().await; @@ -116,6 +124,12 @@ impl ToolHandler for ToolSuggestHandler { &accessible_connectors, ) .await + .map(|discoverable_tools| { + filter_tool_suggest_discoverable_tools_for_client( + discoverable_tools, + turn.app_server_client_name.as_deref(), + ) + }) .map_err(|err| { FunctionCallError::RespondToModel(format!( "tool suggestions are unavailable right now: {err}" diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs index 9f6149d66ad..31aa49bbabc 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -1,108 +1,19 @@ use super::*; -use crate::config::CONFIG_TOML_FILE; -use crate::config::ConfigBuilder; -use crate::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use crate::plugins::PluginInstallRequest; use crate::plugins::PluginsManager; +use crate::plugins::test_support::load_plugins_config; +use crate::plugins::test_support::write_curated_plugin_sha; +use crate::plugins::test_support::write_openai_curated_marketplace; +use crate::plugins::test_support::write_plugins_feature_config; use crate::tools::discoverable::DiscoverablePluginInfo; +use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client; use codex_app_server_protocol::AppInfo; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; -use std::fs; -use std::path::Path; use tempfile::tempdir; -fn write_file(path: &Path, contents: &str) { - fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); - fs::write(path, contents).unwrap(); -} - -fn write_curated_plugin(root: &Path, plugin_name: &str) { - let plugin_root = root.join("plugins").join(plugin_name); - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - &format!( - r#"{{ - "name": "{plugin_name}", - "description": "Plugin that includes skills, MCP servers, and app connectors" -}}"# - ), - ); - write_file( - &plugin_root.join("skills/SKILL.md"), - "---\nname: sample\ndescription: sample\n---\n", - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ - "mcpServers": { - "sample-docs": { - "type": "http", - "url": "https://sample.example/mcp" - } - } -}"#, - ); - write_file( - &plugin_root.join(".app.json"), - r#"{ - "apps": { - "calendar": { - "id": "connector_calendar" - } - } -}"#, - ); -} - -fn write_openai_curated_marketplace(root: &Path, plugin_name: &str) { - write_file( - &root.join(".agents/plugins/marketplace.json"), - &format!( - r#"{{ - "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", - "plugins": [ - {{ - "name": "{plugin_name}", - "source": {{ - "source": "local", - "path": "./plugins/{plugin_name}" - }} - }} - ] -}}"# - ), - ); - write_curated_plugin(root, plugin_name); -} - -fn write_curated_plugin_sha(codex_home: &Path) { - write_file( - &codex_home.join(".tmp/plugins.sha"), - "0123456789abcdef0123456789abcdef01234567\n", - ); -} - -fn write_plugins_feature_config(codex_home: &Path) { - write_file( - &codex_home.join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); -} - -async fn load_plugin_config(codex_home: &Path) -> crate::config::Config { - ConfigBuilder::default() - .codex_home(codex_home.to_path_buf()) - .fallback_cwd(Some(codex_home.to_path_buf())) - .build() - .await - .expect("config should load") -} - #[test] fn build_tool_suggestion_elicitation_request_uses_expected_shape() { let args = ToolSuggestArgs { @@ -248,6 +159,54 @@ fn build_tool_suggestion_meta_uses_expected_shape() { ); } +#[test] +fn filter_tool_suggest_discoverable_tools_for_codex_tui_omits_plugins() { + let discoverable_tools = vec![ + DiscoverableTool::Connector(Box::new(AppInfo { + id: "connector_google_calendar".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://example.test/google-calendar".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + })), + DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "slack@openai-curated".to_string(), + name: "Slack".to_string(), + description: Some("Search Slack messages".to_string()), + has_skills: true, + mcp_server_names: vec!["slack".to_string()], + app_connector_ids: vec!["connector_slack".to_string()], + })), + ]; + + assert_eq!( + filter_tool_suggest_discoverable_tools_for_client(discoverable_tools, Some("codex-tui"),), + vec![DiscoverableTool::Connector(Box::new(AppInfo { + id: "connector_google_calendar".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://example.test/google-calendar".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }))] + ); +} + #[test] fn verified_connector_suggestion_completed_requires_accessible_connector() { let accessible_connectors = vec![AppInfo { @@ -280,11 +239,11 @@ fn verified_connector_suggestion_completed_requires_accessible_connector() { async fn verified_plugin_suggestion_completed_requires_installed_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, "sample"); + write_openai_curated_marketplace(&curated_root, &["sample"]); write_curated_plugin_sha(codex_home.path()); write_plugins_feature_config(codex_home.path()); - let config = load_plugin_config(codex_home.path()).await; + let config = load_plugins_config(codex_home.path()).await; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); assert!(!verified_plugin_suggestion_completed( @@ -304,7 +263,7 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() { .await .expect("plugin should install"); - let refreshed_config = load_plugin_config(codex_home.path()).await; + let refreshed_config = load_plugins_config(codex_home.path()).await; assert!(verified_plugin_suggestion_completed( "sample@openai-curated", &refreshed_config, 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 6e0d1acbb14..2b506d504fd 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -149,7 +149,7 @@ pub(crate) struct ToolSuggestionRequest { pub(crate) suggest_reason: String, pub(crate) tool_id: String, pub(crate) tool_name: String, - pub(crate) install_url: String, + pub(crate) install_url: Option, } #[derive(Clone, Debug, PartialEq)] @@ -373,8 +373,8 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option { AppLinkSuggestionType::Install @@ -989,7 +991,7 @@ impl BottomPane { "Enable this app to use it for the current request.".to_string() } }, - url: tool_suggestion.install_url.clone(), + url: install_url, is_installed, is_enabled: false, suggest_reason: Some(tool_suggestion.suggest_reason.clone()), diff --git a/codex-rs/tui_app_server/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui_app_server/src/bottom_pane/mcp_server_elicitation.rs index db03a7f1cc2..6e8bb3c697e 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mcp_server_elicitation.rs @@ -149,7 +149,7 @@ pub(crate) struct ToolSuggestionRequest { pub(crate) suggest_reason: String, pub(crate) tool_id: String, pub(crate) tool_name: String, - pub(crate) install_url: String, + pub(crate) install_url: Option, } #[derive(Clone, Debug, PartialEq)] @@ -373,8 +373,8 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option { AppLinkSuggestionType::Install @@ -982,7 +984,7 @@ impl BottomPane { "Enable this app to use it for the current request.".to_string() } }, - url: tool_suggestion.install_url.clone(), + url: install_url, is_installed, is_enabled: false, suggest_reason: Some(tool_suggestion.suggest_reason.clone()),