use crate::client_common::tools::FreeformTool; use crate::config::test_config; use crate::models_manager::manager::ModelsManager; use crate::models_manager::model_info::with_config_overrides; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::ToolRouter; use crate::tools::registry::ConfiguredToolSpec; use crate::tools::router::ToolRouterParams; use codex_app_server_protocol::AppInfo; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::path::PathBuf; use super::*; 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 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 windows_shell_safety_description() -> String { format!("\n\n{}", super::windows_destructive_filesystem_guidance()) } fn search_capable_model_info() -> ModelInfo { let config = test_config(); let mut model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); model_info.supports_search_tool = true; model_info } #[test] fn mcp_tool_to_openai_tool_inserts_empty_properties() { let mut schema = rmcp::model::JsonObject::new(); schema.insert("type".to_string(), serde_json::json!("object")); let tool = rmcp::model::Tool { name: "no_props".to_string().into(), title: None, description: Some("No properties".to_string().into()), input_schema: std::sync::Arc::new(schema), output_schema: None, annotations: None, execution: None, icons: None, meta: None, }; let openai_tool = mcp_tool_to_openai_tool("server/no_props".to_string(), tool).expect("convert tool"); let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize schema"); assert_eq!(parameters.get("properties"), Some(&serde_json::json!({}))); } #[test] fn mcp_tool_to_openai_tool_preserves_top_level_output_schema() { let mut input_schema = rmcp::model::JsonObject::new(); input_schema.insert("type".to_string(), serde_json::json!("object")); let mut output_schema = rmcp::model::JsonObject::new(); output_schema.insert( "properties".to_string(), serde_json::json!({ "result": { "properties": { "nested": {} } } }), ); output_schema.insert("required".to_string(), serde_json::json!(["result"])); let tool = rmcp::model::Tool { name: "with_output".to_string().into(), title: None, description: Some("Has output schema".to_string().into()), input_schema: std::sync::Arc::new(input_schema), output_schema: Some(std::sync::Arc::new(output_schema)), annotations: None, execution: None, icons: None, meta: None, }; let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_output".to_string(), tool) .expect("convert tool"); assert_eq!( openai_tool.output_schema, Some(serde_json::json!({ "type": "object", "properties": { "content": { "type": "array", "items": {} }, "structuredContent": { "properties": { "result": { "properties": { "nested": {} } } }, "required": ["result"] }, "isError": { "type": "boolean" }, "_meta": {} }, "required": ["content"], "additionalProperties": false })) ); } #[test] fn mcp_tool_to_openai_tool_preserves_output_schema_without_inferred_type() { let mut input_schema = rmcp::model::JsonObject::new(); input_schema.insert("type".to_string(), serde_json::json!("object")); let mut output_schema = rmcp::model::JsonObject::new(); output_schema.insert("enum".to_string(), serde_json::json!(["ok", "error"])); let tool = rmcp::model::Tool { name: "with_enum_output".to_string().into(), title: None, description: Some("Has enum output schema".to_string().into()), input_schema: std::sync::Arc::new(input_schema), output_schema: Some(std::sync::Arc::new(output_schema)), annotations: None, execution: None, icons: None, meta: None, }; let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_enum_output".to_string(), tool) .expect("convert tool"); assert_eq!( openai_tool.output_schema, Some(serde_json::json!({ "type": "object", "properties": { "content": { "type": "array", "items": {} }, "structuredContent": { "enum": ["ok", "error"] }, "isError": { "type": "boolean" }, "_meta": {} }, "required": ["content"], "additionalProperties": false })) ); } #[test] fn search_tool_deferred_tools_always_set_defer_loading_true() { 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 openai_tool = mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) .expect("convert deferred tool"); assert_eq!(openai_tool.defer_loading, Some(true)); } #[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_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) .expect("convert deferred tool"), )) .expect("serialize deferred tool"); assert_eq!( serialized, serde_json::json!({ "type": "function", "name": "mcp__codex_apps__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, } }) ); } fn tool_name(tool: &ToolSpec) -> &str { match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, ToolSpec::ToolSearch { .. } => "tool_search", ToolSpec::LocalShell {} => "local_shell", ToolSpec::ImageGeneration { .. } => "image_generation", ToolSpec::WebSearch { .. } => "web_search", ToolSpec::Freeform(FreeformTool { name, .. }) => name, } } // Avoid order-based assertions; compare via set containment instead. fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&str]) { use std::collections::HashSet; let mut names = HashSet::new(); let mut duplicates = Vec::new(); for name in tools.iter().map(|t| tool_name(&t.spec)) { 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: &[ConfiguredToolSpec], expected_absent: &str) { let names = tools .iter() .map(|tool| tool_name(&tool.spec)) .collect::>(); assert!( !names.contains(&expected_absent), "expected tool {expected_absent} to be absent; had: {names:?}" ); } fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> { match config.shell_type { ConfigShellToolType::Default => Some("shell"), ConfigShellToolType::Local => Some("local_shell"), ConfigShellToolType::UnifiedExec => None, ConfigShellToolType::Disabled => None, ConfigShellToolType::ShellCommand => Some("shell_command"), } } fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a ConfiguredToolSpec { tools .iter() .find(|tool| tool_name(&tool.spec) == expected_name) .unwrap_or_else(|| panic!("expected tool {expected_name}")) } fn strip_descriptions_schema(schema: &mut JsonSchema) { match schema { JsonSchema::Boolean { description } | JsonSchema::String { description } | JsonSchema::Number { description } => { *description = None; } JsonSchema::Array { items, description } => { strip_descriptions_schema(items); *description = None; } JsonSchema::Object { properties, required: _, additional_properties, } => { for v in properties.values_mut() { strip_descriptions_schema(v); } if let Some(AdditionalProperties::Schema(s)) = additional_properties { strip_descriptions_schema(s); } } } } 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::Freeform(_) | ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } => {} } } fn model_info_from_models_json(slug: &str) -> ModelInfo { let config = test_config(); let response: ModelsResponse = serde_json::from_str(include_str!("../../models.json")).expect("valid models.json"); 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) } #[test] fn unified_exec_is_blocked_for_windows_sandboxed_policies_only() { assert!(!unified_exec_allowed_in_environment( true, &SandboxPolicy::new_read_only_policy(), WindowsSandboxLevel::RestrictedToken, )); assert!(!unified_exec_allowed_in_environment( true, &SandboxPolicy::new_workspace_write_policy(), WindowsSandboxLevel::RestrictedToken, )); assert!(unified_exec_allowed_in_environment( true, &SandboxPolicy::DangerFullAccess, WindowsSandboxLevel::RestrictedToken, )); assert!(unified_exec_allowed_in_environment( true, &SandboxPolicy::DangerFullAccess, WindowsSandboxLevel::Disabled, )); } #[test] fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() { let mut model_info = model_info_from_models_json("gpt-5-codex"); model_info.shell_type = ConfigShellToolType::UnifiedExec; let features = Features::with_defaults(); let available_models = Vec::new(); let config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::new_workspace_write_policy(), windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, }); let expected_shell_type = if cfg!(target_os = "windows") { ConfigShellToolType::ShellCommand } else { ConfigShellToolType::UnifiedExec }; assert_eq!(config.shell_type, expected_shell_type); } #[test] fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { let model_info = model_info_from_models_json("gpt-5-codex"); 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, web_search_mode: Some(WebSearchMode::Live), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&config, None, None, &[]).build(); // Build actual map name -> spec use std::collections::BTreeMap; use std::collections::HashSet; let mut actual: BTreeMap = BTreeMap::from([]); let mut duplicate_names = Vec::new(); for t in &tools { let name = tool_name(&t.spec).to_string(); if actual.insert(name.clone(), t.spec.clone()).is_some() { duplicate_names.push(name); } } assert!( duplicate_names.is_empty(), "duplicate tool entries detected: {duplicate_names:?}" ); // Build expected from the same helpers used by the builder. let mut expected: BTreeMap = BTreeMap::from([]); for spec in [ create_exec_command_tool(true, false), create_write_stdin_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(CollaborationModesConfig::default()), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { external_web_access: Some(true), filters: None, user_location: None, search_context_size: None, search_content_types: None, }, create_view_image_tool(config.can_request_original_image_detail), ] { expected.insert(tool_name(&spec).to_string(), spec); } let collab_specs = if config.multi_agent_v2 { vec![ create_spawn_agent_tool_v2(&config), create_send_message_tool(), create_wait_agent_tool_v2(), create_close_agent_tool_v2(), ] } else { vec![ create_spawn_agent_tool_v1(&config), create_send_input_tool_v1(), create_wait_agent_tool_v1(), create_close_agent_tool_v1(), ] }; for spec in collab_specs { expected.insert(tool_name(&spec).to_string(), spec); } if !config.multi_agent_v2 { let spec = create_resume_agent_tool(); expected.insert(tool_name(&spec).to_string(), spec); } if config.exec_permission_approvals_enabled { let spec = create_request_permissions_tool(); expected.insert(tool_name(&spec).to_string(), spec); } // Exact name set match — this is the only test allowed to fail when tools change. let actual_names: HashSet<_> = actual.keys().cloned().collect(); let expected_names: HashSet<_> = expected.keys().cloned().collect(); assert_eq!(actual_names, expected_names, "tool name set mismatch"); // Compare specs ignoring human-readable descriptions. for name in expected.keys() { let mut a = actual.get(name).expect("present").clone(); let mut e = expected.get(name).expect("present").clone(); strip_descriptions_tool(&mut a); strip_descriptions_tool(&mut e); assert_eq!(a, e, "spec mismatch for {name}"); } } #[test] fn test_build_specs_collab_tools_enabled() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); 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"); } #[test] fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert_contains_tool_names( &tools, &[ "spawn_agent", "send_message", "assign_task", "wait_agent", "close_agent", "list_agents", ], ); let spawn_agent = find_tool(&tools, "spawn_agent"); let ToolSpec::Function(ResponsesApiTool { parameters, output_schema, .. }) = &spawn_agent.spec else { panic!("spawn_agent should be a function tool"); }; let JsonSchema::Object { properties, required, .. } = parameters else { panic!("spawn_agent should use object params"); }; assert!(properties.contains_key("task_name")); assert_eq!(required.as_ref(), None); let output_schema = output_schema .as_ref() .expect("spawn_agent should define output schema"); assert_eq!( output_schema["required"], json!(["agent_id", "task_name", "nickname"]) ); let send_message = find_tool(&tools, "send_message"); let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_message.spec else { panic!("send_message should be a function tool"); }; let JsonSchema::Object { properties, required, .. } = parameters else { panic!("send_message should use object params"); }; assert!(properties.contains_key("target")); assert!(!properties.contains_key("message")); assert_eq!( required.as_ref(), Some(&vec!["target".to_string(), "items".to_string()]) ); let assign_task = find_tool(&tools, "assign_task"); let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &assign_task.spec else { panic!("assign_task should be a function tool"); }; let JsonSchema::Object { properties, required, .. } = parameters else { panic!("assign_task should use object params"); }; assert!(properties.contains_key("target")); assert!(!properties.contains_key("message")); assert_eq!( required.as_ref(), Some(&vec!["target".to_string(), "items".to_string()]) ); let wait_agent = find_tool(&tools, "wait_agent"); let ToolSpec::Function(ResponsesApiTool { parameters, output_schema, .. }) = &wait_agent.spec else { panic!("wait_agent should be a function tool"); }; let JsonSchema::Object { properties, required, .. } = parameters else { panic!("wait_agent should use object params"); }; assert!(properties.contains_key("targets")); assert_eq!(required.as_ref(), Some(&vec!["targets".to_string()])); 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.spec else { panic!("list_agents should be a function tool"); }; let JsonSchema::Object { properties, required, .. } = parameters else { panic!("list_agents should use object params"); }; assert!(properties.contains_key("path_prefix")); assert_eq!(required.as_ref(), 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"); } #[test] fn test_build_specs_enable_fanout_enables_agent_jobs_and_collab_tools() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); 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_feature() { let config = test_config(); let mut model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else { panic!("view_image should be a function tool"); }; let JsonSchema::Object { properties, .. } = parameters else { panic!("view_image should use an object schema"); }; assert!(!properties.contains_key("detail")); } #[test] fn view_image_tool_includes_detail_with_original_detail_feature() { let config = test_config(); let mut model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); model_info.supports_image_detail_original = true; let mut features = Features::with_defaults(); features.enable(Feature::ImageDetailOriginal); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else { panic!("view_image should be a function tool"); }; let JsonSchema::Object { properties, .. } = parameters else { panic!("view_image should use an object schema"); }; assert!(properties.contains_key("detail")); let Some(JsonSchema::String { description: Some(description), }) = properties.get("detail") else { panic!("view_image detail should include a description"); }; assert!(description.contains("only supported value is `original`")); assert!(description.contains("omit this field for default resized behavior")); } #[test] fn test_build_specs_agent_job_worker_tools_enabled() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::SubAgent(SubAgentSource::Other( "agent_job:test".to_string(), )), sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert_contains_tool_names( &tools, &[ "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", "spawn_agents_on_csv", "report_agent_job_result", ], ); assert_lacks_tool_name(&tools, "request_user_input"); } #[test] fn request_user_input_description_reflects_default_mode_feature_flag() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let request_user_input_tool = find_tool(&tools, "request_user_input"); assert_eq!( request_user_input_tool.spec, create_request_user_input_tool(CollaborationModesConfig::default()) ); features.enable(Feature::DefaultModeRequestUserInput); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let request_user_input_tool = find_tool(&tools, "request_user_input"); assert_eq!( request_user_input_tool.spec, create_request_user_input_tool(CollaborationModesConfig { default_mode_request_user_input: true, }) ); } #[test] fn request_permissions_requires_feature_flag() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert_lacks_tool_name(&tools, "request_permissions"); let mut features = Features::with_defaults(); features.enable(Feature::RequestPermissionsTool); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let request_permissions_tool = find_tool(&tools, "request_permissions"); assert_eq!( request_permissions_tool.spec, create_request_permissions_tool() ); } #[test] fn request_permissions_tool_is_independent_from_additional_permissions() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert_lacks_tool_name(&tools, "request_permissions"); } #[test] fn get_memory_requires_feature_flag() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert!( !tools.iter().any(|t| t.spec.name() == "get_memory"), "get_memory should be disabled when memory_tool feature is off" ); } #[test] fn js_repl_requires_feature_flag() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert!( !tools.iter().any(|tool| tool.spec.name() == "js_repl"), "js_repl should be disabled when the feature is off" ); assert!( !tools.iter().any(|tool| tool.spec.name() == "js_repl_reset"), "js_repl_reset should be disabled when the feature is off" ); } #[test] fn js_repl_enabled_adds_tools() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::JsRepl); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]); } #[test] fn image_generation_tools_require_feature_and_supported_model() { let config = test_config(); let mut supported_model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5.2", &config); supported_model_info.slug = "custom/gpt-5.2-variant".to_string(); let mut unsupported_model_info = supported_model_info.clone(); unsupported_model_info.input_modalities = vec![InputModality::Text]; let default_features = Features::with_defaults(); let mut image_generation_features = default_features.clone(); 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: &default_features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (default_tools, _) = build_specs(&default_tools_config, None, None, &[]).build(); assert!( !default_tools .iter() .any(|tool| tool.spec.name() == "image_generation"), "image_generation should be disabled by default" ); let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &supported_model_info, available_models: &available_models, features: &image_generation_features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (supported_tools, _) = build_specs(&supported_tools_config, None, None, &[]).build(); 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.spec).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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert!( !tools .iter() .any(|tool| tool.spec.name() == "image_generation"), "image_generation should be disabled for unsupported models" ); } #[test] fn js_repl_freeform_grammar_blocks_common_non_js_prefixes() { let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else { panic!("js_repl should use a freeform tool spec"); }; assert_eq!(format.syntax, "lark"); assert!(format.definition.contains("PRAGMA_LINE")); assert!(format.definition.contains("`[^`]")); assert!(format.definition.contains("``[^`]")); assert!(format.definition.contains("PLAIN_JS_SOURCE")); assert!(format.definition.contains("codex-js-repl:")); assert!(!format.definition.contains("(?!")); } fn assert_model_tools( model_slug: &str, features: &Features, web_search_mode: Option, expected_tools: &[&str], ) { let _config = test_config(); let model_info = model_info_from_models_json(model_slug); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features, web_search_mode, session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let router = ToolRouter::from_config( &tools_config, ToolRouterParams { mcp_tools: None, app_tools: None, discoverable_tools: None, 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,); } 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); } #[test] fn web_search_mode_cached_sets_external_web_access_false() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let tool = find_tool(&tools, "web_search"); assert_eq!( tool.spec, 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 config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Live), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let tool = find_tool(&tools, "web_search"); assert_eq!( tool.spec, 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 config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Live), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }) .with_web_search_config(Some(web_search_config.clone())); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let tool = find_tool(&tools, "web_search"); assert_eq!( tool.spec, ToolSpec::WebSearch { external_web_access: Some(true), filters: web_search_config .filters .map(crate::client_common::tools::ResponsesApiWebSearchFilters::from), user_location: web_search_config .user_location .map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from), search_context_size: web_search_config.search_context_size, search_content_types: None, } ); } #[test] fn web_search_tool_type_text_and_image_sets_search_content_types() { let config = test_config(); let mut model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); model_info.web_search_tool_type = WebSearchToolType::TextAndImage; 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, web_search_mode: Some(WebSearchMode::Live), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let tool = find_tool(&tools, "web_search"); assert_eq!( tool.spec, ToolSpec::WebSearch { external_web_access: Some(true), filters: None, user_location: None, search_context_size: None, search_content_types: Some( WEB_SEARCH_CONTENT_TYPES .into_iter() .map(str::to_string) .collect() ), } ); } #[test] fn mcp_resource_tools_are_hidden_without_mcp_servers() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert!( !tools.iter().any(|tool| matches!( tool.spec.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 config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); assert_contains_tool_names( &tools, &[ "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", ], ); } #[test] fn test_build_specs_gpt5_codex_default() { let features = Features::with_defaults(); assert_default_model_tools( "gpt-5-codex", &features, Some(WebSearchMode::Cached), "shell_command", &[ "update_plan", "request_user_input", "apply_patch", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] fn test_build_specs_gpt51_codex_default() { let features = Features::with_defaults(); assert_default_model_tools( "gpt-5.1-codex", &features, Some(WebSearchMode::Cached), "shell_command", &[ "update_plan", "request_user_input", "apply_patch", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] 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-codex", &features, Some(WebSearchMode::Live), &[ "exec_command", "write_stdin", "update_plan", "request_user_input", "apply_patch", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] 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.1-codex", &features, Some(WebSearchMode::Live), &[ "exec_command", "write_stdin", "update_plan", "request_user_input", "apply_patch", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] fn test_gpt_5_1_codex_max_defaults() { let features = Features::with_defaults(); assert_default_model_tools( "gpt-5.1-codex-max", &features, Some(WebSearchMode::Cached), "shell_command", &[ "update_plan", "request_user_input", "apply_patch", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] fn test_codex_5_1_mini_defaults() { let features = Features::with_defaults(); assert_default_model_tools( "gpt-5.1-codex-mini", &features, Some(WebSearchMode::Cached), "shell_command", &[ "update_plan", "request_user_input", "apply_patch", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] fn test_gpt_5_defaults() { let features = Features::with_defaults(); assert_default_model_tools( "gpt-5", &features, Some(WebSearchMode::Cached), "shell", &[ "update_plan", "request_user_input", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] fn test_gpt_5_1_defaults() { let features = Features::with_defaults(); assert_default_model_tools( "gpt-5.1", &features, Some(WebSearchMode::Cached), "shell_command", &[ "update_plan", "request_user_input", "apply_patch", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] 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.1-codex-max", &features, Some(WebSearchMode::Live), &[ "exec_command", "write_stdin", "update_plan", "request_user_input", "apply_patch", "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", ], ); } #[test] fn test_build_specs_default_shell_present() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("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, web_search_mode: Some(WebSearchMode::Live), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build(); // 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); } #[test] fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("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, web_search_mode: Some(WebSearchMode::Live), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, 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( &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 } ); } #[test] #[ignore] fn test_parallel_support_flags() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert!(find_tool(&tools, "exec_command").supports_parallel_tool_calls); assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls); } #[test] fn test_test_model_info_includes_sync_tool() { let _config = test_config(); let mut model_info = model_info_from_models_json("gpt-5-codex"); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); assert!( tools .iter() .any(|tool| tool_name(&tool.spec) == "test_sync_tool") ); } #[test] fn test_build_specs_mcp_tools_converted() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("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, web_search_mode: Some(WebSearchMode::Live), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, Some(HashMap::from([( "test_server/do_something_cool".to_string(), 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, }, }, }), ), )])), None, &[], ) .build(); let tool = find_tool(&tools, "test_server/do_something_cool"); assert_eq!( &tool.spec, &ToolSpec::Function(ResponsesApiTool { name: "test_server/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 { properties: BTreeMap::from([ ( "string_property".to_string(), JsonSchema::String { description: None } ), ( "number_property".to_string(), JsonSchema::Number { description: None } ), ]), required: Some(vec![ "string_property".to_string(), "number_property".to_string(), ]), additional_properties: 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 test_build_specs_mcp_tools_sorted_by_name() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); // Intentionally construct a map with keys that would sort alphabetically. let tools_map: HashMap = HashMap::from([ ( "test_server/do".to_string(), mcp_tool("a", "a", serde_json::json!({"type": "object"})), ), ( "test_server/something".to_string(), mcp_tool("b", "b", serde_json::json!({"type": "object"})), ), ( "test_server/cool".to_string(), mcp_tool("c", "c", serde_json::json!({"type": "object"})), ), ]); let (tools, _) = build_specs(&tools_config, Some(tools_map), None, &[]).build(); // Only assert that the MCP tools themselves are sorted by fully-qualified name. let mcp_names: Vec<_> = tools .iter() .map(|t| tool_name(&t.spec).to_string()) .filter(|n| n.starts_with("test_server/")) .collect(); let expected = vec![ "test_server/cool".to_string(), "test_server/do".to_string(), "test_server/something".to_string(), ]; assert_eq!(mcp_names, expected); } #[test] fn search_tool_description_lists_each_codex_apps_connector_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, Some(HashMap::from([ ( "mcp__codex_apps__calendar_create_event".to_string(), mcp_tool( "calendar_create_event", "Create calendar event", serde_json::json!({"type": "object"}), ), ), ( "mcp__rmcp__echo".to_string(), mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), ), ])), Some(HashMap::from([ ( "mcp__codex_apps__calendar_create_event".to_string(), ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: "_create_event".to_string(), tool_namespace: "mcp__codex_apps__calendar".to_string(), 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(), connector_description: Some( "Plan events and manage your calendar.".to_string(), ), }, ), ( "mcp__codex_apps__calendar_list_events".to_string(), ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: "_list_events".to_string(), tool_namespace: "mcp__codex_apps__calendar".to_string(), tool: mcp_tool( "calendar-list-events", "List calendar events", serde_json::json!({"type": "object"}), ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), plugin_display_names: Vec::new(), connector_description: Some( "Plan events and manage your calendar.".to_string(), ), }, ), ( "mcp__codex_apps__gmail_search_threads".to_string(), ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: "_search_threads".to_string(), tool_namespace: "mcp__codex_apps__gmail".to_string(), tool: mcp_tool( "gmail-search-threads", "Search email threads", serde_json::json!({"type": "object"}), ), connector_id: Some("gmail".to_string()), connector_name: Some("Gmail".to_string()), plugin_display_names: Vec::new(), connector_description: Some("Find and summarize email threads.".to_string()), }, ), ( "mcp__rmcp__echo".to_string(), ToolInfo { server_name: "rmcp".to_string(), tool_name: "echo".to_string(), tool_namespace: "rmcp".to_string(), tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), connector_description: None, }, ), ])), &[], ) .build(); let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); let ToolSpec::ToolSearch { description, .. } = &search_tool.spec 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("mcp__rmcp__echo")); } #[test] fn search_tool_requires_model_capability_and_feature_flag() { let model_info = search_capable_model_info(); let app_tools = Some(HashMap::from([( "mcp__codex_apps__calendar_create_event".to_string(), ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: "calendar_create_event".to_string(), tool_namespace: "mcp__codex_apps__calendar".to_string(), 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()), connector_description: None, plugin_display_names: Vec::new(), }, )])); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); let mut features = Features::with_defaults(); features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, app_tools, &[]).build(); assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); } #[test] fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::ToolSearch); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs_with_discoverable_tools( &tools_config, None, None, Some(vec![discoverable_connector( "connector_2128aebfecb84f64a069897515042a44", "Google Calendar", "Plan events and schedules.", )]), &[], ) .build(); assert!( !tools .iter() .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME) ); } #[test] fn tool_suggest_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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs_with_discoverable_tools( &tools_config, None, None, Some(vec![discoverable_connector( "connector_2128aebfecb84f64a069897515042a44", "Google Calendar", "Plan events and schedules.", )]), &[], ) .build(); assert_contains_tool_names(&tools, &[TOOL_SUGGEST_TOOL_NAME]); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME); let ToolSpec::Function(ResponsesApiTool { description, .. }) = &tool_suggest.spec else { panic!("expected function tool"); }; assert!(description.contains( "Suggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin" )); assert!(description.contains( "You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means." )); } #[test] fn tool_suggest_requires_apps_and_plugins_features() { let model_info = search_capable_model_info(); let discoverable_tools = Some(vec![discoverable_connector( "connector_2128aebfecb84f64a069897515042a44", "Google Calendar", "Plan events and schedules.", )]); let available_models = Vec::new(); for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); features.enable(Feature::ToolSearch); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs_with_discoverable_tools( &tools_config, None, None, discoverable_tools.clone(), &[], ) .build(); assert!( !tools .iter() .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME), "tool_suggest should be absent when {disabled_feature:?} is disabled" ); } } #[test] fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build(); let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else { panic!("expected tool_search tool"); }; assert!(description.contains("None currently enabled.")); assert!(!description.contains("{{app_descriptions}}")); } #[test] fn search_tool_description_falls_back_to_connector_name_without_description() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, None, Some(HashMap::from([( "mcp__codex_apps__calendar_create_event".to_string(), ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: "_create_event".to_string(), tool_namespace: "mcp__codex_apps__calendar".to_string(), 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(), connector_description: None, }, )])), &[], ) .build(); let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else { panic!("expected tool_search tool"); }; assert!(description.contains("- Calendar")); assert!(!description.contains("- Calendar:")); } #[test] fn search_tool_registers_namespaced_app_tool_aliases() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (_, registry) = build_specs( &tools_config, None, Some(HashMap::from([ ( "mcp__codex_apps__calendar_create_event".to_string(), ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: "_create_event".to_string(), tool_namespace: "mcp__codex_apps__calendar".to_string(), 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()), connector_description: None, plugin_display_names: Vec::new(), }, ), ( "mcp__codex_apps__calendar_list_events".to_string(), ToolInfo { server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(), tool_name: "_list_events".to_string(), tool_namespace: "mcp__codex_apps__calendar".to_string(), tool: mcp_tool( "calendar-list-events", "List calendar events", serde_json::json!({"type": "object"}), ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: None, plugin_display_names: Vec::new(), }, ), ])), &[], ) .build(); let alias = tool_handler_key("_create_event", Some("mcp__codex_apps__calendar")); assert!(registry.has_handler(TOOL_SEARCH_TOOL_NAME, None)); assert!(registry.has_handler(alias.as_str(), None)); } #[test] fn tool_suggest_description_lists_discoverable_tools() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::Plugins); features.enable(Feature::ToolSearch); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, 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, _) = build_specs_with_discoverable_tools( &tools_config, None, None, Some(discoverable_tools), &[], ) .build(); let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME); let ToolSpec::Function(ResponsesApiTool { description, parameters, .. }) = &tool_suggest.spec else { panic!("expected function tool"); }; assert!(description.contains( "Suggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin" )); 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` or `enable`")); assert!( description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample") ); assert!( description.contains( "You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means." ) ); assert!(description.contains( "For connectors/apps that are not installed but needed for an installed plugin, suggest to install them if the task requirements match precisely." )); assert!(description.contains( "For plugins that are not installed but discoverable, only suggest discoverable and installable plugins when the user's intent very explicitly and unambiguously matches that plugin itself." )); assert!(description.contains( "Do not suggest a plugin just because one of its connectors or capabilities seems relevant." )); assert!(description.contains( "Apply the stricter explicit-and-unambiguous rule for *discoverable tools* like plugin install suggestions; *missing tools* like connector install suggestions continue to use the normal clear-fit standard." )); assert!(description.contains("DO NOT explore or recommend tools that are not on this list.")); assert!(!description.contains("tool_search fails to find a good match")); let JsonSchema::Object { required, .. } = parameters else { panic!("expected object parameters"); }; assert_eq!( required.as_ref(), Some(&vec![ "tool_type".to_string(), "action_type".to_string(), "tool_id".to_string(), "suggest_reason".to_string(), ]) ); } #[test] fn test_mcp_tool_property_missing_type_defaults_to_string() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, Some(HashMap::from([( "dash/search".to_string(), mcp_tool( "search", "Search docs", serde_json::json!({ "type": "object", "properties": { "query": {"description": "search query"} } }), ), )])), None, &[], ) .build(); let tool = find_tool(&tools, "dash/search"); assert_eq!( tool.spec, ToolSpec::Function(ResponsesApiTool { name: "dash/search".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([( "query".to_string(), JsonSchema::String { description: Some("search query".to_string()) } )]), 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, }) ); } #[test] fn test_mcp_tool_integer_normalized_to_number() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, Some(HashMap::from([( "dash/paginate".to_string(), mcp_tool( "paginate", "Pagination", serde_json::json!({ "type": "object", "properties": {"page": {"type": "integer"}} }), ), )])), None, &[], ) .build(); let tool = find_tool(&tools, "dash/paginate"); assert_eq!( tool.spec, ToolSpec::Function(ResponsesApiTool { name: "dash/paginate".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([( "page".to_string(), JsonSchema::Number { 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, }) ); } #[test] fn test_mcp_tool_array_without_items_gets_default_string_items() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::ApplyPatchFreeform); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, Some(HashMap::from([( "dash/tags".to_string(), mcp_tool( "tags", "Tags", serde_json::json!({ "type": "object", "properties": {"tags": {"type": "array"}} }), ), )])), None, &[], ) .build(); let tool = find_tool(&tools, "dash/tags"); assert_eq!( tool.spec, ToolSpec::Function(ResponsesApiTool { name: "dash/tags".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([( "tags".to_string(), JsonSchema::Array { items: Box::new(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, }) ); } #[test] fn test_mcp_tool_anyof_defaults_to_string() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, Some(HashMap::from([( "dash/value".to_string(), mcp_tool( "value", "AnyOf Value", serde_json::json!({ "type": "object", "properties": { "value": {"anyOf": [{"type": "string"}, {"type": "number"}]} } }), ), )])), None, &[], ) .build(); let tool = find_tool(&tools, "dash/value"); assert_eq!( tool.spec, ToolSpec::Function(ResponsesApiTool { name: "dash/value".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([( "value".to_string(), JsonSchema::String { 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, }) ); } #[test] fn test_shell_tool() { let tool = super::create_shell_tool(false); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool else { panic!("expected function tool"); }; assert_eq!(name, "shell"); let expected = if cfg!(windows) { r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. Examples of valid command strings: - ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] - recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] - recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] - ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"] - setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] - running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"# .to_string() + &windows_shell_safety_description() } else { r#"Runs a shell command and returns its output. - The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. - Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# .to_string() }; assert_eq!(description, &expected); } #[test] fn test_exec_command_tool_windows_description_includes_shell_safety_guidance() { let tool = super::create_exec_command_tool(true, false); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool else { panic!("expected function tool"); }; assert_eq!(name, "exec_command"); let expected = if cfg!(windows) { format!( "Runs a command in a PTY, returning output or a session ID for ongoing interaction.{}", windows_shell_safety_description() ) } else { "Runs a command in a PTY, returning output or a session ID for ongoing interaction." .to_string() }; assert_eq!(description, &expected); } #[test] fn shell_tool_with_request_permission_includes_additional_permissions() { let tool = super::create_shell_tool(true); let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { panic!("expected function tool"); }; let JsonSchema::Object { properties, .. } = parameters else { panic!("expected object parameters"); }; assert!(properties.contains_key("additional_permissions")); let Some(JsonSchema::String { description: Some(description), }) = properties.get("sandbox_permissions") else { panic!("expected sandbox_permissions description"); }; assert!(description.contains("with_additional_permissions")); assert!(description.contains("filesystem or network permissions")); let Some(JsonSchema::Object { properties: additional_properties, .. }) = properties.get("additional_permissions") else { panic!("expected additional_permissions schema"); }; assert!(additional_properties.contains_key("network")); assert!(additional_properties.contains_key("file_system")); assert!(!additional_properties.contains_key("macos")); } #[test] fn request_permissions_tool_includes_full_permission_schema() { let tool = super::create_request_permissions_tool(); let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { panic!("expected function tool"); }; let JsonSchema::Object { properties, .. } = parameters else { panic!("expected object parameters"); }; let Some(JsonSchema::Object { properties: permission_properties, additional_properties, .. }) = properties.get("permissions") else { panic!("expected permissions object"); }; assert_eq!(additional_properties, &Some(false.into())); assert!(permission_properties.contains_key("network")); assert!(permission_properties.contains_key("file_system")); assert!(!permission_properties.contains_key("macos")); let Some(JsonSchema::Object { properties: network_properties, additional_properties, .. }) = permission_properties.get("network") else { panic!("expected network object"); }; assert_eq!(additional_properties, &Some(false.into())); assert!(network_properties.contains_key("enabled")); let Some(JsonSchema::Object { properties: file_system_properties, additional_properties, .. }) = permission_properties.get("file_system") else { panic!("expected file_system object"); }; assert_eq!(additional_properties, &Some(false.into())); assert!(file_system_properties.contains_key("read")); assert!(file_system_properties.contains_key("write")); } #[test] fn test_shell_command_tool() { let tool = super::create_shell_command_tool(true, false); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool else { panic!("expected function tool"); }; assert_eq!(name, "shell_command"); let expected = if cfg!(windows) { r#"Runs a Powershell command (Windows) and returns its output. Examples of valid command strings: - ls -a (show hidden): "Get-ChildItem -Force" - recursive find by name: "Get-ChildItem -Recurse -Filter *.py" - recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive" - ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }" - setting an env var: "$env:FOO='bar'; echo $env:FOO" - running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""# .to_string() + &windows_shell_safety_description() } else { r#"Runs a shell command and returns its output. - Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#.to_string() }; assert_eq!(description, &expected); } #[test] fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, Some(HashMap::from([( "test_server/do_something_cool".to_string(), 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 } } } }), ), )])), None, &[], ) .build(); let tool = find_tool(&tools, "test_server/do_something_cool"); assert_eq!( tool.spec, ToolSpec::Function(ResponsesApiTool { name: "test_server/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 { properties: BTreeMap::from([ ( "string_property".to_string(), JsonSchema::String { description: None } ), ( "number_property".to_string(), JsonSchema::Number { description: None } ), ]), required: Some(vec![ "string_property".to_string(), "number_property".to_string(), ]), additional_properties: Some( JsonSchema::Object { properties: BTreeMap::from([( "addtl_prop".to_string(), JsonSchema::String { description: None } ),]), required: Some(vec!["addtl_prop".to_string(),]), additional_properties: 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, }) ); } #[test] fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let ToolSpec::Function(ResponsesApiTool { description, .. }) = &find_tool(&tools, "view_image").spec 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: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```" ); } #[test] fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs( &tools_config, Some(HashMap::from([( "mcp__sample__echo".to_string(), mcp_tool( "echo", "Echo text", serde_json::json!({ "type": "object", "properties": { "message": {"type": "string"} }, "required": ["message"], "additionalProperties": false }), ), )])), None, &[], ) .build(); let ToolSpec::Function(ResponsesApiTool { description, .. }) = &find_tool(&tools, "mcp__sample__echo").spec else { panic!("expected function tool"); }; assert_eq!( description, "Echo text\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__sample__echo(args: { message: string; }): Promise<{ _meta?: unknown; content: Array; isError?: boolean; structuredContent?: unknown; }>; };\n```" ); } #[test] 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.1-codex", &features, Some(WebSearchMode::Live), &["exec", "wait"], ); } #[test] fn code_mode_only_exec_description_includes_full_nested_tool_details() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let ToolSpec::Freeform(FreeformTool { description, .. }) = &find_tool(&tools, "exec").spec else { panic!("expected freeform tool"); }; assert!(!description.contains("Enabled nested tools:")); assert!(!description.contains("Nested tool reference:")); 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` (`update_plan`)")); assert!(description.contains("### `view_image` (`view_image`)")); } #[test] fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); 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, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); let ToolSpec::Freeform(FreeformTool { description, .. }) = &find_tool(&tools, "exec").spec 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` (`update_plan`)")); assert!(!description.contains("### `view_image` (`view_image`)")); } #[test] fn chat_tools_include_top_level_name() { let properties = BTreeMap::from([("foo".to_string(), JsonSchema::String { description: None })]); let tools = vec![ToolSpec::Function(ResponsesApiTool { name: "demo".to_string(), description: "A demo tool".to_string(), strict: false, defer_loading: None, parameters: JsonSchema::Object { properties, required: None, additional_properties: None, }, output_schema: None, })]; let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); assert_eq!( responses_json, vec![json!({ "type": "function", "name": "demo", "description": "A demo tool", "strict": false, "parameters": { "type": "object", "properties": { "foo": { "type": "string" } }, }, })] ); }