diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b6d1932315..79f191ee66 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -201,6 +201,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e9c7921890..439023b071 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7991,6 +7991,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 2aac99cb3a..5573321e77 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -404,6 +404,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json index 0813ed6f56..d4e99f5086 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json @@ -119,6 +119,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json index 4697b34e12..2fb9092cb0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json @@ -119,6 +119,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts index 0c9a13b124..5655213718 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts @@ -16,4 +16,4 @@ export type AppInfo = { id: string, name: string, description: string | null, lo * enabled = false * ``` */ -isEnabled: boolean, }; +isEnabled: boolean, pluginDisplayNames: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 40c9b31961..ce2e6b27ea 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1709,6 +1709,8 @@ pub struct AppInfo { /// ``` #[serde(default = "default_enabled")] pub is_enabled: bool, + #[serde(default)] + pub plugin_display_names: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 72655b5afb..638a020a8c 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -97,6 +97,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = @@ -199,6 +200,7 @@ async fn list_apps_reports_is_enabled_from_config() -> Result<()> { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = @@ -308,6 +310,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "beta".to_string(), @@ -322,6 +325,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -370,6 +374,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }]; let first_update = read_app_list_updated_notification(&mut mcp).await?; @@ -389,6 +394,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "alpha".to_string(), @@ -403,6 +409,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -443,6 +450,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "beta".to_string(), @@ -457,6 +465,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -516,6 +525,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "alpha".to_string(), @@ -530,6 +540,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -564,6 +575,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let (server_url, server_handle) = start_apps_server_with_delays( connectors.clone(), @@ -619,6 +631,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> { install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let update = read_app_list_updated_notification(&mut mcp).await?; @@ -653,6 +666,7 @@ async fn list_apps_paginates_results() -> Result<()> { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "beta".to_string(), @@ -667,6 +681,7 @@ async fn list_apps_paginates_results() -> Result<()> { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -724,6 +739,7 @@ async fn list_apps_paginates_results() -> Result<()> { install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }]; assert_eq!(first_page, expected_first); @@ -767,6 +783,7 @@ async fn list_apps_paginates_results() -> Result<()> { install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; assert_eq!(second_page, expected_second); @@ -791,6 +808,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = @@ -895,6 +913,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "beta".to_string(), @@ -909,6 +928,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; let initial_tools = vec![connector_tool("beta", "Beta App")?]; @@ -958,6 +978,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }] ); @@ -978,6 +999,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "alpha".to_string(), @@ -992,6 +1014,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ] ); @@ -1021,6 +1044,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]); server_control.set_tools(Vec::new()); @@ -1050,6 +1074,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "alpha".to_string(), @@ -1064,6 +1089,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ] ); @@ -1091,6 +1117,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let second_update = read_app_list_updated_notification(&mut mcp).await?; assert_eq!(second_update.data, expected_final); diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 81c382f5d6..2dfe6671ae 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -446,6 +446,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), } } @@ -483,6 +484,7 @@ mod tests { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), } } @@ -540,6 +542,7 @@ mod tests { install_url: Some(connector_install_url(id, id)), is_accessible, is_enabled: true, + plugin_display_names: Vec::new(), } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f7f90eeaa8..5c6eeb876a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -203,9 +203,11 @@ use crate::memories; use crate::mentions::build_connector_slug_counts; use crate::mentions::build_skill_name_counts; use crate::mentions::collect_explicit_app_ids; +use crate::mentions::collect_explicit_plugin_mentions; use crate::mentions::collect_tool_mentions_from_messages; use crate::network_policy_decision::execpolicy_network_rule_amendment; use crate::plugins::PluginsManager; +use crate::plugins::build_plugin_injections; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageContentDeltaEvent; use crate::protocol::AgentReasoningSectionBreakEvent; @@ -512,7 +514,7 @@ impl Codex { session_source_clone, skills_manager, plugins_manager, - mcp_manager, + mcp_manager.clone(), file_watcher, agent_control, ) @@ -1530,8 +1532,8 @@ impl Session { tool_approvals: Mutex::new(ApprovalStore::default()), execve_session_approvals: RwLock::new(HashMap::new()), skills_manager, - plugins_manager, - mcp_manager, + plugins_manager: Arc::clone(&plugins_manager), + mcp_manager: Arc::clone(&mcp_manager), file_watcher, agent_control, network_proxy, @@ -1615,6 +1617,7 @@ impl Session { .map(|(name, _)| name.clone()) .collect(); required_mcp_servers.sort(); + let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref()); { let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await; cancel_guard.cancel(); @@ -1629,6 +1632,7 @@ impl Session { sandbox_state, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + tool_plugin_provenance, ) .await; { @@ -3574,6 +3578,10 @@ impl Session { ) { let auth = self.services.auth_manager.auth().await; let config = self.get_config().await; + let tool_plugin_provenance = self + .services + .mcp_manager + .tool_plugin_provenance(config.as_ref()); let mcp_servers = with_codex_apps_mcp( mcp_servers, self.features.enabled(Feature::Apps), @@ -3601,6 +3609,7 @@ impl Session { sandbox_state, config.codex_home.clone(), codex_apps_tools_cache_key(auth.as_ref()), + tool_plugin_provenance, ) .await; { @@ -4942,25 +4951,38 @@ pub(crate) async fn run_turn( sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) .await; - let available_connectors = if turn_context.config.features.enabled(Feature::Apps) { - let mcp_tools = match sess - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .or_cancel(&cancellation_token) - .await - { - Ok(mcp_tools) => mcp_tools, - Err(_) => return None, + let loaded_plugins = sess + .services + .plugins_manager + .plugins_for_config(&turn_context.config); + // Plain-text @plugin mentions are resolved from the current session's + // enabled plugins, then converted into turn-scoped guidance below. + let mentioned_plugins = + collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries()); + let mcp_tools = + if turn_context.config.features.enabled(Feature::Apps) || !mentioned_plugins.is_empty() { + // Plugin mentions need raw MCP/app inventory even when app tools + // are normally hidden so we can describe the plugin's currently + // usable capabilities for this turn. + match sess + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .or_cancel(&cancellation_token) + .await + { + Ok(mcp_tools) => mcp_tools, + Err(_) if turn_context.config.features.enabled(Feature::Apps) => return None, + Err(_) => HashMap::new(), + } + } else { + HashMap::new() }; - let plugin_apps = sess - .services - .plugins_manager - .plugins_for_config(&turn_context.config); + let available_connectors = if turn_context.config.features.enabled(Feature::Apps) { let connectors = connectors::merge_plugin_apps_with_accessible( - plugin_apps.effective_apps(), + loaded_plugins.effective_apps(), connectors::accessible_connectors_from_mcp_tools(&mcp_tools), ); connectors::with_app_enabled_state(connectors, &turn_context.config) @@ -5021,12 +5043,29 @@ pub(crate) async fn run_turn( .await; } + let plugin_items = + build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); + let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input); explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items( &skill_items, &available_connectors, &skill_name_counts_lower, )); + // Explicit @plugin mentions can make a plugin's enabled apps callable for + // this turn without persisting those connectors as sticky user selections. + let mut turn_enabled_connectors = explicitly_enabled_connectors.clone(); + turn_enabled_connectors.extend( + mentioned_plugins + .iter() + .flat_map(|plugin| plugin.app_connector_ids.iter()) + .map(|connector_id| connector_id.0.clone()) + .filter(|connector_id| { + available_connectors + .iter() + .any(|connector| connector.is_enabled && connector.id == *connector_id) + }), + ); let connector_names_by_id = available_connectors .iter() .map(|connector| (connector.id.as_str(), connector.name.as_str())) @@ -5064,6 +5103,10 @@ pub(crate) async fn run_turn( sess.record_conversation_items(&turn_context, &skill_items) .await; } + if !plugin_items.is_empty() { + sess.record_conversation_items(&turn_context, &plugin_items) + .await; + } sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -5132,7 +5175,7 @@ pub(crate) async fn run_turn( &mut client_session, turn_metadata_header.as_deref(), sampling_request_input, - &explicitly_enabled_connectors, + &turn_enabled_connectors, skills_outcome, &mut server_model_warning_emitted_for_turn, cancellation_token.child_token(), @@ -5678,17 +5721,17 @@ async fn built_tools( .or_cancel(cancellation_token) .await?; drop(mcp_connection_manager); + let loaded_plugins = sess + .services + .plugins_manager + .plugins_for_config(&turn_context.config); let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone(); effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await); let connectors = if turn_context.features.enabled(Feature::Apps) { - let plugin_apps = sess - .services - .plugins_manager - .plugins_for_config(&turn_context.config); let connectors = connectors::merge_plugin_apps_with_accessible( - plugin_apps.effective_apps(), + loaded_plugins.effective_apps(), connectors::accessible_connectors_from_mcp_tools(&mcp_tools), ); Some(connectors::with_app_enabled_state( @@ -5699,6 +5742,8 @@ async fn built_tools( None }; + // Keep the connector-grouped app view around for the router even though + // app tools only become prompt-visible after explicit selection/discovery. let app_tools = connectors.as_ref().map(|connectors| { filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config) }); @@ -6779,6 +6824,7 @@ mod tests { install_url: None, is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), } } @@ -6866,6 +6912,7 @@ mod tests { }, connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), + plugin_display_names: Vec::new(), } } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 3ad48fa008..d75b59aa14 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -1,7 +1,9 @@ +use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::env; use std::path::PathBuf; +use std::sync::Arc; use std::sync::LazyLock; use std::sync::Mutex as StdMutex; use std::time::Duration; @@ -26,11 +28,14 @@ use crate::default_client::is_first_party_chat_originator; use crate::default_client::originator; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::McpManager; +use crate::mcp::ToolPluginProvenance; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; 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::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); @@ -123,9 +128,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( let auth_manager = auth_manager_from_config(config); let auth = auth_manager.auth().await; let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); + let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); + let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config); if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key) { let cached_connectors = filter_disallowed_connectors(cached_connectors); + let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance); return Ok(AccessibleConnectorsStatus { connectors: cached_connectors, codex_apps_ready: true, @@ -162,6 +170,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( sandbox_state, config.codex_home.clone(), codex_apps_tools_cache_key(auth.as_ref()), + ToolPluginProvenance::default(), ) .await; @@ -210,6 +219,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( if codex_apps_ready || !accessible_connectors.is_empty() { write_cached_accessible_connectors(cache_key, &accessible_connectors); } + let accessible_connectors = + with_app_plugin_sources(accessible_connectors, &tool_plugin_provenance); Ok(AccessibleConnectorsStatus { connectors: accessible_connectors, codex_apps_ready, @@ -291,13 +302,19 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String { pub(crate) fn accessible_connectors_from_mcp_tools( mcp_tools: &HashMap, ) -> Vec { + // ToolInfo already carries plugin provenance, so app-level plugin sources + // can be derived here instead of requiring a separate enrichment pass. let tools = mcp_tools.values().filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } let connector_id = tool.connector_id.as_deref()?; let connector_name = normalize_connector_value(tool.connector_name.as_deref()); - Some((connector_id.to_string(), connector_name)) + Some(( + connector_id.to_string(), + connector_name, + tool.plugin_display_names.clone(), + )) }); collect_accessible_connectors(tools) } @@ -334,6 +351,9 @@ pub fn merge_connectors( if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() { existing.distribution_channel = connector.distribution_channel; } + existing + .plugin_display_names + .extend(connector.plugin_display_names); } else { merged.insert(connector_id, connector); } @@ -344,6 +364,8 @@ pub fn merge_connectors( if connector.install_url.is_none() { connector.install_url = Some(connector_install_url(&connector.name, &connector.id)); } + connector.plugin_display_names.sort_unstable(); + connector.plugin_display_names.dedup(); } merged.sort_by(|left, right| { right @@ -407,6 +429,18 @@ pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> connectors } +pub fn with_app_plugin_sources( + mut connectors: Vec, + tool_plugin_provenance: &ToolPluginProvenance, +) -> Vec { + for connector in &mut connectors { + connector.plugin_display_names = tool_plugin_provenance + .plugin_display_names_for_connector_id(connector.id.as_str()) + .to_vec(); + } + connectors +} + pub(crate) fn app_tool_policy( config: &Config, connector_id: Option<&str>, @@ -579,35 +613,49 @@ fn app_tool_policy_from_apps_config( fn collect_accessible_connectors(tools: I) -> Vec where - I: IntoIterator)>, + I: IntoIterator, Vec)>, { - let mut connectors: HashMap = HashMap::new(); - for (connector_id, connector_name) in tools { + let mut connectors: HashMap)> = HashMap::new(); + for (connector_id, connector_name, plugin_display_names) in tools { let connector_name = connector_name.unwrap_or_else(|| connector_id.clone()); - if let Some(existing_name) = connectors.get_mut(&connector_id) { + if let Some((existing_name, existing_plugin_display_names)) = + connectors.get_mut(&connector_id) + { if existing_name == &connector_id && connector_name != connector_id { *existing_name = connector_name; } + existing_plugin_display_names.extend(plugin_display_names); } else { - connectors.insert(connector_id, connector_name); + connectors.insert( + connector_id, + ( + connector_name, + plugin_display_names + .into_iter() + .collect::>(), + ), + ); } } let mut accessible: Vec = connectors .into_iter() - .map(|(connector_id, connector_name)| AppInfo { - id: connector_id.clone(), - name: connector_name.clone(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url(&connector_name, &connector_id)), - is_accessible: true, - is_enabled: true, - }) + .map( + |(connector_id, (connector_name, plugin_display_names))| AppInfo { + id: connector_id.clone(), + name: connector_name.clone(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url(&connector_name, &connector_id)), + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_display_names.into_iter().collect(), + }, + ) .collect(); accessible.sort_by(|left, right| { right @@ -638,6 +686,7 @@ fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo { install_url: Some(connector_install_url(&name, &connector_id)), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), } } @@ -681,7 +730,11 @@ mod tests { use crate::config::types::AppToolConfig; use crate::config::types::AppToolsConfig; use crate::config::types::AppsDefaultConfig; + use crate::mcp_connection_manager::ToolInfo; use pretty_assertions::assert_eq; + use rmcp::model::JsonObject; + use rmcp::model::Tool; + use std::sync::Arc; fn annotations( destructive_hint: Option, @@ -710,13 +763,30 @@ mod tests { labels: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), } } - #[test] - fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { - let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); - let accessible = AppInfo { + fn plugin_names(names: &[&str]) -> Vec { + names.iter().map(ToString::to_string).collect() + } + + fn test_tool_definition(tool_name: &str) -> Tool { + Tool { + name: tool_name.to_string().into(), + title: None, + description: None, + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + } + } + + fn google_calendar_accessible_connector(plugin_display_names: &[&str]) -> AppInfo { + AppInfo { id: "calendar".to_string(), name: "Google Calendar".to_string(), description: Some("Plan events".to_string()), @@ -729,7 +799,30 @@ mod tests { install_url: None, is_accessible: true, is_enabled: true, - }; + plugin_display_names: plugin_names(plugin_display_names), + } + } + + fn codex_app_tool( + tool_name: &str, + connector_id: &str, + connector_name: Option<&str>, + plugin_display_names: &[&str], + ) -> ToolInfo { + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: tool_name.to_string(), + tool: test_tool_definition(tool_name), + connector_id: Some(connector_id.to_string()), + connector_name: connector_name.map(ToOwned::to_owned), + plugin_display_names: plugin_names(plugin_display_names), + } + } + + #[test] + fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { + let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); + let accessible = google_calendar_accessible_connector(&[]); let merged = merge_connectors(vec![plugin], vec![accessible]); @@ -748,11 +841,97 @@ mod tests { install_url: Some(connector_install_url("calendar", "calendar")), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }] ); assert_eq!(connector_mention_slug(&merged[0]), "google-calendar"); } + #[test] + fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { + let tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_list_events".to_string(), + codex_app_tool( + "calendar_list_events", + "calendar", + None, + &["sample", "sample"], + ), + ), + ( + "mcp__codex_apps__calendar_create_event".to_string(), + codex_app_tool( + "calendar_create_event", + "calendar", + Some("Google Calendar"), + &["beta", "sample"], + ), + ), + ( + "mcp__sample__echo".to_string(), + ToolInfo { + server_name: "sample".to_string(), + tool_name: "echo".to_string(), + tool: test_tool_definition("echo"), + connector_id: None, + connector_name: None, + plugin_display_names: plugin_names(&["ignored"]), + }, + ), + ]); + + let connectors = accessible_connectors_from_mcp_tools(&tools); + + assert_eq!( + connectors, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Google Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["beta", "sample"]), + }] + ); + } + + #[test] + fn merge_connectors_unions_and_dedupes_plugin_display_names() { + let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); + plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]); + + let accessible = google_calendar_accessible_connector(&["beta", "alpha"]); + + let merged = merge_connectors(vec![plugin], vec![accessible]); + + assert_eq!( + merged, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("calendar", "calendar")), + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["alpha", "beta", "sample"]), + }] + ); + } + #[test] fn app_tool_policy_uses_global_defaults_for_destructive_hints() { let apps_config = AppsConfigToml { diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index ac7dd2acaf..8b8fdd3942 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -26,6 +26,7 @@ use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::SandboxState; use crate::mcp_connection_manager::codex_apps_tools_cache_key; +use crate::plugins::PluginCapabilitySummary; use crate::plugins::PluginsManager; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; @@ -35,6 +36,64 @@ const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com"; const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp"; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ToolPluginProvenance { + plugin_display_names_by_connector_id: HashMap>, + plugin_display_names_by_mcp_server_name: HashMap>, +} + +impl ToolPluginProvenance { + pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] { + self.plugin_display_names_by_connector_id + .get(connector_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] { + self.plugin_display_names_by_mcp_server_name + .get(server_name) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self { + let mut tool_plugin_provenance = Self::default(); + for plugin in capability_summaries { + for connector_id in &plugin.app_connector_ids { + tool_plugin_provenance + .plugin_display_names_by_connector_id + .entry(connector_id.0.clone()) + .or_default() + .push(plugin.display_name.clone()); + } + + for server_name in &plugin.mcp_server_names { + tool_plugin_provenance + .plugin_display_names_by_mcp_server_name + .entry(server_name.clone()) + .or_default() + .push(plugin.display_name.clone()); + } + } + + for plugin_names in tool_plugin_provenance + .plugin_display_names_by_connector_id + .values_mut() + .chain( + tool_plugin_provenance + .plugin_display_names_by_mcp_server_name + .values_mut(), + ) + { + plugin_names.sort_unstable(); + plugin_names.dedup(); + } + + tool_plugin_provenance + } +} + // Legacy vs new MCP gateway #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CodexAppsMcpGateway { @@ -182,6 +241,11 @@ impl McpManager { ) -> HashMap { effective_mcp_servers(config, auth, self.plugins_manager.as_ref()) } + + pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance { + let loaded_plugins = self.plugins_manager.plugins_for_config(config); + ToolPluginProvenance::from_capability_summaries(loaded_plugins.capability_summaries()) + } } fn configured_mcp_servers( @@ -219,6 +283,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent let auth = auth_manager.auth().await; let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref()); + let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpListToolsResponseEvent { tools: HashMap::new(), @@ -251,6 +316,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent sandbox_state, config.codex_home.clone(), codex_apps_tools_cache_key(auth.as_ref()), + tool_plugin_provenance, ) .await; @@ -407,6 +473,8 @@ mod tests { use super::*; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; + use crate::plugins::AppConnectorId; + use crate::plugins::PluginCapabilitySummary; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -485,6 +553,47 @@ mod tests { assert_eq!(group_tools_by_server(&tools), expected); } + #[test] + fn tool_plugin_provenance_collects_app_and_mcp_sources() { + let provenance = ToolPluginProvenance::from_capability_summaries(&[ + PluginCapabilitySummary { + display_name: "alpha-plugin".to_string(), + app_connector_ids: vec![AppConnectorId("connector_example".to_string())], + mcp_server_names: vec!["alpha".to_string()], + ..PluginCapabilitySummary::default() + }, + PluginCapabilitySummary { + display_name: "beta-plugin".to_string(), + app_connector_ids: vec![ + AppConnectorId("connector_example".to_string()), + AppConnectorId("connector_gmail".to_string()), + ], + mcp_server_names: vec!["beta".to_string()], + ..PluginCapabilitySummary::default() + }, + ]); + + assert_eq!( + provenance, + ToolPluginProvenance { + plugin_display_names_by_connector_id: HashMap::from([ + ( + "connector_example".to_string(), + vec!["alpha-plugin".to_string(), "beta-plugin".to_string()], + ), + ( + "connector_gmail".to_string(), + vec!["beta-plugin".to_string()], + ), + ]), + plugin_display_names_by_mcp_server_name: HashMap::from([ + ("alpha".to_string(), vec!["alpha-plugin".to_string()]), + ("beta".to_string(), vec!["beta-plugin".to_string()]), + ]), + } + ); + } + #[test] fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() { assert_eq!( diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index f332bc19ea..a30a7c7bb2 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -6,6 +6,7 @@ //! in a single aggregated map using the fully-qualified tool name //! `""` as the key. +use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::env; @@ -19,6 +20,7 @@ use std::time::Duration; use std::time::Instant; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::ToolPluginProvenance; use crate::mcp::auth::McpAuthStatusEntry; use anyhow::Context; use anyhow::Result; @@ -80,7 +82,6 @@ use crate::codex::INITIAL_SUBMIT_ID; use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; use crate::connectors::is_connector_id_allowed; - /// Delimiter used to separate the server name from the tool name in a fully /// qualified tool name. /// @@ -198,6 +199,8 @@ pub(crate) struct ToolInfo { pub(crate) tool: Tool, pub(crate) connector_id: Option, pub(crate) connector_name: Option, + #[serde(default)] + pub(crate) plugin_display_names: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -404,9 +407,13 @@ struct AsyncManagedClient { client: Shared>>, startup_snapshot: Option>, startup_complete: Arc, + tool_plugin_provenance: Arc, } impl AsyncManagedClient { + // Keep this constructor flat so the startup inputs remain readable at the + // single call site instead of introducing a one-off params wrapper. + #[allow(clippy::too_many_arguments)] fn new( server_name: String, config: McpServerConfig, @@ -415,6 +422,7 @@ impl AsyncManagedClient { tx_event: Sender, elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, + tool_plugin_provenance: Arc, ) -> Self { let tool_filter = ToolFilter::from_config(&config); let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( @@ -471,6 +479,7 @@ impl AsyncManagedClient { client, startup_snapshot, startup_complete, + tool_plugin_provenance, } } @@ -486,14 +495,63 @@ impl AsyncManagedClient { } async fn listed_tools(&self) -> Option> { - if let Some(startup_tools) = self.startup_snapshot_while_initializing() { - return Some(startup_tools); - } + let annotate_tools = |tools: Vec| { + let mut tools = tools; + for tool in &mut tools { + let plugin_names = match tool.connector_id.as_deref() { + Some(connector_id) => self + .tool_plugin_provenance + .plugin_display_names_for_connector_id(connector_id), + None => self + .tool_plugin_provenance + .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), + }; + tool.plugin_display_names = plugin_names.to_vec(); - match self.client().await { - Ok(client) => Some(client.listed_tools()), - Err(_) => self.startup_snapshot.clone(), - } + if plugin_names.is_empty() { + continue; + } + + let plugin_source_note = if plugin_names.len() == 1 { + format!("This tool is part of plugin `{}`.", plugin_names[0]) + } else { + format!( + "This tool is part of plugins {}.", + plugin_names + .iter() + .map(|plugin_name| format!("`{plugin_name}`")) + .collect::>() + .join(", ") + ) + }; + let description = tool + .tool + .description + .as_deref() + .map(str::trim) + .unwrap_or(""); + let annotated_description = if description.is_empty() { + plugin_source_note + } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { + format!("{description} {plugin_source_note}") + } else { + format!("{description}. {plugin_source_note}") + }; + tool.tool.description = Some(Cow::Owned(annotated_description)); + } + tools + }; + + // Keep cache payloads raw; plugin provenance is resolved per-session at read time. + let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + Some(startup_tools) + } else { + match self.client().await { + Ok(client) => Some(client.listed_tools()), + Err(_) => self.startup_snapshot.clone(), + } + }; + tools.map(annotate_tools) } async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> { @@ -565,12 +623,14 @@ impl McpConnectionManager { initial_sandbox_state: SandboxState, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + tool_plugin_provenance: ToolPluginProvenance, ) -> (Self, CancellationToken) { let cancel_token = CancellationToken::new(); let mut clients = HashMap::new(); let mut server_origins = HashMap::new(); let mut join_set = JoinSet::new(); let elicitation_requests = ElicitationRequestManager::new(approval_policy.value()); + let tool_plugin_provenance = Arc::new(tool_plugin_provenance); let mcp_servers = mcp_servers.clone(); for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { if let Some(origin) = transport_origin(&cfg.transport) { @@ -601,6 +661,7 @@ impl McpConnectionManager { tx_event.clone(), elicitation_requests.clone(), codex_apps_tools_cache_context, + Arc::clone(&tool_plugin_provenance), ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); @@ -1508,6 +1569,7 @@ async fn list_tools_for_client_uncached( tool: tool_def, connector_id: tool.connector_id, connector_name, + plugin_display_names: Vec::new(), } }) .collect(); @@ -1621,6 +1683,7 @@ mod tests { }, connector_id: None, connector_name: None, + plugin_display_names: Vec::new(), } } @@ -2004,6 +2067,7 @@ mod tests { client: pending_client, startup_snapshot: Some(startup_tools), startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), }, ); @@ -2029,6 +2093,7 @@ mod tests { client: pending_client, startup_snapshot: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), }, ); @@ -2051,6 +2116,7 @@ mod tests { client: pending_client, startup_snapshot: Some(Vec::new()), startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), }, ); @@ -2082,6 +2148,7 @@ mod tests { client: failed_client, startup_snapshot: Some(startup_tools), startup_complete, + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), }, ); diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/mentions.rs index 008456d61a..79efa3ec76 100644 --- a/codex-rs/core/src/mentions.rs +++ b/codex-rs/core/src/mentions.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use codex_protocol::user_input::UserInput; use crate::connectors; +use crate::plugins::PluginCapabilitySummary; use crate::skills::SkillMetadata; use crate::skills::injection::ToolMentionKind; use crate::skills::injection::app_id_from_path; @@ -48,6 +49,103 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet { .collect() } +/// Collect explicit plain-text `@plugin` mentions from user text. +/// +/// This is currently the core-side fallback path for plugin mentions. It +/// matches unambiguous plugin `display_name`s from the filtered capability +/// index, case-insensitively, by scanning for exact `@display name` matches. +/// +/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention +/// parser today, and the existing TUI `@...` logic is file-autocomplete, not +/// turn-time parsing. +/// +/// Long term, explicit plugin picks should come through structured +/// `plugin://...` mentions, likely via `UserInput::Mention`, once clients can list +/// plugins and the UI has plugin-mention support (likely a plugins/list app-server +/// endpoint). Even then, this may stay as a text fallback, similar to skills/apps. +pub(crate) fn collect_explicit_plugin_mentions( + input: &[UserInput], + plugins: &[PluginCapabilitySummary], +) -> Vec { + if plugins.is_empty() { + return Vec::new(); + } + + let mut display_name_counts = HashMap::new(); + for plugin in plugins { + *display_name_counts + .entry(plugin.display_name.to_lowercase()) + .or_insert(0) += 1; + } + + let mut display_names = display_name_counts.keys().cloned().collect::>(); + display_names.sort_by_key(|display_name| std::cmp::Reverse(display_name.len())); + + let mut mentioned_display_names = HashSet::new(); + for text in input.iter().filter_map(|item| match item { + UserInput::Text { text, .. } => Some(text.as_str()), + _ => None, + }) { + let text = text.to_lowercase(); + let mut index = 0; + while let Some(relative_at_sign) = text[index..].find('@') { + let at_sign = index + relative_at_sign; + if text[..at_sign] + .chars() + .next_back() + .is_some_and(is_plugin_mention_body_char) + { + index = at_sign + 1; + continue; + } + + let Some((matched_display_name, matched_len)) = + display_names.iter().find_map(|display_name| { + text[at_sign + 1..].starts_with(display_name).then(|| { + let end = at_sign + 1 + display_name.len(); + text[end..] + .chars() + .next() + .is_none_or(|ch| !is_plugin_mention_body_char(ch)) + .then_some((display_name, display_name.len())) + })? + }) + else { + index = at_sign + 1; + continue; + }; + + if display_name_counts + .get(matched_display_name) + .copied() + .unwrap_or(0) + == 1 + { + mentioned_display_names.insert(matched_display_name.clone()); + } + index = at_sign + 1 + matched_len; + } + } + + if mentioned_display_names.is_empty() { + return Vec::new(); + } + + let mut selected = Vec::new(); + let mut seen_display_names = HashSet::new(); + for plugin in plugins { + let display_name = plugin.display_name.to_lowercase(); + if !mentioned_display_names.contains(&display_name) { + continue; + } + if seen_display_names.insert(display_name) { + selected.push(plugin.clone()); + } + } + + selected +} + pub(crate) fn build_skill_name_counts( skills: &[SkillMetadata], disabled_paths: &HashSet, @@ -77,6 +175,10 @@ pub(crate) fn build_connector_slug_counts( counts } +fn is_plugin_mention_body_char(ch: char) -> bool { + ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':') +} + #[cfg(test)] mod tests { use std::collections::HashSet; @@ -85,6 +187,8 @@ mod tests { use pretty_assertions::assert_eq; use super::collect_explicit_app_ids; + use super::collect_explicit_plugin_mentions; + use crate::plugins::PluginCapabilitySummary; fn text_input(text: &str) -> UserInput { UserInput::Text { @@ -93,6 +197,16 @@ mod tests { } } + fn plugin(display_name: &str) -> PluginCapabilitySummary { + PluginCapabilitySummary { + config_name: format!("{display_name}@test"), + display_name: display_name.to_string(), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } + } + #[test] fn collect_explicit_app_ids_from_linked_text_mentions() { let input = vec![text_input("use [$calendar](app://calendar)")]; @@ -141,4 +255,70 @@ mod tests { assert_eq!(app_ids, HashSet::::new()); } + + #[test] + fn collect_explicit_plugin_mentions_resolves_unique_display_names() { + let plugins = vec![plugin("sample"), plugin("other")]; + + let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins); + + assert_eq!(mentioned, vec![plugin("sample")]); + } + + #[test] + fn collect_explicit_plugin_mentions_resolves_non_slug_display_names() { + let spaced_plugins = vec![plugin("Google Calendar")]; + let spaced_mentioned = collect_explicit_plugin_mentions( + &[text_input("use @Google Calendar")], + &spaced_plugins, + ); + assert_eq!(spaced_mentioned, vec![plugin("Google Calendar")]); + + let unicode_plugins = vec![plugin("Café")]; + let unicode_mentioned = + collect_explicit_plugin_mentions(&[text_input("use @Café")], &unicode_plugins); + assert_eq!(unicode_mentioned, vec![plugin("Café")]); + } + + #[test] + fn collect_explicit_plugin_mentions_prefers_longer_display_names() { + let plugins = vec![plugin("Google"), plugin("Google Calendar")]; + + let mentioned = + collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins); + + assert_eq!(mentioned, vec![plugin("Google Calendar")]); + } + + #[test] + fn collect_explicit_plugin_mentions_does_not_fall_back_from_ambiguous_longer_name() { + let plugins = vec![ + plugin("Google"), + PluginCapabilitySummary { + config_name: "calendar-1@test".to_string(), + ..plugin("Google Calendar") + }, + PluginCapabilitySummary { + config_name: "calendar-2@test".to_string(), + ..plugin("Google Calendar") + }, + ]; + + let mentioned = + collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins); + + assert_eq!(mentioned, Vec::::new()); + } + + #[test] + fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() { + let plugins = vec![plugin("sample")]; + + let mentioned = collect_explicit_plugin_mentions( + &[text_input("contact sample@openai.com, do not use plugins")], + &plugins, + ); + + assert_eq!(mentioned, Vec::::new()); + } } diff --git a/codex-rs/core/src/plugins/injection.rs b/codex-rs/core/src/plugins/injection.rs new file mode 100644 index 0000000000..d8adfc92c6 --- /dev/null +++ b/codex-rs/core/src/plugins/injection.rs @@ -0,0 +1,58 @@ +use std::collections::BTreeSet; +use std::collections::HashMap; + +use codex_protocol::models::DeveloperInstructions; +use codex_protocol::models::ResponseItem; + +use crate::connectors; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp_connection_manager::ToolInfo; +use crate::plugins::PluginCapabilitySummary; +use crate::plugins::render_explicit_plugin_instructions; + +pub(crate) fn build_plugin_injections( + mentioned_plugins: &[PluginCapabilitySummary], + mcp_tools: &HashMap, + available_connectors: &[connectors::AppInfo], +) -> Vec { + if mentioned_plugins.is_empty() { + return Vec::new(); + } + + // Turn each explicit @plugin mention into a developer hint that points the + // model at the plugin's visible MCP servers, enabled apps, and skill prefix. + mentioned_plugins + .iter() + .filter_map(|plugin| { + let available_mcp_servers = mcp_tools + .values() + .filter(|tool| { + tool.server_name != CODEX_APPS_MCP_SERVER_NAME + && tool + .plugin_display_names + .iter() + .any(|plugin_name| plugin_name == &plugin.display_name) + }) + .map(|tool| tool.server_name.clone()) + .collect::>() + .into_iter() + .collect::>(); + let available_apps = available_connectors + .iter() + .filter(|connector| { + connector.is_enabled + && connector + .plugin_display_names + .iter() + .any(|plugin_name| plugin_name == &plugin.display_name) + }) + .map(connectors::connector_display_label) + .collect::>() + .into_iter() + .collect::>(); + render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps) + .map(DeveloperInstructions::new) + .map(ResponseItem::from) + }) + .collect() +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 3cfcc81bbd..b710f13331 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -198,11 +198,6 @@ impl PluginsManager { force_reload: bool, ) -> PluginLoadOutcome { if !plugins_feature_enabled_from_stack(config_layer_stack) { - let mut cache = match self.cache_by_cwd.write() { - Ok(cache) => cache, - Err(err) => err.into_inner(), - }; - cache.insert(cwd.to_path_buf(), PluginLoadOutcome::default()); return PluginLoadOutcome::default(); } diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 5c0024a250..faa90dc289 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,9 +1,11 @@ +mod injection; mod manager; mod manifest; mod marketplace; mod render; mod store; +pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; pub use manager::LoadedPlugin; pub use manager::PluginCapabilitySummary; @@ -14,6 +16,7 @@ pub use manager::PluginsManager; pub(crate) use manager::plugin_namespace_for_skill_path; pub(crate) use manifest::load_plugin_manifest; pub(crate) use manifest::plugin_manifest_name; +pub(crate) use render::render_explicit_plugin_instructions; pub(crate) use render::render_plugins_section; pub use store::PluginId; pub use store::PluginInstallResult; diff --git a/codex-rs/core/src/plugins/render.rs b/codex-rs/core/src/plugins/render.rs index f269b90b60..1111ea46be 100644 --- a/codex-rs/core/src/plugins/render.rs +++ b/codex-rs/core/src/plugins/render.rs @@ -30,6 +30,54 @@ pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Opt Some(lines.join("\n")) } +pub(crate) fn render_explicit_plugin_instructions( + plugin: &PluginCapabilitySummary, + available_mcp_servers: &[String], + available_apps: &[String], +) -> Option { + let mut lines = vec![format!( + "Capabilities from the `{}` plugin:", + plugin.display_name + )]; + + if plugin.has_skills { + lines.push(format!( + "- Skills from this plugin are prefixed with `{}:`.", + plugin.display_name + )); + } + + if !available_mcp_servers.is_empty() { + lines.push(format!( + "- MCP servers from this plugin available in this session: {}.", + available_mcp_servers + .iter() + .map(|server| format!("`{server}`")) + .collect::>() + .join(", ") + )); + } + + if !available_apps.is_empty() { + lines.push(format!( + "- Apps from this plugin available in this session: {}.", + available_apps + .iter() + .map(|app| format!("`{app}`")) + .collect::>() + .join(", ") + )); + } + + if lines.len() == 1 { + return None; + } + + lines.push("Use these plugin-associated capabilities to help solve the task.".to_string()); + + Some(lines.join("\n")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index a4878b73a6..cb54a9b569 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -268,6 +268,7 @@ mod tests { install_url: None, is_accessible: true, is_enabled: enabled, + plugin_display_names: Vec::new(), } } @@ -295,6 +296,7 @@ mod tests { }, connector_id: connector_id.map(str::to_string), connector_name: connector_id.map(str::to_string), + plugin_display_names: Vec::new(), }, ) } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 543bf0f7b1..e04bb2b5cd 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -3076,6 +3076,7 @@ mod tests { ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), }, ), ( @@ -3086,6 +3087,7 @@ mod tests { tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), connector_id: None, connector_name: None, + plugin_display_names: Vec::new(), }, ), ])), diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index c845d8d865..d64702bc97 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -14,7 +14,6 @@ use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; -use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -22,41 +21,48 @@ use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; -use dunce::canonicalize as normalize_path; use tempfile::TempDir; use wiremock::MockServer; -fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf { - let plugin_root = home.path().join("plugins/cache/test/sample/local"); - let skill_dir = plugin_root.join("skills/sample-search"); - std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir"); +const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test"; +const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample"; + +fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf { + home.path().join("plugins/cache/test/sample/local") +} + +fn write_sample_plugin_manifest_and_config(home: &TempDir) -> std::path::PathBuf { + let plugin_root = sample_plugin_root(home); std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, + format!(r#"{{"name":"{SAMPLE_PLUGIN_DISPLAY_NAME}"}}"#), ) .expect("write plugin manifest"); + std::fs::write( + home.path().join("config.toml"), + format!( + "[features]\nplugins = true\n\n[plugins.\"{SAMPLE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n" + ), + ) + .expect("write config"); + plugin_root +} + +fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf { + let plugin_root = write_sample_plugin_manifest_and_config(home); + let skill_dir = plugin_root.join("skills/sample-search"); + std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir"); std::fs::write( skill_dir.join("SKILL.md"), "---\ndescription: inspect sample data\n---\n\n# body\n", ) .expect("write plugin skill"); - std::fs::write( - home.path().join("config.toml"), - "[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n", - ) - .expect("write config"); skill_dir.join("SKILL.md") } fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { - let plugin_root = home.path().join("plugins/cache/test/sample/local"); - std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); - std::fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ) - .expect("write plugin manifest"); + let plugin_root = write_sample_plugin_manifest_and_config(home); std::fs::write( plugin_root.join(".mcp.json"), format!( @@ -70,21 +76,10 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { ), ) .expect("write plugin mcp config"); - std::fs::write( - home.path().join("config.toml"), - "[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n", - ) - .expect("write config"); } fn write_plugin_app_plugin(home: &TempDir) { - let plugin_root = home.path().join("plugins/sample"); - std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); - std::fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ) - .expect("write plugin manifest"); + let plugin_root = write_sample_plugin_manifest_and_config(home); std::fs::write( plugin_root.join(".app.json"), r#"{ @@ -96,14 +91,6 @@ fn write_plugin_app_plugin(home: &TempDir) { }"#, ) .expect("write plugin app config"); - std::fs::write( - home.path().join("config.toml"), - format!( - "[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n", - plugin_root.display() - ), - ) - .expect("write config"); } async fn build_plugin_test_codex( @@ -120,6 +107,32 @@ async fn build_plugin_test_codex( .codex) } +async fn build_apps_enabled_plugin_test_codex( + server: &MockServer, + codex_home: Arc, + chatgpt_base_url: String, +) -> Result> { + let mut builder = test_codex() + .with_home(codex_home) + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(move |config| { + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::AppsMcpGateway) + .expect("test config should allow feature update"); + config.chatgpt_base_url = chatgpt_base_url; + }); + Ok(builder + .build(server) + .await + .expect("create new conversation") + .codex) +} + fn tool_names(body: &serde_json::Value) -> Vec { body.get("tools") .and_then(serde_json::Value::as_array) @@ -137,6 +150,22 @@ fn tool_names(body: &serde_json::Value) -> Vec { .unwrap_or_default() } +fn tool_description(body: &serde_json::Value, tool_name: &str) -> Option { + body.get("tools") + .and_then(serde_json::Value::as_array) + .and_then(|tools| { + tools.iter().find_map(|tool| { + if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) { + tool.get("description") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + } else { + None + } + }) + }) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn plugin_skills_append_to_instructions() -> Result<()> { skip_if_no_network!(Ok(())); @@ -149,7 +178,7 @@ async fn plugin_skills_append_to_instructions() -> Result<()> { .await; let codex_home = Arc::new(TempDir::new()?); - let skill_path = write_plugin_skill_plugin(codex_home.as_ref()); + write_plugin_skill_plugin(codex_home.as_ref()); let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?; codex @@ -174,76 +203,48 @@ async fn plugin_skills_append_to_instructions() -> Result<()> { "expected plugins section present" ); assert!( - instructions_text.contains("### Available plugins\n- `sample`"), - "expected enabled plugin list in instructions" - ); - assert!( - instructions_text.contains("### How to use plugins"), - "expected plugin usage guidance heading" - ); - assert!( - instructions_text.contains("## Skills"), - "expected skills section present" + instructions_text.contains("`sample`"), + "expected enabled plugin name in instructions" ); assert!( instructions_text.contains("sample:sample-search: inspect sample data"), "expected namespaced plugin skill summary" ); - let expected_path = normalize_path(skill_path)?; - let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); - assert!( - instructions_text.contains(&expected_path_str), - "expected path {expected_path_str} in instructions" - ); - assert!( - instructions_text.find("## Plugins") < instructions_text.find("## Skills"), - "expected plugins section before skills section" - ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> { +async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; - let mock = mount_sse_sequence( + let mock = mount_sse_once( &server, - vec![ - sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), - ], + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), ) .await; let codex_home = Arc::new(TempDir::new()?); + let rmcp_test_server_bin = match stdio_server_bin() { + Ok(bin) => bin, + Err(err) => { + eprintln!("test_stdio_server binary not available, skipping test: {err}"); + return Ok(()); + } + }; + write_plugin_skill_plugin(codex_home.as_ref()); + write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin); write_plugin_app_plugin(codex_home.as_ref()); - #[allow(clippy::expect_used)] - let mut builder = test_codex() - .with_home(codex_home) - .with_auth(CodexAuth::from_api_key("Test API Key")) - .with_config(move |config| { - config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - config - .features - .disable(Feature::AppsMcpGateway) - .expect("test config should allow feature update"); - config.chatgpt_base_url = apps_server.chatgpt_base_url; - }); - let codex = builder - .build(&server) - .await - .expect("create new conversation") - .codex; + + let codex = + build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url) + .await?; codex .submit(Op::UserInput { items: vec![codex_protocol::user_input::UserInput::Text { - text: "hello".into(), + text: "Use @sample for this task.".into(), text_elements: Vec::new(), }], final_output_json_schema: None, @@ -251,40 +252,46 @@ async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> { .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - codex - .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Text { - text: "Use $google-calendar and then call tools.".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }) - .await?; - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - - let requests = mock.requests(); - assert_eq!(requests.len(), 2, "expected two model requests"); - - let first_tools = tool_names(&requests[0].body_json()); + let request = mock.single_request(); + let developer_messages = request.message_input_texts("developer"); assert!( - !first_tools + developer_messages .iter() - .any(|name| name == "mcp__codex_apps__calendar_create_event"), - "app tools should stay hidden before plugin app mention: {first_tools:?}" - ); - - let second_tools = tool_names(&requests[1].body_json()); - assert!( - second_tools - .iter() - .any(|name| name == "mcp__codex_apps__calendar_create_event"), - "calendar create tool should be available after plugin app mention: {second_tools:?}" + .any(|text| text.contains("Skills from this plugin")), + "expected plugin skills guidance: {developer_messages:?}" ); assert!( - second_tools + developer_messages .iter() - .any(|name| name == "mcp__codex_apps__calendar_list_events"), - "calendar list tool should be available after plugin app mention: {second_tools:?}" + .any(|text| text.contains("MCP servers from this plugin")), + "expected visible plugin MCP guidance: {developer_messages:?}" + ); + assert!( + developer_messages + .iter() + .any(|text| text.contains("Apps from this plugin")), + "expected visible plugin app guidance: {developer_messages:?}" + ); + let request_body = request.body_json(); + let request_tools = tool_names(&request_body); + assert!( + request_tools + .iter() + .any(|name| name == "mcp__codex_apps__calendar_create_event"), + "expected plugin app tools to become visible for this turn: {request_tools:?}" + ); + let echo_description = tool_description(&request_body, "mcp__sample__echo") + .expect("plugin MCP tool description should be present"); + assert!( + echo_description.contains("This tool is part of plugin `sample`."), + "expected plugin MCP provenance in tool description: {echo_description:?}" + ); + let calendar_description = + tool_description(&request_body, "mcp__codex_apps__calendar_create_event") + .expect("plugin app tool description should be present"); + assert!( + calendar_description.contains("This tool is part of plugin `sample`."), + "expected plugin app provenance in tool description: {calendar_description:?}" ); Ok(()) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index c55295f01c..00315d25f4 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -361,6 +361,110 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn search_tool_results_match_plugin_names_and_annotate_descriptions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; + let call_id = "tool-search"; + let args = json!({ + "query": "sample", + "limit": 2, + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + SEARCH_TOOL_BM25_TOOL_NAME, + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let codex_home = Arc::new(tempfile::TempDir::new()?); + let plugin_root = codex_home.path().join("plugins/cache/test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .expect("write plugin manifest"); + std::fs::write( + plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "calendar" + } + } +}"#, + ) + .expect("write plugin app config"); + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n", + ) + .expect("write config"); + + let mut builder = + configured_builder(apps_server.chatgpt_base_url.clone(), None).with_home(codex_home); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "find sample plugin tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests, got {}", + requests.len() + ); + + let search_output_payload = search_tool_output_payload(&requests[1], call_id); + let result_tools = search_result_tools(&search_output_payload); + assert_eq!(result_tools.len(), 2, "expected 2 search results"); + assert!( + result_tools.iter().all(|tool| { + tool.get("description") + .and_then(Value::as_str) + .is_some_and(|description| { + description.contains("This tool is part of plugin `sample`.") + }) + }), + "expected plugin provenance in search result descriptions: {search_output_payload:?}" + ); + assert!( + result_tools + .iter() + .any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_CREATE_TOOL) }), + "expected calendar create tool in search results: {search_output_payload:?}" + ); + assert!( + result_tools + .iter() + .any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_LIST_TOOL) }), + "expected calendar list tool in search results: {search_output_payload:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_selection_persists_across_turns() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c084650660..35fd5229b1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5198,6 +5198,7 @@ mod tests { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }]; composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); @@ -5238,6 +5239,7 @@ mod tests { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, + plugin_display_names: Vec::new(), }]; composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 18cb2dd234..bde96baba6 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6276,6 +6276,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), false, @@ -6312,6 +6313,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, codex_chatgpt::connectors::AppInfo { id: linear_id.to_string(), @@ -6326,6 +6328,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { install_url: Some("https://example.test/linear".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, ], }), @@ -6368,6 +6371,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, codex_chatgpt::connectors::AppInfo { id: linear_id.to_string(), @@ -6382,6 +6386,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; chat.on_connectors_loaded( @@ -6406,6 +6411,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), false, @@ -6449,6 +6455,7 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }]; chat.connectors_cache = ConnectorsCacheState::Ready(ConnectorsSnapshot { connectors: full_connectors.clone(), @@ -6487,6 +6494,7 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, codex_chatgpt::connectors::AppInfo { id: "unit_test_connector_2".to_string(), @@ -6501,6 +6509,7 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; chat.on_connectors_loaded( @@ -6527,6 +6536,7 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, codex_chatgpt::connectors::AppInfo { id: "connector_openai_hidden".to_string(), @@ -6541,6 +6551,7 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { install_url: Some("https://example.test/hidden-openai".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, ], }), @@ -6587,6 +6598,7 @@ async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, + plugin_display_names: Vec::new(), }], }), true, @@ -6640,6 +6652,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), true, @@ -6680,6 +6693,7 @@ async fn apps_refresh_preserves_toggled_enabled_state() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), true, @@ -6701,6 +6715,7 @@ async fn apps_refresh_preserves_toggled_enabled_state() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), true, @@ -6748,6 +6763,7 @@ async fn apps_popup_for_not_installed_app_uses_install_only_selected_description install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }], }), true,