diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 8fa973d722..ddf961e0f4 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -32,6 +32,7 @@ pub(crate) const WINDOW_ID_KEY: &str = "window_id"; pub(crate) const REQUEST_KIND_KEY: &str = "request_kind"; pub(crate) const COMPACTION_KEY: &str = "compaction"; pub(crate) const CODE_MODE_TOOL_NAMES_KEY: &str = "code_mode_tool_names"; +pub(crate) const TOOL_NAMESPACES_INFO_KEY: &str = "tool_namespaces_info"; pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; @@ -58,6 +59,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ REQUEST_KIND_KEY, COMPACTION_KEY, CODE_MODE_TOOL_NAMES_KEY, + TOOL_NAMESPACES_INFO_KEY, TURN_STARTED_AT_UNIX_MS_KEY, FORKED_FROM_THREAD_ID_KEY, PARENT_THREAD_ID_KEY, @@ -149,6 +151,34 @@ pub(crate) struct TurnMetadataWorkspace { pub(crate) has_changes: Option, } +/// Model-visible namespaces indexed by their effective Responses Lite names. +pub(crate) type TurnToolNamespacesInfo = BTreeMap; + +/// The model-visible functions belonging to one effective namespace. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct TurnToolNamespaceInfo { + pub(crate) name: String, + pub(crate) functions: BTreeMap, +} + +/// The effective per-turn exposure of one model-visible function. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct TurnToolFunctionInfo { + pub(crate) name: String, + pub(crate) direct: bool, + pub(crate) code_mode_name: Option, + pub(crate) deferred: bool, + pub(crate) source: TurnToolSource, +} + +/// The owner responsible for dispatching one effective tool function. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum TurnToolSource { + Harness, + Mcp { server_name: String }, +} + /// Caller-owned snapshot of Codex metadata sent to ResponsesAPI. /// /// The full Codex turn metadata blob is transported canonically as @@ -173,6 +203,7 @@ pub struct CodexResponsesMetadata { pub(crate) sandbox: Option, pub(crate) workspaces: BTreeMap, pub(crate) code_mode_tool_names: Option>, + pub(crate) tool_namespaces_info: Option, pub(crate) turn_started_at_unix_ms: Option, pub(crate) extra: BTreeMap, } @@ -201,6 +232,7 @@ impl CodexResponsesMetadata { sandbox: None, workspaces: BTreeMap::new(), code_mode_tool_names: None, + tool_namespaces_info: None, turn_started_at_unix_ms: None, extra: BTreeMap::new(), } @@ -257,11 +289,12 @@ impl CodexResponsesMetadata { pub(crate) fn compatibility_headers(&self) -> ApiHeaderMap { let mut headers = ApiHeaderMap::new(); insert_header(&mut headers, X_CODEX_WINDOW_ID_HEADER, &self.window_id); - // Direct x-codex-turn-metadata is compatibility output. Keep the unbounded Code Mode - // mapping in client_metadata only so HTTP and WebSocket headers remain bounded. + // Direct x-codex-turn-metadata is compatibility output. Keep unbounded tool inventories + // in client_metadata only so HTTP and WebSocket compatibility headers remain bounded. if self.has_turn_metadata() && let Ok(turn_metadata_json) = to_ascii_json_string(&CodexTurnMetadataPayload { code_mode_tool_names: None, + tool_namespaces_info: None, ..self.turn_metadata_payload() }) { @@ -311,6 +344,7 @@ impl CodexResponsesMetadata { sandbox: self.sandbox.as_deref(), workspaces: non_empty_workspaces(&self.workspaces), code_mode_tool_names: self.code_mode_tool_names.as_ref(), + tool_namespaces_info: self.tool_namespaces_info.as_ref(), turn_started_at_unix_ms: self.turn_started_at_unix_ms, compaction, // responsesapi_client_metadata enriches the Codex turn metadata blob, not literal @@ -405,6 +439,8 @@ struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] code_mode_tool_names: Option<&'a BTreeMap>, #[serde(default, skip_serializing_if = "Option::is_none")] + tool_namespaces_info: Option<&'a TurnToolNamespacesInfo>, + #[serde(default, skip_serializing_if = "Option::is_none")] turn_started_at_unix_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] compaction: Option, diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index b6c9324022..893fd0bae3 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -232,6 +232,10 @@ impl CoreToolRuntime for McpHandler { })) } + fn mcp_server_name(&self) -> Option<&str> { + Some(&self.tool_info.server_name) + } + fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags { let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())]; if let Some(origin) = &self.tool_info.server_origin { diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 89d0cd136f..314c6ed1b9 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod runtimes; pub(crate) mod sandboxing; pub(crate) mod spec_plan; pub(crate) mod tool_dispatch_trace; +mod tool_namespaces_info; use std::borrow::Cow; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index b603512dd6..321fe30d84 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -66,6 +66,11 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { None } + /// Returns the owning server only for MCP-backed tool runtimes. + fn mcp_server_name(&self) -> Option<&str> { + None + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!( payload, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 1813d08cec..3c6a5ab479 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -59,9 +59,11 @@ use crate::tools::registry::RegisteredTool; use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistry; use crate::tools::router::ToolRouter; +use crate::tools::tool_namespaces_info::collect_tool_namespaces_info; use codex_extension_api::ExtensionData; use codex_features::Feature; use codex_login::AuthManager; +use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::account::PlanType; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; @@ -370,6 +372,11 @@ pub(crate) fn finalize_tool_router( } let code_mode_tool_names = register_code_mode_executors(turn_context, &mut registry); + let include_tool_namespaces_info = turn_context + .config + .tool_registry + .turn_metadata_includes_tool_info + && turn_context.model_info.use_responses_lite; if turn_context.config.tool_registry.error_on_tool_collisions { if let Some(tool_name) = registry.first_collision() { @@ -379,6 +386,7 @@ pub(crate) fn finalize_tool_router( } let mut namespace_descriptions = BTreeMap::new(); + let mut namespace_owners = BTreeMap::new(); for tool in registry.entries() { let owned_spec; let spec = if let Some(spec) = tool.runtime.immutable_spec() { @@ -387,6 +395,27 @@ pub(crate) fn finalize_tool_router( owned_spec = tool.runtime.spec(); &owned_spec }; + if include_tool_namespaces_info && tool.exposure != ToolExposure::Hidden { + let namespace_name = match spec { + ToolSpec::Namespace(namespace) => namespace.name.as_str(), + ToolSpec::Function(_) | ToolSpec::Freeform(_) => DEFAULT_FUNCTION_NAMESPACE, + ToolSpec::ToolSearch { .. } => TOOL_SEARCH_TOOL_NAME, + ToolSpec::WebSearch { .. } => continue, + }; + let owner = tool.runtime.mcp_server_name(); + match namespace_owners.get(namespace_name) { + Some(existing_owner) if existing_owner != &owner => { + return Err( + CodexErrorDetails::ToolCollision(namespace_name.to_string()).into() + ); + } + Some(_) => {} + None => { + namespace_owners.insert(namespace_name.to_string(), owner); + } + } + } + let ToolSpec::Namespace(namespace) = spec else { continue; }; @@ -407,6 +436,16 @@ pub(crate) fn finalize_tool_router( let model_visible_specs = build_model_visible_specs(turn_context, ®istry, &code_mode_tool_names, hosted_specs); + if include_tool_namespaces_info { + turn_context + .turn_metadata_state + .set_tool_namespaces_info(collect_tool_namespaces_info( + ®istry, + &code_mode_tool_names, + &model_visible_specs, + )); + } + Ok(ToolRouter::from_parts(registry, model_visible_specs)) } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index d2fd4b4f34..b1a3749689 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -41,6 +41,8 @@ use crate::WaitForEnvironmentToolConfig; use crate::config::CurrentTimeReminderConfig; use crate::environment_selection::TurnEnvironmentState; use crate::responses_metadata::CodexResponsesRequestKind; +use crate::responses_metadata::TurnToolFunctionInfo; +use crate::responses_metadata::TurnToolSource; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; use crate::session::tests::mcp_config_for_test; @@ -1207,6 +1209,140 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { )); } +#[tokio::test] +async fn tool_namespaces_info_is_opt_in_and_tracks_mcp_exposure() { + for enabled in [false, true] { + let mut metadata_state = None; + probe_with( + |turn| { + update_config(turn, |config| { + config.tool_registry.turn_metadata_includes_tool_info = enabled; + }); + set_feature(turn, Feature::CodeMode, /*enabled*/ true); + turn.model_info.supports_search_tool = true; + turn.model_info.use_responses_lite = true; + metadata_state = Some(Arc::clone(&turn.turn_metadata_state)); + }, + ToolPlanInputs { + tool_runtimes: vec![ + mcp_runtime("registry", "mcp__registry", "direct", ToolExposure::Direct), + mcp_runtime( + "registry", + "mcp__registry", + "deferred", + ToolExposure::Deferred, + ), + ], + ..ToolPlanInputs::default() + }, + ) + .await; + + let metadata = metadata_state + .expect("tool planning should capture the turn metadata") + .to_responses_metadata( + "installation".to_string(), + "window".to_string(), + CodexResponsesRequestKind::Turn, + ); + let Some(namespaces) = metadata.tool_namespaces_info else { + assert!(!enabled, "opted-in tool planning should publish namespaces"); + continue; + }; + assert!(enabled, "tool namespaces require opt-in"); + + let namespace = namespaces + .get("mcp__registry") + .expect("MCP namespace should be included"); + assert_eq!(namespace.name, "mcp__registry"); + assert_eq!( + namespace.functions.get("direct"), + Some(&TurnToolFunctionInfo { + name: "direct".to_string(), + direct: true, + code_mode_name: Some("mcp__registry__direct".to_string()), + deferred: false, + source: TurnToolSource::Mcp { + server_name: "registry".to_string(), + }, + }) + ); + assert_eq!( + namespace.functions.get("deferred"), + Some(&TurnToolFunctionInfo { + name: "deferred".to_string(), + direct: false, + code_mode_name: Some("mcp__registry__deferred".to_string()), + deferred: true, + source: TurnToolSource::Mcp { + server_name: "registry".to_string(), + }, + }) + ); + } +} + +#[tokio::test] +async fn strict_namespace_ownership_requires_tool_namespace_inventory_opt_in() { + for (enabled, second_exposure) in [ + (false, ToolExposure::Direct), + (true, ToolExposure::Direct), + (true, ToolExposure::Hidden), + ] { + let (_session, mut turn) = make_session_and_context().await; + update_config(&mut turn, |config| { + config.tool_registry.error_on_tool_collisions = true; + config.tool_registry.turn_metadata_includes_tool_info = enabled; + }); + turn.model_info.use_responses_lite = true; + let step_context = StepContext::for_test(Arc::new(turn)); + let mut registry = build_core_tool_registry( + step_context.turn.as_ref(), + &step_context.environments, + step_context.mcp.as_ref(), + /*tool_suggest_candidates*/ None, + /*wait_for_environment_tool_config*/ None, + ); + let runtimes = [ + ("first", "lookup", ToolExposure::Direct), + ("second", "list", second_exposure), + ] + .into_iter() + .map(|(server_name, tool_name, exposure)| { + let mut tool = mcp_tool(server_name, "shared", tool_name); + tool.namespace_description = Some("Shared tools.".to_string()); + RegisteredTool { + runtime: Arc::new(McpHandler::new(tool).expect("MCP tool spec should build")), + exposure, + } + }) + .collect(); + let hosted_specs = append_source_tools( + step_context.turn.as_ref(), + &mut registry, + runtimes, + Vec::new(), + &[], + ); + let result = super::finalize_tool_router( + step_context.turn.as_ref(), + registry, + hosted_specs, + &Default::default(), + ); + + if enabled && second_exposure != ToolExposure::Hidden { + let error = result.err().expect("mixed namespace ownership should fail"); + assert!(matches!( + error.details(), + CodexErrorDetails::ToolCollision(name) if name == "shared" + )); + } else { + assert!(result.is_ok(), "existing strict behavior should not change"); + } + } +} + #[tokio::test] async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() { let plan = probe_with( diff --git a/codex-rs/core/src/tools/tool_namespaces_info.rs b/codex-rs/core/src/tools/tool_namespaces_info.rs new file mode 100644 index 0000000000..f43cb1fe36 --- /dev/null +++ b/codex-rs/core/src/tools/tool_namespaces_info.rs @@ -0,0 +1,111 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +use crate::responses_metadata::TurnToolFunctionInfo; +use crate::responses_metadata::TurnToolNamespaceInfo; +use crate::responses_metadata::TurnToolNamespacesInfo; +use crate::responses_metadata::TurnToolSource; +use crate::tools::registry::ToolExposure; +use crate::tools::registry::ToolRegistry; +use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::ToolName; +use codex_tools::ToolSpec; + +const TOOL_SEARCH_FUNCTION_NAME: &str = "tool_search_tool"; + +pub(super) fn collect_tool_namespaces_info( + registry: &ToolRegistry, + code_mode_tool_names: &BTreeMap, + model_visible_specs: &[ToolSpec], +) -> TurnToolNamespacesInfo { + let mut direct_functions = BTreeSet::new(); + let mut native_tool_search_visible = false; + for spec in model_visible_specs { + match spec { + ToolSpec::Function(_) | ToolSpec::Freeform(_) => { + direct_functions.insert((DEFAULT_FUNCTION_NAMESPACE, spec.name())); + } + ToolSpec::Namespace(namespace) => { + for tool in &namespace.tools { + let function_name = match tool { + ResponsesApiNamespaceTool::Function(tool) => tool.name.as_str(), + ResponsesApiNamespaceTool::Custom(tool) => tool.name.as_str(), + }; + direct_functions.insert((namespace.name.as_str(), function_name)); + } + } + ToolSpec::ToolSearch { .. } => { + native_tool_search_visible = true; + direct_functions.insert((TOOL_SEARCH_TOOL_NAME, TOOL_SEARCH_FUNCTION_NAME)); + } + ToolSpec::WebSearch { .. } => {} + } + } + + let code_mode_names_by_tool = code_mode_tool_names + .iter() + .map(|(code_mode_name, tool_name)| { + (tool_name.clone().with_default_namespace(), code_mode_name) + }) + .collect::>(); + let mut namespaces = TurnToolNamespacesInfo::default(); + + for tool in registry.entries() { + let runtime = &tool.runtime; + let exposure = tool.exposure; + if exposure == ToolExposure::Hidden { + continue; + } + + let tool_name = runtime.tool_name().with_default_namespace(); + let is_tool_search = tool_name.name == TOOL_SEARCH_TOOL_NAME + && matches!(runtime.spec(), ToolSpec::ToolSearch { .. }); + let (namespace_name, function_name) = if is_tool_search { + (TOOL_SEARCH_TOOL_NAME, TOOL_SEARCH_FUNCTION_NAME) + } else { + ( + tool_name + .namespace + .as_deref() + .unwrap_or(DEFAULT_FUNCTION_NAMESPACE), + tool_name.name.as_str(), + ) + }; + let direct = direct_functions.contains(&(namespace_name, function_name)); + let code_mode_name = code_mode_names_by_tool + .get(&tool_name) + .map(|name| (*name).clone()); + let deferred = exposure.is_deferred() + && native_tool_search_visible + && (runtime.immutable_spec().is_some() || runtime.search_info().is_some()); + if !direct && !deferred && code_mode_name.is_none() { + continue; + } + + let namespace = namespaces + .entry(namespace_name.to_string()) + .or_insert_with(|| TurnToolNamespaceInfo { + name: namespace_name.to_string(), + functions: BTreeMap::new(), + }); + namespace + .functions + .entry(function_name.to_string()) + .or_insert_with(|| TurnToolFunctionInfo { + name: function_name.to_string(), + direct, + code_mode_name, + deferred, + source: match runtime.mcp_server_name() { + Some(server_name) => TurnToolSource::Mcp { + server_name: server_name.to_string(), + }, + None => TurnToolSource::Harness, + }, + }); + } + + namespaces +} diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 7f6567b642..5d7c87e5f0 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -12,11 +12,11 @@ use std::sync::atomic::Ordering; use serde_json::Value; use tokio::task::JoinHandle; -use crate::responses_metadata::CODE_MODE_TOOL_NAMES_KEY; use crate::responses_metadata::CodexResponsesMetadata; use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::PARENT_TURN_ID_KEY; use crate::responses_metadata::TurnMetadataWorkspace; +use crate::responses_metadata::TurnToolNamespacesInfo; use crate::responses_metadata::filter_extra_metadata; use crate::responses_metadata::subagent_header_value; use crate::responses_metadata::subagent_metadata_kind; @@ -104,6 +104,7 @@ pub(crate) struct TurnMetadataState { sandbox: Option, enriched_workspaces: RwLock>>, code_mode_tool_names: RwLock>>, + tool_namespaces_info: RwLock>, turn_started_at_unix_ms: RwLock>, responsesapi_client_metadata: RwLock>, user_input_requested_during_turn: AtomicBool, @@ -149,6 +150,7 @@ impl TurnMetadataState { sandbox, enriched_workspaces: RwLock::new(None), code_mode_tool_names: RwLock::new(None), + tool_namespaces_info: RwLock::new(None), turn_started_at_unix_ms: RwLock::new(None), responsesapi_client_metadata: RwLock::new(BTreeMap::new()), user_input_requested_during_turn: AtomicBool::new(false), @@ -160,12 +162,13 @@ impl TurnMetadataState { &self, context: McpTurnMetadataContext<'_>, ) -> Option { - let Value::Object(mut metadata) = - self.responses_metadata_template().turn_metadata_value()? - else { + let mut responses_metadata = self.responses_metadata_template(); + // Never serialize harness-owned tool inventory for external MCP servers. + responses_metadata.code_mode_tool_names = None; + responses_metadata.tool_namespaces_info = None; + let Value::Object(mut metadata) = responses_metadata.turn_metadata_value()? else { return None; }; - metadata.remove(CODE_MODE_TOOL_NAMES_KEY); // Precaution: avoid exposing tool data to external MCPs. metadata.remove(PARENT_TURN_ID_KEY); metadata.insert( MODEL_KEY.to_string(), @@ -226,6 +229,14 @@ impl TurnMetadataState { (!code_mode_tool_names.is_empty()).then_some(code_mode_tool_names); } + pub(crate) fn set_tool_namespaces_info(&self, tool_namespaces_info: TurnToolNamespacesInfo) { + *self + .tool_namespaces_info + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + (!tool_namespaces_info.is_empty()).then_some(tool_namespaces_info); + } + pub(crate) fn set_parent_turn_id(&self, parent_turn_id: String) { if parent_turn_id.trim().is_empty() { return; @@ -268,6 +279,11 @@ impl TurnMetadataState { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(), + tool_namespaces_info: self + .tool_namespaces_info + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), turn_started_at_unix_ms: self.current_turn_started_at_unix_ms(), extra: self .responsesapi_client_metadata diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index f9912c1aa8..359d937767 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -5,6 +5,10 @@ use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; use crate::responses_metadata::INSTALLATION_ID_KEY; use crate::responses_metadata::PARENT_TURN_ID_KEY; +use crate::responses_metadata::TOOL_NAMESPACES_INFO_KEY; +use crate::responses_metadata::TurnToolFunctionInfo; +use crate::responses_metadata::TurnToolNamespaceInfo; +use crate::responses_metadata::TurnToolSource; use crate::responses_metadata::WINDOW_ID_KEY; use crate::sandbox_tags::permission_profile_sandbox_tag; use codex_analytics::CompactionImplementation; @@ -549,6 +553,10 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { CODE_MODE_TOOL_NAMES_KEY.to_string(), "client-supplied".to_string(), ), + ( + TOOL_NAMESPACES_INFO_KEY.to_string(), + "client-supplied".to_string(), + ), ( "turn_started_at_unix_ms".to_string(), "client-supplied".to_string(), @@ -569,6 +577,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { let json: Value = serde_json::from_str(&header).expect("json"); assert!(json.get(CODE_MODE_TOOL_NAMES_KEY).is_none()); + assert!(json.get(TOOL_NAMESPACES_INFO_KEY).is_none()); assert!(json.get("turn_started_at_unix_ms").is_none()); assert!(json.get("forked_from_thread_id").is_none()); assert!(json.get("parent_thread_id").is_none()); @@ -644,6 +653,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( CODE_MODE_TOOL_NAMES_KEY.to_string(), "client-supplied".to_string(), ), + ( + TOOL_NAMESPACES_INFO_KEY.to_string(), + "client-supplied".to_string(), + ), ("turn_id".to_string(), "client-supplied".to_string()), (WINDOW_ID_KEY.to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), @@ -661,6 +674,24 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( ToolName::namespaced("mcp__calendar", "lookup"), ), ])); + state.set_tool_namespaces_info(BTreeMap::from([( + "mcp__calendar".to_string(), + TurnToolNamespaceInfo { + name: "mcp__calendar".to_string(), + functions: BTreeMap::from([( + "lookup".to_string(), + TurnToolFunctionInfo { + name: "lookup".to_string(), + direct: true, + code_mode_name: Some("mcp__calendar__lookup".to_string()), + deferred: false, + source: TurnToolSource::Mcp { + server_name: "calendar".to_string(), + }, + }, + )]), + }, + )])); let header = test_turn_metadata_header(&state); assert!(header.is_ascii()); @@ -687,6 +718,26 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( }, }) ); + assert_eq!( + json[TOOL_NAMESPACES_INFO_KEY], + serde_json::json!({ + "mcp__calendar": { + "name": "mcp__calendar", + "functions": { + "lookup": { + "name": "lookup", + "direct": true, + "code_mode_name": "mcp__calendar__lookup", + "deferred": false, + "source": { + "kind": "mcp", + "server_name": "calendar", + }, + }, + }, + }, + }) + ); assert!(json.get(INSTALLATION_ID_KEY).is_none()); assert!(json.get("x-codex-installation-id").is_none()); assert!(json.get("x-codex-parent-thread-id").is_none()); @@ -727,12 +778,39 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( Some("thread-a:1") ); + let compatibility_headers = state + .to_responses_metadata( + "installation-a".to_string(), + "thread-a:1".to_string(), + CodexResponsesRequestKind::Turn, + ) + .compatibility_headers(); + let compatibility_metadata: Value = serde_json::from_str( + compatibility_headers + .get("x-codex-turn-metadata") + .expect("compatibility turn metadata header") + .to_str() + .expect("valid compatibility header"), + ) + .expect("compatibility metadata json"); + assert!( + compatibility_metadata + .get(CODE_MODE_TOOL_NAMES_KEY) + .is_none() + ); + assert!( + compatibility_metadata + .get(TOOL_NAMESPACES_INFO_KEY) + .is_none() + ); + let meta = state .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) .expect("turn metadata should be present"); assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); assert!(meta.get(CODE_MODE_TOOL_NAMES_KEY).is_none()); + assert!(meta.get(TOOL_NAMESPACES_INFO_KEY).is_none()); assert!(meta.get(PARENT_TURN_ID_KEY).is_none()); assert!(meta.get(WINDOW_ID_KEY).is_none()); assert_eq!(state.workspace_kind().as_deref(), Some("projectless")); diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 75700edb8d..b06ccbb6a2 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -10,6 +10,7 @@ use codex_features::Feature; use codex_image_generation_extension::install as install_image_generation_extension; use codex_login::CodexAuth; use codex_login::auth::BedrockApiKeyAuth; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::InputModality; @@ -18,10 +19,15 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use codex_web_search_extension::install as install_web_search_extension; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; @@ -158,11 +164,76 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result< "namespace": null, }) ); + assert!(turn_metadata.get("tool_namespaces_info").is_none()); assert!(!client_metadata.contains_key("x-codex-code-mode-tool-names")); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_includes_tool_namespaces_info_when_enabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url) + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + model_info.tool_mode = Some(ToolMode::CodeMode); + model_info.supports_search_tool = false; + }) + .with_config(|config| { + config.tool_registry.turn_metadata_includes_tool_info = true; + }); + let test = builder.build_with_auto_env(&server).await?; + wait_for_mcp_server(&test.codex, CODEX_APPS_MCP_SERVER_NAME).await?; + + test.submit_turn("hello").await?; + + let request = response_mock.single_request(); + let body = request.body_json(); + let turn_metadata: Value = serde_json::from_str( + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .context("Responses request should include turn metadata")?, + )?; + let calendar = &turn_metadata["tool_namespaces_info"][SEARCH_CALENDAR_NAMESPACE]; + + assert_eq!(calendar["name"], SEARCH_CALENDAR_NAMESPACE); + assert_eq!( + calendar["functions"][SEARCH_CALENDAR_CREATE_TOOL], + serde_json::json!({ + "name": SEARCH_CALENDAR_CREATE_TOOL, + "direct": true, + "deferred": false, + "code_mode_name": "mcp__codex_apps__calendar_create_event", + "source": { + "kind": "mcp", + "server_name": "codex_apps", + }, + }) + ); + + let compatibility_metadata: Value = serde_json::from_str( + request + .header("x-codex-turn-metadata") + .as_deref() + .context("Responses request should include compatibility turn metadata")?, + )?; + assert!(compatibility_metadata.get("tool_namespaces_info").is_none()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_lite_prepares_images() -> Result<()> { skip_if_no_network!(Ok(()));