From a34da3b2956bbd4b1e927139903bd3c91c880f84 Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Tue, 16 Jun 2026 19:04:07 -0400 Subject: [PATCH] [codex] [3/4] Activate endpoint plugin recommendations (#27704) Summary\n- Await endpoint recommendation selection while constructing each authenticated turn, removing the first-turn cache race.\n- Snapshot and filter endpoint candidates once per turn, then use that same set for the bounded contextual user fragment, tool exposure, and exact install validation.\n- Keep recommendation selection ephemeral: do not persist recommendation state in or gate resumed threads on prior context.\n- Hide the legacy list tool in endpoint mode and preserve legacy discovery unchanged when the endpoint is disabled or unavailable.\n- Keep remote plugin and connector app identities out of model-visible context and attach them only to Codex-owned elicitation metadata.\n\nStack\n- 3/4, based on #28400.\n- Endpoint client and cache: #28399.\n- Generalized suggestion presentation: #28400.\n- Install-schema follow-up: #28403.\n\nValidation\n- \n- \n- \n- \n- Full : 2,649 passed and 88 environment-dependent tests failed because this sandbox cannot write , nest Seatbelt, or locate auxiliary test binaries. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/recommended_plugins.rs | 165 +++++++ codex-rs/core-plugins/Cargo.toml | 1 + codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/manager.rs | 67 +++ codex-rs/core-plugins/src/manager_tests.rs | 73 +++ .../src/context/contextual_user_message.rs | 4 + .../context/contextual_user_message_tests.rs | 8 + codex-rs/core/src/context/mod.rs | 2 + .../recommended_plugins_instructions.rs | 49 ++ codex-rs/core/src/session/mod.rs | 25 + codex-rs/core/src/session/turn.rs | 108 +++-- .../tools/handlers/request_plugin_install.rs | 4 +- .../handlers/request_plugin_install_spec.rs | 10 +- codex-rs/core/src/tools/router.rs | 3 +- codex-rs/core/src/tools/spec_plan_tests.rs | 6 +- .../tests/suite/request_plugin_install.rs | 442 +++++++++++++++--- 18 files changed, 846 insertions(+), 124 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/recommended_plugins.rs create mode 100644 codex-rs/core/src/context/recommended_plugins_instructions.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 59cea06ed..a0713e171 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2760,6 +2760,7 @@ dependencies = [ "codex-otel", "codex-plugin", "codex-protocol", + "codex-tools", "codex-utils-absolute-path", "codex-utils-path-uri", "codex-utils-plugins", diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 917bc1d24..9d07da066 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -44,6 +44,7 @@ mod process_exec; mod rate_limit_reset_credits; mod rate_limits; mod realtime_conversation; +mod recommended_plugins; mod remote_control; #[cfg(debug_assertions)] mod remote_thread_store; diff --git a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs new file mode 100644 index 000000000..59748a198 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs @@ -0,0 +1,165 @@ +use anyhow::Result; +use app_test_support::ChatGptIdTokenClaims; +use app_test_support::TestAppServer; +use app_test_support::encode_id_token; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::responses; +use serde_json::Value; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(20); +const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174010"; + +#[tokio::test] +async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Result<()> { + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(250)) + .set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": "plugin_github", + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "GitHub"} + }] + })), + ) + .expect(1) + .mount(&server) + .await; + let response = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]); + let responses_mock = responses::mount_sse_once(&server, response).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &apps_server.chatgpt_base_url, + )?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!( + "{config}\n[features]\napps = true\nplugins = true\nremote_plugin = true\ntool_suggest = true\n" + ), + )?; + + let sqlite_home = codex_home.path().to_string_lossy(); + let mut app_server = TestAppServer::new_without_managed_config_with_env( + codex_home.path(), + &[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id(WORKSPACE_ID), + )?; + let login_id = app_server + .send_chatgpt_auth_tokens_login_request( + access_token, + WORKSPACE_ID.to_string(), + Some("pro".to_string()), + ) + .await?; + let login_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(login_id)), + ) + .await??; + assert_eq!( + to_response::(login_response)?, + LoginAccountResponse::ChatgptAuthTokens {} + ); + + let thread_id = app_server + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(thread_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_response)?; + + let turn_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "suggest a plugin".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = responses_mock.requests(); + let request = requests + .iter() + .find(|request| { + request + .message_input_texts("user") + .iter() + .any(|text| text.contains("suggest a plugin")) + }) + .expect("turn request"); + let contextual_user_message = request.message_input_texts("user").join("\n"); + assert!(contextual_user_message.contains("")); + assert!(contextual_user_message.contains("- GitHub (github@openai-curated-remote)")); + let body = request.body_json(); + let tool_names = body + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .collect::>(); + assert!(tool_names.contains(&"request_plugin_install")); + assert!(!tool_names.contains(&"list_available_plugins_to_install")); + Ok(()) +} diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 1d361559a..b90192bfe 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -27,6 +27,7 @@ codex-model-provider = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 1b0f49223..356c0f8bd 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -49,6 +49,7 @@ pub use manager::PluginReadRequest; pub use manager::PluginUninstallError; pub use manager::PluginsConfigInput; pub use manager::PluginsManager; +pub use manager::RecommendedPluginCandidatesInput; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome; pub use provider::ExecutorPluginProvider; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index e92b8d935..7fbbcc002 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -57,6 +57,8 @@ use codex_config::ConfigLayerStack; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; +use codex_config::types::ToolSuggestDisabledTool; +use codex_config::types::ToolSuggestDiscoverableType; use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::skill_config_rules_from_stack; @@ -71,6 +73,9 @@ use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; +use codex_tools::DiscoverablePluginInfo; +use codex_tools::DiscoverableTool; +use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PluginSkillRoot; use std::collections::HashMap; @@ -114,6 +119,15 @@ impl PluginsConfigInput { } } +/// Inputs used to select endpoint-backed plugin install candidates. +pub struct RecommendedPluginCandidatesInput<'a> { + pub plugins_config: &'a PluginsConfigInput, + pub loaded_plugins: &'a PluginLoadOutcome, + pub auth: Option<&'a CodexAuth>, + pub disabled_tools: &'a [ToolSuggestDisabledTool], + pub app_server_client_name: Option<&'a str>, +} + #[derive(Clone, PartialEq, Eq)] struct FeaturedPluginIdsCacheKey { chatgpt_base_url: String, @@ -997,6 +1011,59 @@ impl PluginsManager { mode } + /// Returns endpoint recommendations eligible for installation in the current client. + /// `None` selects the legacy discovery workflow. + pub async fn recommended_plugin_candidates_for_config( + &self, + input: RecommendedPluginCandidatesInput<'_>, + ) -> Option> { + let RecommendedPluginsMode::Endpoint { plugins } = self + .recommended_plugins_mode_for_config(input.plugins_config, input.auth) + .await + else { + return None; + }; + if plugins.is_empty() { + return Some(Vec::new()); + } + + let installed_plugin_ids = input + .loaded_plugins + .plugins() + .iter() + .map(|plugin| plugin.config_name.as_str()) + .collect::>(); + let disabled_plugin_ids = input + .disabled_tools + .iter() + .filter(|tool| tool.kind == ToolSuggestDiscoverableType::Plugin) + .map(|tool| tool.id.as_str()) + .collect::>(); + + let candidates = plugins + .into_iter() + .filter(|plugin| { + !installed_plugin_ids.contains(plugin.config_id.as_str()) + && !disabled_plugin_ids.contains(plugin.config_id.as_str()) + }) + .map(|plugin| { + DiscoverableTool::from(DiscoverablePluginInfo { + id: plugin.config_id, + remote_plugin_id: Some(plugin.remote_plugin_id), + name: plugin.display_name, + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: plugin.app_connector_ids, + }) + }) + .collect(); + Some(filter_request_plugin_install_discoverable_tools_for_client( + candidates, + input.app_server_client_name, + )) + } + fn cached_recommended_plugins_mode( &self, cache_key: &RecommendedPluginsCacheKey, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index f219f5e76..7c88d114a 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -3844,6 +3844,79 @@ remote_plugin = true ); } +#[tokio::test] +async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + write_cached_plugin(tmp.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear"); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "enabled": true, + "plugins": [ + { + "id": "plugin_linear", + "name": "linear", + "release": {"display_name": "Linear"} + }, + { + "id": "plugin_github", + "name": "github", + "release": {"display_name": "GitHub"} + }, + { + "id": "plugin_slack", + "name": "slack", + "release": {"display_name": "Slack"} + } + ] + }))) + .expect(1) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = server.uri(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let disabled_tools = [ToolSuggestDisabledTool::plugin( + "github@openai-curated-remote", + )]; + let loaded_plugins = manager.plugins_for_config(&config).await; + + let candidates = manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &config, + loaded_plugins: &loaded_plugins, + auth: Some(&auth), + disabled_tools: &disabled_tools, + app_server_client_name: None, + }) + .await; + + assert_eq!( + candidates, + Some(vec![DiscoverableTool::from(DiscoverablePluginInfo { + id: "slack@openai-curated-remote".to_string(), + remote_plugin_id: Some("plugin_slack".to_string()), + name: "Slack".to_string(), + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + })]) + ); +} + #[tokio::test] async fn recommended_plugins_mode_caches_explicit_false() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index 79768dd3a..de735b6f1 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -10,6 +10,7 @@ use super::InternalModelContextFragment; use super::LegacyApplyPatchExecCommandWarning; use super::LegacyModelMismatchWarning; use super::LegacyUnifiedExecProcessLimitWarning; +use super::RecommendedPluginsInstructions; use super::SkillInstructions; use super::SubagentNotification; use super::TurnAborted; @@ -33,6 +34,8 @@ static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); +static RECOMMENDED_PLUGINS_REGISTRATION: FragmentRegistrationProxy = + FragmentRegistrationProxy::new(); static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy< LegacyUnifiedExecProcessLimitWarning, > = FragmentRegistrationProxy::new(); @@ -52,6 +55,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &TURN_ABORTED_REGISTRATION, &SUBAGENT_NOTIFICATION_REGISTRATION, &INTERNAL_MODEL_CONTEXT_REGISTRATION, + &RECOMMENDED_PLUGINS_REGISTRATION, &LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION, &LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION, &LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION, diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index 133050af2..a7d6e408a 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -75,6 +75,14 @@ fn detects_internal_model_context_fragment() { })); } +#[test] +fn detects_recommended_plugins_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "\n- Google Drive (google-drive@openai-curated-remote)\n" + .to_string(), + })); +} + #[test] fn detects_legacy_goal_context_fragment() { assert!(is_contextual_user_fragment(&ContentItem::InputText { diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index bdc90ad25..a1f204e47 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -23,6 +23,7 @@ mod plugin_instructions; mod realtime_end_instructions; mod realtime_start_instructions; mod realtime_start_with_instructions; +mod recommended_plugins_instructions; mod subagent_notification; mod token_budget_context; mod turn_aborted; @@ -62,6 +63,7 @@ pub(crate) use plugin_instructions::PluginInstructions; pub(crate) use realtime_end_instructions::RealtimeEndInstructions; pub(crate) use realtime_start_instructions::RealtimeStartInstructions; pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions; +pub(crate) use recommended_plugins_instructions::RecommendedPluginsInstructions; pub(crate) use subagent_notification::SubagentNotification; pub(crate) use token_budget_context::TokenBudgetContext; pub(crate) use token_budget_context::TokenBudgetRemainingContext; diff --git a/codex-rs/core/src/context/recommended_plugins_instructions.rs b/codex-rs/core/src/context/recommended_plugins_instructions.rs new file mode 100644 index 000000000..a7b3ea78f --- /dev/null +++ b/codex-rs/core/src/context/recommended_plugins_instructions.rs @@ -0,0 +1,49 @@ +use super::ContextualUserFragment; +use codex_tools::DiscoverableTool; + +const RECOMMENDED_PLUGINS_INTRO: &str = "Here is a list of plugins that are available but not installed. If the user's query would benefit from one of these plugins, use the `request_plugin_install` tool to suggest that they install it. All entries have `tool_type: plugin`; pass `plugin` as `tool_type` and the parenthesized ID as `tool_id`. For example, suggest the Google Drive plugin if the query could possibly be better answered with access to Google Drive."; +const MAX_RECOMMENDED_PLUGINS: usize = 50; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RecommendedPluginsInstructions { + plugins: Vec, +} + +impl RecommendedPluginsInstructions { + pub(crate) fn from_plugins(plugins: &[DiscoverableTool]) -> Option { + if plugins.is_empty() { + return None; + } + Some(Self { + plugins: plugins + .iter() + .take(MAX_RECOMMENDED_PLUGINS) + .cloned() + .collect(), + }) + } +} + +impl ContextualUserFragment for RecommendedPluginsInstructions { + fn role(&self) -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + let plugins = self + .plugins + .iter() + .map(|plugin| format!("- {} ({})", plugin.name(), plugin.id())) + .collect::>() + .join("\n"); + format!("\n{RECOMMENDED_PLUGINS_INTRO}\n\n{plugins}\n") + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 242e35f37..d632609c0 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -30,6 +30,7 @@ use crate::context::ContextualUserFragment; use crate::context::NetworkRuleSaved; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; +use crate::context::RecommendedPluginsInstructions; use crate::default_skill_metadata_budget; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::exec_policy::ExecPolicyManager; @@ -325,6 +326,7 @@ use crate::turn_timing::record_turn_ttfm_metric; use crate::unified_exec::UnifiedExecProcessManager; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_core_plugins::PluginsManager; +use codex_core_plugins::RecommendedPluginCandidatesInput; use codex_git_utils::get_git_repo_root; use codex_mcp::McpConfig; use codex_mcp::compute_auth_statuses; @@ -2975,6 +2977,29 @@ impl Session { .plugins_manager .plugins_for_config(&turn_context.config.plugins_config_input()) .await; + let recommended_plugin_candidates = + if crate::tools::spec_plan::tool_suggest_enabled(turn_context) { + let auth = self.services.auth_manager.auth().await; + let plugins_config = turn_context.config.plugins_config_input(); + self.services + .plugins_manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &plugins_config, + loaded_plugins: &loaded_plugins, + auth: auth.as_ref(), + disabled_tools: &turn_context.config.tool_suggest.disabled_tools, + app_server_client_name: turn_context.app_server_client_name.as_deref(), + }) + .await + } else { + None + }; + if let Some(recommended_plugins) = recommended_plugin_candidates + .as_deref() + .and_then(RecommendedPluginsInstructions::from_plugins) + { + contextual_user_sections.push(recommended_plugins.render()); + } if let Some(plugin_instructions) = AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries()) { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 8eb7d5f9e..4b052cb65 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -73,6 +73,7 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_core_plugins::RecommendedPluginCandidatesInput; use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; @@ -1186,49 +1187,78 @@ pub(crate) async fn built_tools( } else { None }; - let auth = sess.services.auth_manager.auth().await; - let loaded_plugin_app_connector_ids = loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0) - .collect::>(); - let tool_suggest_candidates = async { - if apps_enabled && tool_suggest_enabled(turn_context) { - if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { - match connectors::list_tool_suggest_discoverable_tools_with_auth( - &turn_context.config, - sess.services.plugins_manager.as_ref(), - auth.as_ref(), - accessible_connectors.as_slice(), - &loaded_plugin_app_connector_ids, - ) - .await - .map(|discoverable_tools| { - filter_request_plugin_install_discoverable_tools_for_client( - discoverable_tools, - turn_context.app_server_client_name.as_deref(), - ) - }) { - Ok(discoverable_tools) if discoverable_tools.is_empty() => None, - Ok(discoverable_tools) => Some(ToolSuggestCandidates { - tools: discoverable_tools, - presentation: ToolSuggestPresentation::ListTool, - }), - Err(err) => { - warn!("failed to load discoverable tool suggestions: {err:#}"); + let tool_suggest_is_enabled = tool_suggest_enabled(turn_context); + let auth = if tool_suggest_is_enabled { + sess.services.auth_manager.auth().await + } else { + None + }; + let endpoint_recommended_plugin_candidates = if tool_suggest_is_enabled { + let plugins_config = turn_context.config.plugins_config_input(); + sess.services + .plugins_manager + .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { + plugins_config: &plugins_config, + loaded_plugins: &loaded_plugins, + auth: auth.as_ref(), + disabled_tools: &turn_context.config.tool_suggest.disabled_tools, + app_server_client_name: turn_context.app_server_client_name.as_deref(), + }) + .await + } else { + None + }; + let tool_suggest_candidates = + if let Some(recommended_plugin_candidates) = endpoint_recommended_plugin_candidates { + Some(ToolSuggestCandidates { + tools: recommended_plugin_candidates, + presentation: ToolSuggestPresentation::RecommendationContext, + }) + } else { + let loaded_plugin_app_connector_ids = loaded_plugins + .effective_apps() + .into_iter() + .map(|connector_id| connector_id.0) + .collect::>(); + async { + if apps_enabled && tool_suggest_is_enabled { + if let Some(accessible_connectors) = + accessible_connectors_with_enabled_state.as_ref() + { + match connectors::list_tool_suggest_discoverable_tools_with_auth( + &turn_context.config, + sess.services.plugins_manager.as_ref(), + auth.as_ref(), + accessible_connectors.as_slice(), + &loaded_plugin_app_connector_ids, + ) + .await + .map(|discoverable_tools| { + filter_request_plugin_install_discoverable_tools_for_client( + discoverable_tools, + turn_context.app_server_client_name.as_deref(), + ) + }) { + Ok(discoverable_tools) if discoverable_tools.is_empty() => None, + Ok(discoverable_tools) => Some(ToolSuggestCandidates { + tools: discoverable_tools, + presentation: ToolSuggestPresentation::ListTool, + }), + Err(err) => { + warn!("failed to load discoverable tool suggestions: {err:#}"); + None + } + } + } else { None } + } else { + None } - } else { - None } - } else { - None - } - } - .instrument(trace_span!("built_tools.load_discoverable_tools")) - .await; - + .instrument(trace_span!("built_tools.load_discoverable_tools")) + .await + }; let mcp_tool_exposure = build_mcp_tool_exposure( &all_mcp_tools, connectors.as_deref(), diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index c4622615d..0a7f19546 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -130,8 +130,8 @@ impl RequestPluginInstallHandler { ToolSuggestPresentation::ListTool => format!( "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" ), - ToolSuggestPresentation::DeveloperContext => { - "the developer recommendations".to_string() + ToolSuggestPresentation::RecommendationContext => { + "the list".to_string() } }; FunctionCallError::RespondToModel(format!( diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs index 1c9bfec01..fa3bbdab0 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs @@ -39,8 +39,8 @@ pub(crate) fn create_request_plugin_install_tool( ToolSuggestPresentation::ListTool => format!( "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." ), - ToolSuggestPresentation::DeveloperContext => - "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the developer `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), + ToolSuggestPresentation::RecommendationContext => + "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(), }; ToolSpec::Function(ResponsesApiTool { @@ -126,15 +126,15 @@ mod tests { } #[test] - fn developer_recommendations_change_only_the_description() { + fn recommendation_context_changes_only_the_description() { let mut expected = create_request_plugin_install_tool(ToolSuggestPresentation::ListTool); let recommendations = - create_request_plugin_install_tool(ToolSuggestPresentation::DeveloperContext); + create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext); let ToolSpec::Function(expected_function) = &mut expected else { panic!("expected function tool specs"); }; - expected_function.description = "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the developer `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(); + expected_function.description = "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(); assert_eq!(recommendations, expected); } diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 7cc72395f..0acd6e37c 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -48,8 +48,7 @@ pub(crate) struct ToolRouterParams<'a> { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ToolSuggestPresentation { ListTool, - #[allow(dead_code)] - DeveloperContext, + RecommendationContext, } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index ee7cd7e35..fc17ff5d3 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -883,7 +883,7 @@ async fn request_plugin_install_requires_all_discovery_features() { None, Some(ToolSuggestCandidates { tools: Vec::new(), - presentation: ToolSuggestPresentation::DeveloperContext, + presentation: ToolSuggestPresentation::RecommendationContext, }), ] { let plan = probe_with( @@ -959,7 +959,7 @@ async fn request_plugin_install_description_refers_to_recommended_plugins_hint() }, ToolPlanInputs { tool_suggest_candidates: Some(plugin_candidates( - ToolSuggestPresentation::DeveloperContext, + ToolSuggestPresentation::RecommendationContext, )), ..ToolPlanInputs::default() }, @@ -973,7 +973,7 @@ async fn request_plugin_install_description_refers_to_recommended_plugins_hint() else { panic!("expected request_plugin_install function spec"); }; - assert!(request_description.contains("developer `` list")); + assert!(request_description.contains("the `` list")); assert!(!request_description.contains("list_available_plugins_to_install")); assert!(!request_description.contains("github")); plan.assert_visible_lacks(&["list_available_plugins_to_install"]); diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index b35ecbc22..9812d3941 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -2,24 +2,46 @@ #![allow(clippy::unwrap_used)] use anyhow::Result; +use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::ToolSuggestDiscoverableType; use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; 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; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use serde_json::Value; +use serde_json::json; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; @@ -43,36 +65,20 @@ fn tool_names(body: &Value) -> Vec { .unwrap_or_default() } -fn function_tool_description(body: &Value, name: &str) -> Option { - body.get("tools") - .and_then(Value::as_array) - .and_then(|tools| { - tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some(name) { - tool.get("description") - .and_then(Value::as_str) - .map(str::to_string) - } else { - None - } - }) - }) -} - fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) { - config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::Plugins) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::ToolSuggest) - .expect("test config should allow feature update"); - let mut model_catalog = bundled_models_response().expect("bundled models.json should parse"); + for feature in [ + Feature::Apps, + Feature::Plugins, + Feature::RemotePlugin, + Feature::ToolSuggest, + ] { + config + .features + .enable(feature) + .expect("test config should allow feature update"); + } + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); let model = model_catalog .models .iter_mut() @@ -88,13 +94,326 @@ fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) config.model_catalog = Some(model_catalog); } +async fn mount_recommendations(server: &wiremock::MockServer, response: ResponseTemplate) { + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with(response) + .mount(server) + .await; +} + +fn assert_legacy_tools(body: &Value) { + let tools = tool_names(body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!( + tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME), + "legacy mode should expose {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}" + ); + assert!( + tools + .iter() + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME), + "legacy mode should expose {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}" + ); +} + +async fn build_test( + server: &wiremock::MockServer, + apps_server: &AppsTestServer, +) -> Result { + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config({ + let apps_base_url = apps_server.chatgpt_base_url.clone(); + move |config| configure_apps_without_search_tool(config, apps_base_url.as_str()) + }); + builder.build(server).await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn request_plugin_install_is_available_without_search_tool_after_discovery_attempts() --> Result<()> { +async fn explicit_false_preserves_legacy_workflow() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({"enabled": false, "plugins": []})), + ) + .await; + let call_id = "list-installable-tools"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME, "{}"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + test.submit_turn_with_approval_and_permission_profile( + "list tools", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let request = &requests[0]; + assert!( + !request + .message_input_texts("user") + .join("\n") + .contains("") + ); + assert_legacy_tools(&request.body_json()); + let output = requests[1] + .function_call_output_text(call_id) + .expect("list tool output"); + let output: Value = serde_json::from_str(&output)?; + assert!(output["tools"].as_array().is_some_and(|tools| { + tools + .iter() + .any(|tool| tool["id"] == DISCOVERABLE_GMAIL_ID && tool["tool_type"] == "connector") + })); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_mode_injects_candidates_hides_list_and_rejects_invented_ids() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [ + { + "id": "plugin_google_calendar", + "name": "google-calendar", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "Google Calendar"} + }, + { + "id": "plugin_github", + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": {"display_name": "GitHub"} + } + ] + })), + ) + .await; + let call_id = "invented-plugin"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + REQUEST_PLUGIN_INSTALL_TOOL_NAME, + &serde_json::to_string(&json!({ + "tool_type": "plugin", + "action_type": "install", + "tool_id": "invented@openai-curated-remote", + "suggest_reason": "Try this" + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + + test.submit_turn("suggest a plugin").await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let contextual_user_message = requests[0].message_input_texts("user").join("\n"); + assert!(contextual_user_message.contains("")); + assert!(contextual_user_message.contains("github@openai-curated-remote")); + assert!(contextual_user_message.contains("google-calendar@openai-curated-remote")); + let body = requests[0].body_json(); + let tools = tool_names(&body); + assert!( + !tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) + ); + assert!( + tools + .iter() + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME) + ); + let output = requests[1] + .function_call_output_text(call_id) + .expect("request tool output"); + assert!(output.contains(" list")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metadata() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + const REMOTE_PLUGIN_ID: &str = "plugin_connector_github"; + const APP_CONNECTOR_ID: &str = "connector_github"; + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": REMOTE_PLUGIN_ID, + "name": "github", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": { + "display_name": "GitHub", + "app_ids": [APP_CONNECTOR_ID] + } + }] + })), + ) + .await; + let call_id = "install-github"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + REQUEST_PLUGIN_INSTALL_TOOL_NAME, + &serde_json::to_string(&json!({ + "tool_type": "plugin", + "action_type": "install", + "tool_id": "github@openai-curated-remote", + "suggest_reason": "Use GitHub for this request" + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "use GitHub".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let elicitation = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ElicitationRequest(request) => Some(request.clone()), + _ => None, + }) + .await; + let ElicitationRequest::Form { + meta: Some(meta), .. + } = &elicitation.request + else { + panic!("expected form elicitation metadata"); + }; + assert_eq!(meta["remote_plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(meta["app_connector_ids"], json!([APP_CONNECTOR_ID])); + + test.codex + .submit(Op::ResolveElicitation { + server_name: elicitation.server_name, + request_id: elicitation.id, + decision: ElicitationAction::Decline, + content: None, + meta: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + for request in requests { + let body = request.body_json().to_string(); + assert!(!body.contains(REMOTE_PLUGIN_ID)); + assert!(!body.contains(APP_CONNECTOR_ID)); + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn endpoint_mode_with_no_eligible_candidates_exposes_no_suggestion_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": "plugin_google_calendar", + "name": "google-calendar", + "release": {"display_name": "Google Calendar"} + }] + })), + ) + .await; let mock = mount_sse_once( &server, sse(vec![ @@ -104,61 +423,38 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery ]), ) .await; - let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| { - configure_apps_without_search_tool(config, apps_server.chatgpt_base_url.as_str()) + .with_config({ + let apps_base_url = apps_server.chatgpt_base_url.clone(); + move |config| { + configure_apps_without_search_tool(config, apps_base_url.as_str()); + config.tool_suggest.disabled_tools = vec![ToolSuggestDisabledTool::plugin( + "google-calendar@openai-curated-remote", + )]; + } }); let test = builder.build(&server).await?; - test.submit_turn_with_approval_and_permission_profile( - "list tools", - AskForApproval::Never, - PermissionProfile::Disabled, - ) - .await?; + test.submit_turn("list tools").await?; - let body = mock.single_request().body_json(); - let tools = tool_names(&body); + let request = mock.single_request(); assert!( - !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), - "tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}" + !request + .message_input_texts("user") + .join("\n") + .contains("") ); + let tools = tool_names(&request.body_json()); assert!( - tools + !tools .iter() - .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME), - "tools list should include {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}" + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) ); assert!( - tools + !tools .iter() - .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME), - "tools list should include {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}" + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME) ); - - let list_description = - function_tool_description(&body, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) - .expect("description"); - assert!(list_description.contains( - "The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list." - )); - assert!(list_description.contains( - "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." - )); - assert!(list_description.contains( - "When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed." - )); - - let description = - function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description"); - assert!(description.contains( - "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request." - )); - assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools.")); - assert!(!description.contains(DISCOVERABLE_GMAIL_ID)); - assert!(!description.contains("tool_search fails to find a good match")); - Ok(()) }