diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4e5d5be559..3ce2a1ac3d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -287,7 +287,7 @@ 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::discoverable_catalog; use crate::tools::js_repl::JsReplHandle; use crate::tools::js_repl::resolve_compatible_node; use crate::tools::network_approval::NetworkApprovalService; @@ -6323,17 +6323,18 @@ pub(crate) async fn built_tools( && 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( + match discoverable_catalog::load_discoverable_tools( &turn_context.config, auth.as_ref(), + sess.services.plugins_manager.as_ref(), + &loaded_plugins, + turn_context.cwd.as_path(), accessible_connectors.as_slice(), ) .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 cced54174a..e3d05a9fb5 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -46,7 +46,7 @@ use crate::token_data::TokenData; 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] = &[ +pub(crate) const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[ "connector_2128aebfecb84f64a069897515042a44", "connector_68df038e0ba48191908c8434991bbac2", "asdk_app_69a1d78e929881919bba0dbda1f6436d", @@ -109,19 +109,6 @@ 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> { @@ -342,7 +329,7 @@ fn write_cached_accessible_connectors( }); } -fn filter_tool_suggest_discoverable_tools( +pub(crate) fn filter_tool_suggest_discoverable_tools( directory_connectors: Vec, accessible_connectors: &[AppInfo], ) -> Vec { @@ -369,7 +356,7 @@ fn filter_tool_suggest_discoverable_tools( connectors } -async fn list_directory_connectors_for_tool_suggest_with_auth( +pub(crate) async fn list_directory_connectors_with_auth( config: &Config, auth: Option<&CodexAuth>, ) -> anyhow::Result> { diff --git a/codex-rs/core/src/tools/discoverable.rs b/codex-rs/core/src/tools/discoverable.rs index 75de51b150..8e54d652b8 100644 --- a/codex-rs/core/src/tools/discoverable.rs +++ b/codex-rs/core/src/tools/discoverable.rs @@ -1,5 +1,6 @@ -use crate::plugins::PluginCapabilitySummary; +use crate::plugins::PluginDetailSummary; use codex_app_server_protocol::AppInfo; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -63,10 +64,22 @@ impl DiscoverableTool { } } - pub(crate) fn description(&self) -> Option<&str> { + pub(crate) fn action(&self) -> DiscoverableToolAction { match self { - Self::Connector(connector) => connector.description.as_deref(), - Self::Plugin(plugin) => plugin.description.as_deref(), + Self::Connector(connector) => { + if connector.is_accessible && !connector.is_enabled { + DiscoverableToolAction::Enable + } else { + DiscoverableToolAction::Install + } + } + Self::Plugin(plugin) => { + if plugin.installed && !plugin.enabled { + DiscoverableToolAction::Enable + } else { + DiscoverableToolAction::Install + } + } } } } @@ -91,21 +104,44 @@ pub(crate) struct DiscoverablePluginInfo { pub(crate) has_skills: bool, pub(crate) mcp_server_names: Vec, pub(crate) app_connector_ids: Vec, + pub(crate) marketplace_path: AbsolutePathBuf, + pub(crate) plugin_name: String, + pub(crate) installed: bool, + pub(crate) enabled: bool, } -impl From for DiscoverablePluginInfo { - fn from(value: PluginCapabilitySummary) -> Self { +impl DiscoverablePluginInfo { + pub(crate) fn from_plugin_detail( + marketplace_path: AbsolutePathBuf, + plugin: PluginDetailSummary, + ) -> Self { + let display_name = plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.clone()) + .unwrap_or_else(|| plugin.name.clone()); + let description = plugin.description.clone().or_else(|| { + plugin + .interface + .as_ref() + .and_then(|interface| interface.short_description.clone()) + }); + 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 + id: plugin.id, + name: display_name, + description, + has_skills: !plugin.skills.is_empty(), + mcp_server_names: plugin.mcp_server_names, + app_connector_ids: plugin + .apps .into_iter() .map(|connector_id| connector_id.0) .collect(), + marketplace_path, + plugin_name: plugin.name, + installed: plugin.installed, + enabled: plugin.enabled, } } } diff --git a/codex-rs/core/src/tools/discoverable_catalog.rs b/codex-rs/core/src/tools/discoverable_catalog.rs new file mode 100644 index 0000000000..9871121396 --- /dev/null +++ b/codex-rs/core/src/tools/discoverable_catalog.rs @@ -0,0 +1,246 @@ +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Path; + +use codex_app_server_protocol::AppInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use tracing::warn; + +use crate::CodexAuth; +use crate::config::Config; +use crate::connectors; +use crate::plugins::AppConnectorId; +use crate::plugins::PluginCapabilitySummary; +use crate::plugins::PluginLoadOutcome; +use crate::plugins::PluginReadRequest; +use crate::plugins::PluginsManager; +use crate::tools::discoverable::DiscoverablePluginInfo; +use crate::tools::discoverable::DiscoverableTool; + +pub(crate) const TRUSTED_DISCOVERABLE_PLUGIN_IDS: &[&str] = &[ + "calendar@openai-curated", + "gmail@openai-curated", + "linear@openai-curated", + "slack@openai-curated", +]; + +pub(crate) async fn load_discoverable_tools( + config: &Config, + auth: Option<&CodexAuth>, + plugins_manager: &PluginsManager, + loaded_plugins: &PluginLoadOutcome, + cwd: &Path, + accessible_connectors: &[AppInfo], +) -> anyhow::Result> { + let directory_connectors = + connectors::list_directory_connectors_with_auth(config, auth).await?; + let mut discoverable_tools = build_discoverable_connector_tools( + directory_connectors, + accessible_connectors, + &loaded_plugins.effective_apps(), + loaded_plugins.capability_summaries(), + ) + .into_iter() + .map(DiscoverableTool::from) + .collect::>(); + discoverable_tools.extend( + load_discoverable_plugins(config, plugins_manager, cwd)? + .into_iter() + .map(DiscoverableTool::from), + ); + discoverable_tools.sort_by(|left, right| { + left.name() + .cmp(right.name()) + .then_with(|| left.id().cmp(right.id())) + }); + Ok(discoverable_tools) +} + +pub(crate) fn build_discoverable_connector_tools( + directory_connectors: Vec, + accessible_connectors: &[AppInfo], + enabled_plugin_apps: &[AppConnectorId], + capability_summaries: &[PluginCapabilitySummary], +) -> Vec { + let accessible_by_id = accessible_connectors + .iter() + .map(|connector| (connector.id.as_str(), connector)) + .collect::>(); + let mut discoverable_by_id = connectors::filter_tool_suggest_discoverable_tools( + directory_connectors.clone(), + accessible_connectors, + ) + .into_iter() + .map(|mut connector| { + if let Some(accessible_connector) = accessible_by_id.get(connector.id.as_str()) { + connector.is_accessible = accessible_connector.is_accessible; + connector.is_enabled = accessible_connector.is_enabled; + } + (connector.id.clone(), connector) + }) + .collect::>(); + let accessible_enabled_ids = accessible_connectors + .iter() + .filter(|connector| connector.is_accessible && connector.is_enabled) + .map(|connector| connector.id.as_str()) + .collect::>(); + let plugin_connector_ids = enabled_plugin_apps + .iter() + .map(|connector_id| connector_id.0.as_str()) + .collect::>(); + let plugin_display_names = build_plugin_display_names_by_connector_id(capability_summaries); + + for mut connector in connectors::filter_disallowed_connectors(connectors::merge_plugin_apps( + directory_connectors, + enabled_plugin_apps.to_vec(), + )) { + if accessible_enabled_ids.contains(connector.id.as_str()) + || !plugin_connector_ids.contains(connector.id.as_str()) + { + continue; + } + connector.plugin_display_names = plugin_display_names + .get(connector.id.as_str()) + .cloned() + .unwrap_or_default(); + discoverable_by_id + .entry(connector.id.clone()) + .and_modify(|existing| { + existing + .plugin_display_names + .extend(connector.plugin_display_names.iter().cloned()); + existing.plugin_display_names.sort_unstable(); + existing.plugin_display_names.dedup(); + }) + .or_insert(connector); + } + + let mut discoverable = discoverable_by_id.into_values().collect::>(); + discoverable.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + discoverable +} + +pub(crate) fn build_discoverable_plugin_tools( + discoverable_plugins: Vec, +) -> Vec { + let trusted_plugin_ids = TRUSTED_DISCOVERABLE_PLUGIN_IDS + .iter() + .copied() + .collect::>(); + let mut discoverable_plugins = discoverable_plugins + .into_iter() + .filter(|plugin| trusted_plugin_ids.contains(plugin.id.as_str())) + .filter(|plugin| !(plugin.installed && plugin.enabled)) + .collect::>(); + discoverable_plugins.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + discoverable_plugins +} + +pub(crate) fn plugin_marketplace_roots(cwd: &Path) -> Vec { + AbsolutePathBuf::try_from(cwd.to_path_buf()) + .map(|cwd| vec![cwd]) + .unwrap_or_default() +} + +pub(crate) fn plugin_completion_verified( + plugins_manager: &PluginsManager, + config: &Config, + cwd: &Path, + plugin_id: &str, +) -> bool { + plugins_manager + .list_marketplaces_for_config(config, &plugin_marketplace_roots(cwd)) + .ok() + .and_then(|marketplaces| { + marketplaces + .into_iter() + .flat_map(|marketplace| marketplace.plugins.into_iter()) + .find(|plugin| plugin.id == plugin_id) + }) + .is_some_and(|plugin| plugin.installed && plugin.enabled) +} + +fn load_discoverable_plugins( + config: &Config, + plugins_manager: &PluginsManager, + cwd: &Path, +) -> anyhow::Result> { + let marketplaces = match plugins_manager + .list_marketplaces_for_config(config, &plugin_marketplace_roots(cwd)) + { + Ok(marketplaces) => marketplaces, + Err(err) => { + warn!("failed to list plugin marketplaces for tool suggestions: {err}"); + return Ok(Vec::new()); + } + }; + + let mut discoverable_plugins = Vec::new(); + for marketplace in marketplaces { + for plugin in marketplace.plugins { + if !TRUSTED_DISCOVERABLE_PLUGIN_IDS.contains(&plugin.id.as_str()) + || (plugin.installed && plugin.enabled) + { + continue; + } + + let request = PluginReadRequest { + marketplace_path: marketplace.path.clone(), + plugin_name: plugin.name.clone(), + }; + match plugins_manager.read_plugin_for_config(config, &request) { + Ok(plugin_detail) => { + discoverable_plugins.push(DiscoverablePluginInfo::from_plugin_detail( + plugin_detail.marketplace_path, + plugin_detail.plugin, + )) + } + Err(err) => { + warn!( + plugin_id = plugin.id, + "failed to read plugin details for tool suggestion: {err}" + ); + } + } + } + } + + Ok(build_discoverable_plugin_tools(discoverable_plugins)) +} + +fn build_plugin_display_names_by_connector_id( + capability_summaries: &[PluginCapabilitySummary], +) -> HashMap<&str, Vec> { + let mut plugin_display_names_by_connector_id: HashMap<&str, BTreeSet> = HashMap::new(); + for plugin in capability_summaries { + for connector_id in &plugin.app_connector_ids { + plugin_display_names_by_connector_id + .entry(connector_id.0.as_str()) + .or_default() + .insert(plugin.display_name.clone()); + } + } + + plugin_display_names_by_connector_id + .into_iter() + .map(|(connector_id, plugin_display_names)| { + ( + connector_id, + plugin_display_names.into_iter().collect::>(), + ) + }) + .collect() +} + +#[cfg(test)] +#[path = "discoverable_catalog_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/discoverable_catalog_tests.rs b/codex-rs/core/src/tools/discoverable_catalog_tests.rs new file mode 100644 index 0000000000..c54d4487c6 --- /dev/null +++ b/codex-rs/core/src/tools/discoverable_catalog_tests.rs @@ -0,0 +1,268 @@ +use codex_app_server_protocol::AppInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::TempDir; + +use super::*; +use crate::config::Config; +use crate::config::ConfigBuilder; +use crate::plugins::AppConnectorId; +use crate::plugins::PluginCapabilitySummary; +use crate::plugins::PluginInstallRequest; +use crate::plugins::PluginsManager; +use crate::tools::discoverable::DiscoverablePluginInfo; +use crate::tools::discoverable::DiscoverableToolAction; +use crate::tools::discoverable::DiscoverableToolType; + +fn connector(id: &str, name: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: name.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + } +} + +fn discoverable_plugin( + id: &str, + name: &str, + installed: bool, + enabled: bool, +) -> DiscoverablePluginInfo { + DiscoverablePluginInfo { + id: id.to_string(), + name: name.to_string(), + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + marketplace_path: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"), + plugin_name: name.to_string(), + installed, + enabled, + } +} + +fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).expect("create dir"); + fs::write(path, contents).expect("write file"); +} + +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")).expect("create plugin dir"); + fs::create_dir_all(plugin_root.join("skills")).expect("create skills dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{manifest_name}"}}"#), + ) + .expect("write manifest"); + fs::write(plugin_root.join("skills/SKILL.md"), "skill").expect("write skill"); + fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).expect("write mcp config"); +} + +fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + fs::create_dir_all(root.join(".agents/plugins")).expect("create marketplace dir"); + 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", + "plugins": [ +{plugins} + ] +}}"# + ), + ) + .expect("write marketplace"); + 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 write_plugins_feature_enabled(codex_home: &Path) { + write_file( + &codex_home.join("config.toml"), + "[features]\nplugins = true\n", + ); +} + +async fn load_config(codex_home: &Path, cwd: &Path) -> Config { + ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .fallback_cwd(Some(cwd.to_path_buf())) + .build() + .await + .expect("config should load") +} + +#[test] +fn disabled_accessible_connectors_render_enable_action() { + let discoverable = build_discoverable_connector_tools( + vec![connector( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + )], + &[AppInfo { + is_accessible: true, + is_enabled: false, + ..connector("connector_68df038e0ba48191908c8434991bbac2", "Gmail") + }], + &[], + &[], + ); + + assert_eq!(discoverable.len(), 1); + let tool = DiscoverableTool::from(discoverable[0].clone()); + assert_eq!(tool.tool_type(), DiscoverableToolType::Connector); + assert_eq!(tool.action(), DiscoverableToolAction::Enable); +} + +#[test] +fn enabled_accessible_connectors_are_excluded() { + let discoverable = build_discoverable_connector_tools( + vec![connector( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + )], + &[AppInfo { + is_accessible: true, + is_enabled: true, + ..connector("connector_68df038e0ba48191908c8434991bbac2", "Gmail") + }], + &[], + &[], + ); + + assert_eq!(discoverable, Vec::::new()); +} + +#[test] +fn plugin_sourced_inaccessible_connectors_are_included() { + let discoverable = build_discoverable_connector_tools( + Vec::new(), + &[], + &[AppConnectorId("connector_plugin_mail".to_string())], + &[PluginCapabilitySummary { + config_name: "gmail".to_string(), + display_name: "Gmail Plugin".to_string(), + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: vec![AppConnectorId("connector_plugin_mail".to_string())], + }], + ); + + assert_eq!(discoverable.len(), 1); + assert_eq!(discoverable[0].id, "connector_plugin_mail"); + assert_eq!( + discoverable[0].plugin_display_names, + vec!["Gmail Plugin".to_string()] + ); +} + +#[test] +fn trusted_plugins_surface_with_install_and_enable_actions() { + let discoverable_tools = build_discoverable_plugin_tools(vec![ + discoverable_plugin("gmail@openai-curated", "Gmail", false, false), + discoverable_plugin("slack@openai-curated", "Slack", true, false), + discoverable_plugin("calendar@openai-curated", "Calendar", true, true), + ]) + .into_iter() + .map(DiscoverableTool::from) + .collect::>(); + + assert_eq!(discoverable_tools.len(), 2); + assert_eq!(discoverable_tools[0].id(), "gmail@openai-curated"); + assert_eq!( + discoverable_tools[0].action(), + DiscoverableToolAction::Install + ); + assert_eq!(discoverable_tools[1].id(), "slack@openai-curated"); + assert_eq!( + discoverable_tools[1].action(), + DiscoverableToolAction::Enable + ); +} + +#[test] +fn non_allowlisted_plugins_stay_hidden() { + let discoverable = build_discoverable_plugin_tools(vec![ + discoverable_plugin("gmail@openai-curated", "Gmail", false, false), + discoverable_plugin("not-trusted@test", "Not Trusted", false, false), + ]); + + assert_eq!(discoverable.len(), 1); + assert_eq!(discoverable[0].id, "gmail@openai-curated"); +} + +#[tokio::test] +async fn plugin_completion_verified_requires_installed_and_enabled_plugin() { + let codex_home = TempDir::new().expect("tempdir"); + let repo_root = TempDir::new().expect("tempdir"); + write_plugins_feature_enabled(codex_home.path()); + write_curated_plugin_sha( + codex_home.path(), + "0123456789abcdef0123456789abcdef01234567", + ); + write_openai_curated_marketplace(repo_root.path(), &["gmail"]); + + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let config = load_config(codex_home.path(), repo_root.path()).await; + assert!(!plugin_completion_verified( + &plugins_manager, + &config, + repo_root.path(), + "gmail@openai-curated", + )); + + plugins_manager + .install_plugin(PluginInstallRequest { + marketplace_path: AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + ) + .expect("absolute marketplace path"), + plugin_name: "gmail".to_string(), + }) + .await + .expect("install plugin"); + + let refreshed_config = load_config(codex_home.path(), repo_root.path()).await; + plugins_manager.clear_cache(); + assert!(plugin_completion_verified( + &plugins_manager, + &refreshed_config, + repo_root.path(), + "gmail@openai-curated", + )); +} diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index 311f191bd0..a3cb27b2ef 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -8,12 +8,16 @@ use codex_app_server_protocol::McpElicitationSchema; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_rmcp_client::ElicitationAction; +use codex_utils_absolute_path::AbsolutePathBuf; use rmcp::model::RequestId; use serde::Deserialize; use serde::Serialize; use serde_json::json; use tracing::warn; +use crate::CodexAuth; +use crate::codex::Session; +use crate::codex::TurnContext; use crate::connectors; use crate::function_tool::FunctionCallError; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -23,6 +27,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_catalog; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; @@ -52,14 +57,26 @@ struct ToolSuggestResult { } #[derive(Debug, Serialize, PartialEq, Eq)] -struct ToolSuggestMeta<'a> { +struct ToolSuggestMeta { 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, + suggest_reason: String, + tool_id: String, + tool_name: String, + #[serde(flatten)] + tool_metadata: ToolSuggestMetaToolMetadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "tool_type", rename_all = "snake_case")] +enum ToolSuggestMetaToolMetadata { + Connector { + install_url: String, + }, + Plugin { + marketplace_path: AbsolutePathBuf, + plugin_name: String, + }, } #[async_trait] @@ -95,17 +112,6 @@ 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(), - )); - } let auth = session.services.auth_manager.auth().await; let manager = session.services.mcp_connection_manager.read().await; @@ -115,37 +121,45 @@ impl ToolHandler for ToolSuggestHandler { connectors::accessible_connectors_from_mcp_tools(&mcp_tools), &turn.config, ); - let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth( + let loaded_plugins = session + .services + .plugins_manager + .plugins_for_config(&turn.config); + let discoverable_tools = discoverable_catalog::load_discoverable_tools( &turn.config, auth.as_ref(), + session.services.plugins_manager.as_ref(), + &loaded_plugins, + turn.cwd.as_path(), &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.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}" )) })?; + if args.tool_type != tool.tool_type() { + return Err(FunctionCallError::RespondToModel(format!( + "tool_type must match the discoverable tool entry for tool_id `{}`", + args.tool_id + ))); + } + if args.action_type != tool.action() { + return Err(FunctionCallError::RespondToModel(format!( + "action_type must match the discoverable tool entry for tool_id `{}`", + args.tool_id + ))); + } let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into()); let params = build_tool_suggestion_elicitation_request( @@ -153,7 +167,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 +177,31 @@ 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, + match &tool { + DiscoverableTool::Connector(connector) => { + refresh_and_verify_connector_suggestion_completed( + session.as_ref(), + turn.as_ref(), auth.as_ref(), - &mcp_tools, - ); - verified_connector_suggestion_completed( args.action_type, connector.id.as_str(), - &accessible_connectors, ) + .await } - Err(err) => { - warn!( - "failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}", - connector.id - ); - false + DiscoverableTool::Plugin(plugin) => { + refresh_and_verify_plugin_suggestion_completed( + session.as_ref(), + turn.as_ref(), + plugin.id.as_str(), + ) + .await } } } else { false }; - if completed { + if completed && let DiscoverableTool::Connector(connector) = &tool { session .merge_connector_selection(HashSet::from([connector.id.clone()])) .await; @@ -204,8 +212,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 +231,19 @@ 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 message = match tool { + DiscoverableTool::Connector(_) => 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() + ), + DiscoverableTool::Plugin(_) => format!( + "{tool_name} could help with this request.\n\n{suggest_reason}\n\nUse Codex to {} it, then confirm here if you finish.", + args.action_type.as_str() + ), + }; McpServerElicitationRequestParams { thread_id, @@ -242,12 +251,9 @@ fn build_tool_suggestion_elicitation_request( 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(), + tool, ))), message, requested_schema: McpElicitationSchema { @@ -260,22 +266,56 @@ fn build_tool_suggestion_elicitation_request( } } -fn build_tool_suggestion_meta<'a>( - tool_type: DiscoverableToolType, +fn build_tool_suggestion_meta( action_type: DiscoverableToolAction, - suggest_reason: &'a str, - tool_id: &'a str, - tool_name: &'a str, - install_url: &'a str, -) -> ToolSuggestMeta<'a> { + suggest_reason: &str, + tool: &DiscoverableTool, +) -> ToolSuggestMeta { ToolSuggestMeta { codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type, suggest_type: action_type, - suggest_reason, - tool_id, - tool_name, - install_url, + suggest_reason: suggest_reason.to_string(), + tool_id: tool.id().to_string(), + tool_name: tool.name().to_string(), + tool_metadata: match tool { + DiscoverableTool::Connector(connector) => ToolSuggestMetaToolMetadata::Connector { + install_url: connector_install_url(connector.as_ref()), + }, + DiscoverableTool::Plugin(plugin) => ToolSuggestMetaToolMetadata::Plugin { + marketplace_path: plugin.marketplace_path.clone(), + plugin_name: plugin.plugin_name.clone(), + }, + }, + } +} + +async fn refresh_and_verify_connector_suggestion_completed( + session: &Session, + turn: &TurnContext, + auth: Option<&CodexAuth>, + action_type: DiscoverableToolAction, + tool_id: &str, +) -> bool { + 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(action_type, tool_id, &accessible_connectors) + } + Err(err) => { + warn!( + "failed to refresh codex apps tools cache after tool suggestion for {tool_id}: {err:#}" + ); + false + } } } @@ -293,6 +333,42 @@ fn verified_connector_suggestion_completed( }) } +async fn refresh_and_verify_plugin_suggestion_completed( + session: &Session, + turn: &TurnContext, + tool_id: &str, +) -> bool { + let auth = session.services.auth_manager.auth().await; + session.reload_user_config_layer().await; + let refreshed_turn = session + .new_default_turn_with_sub_id(turn.sub_id.clone()) + .await; + let refresh_servers = session + .services + .mcp_manager + .effective_servers(&refreshed_turn.config, auth.as_ref()); + session + .refresh_mcp_servers_now( + refreshed_turn.as_ref(), + refresh_servers, + refreshed_turn.config.mcp_oauth_credentials_store_mode, + ) + .await; + discoverable_catalog::plugin_completion_verified( + session.services.plugins_manager.as_ref(), + &refreshed_turn.config, + refreshed_turn.cwd.as_path(), + tool_id, + ) +} + +fn connector_install_url(connector: &AppInfo) -> String { + connector + .install_url + .clone() + .unwrap_or_else(|| connectors::connector_install_url(&connector.name, &connector.id)) +} + #[cfg(test)] #[path = "tool_suggest_tests.rs"] mod tests; 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 a8c4541e91..188849604e 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -1,15 +1,44 @@ -use super::*; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; +use crate::tools::discoverable::DiscoverablePluginInfo; + +fn discoverable_connector(connector: AppInfo) -> DiscoverableTool { + DiscoverableTool::Connector(Box::new(connector)) +} + +fn discoverable_plugin(installed: bool, enabled: bool) -> DiscoverableTool { + DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "gmail@openai-curated".to_string(), + name: "Gmail Plugin".to_string(), + description: Some("Read and search Gmail".to_string()), + has_skills: true, + mcp_server_names: vec!["gmail-mcp".to_string()], + app_connector_ids: vec!["connector_gmail".to_string()], + marketplace_path: AbsolutePathBuf::try_from(PathBuf::from( + "/tmp/marketplaces/openai-curated", + )) + .expect("absolute path"), + plugin_name: "gmail".to_string(), + installed, + enabled, + })) +} #[test] -fn build_tool_suggestion_elicitation_request_uses_expected_shape() { +fn build_tool_suggestion_elicitation_request_uses_expected_shape_for_connector() { 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 { + let connector = discoverable_connector(AppInfo { id: "connector_2128aebfecb84f64a069897515042a44".to_string(), name: "Google Calendar".to_string(), description: Some("Plan events and schedules.".to_string()), @@ -26,7 +55,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,54 +66,152 @@ 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, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Plan and reference events from your calendar".to_string(), + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + tool_name: "Google Calendar".to_string(), + tool_metadata: ToolSuggestMetaToolMetadata::Connector { + install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" + .to_string(), }, + })), + 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() { +fn build_tool_suggestion_elicitation_request_uses_expected_shape_for_plugin() { + let args = ToolSuggestArgs { + tool_type: DiscoverableToolType::Plugin, + action_type: DiscoverableToolAction::Enable, + tool_id: "gmail@openai-curated".to_string(), + suggest_reason: "Search your inbox directly from Codex".to_string(), + }; + let plugin = discoverable_plugin(true, false); + + let request = build_tool_suggestion_elicitation_request( + "thread-1".to_string(), + "turn-1".to_string(), + &args, + "Search your inbox directly from Codex", + &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, + suggest_type: DiscoverableToolAction::Enable, + suggest_reason: "Search your inbox directly from Codex".to_string(), + tool_id: "gmail@openai-curated".to_string(), + tool_name: "Gmail Plugin".to_string(), + tool_metadata: ToolSuggestMetaToolMetadata::Plugin { + marketplace_path: AbsolutePathBuf::try_from(PathBuf::from( + "/tmp/marketplaces/openai-curated", + )) + .expect("absolute path"), + plugin_name: "gmail".to_string(), + }, + })), + message: "Gmail Plugin could help with this request.\n\nSearch your inbox directly from Codex\n\nUse Codex to enable 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_for_connector() { + let connector = discoverable_connector(AppInfo { + id: "connector_68df038e0ba48191908c8434991bbac2".to_string(), + name: "Gmail".to_string(), + description: Some("Search email".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/gmail/connector_68df038e0ba48191908c8434991bbac2".to_string(), + ), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }); 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", + &connector, ); 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", + suggest_reason: "Find and reference emails from your inbox".to_string(), + tool_id: "connector_68df038e0ba48191908c8434991bbac2".to_string(), + tool_name: "Gmail".to_string(), + tool_metadata: ToolSuggestMetaToolMetadata::Connector { + install_url: + "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2" + .to_string(), + }, + } + ); +} + +#[test] +fn build_tool_suggestion_meta_uses_expected_shape_for_plugin() { + let plugin = discoverable_plugin(true, false); + let meta = build_tool_suggestion_meta( + DiscoverableToolAction::Enable, + "Search your inbox directly from Codex", + &plugin, + ); + + assert_eq!( + meta, + ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + suggest_type: DiscoverableToolAction::Enable, + suggest_reason: "Search your inbox directly from Codex".to_string(), + tool_id: "gmail@openai-curated".to_string(), + tool_name: "Gmail Plugin".to_string(), + tool_metadata: ToolSuggestMetaToolMetadata::Plugin { + marketplace_path: AbsolutePathBuf::try_from(PathBuf::from( + "/tmp/marketplaces/openai-curated", + )) + .expect("absolute path"), + plugin_name: "gmail".to_string(), + }, } ); } diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 4e495190ec..18fd1846c9 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -2,6 +2,7 @@ pub mod code_mode; pub(crate) mod code_mode_description; pub mod context; pub(crate) mod discoverable; +pub(crate) mod discoverable_catalog; pub mod events; pub(crate) mod handlers; pub mod js_repl; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 28f522847d..68668a8274 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -15,8 +15,6 @@ use crate::tools::code_mode::wait_tool_description as code_mode_wait_tool_descri 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; @@ -1737,24 +1735,19 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String 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, + let description = match &tool { + DiscoverableTool::Connector(connector) => format_connector_summary( + connector.as_ref(), + connector.plugin_display_names.as_slice(), + ), + DiscoverableTool::Plugin(plugin) => format_plugin_summary(plugin.as_ref()), }; format!( "- {} (id: `{}`, type: {}, action: {}): {}", tool.name(), tool.id(), tool.tool_type().as_str(), - default_action.as_str(), + tool.action().as_str(), description ) }) @@ -1762,6 +1755,33 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String .join("\n") } +fn format_connector_summary( + connector: &codex_app_server_protocol::AppInfo, + plugin_display_names: &[String], +) -> String { + let mut details = Vec::new(); + if let Some(description) = connector + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + { + details.push(description.to_string()); + } + if !plugin_display_names.is_empty() { + details.push(format!( + "available through plugins: {}", + plugin_display_names.join(", ") + )); + } + + if details.is_empty() { + "No description provided.".to_string() + } else { + details.join("; ") + } +} + fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String { let mut details = Vec::new(); if plugin.has_skills { diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 30147ffc90..d95e8ac914 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -7,7 +7,9 @@ use codex_app_server_protocol::AppInfo; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::path::PathBuf; use super::*; @@ -1860,11 +1862,24 @@ fn tool_suggest_description_lists_discoverable_tools() { }); let discoverable_tools = vec![ - discoverable_connector( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - "Plan events and schedules.", - ), + DiscoverableTool::Connector(Box::new(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!["Calendar Plugin".to_string()], + })), discoverable_connector( "connector_68df038e0ba48191908c8434991bbac2", "Gmail", @@ -1877,6 +1892,11 @@ fn tool_suggest_description_lists_discoverable_tools() { has_skills: true, mcp_server_names: vec!["sample-docs".to_string()], app_connector_ids: vec!["connector_sample".to_string()], + marketplace_path: AbsolutePathBuf::try_from(PathBuf::from("/tmp/openai-curated")) + .expect("absolute path"), + plugin_name: "sample".to_string(), + installed: true, + enabled: false, })), ]; @@ -1903,10 +1923,15 @@ 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("available through plugins: Calendar Plugin")); 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("the tool should become available on the next router rebuild or next turn") + ); 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"); diff --git a/codex-rs/core/templates/search_tool/tool_suggest_description.md b/codex-rs/core/templates/search_tool/tool_suggest_description.md index fcf599c397..4918ed4c2c 100644 --- a/codex-rs/core/templates/search_tool/tool_suggest_description.md +++ b/codex-rs/core/templates/search_tool/tool_suggest_description.md @@ -21,5 +21,5 @@ Workflow: - `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 finished the install or enable flow, continue by searching again; the tool should become available on the next router rebuild or next turn - 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/tui/src/app.rs b/codex-rs/tui/src/app.rs index 87b2dc795c..c2f4ccd3ba 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -55,6 +55,7 @@ use codex_core::models_manager::collaboration_mode_presets::CollaborationModesCo use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::plugins::PluginInstallRequest; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_otel::SessionTelemetry; @@ -779,6 +780,81 @@ impl App { Ok(()) } + async fn finish_plugin_suggestion_update(&mut self, action: &str) { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, action, "failed to refresh config after plugin update"); + } + self.chat_widget.submit_op(Op::ReloadUserConfig); + self.chat_widget.refresh_connectors(true); + } + + async fn install_suggested_plugin( + &mut self, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + ) { + let enable_plugins_feature = ConfigEditsBuilder::new(&self.config.codex_home) + .set_feature_enabled(Feature::Plugins.key(), true) + .apply() + .await; + if let Err(err) = enable_plugins_feature { + self.chat_widget.add_error_message(format!( + "Failed to enable plugins feature for {plugin_name}: {err}" + )); + return; + } + if let Err(err) = self.config.features.set_enabled(Feature::Plugins, true) { + tracing::warn!(error = %err, "failed to update in-memory plugins feature state"); + } + self.chat_widget.set_feature_enabled(Feature::Plugins, true); + + match self + .server + .plugins_manager() + .install_plugin(PluginInstallRequest { + marketplace_path, + plugin_name: plugin_name.clone(), + }) + .await + { + Ok(_) => { + self.finish_plugin_suggestion_update("plugin install").await; + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to install plugin {plugin_name}: {err}")); + } + } + } + + async fn enable_suggested_plugin(&mut self, plugin_id: String) { + match ConfigEditsBuilder::new(&self.config.codex_home) + .set_feature_enabled(Feature::Plugins.key(), true) + .with_edits([ConfigEdit::SetPath { + segments: vec![ + "plugins".to_string(), + plugin_id.clone(), + "enabled".to_string(), + ], + value: true.into(), + }]) + .apply() + .await + { + Ok(()) => { + if let Err(err) = self.config.features.set_enabled(Feature::Plugins, true) { + tracing::warn!(error = %err, "failed to update in-memory plugins feature state"); + } + self.chat_widget.set_feature_enabled(Feature::Plugins, true); + self.finish_plugin_suggestion_update("plugin enable").await; + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to enable plugin {plugin_id}: {err}")); + } + } + } + async fn refresh_in_memory_config_from_disk_best_effort(&mut self, action: &str) { if let Err(err) = self.refresh_in_memory_config_from_disk().await { tracing::warn!( @@ -3254,6 +3330,16 @@ impl App { } } } + AppEvent::InstallSuggestedPlugin { + marketplace_path, + plugin_name, + } => { + self.install_suggested_plugin(marketplace_path, plugin_name) + .await; + } + AppEvent::EnableSuggestedPlugin { plugin_id } => { + self.enable_suggested_plugin(plugin_id).await; + } AppEvent::OpenPermissionsPopup => { self.chat_widget.open_permissions_popup(); } @@ -3906,14 +3992,17 @@ mod tests { use codex_protocol::protocol::UserMessageEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; + use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; + use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::tempdir; + use tokio::sync::mpsc::UnboundedReceiver; use tokio::time; #[test] @@ -5150,6 +5239,94 @@ mod tests { Ok(()) } + #[tokio::test] + async fn install_suggested_plugin_updates_config_and_reloads_user_config() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + set_test_app_home(&mut app, codex_home.path().to_path_buf()).await?; + let repo_root = codex_home.path().join("repo"); + let marketplace_path = write_test_plugin_marketplace(&repo_root, "debug", "sample-plugin"); + + app.install_suggested_plugin(marketplace_path, "sample-plugin".to_string()) + .await; + + assert!(app.config.features.enabled(Feature::Plugins)); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains("plugins = true")); + assert!( + config.contains(r#"[plugins."sample-plugin@debug"]"#), + "unexpected config: {config}" + ); + assert!(config.contains("enabled = true")); + expect_reload_user_config_op(&mut op_rx); + assert!(drain_history_messages(&mut app_event_rx).is_empty()); + Ok(()) + } + + #[tokio::test] + async fn install_suggested_plugin_reports_errors_without_reloading_user_config() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + set_test_app_home(&mut app, codex_home.path().to_path_buf()).await?; + let repo_root = codex_home.path().join("repo"); + let marketplace_path = write_test_plugin_marketplace(&repo_root, "debug", "other-plugin"); + + app.install_suggested_plugin(marketplace_path, "missing-plugin".to_string()) + .await; + + let history = drain_history_messages(&mut app_event_rx).join("\n"); + assert!(history.contains("Failed to install plugin missing-plugin")); + assert_no_reload_user_config_op(&mut op_rx); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains("plugins = true")); + assert!(!config.contains(r#"[plugins."missing-plugin@debug"]"#)); + Ok(()) + } + + #[tokio::test] + async fn enable_suggested_plugin_updates_config_and_reloads_user_config() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + set_test_app_home(&mut app, codex_home.path().to_path_buf()).await?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = false + +[plugins."sample-plugin@debug"] +enabled = false +"#, + )?; + + app.enable_suggested_plugin("sample-plugin@debug".to_string()) + .await; + + assert!(app.config.features.enabled(Feature::Plugins)); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains("plugins = true")); + assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); + assert!(config.contains("enabled = true")); + expect_reload_user_config_op(&mut op_rx); + assert!(drain_history_messages(&mut app_event_rx).is_empty()); + Ok(()) + } + + #[tokio::test] + async fn enable_suggested_plugin_reports_errors_without_reloading_user_config() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + set_test_app_home(&mut app, codex_home.path().to_path_buf()).await?; + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + + app.enable_suggested_plugin("sample-plugin@debug".to_string()) + .await; + + let history = drain_history_messages(&mut app_event_rx).join("\n"); + assert!(history.contains("Failed to enable plugin sample-plugin@debug")); + assert_no_reload_user_config_op(&mut op_rx); + Ok(()) + } + #[tokio::test] async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> { @@ -5686,6 +5863,21 @@ mod tests { ) } + async fn set_test_app_home(app: &mut App, codex_home: PathBuf) -> Result<()> { + app.config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .build() + .await?; + app.server = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::from_api_key("Test API Key"), + app.config.model_provider.clone(), + codex_home, + ), + ); + Ok(()) + } + fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { let mut seen = Vec::new(); while let Ok(op) = op_rx.try_recv() { @@ -5697,6 +5889,85 @@ mod tests { panic!("expected UserTurn op, saw: {seen:?}"); } + fn expect_reload_user_config_op(op_rx: &mut UnboundedReceiver) { + let mut seen = Vec::new(); + while let Ok(op) = op_rx.try_recv() { + if matches!(op, Op::ReloadUserConfig) { + return; + } + seen.push(format!("{op:?}")); + } + panic!("expected ReloadUserConfig op, saw: {seen:?}"); + } + + fn assert_no_reload_user_config_op(op_rx: &mut UnboundedReceiver) { + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::ReloadUserConfig), + "unexpected ReloadUserConfig op: {op:?}" + ); + } + } + + fn drain_history_messages(app_event_rx: &mut UnboundedReceiver) -> Vec { + let mut out = Vec::new(); + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + out.push( + cell.display_lines(120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ); + } + } + out + } + + fn write_test_plugin_marketplace( + repo_root: &Path, + marketplace_name: &str, + plugin_name: &str, + ) -> AbsolutePathBuf { + let plugin_root = repo_root.join(plugin_name); + std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("plugin metadata dir"); + std::fs::create_dir_all(plugin_root.join("skills")).expect("plugin skills dir"); + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .expect("plugin manifest"); + std::fs::write(plugin_root.join("skills/SKILL.md"), "skill").expect("skill file"); + std::fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#) + .expect("mcp manifest"); + + let marketplace_path = repo_root.join(".agents/plugins/marketplace.json"); + let parent = marketplace_path.parent().expect("marketplace parent"); + std::fs::create_dir_all(parent).expect("marketplace dir"); + std::fs::write( + &marketplace_path, + format!( + r#"{{ + "name": "{marketplace_name}", + "plugins": [ + {{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./{plugin_name}" + }}, + "authPolicy": "ON_USE" + }} + ] +}}"# + ), + ) + .expect("marketplace file"); + + AbsolutePathBuf::try_from(marketplace_path).expect("absolute marketplace path") + } + fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { let model_info = codex_core::test_support::construct_model_info_offline(model, config); SessionTelemetry::new( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9f9a8d6de1..2b80a99301 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -16,6 +16,7 @@ use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -375,6 +376,17 @@ pub(crate) enum AppEvent { enabled: bool, }, + /// Install a suggested plugin from the given marketplace. + InstallSuggestedPlugin { + marketplace_path: AbsolutePathBuf, + plugin_name: String, + }, + + /// Enable a suggested plugin by plugin ID. + EnableSuggestedPlugin { + plugin_id: String, + }, + /// Notify that the manage skills popup was closed. ManageSkillsClosed, 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 975b8701d2..71198211dc 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -12,6 +12,7 @@ use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::RequestId as McpRequestId; use codex_protocol::protocol::Op; use codex_protocol::user_input::TextElement; +use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -70,6 +71,8 @@ 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"; +const TOOL_SUGGEST_MARKETPLACE_PATH_KEY: &str = "marketplace_path"; +const TOOL_SUGGEST_PLUGIN_NAME_KEY: &str = "plugin_name"; #[derive(Clone, PartialEq, Default)] struct ComposerDraft { @@ -130,26 +133,30 @@ 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) enum ToolSuggestionPayload { + Connector { + install_url: String, + }, + Plugin { + marketplace_path: AbsolutePathBuf, + plugin_name: String, + }, +} + #[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, + pub(crate) payload: ToolSuggestionPayload, } #[derive(Clone, Debug, PartialEq)] @@ -347,11 +354,6 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option ToolSuggestionToolType::Connector, - Some("plugin") => ToolSuggestionToolType::Plugin, - _ => return None, - }; let suggest_type = match meta .get(TOOL_SUGGEST_SUGGEST_TYPE_KEY) .and_then(Value::as_str) @@ -361,8 +363,28 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option return None, }; + let payload = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) { + Some("connector") => ToolSuggestionPayload::Connector { + install_url: meta + .get(TOOL_SUGGEST_INSTALL_URL_KEY) + .and_then(Value::as_str)? + .to_string(), + }, + Some("plugin") => ToolSuggestionPayload::Plugin { + marketplace_path: AbsolutePathBuf::try_from(PathBuf::from( + meta.get(TOOL_SUGGEST_MARKETPLACE_PATH_KEY) + .and_then(Value::as_str)?, + )) + .ok()?, + plugin_name: meta + .get(TOOL_SUGGEST_PLUGIN_NAME_KEY) + .and_then(Value::as_str)? + .to_string(), + }, + _ => return None, + }; + Some(ToolSuggestionRequest { - tool_type, suggest_type, suggest_reason: meta .get(TOOL_SUGGEST_REASON_KEY) @@ -370,10 +392,7 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option { - 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)); + match &tool_suggestion.payload { + mcp_server_elicitation::ToolSuggestionPayload::Connector { install_url } => { + let elicitation_target = Some(AppLinkElicitationTarget { + thread_id: request.thread_id(), + server_name: request.server_name().to_string(), + request_id: request.request_id().clone(), + }); + 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: install_url.clone(), + is_installed, + is_enabled: false, + suggest_reason: Some(tool_suggestion.suggest_reason.clone()), + suggestion_type: Some(suggestion_type), + elicitation_target, + }, + self.app_event_tx.clone(), + ); + self.push_view(Box::new(view)); + } + mcp_server_elicitation::ToolSuggestionPayload::Plugin { + marketplace_path, + plugin_name, + } => { + let elicitation_target = PluginSuggestionElicitationTarget { + thread_id: request.thread_id(), + server_name: request.server_name().to_string(), + request_id: request.request_id().clone(), + }; + let view = PluginSuggestionView::new( + PluginSuggestionViewParams { + plugin_id: tool_suggestion.tool_id.clone(), + title: tool_suggestion.tool_name.clone(), + plugin_name: plugin_name.clone(), + marketplace_path: marketplace_path.clone(), + suggest_reason: tool_suggestion.suggest_reason.clone(), + suggest_type: tool_suggestion.suggest_type, + elicitation_target, + }, + self.app_event_tx.clone(), + ); + self.push_view(Box::new(view)); + } + } return; } diff --git a/codex-rs/tui/src/bottom_pane/plugin_suggestion_view.rs b/codex-rs/tui/src/bottom_pane/plugin_suggestion_view.rs new file mode 100644 index 0000000000..3783dd58cb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/plugin_suggestion_view.rs @@ -0,0 +1,604 @@ +use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::mcp::RequestId as McpRequestId; +use codex_protocol::protocol::Op; +use codex_utils_absolute_path::AbsolutePathBuf; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; +use textwrap::wrap; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::mcp_server_elicitation::ToolSuggestionType; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::style::user_message_style; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PluginSuggestionElicitationTarget { + pub(crate) thread_id: ThreadId, + pub(crate) server_name: String, + pub(crate) request_id: McpRequestId, +} + +pub(crate) struct PluginSuggestionViewParams { + pub(crate) plugin_id: String, + pub(crate) title: String, + pub(crate) plugin_name: String, + pub(crate) marketplace_path: AbsolutePathBuf, + pub(crate) suggest_reason: String, + pub(crate) suggest_type: ToolSuggestionType, + pub(crate) elicitation_target: PluginSuggestionElicitationTarget, +} + +pub(crate) struct PluginSuggestionView { + plugin_id: String, + title: String, + plugin_name: String, + marketplace_path: AbsolutePathBuf, + suggest_reason: String, + suggest_type: ToolSuggestionType, + elicitation_target: PluginSuggestionElicitationTarget, + app_event_tx: AppEventSender, + selected_action: usize, + complete: bool, +} + +impl PluginSuggestionView { + pub(crate) fn new(params: PluginSuggestionViewParams, app_event_tx: AppEventSender) -> Self { + let PluginSuggestionViewParams { + plugin_id, + title, + plugin_name, + marketplace_path, + suggest_reason, + suggest_type, + elicitation_target, + } = params; + Self { + plugin_id, + title, + plugin_name, + marketplace_path, + suggest_reason, + suggest_type, + elicitation_target, + app_event_tx, + selected_action: 0, + complete: false, + } + } + + fn action_labels(&self) -> [&'static str; 2] { + match self.suggest_type { + ToolSuggestionType::Install => ["Install plugin", "Back"], + ToolSuggestionType::Enable => ["Enable plugin", "Back"], + } + } + + fn instructions(&self) -> &'static str { + match self.suggest_type { + ToolSuggestionType::Install => { + "Install this plugin in Codex to make its tools available." + } + ToolSuggestionType::Enable => { + "Enable this plugin in Codex to use it for the current request." + } + } + } + + fn move_selection_prev(&mut self) { + self.selected_action = self.selected_action.saturating_sub(1); + } + + fn move_selection_next(&mut self) { + self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1); + } + + fn resolve_elicitation(&self, decision: ElicitationAction) { + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: self.elicitation_target.thread_id, + op: Op::ResolveElicitation { + server_name: self.elicitation_target.server_name.clone(), + request_id: self.elicitation_target.request_id.clone(), + decision, + content: None, + meta: None, + }, + }); + } + + fn decline(&mut self) { + self.resolve_elicitation(ElicitationAction::Decline); + self.complete = true; + } + + fn submit_action(&mut self) { + match self.suggest_type { + ToolSuggestionType::Install => { + self.app_event_tx.send(AppEvent::InstallSuggestedPlugin { + marketplace_path: self.marketplace_path.clone(), + plugin_name: self.plugin_name.clone(), + }) + } + ToolSuggestionType::Enable => self.app_event_tx.send(AppEvent::EnableSuggestedPlugin { + plugin_id: self.plugin_id.clone(), + }), + } + self.resolve_elicitation(ElicitationAction::Accept); + self.complete = true; + } + + fn activate_selected_action(&mut self) { + match self.selected_action { + 0 => self.submit_action(), + 1 => self.decline(), + _ => {} + } + } + + fn content_lines(&self, width: u16) -> Vec> { + let usable_width = width.max(1) as usize; + let mut lines = vec![Line::from(self.title.clone().bold()), Line::from("")]; + + for line in wrap(self.suggest_reason.trim(), usable_width) { + lines.push(Line::from(line.into_owned().italic())); + } + lines.push(Line::from("")); + + for line in wrap(self.instructions(), usable_width) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Changes take effect after the next router rebuild or next turn.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + lines.push(Line::from(vec![ + "Plugin ID: ".dim(), + self.plugin_id.clone().into(), + ])); + + lines + } + + fn action_rows(&self) -> Vec { + self.action_labels() + .into_iter() + .enumerate() + .map(|(index, label)| { + let prefix = if self.selected_action == index { + '›' + } else { + ' ' + }; + GenericDisplayRow { + name: format!("{prefix} {}. {label}", index + 1), + ..Default::default() + } + }) + .collect() + } + + fn action_state(&self) -> ScrollState { + let mut state = ScrollState::new(); + state.selected_idx = Some(self.selected_action); + state + } + + fn action_rows_height(&self, width: u16) -> u16 { + let rows = self.action_rows(); + let state = self.action_state(); + measure_rows_height(&rows, &state, rows.len().max(1), width.max(1)) + } + + fn hint_line(&self) -> Line<'static> { + Line::from(vec![ + "Use ".into(), + key_hint::plain(KeyCode::Tab).into(), + " / ".into(), + key_hint::plain(KeyCode::Up).into(), + " ".into(), + key_hint::plain(KeyCode::Down).into(), + " to move, ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to select, ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close".into(), + ]) + } +} + +impl BottomPaneView for PluginSuggestionView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Left, + .. + } + | KeyEvent { + code: KeyCode::BackTab, + .. + } + | KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_selection_prev(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Right, + .. + } + | KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_selection_next(), + KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE, + .. + } => { + if let Some(index) = c + .to_digit(10) + .and_then(|digit| digit.checked_sub(1)) + .map(|index| index as usize) + && index < self.action_labels().len() + { + self.selected_action = index; + self.activate_selected_action(); + } + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.activate_selected_action(), + _ => {} + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.decline(); + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } +} + +impl crate::render::renderable::Renderable for PluginSuggestionView { + fn desired_height(&self, width: u16) -> u16 { + let content_width = width.saturating_sub(4).max(1); + let content_rows = Paragraph::new(self.content_lines(content_width)) + .wrap(Wrap { trim: false }) + .line_count(content_width) + .max(1) as u16; + let action_rows_height = self.action_rows_height(content_width); + content_rows + action_rows_height + 3 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + Block::default() + .style(user_message_style()) + .render(area, buf); + + let actions_height = self.action_rows_height(area.width.saturating_sub(4)); + let [content_area, actions_area, hint_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(actions_height), + Constraint::Length(1), + ]) + .areas(area); + + let inner = content_area.inset(Insets::vh(1, 2)); + let content_width = inner.width.max(1); + Paragraph::new(self.content_lines(content_width)) + .wrap(Wrap { trim: false }) + .render(inner, buf); + + if actions_area.height > 0 { + let actions_area = Rect { + x: actions_area.x.saturating_add(2), + y: actions_area.y, + width: actions_area.width.saturating_sub(2), + height: actions_area.height, + }; + let action_rows = self.action_rows(); + let action_state = self.action_state(); + render_rows( + actions_area, + buf, + &action_rows, + &action_state, + action_rows.len().max(1), + "No actions", + ); + } + + if hint_area.height > 0 { + let hint_area = Rect { + x: hint_area.x.saturating_add(2), + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + self.hint_line().dim().render(hint_area, buf); + } + } +} + +#[cfg(test)] +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() -> PluginSuggestionElicitationTarget { + PluginSuggestionElicitationTarget { + 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: &PluginSuggestionView, 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") + } + + fn marketplace_path() -> AbsolutePathBuf { + AbsolutePathBuf::try_from("/tmp/marketplaces/openai-curated").expect("absolute path") + } + + #[test] + fn install_plugin_suggestion_sends_install_event_and_accepts() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = PluginSuggestionView::new( + PluginSuggestionViewParams { + plugin_id: "gmail@openai-curated".to_string(), + title: "Gmail Plugin".to_string(), + plugin_name: "gmail".to_string(), + marketplace_path: marketplace_path(), + suggest_reason: "Search your inbox directly from Codex".to_string(), + suggest_type: ToolSuggestionType::Install, + elicitation_target: suggestion_target(), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::InstallSuggestedPlugin { + marketplace_path: installed_marketplace_path, + plugin_name, + }) => { + assert_eq!(installed_marketplace_path, marketplace_path()); + assert_eq!(plugin_name, "gmail"); + } + 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 enable_plugin_suggestion_sends_enable_event_and_accepts() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = PluginSuggestionView::new( + PluginSuggestionViewParams { + plugin_id: "gmail@openai-curated".to_string(), + title: "Gmail Plugin".to_string(), + plugin_name: "gmail".to_string(), + marketplace_path: marketplace_path(), + suggest_reason: "Search your inbox directly from Codex".to_string(), + suggest_type: ToolSuggestionType::Enable, + elicitation_target: suggestion_target(), + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::EnableSuggestedPlugin { plugin_id }) => { + assert_eq!(plugin_id, "gmail@openai-curated"); + } + 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_plugin_suggestion_resolves_decline() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = PluginSuggestionView::new( + PluginSuggestionViewParams { + plugin_id: "gmail@openai-curated".to_string(), + title: "Gmail Plugin".to_string(), + plugin_name: "gmail".to_string(), + marketplace_path: marketplace_path(), + suggest_reason: "Search your inbox directly from Codex".to_string(), + suggest_type: ToolSuggestionType::Install, + elicitation_target: 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 install_plugin_suggestion_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = PluginSuggestionView::new( + PluginSuggestionViewParams { + plugin_id: "gmail@openai-curated".to_string(), + title: "Gmail Plugin".to_string(), + plugin_name: "gmail".to_string(), + marketplace_path: marketplace_path(), + suggest_reason: "Search your inbox directly from Codex".to_string(), + suggest_type: ToolSuggestionType::Install, + elicitation_target: suggestion_target(), + }, + tx, + ); + + assert_snapshot!( + "plugin_suggestion_view_install", + render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))) + ); + } + + #[test] + fn enable_plugin_suggestion_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = PluginSuggestionView::new( + PluginSuggestionViewParams { + plugin_id: "gmail@openai-curated".to_string(), + title: "Gmail Plugin".to_string(), + plugin_name: "gmail".to_string(), + marketplace_path: marketplace_path(), + suggest_reason: "Search your inbox directly from Codex".to_string(), + suggest_type: ToolSuggestionType::Enable, + elicitation_target: suggestion_target(), + }, + tx, + ); + + assert_snapshot!( + "plugin_suggestion_view_enable", + render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72))) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__plugin_suggestion_view__tests__plugin_suggestion_view_enable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__plugin_suggestion_view__tests__plugin_suggestion_view_enable.snap new file mode 100644 index 0000000000..c56fd3fe49 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__plugin_suggestion_view__tests__plugin_suggestion_view_enable.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/plugin_suggestion_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Gmail Plugin + + Search your inbox directly from Codex + + Enable this plugin in Codex to use it for the current request. + Changes take effect after the next router rebuild or next turn. + + Plugin ID: gmail@openai-curated + + › 1. Enable plugin + 2. 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__plugin_suggestion_view__tests__plugin_suggestion_view_install.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__plugin_suggestion_view__tests__plugin_suggestion_view_install.snap new file mode 100644 index 0000000000..03c7033769 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__plugin_suggestion_view__tests__plugin_suggestion_view_install.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/plugin_suggestion_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Gmail Plugin + + Search your inbox directly from Codex + + Install this plugin in Codex to make its tools available. + Changes take effect after the next router rebuild or next turn. + + Plugin ID: gmail@openai-curated + + › 1. Install plugin + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close