diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 7cc07c0747..3657a23255 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -1,10 +1,72 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_app_server_protocol::AppInfo; use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; -pub(crate) fn render_apps_section() -> String { +pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option { + if !connectors + .iter() + .any(|connector| connector.is_accessible && connector.is_enabled) + { + return None; + } + let body = format!( "## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps." ); - format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}") + Some(format!( + "{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connector(id: &str, is_accessible: bool, is_enabled: bool) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible, + is_enabled, + plugin_display_names: Vec::new(), + } + } + + #[test] + fn omits_apps_section_without_accessible_and_enabled_apps() { + assert_eq!(render_apps_section(&[]), None); + assert_eq!( + render_apps_section(&[connector( + "calendar", /*is_accessible*/ true, /*is_enabled*/ false + )]), + None + ); + assert_eq!( + render_apps_section(&[connector( + "calendar", /*is_accessible*/ false, /*is_enabled*/ true + )]), + None + ); + } + + #[test] + fn renders_apps_section_with_an_accessible_and_enabled_app() { + let rendered = render_apps_section(&[connector( + "calendar", /*is_accessible*/ true, /*is_enabled*/ true, + )]) + .expect("expected apps section"); + + assert!(rendered.starts_with(APPS_INSTRUCTIONS_OPEN_TAG)); + assert!(rendered.contains("## Apps (Connectors)")); + assert!(rendered.ends_with(APPS_INSTRUCTIONS_CLOSE_TAG)); + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b37eb063fb..c9aa21d0af 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3642,7 +3642,16 @@ impl Session { } } if turn_context.apps_enabled() { - developer_sections.push(render_apps_section()); + let mcp_connection_manager = self.services.mcp_connection_manager.read().await; + let accessible_and_enabled_connectors = + connectors::list_accessible_and_enabled_connectors_from_manager( + &mcp_connection_manager, + &turn_context.config, + ) + .await; + if let Some(apps_section) = render_apps_section(&accessible_and_enabled_connectors) { + developer_sections.push(apps_section); + } } let implicit_skills = turn_context .turn_skills diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 600ba9c6f9..fdb0f43222 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -103,6 +103,19 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) } +pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( + mcp_connection_manager: &McpConnectionManager, + config: &Config, +) -> Vec { + with_app_enabled_state( + accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await), + config, + ) + .into_iter() + .filter(|connector| connector.is_accessible && connector.is_enabled) + .collect() +} + pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, auth: Option<&CodexAuth>, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d6b167ed9b..77c2954067 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -389,7 +389,10 @@ impl ToolsConfig { let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = model_info.supports_search_tool; - let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest); + let include_tool_suggest = include_search_tool + && features.enabled(Feature::ToolSuggest) + && features.enabled(Feature::Apps) + && features.enabled(Feature::Plugins); let include_original_image_detail = can_request_original_image_detail(features, model_info); let include_artifact_tools = features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime(); diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 7550495c77..dd806f887b 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2031,6 +2031,7 @@ fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::Plugins); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2061,6 +2062,52 @@ fn tool_suggest_is_not_registered_without_feature_flag() { ); } +#[test] +fn tool_suggest_requires_apps_and_plugins_features() { + let model_info = search_capable_model_info(); + let discoverable_tools = Some(vec![discoverable_connector( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + "Plan events and schedules.", + )]); + let available_models = Vec::new(); + + for disabled_feature in [Feature::Apps, Feature::Plugins] { + let mut features = Features::with_defaults(); + features.enable(Feature::ToolSuggest); + for feature in [Feature::Apps, Feature::Plugins] { + if feature != disabled_feature { + features.enable(feature); + } + } + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + None, + None, + discoverable_tools.clone(), + &[], + ) + .build(); + + assert!( + !tools + .iter() + .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME), + "tool_suggest should be absent when {disabled_feature:?} is disabled" + ); + } +} + #[test] fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info(); @@ -2205,6 +2252,7 @@ fn tool_suggest_description_lists_discoverable_tools() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::Plugins); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams {