From 16d4ea9ca8dd2f6ce586168467b568b2ac6973c1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 27 Mar 2026 14:26:54 -0700 Subject: [PATCH] codex-tools: extract responses API tool models (#16031) ## Why The previous extraction steps moved shared tool-schema parsing into `codex-tools`, but `codex-core` still owned the generic Responses API tool models and the last adapter layer that turned parsed tool definitions into `ResponsesApiTool` values. That left `core/src/tools/spec.rs` and `core/src/client_common.rs` holding a chunk of tool-shaping code that does not need session state, runtime plumbing, or any other `codex-core`-specific dependency. As a result, `codex-tools` owned the parsed tool definition, but `codex-core` still owned the generic wire model that those definitions are converted into. This change moves that boundary one step further. `codex-tools` now owns the reusable Responses/tool wire structs and the shared conversion helpers for dynamic tools, MCP tools, and deferred MCP aliases. `codex-core` continues to own `ToolSpec` orchestration and the remaining web-search-specific request shapes. ## What changed - added `tools/src/responses_api.rs` to own `ResponsesApiTool`, `FreeformTool`, `ToolSearchOutputTool`, namespace output types, and the shared `ToolDefinition -> ResponsesApiTool` adapter helpers - added `tools/src/responses_api_tests.rs` for deferred-loading behavior, adapter coverage, and namespace serialization coverage - rewired `core/src/tools/spec.rs` to use the extracted dynamic/MCP adapter helpers instead of defining those conversions locally - rewired `core/src/tools/handlers/tool_search.rs` to use the extracted deferred MCP adapter and namespace output types directly - slimmed `core/src/client_common.rs` so it now keeps `ToolSpec` and the web-search-specific wire types, while reusing the extracted tool models from `codex-tools` - moved the extracted seam tests out of `core` and updated `codex-rs/tools/README.md` plus `tools/src/lib.rs` to reflect the expanded `codex-tools` boundary ## Test plan - `cargo test -p codex-tools` - `cargo test -p codex-core --lib tools::spec::` - `cargo test -p codex-core --lib tools::handlers::tool_search::` - `just fix -p codex-tools -p codex-core` - `just argument-comment-lint` ## References - [#15923](https://github.com/openai/codex/pull/15923) `codex-tools: extract shared tool schema parsing` - [#15928](https://github.com/openai/codex/pull/15928) `codex-tools: extract MCP schema adapters` - [#15944](https://github.com/openai/codex/pull/15944) `codex-tools: extract dynamic tool adapters` - [#15953](https://github.com/openai/codex/pull/15953) `codex-tools: introduce named tool definitions` --- codex-rs/core/src/client_common.rs | 61 +----- codex-rs/core/src/client_common_tests.rs | 46 ----- .../core/src/tools/handlers/tool_search.rs | 13 +- .../src/tools/handlers/tool_search_tests.rs | 1 + codex-rs/core/src/tools/spec.rs | 50 +---- codex-rs/core/src/tools/spec_tests.rs | 64 +------ codex-rs/tools/README.md | 13 +- codex-rs/tools/src/lib.rs | 14 +- codex-rs/tools/src/responses_api.rs | 102 +++++++++++ codex-rs/tools/src/responses_api_tests.rs | 173 ++++++++++++++++++ 10 files changed, 320 insertions(+), 217 deletions(-) create mode 100644 codex-rs/tools/src/responses_api.rs create mode 100644 codex-rs/tools/src/responses_api_tests.rs diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 33a1f535c..50a523a26 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -157,14 +157,16 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { } pub(crate) mod tools { - use crate::tools::spec::JsonSchema; use codex_protocol::config_types::WebSearchContextSize; use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; use codex_protocol::config_types::WebSearchUserLocationType; - use serde::Deserialize; + pub(crate) use codex_tools::FreeformTool; + pub(crate) use codex_tools::FreeformToolFormat; + use codex_tools::JsonSchema; + pub(crate) use codex_tools::ResponsesApiTool; + pub(crate) use codex_tools::ToolSearchOutputTool; use serde::Serialize; - use serde_json::Value; /// When serialized as JSON, this produces a valid "Tool" in the OpenAI /// Responses API. @@ -256,59 +258,6 @@ pub(crate) mod tools { } } } - - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - pub struct FreeformTool { - pub(crate) name: String, - pub(crate) description: String, - pub(crate) format: FreeformToolFormat, - } - - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - pub struct FreeformToolFormat { - pub(crate) r#type: String, - pub(crate) syntax: String, - pub(crate) definition: String, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub struct ResponsesApiTool { - pub(crate) name: String, - pub(crate) description: String, - /// TODO: Validation. When strict is set to true, the JSON schema, - /// `required` and `additional_properties` must be present. All fields in - /// `properties` must be present in `required`. - pub(crate) strict: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) defer_loading: Option, - pub(crate) parameters: JsonSchema, - #[serde(skip)] - pub(crate) output_schema: Option, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ToolSearchOutputTool { - #[allow(dead_code)] - #[serde(rename = "function")] - Function(ResponsesApiTool), - #[serde(rename = "namespace")] - Namespace(ResponsesApiNamespace), - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiNamespace { - pub(crate) name: String, - pub(crate) description: String, - pub(crate) tools: Vec, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ResponsesApiNamespaceTool { - #[serde(rename = "function")] - Function(ResponsesApiTool), - } } pub struct ResponseStream { diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 2f2305c7a..c3add6237 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -197,49 +197,3 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { ] ); } - -#[test] -fn tool_search_output_namespace_serializes_with_deferred_child_tools() { - let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace { - name: "mcp__codex_apps__calendar".to_string(), - description: "Plan events".to_string(), - tools: vec![tools::ResponsesApiNamespaceTool::Function( - tools::ResponsesApiTool { - name: "create_event".to_string(), - description: "Create a calendar event.".to_string(), - strict: false, - defer_loading: Some(true), - parameters: crate::tools::spec::JsonSchema::Object { - properties: Default::default(), - required: None, - additional_properties: None, - }, - output_schema: None, - }, - )], - }); - - let value = serde_json::to_value(namespace).expect("serialize namespace"); - - assert_eq!( - value, - serde_json::json!({ - "type": "namespace", - "name": "mcp__codex_apps__calendar", - "description": "Plan events", - "tools": [ - { - "type": "function", - "name": "create_event", - "description": "Create a calendar event.", - "strict": false, - "defer_loading": true, - "parameters": { - "type": "object", - "properties": {} - } - } - ] - }) - ); -} diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 2005c7d2f..487e57c35 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -1,6 +1,3 @@ -use crate::client_common::tools::ResponsesApiNamespace; -use crate::client_common::tools::ResponsesApiNamespaceTool; -use crate::client_common::tools::ToolSearchOutputTool; use crate::function_tool::FunctionCallError; use crate::mcp_connection_manager::ToolInfo; use crate::tools::context::ToolInvocation; @@ -8,17 +5,17 @@ use crate::tools::context::ToolPayload; use crate::tools::context::ToolSearchOutput; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use crate::tools::spec::mcp_tool_to_deferred_openai_tool; use async_trait::async_trait; use bm25::Document; use bm25::Language; use bm25::SearchEngineBuilder; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ToolSearchOutputTool; +use codex_tools::mcp_tool_to_deferred_responses_api_tool; use std::collections::BTreeMap; use std::collections::HashMap; -#[cfg(test)] -use crate::client_common::tools::ResponsesApiTool; - pub struct ToolSearchHandler { tools: HashMap, } @@ -129,7 +126,7 @@ fn serialize_tool_search_output_tools( let tools = tools .iter() .map(|tool| { - mcp_tool_to_deferred_openai_tool(tool.tool_name.clone(), tool.tool.clone()) + mcp_tool_to_deferred_responses_api_tool(tool.tool_name.clone(), &tool.tool) .map(ResponsesApiNamespaceTool::Function) }) .collect::, _>>()?; diff --git a/codex-rs/core/src/tools/handlers/tool_search_tests.rs b/codex-rs/core/src/tools/handlers/tool_search_tests.rs index 704653c21..9a90ad738 100644 --- a/codex-rs/core/src/tools/handlers/tool_search_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_search_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_tools::ResponsesApiTool; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 053e1b9ea..e938f82f8 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,6 +1,3 @@ -use crate::client_common::tools::FreeformTool; -use crate::client_common::tools::FreeformToolFormat; -use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::config::AgentRoleConfig; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -46,9 +43,11 @@ use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_tools::ToolDefinition; -use codex_tools::parse_dynamic_tool; -use codex_tools::parse_mcp_tool; +use codex_tools::FreeformTool; +use codex_tools::FreeformToolFormat; +use codex_tools::ResponsesApiTool; +use codex_tools::dynamic_tool_to_responses_api_tool; +use codex_tools::mcp_tool_to_responses_api_tool; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_template::Template; use serde::Deserialize; @@ -2382,41 +2381,6 @@ fn push_tool_spec( } } -pub(crate) fn mcp_tool_to_openai_tool( - fully_qualified_name: String, - tool: rmcp::model::Tool, -) -> Result { - Ok(tool_definition_to_openai_tool( - parse_mcp_tool(&tool)?.renamed(fully_qualified_name), - )) -} - -pub(crate) fn mcp_tool_to_deferred_openai_tool( - name: String, - tool: rmcp::model::Tool, -) -> Result { - Ok(tool_definition_to_openai_tool( - parse_mcp_tool(&tool)?.renamed(name).into_deferred(), - )) -} - -fn dynamic_tool_to_openai_tool( - tool: &DynamicToolSpec, -) -> Result { - Ok(tool_definition_to_openai_tool(parse_dynamic_tool(tool)?)) -} - -fn tool_definition_to_openai_tool(tool_definition: ToolDefinition) -> ResponsesApiTool { - ResponsesApiTool { - name: tool_definition.name, - description: tool_definition.description, - strict: false, - defer_loading: tool_definition.defer_loading.then_some(true), - parameters: tool_definition.input_schema, - output_schema: tool_definition.output_schema, - } -} - /// Builds the tool registry builder while collecting tool specs for later serialization. #[cfg(test)] pub(crate) fn build_specs( @@ -2911,7 +2875,7 @@ pub(crate) fn build_specs_with_discoverable_tools( entries.sort_by(|a, b| a.0.cmp(&b.0)); for (name, tool) in entries.into_iter() { - match mcp_tool_to_openai_tool(name.clone(), tool.clone()) { + match mcp_tool_to_responses_api_tool(name.clone(), &tool) { Ok(converted_tool) => { push_tool_spec( &mut builder, @@ -2930,7 +2894,7 @@ pub(crate) fn build_specs_with_discoverable_tools( if !dynamic_tools.is_empty() { for tool in dynamic_tools { - match dynamic_tool_to_openai_tool(tool) { + match dynamic_tool_to_responses_api_tool(tool) { Ok(converted_tool) => { push_tool_spec( &mut builder, diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index cbdabf88f..9a096b8bc 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -8,11 +8,11 @@ use crate::tools::ToolRouter; use crate::tools::registry::ConfiguredToolSpec; use crate::tools::router::ToolRouterParams; use codex_app_server_protocol::AppInfo; -use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_tools::AdditionalProperties; +use codex_tools::mcp_tool_to_deferred_responses_api_tool; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -64,28 +64,6 @@ fn search_capable_model_info() -> ModelInfo { model_info } -#[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( @@ -102,7 +80,7 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { ); let serialized = serde_json::to_value(ToolSpec::Function( - mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool) + mcp_tool_to_deferred_responses_api_tool("mcp__codex_apps__lookup_order".to_string(), &tool) .expect("convert deferred tool"), )) .expect("serialize deferred tool"); @@ -127,44 +105,6 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { ); } -#[test] -fn dynamic_tool_preserves_defer_loading() { - let tool = DynamicToolSpec { - name: "lookup_order".to_string(), - description: "Look up an order".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": { - "order_id": {"type": "string"} - }, - "required": ["order_id"], - "additionalProperties": false, - }), - defer_loading: true, - }; - - let openai_tool = dynamic_tool_to_openai_tool(&tool).expect("convert dynamic tool"); - - assert_eq!( - openai_tool, - ResponsesApiTool { - name: "lookup_order".to_string(), - description: "Look up an order".to_string(), - strict: false, - defer_loading: Some(true), - parameters: JsonSchema::Object { - properties: BTreeMap::from([( - "order_id".to_string(), - JsonSchema::String { description: None }, - )]), - required: Some(vec!["order_id".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - } - ); -} - fn tool_name(tool: &ToolSpec) -> &str { match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index aeedbbdde..ca73e8cf6 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -5,15 +5,26 @@ shared across multiple crates and does not need to stay coupled to `codex-core`. Today this crate is intentionally small. It currently owns the shared tool -schema primitives that no longer need to live in `core/src/tools/spec.rs`: +schema and Responses API tool primitives that no longer need to live in +`core/src/tools/spec.rs` or `core/src/client_common.rs`: - `JsonSchema` - `AdditionalProperties` - `ToolDefinition` +- `ResponsesApiTool` +- `FreeformTool` +- `FreeformToolFormat` +- `ToolSearchOutputTool` +- `ResponsesApiNamespace` +- `ResponsesApiNamespaceTool` - `parse_tool_input_schema()` - `parse_dynamic_tool()` - `parse_mcp_tool()` - `mcp_call_tool_result_output_schema()` +- `tool_definition_to_responses_api_tool()` +- `dynamic_tool_to_responses_api_tool()` +- `mcp_tool_to_responses_api_tool()` +- `mcp_tool_to_deferred_responses_api_tool()` That extraction is the first step in a longer migration. The goal is not to move all of `core/src/tools` into this crate in one shot. Instead, the plan is diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 0579e8e72..92dc860d6 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -1,8 +1,10 @@ -//! Shared tool-schema parsing primitives that can live outside `codex-core`. +//! Shared tool definitions and Responses API tool primitives that can live +//! outside `codex-core`. mod dynamic_tool; mod json_schema; mod mcp_tool; +mod responses_api; mod tool_definition; pub use dynamic_tool::parse_dynamic_tool; @@ -11,4 +13,14 @@ pub use json_schema::JsonSchema; pub use json_schema::parse_tool_input_schema; pub use mcp_tool::mcp_call_tool_result_output_schema; pub use mcp_tool::parse_mcp_tool; +pub use responses_api::FreeformTool; +pub use responses_api::FreeformToolFormat; +pub use responses_api::ResponsesApiNamespace; +pub use responses_api::ResponsesApiNamespaceTool; +pub use responses_api::ResponsesApiTool; +pub use responses_api::ToolSearchOutputTool; +pub use responses_api::dynamic_tool_to_responses_api_tool; +pub use responses_api::mcp_tool_to_deferred_responses_api_tool; +pub use responses_api::mcp_tool_to_responses_api_tool; +pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_definition::ToolDefinition; diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs new file mode 100644 index 000000000..6a8823582 --- /dev/null +++ b/codex-rs/tools/src/responses_api.rs @@ -0,0 +1,102 @@ +use crate::JsonSchema; +use crate::ToolDefinition; +use crate::parse_dynamic_tool; +use crate::parse_mcp_tool; +use codex_protocol::dynamic_tools::DynamicToolSpec; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FreeformTool { + pub name: String, + pub description: String, + pub format: FreeformToolFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FreeformToolFormat { + pub r#type: String, + pub syntax: String, + pub definition: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ResponsesApiTool { + pub name: String, + pub description: String, + /// TODO: Validation. When strict is set to true, the JSON schema, + /// `required` and `additional_properties` must be present. All fields in + /// `properties` must be present in `required`. + pub strict: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub defer_loading: Option, + pub parameters: JsonSchema, + #[serde(skip)] + pub output_schema: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(tag = "type")] +pub enum ToolSearchOutputTool { + #[allow(dead_code)] + #[serde(rename = "function")] + Function(ResponsesApiTool), + #[serde(rename = "namespace")] + Namespace(ResponsesApiNamespace), +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ResponsesApiNamespace { + pub name: String, + pub description: String, + pub tools: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(tag = "type")] +pub enum ResponsesApiNamespaceTool { + #[serde(rename = "function")] + Function(ResponsesApiTool), +} + +pub fn dynamic_tool_to_responses_api_tool( + tool: &DynamicToolSpec, +) -> Result { + Ok(tool_definition_to_responses_api_tool(parse_dynamic_tool( + tool, + )?)) +} + +pub fn mcp_tool_to_responses_api_tool( + name: String, + tool: &rmcp::model::Tool, +) -> Result { + Ok(tool_definition_to_responses_api_tool( + parse_mcp_tool(tool)?.renamed(name), + )) +} + +pub fn mcp_tool_to_deferred_responses_api_tool( + name: String, + tool: &rmcp::model::Tool, +) -> Result { + Ok(tool_definition_to_responses_api_tool( + parse_mcp_tool(tool)?.renamed(name).into_deferred(), + )) +} + +pub fn tool_definition_to_responses_api_tool(tool_definition: ToolDefinition) -> ResponsesApiTool { + ResponsesApiTool { + name: tool_definition.name, + description: tool_definition.description, + strict: false, + defer_loading: tool_definition.defer_loading.then_some(true), + parameters: tool_definition.input_schema, + output_schema: tool_definition.output_schema, + } +} + +#[cfg(test)] +#[path = "responses_api_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/responses_api_tests.rs b/codex-rs/tools/src/responses_api_tests.rs new file mode 100644 index 000000000..ee19f3728 --- /dev/null +++ b/codex-rs/tools/src/responses_api_tests.rs @@ -0,0 +1,173 @@ +use super::ResponsesApiNamespace; +use super::ResponsesApiNamespaceTool; +use super::ResponsesApiTool; +use super::ToolSearchOutputTool; +use super::dynamic_tool_to_responses_api_tool; +use super::mcp_tool_to_deferred_responses_api_tool; +use super::tool_definition_to_responses_api_tool; +use crate::JsonSchema; +use crate::ToolDefinition; +use codex_protocol::dynamic_tools::DynamicToolSpec; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; + +#[test] +fn tool_definition_to_responses_api_tool_omits_false_defer_loading() { + assert_eq!( + tool_definition_to_responses_api_tool(ToolDefinition { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + input_schema: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(json!({"type": "object"})), + defer_loading: false, + }), + ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(json!({"type": "object"})), + } + ); +} + +#[test] +fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() { + let tool = DynamicToolSpec { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }), + defer_loading: true, + }; + + assert_eq!( + dynamic_tool_to_responses_api_tool(&tool).expect("convert dynamic tool"), + ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: Some(true), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + } + ); +} + +#[test] +fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() { + let tool = rmcp::model::Tool { + name: "lookup_order".to_string().into(), + title: None, + description: Some("Look up an order".to_string().into()), + input_schema: std::sync::Arc::new(rmcp::model::object(json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }))), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + assert_eq!( + mcp_tool_to_deferred_responses_api_tool( + "mcp__codex_apps__lookup_order".to_string(), + &tool, + ) + .expect("convert deferred tool"), + ResponsesApiTool { + name: "mcp__codex_apps__lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: Some(true), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + } + ); +} + +#[test] +fn tool_search_output_namespace_serializes_with_deferred_child_tools() { + let namespace = ToolSearchOutputTool::Namespace(ResponsesApiNamespace { + name: "mcp__codex_apps__calendar".to_string(), + description: "Plan events".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "create_event".to_string(), + description: "Create a calendar event.".to_string(), + strict: false, + defer_loading: Some(true), + parameters: JsonSchema::Object { + properties: Default::default(), + required: None, + additional_properties: None, + }, + output_schema: None, + })], + }); + + let value = serde_json::to_value(namespace).expect("serialize namespace"); + + assert_eq!( + value, + json!({ + "type": "namespace", + "name": "mcp__codex_apps__calendar", + "description": "Plan events", + "tools": [ + { + "type": "function", + "name": "create_event", + "description": "Create a calendar event.", + "strict": false, + "defer_loading": true, + "parameters": { + "type": "object", + "properties": {} + } + } + ] + }) + ); +}