diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 490f02553..4c852d48f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -377,6 +377,7 @@ pub(crate) const INITIAL_SUBMIT_ID: &str = ""; pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512; const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber"; const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyber-safety"; +const DIRECT_APP_TOOL_EXPOSURE_THRESHOLD: usize = 100; impl Codex { /// Spawn a new [`Codex`] and initialize the session. @@ -6463,8 +6464,6 @@ pub(crate) async fn built_tools( None }; - // Keep the connector-grouped app view around for the router even though - // app tools only become prompt-visible after explicit selection/discovery. let app_tools = connectors.as_ref().map(|connectors| { filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config) }); @@ -6491,6 +6490,21 @@ pub(crate) async fn built_tools( mcp_tools = selected_mcp_tools; } + // Expose app tools directly when tool_search is disabled, or when tool_search + // is enabled but the accessible app tool set stays below the direct-exposure threshold. + let expose_app_tools_directly = !turn_context.tools_config.search_tool + || app_tools + .as_ref() + .is_some_and(|tools| tools.len() < DIRECT_APP_TOOL_EXPOSURE_THRESHOLD); + if expose_app_tools_directly && let Some(app_tools) = app_tools.as_ref() { + mcp_tools.extend(app_tools.clone()); + } + let app_tools = if expose_app_tools_directly { + None + } else { + app_tools + }; + Ok(Arc::new(ToolRouter::from_config( &turn_context.tools_config, ToolRouterParams { diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 8ac60ffb1..450a170b2 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -18,6 +18,7 @@ const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar."; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; +const SEARCHABLE_TOOL_COUNT: usize = 100; pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str = "connector://calendar/tools/calendar_create_event"; const CALENDAR_LIST_EVENTS_RESOURCE_URI: &str = "connector://calendar/tools/calendar_list_events"; @@ -32,6 +33,21 @@ impl AppsTestServer { Self::mount_with_connector_name(server, CONNECTOR_NAME).await } + pub async fn mount_searchable(server: &MockServer) -> Result { + mount_oauth_metadata(server).await; + mount_connectors_directory(server).await; + mount_streamable_http_json_rpc( + server, + CONNECTOR_NAME.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + /*searchable*/ true, + ) + .await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } + pub async fn mount_with_connector_name( server: &MockServer, connector_name: &str, @@ -42,6 +58,7 @@ impl AppsTestServer { server, connector_name.to_string(), CONNECTOR_DESCRIPTION.to_string(), + /*searchable*/ false, ) .await; Ok(Self { @@ -97,12 +114,14 @@ async fn mount_streamable_http_json_rpc( server: &MockServer, connector_name: String, connector_description: String, + searchable: bool, ) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) .respond_with(CodexAppsJsonRpcResponder { connector_name, connector_description, + searchable, }) .mount(server) .await; @@ -111,6 +130,7 @@ async fn mount_streamable_http_json_rpc( struct CodexAppsJsonRpcResponder { connector_name: String, connector_description: String, + searchable: bool, } impl Respond for CodexAppsJsonRpcResponder { @@ -157,7 +177,7 @@ impl Respond for CodexAppsJsonRpcResponder { "notifications/initialized" => ResponseTemplate::new(202), "tools/list" => { let id = body.get("id").cloned().unwrap_or(Value::Null); - ResponseTemplate::new(200).set_body_json(json!({ + let mut response = json!({ "jsonrpc": "2.0", "id": id, "result": { @@ -211,7 +231,32 @@ impl Respond for CodexAppsJsonRpcResponder { ], "nextCursor": null } - })) + }); + if self.searchable + && let Some(tools) = response + .pointer_mut("/result/tools") + .and_then(Value::as_array_mut) + { + for index in 2..SEARCHABLE_TOOL_COUNT { + tools.push(json!({ + "name": format!("calendar_timezone_option_{index}"), + "description": format!("Read timezone option {index}."), + "inputSchema": { + "type": "object", + "properties": { + "timezone": { "type": "string" } + }, + "additionalProperties": false + }, + "_meta": { + "connector_id": CONNECTOR_ID, + "connector_name": self.connector_name.clone(), + "connector_description": self.connector_description.clone() + } + })); + } + } + ResponseTemplate::new(200).set_body_json(response) } "tools/call" => { let id = body.get("id").cloned().unwrap_or(Value::Null); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 485a216b4..118f1bd58 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -116,7 +116,7 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; + let apps_server = AppsTestServer::mount_searchable(&server).await?; let mock = mount_sse_once( &server, sse(vec![ @@ -212,7 +212,7 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; + let apps_server = AppsTestServer::mount_searchable(&server).await?; let mock = mount_sse_once( &server, sse(vec![ @@ -254,7 +254,7 @@ async fn search_tool_hides_apps_tools_without_search() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; + let apps_server = AppsTestServer::mount_searchable(&server).await?; let mock = mount_sse_once( &server, sse(vec![ @@ -329,7 +329,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; + let apps_server = AppsTestServer::mount_searchable(&server).await?; let call_id = "tool-search-1"; let mock = mount_sse_sequence( &server,