Include tool namespace inventory in turn metadata (#37492)

## What changed

- Add opt-in `tool_namespaces_info` metadata for Responses Lite turns when
  `tool_registry.turn_metadata_includes_tool_info` is enabled.
- Describe each model-visible function's namespace, direct and deferred
  exposure, Code Mode name, and harness or MCP ownership.
- Reject visible namespaces shared by different owners when strict collision
  checking and tool inventory metadata are enabled.
- Keep tool inventories out of compatibility headers and metadata sent to MCP
  servers.

## Testing

- Cover opt-in behavior, MCP exposure details, namespace ownership collisions,
  reserved metadata handling, and Responses Lite request serialization.

GitOrigin-RevId: 6a6965d2ba98e29444fdfbb4ccec6aafcf0391e6
This commit is contained in:
rka-oai
2026-08-07 19:32:15 +00:00
committed by copyberry
parent ba94150c2a
commit 2b1811e562
10 changed files with 504 additions and 7 deletions
+38 -2
View File
@@ -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<bool>,
}
/// Model-visible namespaces indexed by their effective Responses Lite names.
pub(crate) type TurnToolNamespacesInfo = BTreeMap<String, TurnToolNamespaceInfo>;
/// 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<String, TurnToolFunctionInfo>,
}
/// 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<String>,
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<String>,
pub(crate) workspaces: BTreeMap<String, TurnMetadataWorkspace>,
pub(crate) code_mode_tool_names: Option<BTreeMap<String, ToolName>>,
pub(crate) tool_namespaces_info: Option<TurnToolNamespacesInfo>,
pub(crate) turn_started_at_unix_ms: Option<i64>,
pub(crate) extra: BTreeMap<String, String>,
}
@@ -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<String, ToolName>>,
#[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<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
compaction: Option<CompactionTurnMetadata>,
+4
View File
@@ -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 {
+1
View File
@@ -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;
+5
View File
@@ -66,6 +66,11 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
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,
+39
View File
@@ -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, &registry, &code_mode_tool_names, hosted_specs);
if include_tool_namespaces_info {
turn_context
.turn_metadata_state
.set_tool_namespaces_info(collect_tool_namespaces_info(
&registry,
&code_mode_tool_names,
&model_visible_specs,
));
}
Ok(ToolRouter::from_parts(registry, model_visible_specs))
}
+136
View File
@@ -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(
@@ -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<String, ToolName>,
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::<BTreeMap<_, _>>();
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
}
+21 -5
View File
@@ -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<String>,
enriched_workspaces: RwLock<Option<BTreeMap<String, TurnMetadataWorkspace>>>,
code_mode_tool_names: RwLock<Option<BTreeMap<String, ToolName>>>,
tool_namespaces_info: RwLock<Option<TurnToolNamespacesInfo>>,
turn_started_at_unix_ms: RwLock<Option<i64>>,
responsesapi_client_metadata: RwLock<BTreeMap<String, String>>,
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<serde_json::Value> {
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
+78
View File
@@ -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"));
@@ -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(()));