diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index 8f8be1107..e58be65f9 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -3,7 +3,6 @@ use std::collections::HashSet; use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo as McpToolInfo; -use codex_tools::ToolsConfig; use crate::config::Config; use crate::connectors; @@ -19,7 +18,7 @@ pub(crate) fn build_mcp_tool_exposure( all_mcp_tools: &[McpToolInfo], connectors: Option<&[connectors::AppInfo]>, config: &Config, - tools_config: &ToolsConfig, + search_tool_enabled: bool, ) -> McpToolExposure { let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools); if let Some(connectors) = connectors { @@ -30,7 +29,7 @@ pub(crate) fn build_mcp_tool_exposure( )); } - let should_defer = tools_config.search_tool + let should_defer = search_tool_enabled && (config .features .enabled(Feature::ToolSearchAlwaysDeferMcpTools) diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index 06c9d1255..fba7fe1f8 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -2,17 +2,9 @@ use std::collections::HashSet; use std::sync::Arc; use codex_features::Feature; -use codex_features::Features; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; -use codex_models_manager::test_support::construct_model_info_offline_for_tests; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::SessionSource; use codex_tools::ToolName; -use codex_tools::ToolsConfig; -use codex_tools::ToolsConfigParams; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; @@ -94,34 +86,14 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet { .collect() } -async fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig { - let config = test_config().await; - let model_info = - construct_model_info_offline_for_tests("gpt-5.4", &config.to_models_manager_config()); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.search_tool = search_tool; - tools_config -} - #[tokio::test] async fn directly_exposes_small_effective_tool_sets() { let config = test_config().await; - let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1); - let exposure = - build_mcp_tool_exposure(&mcp_tools, /*connectors*/ None, &config, &tools_config); + let exposure = build_mcp_tool_exposure( + &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, + ); assert_eq!(tool_names(&exposure.direct_tools), tool_names(&mcp_tools)); assert!(exposure.deferred_tools.is_none()); @@ -130,11 +102,11 @@ async fn directly_exposes_small_effective_tool_sets() { #[tokio::test] async fn searches_large_effective_tool_sets() { let config = test_config().await; - let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD); - let exposure = - build_mcp_tool_exposure(&mcp_tools, /*connectors*/ None, &config, &tools_config); + let exposure = build_mcp_tool_exposure( + &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, + ); assert!(exposure.direct_tools.is_empty()); let deferred_tools = exposure @@ -151,7 +123,6 @@ async fn always_defer_feature_defers_apps_too() { .features .enable(Feature::ToolSearchAlwaysDeferMcpTools) .expect("test config should allow feature update"); - let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; let mcp_tools = vec![ make_mcp_tool( "rmcp", @@ -176,7 +147,7 @@ async fn always_defer_feature_defers_apps_too() { &mcp_tools, Some(connectors.as_slice()), &config, - &tools_config, + /*search_tool_enabled*/ true, ); assert!(exposure.direct_tools.is_empty()); diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index baa64459a..9e9601c73 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -43,14 +43,16 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) .ok() .map(|params| { - if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) { + if !invocation.turn.config.permissions.allow_login_shell + && params.login == Some(true) + { #[allow(deprecated)] let cwd = invocation.turn.resolve_path(params.workdir).to_path_buf(); return (Vec::new(), cwd); } let use_login_shell = params .login - .unwrap_or(invocation.turn.tools_config.allow_login_shell); + .unwrap_or(invocation.turn.config.permissions.allow_login_shell); let command = invocation .session .user_shell() @@ -65,8 +67,8 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec, turn_context: Arc) -> ToolCallRuntime { - let router = Arc::new(ToolRouter::from_config( - &turn_context.tools_config, + let router = Arc::new(ToolRouter::from_turn_context( + &turn_context, crate::tools::router::ToolRouterParams { mcp_tools: None, deferred_mcp_tools: None, @@ -9259,8 +9259,8 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { .await }; let deferred_mcp_tools = Some(tools.clone()); - let router = ToolRouter::from_config( - &turn_context.tools_config, + let router = ToolRouter::from_turn_context( + &turn_context, crate::tools::router::ToolRouterParams { deferred_mcp_tools, mcp_tools: Some(tools), @@ -9828,9 +9828,10 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { turn_context_mut.permission_profile = PermissionProfile::Disabled; let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); - let command = session - .user_shell() - .derive_exec_args(command_script, turn_context.tools_config.allow_login_shell); + let command = session.user_shell().derive_exec_args( + command_script, + turn_context.config.permissions.allow_login_shell, + ); let exec_approval_requirement = session .services .exec_policy diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 27d35504f..5c4567b9f 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -55,6 +55,8 @@ use crate::tools::parallel::ToolCallRuntime; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolRouterParams; use crate::tools::router::extension_tool_executors; +use crate::tools::spec_plan::search_tool_enabled; +use crate::tools::spec_plan::tool_suggest_enabled; use crate::turn_diff_tracker::TurnDiffTracker; use crate::turn_timing::record_turn_ttft_metric; use crate::util::backoff; @@ -1120,7 +1122,7 @@ pub(crate) async fn built_tools( None }; let auth = sess.services.auth_manager.auth().await; - let discoverable_tools = if apps_enabled && turn_context.tools_config.tool_suggest { + let discoverable_tools = 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, @@ -1152,12 +1154,12 @@ pub(crate) async fn built_tools( &all_mcp_tools, connectors.as_deref(), &turn_context.config, - &turn_context.tools_config, + search_tool_enabled(turn_context), ); let mcp_tools = has_mcp_servers.then_some(mcp_tool_exposure.direct_tools); let deferred_mcp_tools = mcp_tool_exposure.deferred_tools; - Ok(Arc::new(ToolRouter::from_config( - &turn_context.tools_config, + Ok(Arc::new(ToolRouter::from_turn_context( + turn_context, ToolRouterParams { mcp_tools, deferred_mcp_tools, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b14999d2c..3338d0a55 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -14,10 +14,6 @@ use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool { - auth_manager.is_some_and(AuthManager::current_auth_uses_codex_backend) -} - #[derive(Clone, Debug)] pub(crate) struct TurnSkillsContext { pub(crate) outcome: Arc, @@ -84,7 +80,9 @@ pub struct TurnContext { pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, - pub(crate) tools_config: ToolsConfig, + pub(crate) available_models: Vec, + pub(crate) unified_exec_shell_mode: UnifiedExecShellMode, + pub(crate) goal_tools_supported: bool, pub features: ManagedFeatures, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, @@ -156,6 +154,14 @@ impl TurnContext { self.features.apps_enabled_for_auth(uses_codex_backend) } + pub(crate) fn tool_environment_mode(&self) -> ToolEnvironmentMode { + ToolEnvironmentMode::from_count(self.environments.turn_environments.len()) + } + + pub(crate) fn goal_tools_enabled(&self) -> bool { + self.goal_tools_supported && self.features.get().enabled(Feature::Goals) + } + pub(crate) async fn with_model( &self, model: String, @@ -195,61 +201,9 @@ impl TurnContext { /*developer_instructions*/ None, ); let features = self.features.clone(); - let provider_capabilities = self.provider.capabilities(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &models_manager - .list_models(RefreshStrategy::OnlineIfUncached) - .await, - features: &features, - image_generation_tool_auth_allowed: image_generation_tool_auth_allowed( - self.auth_manager.as_deref(), - ), - web_search_mode: self.tools_config.web_search_mode, - session_source: self.session_source.clone(), - permission_profile: &self.permission_profile, - windows_sandbox_level: self.windows_sandbox_level, - }) - .with_namespace_tools_capability(provider_capabilities.namespace_tools) - .with_image_generation_capability(provider_capabilities.image_generation) - .with_web_search_capability(provider_capabilities.web_search) - .with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone()) - .with_web_search_config(self.tools_config.web_search_config.clone()) - .with_allow_login_shell(self.tools_config.allow_login_shell) - .with_environment_mode(self.tools_config.environment_mode) - .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) - .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) - .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) - .with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone()) - .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) - .with_goal_tools_allowed(self.tools_config.goal_tools) - .with_max_concurrent_threads_per_session( - config - .features - .enabled(Feature::MultiAgentV2) - .then_some(config.multi_agent_v2.max_concurrent_threads_per_session), - ) - .with_wait_agent_min_timeout_ms( - config - .features - .enabled(Feature::MultiAgentV2) - .then_some(config.multi_agent_v2.min_wait_timeout_ms), - ) - .with_wait_agent_max_timeout_ms( - config - .features - .enabled(Feature::MultiAgentV2) - .then_some(config.multi_agent_v2.max_wait_timeout_ms), - ) - .with_wait_agent_default_timeout_ms( - config - .features - .enabled(Feature::MultiAgentV2) - .then_some(config.multi_agent_v2.default_wait_timeout_ms), - ) - .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( - &config.agent_roles, - )); + let available_models = models_manager + .list_models(RefreshStrategy::OnlineIfUncached) + .await; Self { sub_id: self.sub_id.clone(), @@ -283,7 +237,9 @@ impl TurnContext { network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, shell_environment_policy: self.shell_environment_policy.clone(), - tools_config, + available_models, + unified_exec_shell_mode: self.unified_exec_shell_mode.clone(), + goal_tools_supported: self.goal_tools_supported, features, ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), @@ -503,72 +459,18 @@ impl Session { model_info.slug.as_str(), ); let session_source = session_configuration.session_source.clone(); - let image_generation_tool_auth_allowed = - image_generation_tool_auth_allowed(auth_manager.as_deref()); let auth_manager_for_context = auth_manager.clone(); let provider_for_context = create_model_provider(provider, auth_manager); - let provider_capabilities = provider_for_context.capabilities(); let session_telemetry_for_context = session_telemetry; - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &models_manager.try_list_models().unwrap_or_default(), - features: &per_turn_config.features, - image_generation_tool_auth_allowed, - web_search_mode: Some(per_turn_config.web_search_mode.value()), - session_source: session_source.clone(), - permission_profile: &session_configuration.permission_profile(), - windows_sandbox_level: session_configuration.windows_sandbox_level, - }) - .with_namespace_tools_capability(provider_capabilities.namespace_tools) - .with_image_generation_capability(provider_capabilities.image_generation) - .with_web_search_capability(provider_capabilities.web_search) - .with_unified_exec_shell_mode_for_session( + let available_models = models_manager.try_list_models().unwrap_or_default(); + let shell_command_backend = + shell_command_backend_for_features(per_turn_config.features.get()); + let unified_exec_shell_mode = UnifiedExecShellMode::for_session( + shell_command_backend, crate::tools::tool_user_shell_type(user_shell), shell_zsh_path, main_execve_wrapper_exe, - ) - .with_web_search_config(per_turn_config.web_search_config.clone()) - .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) - .with_environment_mode(ToolEnvironmentMode::from_count( - environments.turn_environments.len(), - )) - .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) - .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) - .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) - .with_multi_agent_v2_tool_namespace(per_turn_config.multi_agent_v2.tool_namespace.clone()) - .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) - .with_goal_tools_allowed(goal_tools_supported) - .with_max_concurrent_threads_per_session( - per_turn_config - .features - .enabled(Feature::MultiAgentV2) - .then_some( - per_turn_config - .multi_agent_v2 - .max_concurrent_threads_per_session, - ), - ) - .with_wait_agent_min_timeout_ms( - per_turn_config - .features - .enabled(Feature::MultiAgentV2) - .then_some(per_turn_config.multi_agent_v2.min_wait_timeout_ms), - ) - .with_wait_agent_max_timeout_ms( - per_turn_config - .features - .enabled(Feature::MultiAgentV2) - .then_some(per_turn_config.multi_agent_v2.max_wait_timeout_ms), - ) - .with_wait_agent_default_timeout_ms( - per_turn_config - .features - .enabled(Feature::MultiAgentV2) - .then_some(per_turn_config.multi_agent_v2.default_wait_timeout_ms), - ) - .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( - &per_turn_config.agent_roles, - )); + ); let mut per_turn_config = per_turn_config; per_turn_config.service_tier = per_turn_config @@ -616,7 +518,9 @@ impl Session { network, windows_sandbox_level: session_configuration.windows_sandbox_level, shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), - tools_config, + available_models, + unified_exec_shell_mode, + goal_tools_supported, features: per_turn_config.features.clone(), ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index 2d095906e..28ffbb7a6 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -268,8 +268,8 @@ async fn handle_output_item_done_returns_contributed_last_agent_message() { session.services.extensions = Arc::new(builder.build()); let session = Arc::new(session); let turn_context = Arc::new(turn_context); - let router = Arc::new(ToolRouter::from_config( - &turn_context.tools_config, + let router = Arc::new(ToolRouter::from_turn_context( + &turn_context, crate::tools::router::ToolRouterParams { mcp_tools: None, deferred_mcp_tools: None, diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index 7e31b86d1..4dffdd688 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -183,7 +183,7 @@ impl ToolExecutor for ShellCommandHandler { session.as_ref(), turn.as_ref(), session.conversation_id, - turn.tools_config.allow_login_shell, + turn.config.permissions.allow_login_shell, )?; let shell_type = Some(session.user_shell().shell_type.clone()); run_exec_like(RunExecLikeArgs { diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index b048227cf..af1c75252 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -144,8 +144,8 @@ impl ToolExecutor for ExecCommandHandler { let resolved_command = get_command( &args, session.user_shell(), - &turn.tools_config.unified_exec_shell_mode, - turn.tools_config.allow_login_shell, + &turn.unified_exec_shell_mode, + turn.config.permissions.allow_login_shell, ) .map_err(FunctionCallError::RespondToModel)?; let command = resolved_command.command; diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 5b7a17f42..1171596f8 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod runtimes; pub(crate) mod sandboxing; pub(crate) mod spec_plan; pub(crate) mod tool_dispatch_trace; +pub(crate) mod tool_family; pub(crate) mod tool_search_entry; use std::borrow::Cow; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 363c3f2a0..96837497f 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -287,8 +287,15 @@ impl ToolRegistry { } #[cfg(test)] - pub(crate) fn has_tool(&self, name: &ToolName) -> bool { - self.tool(name).is_some() + pub(crate) fn tool_names_for_test(&self) -> Vec { + let mut names = self.tools.keys().cloned().collect::>(); + names.sort(); + names + } + + #[cfg(test)] + pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { + self.tools.get(name).map(|tool| tool.exposure()) } pub(crate) fn create_diff_consumer( diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index a279ec88d..cfd477689 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -17,7 +17,6 @@ use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; -use codex_tools::ToolsConfig; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tokio_util::sync::CancellationToken; @@ -46,8 +45,8 @@ pub(crate) struct ToolRouterParams<'a> { } impl ToolRouter { - pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self { - build_tool_router(config, params) + pub fn from_turn_context(turn_context: &TurnContext, params: ToolRouterParams<'_>) -> Self { + build_tool_router(turn_context, params) } pub(crate) fn from_parts(registry: ToolRegistry, model_visible_specs: Vec) -> Self { @@ -61,6 +60,19 @@ impl ToolRouter { self.model_visible_specs.clone() } + #[cfg(test)] + pub(crate) fn registered_tool_names_for_test(&self) -> Vec { + self.registry.tool_names_for_test() + } + + #[cfg(test)] + pub(crate) fn tool_exposure_for_test( + &self, + name: &ToolName, + ) -> Option { + self.registry.tool_exposure(name) + } + pub(crate) fn create_diff_consumer( &self, tool_name: &ToolName, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index c9bb4bcaf..92073d56b 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -106,8 +106,8 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow .await .list_all_tools() .await; - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: Some(mcp_tools), @@ -172,8 +172,8 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() #[tokio::test] async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: Some(vec![ @@ -220,8 +220,8 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { #[tokio::test] async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, @@ -272,8 +272,8 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> { }, ]; - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, @@ -328,8 +328,8 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow let (mut session, turn) = make_session_and_context().await; session.services.extensions = extension_tool_test_registry(); - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 3f355d250..26c91523c 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1,6 +1,4 @@ -use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; -use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; -use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; +use crate::session::turn_context::TurnContext; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::context::ToolInvocation; use crate::tools::handlers::ApplyPatchHandler; @@ -8,8 +6,6 @@ use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; -use crate::tools::handlers::ExecCommandHandler; -use crate::tools::handlers::ExecCommandHandlerOptions; use crate::tools::handlers::GetGoalHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; @@ -19,13 +15,10 @@ use crate::tools::handlers::ReadMcpResourceHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; -use crate::tools::handlers::ShellCommandHandler; -use crate::tools::handlers::ShellCommandHandlerOptions; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; use crate::tools::handlers::UpdateGoalHandler; use crate::tools::handlers::ViewImageHandler; -use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler; use crate::tools::handlers::extension_tools::ExtensionToolAdapter; @@ -55,9 +48,15 @@ use crate::tools::registry::ToolRegistry; use crate::tools::registry::override_tool_exposure; use crate::tools::router::ToolRouter; use crate::tools::router::ToolRouterParams; +use crate::tools::tool_family::shell::ShellToolsOptions; +use crate::tools::tool_family::shell::build_shell_tools; +use codex_features::Feature; +use codex_login::AuthManager; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; @@ -68,9 +67,12 @@ use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolOutput; use codex_tools::ToolSpec; -use codex_tools::ToolsConfig; +use codex_tools::can_request_original_image_detail; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; use codex_tools::default_namespace_description; +use codex_tools::request_user_input_available_modes; +use codex_tools::shell_command_backend_for_features; +use codex_tools::shell_type_for_model_and_features; use std::collections::BTreeMap; use std::collections::HashSet; use std::sync::Arc; @@ -78,8 +80,34 @@ use tracing::warn; const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; +type PlannedRuntime = Arc; + +#[derive(Default)] +struct PlannedTools { + runtimes: Vec, + hosted_specs: Vec, +} + +impl PlannedTools { + fn add_runtime(&mut self, runtime: T) + where + T: CoreToolRuntime + 'static, + { + self.runtimes.push(Arc::new(runtime)); + } + + fn add_runtime_arc(&mut self, runtime: PlannedRuntime) { + self.runtimes.push(runtime); + } + + fn runtimes(&self) -> &[PlannedRuntime] { + &self.runtimes + } +} + #[derive(Clone, Copy)] -struct ToolRegistryBuildParams<'a> { +struct CoreToolPlanContext<'a> { + turn_context: &'a TurnContext, mcp_tools: Option<&'a [ToolInfo]>, deferred_mcp_tools: Option<&'a [ToolInfo]>, discoverable_tools: Option<&'a [DiscoverableTool]>, @@ -89,13 +117,16 @@ struct ToolRegistryBuildParams<'a> { wait_agent_timeouts: WaitAgentTimeoutOptions, } -pub(crate) fn build_tool_router(config: &ToolsConfig, params: ToolRouterParams<'_>) -> ToolRouter { - let (model_visible_specs, registry) = build_tool_specs_and_registry(config, params); +pub(crate) fn build_tool_router( + turn_context: &TurnContext, + params: ToolRouterParams<'_>, +) -> ToolRouter { + let (model_visible_specs, registry) = build_tool_specs_and_registry(turn_context, params); ToolRouter::from_parts(registry, model_visible_specs) } fn build_tool_specs_and_registry( - config: &ToolsConfig, + turn_context: &TurnContext, params: ToolRouterParams<'_>, ) -> (Vec, ToolRegistry) { let ToolRouterParams { @@ -107,65 +138,73 @@ fn build_tool_specs_and_registry( } = params; let default_agent_type_description = crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); - let mut executors = collect_tool_executors( - config, - ToolRegistryBuildParams { - mcp_tools: mcp_tools.as_deref(), - deferred_mcp_tools: deferred_mcp_tools.as_deref(), - discoverable_tools: discoverable_tools.as_deref(), - extension_tool_executors: &extension_tool_executors, - dynamic_tools, - default_agent_type_description: &default_agent_type_description, - wait_agent_timeouts: wait_agent_timeout_options(config), - }, - ); - append_tool_search_executor(config, &mut executors); - prepend_code_mode_executors(config, &mut executors); - build_model_visible_specs_and_registry(config, executors, hosted_model_tool_specs(config)) + let context = CoreToolPlanContext { + turn_context, + mcp_tools: mcp_tools.as_deref(), + deferred_mcp_tools: deferred_mcp_tools.as_deref(), + discoverable_tools: discoverable_tools.as_deref(), + extension_tool_executors: &extension_tool_executors, + dynamic_tools, + default_agent_type_description: &default_agent_type_description, + wait_agent_timeouts: wait_agent_timeout_options(turn_context), + }; + let mut planned_tools = PlannedTools::default(); + add_tool_sources(&context, &mut planned_tools); + append_tool_search_executor(&context, &mut planned_tools); + prepend_code_mode_executors(&context, &mut planned_tools); + build_model_visible_specs_and_registry(turn_context, planned_tools) } fn build_model_visible_specs_and_registry( - config: &ToolsConfig, - executors: Vec>, - hosted_specs: Vec, + turn_context: &TurnContext, + planned_tools: PlannedTools, ) -> (Vec, ToolRegistry) { + let PlannedTools { + runtimes, + hosted_specs, + } = planned_tools; let mut specs = Vec::new(); let mut seen_tool_names = HashSet::new(); - for executor in &executors { - let tool_name = executor.tool_name(); + for runtime in &runtimes { + let tool_name = runtime.tool_name(); if !seen_tool_names.insert(tool_name.clone()) { continue; } - let exposure = executor.exposure(); + let exposure = runtime.exposure(); if exposure.is_direct() - && !is_hidden_by_code_mode_only(config, &tool_name, exposure) - && let Some(spec) = executor.spec() + && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure) + && let Some(spec) = runtime.spec() { - specs.push(spec_for_model_request(config, exposure, spec)); + specs.push(spec_for_model_request(turn_context, exposure, spec)); } } for spec in hosted_specs { - if !is_hidden_by_code_mode_only(config, &ToolName::plain(spec.name()), ToolExposure::Direct) - { + if !is_hidden_by_code_mode_only( + turn_context, + &ToolName::plain(spec.name()), + ToolExposure::Direct, + ) { specs.push(spec); } } - let registry = ToolRegistry::from_tools(executors); + let registry = ToolRegistry::from_tools(runtimes); let model_visible_specs = merge_into_namespaces(specs) .into_iter() - .filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_))) + .filter(|spec| { + namespace_tools_enabled(turn_context) || !matches!(spec, ToolSpec::Namespace(_)) + }) .collect(); (model_visible_specs, registry) } fn spec_for_model_request( - config: &ToolsConfig, + turn_context: &TurnContext, exposure: ToolExposure, spec: ToolSpec, ) -> ToolSpec { - if config.code_mode_enabled + if code_mode_enabled(turn_context) && exposure != ToolExposure::DirectModelOnly && codex_code_mode::is_code_mode_nested_tool(spec.name()) { @@ -175,33 +214,104 @@ fn spec_for_model_request( } } -pub(crate) fn hosted_model_tool_specs(config: &ToolsConfig) -> Vec { +pub(crate) fn hosted_model_tool_specs(turn_context: &TurnContext) -> Vec { let mut specs = Vec::new(); + let provider_capabilities = turn_context.provider.capabilities(); + let web_search_mode = provider_capabilities + .web_search + .then_some(turn_context.config.web_search_mode.value()); + let web_search_config = if provider_capabilities.web_search { + turn_context.config.web_search_config.as_ref() + } else { + None + }; if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions { - web_search_mode: config.web_search_mode, - web_search_config: config.web_search_config.as_ref(), - web_search_tool_type: config.web_search_tool_type, + web_search_mode, + web_search_config, + web_search_tool_type: turn_context.model_info.web_search_tool_type, }) { specs.push(web_search_tool); } - if config.image_gen_tool { + if image_generation_tool_enabled(turn_context) { specs.push(create_image_generation_tool("png")); } specs } -fn wait_agent_timeout_options(config: &ToolsConfig) -> WaitAgentTimeoutOptions { - if config.multi_agent_v2 { +pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool { + turn_context.model_info.supports_search_tool +} + +pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool { + let features = turn_context.features.get(); + features.enabled(Feature::ToolSuggest) + && features.enabled(Feature::Apps) + && features.enabled(Feature::Plugins) +} + +fn namespace_tools_enabled(turn_context: &TurnContext) -> bool { + turn_context.provider.capabilities().namespace_tools +} + +fn code_mode_enabled(turn_context: &TurnContext) -> bool { + turn_context.features.get().enabled(Feature::CodeMode) +} + +fn code_mode_only_enabled(turn_context: &TurnContext) -> bool { + code_mode_enabled(turn_context) && turn_context.features.get().enabled(Feature::CodeModeOnly) +} + +fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool { + turn_context.features.get().enabled(Feature::MultiAgentV2) +} + +fn collab_tools_enabled(turn_context: &TurnContext) -> bool { + multi_agent_v2_enabled(turn_context) || turn_context.features.get().enabled(Feature::Collab) +} + +fn goal_tools_enabled(turn_context: &TurnContext) -> bool { + turn_context.goal_tools_enabled() + && !matches!( + turn_context.session_source, + SessionSource::SubAgent(SubAgentSource::Review) + ) +} + +fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { + turn_context.features.get().enabled(Feature::SpawnCsv) +} + +fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { + agent_jobs_tools_enabled(turn_context) + && matches!( + &turn_context.session_source, + SessionSource::SubAgent(SubAgentSource::Other(label)) + if label.starts_with("agent_job:") + ) +} + +fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool { + turn_context + .auth_manager + .as_deref() + .is_some_and(AuthManager::current_auth_uses_codex_backend) + && turn_context.provider.capabilities().image_generation + && turn_context + .features + .get() + .enabled(Feature::ImageGeneration) + && turn_context + .model_info + .input_modalities + .contains(&InputModality::Image) +} + +fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOptions { + if multi_agent_v2_enabled(turn_context) { return WaitAgentTimeoutOptions { - default_timeout_ms: config - .wait_agent_default_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS), - min_timeout_ms: config - .wait_agent_min_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS), - max_timeout_ms: config - .wait_agent_max_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS), + default_timeout_ms: turn_context.config.multi_agent_v2.default_wait_timeout_ms, + min_timeout_ms: turn_context.config.multi_agent_v2.min_wait_timeout_ms, + max_timeout_ms: turn_context.config.multi_agent_v2.max_wait_timeout_ms, }; } @@ -212,20 +322,34 @@ fn wait_agent_timeout_options(config: &ToolsConfig) -> WaitAgentTimeoutOptions { } } -fn agent_type_description(config: &ToolsConfig, default_agent_type_description: &str) -> String { - if config.agent_type_description.is_empty() { +fn max_concurrent_threads_per_session(turn_context: &TurnContext) -> Option { + multi_agent_v2_enabled(turn_context).then_some( + turn_context + .config + .multi_agent_v2 + .max_concurrent_threads_per_session, + ) +} + +fn agent_type_description( + turn_context: &TurnContext, + default_agent_type_description: &str, +) -> String { + let agent_type_description = + crate::agent::role::spawn_tool_spec::build(&turn_context.config.agent_roles); + if agent_type_description.is_empty() { default_agent_type_description.to_string() } else { - config.agent_type_description.clone() + agent_type_description } } fn is_hidden_by_code_mode_only( - config: &ToolsConfig, + turn_context: &TurnContext, tool_name: &ToolName, exposure: ToolExposure, ) -> bool { - config.code_mode_only_enabled + code_mode_only_enabled(turn_context) && exposure != ToolExposure::DirectModelOnly && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( tool_name, @@ -233,11 +357,11 @@ fn is_hidden_by_code_mode_only( } fn build_code_mode_executors( - config: &ToolsConfig, + turn_context: &TurnContext, executors: &[Arc], deferred_tools_available: bool, ) -> Vec> { - if !config.code_mode_enabled { + if !code_mode_enabled(turn_context) { return vec![]; } @@ -262,7 +386,7 @@ fn build_code_mode_executors( create_code_mode_tool( &enabled_tools, &namespace_descriptions, - config.code_mode_only_enabled, + code_mode_only_enabled(turn_context), deferred_tools_available, ), code_mode_nested_tool_specs, @@ -339,203 +463,201 @@ fn code_mode_namespace_descriptions( namespace_descriptions } -fn collect_tool_executors( - config: &ToolsConfig, - params: ToolRegistryBuildParams<'_>, -) -> Vec> { - let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; - let mut executors = Vec::>::new(); +fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + add_shell_tools(context, planned_tools); + add_mcp_resource_tools(context, planned_tools); + add_core_utility_tools(context, planned_tools); + add_collaboration_tools(context, planned_tools); + add_mcp_runtime_tools(context, planned_tools); + add_dynamic_tools(context, planned_tools); + add_extension_tools(context, planned_tools); + planned_tools + .hosted_specs + .extend(hosted_model_tool_specs(context.turn_context)); +} - if config.environment_mode.has_environment() { - let include_environment_id = - matches!(config.environment_mode, ToolEnvironmentMode::Multiple); - match &config.shell_type { - ConfigShellToolType::UnifiedExec => { - executors.push(Arc::new(ExecCommandHandler::new( - ExecCommandHandlerOptions { - allow_login_shell: config.allow_login_shell, - exec_permission_approvals_enabled, - include_environment_id, - }, - ))); - executors.push(Arc::new(WriteStdinHandler)); - } - ConfigShellToolType::Disabled => {} - ConfigShellToolType::Default - | ConfigShellToolType::Local - | ConfigShellToolType::ShellCommand => { - executors.push(Arc::new(ShellCommandHandler::new( - ShellCommandHandlerOptions { - backend_config: config.shell_command_backend, - allow_login_shell: config.allow_login_shell, - exec_permission_approvals_enabled, - }, - ))); - } - } +fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + let turn_context = context.turn_context; + let features = turn_context.features.get(); + planned_tools + .runtimes + .extend(build_shell_tools(ShellToolsOptions { + shell_type: shell_type_for_model_and_features(&turn_context.model_info, features), + shell_command_backend: shell_command_backend_for_features(features), + environment_mode: turn_context.tool_environment_mode(), + allow_login_shell: turn_context.config.permissions.allow_login_shell, + exec_permission_approvals_enabled: features.enabled(Feature::ExecPermissionApprovals), + })); +} + +fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + if context.mcp_tools.is_some() { + planned_tools.add_runtime(ListMcpResourcesHandler); + planned_tools.add_runtime(ListMcpResourceTemplatesHandler); + planned_tools.add_runtime(ReadMcpResourceHandler); + } +} + +fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + let turn_context = context.turn_context; + let features = turn_context.features.get(); + let environment_mode = turn_context.tool_environment_mode(); + + planned_tools.add_runtime(PlanHandler); + if goal_tools_enabled(turn_context) { + planned_tools.add_runtime(GetGoalHandler); + planned_tools.add_runtime(CreateGoalHandler); + planned_tools.add_runtime(UpdateGoalHandler); } - if config.environment_mode.has_environment() - && config.shell_type != ConfigShellToolType::Disabled - { - match &config.shell_type { - ConfigShellToolType::UnifiedExec => { - executors.push(Arc::new(ShellCommandHandler::from( - config.shell_command_backend, - ))); - } - ConfigShellToolType::Default - | ConfigShellToolType::Local - | ConfigShellToolType::ShellCommand - | ConfigShellToolType::Disabled => {} - } + planned_tools.add_runtime(RequestUserInputHandler { + available_modes: request_user_input_available_modes(features), + }); + + if features.enabled(Feature::RequestPermissionsTool) { + planned_tools.add_runtime(RequestPermissionsHandler); } - if params.mcp_tools.is_some() { - executors.push(Arc::new(ListMcpResourcesHandler)); - executors.push(Arc::new(ListMcpResourceTemplatesHandler)); - executors.push(Arc::new(ReadMcpResourceHandler)); - } - - executors.push(Arc::new(PlanHandler)); - if config.goal_tools { - executors.push(Arc::new(GetGoalHandler)); - executors.push(Arc::new(CreateGoalHandler)); - executors.push(Arc::new(UpdateGoalHandler)); - } - - executors.push(Arc::new(RequestUserInputHandler { - available_modes: config.request_user_input_available_modes.clone(), - })); - - if config.request_permissions_tool_enabled { - executors.push(Arc::new(RequestPermissionsHandler)); - } - - if config.tool_suggest + if tool_suggest_enabled(turn_context) && let Some(discoverable_tools) = - params.discoverable_tools.filter(|tools| !tools.is_empty()) + context.discoverable_tools.filter(|tools| !tools.is_empty()) { - executors.push(Arc::new(RequestPluginInstallHandler::new( - discoverable_tools, - ))); + planned_tools.add_runtime(RequestPluginInstallHandler::new(discoverable_tools)); } - if config.environment_mode.has_environment() && config.apply_patch_tool_type.is_some() { - let include_environment_id = - matches!(config.environment_mode, ToolEnvironmentMode::Multiple); - executors.push(Arc::new(ApplyPatchHandler::new(include_environment_id))); + if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some() + { + let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple); + planned_tools.add_runtime(ApplyPatchHandler::new(include_environment_id)); } - if config + if turn_context + .model_info .experimental_supported_tools .iter() .any(|tool| tool == "test_sync_tool") { - executors.push(Arc::new(TestSyncHandler)); + planned_tools.add_runtime(TestSyncHandler); } - if config.environment_mode.has_environment() { - let include_environment_id = - matches!(config.environment_mode, ToolEnvironmentMode::Multiple); - executors.push(Arc::new(ViewImageHandler::new(ViewImageToolOptions { - can_request_original_image_detail: config.can_request_original_image_detail, + if environment_mode.has_environment() { + let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple); + planned_tools.add_runtime(ViewImageHandler::new(ViewImageToolOptions { + can_request_original_image_detail: can_request_original_image_detail( + &turn_context.model_info, + ), include_environment_id, - }))); + })); } +} - if config.collab_tools { - if config.multi_agent_v2 { - let exposure = if config.multi_agent_v2_non_code_mode_only { +fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + let turn_context = context.turn_context; + if collab_tools_enabled(turn_context) { + if multi_agent_v2_enabled(turn_context) { + let exposure = if turn_context.config.multi_agent_v2.non_code_mode_only { ToolExposure::DirectModelOnly } else { ToolExposure::Direct }; - let tool_namespace = config - .namespace_tools - .then_some(config.multi_agent_v2_tool_namespace.as_deref()) + let tool_namespace = namespace_tools_enabled(turn_context) + .then_some(turn_context.config.multi_agent_v2.tool_namespace.as_deref()) .flatten(); let agent_type_description = - agent_type_description(config, params.default_agent_type_description); - executors.push(multi_agent_v2_handler( + agent_type_description(turn_context, context.default_agent_type_description); + planned_tools.add_runtime_arc(multi_agent_v2_handler( SpawnAgentHandlerV2::new(SpawnAgentToolOptions { - available_models: config.available_models.clone(), + available_models: turn_context.available_models.clone(), agent_type_description, - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, + hide_agent_type_model_reasoning: turn_context + .config + .multi_agent_v2 + .hide_spawn_agent_metadata, + include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, + usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), + max_concurrent_threads_per_session: max_concurrent_threads_per_session( + turn_context, + ), }), exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( + planned_tools.add_runtime_arc(multi_agent_v2_handler( SendMessageHandlerV2, exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( + planned_tools.add_runtime_arc(multi_agent_v2_handler( FollowupTaskHandlerV2, exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( - WaitAgentHandlerV2::new(params.wait_agent_timeouts), + planned_tools.add_runtime_arc(multi_agent_v2_handler( + WaitAgentHandlerV2::new(context.wait_agent_timeouts), exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( + planned_tools.add_runtime_arc(multi_agent_v2_handler( CloseAgentHandlerV2, exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( + planned_tools.add_runtime_arc(multi_agent_v2_handler( ListAgentsHandlerV2, exposure, tool_namespace, )); } else { let agent_type_description = - agent_type_description(config, params.default_agent_type_description); - executors.push(Arc::new(SpawnAgentHandler::new(SpawnAgentToolOptions { - available_models: config.available_models.clone(), + agent_type_description(turn_context, context.default_agent_type_description); + planned_tools.add_runtime(SpawnAgentHandler::new(SpawnAgentToolOptions { + available_models: turn_context.available_models.clone(), agent_type_description, - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, - }))); - executors.push(Arc::new(SendInputHandler)); - executors.push(Arc::new(ResumeAgentHandler)); - executors.push(Arc::new(WaitAgentHandler::new(params.wait_agent_timeouts))); - executors.push(Arc::new(CloseAgentHandler)); + hide_agent_type_model_reasoning: turn_context + .config + .multi_agent_v2 + .hide_spawn_agent_metadata, + include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, + usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), + max_concurrent_threads_per_session: max_concurrent_threads_per_session( + turn_context, + ), + })); + planned_tools.add_runtime(SendInputHandler); + planned_tools.add_runtime(ResumeAgentHandler); + planned_tools.add_runtime(WaitAgentHandler::new(context.wait_agent_timeouts)); + planned_tools.add_runtime(CloseAgentHandler); } } - if config.agent_jobs_tools { - executors.push(Arc::new(SpawnAgentsOnCsvHandler)); - if config.agent_jobs_worker_tools { - executors.push(Arc::new(ReportAgentJobResultHandler)); + if agent_jobs_tools_enabled(turn_context) { + planned_tools.add_runtime(SpawnAgentsOnCsvHandler); + if agent_jobs_worker_tools_enabled(turn_context) { + planned_tools.add_runtime(ReportAgentJobResultHandler); } } +} - if let Some(mcp_tools) = params.mcp_tools { +fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + if let Some(mcp_tools) = context.mcp_tools { for tool in mcp_tools { - executors.push(Arc::new(McpHandler::new(tool.clone()))); + planned_tools.add_runtime(McpHandler::new(tool.clone())); } } - if let Some(deferred_mcp_tools) = params.deferred_mcp_tools { + if let Some(deferred_mcp_tools) = context.deferred_mcp_tools { for tool in deferred_mcp_tools { - executors.push(Arc::new(McpHandler::with_exposure( + planned_tools.add_runtime(McpHandler::with_exposure( tool.clone(), ToolExposure::Deferred, - ))); + )); } } +} - for tool in params.dynamic_tools { - let Some(handler) = DynamicToolHandler::new(tool).map(Arc::new) else { +fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + for tool in context.dynamic_tools { + let Some(handler) = DynamicToolHandler::new(tool) else { tracing::error!( "Failed to convert dynamic tool {:?} to OpenAI tool", tool.name @@ -543,23 +665,31 @@ fn collect_tool_executors( continue; }; - executors.push(handler); + planned_tools.add_runtime(handler); } +} - append_extension_tool_executors(config, params.extension_tool_executors, &mut executors); - - executors +fn add_extension_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + // Extension ToolContributor implementations are resolved into executors + // before planning. Core only adapts those executors into its runtime set. + append_extension_tool_executors( + context.turn_context, + context.extension_tool_executors, + planned_tools, + ); } fn append_tool_search_executor( - config: &ToolsConfig, - executors: &mut Vec>, + context: &CoreToolPlanContext<'_>, + planned_tools: &mut PlannedTools, ) { - if !(config.search_tool && config.namespace_tools) { + let turn_context = context.turn_context; + if !(search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context)) { return; } - let search_infos = executors + let search_infos = planned_tools + .runtimes() .iter() .filter(|executor| executor.exposure() == ToolExposure::Deferred) .filter_map(|executor| executor.search_info()) @@ -568,42 +698,49 @@ fn append_tool_search_executor( return; } - executors.push(Arc::new(ToolSearchHandler::new(search_infos))); + planned_tools.add_runtime(ToolSearchHandler::new(search_infos)); } fn prepend_code_mode_executors( - config: &ToolsConfig, - executors: &mut Vec>, + context: &CoreToolPlanContext<'_>, + planned_tools: &mut PlannedTools, ) { - let deferred_tools_available = config.search_tool - && executors + let turn_context = context.turn_context; + let deferred_tools_available = search_tool_enabled(turn_context) + && planned_tools + .runtimes() .iter() .any(|executor| executor.exposure() == ToolExposure::Deferred); - let code_mode_executors = - build_code_mode_executors(config, executors, deferred_tools_available); - executors.splice(0..0, code_mode_executors); + let code_mode_executors = build_code_mode_executors( + turn_context, + planned_tools.runtimes(), + deferred_tools_available, + ); + planned_tools.runtimes.splice(0..0, code_mode_executors); } fn append_extension_tool_executors( - config: &ToolsConfig, + turn_context: &TurnContext, executors: &[Arc>], - registered_executors: &mut Vec>, + planned_tools: &mut PlannedTools, ) { if executors.is_empty() { return; } - let mut reserved_tool_names = registered_executors + let mut reserved_tool_names = planned_tools + .runtimes() .iter() .map(|executor| executor.tool_name()) .collect::>(); - if config.code_mode_enabled { + if code_mode_enabled(turn_context) { reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME)); reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); } - if config.search_tool - && config.namespace_tools - && registered_executors + if search_tool_enabled(turn_context) + && namespace_tools_enabled(turn_context) + && planned_tools + .runtimes() .iter() .any(|executor| executor.exposure() == ToolExposure::Deferred) { @@ -616,7 +753,7 @@ fn append_extension_tool_executors( warn!("Skipping extension tool `{tool_name}`: tool already registered"); continue; } - registered_executors.push(Arc::new(ExtensionToolAdapter::new(executor))); + planned_tools.add_runtime(ExtensionToolAdapter::new(executor)); } } @@ -714,9 +851,6 @@ fn code_mode_namespace_name<'a>( .map(|namespace_description| namespace_description.name.as_str()) } -#[cfg(test)] -#[path = "spec_plan_model_tests.rs"] -mod model_tests; #[cfg(test)] #[path = "spec_plan_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs deleted file mode 100644 index 1896a7345..000000000 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ /dev/null @@ -1,1401 +0,0 @@ -use crate::config::test_config; -use crate::shell::Shell; -use crate::shell::ShellType; -use crate::test_support::construct_model_info_offline; -use crate::tools::ToolRouter; -use crate::tools::router::ToolRouterParams; -use crate::tools::tool_user_shell_type; -use codex_app_server_protocol::AppInfo; -use codex_features::Feature; -use codex_features::Features; -use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; -use codex_mcp::ToolInfo; -use codex_models_manager::bundled_models_response; -use codex_models_manager::model_info::with_config_overrides; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::models::PermissionProfile; -use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::ModelInfo; -use codex_protocol::protocol::SessionSource; -use codex_tools::DiscoverableTool; -use codex_tools::JsonSchema; -use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; -use codex_tools::ResponsesApiNamespaceTool; -use codex_tools::ResponsesApiTool; -use codex_tools::ShellCommandBackendConfig; -use codex_tools::TOOL_SEARCH_TOOL_NAME; -use codex_tools::ToolName; -use codex_tools::ToolSpec; -use codex_tools::ToolsConfig; -use codex_tools::ToolsConfigParams; -use codex_tools::UnifiedExecShellMode; -use codex_tools::ZshForkConfig; -use codex_tools::mcp_call_tool_result_output_schema; -use codex_tools::mcp_tool_to_deferred_responses_api_tool; -use codex_utils_absolute_path::AbsolutePathBuf; -use core_test_support::assert_regex_match; -use pretty_assertions::assert_eq; -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::sync::Arc; - -fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { - rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(description.to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } -} - -fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool) -> ToolInfo { - let (callable_namespace, callable_name) = display_name - .rsplit_once('/') - .map(|(namespace, callable_name)| (format!("{namespace}/"), callable_name.to_string())) - .unwrap_or_else(|| ("".to_string(), display_name.to_string())); - - ToolInfo { - server_name: "test_server".to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name, - callable_namespace, - namespace_description: None, - tool, - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - } -} - -fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { - let slug = name.replace(' ', "-").to_lowercase(); - DiscoverableTool::Connector(Box::new(AppInfo { - id: id.to_string(), - name: name.to_string(), - description: Some(description.to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })) -} - -async fn search_capable_model_info() -> ModelInfo { - let config = test_config().await; - let mut model_info = construct_model_info_offline("gpt-5.4", &config); - model_info.supports_search_tool = true; - model_info -} - -#[test] -fn deferred_responses_api_tool_serializes_with_defer_loading() { - let tool = mcp_tool( - "lookup_order", - "Look up an order", - serde_json::json!({ - "type": "object", - "properties": { - "order_id": {"type": "string"} - }, - "required": ["order_id"], - "additionalProperties": false, - }), - ); - - let serialized = serde_json::to_value(ToolSpec::Function( - mcp_tool_to_deferred_responses_api_tool( - &ToolName::namespaced("mcp__codex_apps__", "lookup_order"), - &tool, - ) - .expect("convert deferred tool"), - )) - .expect("serialize deferred tool"); - - assert_eq!( - serialized, - serde_json::json!({ - "type": "function", - "name": "lookup_order", - "description": "Look up an order", - "strict": false, - "defer_loading": true, - "parameters": { - "type": "object", - "properties": { - "order_id": {"type": "string"} - }, - "required": ["order_id"], - "additionalProperties": false, - } - }) - ); -} - -// Avoid order-based assertions; compare via set containment instead. -fn assert_contains_tool_names(tools: &[ToolSpec], expected_subset: &[&str]) { - use std::collections::HashSet; - let mut names = HashSet::new(); - let mut duplicates = Vec::new(); - for name in tools.iter().map(ToolSpec::name) { - if !names.insert(name) { - duplicates.push(name); - } - } - assert!( - duplicates.is_empty(), - "duplicate tool entries detected: {duplicates:?}" - ); - for expected in expected_subset { - assert!( - names.contains(expected), - "expected tool {expected} to be present; had: {names:?}" - ); - } -} - -fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> { - match config.shell_type { - ConfigShellToolType::Default => Some("shell_command"), - ConfigShellToolType::Local => Some("shell_command"), - ConfigShellToolType::UnifiedExec => None, - ConfigShellToolType::Disabled => None, - ConfigShellToolType::ShellCommand => Some("shell_command"), - } -} - -fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec { - tools - .iter() - .find(|tool| tool.name() == expected_name) - .unwrap_or_else(|| panic!("expected tool {expected_name}")) -} - -fn find_namespace_function_tool<'a>( - tools: &'a [ToolSpec], - expected_namespace: &str, - expected_name: &str, -) -> &'a ResponsesApiTool { - let namespace_tool = find_tool(tools, expected_namespace); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool {expected_namespace}"); - }; - namespace - .tools - .iter() - .find_map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name => Some(tool), - _ => None, - }) - .unwrap_or_else(|| panic!("expected tool {expected_namespace}{expected_name} in namespace")) -} - -async fn multi_agent_v2_tools_config() -> ToolsConfig { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::Collab); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .with_max_concurrent_threads_per_session(Some(4)) -} - -fn multi_agent_v2_spawn_agent_description(tools_config: &ToolsConfig) -> String { - let tools = build_specs( - tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let spawn_agent = find_tool(&tools, "spawn_agent"); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = spawn_agent else { - panic!("spawn_agent should be a function tool"); - }; - description.clone() -} - -async fn model_info_from_models_json(slug: &str) -> ModelInfo { - let config = test_config().await; - let response = bundled_models_response() - .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); - let model = response - .models - .into_iter() - .find(|candidate| candidate.slug == slug) - .unwrap_or_else(|| panic!("model slug {slug} is missing from models.json")); - with_config_overrides(model, &config.to_models_manager_config()) -} - -/// Builds tool specs and the matching registry from the same executor list. -fn build_specs( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - dynamic_tools: &[DynamicToolSpec], -) -> Vec { - build_specs_with_inputs_for_test( - config, - mcp_tools, - deferred_mcp_tools, - /*discoverable_tools*/ None, - /*extension_tool_executors*/ &[], - dynamic_tools, - ) -} - -fn build_specs_with_inputs_for_test( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - discoverable_tools: Option>, - extension_tool_executors: &[Arc< - dyn codex_extension_api::ToolExecutor, - >], - dynamic_tools: &[DynamicToolSpec], -) -> Vec { - ToolRouter::from_config( - config, - ToolRouterParams { - mcp_tools, - deferred_mcp_tools, - discoverable_tools, - extension_tool_executors: extension_tool_executors.to_vec(), - dynamic_tools, - }, - ) - .model_visible_specs() -} - -#[tokio::test] -async fn get_memory_requires_feature_flag() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.disable(Feature::MemoryTool); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let tools = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert!( - !tools.iter().any(|t| t.name() == "get_memory"), - "get_memory should be disabled when memory_tool feature is off" - ); -} - -async fn assert_model_tools( - model_slug: &str, - features: &Features, - web_search_mode: Option, - expected_tools: &[&str], -) { - let _config = test_config().await; - let model_info = model_info_from_models_json(model_slug).await; - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features, - image_generation_tool_auth_allowed: true, - web_search_mode, - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let router = ToolRouter::from_config( - &tools_config, - ToolRouterParams { - mcp_tools: None, - deferred_mcp_tools: None, - discoverable_tools: None, - extension_tool_executors: Vec::new(), - dynamic_tools: &[], - }, - ); - let model_visible_specs = router.model_visible_specs(); - let tool_names = model_visible_specs - .iter() - .map(ToolSpec::name) - .collect::>(); - assert_eq!(&tool_names, &expected_tools); -} - -async fn assert_default_model_tools( - model_slug: &str, - features: &Features, - web_search_mode: Option, - shell_tool: &'static str, - expected_tail: &[&str], -) { - let mut expected = if features.enabled(Feature::UnifiedExec) { - vec!["exec_command", "write_stdin"] - } else { - vec![shell_tool] - }; - expected.extend(expected_tail); - assert_model_tools(model_slug, features, web_search_mode, &expected).await; -} - -#[tokio::test] -async fn test_build_specs_gpt5_codex_default() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_build_specs_gpt51_codex_default() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_build_specs_gpt5_codex_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_build_specs_gpt51_codex_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_gpt_5_1_codex_max_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_codex_5_1_mini_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4-mini", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_gpt_5_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.2", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_gpt_5_1_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_gpt_5_1_codex_max_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_build_specs_default_shell_present() { - let config = test_config().await; - let model_info = construct_model_info_offline("o3", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let tools = build_specs( - &tools_config, - Some(Vec::new()), - /*deferred_mcp_tools*/ None, - &[], - ); - - // Only check the shell variant and a couple of core tools. - let mut subset = vec!["exec_command", "write_stdin", "update_plan"]; - if let Some(shell_tool) = shell_tool_name(&tools_config) { - subset.push(shell_tool); - } - assert_contains_tool_names(&tools, &subset); -} - -#[tokio::test] -async fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { - let config = test_config().await; - let model_info = construct_model_info_offline("o3", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - features.enable(Feature::ShellZshFork); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let user_shell = Shell { - shell_type: ShellType::Zsh, - shell_path: PathBuf::from("/bin/zsh"), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - - assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); - assert_eq!( - tools_config.shell_command_backend, - ShellCommandBackendConfig::ZshFork - ); - assert_eq!( - tools_config.unified_exec_shell_mode, - UnifiedExecShellMode::Direct - ); - assert_eq!( - tools_config - .with_unified_exec_shell_mode_for_session( - tool_user_shell_type(&user_shell), - Some(&PathBuf::from(if cfg!(windows) { - r"C:\opt\codex\zsh" - } else { - "/opt/codex/zsh" - })), - Some(&PathBuf::from(if cfg!(windows) { - r"C:\opt\codex\codex-execve-wrapper" - } else { - "/opt/codex/codex-execve-wrapper" - })), - ) - .unified_exec_shell_mode, - if cfg!(unix) { - UnifiedExecShellMode::ZshFork(ZshForkConfig { - shell_zsh_path: AbsolutePathBuf::from_absolute_path("/opt/codex/zsh").unwrap(), - main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path( - "/opt/codex/codex-execve-wrapper", - ) - .unwrap(), - }) - } else { - UnifiedExecShellMode::Direct - } - ); -} - -#[tokio::test] -async fn spawn_agent_description_omits_usage_hint_when_disabled() { - let tools_config = multi_agent_v2_tools_config() - .await - .with_spawn_agent_usage_hint(/*spawn_agent_usage_hint*/ false); - let description = multi_agent_v2_spawn_agent_description(&tools_config); - - assert_regex_match( - r#"(?sx) - ^\s* - No\ picker-visible\ model\ overrides\ are\ currently\ loaded\. - \s+Spawns\ an\ agent\ to\ work\ on\ the\ specified\ task\.\ If\ your\ current\ task\ is\ `/root/task1`\ and\ you\ spawn_agent\ with\ task_name\ "task_3"\ the\ agent\ will\ have\ canonical\ task\ name\ `/root/task1/task_3`\. - \s+You\ are\ then\ able\ to\ refer\ to\ this\ agent\ as\ `task_3`\ or\ `/root/task1/task_3`\ interchangeably\.\ However\ an\ agent\ `/root/task2/task_3`\ would\ only\ be\ able\ to\ communicate\ with\ this\ agent\ via\ its\ canonical\ name\ `/root/task1/task_3`\. - \s+The\ spawned\ agent\ will\ have\ the\ same\ tools\ as\ you\ and\ the\ ability\ to\ spawn\ its\ own\ subagents\. - \s+Spawned\ agents\ inherit\ your\ current\ model\ by\ default\.\ Omit\ `model`\ to\ use\ that\ preferred\ default;\ set\ `model`\ only\ when\ an\ explicit\ override\ is\ needed\. - \s+It\ will\ be\ able\ to\ send\ you\ and\ other\ running\ agents\ messages,\ and\ its\ final\ answer\ will\ be\ provided\ to\ you\ when\ it\ finishes\. - \s+The\ new\ agent's\ canonical\ task\ name\ will\ be\ provided\ to\ it\ along\ with\ the\ message\. - \s+This\ session\ is\ configured\ with\ `max_concurrent_threads_per_session\ =\ 4`\ for\ concurrently\ open\ agent\ threads\. - \s*$ - "#, - &description, - ); -} - -#[tokio::test] -async fn spawn_agent_description_uses_configured_usage_hint_text() { - let tools_config = multi_agent_v2_tools_config() - .await - .with_spawn_agent_usage_hint_text(Some( - /*spawn_agent_usage_hint_text*/ "Custom delegation guidance only.".to_string(), - )); - let description = multi_agent_v2_spawn_agent_description(&tools_config); - - assert_regex_match( - r#"(?sx) - ^\s* - No\ picker-visible\ model\ overrides\ are\ currently\ loaded\. - \s+Spawns\ an\ agent\ to\ work\ on\ the\ specified\ task\.\ If\ your\ current\ task\ is\ `/root/task1`\ and\ you\ spawn_agent\ with\ task_name\ "task_3"\ the\ agent\ will\ have\ canonical\ task\ name\ `/root/task1/task_3`\. - \s+You\ are\ then\ able\ to\ refer\ to\ this\ agent\ as\ `task_3`\ or\ `/root/task1/task_3`\ interchangeably\.\ However\ an\ agent\ `/root/task2/task_3`\ would\ only\ be\ able\ to\ communicate\ with\ this\ agent\ via\ its\ canonical\ name\ `/root/task1/task_3`\. - \s+The\ spawned\ agent\ will\ have\ the\ same\ tools\ as\ you\ and\ the\ ability\ to\ spawn\ its\ own\ subagents\. - \s+Spawned\ agents\ inherit\ your\ current\ model\ by\ default\.\ Omit\ `model`\ to\ use\ that\ preferred\ default;\ set\ `model`\ only\ when\ an\ explicit\ override\ is\ needed\. - \s+It\ will\ be\ able\ to\ send\ you\ and\ other\ running\ agents\ messages,\ and\ its\ final\ answer\ will\ be\ provided\ to\ you\ when\ it\ finishes\. - \s+The\ new\ agent's\ canonical\ task\ name\ will\ be\ provided\ to\ it\ along\ with\ the\ message\. - \s+This\ session\ is\ configured\ with\ `max_concurrent_threads_per_session\ =\ 4`\ for\ concurrently\ open\ agent\ threads\. - \s+Custom\ delegation\ guidance\ only\. - \s*$ - "#, - &description, - ); -} - -#[tokio::test] -async fn multi_agent_v2_wait_agent_schema_uses_configured_timeouts() { - let wait_agent_min_timeout_ms = Some(20_000); - let wait_agent_max_timeout_ms = Some(120_000); - let wait_agent_default_timeout_ms = Some(60_000); - let tools_config = multi_agent_v2_tools_config() - .await - .with_wait_agent_min_timeout_ms(wait_agent_min_timeout_ms) - .with_wait_agent_max_timeout_ms(wait_agent_max_timeout_ms) - .with_wait_agent_default_timeout_ms(wait_agent_default_timeout_ms); - let tools = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let wait_agent = find_tool(&tools, "wait_agent"); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = wait_agent else { - panic!("wait_agent should be a function tool"); - }; - let timeout_description = parameters - .properties - .as_ref() - .and_then(|properties| properties.get("timeout_ms")) - .and_then(|schema| schema.description.as_deref()); - - assert_eq!( - timeout_description, - Some("Optional timeout in milliseconds. Defaults to 60000, min 20000, max 120000.") - ); -} - -#[tokio::test] -async fn request_plugin_install_requires_apps_and_plugins_features() { - let model_info = search_capable_model_info().await; - 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); - features.enable(Feature::Apps); - features.enable(Feature::Plugins); - features.disable(disabled_feature); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let tools = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - discoverable_tools.clone(), - /*extension_tool_executors*/ &[], - &[], - ); - - assert!( - !tools - .iter() - .any(|tool| tool.name() == REQUEST_PLUGIN_INSTALL_TOOL_NAME), - "tool_suggest should be absent when {disabled_feature:?} is disabled" - ); - } -} - -#[tokio::test] -async fn search_tool_is_hidden_without_deferred_tools() { - let model_info = search_capable_model_info().await; - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(Vec::new()), - &[], - ); - assert!( - !tools - .iter() - .any(|tool| tool.name() == TOOL_SEARCH_TOOL_NAME) - ); -} - -#[tokio::test] -async fn search_tool_description_falls_back_to_connector_name_without_description() { - let model_info = search_capable_model_info().await; - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(vec![ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: "_create_event".to_string(), - callable_namespace: "mcp__codex_apps__calendar".to_string(), - namespace_description: None, - tool: mcp_tool( - "calendar_create_event", - "Create calendar event", - serde_json::json!({"type": "object"}), - ), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - }]), - &[], - ); - let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); - let ToolSpec::ToolSearch { description, .. } = search_tool else { - panic!("expected tool_search tool"); - }; - - assert!(description.contains("- Calendar")); - assert!(!description.contains("- Calendar:")); -} - -#[tokio::test] -async fn test_mcp_tool_property_missing_type_defaults_to_empty_schema() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "dash/search", - mcp_tool( - "search", - "Search docs", - serde_json::json!({ - "type": "object", - "properties": { - "query": {"description": "search query"} - } - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "dash/", "search"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "search".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([("query".to_string(), JsonSchema::default())]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Search docs".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn test_mcp_tool_preserves_integer_schema() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "dash/paginate", - mcp_tool( - "paginate", - "Pagination", - serde_json::json!({ - "type": "object", - "properties": {"page": {"type": "integer"}} - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "dash/", "paginate"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "paginate".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([( - "page".to_string(), - JsonSchema::integer(/*description*/ None), - )]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Pagination".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn test_mcp_tool_array_without_items_gets_default_string_items() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "dash/tags", - mcp_tool( - "tags", - "Tags", - serde_json::json!({ - "type": "object", - "properties": {"tags": {"type": "array"}} - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "dash/", "tags"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "tags".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([( - "tags".to_string(), - JsonSchema::array( - JsonSchema::string(/*description*/ None), - /*description*/ None, - ), - )]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Tags".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn test_mcp_tool_anyof_defaults_to_string() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "dash/value", - mcp_tool( - "value", - "AnyOf Value", - serde_json::json!({ - "type": "object", - "properties": { - "value": {"anyOf": [{"type": "string"}, {"type": "number"}]} - } - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "dash/", "value"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "value".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([( - "value".to_string(), - JsonSchema::any_of( - vec![ - JsonSchema::string(/*description*/ None), - JsonSchema::number(/*description*/ None), - ], - /*description*/ None, - ), - )]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "AnyOf Value".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "test_server/do_something_cool", - mcp_tool( - "do_something_cool", - "Do something cool", - serde_json::json!({ - "type": "object", - "properties": { - "string_argument": {"type": "string"}, - "number_argument": {"type": "number"}, - "object_argument": { - "type": "object", - "properties": { - "string_property": {"type": "string"}, - "number_property": {"type": "number"} - }, - "required": ["string_property", "number_property"], - "additionalProperties": { - "type": "object", - "properties": { - "addtl_prop": {"type": "string"} - }, - "required": ["addtl_prop"], - "additionalProperties": false - } - } - } - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "test_server/", "do_something_cool"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "do_something_cool".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([ - ( - "string_argument".to_string(), - JsonSchema::string(/*description*/ None), - ), - ( - "number_argument".to_string(), - JsonSchema::number(/*description*/ None), - ), - ( - "object_argument".to_string(), - JsonSchema::object( - BTreeMap::from([ - ( - "string_property".to_string(), - JsonSchema::string(/*description*/ None), - ), - ( - "number_property".to_string(), - JsonSchema::number(/*description*/ None), - ), - ]), - Some(vec![ - "string_property".to_string(), - "number_property".to_string(), - ]), - Some( - JsonSchema::object( - BTreeMap::from([( - "addtl_prop".to_string(), - JsonSchema::string(/*description*/ None), - )]), - Some(vec!["addtl_prop".to_string()]), - Some(false.into()), - ) - .into(), - ), - ), - ), - ]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Do something cool".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn code_mode_only_restricts_model_tools_to_exec_tools() { - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - - assert_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Live), - &["exec", "wait"], - ) - .await; -} - -#[tokio::test] -async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); - let router = ToolRouter::from_config( - &tools_config, - ToolRouterParams { - mcp_tools: None, - deferred_mcp_tools: None, - discoverable_tools: None, - extension_tool_executors: Vec::new(), - dynamic_tools: &[], - }, - ); - let model_visible_specs = router.model_visible_specs(); - let tool_names = model_visible_specs - .iter() - .map(ToolSpec::name) - .collect::>(); - - assert_eq!( - tool_names, - vec![ - "exec", - "wait", - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "close_agent", - "list_agents", - ] - ); - - let exec = find_tool(&model_visible_specs, "exec"); - let ToolSpec::Freeform(exec) = exec else { - panic!("exec should be a freeform tool"); - }; - assert!(!exec.description.contains("spawn_agent")); - assert!(!exec.description.contains("wait_agent")); - assert!( - !exec - .description - .contains("do not attempt to use any other tools directly") - ); - - let spawn_agent = find_tool(&model_visible_specs, "spawn_agent"); - let ToolSpec::Function(spawn_agent) = spawn_agent else { - panic!("spawn_agent should be a function tool"); - }; - assert!(!spawn_agent.description.contains("exec tool declaration")); -} - -#[tokio::test] -async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .with_multi_agent_v2_tool_namespace(Some("agents".to_string())) - .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); - let router = ToolRouter::from_config( - &tools_config, - ToolRouterParams { - mcp_tools: None, - deferred_mcp_tools: None, - discoverable_tools: None, - extension_tool_executors: Vec::new(), - dynamic_tools: &[], - }, - ); - let model_visible_specs = router.model_visible_specs(); - let tool_names = model_visible_specs - .iter() - .map(ToolSpec::name) - .collect::>(); - - assert_eq!(tool_names, vec!["exec", "wait", "agents"]); - - let exec = find_tool(&model_visible_specs, "exec"); - let ToolSpec::Freeform(exec) = exec else { - panic!("exec should be a freeform tool"); - }; - assert!(!exec.description.contains("spawn_agent")); - assert!(!exec.description.contains("wait_agent")); - assert!( - !exec - .description - .contains("do not attempt to use any other tools directly") - ); - - for tool_name in [ - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "close_agent", - "list_agents", - ] { - let tool = find_namespace_function_tool(&model_visible_specs, "agents", tool_name); - assert!(!tool.description.contains("exec tool declaration")); - } -} diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1b64093ad..3c7c361e8 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1,607 +1,669 @@ -use super::*; -use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool; -use crate::tools::handlers::goal_spec::create_create_goal_tool; -use crate::tools::handlers::goal_spec::create_get_goal_tool; -use crate::tools::handlers::goal_spec::create_update_goal_tool; -use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; -use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1; -use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v2; -use crate::tools::handlers::multi_agents_spec::create_resume_agent_tool; -use crate::tools::handlers::multi_agents_spec::create_send_input_tool_v1; -use crate::tools::handlers::multi_agents_spec::create_send_message_tool; -use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v1; -use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; -use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1; -use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2; -use crate::tools::handlers::plan_spec::create_update_plan_tool; -use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME; -use crate::tools::handlers::request_user_input_spec::create_request_user_input_tool; -use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description; -use crate::tools::handlers::shell_spec::CommandToolOptions; -use crate::tools::handlers::shell_spec::create_exec_command_tool; -use crate::tools::handlers::shell_spec::create_request_permissions_tool; -use crate::tools::handlers::shell_spec::create_write_stdin_tool; -use crate::tools::handlers::shell_spec::request_permissions_tool_description; -use crate::tools::handlers::view_image_spec::ViewImageToolOptions; -use crate::tools::handlers::view_image_spec::create_view_image_tool; -use crate::tools::registry::ToolRegistry; -use codex_app_server_protocol::AppInfo; -use codex_extension_api::ToolCall as ExtensionToolCall; -use codex_extension_api::ToolExecutor; +use std::collections::BTreeMap; +use std::sync::Arc; + use codex_features::Feature; -use codex_features::Features; +use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_mcp::ToolInfo; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::WebSearchConfig; +use codex_model_provider::create_model_provider; +use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; +use codex_model_provider_info::ModelProviderInfo; use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::models::PermissionProfile; -use codex_protocol::models::VIEW_IMAGE_TOOL_NAME; +use codex_protocol::openai_models::ApplyPatchToolType; +use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; -use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_tools::AdditionalProperties; use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; -use codex_tools::FreeformTool; -use codex_tools::JsonSchema; -use codex_tools::JsonSchemaPrimitiveType; -use codex_tools::JsonSchemaType; -use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; use codex_tools::ResponsesApiNamespaceTool; -use codex_tools::ResponsesApiTool; -use codex_tools::ResponsesApiWebSearchFilters; -use codex_tools::ResponsesApiWebSearchUserLocation; -use codex_tools::TOOL_SEARCH_TOOL_NAME; -use codex_tools::ToolEnvironmentMode; +use codex_tools::ToolExposure; use codex_tools::ToolName; -use codex_tools::ToolsConfigParams; -use codex_tools::mcp_call_tool_result_output_schema; -use codex_tools::request_user_input_available_modes; +use codex_tools::ToolSpec; use pretty_assertions::assert_eq; use serde_json::json; -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::sync::Arc; -const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; -const DEFAULT_AGENT_TYPE_DESCRIPTION: &str = "Test agent type description."; -const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; -const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; -const MAX_WAIT_TIMEOUT_MS: i64 = 3_600_000; +use crate::session::tests::make_session_and_context; +use crate::session::turn_context::TurnContext; +use crate::tools::router::ToolRouter; +use crate::tools::router::ToolRouterParams; -fn extension_tool_executor( - name: &str, - description: &str, -) -> Arc> { - struct SpecOnlyExtensionExecutor { - name: String, - description: String, - } - - #[async_trait::async_trait] - impl ToolExecutor for SpecOnlyExtensionExecutor { - fn tool_name(&self) -> ToolName { - ToolName::plain(self.name.as_str()) - } - - fn spec(&self) -> Option { - Some(ToolSpec::Function(ResponsesApiTool { - name: self.name.clone(), - description: self.description.clone(), - strict: true, - parameters: JsonSchema::object( - BTreeMap::from([( - "message".to_string(), - JsonSchema::string(/*description*/ None), - )]), - Some(vec!["message".to_string()]), - Some(false.into()), - ), - output_schema: None, - defer_loading: None, - })) - } - - async fn handle( - &self, - _call: ExtensionToolCall, - ) -> Result, codex_tools::FunctionCallError> { - panic!("spec planning should not execute extension tools") - } - } - - Arc::new(SpecOnlyExtensionExecutor { - name: name.to_string(), - description: description.to_string(), - }) +#[derive(Default)] +struct ToolPlanInputs { + mcp_tools: Option>, + deferred_mcp_tools: Option>, + discoverable_tools: Option>, + dynamic_tools: Vec, } -#[test] -fn extension_tools_do_not_replace_builtin_tools() { - let model_info = model_info(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &Features::with_defaults(), - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let extension_tool_executors = vec![extension_tool_executor( - "update_plan", - "Extension attempt to replace a built-in tool.", - )]; - let (tools, _) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - /*discoverable_tools*/ None, - &extension_tool_executors, - &[], - ); +struct ToolPlanProbe { + visible_specs: Vec, + visible_names: Vec, + namespace_functions: BTreeMap>, + registered_names: Vec, + exposures: BTreeMap, +} - assert_eq!( - find_tool(&tools, "update_plan").clone(), - create_update_plan_tool() - ); - assert_eq!( - tools +impl ToolPlanProbe { + fn from_router(router: ToolRouter) -> Self { + let visible_specs = router.model_visible_specs(); + let visible_names = visible_specs .iter() - .filter(|tool| tool.name() == "update_plan") - .count(), - 1 - ); -} + .map(|spec| spec.name().to_string()) + .collect::>(); + let namespace_functions = visible_specs + .iter() + .filter_map(|spec| match spec { + ToolSpec::Namespace(namespace) => Some(( + namespace.name.clone(), + namespace + .tools + .iter() + .map(|tool| match tool { + ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(), + }) + .collect::>(), + )), + ToolSpec::Function(_) + | ToolSpec::ToolSearch { .. } + | ToolSpec::ImageGeneration { .. } + | ToolSpec::WebSearch { .. } + | ToolSpec::Freeform(_) => None, + }) + .collect::>(); + let registered_tool_names = router.registered_tool_names_for_test(); + let registered_names = registered_tool_names + .iter() + .map(ToString::to_string) + .collect::>(); + let exposures = registered_tool_names + .iter() + .filter_map(|name| { + router + .tool_exposure_for_test(name) + .map(|exposure| (name.to_string(), exposure)) + }) + .collect::>(); -#[test] -fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - let mut actual = BTreeMap::new(); - let mut duplicate_names = Vec::new(); - for tool in &tools { - let name = tool.name().to_string(); - if actual.insert(name.clone(), tool.clone()).is_some() { - duplicate_names.push(name); + Self { + visible_specs, + visible_names, + namespace_functions, + registered_names, + exposures, } } - assert!( - duplicate_names.is_empty(), - "duplicate tool entries detected: {duplicate_names:?}" - ); - let mut expected = BTreeMap::new(); - for spec in [ - create_exec_command_tool(CommandToolOptions { - allow_login_shell: true, - exec_permission_approvals_enabled: false, - }), - create_write_stdin_tool(), - create_update_plan_tool(), - request_user_input_tool_spec(&request_user_input_available_modes(&features)), - create_apply_patch_freeform_tool(/*include_environment_id*/ false), - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, + fn assert_visible_contains(&self, expected: &[&str]) { + for name in expected { + assert!( + self.visible_names.iter().any(|visible| visible == name), + "expected visible tool `{name}` in {:?}", + self.visible_names + ); + } + } + + fn assert_visible_lacks(&self, expected_absent: &[&str]) { + for name in expected_absent { + assert!( + !self.visible_names.iter().any(|visible| visible == name), + "expected visible tool `{name}` to be absent from {:?}", + self.visible_names + ); + } + } + + fn assert_registered_contains(&self, expected: &[&str]) { + for name in expected { + assert!( + self.registered_names + .iter() + .any(|registered| registered == name), + "expected registered tool `{name}` in {:?}", + self.registered_names + ); + } + } + + fn assert_registered_lacks(&self, expected_absent: &[&str]) { + for name in expected_absent { + assert!( + !self + .registered_names + .iter() + .any(|registered| registered == name), + "expected registered tool `{name}` to be absent from {:?}", + self.registered_names + ); + } + } + + fn namespace_function_names(&self, namespace: &str) -> &[String] { + self.namespace_functions + .get(namespace) + .map_or(&[], Vec::as_slice) + } + + fn visible_spec(&self, name: &str) -> &ToolSpec { + self.visible_specs + .iter() + .find(|spec| spec.name() == name) + .unwrap_or_else(|| panic!("expected visible spec `{name}` in {:?}", self.visible_names)) + } + + fn exposure(&self, name: &str) -> ToolExposure { + *self + .exposures + .get(name) + .unwrap_or_else(|| panic!("expected registered tool `{name}`")) + } +} + +async fn probe_with( + configure_turn: impl FnOnce(&mut TurnContext), + inputs: ToolPlanInputs, +) -> ToolPlanProbe { + let (_session, mut turn) = make_session_and_context().await; + configure_turn(&mut turn); + let router = ToolRouter::from_turn_context( + &turn, + ToolRouterParams { + mcp_tools: inputs.mcp_tools, + deferred_mcp_tools: inputs.deferred_mcp_tools, + discoverable_tools: inputs.discoverable_tools, + extension_tool_executors: Vec::new(), + dynamic_tools: inputs.dynamic_tools.as_slice(), }, - create_image_generation_tool("png"), - create_view_image_tool(ViewImageToolOptions { - can_request_original_image_detail: config.can_request_original_image_detail, - include_environment_id: false, - }), - ] { - expected.insert(spec.name().to_string(), spec); - } - if config.goal_tools { - for spec in [ - create_get_goal_tool(), - create_create_goal_tool(), - create_update_goal_tool(), - ] { - expected.insert(spec.name().to_string(), spec); - } - } - let collab_specs = if config.multi_agent_v2 { - vec![ - create_spawn_agent_tool_v2(spawn_agent_tool_options(&config)), - create_send_message_tool(), - create_wait_agent_tool_v2(wait_agent_timeout_options()), - create_close_agent_tool_v2(), - ] + ); + ToolPlanProbe::from_router(router) +} + +async fn probe(configure_turn: impl FnOnce(&mut TurnContext)) -> ToolPlanProbe { + probe_with(configure_turn, ToolPlanInputs::default()).await +} + +fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { + if enabled { + turn.features + .enable(feature) + .expect("test feature should be enableable"); } else { - vec![ - create_spawn_agent_tool_v1(spawn_agent_tool_options(&config)), - create_send_input_tool_v1(), - create_wait_agent_tool_v1(wait_agent_timeout_options()), - create_close_agent_tool_v1(), - ] - }; - for spec in collab_specs { - expected.insert(spec.name().to_string(), spec); - } - if !config.multi_agent_v2 { - let spec = create_resume_agent_tool(); - expected.insert(spec.name().to_string(), spec); + turn.features + .disable(feature) + .expect("test feature should be disableable"); } - if config.exec_permission_approvals_enabled { - let spec = create_request_permissions_tool(request_permissions_tool_description()); - expected.insert(spec.name().to_string(), spec); + let mut config = (*turn.config).clone(); + if enabled { + config + .features + .enable(feature) + .expect("test feature should be enableable in config"); + } else { + config + .features + .disable(feature) + .expect("test feature should be disableable in config"); } + turn.config = Arc::new(config); +} - assert_eq!( - actual.keys().collect::>(), - expected.keys().collect::>(), - "tool name set mismatch" - ); - - for name in expected.keys() { - let mut actual_spec = actual.get(name).expect("present").clone(); - let mut expected_spec = expected.get(name).expect("present").clone(); - strip_descriptions_tool(&mut actual_spec); - strip_descriptions_tool(&mut expected_spec); - assert_eq!(actual_spec, expected_spec, "spec mismatch for {name}"); +fn set_features(turn: &mut TurnContext, features: &[Feature]) { + for feature in features { + set_feature(turn, *feature, /*enabled*/ true); } } -#[test] -fn exec_command_spec_includes_environment_id_only_for_multiple_selected_environments() { - let model_info = model_info(); - let available_models = Vec::new(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +fn update_config(turn: &mut TurnContext, update: impl FnOnce(&mut crate::config::Config)) { + let mut config = (*turn.config).clone(); + update(&mut config); + turn.config = Arc::new(config); +} + +fn set_web_search_mode(turn: &mut TurnContext, mode: WebSearchMode) { + update_config(turn, |config| { + config + .web_search_mode + .set(mode) + .expect("test web search mode should be accepted"); }); +} - let (single_environment_tools, _) = build_specs( - &config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_process_tool_environment_id( - &single_environment_tools, - "exec_command", - /*expected_present*/ false, - ); - - let multi_environment_config = config.with_environment_mode(ToolEnvironmentMode::Multiple); - let (multi_environment_tools, _) = build_specs( - &multi_environment_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_process_tool_environment_id( - &multi_environment_tools, - "exec_command", - /*expected_present*/ true, +fn use_chatgpt_auth(turn: &mut TurnContext) { + turn.auth_manager = Some(AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + turn.provider = create_model_provider( + turn.config.model_provider.clone(), + turn.auth_manager.clone(), ); } -#[test] -fn apply_patch_spec_includes_environment_id_only_for_multiple_selected_environments() { - let model_info = model_info(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &Features::with_defaults(), - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +fn use_bedrock_provider(turn: &mut TurnContext) { + let provider_info = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None); + update_config(turn, |config| { + config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string(); + config.model_provider = provider_info.clone(); }); - - let (single_environment_tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_apply_patch_environment_id(&single_environment_tools, /*expected_present*/ false); - - let multi_environment_config = - tools_config.with_environment_mode(ToolEnvironmentMode::Multiple); - let (multi_environment_tools, _) = build_specs( - &multi_environment_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_apply_patch_environment_id(&multi_environment_tools, /*expected_present*/ true); + turn.provider = create_model_provider(provider_info, turn.auth_manager.clone()); } -#[test] -fn test_build_specs_collab_tools_enabled() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::Collab); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &["spawn_agent", "send_input", "wait_agent", "close_agent"], - ); - assert_lacks_tool_name(&tools, "spawn_agents_on_csv"); - assert_lacks_tool_name(&tools, "list_agents"); - - let spawn_agent = find_tool(&tools, "spawn_agent"); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = spawn_agent else { - panic!("spawn_agent should be a function tool"); - }; - let (properties, _) = expect_object_schema(parameters); - assert!(properties.contains_key("fork_context")); - assert!(!properties.contains_key("fork_turns")); +fn duplicate_primary_environment(turn: &mut TurnContext) { + let mut second_environment = turn.environments.turn_environments[0].clone(); + second_environment.environment_id = "secondary".to_string(); + turn.environments.turn_environments.push(second_environment); } -#[test] -fn goal_tools_require_goals_feature() { - let model_info = model_info(); - let available_models = Vec::new(); - let mut features = Features::with_defaults(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_lacks_tool_name(&tools, "get_goal"); - assert_lacks_tool_name(&tools, "create_goal"); - assert_lacks_tool_name(&tools, "update_goal"); - - features.enable(Feature::Goals); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_contains_tool_names(&tools, &["get_goal", "create_goal", "update_goal"]); +fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo { + ToolInfo { + server_name: server.to_string(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: name.to_string(), + callable_namespace: namespace.to_string(), + namespace_description: Some(format!("Tools from {server}.")), + tool: rmcp::model::Tool { + name: name.to_string().into(), + title: None, + description: Some(format!("{name} test tool").into()), + input_schema: Arc::new(rmcp::model::object(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }))), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: None, + connector_name: None, + plugin_display_names: Vec::new(), + } } -#[test] -fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::Collab); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "close_agent", - "list_agents", - ], - ); - - let spawn_agent = find_tool(&tools, "spawn_agent"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = spawn_agent - else { - panic!("spawn_agent should be a function tool"); - }; - let (properties, required) = expect_object_schema(parameters); - assert!(properties.contains_key("task_name")); - assert!(properties.contains_key("message")); - assert!(properties.contains_key("fork_turns")); - assert!(!properties.contains_key("items")); - assert!(!properties.contains_key("fork_context")); - assert_eq!( - required, - Some(&vec!["task_name".to_string(), "message".to_string()]) - ); - let output_schema = output_schema - .as_ref() - .expect("spawn_agent should define output schema"); - assert_eq!(output_schema["required"], json!(["task_name", "nickname"])); - - let send_message = find_tool(&tools, "send_message"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = send_message - else { - panic!("send_message should be a function tool"); - }; - assert_eq!(output_schema, &None); - let (properties, required) = expect_object_schema(parameters); - assert!(properties.contains_key("target")); - assert!(!properties.contains_key("interrupt")); - assert!(properties.contains_key("message")); - assert!(!properties.contains_key("items")); - assert_eq!( - required, - Some(&vec!["target".to_string(), "message".to_string()]) - ); - - let followup_task = find_tool(&tools, "followup_task"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = followup_task - else { - panic!("followup_task should be a function tool"); - }; - assert_eq!(output_schema, &None); - let (properties, required) = expect_object_schema(parameters); - assert!(properties.contains_key("target")); - assert!(properties.contains_key("message")); - assert!(!properties.contains_key("items")); - assert_eq!( - required, - Some(&vec!["target".to_string(), "message".to_string()]) - ); - - let wait_agent = find_tool(&tools, "wait_agent"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = wait_agent - else { - panic!("wait_agent should be a function tool"); - }; - let (properties, required) = expect_object_schema(parameters); - assert!(!properties.contains_key("targets")); - assert!(properties.contains_key("timeout_ms")); - assert_eq!(required, None); - let output_schema = output_schema - .as_ref() - .expect("wait_agent should define output schema"); - assert_eq!( - output_schema["properties"]["message"]["description"], - json!("Brief wait summary without the agent's final content.") - ); - - let list_agents = find_tool(&tools, "list_agents"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = list_agents - else { - panic!("list_agents should be a function tool"); - }; - let (properties, required) = expect_object_schema(parameters); - assert!(properties.contains_key("path_prefix")); - assert_eq!(required, None); - let output_schema = output_schema - .as_ref() - .expect("list_agents should define output schema"); - assert_eq!( - output_schema["properties"]["agents"]["items"]["required"], - json!(["agent_name", "agent_status", "last_task_message"]) - ); - assert_lacks_tool_name(&tools, "send_input"); - assert_lacks_tool_name(&tools, "resume_agent"); +fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> DynamicToolSpec { + DynamicToolSpec { + namespace: namespace.map(str::to_string), + name: name.to_string(), + description: format!("{name} dynamic tool"), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading, + } } -#[test] -fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +fn discoverable_plugin(id: &str, name: &str) -> DiscoverableTool { + DiscoverablePluginInfo { + id: id.to_string(), + name: name.to_string(), + description: Some(format!("{name} plugin")), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } + .into() +} + +fn has_parameter(spec: &ToolSpec, parameter_name: &str) -> bool { + serde_json::to_value(spec) + .expect("tool spec should serialize") + .pointer(&format!("/parameters/properties/{parameter_name}")) + .is_some() +} + +fn apply_patch_accepts_environment_id(spec: &ToolSpec) -> bool { + match spec { + ToolSpec::Freeform(tool) if tool.name == "apply_patch" => { + tool.format.definition.contains("Environment ID") + } + _ => false, + } +} + +#[tokio::test] +async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() { + let plan = probe(|turn| { + set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); + set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); + turn.model_info.shell_type = ConfigShellToolType::ShellCommand; }) - .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], + .await; + + plan.assert_visible_contains(&["exec_command", "write_stdin"]); + plan.assert_visible_lacks(&["shell_command"]); + plan.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]); + assert_eq!(plan.exposure("shell_command"), ToolExposure::Direct); +} + +#[tokio::test] +async fn environment_count_controls_environment_backed_tools() { + let no_environment = probe(|turn| { + turn.environments.turn_environments.clear(); + set_feature(turn, Feature::ShellTool, /*enabled*/ true); + turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); + }) + .await; + no_environment.assert_visible_lacks(&[ + "shell_command", + "exec_command", + "apply_patch", + "view_image", + ]); + no_environment.assert_registered_lacks(&[ + "shell_command", + "exec_command", + "apply_patch", + "view_image", + ]); + + let multiple_environments = probe(|turn| { + duplicate_primary_environment(turn); + set_feature(turn, Feature::ShellTool, /*enabled*/ true); + set_feature(turn, Feature::UnifiedExec, /*enabled*/ true); + turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); + }) + .await; + multiple_environments.assert_visible_contains(&["exec_command", "apply_patch", "view_image"]); + assert!(has_parameter( + multiple_environments.visible_spec("exec_command"), + "environment_id" + )); + assert!(apply_patch_accepts_environment_id( + multiple_environments.visible_spec("apply_patch") + )); + assert!(has_parameter( + multiple_environments.visible_spec("view_image"), + "environment_id" + )); +} + +#[tokio::test] +async fn host_context_gates_goal_and_agent_job_tools() { + let feature_disabled = probe(|turn| { + set_feature(turn, Feature::Goals, /*enabled*/ false); + turn.goal_tools_supported = true; + }) + .await; + feature_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); + + let host_disabled = probe(|turn| { + set_feature(turn, Feature::Goals, /*enabled*/ true); + turn.goal_tools_supported = false; + }) + .await; + host_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); + + let enabled = probe(|turn| { + set_feature(turn, Feature::Goals, /*enabled*/ true); + turn.goal_tools_supported = true; + }) + .await; + enabled.assert_visible_contains(&["get_goal", "create_goal", "update_goal"]); + + let review_thread = probe(|turn| { + set_feature(turn, Feature::Goals, /*enabled*/ true); + turn.goal_tools_supported = true; + turn.session_source = SessionSource::SubAgent(SubAgentSource::Review); + }) + .await; + review_thread.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); + + let normal_agent_job = probe(|turn| { + set_feature(turn, Feature::SpawnCsv, /*enabled*/ true); + }) + .await; + normal_agent_job.assert_visible_contains(&["spawn_agents_on_csv"]); + normal_agent_job.assert_visible_lacks(&["report_agent_job_result"]); + + let worker_agent_job = probe(|turn| { + set_feature(turn, Feature::SpawnCsv, /*enabled*/ true); + turn.session_source = + SessionSource::SubAgent(SubAgentSource::Other("agent_job:42".to_string())); + }) + .await; + worker_agent_job.assert_visible_contains(&["spawn_agents_on_csv", "report_agent_job_result"]); +} + +#[tokio::test] +async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { + let direct_mcp = probe_with( + |_| {}, + ToolPlanInputs { + mcp_tools: Some(vec![mcp_tool("direct", "mcp__direct__", "lookup")]), + ..ToolPlanInputs::default() + }, + ) + .await; + direct_mcp.assert_visible_contains(&[ + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + ]); + assert_eq!( + direct_mcp.namespace_function_names("mcp__direct__"), + &["lookup".to_string()] ); - assert_contains_tool_names(&tools, &["agents"]); + let searchable_mcp = ToolPlanInputs { + deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable__", "lookup")]), + ..ToolPlanInputs::default() + }; + + let missing_model_capability = probe_with( + |turn| { + turn.model_info.supports_search_tool = false; + }, + ToolPlanInputs { + deferred_mcp_tools: searchable_mcp.deferred_mcp_tools.clone(), + ..ToolPlanInputs::default() + }, + ) + .await; + missing_model_capability.assert_visible_lacks(&["tool_search"]); + + let missing_deferred_tools = probe(|turn| { + turn.model_info.supports_search_tool = true; + }) + .await; + missing_deferred_tools.assert_visible_lacks(&["tool_search"]); + missing_deferred_tools.assert_visible_lacks(&[ + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + ]); + + let missing_namespace_capability = probe_with( + |turn| { + turn.model_info.supports_search_tool = true; + use_bedrock_provider(turn); + }, + ToolPlanInputs { + deferred_mcp_tools: searchable_mcp.deferred_mcp_tools.clone(), + ..ToolPlanInputs::default() + }, + ) + .await; + missing_namespace_capability.assert_visible_lacks(&["tool_search"]); + + let enabled = probe_with( + |turn| { + turn.model_info.supports_search_tool = true; + }, + searchable_mcp, + ) + .await; + enabled.assert_visible_contains(&["tool_search"]); + enabled.assert_registered_contains(&["tool_search", "mcp__searchable__lookup"]); +} + +#[tokio::test] +async fn request_plugin_install_requires_all_discovery_features_and_discoverable_tools() { + let discoverable_tools = Some(vec![discoverable_plugin("github", "GitHub")]); + for disabled_feature in [Feature::ToolSuggest, Feature::Apps, Feature::Plugins] { + let plan = probe_with( + |turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + set_feature(turn, disabled_feature, /*enabled*/ false); + }, + ToolPlanInputs { + discoverable_tools: discoverable_tools.clone(), + ..ToolPlanInputs::default() + }, + ) + .await; + plan.assert_visible_lacks(&["request_plugin_install"]); + } + + let no_candidates = probe(|turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }) + .await; + no_candidates.assert_visible_lacks(&["request_plugin_install"]); + + let enabled = probe_with( + |turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }, + ToolPlanInputs { + discoverable_tools, + ..ToolPlanInputs::default() + }, + ) + .await; + enabled.assert_visible_contains(&["request_plugin_install"]); +} + +#[tokio::test] +async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { + let input = ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("codex_app"), + "lookup", + /*defer_loading*/ false, + )], + ..ToolPlanInputs::default() + }; + let plain = probe_with(|_| {}, input).await; + assert_eq!( + plain.namespace_function_names("codex_app"), + &["lookup".to_string()] + ); + plain.assert_visible_lacks(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + ]); + + let code_mode_only = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("codex_app"), + "lookup", + /*defer_loading*/ false, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + code_mode_only.assert_visible_contains(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + ]); + assert_eq!( + code_mode_only.namespace_function_names("codex_app"), + Vec::::new().as_slice() + ); +} + +#[tokio::test] +async fn multi_agent_feature_selects_one_agent_tool_family() { + let v1 = probe(|turn| { + set_feature(turn, Feature::Collab, /*enabled*/ true); + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false); + }) + .await; + v1.assert_visible_contains(&[ + "spawn_agent", + "send_input", + "resume_agent", + "wait_agent", + "close_agent", + ]); + v1.assert_visible_lacks(&["send_message", "followup_task", "list_agents"]); + + let v2 = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.max_concurrent_threads_per_session = 17; + }); + }) + .await; + v2.assert_visible_contains(&[ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ]); + v2.assert_visible_lacks(&["send_input", "resume_agent"]); + let spawn_agent_description = match v2.visible_spec("spawn_agent") { + ToolSpec::Function(tool) => tool.description.as_str(), + other => panic!("expected spawn_agent function spec, got {other:?}"), + }; + assert!(spawn_agent_description.contains("max_concurrent_threads_per_session = 17")); + + let direct_model_only = probe(|turn| { + set_features( + turn, + &[ + Feature::CodeMode, + Feature::CodeModeOnly, + Feature::MultiAgentV2, + ], + ); + update_config(turn, |config| { + config.multi_agent_v2.non_code_mode_only = true; + }); + }) + .await; + direct_model_only.assert_visible_contains(&["spawn_agent", "send_message", "wait_agent"]); + assert_eq!( + direct_model_only.exposure("spawn_agent"), + ToolExposure::DirectModelOnly + ); +} + +#[tokio::test] +async fn multi_agent_v2_can_use_configured_tool_namespace() { + let namespaced = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); + }) + .await; + + namespaced.assert_visible_contains(&["agents"]); for tool_name in [ "spawn_agent", "send_message", @@ -610,663 +672,114 @@ fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() { "close_agent", "list_agents", ] { - assert_lacks_tool_name(&tools, tool_name); - assert!(registry.has_tool(&ToolName::namespaced("agents", tool_name))); - assert!(!registry.has_tool(&ToolName::plain(tool_name))); - assert_namespace_contains_function(&tools, "agents", tool_name); + namespaced.assert_visible_lacks(&[tool_name]); + assert!( + namespaced + .registered_names + .contains(&ToolName::namespaced("agents", tool_name).to_string()), + "expected namespaced runtime for {tool_name}" + ); + assert!( + !namespaced + .registered_names + .contains(&ToolName::plain(tool_name).to_string()), + "expected no plain runtime for {tool_name}" + ); + assert!( + namespaced + .namespace_function_names("agents") + .iter() + .any(|name| name == tool_name), + "expected {tool_name} in agents namespace" + ); } } -#[test] -fn test_build_specs_multi_agent_v2_ignores_tool_namespace_without_namespace_support() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +#[tokio::test] +async fn multi_agent_v2_namespace_is_ignored_without_provider_namespace_support() { + let plan = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); + use_bedrock_provider(turn); }) - .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); - tools_config.namespace_tools = false; - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); + .await; - assert_contains_tool_names(&tools, &["spawn_agent", "send_message", "list_agents"]); - assert_lacks_tool_name(&tools, "agents"); - assert!(registry.has_tool(&ToolName::plain("spawn_agent"))); - assert!(!registry.has_tool(&ToolName::namespaced("agents", "spawn_agent"))); -} - -#[test] -fn test_build_specs_multi_agent_v2_does_not_require_collab_feature() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.disable(Feature::Collab); - features.enable(Feature::MultiAgentV2); - assert!(!features.enabled(Feature::Collab)); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "close_agent", - "list_agents", - ], - ); - assert_lacks_tool_name(&tools, "send_input"); - assert_lacks_tool_name(&tools, "resume_agent"); -} - -#[test] -fn test_build_specs_enable_fanout_enables_agent_jobs_and_collab_tools() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::SpawnCsv); - features.normalize_dependencies(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_input", - "wait_agent", - "close_agent", - "spawn_agents_on_csv", - ], - ); -} - -#[test] -fn view_image_tool_omits_detail_without_original_detail_support() { - let mut model_info = model_info(); - model_info.supports_image_detail_original = false; - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = view_image else { - panic!("view_image should be a function tool"); - }; - let (properties, _) = expect_object_schema(parameters); - assert!(!properties.contains_key("detail")); -} - -#[test] -fn view_image_tool_includes_detail_with_original_detail_support() { - let mut model_info = model_info(); - model_info.supports_image_detail_original = true; - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = view_image else { - panic!("view_image should be a function tool"); - }; - let (properties, _) = expect_object_schema(parameters); - assert!(properties.contains_key("detail")); - let detail_schema = properties - .get("detail") - .expect("view_image detail should include a description"); - let description = expect_string_description(detail_schema); - let expected = vec![json!("high"), json!("original")]; - assert_eq!(detail_schema.enum_values.as_ref(), Some(&expected)); - assert!(description.contains("Supported values are `high` and `original`")); - assert!(description.contains("omit this field for default high resized behavior")); -} - -#[test] -fn disabled_environment_omits_environment_backed_tools() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .with_environment_mode(ToolEnvironmentMode::None); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_lacks_tool_name(&tools, "exec_command"); - assert_lacks_tool_name(&tools, "write_stdin"); - assert_lacks_tool_name(&tools, "apply_patch"); - assert_lacks_tool_name(&tools, VIEW_IMAGE_TOOL_NAME); -} - -#[test] -fn view_image_spec_includes_environment_id_only_for_multiple_selected_environments() { - let model_info = model_info(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &Features::with_defaults(), - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (single_environment_tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_process_tool_environment_id( - &single_environment_tools, - VIEW_IMAGE_TOOL_NAME, - /*expected_present*/ false, - ); - - let multi_environment_config = - tools_config.with_environment_mode(ToolEnvironmentMode::Multiple); - let (multi_environment_tools, _) = build_specs( - &multi_environment_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_process_tool_environment_id( - &multi_environment_tools, - VIEW_IMAGE_TOOL_NAME, - /*expected_present*/ true, - ); -} - -#[test] -fn test_build_specs_agent_job_worker_tools_enabled() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::SpawnCsv); - features.normalize_dependencies(); - features.enable(Feature::Sqlite); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::SubAgent(SubAgentSource::Other( - "agent_job:test".to_string(), - )), - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "spawn_agents_on_csv", - "report_agent_job_result", - REQUEST_USER_INPUT_TOOL_NAME, - ], - ); -} - -#[test] -fn request_user_input_description_reflects_default_mode_feature_flag() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); - assert_eq!( - request_user_input_tool.clone(), - request_user_input_tool_spec(&request_user_input_available_modes(&features)) - ); - - features.enable(Feature::DefaultModeRequestUserInput); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); - assert_eq!( - request_user_input_tool.clone(), - request_user_input_tool_spec(&request_user_input_available_modes(&features)) - ); -} - -#[test] -fn request_permissions_requires_feature_flag() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_lacks_tool_name(&tools, "request_permissions"); - - let mut features = Features::with_defaults(); - features.enable(Feature::RequestPermissionsTool); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let request_permissions_tool = find_tool(&tools, "request_permissions"); - assert_eq!( - request_permissions_tool.clone(), - create_request_permissions_tool(request_permissions_tool_description()) - ); -} - -#[test] -fn request_permissions_tool_is_independent_from_additional_permissions() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::ExecPermissionApprovals); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_lacks_tool_name(&tools, "request_permissions"); -} - -#[test] -fn image_generation_tools_require_feature_and_supported_model() { - let supported_model_info = model_info(); - let mut unsupported_model_info = supported_model_info.clone(); - unsupported_model_info.input_modalities = vec![InputModality::Text]; - let mut image_generation_disabled_features = Features::with_defaults(); - image_generation_disabled_features.disable(Feature::ImageGeneration); - let mut image_generation_features = Features::with_defaults(); - image_generation_features.enable(Feature::ImageGeneration); - - let available_models = Vec::new(); - let default_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_disabled_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (default_tools, _) = build_specs( - &default_tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], + plan.assert_visible_contains(&["spawn_agent", "send_message", "list_agents"]); + plan.assert_visible_lacks(&["agents"]); + assert!( + plan.registered_names + .contains(&ToolName::plain("spawn_agent").to_string()) ); assert!( - !default_tools - .iter() - .any(|tool| tool.name() == "image_generation"), - "image_generation should be disabled when the feature is disabled" - ); - - let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (supported_tools, _) = build_specs( - &supported_tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_contains_tool_names(&supported_tools, &["image_generation"]); - let image_generation_tool = find_tool(&supported_tools, "image_generation"); - assert_eq!( - serde_json::to_value(image_generation_tool).expect("serialize image tool"), - serde_json::json!({ - "type": "image_generation", - "output_format": "png" - }) - ); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &unsupported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert!( - !tools.iter().any(|tool| tool.name() == "image_generation"), - "image_generation should be disabled for unsupported models" + !plan + .registered_names + .contains(&ToolName::namespaced("agents", "spawn_agent").to_string()) ); } -#[test] -fn web_search_mode_cached_sets_external_web_access_false() { - let model_info = model_info(); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.clone(), - ToolSpec::WebSearch { - external_web_access: Some(false), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, - } - ); -} - -#[test] -fn web_search_mode_live_sets_external_web_access_true() { - let model_info = model_info(); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.clone(), - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, - } - ); -} - -#[test] -fn web_search_config_is_forwarded_to_tool_spec() { - let model_info = model_info(); - let features = Features::with_defaults(); - let web_search_config = WebSearchConfig { - filters: Some(codex_protocol::config_types::WebSearchFilters { - allowed_domains: Some(vec!["example.com".to_string()]), - }), - user_location: Some(codex_protocol::config_types::WebSearchUserLocation { - r#type: codex_protocol::config_types::WebSearchUserLocationType::Approximate, - country: Some("US".to_string()), - region: Some("California".to_string()), - city: Some("San Francisco".to_string()), - timezone: Some("America/Los_Angeles".to_string()), - }), - search_context_size: Some(codex_protocol::config_types::WebSearchContextSize::High), - }; - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +#[tokio::test] +async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { + let plan = probe(|turn| { + set_features( + turn, + &[ + Feature::CodeMode, + Feature::CodeModeOnly, + Feature::MultiAgentV2, + ], + ); + update_config(turn, |config| { + config.multi_agent_v2.non_code_mode_only = true; + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); }) - .with_web_search_config(Some(web_search_config.clone())); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); + .await; - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.clone(), - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: web_search_config - .filters - .map(ResponsesApiWebSearchFilters::from), - user_location: web_search_config - .user_location - .map(ResponsesApiWebSearchUserLocation::from), - search_context_size: web_search_config.search_context_size, - search_content_types: None, - } - ); + assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]); + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] { + assert!( + plan.namespace_function_names("agents") + .iter() + .any(|name| name == tool_name), + "expected {tool_name} in agents namespace" + ); + } } -#[test] -fn web_search_tool_type_text_and_image_sets_search_content_types() { - let mut model_info = model_info(); - model_info.web_search_tool_type = WebSearchToolType::TextAndImage; - let features = Features::with_defaults(); +#[tokio::test] +async fn hosted_tools_follow_provider_auth_model_and_config_gates() { + let api_key_auth = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + api_key_auth.assert_visible_lacks(&["image_generation"]); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); + let image_generation = probe(|turn| { + use_chatgpt_auth(turn); + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + image_generation.assert_visible_contains(&["image_generation"]); - let tool = find_tool(&tools, "web_search"); + let live_web_search = probe(|turn| { + set_web_search_mode(turn, WebSearchMode::Live); + turn.model_info.web_search_tool_type = WebSearchToolType::TextAndImage; + }) + .await; assert_eq!( - tool.clone(), - ToolSpec::WebSearch { + live_web_search.visible_spec("web_search"), + &ToolSpec::WebSearch { external_web_access: Some(true), filters: None, user_location: None, @@ -1274,1574 +787,11 @@ fn web_search_tool_type_text_and_image_sets_search_content_types() { search_content_types: Some(vec!["text".to_string(), "image".to_string()]), } ); -} - -#[test] -fn mcp_resource_tools_are_hidden_without_mcp_servers() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert!( - !tools.iter().any(|tool| matches!( - tool.name(), - "list_mcp_resources" | "list_mcp_resource_templates" | "read_mcp_resource" - )), - "MCP resource tools should be omitted when no MCP servers are configured" - ); -} - -#[test] -fn mcp_resource_tools_are_included_when_mcp_servers_are_present() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::new()), - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", - ], - ); -} - -#[test] -#[ignore] -fn test_parallel_support_flags() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names(&tools, &["exec_command", "write_stdin"]); -} - -#[test] -fn test_test_model_info_includes_sync_tool() { - let mut model_info = model_info(); - model_info.experimental_supported_tools = vec!["test_sync_tool".to_string()]; - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool")); -} - -#[test] -fn test_build_specs_mcp_tools_converted() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("test_server/", "do_something_cool"), - mcp_tool( - "do_something_cool", - "Do something cool", - serde_json::json!({ - "type": "object", - "properties": { - "string_argument": { "type": "string" }, - "number_argument": { "type": "number" }, - "object_argument": { - "type": "object", - "properties": { - "string_property": { "type": "string" }, - "number_property": { "type": "number" }, - }, - "required": ["string_property", "number_property"], - "additionalProperties": false, - }, - }, - }), - ), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "test_server/", "do_something_cool"); - assert_eq!( - tool, - &ResponsesApiTool { - name: "do_something_cool".to_string(), - parameters: JsonSchema::object( - BTreeMap::from([ - ( - "string_argument".to_string(), - JsonSchema::string(/*description*/ None), - ), - ( - "number_argument".to_string(), - JsonSchema::number(/*description*/ None), - ), - ( - "object_argument".to_string(), - JsonSchema::object( - BTreeMap::from([ - ( - "string_property".to_string(), - JsonSchema::string(/*description*/ None), - ), - ( - "number_property".to_string(), - JsonSchema::number(/*description*/ None), - ), - ]), - Some(vec![ - "string_property".to_string(), - "number_property".to_string(), - ]), - Some(false.into()), - ), - ), - ]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Do something cool".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[test] -fn namespace_specs_are_hidden_when_namespace_tools_are_disabled() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - - let (tools, registry) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("mcp__sample__", "echo"), - mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_lacks_tool_name(&tools, "mcp__sample__"); - assert!(registry.has_tool(&ToolName::namespaced("mcp__sample__", "echo"))); -} - -#[test] -fn namespaced_dynamic_specs_are_hidden_when_namespace_tools_are_disabled() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_update".to_string(), - description: "Create or update automations.".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - defer_loading: false, - }, - DynamicToolSpec { - namespace: None, - name: "plain_dynamic".to_string(), - description: "Plain dynamic tool.".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - defer_loading: false, - }, - ]; - - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &dynamic_tools, - ); - - assert_lacks_tool_name(&tools, "codex_app"); - assert_contains_tool_names(&tools, &["plain_dynamic"]); -} - -#[test] -fn test_build_specs_mcp_namespace_description_falls_back_when_missing() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("test_server/", "do_something_cool"), - mcp_tool( - "do_something_cool", - "Do something cool", - serde_json::json!({"type": "object"}), - ), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let namespace_tool = find_tool(&tools, "test_server/"); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool"); - }; - assert_eq!( - namespace.description, - "Tools in the test_server/ namespace." - ); -} - -#[test] -fn test_build_specs_mcp_tools_sorted_by_name() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools_map = HashMap::from([ - ( - ToolName::namespaced("test_server/", "do"), - mcp_tool("do", "a", serde_json::json!({"type": "object"})), - ), - ( - ToolName::namespaced("test_server/", "something"), - mcp_tool("something", "b", serde_json::json!({"type": "object"})), - ), - ( - ToolName::namespaced("test_server/", "cool"), - mcp_tool("cool", "c", serde_json::json!({"type": "object"})), - ), - ]); - - let (tools, _) = build_specs( - &tools_config, - Some(tools_map), - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_eq!( - namespace_function_names(&tools, "test_server/"), - vec![ - "cool".to_string(), - "do".to_string(), - "something".to_string(), - ] - ); -} - -#[test] -fn search_tool_description_lists_each_mcp_source_once() { - let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(vec![ - deferred_mcp_tool( - "_create_event", - "mcp__codex_apps__calendar", - CODEX_APPS_MCP_SERVER_NAME, - Some("Calendar"), - Some("Plan events and manage your calendar."), - ), - deferred_mcp_tool( - "_list_events", - "mcp__codex_apps__calendar", - CODEX_APPS_MCP_SERVER_NAME, - Some("Calendar"), - Some("Plan events and manage your calendar."), - ), - deferred_mcp_tool( - "_search_threads", - "mcp__codex_apps__gmail", - CODEX_APPS_MCP_SERVER_NAME, - Some("Gmail"), - Some("Find and summarize email threads."), - ), - deferred_mcp_tool( - "echo", - "mcp__rmcp__", - "rmcp", - /*connector_name*/ None, - Some("Remote memory tools."), - ), - ]), - &[], - ); - - let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); - let ToolSpec::ToolSearch { description, .. } = search_tool else { - panic!("expected tool_search tool"); - }; - let description = description.as_str(); - assert!(description.contains("- Calendar: Plan events and manage your calendar.")); - assert!(description.contains("- Gmail: Find and summarize email threads.")); - assert_eq!( - description - .matches("- Calendar: Plan events and manage your calendar.") - .count(), - 1 - ); - assert!(description.contains("- rmcp: Remote memory tools.")); - assert!(!description.contains("mcp__rmcp__echo")); - - assert!(registry.has_tool(&ToolName::namespaced( - "mcp__codex_apps__calendar", - "_create_event", - ))); - assert!(registry.has_tool(&ToolName::namespaced("mcp__rmcp__", "echo"))); -} - -#[test] -fn search_tool_requires_model_capability() { - let model_info = search_capable_model_info(); - let deferred_mcp_tools = Some(vec![deferred_mcp_tool( - "_create_event", - "mcp__codex_apps__calendar", - CODEX_APPS_MCP_SERVER_NAME, - Some("Calendar"), - /*description*/ None, - )]); - - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &ModelInfo { - supports_search_tool: false, - ..model_info.clone() - }, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - deferred_mcp_tools.clone(), - &[], - ); - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - deferred_mcp_tools, - &[], - ); - assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); -} - -#[test] -fn no_search_tool_when_namespaces_disabled() { - let model_info = search_capable_model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(vec![deferred_mcp_tool( - "_create_event", - "mcp__codex_apps__calendar", - CODEX_APPS_MCP_SERVER_NAME, - Some("Calendar"), - Some("Plan events and manage your calendar."), - )]), - &[], - ); - - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - assert!(!registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); -} - -#[test] -fn search_tool_registers_for_deferred_dynamic_tools() { - let model_info = search_capable_model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_update".to_string(), - description: "Create, update, view, or delete recurring automations.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "mode": { "type": "string" }, - }, - }), - defer_loading: true, - }, - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_list".to_string(), - description: "List recurring automations.".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - }), - defer_loading: true, - }, - ]; - - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &dynamic_tools, - ); - - let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); - let ToolSpec::ToolSearch { description, .. } = search_tool else { - panic!("expected tool_search tool"); - }; - assert!(description.contains("- Dynamic tools: Tools provided by the current Codex thread.")); - assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); - assert_lacks_tool_name(&tools, "codex_app"); - assert!(registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_update"))); - assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_list"))); -} - -#[test] -fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_disabled() { - let model_info = search_capable_model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_update".to_string(), - description: "Create or update automations.".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - defer_loading: true, - }, - DynamicToolSpec { - namespace: None, - name: "plain_dynamic".to_string(), - description: "Plain dynamic tool.".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - defer_loading: true, - }, - ]; - - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &dynamic_tools, - ); - - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - assert_lacks_tool_name(&tools, "codex_app"); - assert_lacks_tool_name(&tools, "plain_dynamic"); - assert!(!registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_update"))); - assert!(registry.has_tool(&ToolName::plain("plain_dynamic"))); -} - -#[test] -fn request_plugin_install_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); - features.disable(Feature::ToolSuggest); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - Some(vec![discoverable_connector( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - "Plan events and schedules.", - )]), - /*extension_tool_executors*/ &[], - &[], - ); - - assert!( - !tools - .iter() - .any(|tool| tool.name() == REQUEST_PLUGIN_INSTALL_TOOL_NAME) - ); -} - -#[test] -fn request_plugin_install_can_be_registered_without_search_tool() { - let model_info = ModelInfo { - supports_search_tool: false, - ..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 { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - Some(vec![discoverable_connector( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - "Plan events and schedules.", - )]), - /*extension_tool_executors*/ &[], - &[], - ); - - assert_contains_tool_names(&tools, &[REQUEST_PLUGIN_INSTALL_TOOL_NAME]); - let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME); - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - - let ToolSpec::Function(ResponsesApiTool { description, .. }) = request_plugin_install else { - panic!("expected function tool"); - }; - assert!(description.contains( - "Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed." - )); - assert!(description.contains( - "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." - )); -} - -#[test] -fn request_plugin_install_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 { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let discoverable_tools = vec![ - discoverable_connector( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - "Plan events and schedules.", - ), - discoverable_connector( - "connector_68df038e0ba48191908c8434991bbac2", - "Gmail", - "Find and summarize email threads.", - ), - DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { - id: "sample@test".to_string(), - name: "Sample Plugin".to_string(), - description: None, - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_sample".to_string()], - })), - ]; - - let (tools, registry) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - Some(discoverable_tools), - /*extension_tool_executors*/ &[], - &[], - ); - assert!(registry.has_tool(&ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME))); - - let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { - description, - parameters, - .. - }) = request_plugin_install - else { - panic!("expected function tool"); - }; - assert!(description.contains( - "Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed." - )); - assert!(description.contains("Google Calendar")); - assert!(description.contains("Gmail")); - assert!(description.contains("Sample Plugin")); - assert!(description.contains("Plan events and schedules.")); - assert!(description.contains("Find and summarize email threads.")); - assert!(description.contains("id: `sample@test`, type: plugin, action: install")); - assert!(description.contains("`action_type`: `install`")); - assert!( - description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample") - ); - assert!( - 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!(description.contains( - "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." - )); - assert!(description.contains( - "The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list." - )); - assert!(description.contains( - "Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful." - )); - assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools.")); - assert!(description.contains( - "If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool." - )); - assert!(!description.contains("targeted lookup")); - assert!(!description.contains("broad or speculative searches")); - assert!(description.contains("Only proceed when one listed plugin or connector exactly fits.")); - assert!(description.contains( - "If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not." - )); - assert!(!description.contains("{{discoverable_tools}}")); - assert!(!description.contains("tool_search fails to find a good match")); - let (_, required) = expect_object_schema(parameters); - assert_eq!( - required, - Some(&vec![ - "tool_type".to_string(), - "action_type".to_string(), - "tool_id".to_string(), - "suggest_reason".to_string(), - ]) - ); -} - -#[test] -fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("mcp__sample__", "echo"), - mcp_tool( - "echo", - "Echo text", - serde_json::json!({ - "type": "object", - "properties": { - "message": {"type": "string"} - }, - "required": ["message"], - "additionalProperties": false - }), - ), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(description.contains( - r#"### `mcp__sample__echo` -Echo text - -exec tool declaration: -```ts -declare const tools: { mcp__sample__echo(args: { message: string; }): Promise; }; -```"# - )); -} - -#[test] -fn code_mode_preserves_nullable_and_literal_mcp_input_shapes() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("mcp__sample__", "fn"), - mcp_tool( - "fn", - "Sample fn", - serde_json::json!({ - "type": "object", - "properties": { - "open": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "object", - "properties": { - "ref_id": {"type": "string"}, - "lineno": {"anyOf": [{"type": "integer"}, {"type": "null"}]} - }, - "required": ["ref_id"], - "additionalProperties": false - } - }, - {"type": "null"} - ] - }, - "tagged_list": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "object", - "properties": { - "kind": {"type": "const", "const": "tagged"}, - "variant": {"type": "enum", "enum": ["alpha", "beta"]}, - "scope": {"type": "enum", "enum": ["one", "two"]} - }, - "required": ["kind", "variant", "scope"] - } - }, - {"type": "null"} - ] - }, - "response_length": {"type": "enum", "enum": ["short", "medium", "long"]} - }, - "additionalProperties": false - }), - ), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let ResponsesApiTool { description, .. } = - find_namespace_function_tool(&tools, "mcp__sample__", "fn"); - - assert!(description.contains( - r#"exec tool declaration: -```ts -declare const tools: { mcp__sample__fn(args: { open?: Array<{ lineno?: number | null; ref_id: string; }> | null; response_length?: "short" | "medium" | "long"; tagged_list?: Array<{ kind: "tagged"; scope: "one" | "two"; variant: "alpha" | "beta"; }> | null; }): Promise; }; -```"# - )); -} - -#[test] -fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = - find_tool(&tools, VIEW_IMAGE_TOOL_NAME) - else { - panic!("expected function tool"); - }; - - assert_eq!( - description, - "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```" - ); -} - -#[test] -fn code_mode_only_exec_description_includes_full_nested_tool_details() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(!description.contains("Enabled nested tools:")); - assert!(!description.contains("Nested tool reference:")); - assert!(description.starts_with("Run JavaScript code to orchestrate/compose tool calls")); - assert!(!description.contains("do not attempt to use any other tools directly")); - assert!(description.contains("### `update_plan`")); - assert!(description.contains("### `view_image`")); -} - -#[test] -fn code_mode_only_exec_description_includes_extension_tool_details() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let extension_tool_executors = vec![extension_tool_executor( - "extension_echo", - "Echoes arguments through an extension tool.", - )]; - let (tools, _) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - /*discoverable_tools*/ None, - &extension_tool_executors, - &[], - ); - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(description.contains("### `extension_echo`")); - assert!(description.contains("Echoes arguments through an extension tool.")); -} - -#[test] -fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(!description.starts_with( - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly" - )); - assert!(!description.contains("### `update_plan`")); - assert!(!description.contains("### `view_image`")); -} - -fn model_info() -> ModelInfo { - serde_json::from_value(json!({ - "slug": "gpt-5-codex", - "display_name": "GPT-5 Codex", - "description": null, - "supported_reasoning_levels": [], - "shell_type": "shell_command", - "visibility": "list", - "supported_in_api": true, - "priority": 1, - "availability_nux": null, - "upgrade": null, - "base_instructions": "base", - "model_messages": null, - "supports_reasoning_summaries": false, - "default_reasoning_summary": "auto", - "support_verbosity": false, - "default_verbosity": null, - "apply_patch_tool_type": "freeform", - "truncation_policy": { - "mode": "bytes", - "limit": 10000 - }, - "supports_parallel_tool_calls": false, - "supports_image_detail_original": false, - "context_window": null, - "auto_compact_token_limit": null, - "effective_context_window_percent": 95, - "experimental_supported_tools": [], - "input_modalities": ["text", "image"], - "supports_search_tool": false - })) - .expect("deserialize test model") -} - -fn search_capable_model_info() -> ModelInfo { - ModelInfo { - supports_search_tool: true, - ..model_info() - } -} - -fn build_specs( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - dynamic_tools: &[DynamicToolSpec], -) -> (Vec, ToolRegistry) { - build_specs_with_inputs_for_test( - config, - mcp_tools, - deferred_mcp_tools, - /*discoverable_tools*/ None, - /*extension_tool_executors*/ &[], - dynamic_tools, - ) -} - -fn build_specs_with_inputs_for_test( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - discoverable_tools: Option>, - extension_tool_executors: &[Arc>], - dynamic_tools: &[DynamicToolSpec], -) -> (Vec, ToolRegistry) { - let mcp_tool_inputs = mcp_tools.as_ref().map(|mcp_tools| { - mcp_tools - .iter() - .map(|(name, tool)| tool_info_from_parts(name, tool.clone())) - .collect::>() - }); - let params = ToolRegistryBuildParams { - mcp_tools: mcp_tool_inputs.as_deref(), - deferred_mcp_tools: deferred_mcp_tools.as_deref(), - discoverable_tools: discoverable_tools.as_deref(), - extension_tool_executors, - dynamic_tools, - default_agent_type_description: DEFAULT_AGENT_TYPE_DESCRIPTION, - wait_agent_timeouts: wait_agent_timeout_options(), - }; - let mut executors = collect_tool_executors(config, params); - append_tool_search_executor(config, &mut executors); - prepend_code_mode_executors(config, &mut executors); - build_model_visible_specs_and_registry(config, executors, hosted_model_tool_specs(config)) -} - -fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { - rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(description.to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } -} - -fn tool_info_from_parts(name: &ToolName, tool: rmcp::model::Tool) -> ToolInfo { - ToolInfo { - server_name: server_name_from_tool_name(name), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: name.name.clone(), - callable_namespace: name.namespace.clone().unwrap_or_default(), - namespace_description: None, - tool, - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - } -} - -fn server_name_from_tool_name(name: &ToolName) -> String { - name.namespace - .as_deref() - .and_then(|namespace| { - namespace - .strip_prefix("mcp__") - .and_then(|suffix| suffix.strip_suffix("__")) - }) - .unwrap_or_else(|| name.namespace.as_deref().unwrap_or("test_server")) - .to_string() -} - -#[test] -fn code_mode_augments_mcp_tool_descriptions_with_structured_output_sample() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let mut tool = mcp_tool( - "echo", - "Echo text", - serde_json::json!({ - "type": "object", - "properties": { - "message": {"type": "string"} - }, - "required": ["message"], - "additionalProperties": false - }), - ); - tool.output_schema = Some(std::sync::Arc::new(rmcp::model::object( - serde_json::json!({ - "type": "object", - "properties": { - "echo": {"type": "string"}, - "env": { - "anyOf": [ - {"type": "string"}, - {"type": "null"} - ] - } - }, - "required": ["echo", "env"], - "additionalProperties": false - }), - ))); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("mcp__sample__", "echo"), - tool, - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(description.contains( - r#"### `mcp__sample__echo` -Echo text - -exec tool declaration: -```ts -declare const tools: { mcp__sample__echo(args: { message: string; }): Promise>; }; -```"# - )); -} - -fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { - let slug = name.replace(' ', "-").to_lowercase(); - DiscoverableTool::Connector(Box::new(AppInfo { - id: id.to_string(), - name: name.to_string(), - description: Some(description.to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })) -} - -fn deferred_mcp_tool( - tool_name: &str, - tool_namespace: &str, - server_name: &str, - connector_name: Option<&str>, - description: Option<&str>, -) -> ToolInfo { - ToolInfo { - server_name: server_name.to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: tool_name.to_string(), - callable_namespace: tool_namespace.to_string(), - namespace_description: description.map(str::to_string), - tool: mcp_tool( - tool_name, - description.unwrap_or("Deferred MCP tool"), - json!({}), - ), - connector_id: None, - connector_name: connector_name.map(str::to_string), - plugin_display_names: Vec::new(), - } -} - -fn assert_contains_tool_names(tools: &[ToolSpec], expected_subset: &[&str]) { - use std::collections::HashSet; - - let mut names = HashSet::new(); - let mut duplicates = Vec::new(); - for name in tools.iter().map(ToolSpec::name) { - if !names.insert(name) { - duplicates.push(name); - } - } - assert!( - duplicates.is_empty(), - "duplicate tool entries detected: {duplicates:?}" - ); - for expected in expected_subset { - assert!( - names.contains(expected), - "expected tool {expected} to be present; had: {names:?}" - ); - } -} - -fn assert_lacks_tool_name(tools: &[ToolSpec], expected_absent: &str) { - let names = tools.iter().map(ToolSpec::name).collect::>(); - assert!( - !names.contains(&expected_absent), - "expected tool {expected_absent} to be absent; had: {names:?}" - ); -} - -fn request_user_input_tool_spec(available_modes: &[ModeKind]) -> ToolSpec { - create_request_user_input_tool(request_user_input_tool_description(available_modes)) -} - -fn spawn_agent_tool_options(config: &ToolsConfig) -> SpawnAgentToolOptions { - SpawnAgentToolOptions { - available_models: config.available_models.clone(), - agent_type_description: agent_type_description(config, DEFAULT_AGENT_TYPE_DESCRIPTION), - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, - } -} - -fn wait_agent_timeout_options() -> WaitAgentTimeoutOptions { - WaitAgentTimeoutOptions { - default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS, - min_timeout_ms: MIN_WAIT_TIMEOUT_MS, - max_timeout_ms: MAX_WAIT_TIMEOUT_MS, - } -} - -fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec { - tools - .iter() - .find(|tool| tool.name() == expected_name) - .unwrap_or_else(|| panic!("expected tool {expected_name}")) -} - -fn assert_namespace_contains_function( - tools: &[ToolSpec], - expected_namespace: &str, - expected_name: &str, -) { - let namespace_tool = find_tool(tools, expected_namespace); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool {expected_namespace}"); - }; - assert!( - namespace.tools.iter().any(|tool| { - matches!(tool, ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name) - }), - "expected tool {expected_name} in namespace {expected_namespace}" - ); -} - -fn assert_process_tool_environment_id( - tools: &[ToolSpec], - expected_name: &str, - expected_present: bool, -) { - let tool = find_tool(tools, expected_name); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { - panic!("expected function tool {expected_name}"); - }; - let (properties, _) = expect_object_schema(parameters); - assert_eq!( - properties.contains_key("environment_id"), - expected_present, - "{expected_name} environment_id parameter presence" - ); -} - -fn assert_apply_patch_environment_id(tools: &[ToolSpec], expected_present: bool) { - let tool = find_tool(tools, "apply_patch"); - let ToolSpec::Freeform(FreeformTool { format, .. }) = tool else { - panic!("expected freeform apply_patch tool"); - }; - assert_eq!( - format.definition.contains("environment_id?"), - expected_present, - "apply_patch environment_id grammar presence" - ); -} - -fn find_namespace_function_tool<'a>( - tools: &'a [ToolSpec], - expected_namespace: &str, - expected_name: &str, -) -> &'a ResponsesApiTool { - let namespace_tool = find_tool(tools, expected_namespace); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool {expected_namespace}"); - }; - namespace - .tools - .iter() - .find_map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name => Some(tool), - _ => None, - }) - .unwrap_or_else(|| panic!("expected tool {expected_namespace}{expected_name} in namespace")) -} - -fn namespace_function_names(tools: &[ToolSpec], expected_namespace: &str) -> Vec { - let namespace_tool = find_tool(tools, expected_namespace); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool {expected_namespace}"); - }; - namespace - .tools - .iter() - .map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(), - }) - .collect() -} - -fn expect_object_schema( - schema: &JsonSchema, -) -> (&BTreeMap, Option<&Vec>) { - assert_eq!( - schema.schema_type, - Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)) - ); - let properties = schema - .properties - .as_ref() - .expect("expected object properties"); - (properties, schema.required.as_ref()) -} - -fn expect_string_description(schema: &JsonSchema) -> &str { - assert_eq!( - schema.schema_type, - Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::String)) - ); - schema.description.as_deref().expect("expected description") -} - -fn strip_descriptions_schema(schema: &mut JsonSchema) { - if let Some(variants) = &mut schema.any_of { - for variant in variants { - strip_descriptions_schema(variant); - } - } - if let Some(items) = &mut schema.items { - strip_descriptions_schema(items); - } - if let Some(properties) = &mut schema.properties { - for value in properties.values_mut() { - strip_descriptions_schema(value); - } - } - if let Some(AdditionalProperties::Schema(schema)) = &mut schema.additional_properties { - strip_descriptions_schema(schema); - } - schema.description = None; -} - -fn strip_descriptions_tool(spec: &mut ToolSpec) { - match spec { - ToolSpec::ToolSearch { parameters, .. } => strip_descriptions_schema(parameters), - ToolSpec::Function(ResponsesApiTool { parameters, .. }) => { - strip_descriptions_schema(parameters); - } - ToolSpec::Namespace(namespace) => { - for tool in &mut namespace.tools { - match tool { - ResponsesApiNamespaceTool::Function(ResponsesApiTool { - parameters, .. - }) => { - strip_descriptions_schema(parameters); - } - } - } - } - ToolSpec::Freeform(FreeformTool { .. }) - | ToolSpec::ImageGeneration { .. } - | ToolSpec::WebSearch { .. } => {} - } + + let unsupported_provider = probe(|turn| { + set_web_search_mode(turn, WebSearchMode::Live); + use_bedrock_provider(turn); + }) + .await; + unsupported_provider.assert_visible_lacks(&["web_search"]); } diff --git a/codex-rs/core/src/tools/tool_family/mod.rs b/codex-rs/core/src/tools/tool_family/mod.rs new file mode 100644 index 000000000..d1e1cbd5a --- /dev/null +++ b/codex-rs/core/src/tools/tool_family/mod.rs @@ -0,0 +1 @@ +pub(crate) mod shell; diff --git a/codex-rs/core/src/tools/tool_family/shell.rs b/codex-rs/core/src/tools/tool_family/shell.rs new file mode 100644 index 000000000..10777562b --- /dev/null +++ b/codex-rs/core/src/tools/tool_family/shell.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use codex_protocol::openai_models::ConfigShellToolType; +use codex_tools::ShellCommandBackendConfig; +use codex_tools::ToolEnvironmentMode; + +use crate::tools::handlers::ExecCommandHandler; +use crate::tools::handlers::ExecCommandHandlerOptions; +use crate::tools::handlers::ShellCommandHandler; +use crate::tools::handlers::ShellCommandHandlerOptions; +use crate::tools::handlers::WriteStdinHandler; +use crate::tools::registry::CoreToolRuntime; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ShellToolsOptions { + pub(crate) shell_type: ConfigShellToolType, + pub(crate) shell_command_backend: ShellCommandBackendConfig, + pub(crate) environment_mode: ToolEnvironmentMode, + pub(crate) allow_login_shell: bool, + pub(crate) exec_permission_approvals_enabled: bool, +} + +pub(crate) fn build_shell_tools(options: ShellToolsOptions) -> Vec> { + let mut runtimes = Vec::new(); + if !options.environment_mode.has_environment() { + return runtimes; + } + + let include_environment_id = matches!(options.environment_mode, ToolEnvironmentMode::Multiple); + match options.shell_type { + ConfigShellToolType::UnifiedExec => { + add_runtime( + &mut runtimes, + ExecCommandHandler::new(ExecCommandHandlerOptions { + allow_login_shell: options.allow_login_shell, + exec_permission_approvals_enabled: options.exec_permission_approvals_enabled, + include_environment_id, + }), + ); + add_runtime(&mut runtimes, WriteStdinHandler); + + // Keep the legacy shell tool registered as a hidden runtime while + // unified exec is model-visible. + add_runtime( + &mut runtimes, + ShellCommandHandler::from(options.shell_command_backend), + ); + } + ConfigShellToolType::Disabled => {} + ConfigShellToolType::Default + | ConfigShellToolType::Local + | ConfigShellToolType::ShellCommand => { + add_runtime( + &mut runtimes, + ShellCommandHandler::new(ShellCommandHandlerOptions { + backend_config: options.shell_command_backend, + allow_login_shell: options.allow_login_shell, + exec_permission_approvals_enabled: options.exec_permission_approvals_enabled, + }), + ); + } + } + + runtimes +} + +fn add_runtime(runtimes: &mut Vec>, runtime: T) +where + T: CoreToolRuntime + 'static, +{ + runtimes.push(Arc::new(runtime)); +} diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index e8e4f87e9..8c0cc8968 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1014,10 +1014,8 @@ impl UnifiedExecProcessManager { local_policy_env, }; let mut orchestrator = ToolOrchestrator::new(); - let mut runtime = UnifiedExecRuntime::new( - self, - context.turn.tools_config.unified_exec_shell_mode.clone(), - ); + let mut runtime = + UnifiedExecRuntime::new(self, context.turn.unified_exec_shell_mode.clone()); let file_system_sandbox_policy = context.turn.file_system_sandbox_policy(); let exec_approval_requirement = context .session diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index 68f408095..92cccaf30 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -1,7 +1,7 @@ # codex-tools -`codex-tools` is the shared support crate for building, adapting, planning, and -executing model-visible tool sets outside `codex-core`. +`codex-tools` is the shared support crate for building, adapting, and executing +model-visible tools outside `codex-core`. Today this crate owns the host-facing tool models and helpers that no longer need to live in `core/src/tools/spec.rs` or `core/src/client_common.rs`: @@ -9,8 +9,8 @@ need to live in `core/src/tools/spec.rs` or `core/src/client_common.rs`: - aggregate host models such as `ToolSpec`, `ConfiguredToolSpec`, `LoadableToolSpec`, `ResponsesApiNamespace`, and `ResponsesApiNamespaceTool` -- host config and discovery models used while assembling tool sets, including - `ToolsConfig`, discoverable-tool models, and request-plugin-install helpers +- host discovery models used while assembling tool sets, including + discoverable-tool models and request-plugin-install helpers - host adapters such as schema sanitization, MCP/dynamic conversion, code-mode augmentation, and image-detail normalization - shared executable-tool contracts such as `ToolExecutor`, `ToolCall`, and diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 2a87ba35d..4d972af2c 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -61,11 +61,11 @@ pub use tool_call::ToolCall; pub use tool_config::ShellCommandBackendConfig; pub use tool_config::ToolEnvironmentMode; pub use tool_config::ToolUserShellType; -pub use tool_config::ToolsConfig; -pub use tool_config::ToolsConfigParams; pub use tool_config::UnifiedExecShellMode; pub use tool_config::ZshForkConfig; pub use tool_config::request_user_input_available_modes; +pub use tool_config::shell_command_backend_for_features; +pub use tool_config::shell_type_for_model_and_features; pub use tool_definition::ToolDefinition; pub use tool_discovery::DiscoverablePluginInfo; pub use tool_discovery::DiscoverableTool; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index b6f4ae8db..3eaa73f18 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -1,20 +1,9 @@ -use crate::can_request_original_image_detail; use codex_features::Feature; use codex_features::Features; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; -use codex_protocol::config_types::WebSearchConfig; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::PermissionProfile; -use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::WebSearchToolType; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::PathBuf; @@ -44,6 +33,44 @@ pub fn request_user_input_available_modes(features: &Features) -> Vec .collect() } +pub fn shell_command_backend_for_features(features: &Features) -> ShellCommandBackendConfig { + if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { + ShellCommandBackendConfig::ZshFork + } else { + ShellCommandBackendConfig::Classic + } +} + +pub fn shell_type_for_model_and_features( + model_info: &ModelInfo, + features: &Features, +) -> ConfigShellToolType { + let unified_exec_enabled = features.enabled(Feature::UnifiedExec); + let model_shell_type = match model_info.shell_type { + ConfigShellToolType::UnifiedExec if !unified_exec_enabled => { + ConfigShellToolType::ShellCommand + } + ConfigShellToolType::Default | ConfigShellToolType::Local => { + ConfigShellToolType::ShellCommand + } + other => other, + }; + + if !features.enabled(Feature::ShellTool) { + ConfigShellToolType::Disabled + } else if features.enabled(Feature::ShellZshFork) { + ConfigShellToolType::ShellCommand + } else if unified_exec_enabled { + if codex_utils_pty::conpty_supported() { + ConfigShellToolType::UnifiedExec + } else { + ConfigShellToolType::ShellCommand + } + } else { + model_shell_type + } +} + #[derive(Debug, Clone, Eq, PartialEq)] pub enum UnifiedExecShellMode { Direct, @@ -93,57 +120,6 @@ impl UnifiedExecShellMode { } } -#[derive(Debug, Clone)] -pub struct ToolsConfig { - pub available_models: Vec, - pub shell_type: ConfigShellToolType, - pub shell_command_backend: ShellCommandBackendConfig, - pub unified_exec_shell_mode: UnifiedExecShellMode, - pub environment_mode: ToolEnvironmentMode, - pub allow_login_shell: bool, - pub apply_patch_tool_type: Option, - pub web_search_mode: Option, - pub web_search_config: Option, - pub web_search_tool_type: WebSearchToolType, - pub image_gen_tool: bool, - pub search_tool: bool, - pub namespace_tools: bool, - pub tool_suggest: bool, - pub exec_permission_approvals_enabled: bool, - pub request_permissions_tool_enabled: bool, - pub code_mode_enabled: bool, - pub code_mode_only_enabled: bool, - pub can_request_original_image_detail: bool, - pub collab_tools: bool, - pub goal_tools: bool, - pub multi_agent_v2: bool, - pub multi_agent_v2_non_code_mode_only: bool, - pub hide_spawn_agent_metadata: bool, - pub spawn_agent_usage_hint: bool, - pub spawn_agent_usage_hint_text: Option, - pub multi_agent_v2_tool_namespace: Option, - pub max_concurrent_threads_per_session: Option, - pub wait_agent_min_timeout_ms: Option, - pub wait_agent_max_timeout_ms: Option, - pub wait_agent_default_timeout_ms: Option, - pub request_user_input_available_modes: Vec, - pub experimental_supported_tools: Vec, - pub agent_jobs_tools: bool, - pub agent_jobs_worker_tools: bool, - pub agent_type_description: String, -} - -pub struct ToolsConfigParams<'a> { - pub model_info: &'a ModelInfo, - pub available_models: &'a [ModelPreset], - pub features: &'a Features, - pub image_generation_tool_auth_allowed: bool, - pub web_search_mode: Option, - pub session_source: SessionSource, - pub permission_profile: &'a PermissionProfile, - pub windows_sandbox_level: WindowsSandboxLevel, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolEnvironmentMode { None, @@ -165,262 +141,6 @@ impl ToolEnvironmentMode { } } -impl ToolsConfig { - pub fn new(params: &ToolsConfigParams<'_>) -> Self { - let ToolsConfigParams { - model_info, - available_models, - features, - image_generation_tool_auth_allowed, - web_search_mode, - session_source, - .. - } = params; - let include_code_mode = features.enabled(Feature::CodeMode); - let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly); - let include_goal_tools = features.enabled(Feature::Goals); - let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); - let include_collab_tools = include_multi_agent_v2 || features.enabled(Feature::Collab); - let include_agent_jobs = features.enabled(Feature::SpawnCsv); - let include_search_tool = model_info.supports_search_tool; - let include_tool_suggest = features.enabled(Feature::ToolSuggest) - && features.enabled(Feature::Apps) - && features.enabled(Feature::Plugins); - let include_original_image_detail = can_request_original_image_detail(model_info); - // API-key auth bypasses Codex backend entitlement/tool normalization, so - // callers must confirm ChatGPT auth before exposing the built-in tool. - let include_image_gen_tool = *image_generation_tool_auth_allowed - && features.enabled(Feature::ImageGeneration) - && supports_image_generation(model_info); - let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); - let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); - let shell_command_backend = - if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { - ShellCommandBackendConfig::ZshFork - } else { - ShellCommandBackendConfig::Classic - }; - let unified_exec_enabled = features.enabled(Feature::UnifiedExec); - let model_shell_type = match model_info.shell_type { - ConfigShellToolType::UnifiedExec if !unified_exec_enabled => { - ConfigShellToolType::ShellCommand - } - ConfigShellToolType::Default | ConfigShellToolType::Local => { - ConfigShellToolType::ShellCommand - } - other => other, - }; - let shell_type = if !features.enabled(Feature::ShellTool) { - ConfigShellToolType::Disabled - } else if features.enabled(Feature::ShellZshFork) { - ConfigShellToolType::ShellCommand - } else if unified_exec_enabled { - if codex_utils_pty::conpty_supported() { - ConfigShellToolType::UnifiedExec - } else { - ConfigShellToolType::ShellCommand - } - } else { - model_shell_type - }; - - let apply_patch_tool_type = model_info.apply_patch_tool_type.clone(); - - let agent_jobs_worker_tools = include_agent_jobs - && matches!( - session_source, - SessionSource::SubAgent(SubAgentSource::Other(label)) - if label.starts_with("agent_job:") - ); - - Self { - available_models: available_models.to_vec(), - shell_type, - shell_command_backend, - unified_exec_shell_mode: UnifiedExecShellMode::Direct, - environment_mode: ToolEnvironmentMode::Single, - allow_login_shell: true, - apply_patch_tool_type, - web_search_mode: *web_search_mode, - web_search_config: None, - web_search_tool_type: model_info.web_search_tool_type, - image_gen_tool: include_image_gen_tool, - search_tool: include_search_tool, - namespace_tools: true, - tool_suggest: include_tool_suggest, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, - code_mode_enabled: include_code_mode, - code_mode_only_enabled: include_code_mode_only, - can_request_original_image_detail: include_original_image_detail, - collab_tools: include_collab_tools, - goal_tools: include_goal_tools, - multi_agent_v2: include_multi_agent_v2, - multi_agent_v2_non_code_mode_only: false, - hide_spawn_agent_metadata: false, - spawn_agent_usage_hint: true, - spawn_agent_usage_hint_text: None, - multi_agent_v2_tool_namespace: None, - max_concurrent_threads_per_session: None, - wait_agent_min_timeout_ms: None, - wait_agent_max_timeout_ms: None, - wait_agent_default_timeout_ms: None, - request_user_input_available_modes: request_user_input_available_modes(features), - experimental_supported_tools: model_info.experimental_supported_tools.clone(), - agent_jobs_tools: include_agent_jobs, - agent_jobs_worker_tools, - agent_type_description: String::new(), - } - } - - pub fn with_agent_type_description(mut self, agent_type_description: String) -> Self { - self.agent_type_description = agent_type_description; - self - } - - pub fn with_namespace_tools_capability(mut self, namespace_tools: bool) -> Self { - if !namespace_tools { - self.namespace_tools = false; - } - self - } - - pub fn with_image_generation_capability(mut self, image_generation: bool) -> Self { - if !image_generation { - self.image_gen_tool = false; - } - self - } - - pub fn with_web_search_capability(mut self, web_search: bool) -> Self { - if !web_search { - self.web_search_mode = None; - } - self - } - - pub fn with_spawn_agent_usage_hint(mut self, spawn_agent_usage_hint: bool) -> Self { - self.spawn_agent_usage_hint = spawn_agent_usage_hint; - self - } - - pub fn with_spawn_agent_usage_hint_text( - mut self, - spawn_agent_usage_hint_text: Option, - ) -> Self { - self.spawn_agent_usage_hint_text = spawn_agent_usage_hint_text; - self - } - - pub fn with_hide_spawn_agent_metadata(mut self, hide_spawn_agent_metadata: bool) -> Self { - self.hide_spawn_agent_metadata = hide_spawn_agent_metadata; - self - } - - pub fn with_multi_agent_v2_tool_namespace( - mut self, - multi_agent_v2_tool_namespace: Option, - ) -> Self { - self.multi_agent_v2_tool_namespace = multi_agent_v2_tool_namespace; - self - } - - pub fn with_multi_agent_v2_non_code_mode_only( - mut self, - multi_agent_v2_non_code_mode_only: bool, - ) -> Self { - self.multi_agent_v2_non_code_mode_only = - self.multi_agent_v2 && multi_agent_v2_non_code_mode_only; - self - } - - pub fn with_goal_tools_allowed(mut self, allowed: bool) -> Self { - self.goal_tools = self.goal_tools && allowed; - self - } - - pub fn with_max_concurrent_threads_per_session( - mut self, - max_concurrent_threads_per_session: Option, - ) -> Self { - self.max_concurrent_threads_per_session = max_concurrent_threads_per_session; - self - } - - pub fn with_wait_agent_min_timeout_ms( - mut self, - wait_agent_min_timeout_ms: Option, - ) -> Self { - self.wait_agent_min_timeout_ms = wait_agent_min_timeout_ms; - self - } - - pub fn with_wait_agent_max_timeout_ms( - mut self, - wait_agent_max_timeout_ms: Option, - ) -> Self { - self.wait_agent_max_timeout_ms = wait_agent_max_timeout_ms; - self - } - - pub fn with_wait_agent_default_timeout_ms( - mut self, - wait_agent_default_timeout_ms: Option, - ) -> Self { - self.wait_agent_default_timeout_ms = wait_agent_default_timeout_ms; - self - } - - pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { - self.allow_login_shell = allow_login_shell; - self - } - - pub fn with_environment_mode(mut self, environment_mode: ToolEnvironmentMode) -> Self { - self.environment_mode = environment_mode; - self - } - - pub fn with_unified_exec_shell_mode( - mut self, - unified_exec_shell_mode: UnifiedExecShellMode, - ) -> Self { - self.unified_exec_shell_mode = unified_exec_shell_mode; - self - } - - pub fn with_unified_exec_shell_mode_for_session( - mut self, - user_shell_type: ToolUserShellType, - shell_zsh_path: Option<&PathBuf>, - main_execve_wrapper_exe: Option<&PathBuf>, - ) -> Self { - self.unified_exec_shell_mode = UnifiedExecShellMode::for_session( - self.shell_command_backend, - user_shell_type, - shell_zsh_path, - main_execve_wrapper_exe, - ); - self - } - - pub fn with_web_search_config(mut self, web_search_config: Option) -> Self { - self.web_search_config = web_search_config; - self - } - - pub fn for_code_mode_nested_tools(&self) -> Self { - let mut nested = self.clone(); - nested.code_mode_enabled = false; - nested.code_mode_only_enabled = false; - nested - } -} - -fn supports_image_generation(model_info: &ModelInfo) -> bool { - model_info.input_modalities.contains(&InputModality::Image) -} - #[cfg(test)] #[path = "tool_config_tests.rs"] mod tests; diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 2fa49b03b..8ce68acbb 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -1,294 +1,153 @@ -use super::*; use codex_features::Feature; use codex_features::Features; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::PermissionProfile; +use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::TruncationPolicyConfig; use pretty_assertions::assert_eq; -use serde_json::json; -use std::path::PathBuf; -fn model_info() -> ModelInfo { - serde_json::from_value(json!({ - "slug": "test-model", - "display_name": "Test Model", - "description": null, - "supported_reasoning_levels": [], - "shell_type": "unified_exec", - "visibility": "list", - "supported_in_api": true, - "priority": 1, - "availability_nux": null, - "upgrade": null, - "base_instructions": "base", - "model_messages": null, - "supports_reasoning_summaries": false, - "default_reasoning_summary": "auto", - "support_verbosity": false, - "default_verbosity": null, - "apply_patch_tool_type": null, - "truncation_policy": { - "mode": "bytes", - "limit": 10000 - }, - "supports_parallel_tool_calls": false, - "supports_image_detail_original": false, - "context_window": null, - "auto_compact_token_limit": null, - "effective_context_window_percent": 95, - "experimental_supported_tools": [], - "input_modalities": ["text", "image"], - "supports_search_tool": false - })) - .expect("deserialize test model") +use super::*; + +fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { + ModelInfo { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + description: None, + default_reasoning_level: None, + supported_reasoning_levels: Vec::new(), + shell_type, + visibility: ModelVisibility::List, + supported_in_api: true, + priority: 0, + additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), + availability_nux: None, + upgrade: None, + base_instructions: String::new(), + model_messages: None, + supports_reasoning_summaries: false, + default_reasoning_summary: Default::default(), + support_verbosity: false, + default_verbosity: None, + apply_patch_tool_type: None, + web_search_tool_type: Default::default(), + truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 1024), + supports_parallel_tool_calls: true, + supports_image_detail_original: false, + context_window: None, + max_context_window: None, + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + input_modalities: codex_protocol::openai_models::default_input_modalities(), + used_fallback_model_metadata: false, + supports_search_tool: false, + } } -#[test] -fn model_provided_unified_exec_requires_feature_flag() { - let model_info = model_info(); +fn shell_features() -> Features { let mut features = Features::with_defaults(); + features.enable(Feature::ShellTool); + features.disable(Feature::ShellZshFork); features.disable(Feature::UnifiedExec); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); + features } #[test] -fn unified_exec_can_be_enabled_for_restricted_token_workspace_write() { - let model_info = model_info(); - let mut features = Features::with_defaults(); +fn shell_type_is_derived_from_model_and_feature_gates() { + let model = model_with_shell_type(ConfigShellToolType::UnifiedExec); + let mut features = shell_features(); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + ConfigShellToolType::ShellCommand + ); + features.enable(Feature::UnifiedExec); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::workspace_write(), - windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, - }); - - let expected_shell_type = if codex_utils_pty::conpty_supported() { + let expected_unified_exec = if codex_utils_pty::conpty_supported() { ConfigShellToolType::UnifiedExec } else { ConfigShellToolType::ShellCommand }; - assert_eq!(tools_config.shell_type, expected_shell_type); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + expected_unified_exec + ); + + features.enable(Feature::ShellZshFork); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + ConfigShellToolType::ShellCommand + ); + + features.disable(Feature::ShellTool); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + ConfigShellToolType::Disabled + ); } #[test] -fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - features.enable(Feature::ShellZshFork); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); +fn shell_command_backend_requires_both_shell_tool_and_zsh_fork() { + let mut features = shell_features(); assert_eq!( - tools_config.shell_command_backend, + shell_command_backend_for_features(&features), + ShellCommandBackendConfig::Classic + ); + + features.enable(Feature::ShellZshFork); + assert_eq!( + shell_command_backend_for_features(&features), ShellCommandBackendConfig::ZshFork ); + + features.disable(Feature::ShellTool); assert_eq!( - tools_config.unified_exec_shell_mode, + shell_command_backend_for_features(&features), + ShellCommandBackendConfig::Classic + ); +} + +#[test] +fn request_user_input_modes_follow_default_mode_feature() { + let mut features = Features::with_defaults(); + features.disable(Feature::DefaultModeRequestUserInput); + assert_eq!( + request_user_input_available_modes(&features), + vec![ModeKind::Plan] + ); + + features.enable(Feature::DefaultModeRequestUserInput); + assert_eq!( + request_user_input_available_modes(&features), + vec![ModeKind::Default, ModeKind::Plan] + ); +} + +#[test] +fn unified_exec_shell_mode_uses_zsh_fork_only_when_all_inputs_match() { + let exe = std::env::current_exe().expect("current exe path"); + let shell = exe.clone(); + + let mode = UnifiedExecShellMode::for_session( + ShellCommandBackendConfig::ZshFork, + ToolUserShellType::Zsh, + Some(&shell), + Some(&exe), + ); + if cfg!(unix) { + assert!(matches!(mode, UnifiedExecShellMode::ZshFork(_))); + } else { + assert_eq!(mode, UnifiedExecShellMode::Direct); + } + + assert_eq!( + UnifiedExecShellMode::for_session( + ShellCommandBackendConfig::Classic, + ToolUserShellType::Zsh, + Some(&shell), + Some(&exe), + ), UnifiedExecShellMode::Direct ); - assert_eq!( - tools_config - .with_unified_exec_shell_mode_for_session( - ToolUserShellType::Zsh, - Some(&PathBuf::from(if cfg!(windows) { - r"C:\opt\codex\zsh" - } else { - "/opt/codex/zsh" - })), - Some(&PathBuf::from(if cfg!(windows) { - r"C:\opt\codex\codex-execve-wrapper" - } else { - "/opt/codex/codex-execve-wrapper" - })), - ) - .unified_exec_shell_mode, - if cfg!(unix) { - UnifiedExecShellMode::ZshFork(ZshForkConfig { - shell_zsh_path: AbsolutePathBuf::from_absolute_path("/opt/codex/zsh").unwrap(), - main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path( - "/opt/codex/codex-execve-wrapper", - ) - .unwrap(), - }) - } else { - UnifiedExecShellMode::Direct - } - ); -} - -#[test] -fn fallback_apply_patch_models_do_not_use_freeform_tool_by_default() { - let model_info = model_info(); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - assert_eq!(tools_config.apply_patch_tool_type, None); -} - -#[test] -fn subagents_keep_request_user_input_config_and_agent_jobs_workers_opt_in_by_label() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::DefaultModeRequestUserInput); - features.enable(Feature::SpawnCsv); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::SubAgent(SubAgentSource::Other( - "agent_job:test".to_string(), - )), - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - assert_eq!( - tools_config.request_user_input_available_modes, - request_user_input_available_modes(&features) - ); - assert!(tools_config.agent_jobs_tools); - assert!(tools_config.agent_jobs_worker_tools); -} - -#[test] -fn image_generation_requires_feature_and_supported_model() { - let supported_model_info = model_info(); - let mut unsupported_model_info = supported_model_info.clone(); - unsupported_model_info.input_modalities = vec![InputModality::Text]; - - let mut image_generation_disabled_features = Features::with_defaults(); - image_generation_disabled_features.disable(Feature::ImageGeneration); - let mut image_generation_features = Features::with_defaults(); - image_generation_features.enable(Feature::ImageGeneration); - - let available_models = Vec::new(); - let default_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_disabled_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let auth_disallowed_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: false, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let unsupported_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &unsupported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - assert!(!default_tools_config.image_gen_tool); - assert!(supported_tools_config.image_gen_tool); - assert!(!auth_disallowed_tools_config.image_gen_tool); - assert!(!unsupported_tools_config.image_gen_tool); -} - -#[test] -fn provider_capability_methods_disable_provider_bound_tool_surfaces() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.search_tool = true; - tools_config.tool_suggest = true; - tools_config.image_gen_tool = true; - tools_config.namespace_tools = true; - - let tools_config = tools_config - .with_namespace_tools_capability(/*namespace_tools*/ false) - .with_image_generation_capability(/*image_generation*/ false) - .with_web_search_capability(/*web_search*/ false); - - assert!(tools_config.search_tool); - assert!(tools_config.tool_suggest); - assert!(!tools_config.image_gen_tool); - assert!(!tools_config.namespace_tools); - assert_eq!(tools_config.web_search_mode, None); }