Skip to main content

harness_codex/
wire.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3use router_protocol::{
4    CallId, CanonicalRequest, CapabilityRequirements, ContentBlock, FunctionTool,
5    HarnessInstanceId, InputItem, M1_WIRE_VERSION, MessageRole, MultiAgentNamespace,
6    ReasoningIntent, SessionId, StructuredOutputIntent, ToolChoice, TurnId, validate_tool_name,
7    validate_tool_namespace,
8};
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value, json};
11use std::collections::{BTreeMap, HashSet};
12use thiserror::Error;
13
14const TOP_LEVEL_FIELDS: &[&str] = &[
15    "model",
16    "instructions",
17    "input",
18    "tools",
19    "tool_choice",
20    "parallel_tool_calls",
21    "reasoning",
22    "store",
23    "stream",
24    "stream_options",
25    "include",
26    "service_tier",
27    "prompt_cache_key",
28    "text",
29    "client_metadata",
30];
31
32const ITEM_FIELDS: &[&str] = &[
33    "type",
34    "id",
35    "role",
36    "content",
37    "tools",
38    "phase",
39    "name",
40    "namespace",
41    "arguments",
42    "call_id",
43    "output",
44    "summary",
45    "encrypted_content",
46    "internal_chat_message_metadata_passthrough",
47];
48
49const TOOL_FIELDS: &[&str] = &[
50    "type",
51    "name",
52    "description",
53    "strict",
54    "defer_loading",
55    "parameters",
56];
57
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
59pub struct CodexRequestIdentity {
60    pub session_id: Option<String>,
61    pub thread_id: Option<String>,
62    pub fallback_id: String,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum CodexWireErrorKind {
67    InvalidRequest,
68    UnsupportedCapability,
69    UnsupportedWireCombination,
70}
71
72#[derive(Clone, Debug, PartialEq)]
73pub struct CodexWireError {
74    pub kind: CodexWireErrorKind,
75    pub diagnostics: Value,
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79enum FailureStage {
80    RequestShape,
81    RequestStream,
82    RequestStore,
83    InputItemType,
84    InputItemShape,
85    InputFunctionArguments,
86    InputFunctionCallId,
87    InputFunctionOutput,
88    ContentType,
89    InputItemNamespace,
90    ToolTypeCustom,
91    ToolTypeToolSearch,
92    ToolTypeWebSearch,
93    ToolTypeUnknown,
94    ToolName,
95    ToolNamespaceName,
96    ToolNamespaceShape,
97    ToolNamespaceInnerType,
98    ToolDuplicate,
99    ToolReservedCollision,
100    ToolDeferLoading,
101    ToolChoice,
102    Reasoning,
103    ReasoningEffort,
104    ReasoningContext,
105    ReasoningSummary,
106    Text,
107    TextVerbosity,
108    TextFormat,
109    TextFormatType,
110    StreamOptions,
111    Include,
112    ServiceTier,
113    CanonicalValidation,
114}
115
116impl FailureStage {
117    #[cfg(test)]
118    const ALL: [Self; 34] = [
119        Self::RequestShape,
120        Self::RequestStream,
121        Self::RequestStore,
122        Self::InputItemType,
123        Self::InputItemShape,
124        Self::InputFunctionArguments,
125        Self::InputFunctionCallId,
126        Self::InputFunctionOutput,
127        Self::ContentType,
128        Self::InputItemNamespace,
129        Self::ToolTypeCustom,
130        Self::ToolTypeToolSearch,
131        Self::ToolTypeWebSearch,
132        Self::ToolTypeUnknown,
133        Self::ToolName,
134        Self::ToolNamespaceName,
135        Self::ToolNamespaceShape,
136        Self::ToolNamespaceInnerType,
137        Self::ToolDuplicate,
138        Self::ToolReservedCollision,
139        Self::ToolDeferLoading,
140        Self::ToolChoice,
141        Self::Reasoning,
142        Self::ReasoningEffort,
143        Self::ReasoningContext,
144        Self::ReasoningSummary,
145        Self::Text,
146        Self::TextVerbosity,
147        Self::TextFormat,
148        Self::TextFormatType,
149        Self::StreamOptions,
150        Self::Include,
151        Self::ServiceTier,
152        Self::CanonicalValidation,
153    ];
154
155    const fn as_str(self) -> &'static str {
156        match self {
157            Self::RequestShape => "codex_wire.request.shape",
158            Self::RequestStream => "codex_wire.request.stream",
159            Self::RequestStore => "codex_wire.request.store",
160            Self::InputItemType => "codex_wire.input_item.type",
161            Self::InputItemShape => "codex_wire.input_item.shape",
162            Self::InputFunctionArguments => "codex_wire.input_function.arguments",
163            Self::InputFunctionCallId => "codex_wire.input_function.call_id",
164            Self::InputFunctionOutput => "codex_wire.input_function.output",
165            Self::ContentType => "codex_wire.content.type",
166            Self::InputItemNamespace => "codex_wire.input_item.namespace",
167            Self::ToolTypeCustom => "codex_wire.tool.type_custom",
168            Self::ToolTypeToolSearch => "codex_wire.tool.type_tool_search",
169            Self::ToolTypeWebSearch => "codex_wire.tool.type_web_search",
170            Self::ToolTypeUnknown => "codex_wire.tool.type_unknown",
171            Self::ToolName => "codex_wire.tool.name",
172            Self::ToolNamespaceName => "codex_wire.tool.namespace.name",
173            Self::ToolNamespaceShape => "codex_wire.tool.namespace.shape",
174            Self::ToolNamespaceInnerType => "codex_wire.tool.namespace.inner_type",
175            Self::ToolDuplicate => "codex_wire.tool.duplicate",
176            Self::ToolReservedCollision => "codex_wire.tool.reserved_collision",
177            Self::ToolDeferLoading => "codex_wire.tool.defer_loading",
178            Self::ToolChoice => "codex_wire.tool_choice",
179            Self::Reasoning => "codex_wire.reasoning",
180            Self::ReasoningEffort => "codex_wire.reasoning.effort",
181            Self::ReasoningContext => "codex_wire.reasoning.context",
182            Self::ReasoningSummary => "codex_wire.reasoning.summary",
183            Self::Text => "codex_wire.text",
184            Self::TextVerbosity => "codex_wire.text.verbosity",
185            Self::TextFormat => "codex_wire.text.format",
186            Self::TextFormatType => "codex_wire.text.format.type",
187            Self::StreamOptions => "codex_wire.stream_options.reasoning_summary_delivery",
188            Self::Include => "codex_wire.include",
189            Self::ServiceTier => "codex_wire.service_tier",
190            Self::CanonicalValidation => "codex_wire.canonical_validation",
191        }
192    }
193}
194
195#[derive(Debug, Deserialize)]
196#[serde(deny_unknown_fields)]
197struct ResponsesApiRequest {
198    model: String,
199    #[serde(default)]
200    instructions: String,
201    input: Vec<Value>,
202    #[serde(default)]
203    tools: Option<Vec<Value>>,
204    #[serde(default = "default_tool_choice")]
205    tool_choice: String,
206    #[serde(default)]
207    parallel_tool_calls: bool,
208    #[serde(default)]
209    reasoning: Option<Value>,
210    #[serde(default)]
211    store: bool,
212    stream: bool,
213    #[serde(default)]
214    stream_options: Option<Value>,
215    #[serde(default)]
216    include: Vec<String>,
217    #[serde(default)]
218    service_tier: Option<String>,
219    #[serde(default)]
220    prompt_cache_key: Option<String>,
221    #[serde(default)]
222    text: Option<Value>,
223    #[serde(default)]
224    client_metadata: Option<BTreeMap<String, String>>,
225}
226
227fn default_tool_choice() -> String {
228    "auto".to_owned()
229}
230
231/// The Codex harness multi-agent namespace exposed to the CLI.
232pub const MULTI_AGENT_NAMESPACE: &str = "multi_agent_v1";
233
234/// Fixed, owner-authorized limits for a canonical Codex multi-agent turn.
235///
236/// These are request-context facts, not a local fan-out implementation. The
237/// harness owns child execution; SteadyMux only preserves the inherited route
238/// policy or refuses before any visible output.
239#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
240pub struct CodexSubagentRequestContext {
241    pub max_threads: u8,
242    pub max_depth: u8,
243    pub fork_turns: &'static str,
244}
245
246impl CodexSubagentRequestContext {
247    pub const MAX_THREADS: u8 = 4;
248    pub const MAX_DEPTH: u8 = 2;
249    pub const FORK_TURNS: &'static str = "none";
250
251    const fn fixed() -> Self {
252        Self {
253            max_threads: Self::MAX_THREADS,
254            max_depth: Self::MAX_DEPTH,
255            fork_turns: Self::FORK_TURNS,
256        }
257    }
258}
259
260/// Returns the fixed subagent context only for canonical Codex subagent
261/// requests: the `multi_agent_v1` namespace, either MAv2 namespace
262/// (`collaboration` or `agents`), or a bare MAv2 tool name. Both the namespace
263/// tool declaration and a namespace function-call are authoritative request
264/// context. A bare canonical namespace name is also refused conservatively so
265/// it cannot bypass the pre-visible contract through a malformed provider seam.
266pub fn canonical_subagent_request_context(
267    request: &CanonicalRequest,
268) -> Option<CodexSubagentRequestContext> {
269    let declares_namespace = request.tools.iter().any(|tool| {
270        tool.namespace.as_deref().is_some_and(is_subagent_namespace)
271            || (tool.namespace.is_none()
272                && (tool.name == MULTI_AGENT_NAMESPACE || is_mav2_tool_name(&tool.name)))
273    });
274    let continues_namespace = request.input.iter().any(|item| {
275        matches!(
276            item,
277            InputItem::FunctionCall {
278                namespace: Some(namespace),
279                ..
280            } if is_subagent_namespace(namespace)
281        ) || matches!(
282            item,
283            InputItem::FunctionCall {
284                namespace: None,
285                name,
286                ..
287            } if name == MULTI_AGENT_NAMESPACE || is_mav2_tool_name(name)
288        )
289    });
290    (declares_namespace || continues_namespace).then_some(CodexSubagentRequestContext::fixed())
291}
292
293/// Default namespace function for a bare namespace-container call emitted by a
294/// backend that cannot represent namespaces.
295const MULTI_AGENT_DEFAULT_FUNCTION: &str = "spawn_agent";
296
297/// Disambiguates a bare `multi_agent_v1` container call into the namespace
298/// function the argument shape matches, mirroring the Codex CLI 0.148.0
299/// `multi_agent_v1` tool schemas:
300/// - `resume_agent` requires `id`;
301/// - `wait_agent` requires `timeout_ms`;
302/// - `send_input` requires `target` plus `message`/`items`/`interrupt`;
303/// - `close_agent` requires `target` only;
304/// - anything else (or no recognizable shape) is the spawn entry point.
305fn disambiguate_multi_agent_function(arguments: &Value) -> &'static str {
306    let object = arguments.as_object();
307    let has = |key: &str| object.is_some_and(|map| map.contains_key(key));
308    if has("id") {
309        return "resume_agent";
310    }
311    if has("timeout_ms") {
312        return "wait_agent";
313    }
314    if has("target") {
315        if has("message") || has("items") || has("interrupt") {
316            return "send_input";
317        }
318        return "close_agent";
319    }
320    MULTI_AGENT_DEFAULT_FUNCTION
321}
322
323/// Normalizes a provider-emitted tool call name into a `(namespace, name)` pair
324/// for the Codex wire. Backends that cannot represent namespaces receive the
325/// flattened `namespace__name` form on the request side; this reverses that
326/// translation. A bare namespace-container name (for example `multi_agent_v1`)
327/// is mapped to the namespace function its argument shape matches so the CLI
328/// can resolve the call instead of rejecting it with `unsupported call`.
329pub fn normalize_provider_tool_call(name: &str, arguments: &Value) -> (Option<String>, String) {
330    if let Some((namespace, function)) = name.split_once("__")
331        && validate_tool_namespace(namespace).is_ok()
332        && !function.is_empty()
333    {
334        return (Some(namespace.to_owned()), function.to_owned());
335    }
336    if name == MULTI_AGENT_NAMESPACE {
337        return (
338            Some(MULTI_AGENT_NAMESPACE.to_owned()),
339            disambiguate_multi_agent_function(arguments).to_owned(),
340        );
341    }
342    (None, name.to_owned())
343}
344
345/// The Codex MAv2 fixed namespace (openai/codex#29067, merged 2026-06-23). This
346/// namespace is model-reserved for GPT-5.6 Sol/Terra/Luna and requires the
347/// exact canonical schema.
348pub const MAV2_COLLABORATION_NAMESPACE: &str = "collaboration";
349
350/// The documented non-reserved MAv2 namespace workaround
351/// (openai/codex#31864): moving the fully-qualified tool identity to
352/// `functions.agents.<fn>` bypasses the reserved-schema 400.
353pub const MAV2_AGENTS_NAMESPACE: &str = "agents";
354
355/// The fixed MAv2 tool set exposed under the chosen namespace.
356pub const MAV2_TOOL_NAMES: [&str; 5] = [
357    "spawn_agent",
358    "send_input",
359    "resume_agent",
360    "wait_agent",
361    "close_agent",
362];
363
364/// Returns true when a namespace is one of the canonical subagent namespaces
365/// (`multi_agent_v1`, `collaboration`, or `agents`).
366pub fn is_subagent_namespace(namespace: &str) -> bool {
367    matches!(
368        namespace,
369        MULTI_AGENT_NAMESPACE | MAV2_COLLABORATION_NAMESPACE | MAV2_AGENTS_NAMESPACE
370    )
371}
372
373/// Returns true when a bare tool name identifies a canonical Codex MAv2 tool.
374pub fn is_mav2_tool_name(name: &str) -> bool {
375    MAV2_TOOL_NAMES.contains(&name)
376}
377
378/// Normalizes a provider-emitted tool call name into a `(namespace, name)` pair
379/// for the Codex wire, resolving MAv2 namespace semantics for the route's
380/// chosen namespace (PRD-001u R2.1/R2.2). Backends that cannot represent
381/// namespaces receive the flattened `namespace__name` form on the request side;
382/// this reverses that translation. A bare MAv2 tool name (for example
383/// `spawn_agent`, the PRD-001u AC-04 fake emission shape) is mapped to the
384/// route's chosen namespace so the CLI can resolve the call instead of
385/// rejecting it with `unsupported call`. Flattened-name and v1-container
386/// handling is delegated to `normalize_provider_tool_call`.
387pub fn normalize_mav2_provider_tool_call(
388    name: &str,
389    arguments: &Value,
390    namespace_choice: MultiAgentNamespace,
391) -> (Option<String>, String) {
392    if MAV2_TOOL_NAMES.contains(&name) {
393        return (Some(namespace_choice.as_str().to_owned()), name.to_owned());
394    }
395    normalize_provider_tool_call(name, arguments)
396}
397
398/// Id-smuggling separator modeled on the rflectr `__ts__` pattern (PRD-001u
399/// R-A/AC-09): the namespace is carried through a field the host echoes
400/// unchanged (the tool-use call id) as `<rawId>__ns__<base64url(namespace)>`.
401/// The marker is collision-proof: a raw id that already contains it is refused
402/// at encode time, so the encoder never produces an ambiguous value. A value
403/// that carries the marker and decodes to a canonical subagent namespace is by
404/// construction one this encoder produced; `decode_namespace_call_id` cannot,
405/// however, distinguish a provider-supplied id that happens to match the shape
406/// from an encoder-produced id, so callers must treat the decode result as
407/// advisory rather than provenance.
408pub const NAMESPACE_ID_SMUGGLE_MARKER: &str = "__ns__";
409
410#[derive(Debug, Error, PartialEq, Eq)]
411pub enum NamespaceIdSmuggleError {
412    #[error("the call id collides with the namespace marker")]
413    MarkerCollision,
414}
415
416/// Encodes a namespace into a call id as `<rawId>__ns__<base64url(namespace)>`.
417/// Idempotent: an already-encoded id is returned unchanged. Collision-proof: a
418/// raw id that already contains the marker is refused instead of producing an
419/// ambiguous value.
420pub fn encode_namespace_call_id(
421    raw: &str,
422    namespace: &str,
423) -> Result<String, NamespaceIdSmuggleError> {
424    if decode_namespace_call_id(raw).is_some() {
425        return Ok(raw.to_owned());
426    }
427    if raw.contains(NAMESPACE_ID_SMUGGLE_MARKER) {
428        return Err(NamespaceIdSmuggleError::MarkerCollision);
429    }
430    use base64::Engine;
431    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(namespace.as_bytes());
432    Ok(format!("{raw}{NAMESPACE_ID_SMUGGLE_MARKER}{payload}"))
433}
434
435/// Decodes a namespace-smuggled call id back to `(raw, namespace)`. Returns
436/// `None` when the value does not carry the marker or the payload does not
437/// decode to a canonical subagent namespace, so a plain call id always
438/// round-trips as a plain call id.
439pub fn decode_namespace_call_id(value: &str) -> Option<(String, String)> {
440    use base64::Engine;
441    let (raw, payload) = value.rsplit_once(NAMESPACE_ID_SMUGGLE_MARKER)?;
442    if raw.is_empty() {
443        return None;
444    }
445    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
446        .decode(payload)
447        .ok()?;
448    let namespace = String::from_utf8(bytes).ok()?;
449    is_subagent_namespace(&namespace).then_some((raw.to_owned(), namespace))
450}
451
452/// Validated MAv2 `spawn_agent` contract (PRD-001u R5).
453#[derive(Clone, Debug, PartialEq, Eq)]
454pub struct Mav2SpawnAgentContract {
455    pub task_name: String,
456    /// Per-child model override gated by
457    /// `features.multi_agent_v2.expose_spawn_agent_model_overrides`; absent
458    /// inherits the parent route settings.
459    pub model: Option<String>,
460    pub reasoning_effort: Option<String>,
461    pub fork_turns: Mav2ForkTurns,
462}
463
464/// MAv2 `fork_turns` semantics: `none`, `all`, or a positive integer.
465#[derive(Clone, Copy, Debug, PartialEq, Eq)]
466pub enum Mav2ForkTurns {
467    None,
468    All,
469    Turns(u64),
470}
471
472impl Mav2ForkTurns {
473    pub const fn as_str(self) -> &'static str {
474        match self {
475            Self::None => "none",
476            Self::All => "all",
477            Self::Turns(_) => "int",
478        }
479    }
480}
481
482#[derive(Debug, Error, PartialEq, Eq)]
483pub enum Mav2SpawnAgentError {
484    #[error("spawn_agent arguments must be a JSON object")]
485    InvalidArguments,
486    #[error("fork_context is rejected in MAv2; use fork_turns instead")]
487    ForkContextRejected,
488    #[error("fork_turns must be none, all, or a positive integer")]
489    InvalidForkTurns,
490    #[error("model override is not a valid model identifier")]
491    InvalidModelOverride,
492    #[error("reasoning_effort override is not a supported value")]
493    InvalidReasoningEffortOverride,
494}
495
496/// Supported reasoning-effort override values, mirroring the request-side
497/// reasoning normalization set.
498const MAV2_REASONING_EFFORT_VALUES: [&str; 6] =
499    ["none", "minimal", "low", "medium", "high", "xhigh"];
500
501/// Validates an MAv2 `spawn_agent` argument object deterministically before any
502/// visible output (PRD-001u R5.1/R5.2). `fork_context` is rejected in v2;
503/// `fork_turns` must be `none`, `all`, or a positive integer; `model` and
504/// `reasoning_effort` are optional per-child overrides.
505pub fn validate_mav2_spawn_agent_arguments(
506    arguments: &Value,
507) -> Result<Mav2SpawnAgentContract, Mav2SpawnAgentError> {
508    let object = arguments
509        .as_object()
510        .ok_or(Mav2SpawnAgentError::InvalidArguments)?;
511    if object.contains_key("fork_context") {
512        return Err(Mav2SpawnAgentError::ForkContextRejected);
513    }
514    let fork_turns = match object.get("fork_turns") {
515        None | Some(Value::Null) => Mav2ForkTurns::None,
516        Some(Value::String(value)) if value == "none" => Mav2ForkTurns::None,
517        Some(Value::String(value)) if value == "all" => Mav2ForkTurns::All,
518        Some(Value::Number(value)) => value
519            .as_u64()
520            .filter(|turns| *turns > 0)
521            .map(Mav2ForkTurns::Turns)
522            .ok_or(Mav2SpawnAgentError::InvalidForkTurns)?,
523        Some(Value::String(_)) => return Err(Mav2SpawnAgentError::InvalidForkTurns),
524        Some(Value::Bool(_)) | Some(Value::Array(_)) | Some(Value::Object(_)) => {
525            return Err(Mav2SpawnAgentError::InvalidForkTurns);
526        }
527    };
528    let model = match object.get("model") {
529        None | Some(Value::Null) => None,
530        Some(Value::String(value)) if valid_override_model(value) => Some(value.clone()),
531        Some(Value::String(_)) => return Err(Mav2SpawnAgentError::InvalidModelOverride),
532        Some(_) => return Err(Mav2SpawnAgentError::InvalidModelOverride),
533    };
534    let reasoning_effort = match object.get("reasoning_effort") {
535        None | Some(Value::Null) => None,
536        Some(Value::String(value)) if MAV2_REASONING_EFFORT_VALUES.contains(&value.as_str()) => {
537            Some(value.clone())
538        }
539        Some(Value::String(_)) => {
540            return Err(Mav2SpawnAgentError::InvalidReasoningEffortOverride);
541        }
542        Some(_) => return Err(Mav2SpawnAgentError::InvalidReasoningEffortOverride),
543    };
544    let task_name = object
545        .get("task_name")
546        .and_then(Value::as_str)
547        .unwrap_or_default()
548        .to_owned();
549    Ok(Mav2SpawnAgentContract {
550        task_name,
551        model,
552        reasoning_effort,
553        fork_turns,
554    })
555}
556
557fn valid_override_model(value: &str) -> bool {
558    !value.is_empty()
559        && value.len() <= 128
560        && value.bytes().all(|byte| {
561            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
562        })
563}
564
565pub fn normalize_responses_request(
566    body: &[u8],
567    identity: CodexRequestIdentity,
568) -> Result<CanonicalRequest, CodexWireError> {
569    let raw: Value = serde_json::from_slice(body).map_err(|_| CodexWireError {
570        kind: CodexWireErrorKind::InvalidRequest,
571        diagnostics: json!({
572            "schema": "codex_responses_0_145",
573            "body_type": "invalid_json"
574        }),
575    })?;
576    let diagnostics = structural_diagnostics(&raw);
577    let wire: ResponsesApiRequest = serde_json::from_value(raw)
578        .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::RequestShape))?;
579
580    normalize_wire(wire, identity, diagnostics)
581}
582
583fn normalize_wire(
584    wire: ResponsesApiRequest,
585    identity: CodexRequestIdentity,
586    diagnostics: Value,
587) -> Result<CanonicalRequest, CodexWireError> {
588    if !wire.stream {
589        return Err(wire_error_at(
590            CodexWireErrorKind::UnsupportedWireCombination,
591            diagnostics,
592            FailureStage::RequestStream,
593        ));
594    }
595    if wire.store {
596        return Err(wire_error_at(
597            CodexWireErrorKind::UnsupportedWireCombination,
598            diagnostics,
599            FailureStage::RequestStore,
600        ));
601    }
602    validate_stream_options(wire.stream_options.as_ref(), &diagnostics)?;
603    validate_include(&wire.include, &diagnostics)?;
604    validate_service_tier(wire.service_tier.as_deref(), &diagnostics)?;
605    let _cache_hint_was_supplied = wire.prompt_cache_key.is_some();
606    let _parallel_calls_permitted = wire.parallel_tool_calls;
607
608    let metadata = wire.client_metadata.as_ref();
609    let session = identity
610        .session_id
611        .as_deref()
612        .or_else(|| metadata.and_then(|fields| fields.get("session_id").map(String::as_str)))
613        .or(identity.thread_id.as_deref())
614        .or_else(|| metadata.and_then(|fields| fields.get("thread_id").map(String::as_str)))
615        .unwrap_or(identity.fallback_id.as_str());
616    let turn = metadata
617        .and_then(|fields| fields.get("turn_id").map(String::as_str))
618        .or(identity.thread_id.as_deref())
619        .or_else(|| metadata.and_then(|fields| fields.get("thread_id").map(String::as_str)))
620        .unwrap_or(session);
621
622    let harness_instance =
623        HarnessInstanceId::parse("codex-cli").map_err(|_| invalid(diagnostics.clone()))?;
624    let session_id = SessionId::parse(session).map_err(|_| invalid(diagnostics.clone()))?;
625    let turn_id = TurnId::parse(turn).map_err(|_| invalid(diagnostics.clone()))?;
626
627    let mut capabilities = CapabilityRequirements::default();
628    let mut input = Vec::new();
629    let mut declared_tools = wire.tools.unwrap_or_default();
630    if !wire.instructions.is_empty() {
631        input.push(InputItem::Message {
632            role: MessageRole::Developer,
633            content: vec![ContentBlock::Text {
634                text: wire.instructions,
635            }],
636        });
637    }
638    for (input_index, item) in wire.input.into_iter().enumerate() {
639        if item
640            .as_object()
641            .and_then(|object| object.get("type"))
642            .and_then(Value::as_str)
643            == Some("additional_tools")
644        {
645            declared_tools.extend(
646                extract_additional_tools(&item, &diagnostics)
647                    .map_err(|error| with_input_index(error, input_index))?,
648            );
649            continue;
650        }
651        input.push(
652            normalize_input_item(&item, &mut capabilities, &diagnostics)
653                .map_err(|error| with_input_index(error, input_index))?,
654        );
655    }
656
657    let tools = normalize_tools(declared_tools, &diagnostics)?;
658    let tool_choice = normalize_tool_choice(&wire.tool_choice, &diagnostics)?;
659    let reasoning = normalize_reasoning(wire.reasoning.as_ref(), &diagnostics)?;
660    capabilities.public_reasoning = reasoning == ReasoningIntent::PublicSummary;
661    let structured_output = normalize_text(wire.text.as_ref(), &diagnostics)?;
662    capabilities.structured_output = structured_output.is_some();
663
664    let request = CanonicalRequest {
665        wire_version: M1_WIRE_VERSION.to_owned(),
666        model: wire.model,
667        stream: true,
668        harness_instance,
669        session_id,
670        turn_id,
671        input,
672        tools,
673        tool_choice,
674        structured_output,
675        reasoning,
676        max_output_tokens: None,
677        max_spend_micro_usd: None,
678        capabilities,
679    };
680    request
681        .validate()
682        .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::CanonicalValidation))?;
683    Ok(request)
684}
685
686fn with_input_index(mut error: CodexWireError, input_index: usize) -> CodexWireError {
687    if let Some(object) = error.diagnostics.as_object_mut() {
688        object
689            .entry("failure_stage".to_owned())
690            .or_insert_with(|| Value::String(FailureStage::InputItemShape.as_str().to_owned()));
691        object.insert(
692            "input_index".to_owned(),
693            Value::from(input_index.min(255) as u64),
694        );
695    }
696    error
697}
698
699fn normalize_input_item(
700    value: &Value,
701    capabilities: &mut CapabilityRequirements,
702    diagnostics: &Value,
703) -> Result<InputItem, CodexWireError> {
704    let object = value
705        .as_object()
706        .ok_or_else(|| invalid(diagnostics.clone()))?;
707    let item_type = string_field(object, "type", diagnostics)?;
708    match item_type {
709        "message" => normalize_message(object, capabilities, diagnostics),
710        "agent_message" => normalize_agent_message(object, capabilities, diagnostics),
711        "function_call" => normalize_function_call(object, diagnostics),
712        "function_call_output" => normalize_function_output(object, diagnostics),
713        unsupported @ ("reasoning"
714        | "apply_patch_call"
715        | "apply_patch_call_output"
716        | "code_interpreter_call"
717        | "computer_call"
718        | "computer_call_output"
719        | "file_search_call"
720        | "item_reference"
721        | "local_shell_call"
722        | "local_shell_call_output"
723        | "custom_tool_call"
724        | "custom_tool_call_output"
725        | "tool_search_call"
726        | "tool_search_output"
727        | "web_search_call"
728        | "image_generation_call"
729        | "compaction"
730        | "compaction_summary"
731        | "compaction_trigger"
732        | "context_compaction"
733        | "mcp_call"
734        | "mcp_call_output"
735        | "mcp_approval_request"
736        | "mcp_approval_response"
737        | "mcp_list_tools"
738        | "mcp_tool_call"
739        | "mcp_tool_call_output"
740        | "shell_call"
741        | "shell_call_output") => Err(wire_error_at_input_item_type(
742            diagnostics.clone(),
743            unsupported,
744        )),
745        _ => Err(invalid(diagnostics.clone())),
746    }
747}
748
749fn extract_additional_tools(
750    value: &Value,
751    diagnostics: &Value,
752) -> Result<Vec<Value>, CodexWireError> {
753    let object = value
754        .as_object()
755        .ok_or_else(|| invalid(diagnostics.clone()))?;
756    ensure_only_keys(object, &["type", "role", "tools"], diagnostics)?;
757    if string_field(object, "role", diagnostics)? != "developer" {
758        return Err(invalid(diagnostics.clone()));
759    }
760    object
761        .get("tools")
762        .and_then(Value::as_array)
763        .cloned()
764        .ok_or_else(|| invalid(diagnostics.clone()))
765}
766
767fn normalize_agent_message(
768    object: &Map<String, Value>,
769    capabilities: &mut CapabilityRequirements,
770    diagnostics: &Value,
771) -> Result<InputItem, CodexWireError> {
772    if string_field(object, "role", diagnostics)? != "assistant" {
773        return Err(invalid(diagnostics.clone()));
774    }
775    normalize_message(object, capabilities, diagnostics)
776}
777
778fn normalize_message(
779    object: &Map<String, Value>,
780    capabilities: &mut CapabilityRequirements,
781    diagnostics: &Value,
782) -> Result<InputItem, CodexWireError> {
783    ensure_only_keys(
784        object,
785        &[
786            "type",
787            "id",
788            "role",
789            "content",
790            "phase",
791            "internal_chat_message_metadata_passthrough",
792        ],
793        diagnostics,
794    )
795    .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputItemShape))?;
796    let role = match string_field(object, "role", diagnostics)? {
797        "developer" => MessageRole::Developer,
798        "system" => MessageRole::System,
799        "user" => MessageRole::User,
800        "assistant" => MessageRole::Assistant,
801        _ => return Err(invalid(diagnostics.clone())),
802    };
803    let content = match object.get("content") {
804        Some(content) => content
805            .as_array()
806            .ok_or_else(|| invalid(diagnostics.clone()))?,
807        None if matches!(role, MessageRole::Developer | MessageRole::System) => {
808            return Ok(InputItem::Message {
809                role,
810                content: Vec::new(),
811            });
812        }
813        None => return Err(invalid(diagnostics.clone())),
814    };
815    let mut normalized = Vec::with_capacity(content.len());
816    for part in content {
817        let part = part
818            .as_object()
819            .ok_or_else(|| invalid(diagnostics.clone()))?;
820        match string_field(part, "type", diagnostics)? {
821            "input_text" | "output_text" => {
822                ensure_only_keys(part, &["type", "text"], diagnostics)?;
823                normalized.push(ContentBlock::Text {
824                    text: string_field(part, "text", diagnostics)?.to_owned(),
825                });
826            }
827            "input_image" => {
828                ensure_only_keys(part, &["type", "image_url", "detail"], diagnostics)?;
829                capabilities.images = true;
830                normalized.push(ContentBlock::ImageReference {
831                    url: string_field(part, "image_url", diagnostics)?.to_owned(),
832                });
833            }
834            "input_audio" => {
835                return Err(wire_error_at(
836                    CodexWireErrorKind::UnsupportedCapability,
837                    diagnostics.clone(),
838                    FailureStage::ContentType,
839                ));
840            }
841            _ => return Err(invalid(diagnostics.clone())),
842        }
843    }
844    Ok(InputItem::Message {
845        role,
846        content: normalized,
847    })
848}
849
850fn normalize_function_call(
851    object: &Map<String, Value>,
852    diagnostics: &Value,
853) -> Result<InputItem, CodexWireError> {
854    ensure_only_keys(
855        object,
856        &[
857            "type",
858            "id",
859            "name",
860            "namespace",
861            "arguments",
862            "call_id",
863            "internal_chat_message_metadata_passthrough",
864        ],
865        diagnostics,
866    )
867    .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputItemShape))?;
868    let namespace = object
869        .get("namespace")
870        .filter(|value| !value.is_null())
871        .map(|value| {
872            value
873                .as_str()
874                .ok_or_else(|| invalid(diagnostics.clone()))
875                .and_then(|namespace| {
876                    validate_tool_namespace(namespace).map_err(|_| {
877                        invalid_at(diagnostics.clone(), FailureStage::InputItemNamespace)
878                    })?;
879                    Ok(namespace.to_owned())
880                })
881        })
882        .transpose()?;
883    let raw_arguments = string_field(object, "arguments", diagnostics)
884        .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputFunctionArguments))?;
885    let arguments = serde_json::from_str(raw_arguments)
886        .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputFunctionArguments))?;
887    Ok(InputItem::FunctionCall {
888        call_id: CallId::parse(
889            string_field(object, "call_id", diagnostics)
890                .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputFunctionCallId))?,
891        )
892        .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputFunctionCallId))?,
893        namespace,
894        name: validated_tool_name(object, diagnostics)?,
895        arguments,
896    })
897}
898
899fn normalize_function_output(
900    object: &Map<String, Value>,
901    diagnostics: &Value,
902) -> Result<InputItem, CodexWireError> {
903    ensure_only_keys(
904        object,
905        &[
906            "type",
907            "id",
908            "call_id",
909            "output",
910            "internal_chat_message_metadata_passthrough",
911        ],
912        diagnostics,
913    )
914    .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputItemShape))?;
915    let output = object
916        .get("output")
917        .filter(|value| value.is_string() || value.is_array())
918        .cloned()
919        .ok_or_else(|| invalid_at(diagnostics.clone(), FailureStage::InputFunctionOutput))?;
920    Ok(InputItem::FunctionCallOutput {
921        call_id: CallId::parse(
922            string_field(object, "call_id", diagnostics)
923                .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputFunctionCallId))?,
924        )
925        .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::InputFunctionCallId))?,
926        output,
927    })
928}
929
930fn normalize_tools(
931    values: Vec<Value>,
932    diagnostics: &Value,
933) -> Result<Vec<FunctionTool>, CodexWireError> {
934    let mut tools = Vec::new();
935    let mut namespace_containers = HashSet::new();
936    for value in values {
937        let object = value
938            .as_object()
939            .ok_or_else(|| invalid(diagnostics.clone()))?;
940        match string_field(object, "type", diagnostics)? {
941            "function" => tools.push(normalize_function_tool(object, None, diagnostics)?),
942            "namespace" => {
943                ensure_only_keys(
944                    object,
945                    &["type", "name", "description", "tools"],
946                    diagnostics,
947                )?;
948                let namespace = string_field(object, "name", diagnostics)?;
949                validate_tool_namespace(namespace).map_err(|_| {
950                    invalid_at(diagnostics.clone(), FailureStage::ToolNamespaceName)
951                })?;
952                if !namespace_containers.insert(namespace.to_owned()) {
953                    return Err(invalid_at(diagnostics.clone(), FailureStage::ToolDuplicate));
954                }
955                string_field(object, "description", diagnostics)?;
956                let inner_tools = object
957                    .get("tools")
958                    .and_then(Value::as_array)
959                    .filter(|tools| !tools.is_empty())
960                    .ok_or_else(|| {
961                        invalid_at(diagnostics.clone(), FailureStage::ToolNamespaceShape)
962                    })?;
963                for inner in inner_tools {
964                    let inner = inner.as_object().ok_or_else(|| {
965                        invalid_at(diagnostics.clone(), FailureStage::ToolNamespaceShape)
966                    })?;
967                    if string_field(inner, "type", diagnostics)? != "function" {
968                        return Err(invalid_at(
969                            diagnostics.clone(),
970                            FailureStage::ToolNamespaceInnerType,
971                        ));
972                    }
973                    tools.push(normalize_function_tool(
974                        inner,
975                        Some(namespace.to_owned()),
976                        diagnostics,
977                    )?);
978                }
979            }
980            "custom" => {
981                return Err(wire_error_at(
982                    CodexWireErrorKind::UnsupportedCapability,
983                    diagnostics.clone(),
984                    FailureStage::ToolTypeCustom,
985                ));
986            }
987            "tool_search" => {
988                return Err(wire_error_at(
989                    CodexWireErrorKind::UnsupportedCapability,
990                    diagnostics.clone(),
991                    FailureStage::ToolTypeToolSearch,
992                ));
993            }
994            "web_search" => {
995                return Err(wire_error_at(
996                    CodexWireErrorKind::UnsupportedCapability,
997                    diagnostics.clone(),
998                    FailureStage::ToolTypeWebSearch,
999                ));
1000            }
1001            _ => {
1002                return Err(invalid_at(
1003                    diagnostics.clone(),
1004                    FailureStage::ToolTypeUnknown,
1005                ));
1006            }
1007        }
1008    }
1009    validate_flattened_tools(&tools, diagnostics)?;
1010    Ok(tools)
1011}
1012
1013fn normalize_function_tool(
1014    object: &Map<String, Value>,
1015    namespace: Option<String>,
1016    diagnostics: &Value,
1017) -> Result<FunctionTool, CodexWireError> {
1018    ensure_only_keys(object, TOOL_FIELDS, diagnostics)?;
1019    if object
1020        .get("defer_loading")
1021        .and_then(Value::as_bool)
1022        .unwrap_or(false)
1023    {
1024        return Err(wire_error_at(
1025            CodexWireErrorKind::UnsupportedCapability,
1026            diagnostics.clone(),
1027            FailureStage::ToolDeferLoading,
1028        ));
1029    }
1030    Ok(FunctionTool {
1031        namespace,
1032        name: validated_tool_name(object, diagnostics)?,
1033        description: string_field(object, "description", diagnostics)?.to_owned(),
1034        parameters: object
1035            .get("parameters")
1036            .cloned()
1037            .ok_or_else(|| invalid(diagnostics.clone()))?,
1038        strict: object
1039            .get("strict")
1040            .and_then(Value::as_bool)
1041            .ok_or_else(|| invalid(diagnostics.clone()))?,
1042    })
1043}
1044
1045fn validated_tool_name(
1046    object: &Map<String, Value>,
1047    diagnostics: &Value,
1048) -> Result<String, CodexWireError> {
1049    let name = string_field(object, "name", diagnostics)?;
1050    validate_tool_name(name)
1051        .map_err(|_| invalid_at(diagnostics.clone(), FailureStage::ToolName))?;
1052    Ok(name.to_owned())
1053}
1054
1055fn validate_flattened_tools(
1056    tools: &[FunctionTool],
1057    diagnostics: &Value,
1058) -> Result<(), CodexWireError> {
1059    let mut identities = HashSet::new();
1060    let direct_names = tools
1061        .iter()
1062        .filter(|tool| tool.namespace.is_none())
1063        .map(|tool| tool.name.as_str())
1064        .collect::<HashSet<_>>();
1065    for tool in tools {
1066        if !identities.insert((tool.namespace.as_deref(), tool.name.as_str())) {
1067            return Err(invalid_at(diagnostics.clone(), FailureStage::ToolDuplicate));
1068        }
1069        if tool
1070            .namespace
1071            .as_deref()
1072            .is_some_and(|namespace| direct_names.contains(namespace))
1073        {
1074            return Err(invalid_at(
1075                diagnostics.clone(),
1076                FailureStage::ToolReservedCollision,
1077            ));
1078        }
1079    }
1080    Ok(())
1081}
1082
1083fn normalize_tool_choice(value: &str, diagnostics: &Value) -> Result<ToolChoice, CodexWireError> {
1084    match value {
1085        "auto" => Ok(ToolChoice::Auto),
1086        "none" => Ok(ToolChoice::None),
1087        "required" => Ok(ToolChoice::Required),
1088        _ => Err(wire_error_at(
1089            CodexWireErrorKind::UnsupportedWireCombination,
1090            diagnostics.clone(),
1091            FailureStage::ToolChoice,
1092        )),
1093    }
1094}
1095
1096fn normalize_reasoning(
1097    value: Option<&Value>,
1098    diagnostics: &Value,
1099) -> Result<ReasoningIntent, CodexWireError> {
1100    let Some(value) = value else {
1101        return Ok(ReasoningIntent::None);
1102    };
1103    let object = value
1104        .as_object()
1105        .ok_or_else(|| invalid_at(diagnostics.clone(), FailureStage::Reasoning))?;
1106    ensure_only_keys(object, &["effort", "summary", "context"], diagnostics)?;
1107    validate_optional_enum(
1108        object.get("effort"),
1109        &["none", "minimal", "low", "medium", "high", "xhigh"],
1110        diagnostics,
1111        FailureStage::ReasoningEffort,
1112    )?;
1113    validate_optional_enum(
1114        object.get("context"),
1115        &["auto", "current_turn", "all_turns"],
1116        diagnostics,
1117        FailureStage::ReasoningContext,
1118    )?;
1119    let summary = object.get("summary");
1120    validate_optional_enum(
1121        summary,
1122        &["none", "auto", "concise", "detailed"],
1123        diagnostics,
1124        FailureStage::ReasoningSummary,
1125    )?;
1126    Ok(match summary.and_then(Value::as_str) {
1127        Some("auto" | "concise" | "detailed") => ReasoningIntent::PublicSummary,
1128        Some("none") | None => ReasoningIntent::None,
1129        Some(_) => {
1130            return Err(invalid_at(
1131                diagnostics.clone(),
1132                FailureStage::ReasoningSummary,
1133            ));
1134        }
1135    })
1136}
1137
1138fn normalize_text(
1139    value: Option<&Value>,
1140    diagnostics: &Value,
1141) -> Result<Option<StructuredOutputIntent>, CodexWireError> {
1142    let Some(value) = value else {
1143        return Ok(None);
1144    };
1145    let object = value
1146        .as_object()
1147        .ok_or_else(|| invalid_at(diagnostics.clone(), FailureStage::Text))?;
1148    ensure_only_keys(object, &["verbosity", "format"], diagnostics)?;
1149    validate_optional_enum(
1150        object.get("verbosity"),
1151        &["low", "medium", "high"],
1152        diagnostics,
1153        FailureStage::TextVerbosity,
1154    )?;
1155    let Some(format) = object.get("format").filter(|value| !value.is_null()) else {
1156        return Ok(None);
1157    };
1158    let format = format
1159        .as_object()
1160        .ok_or_else(|| invalid_at(diagnostics.clone(), FailureStage::TextFormat))?;
1161    match string_field(format, "type", diagnostics)? {
1162        "text" => {
1163            ensure_only_keys(format, &["type"], diagnostics)?;
1164            return Ok(None);
1165        }
1166        "json_schema" => {
1167            ensure_only_keys(format, &["type", "strict", "schema", "name"], diagnostics)?;
1168        }
1169        _ => {
1170            return Err(wire_error_at(
1171                CodexWireErrorKind::UnsupportedCapability,
1172                diagnostics.clone(),
1173                FailureStage::TextFormatType,
1174            ));
1175        }
1176    }
1177    Ok(Some(StructuredOutputIntent {
1178        name: string_field(format, "name", diagnostics)?.to_owned(),
1179        schema: format
1180            .get("schema")
1181            .cloned()
1182            .ok_or_else(|| invalid(diagnostics.clone()))?,
1183        strict: format
1184            .get("strict")
1185            .and_then(Value::as_bool)
1186            .ok_or_else(|| invalid(diagnostics.clone()))?,
1187    }))
1188}
1189
1190fn validate_stream_options(
1191    value: Option<&Value>,
1192    diagnostics: &Value,
1193) -> Result<(), CodexWireError> {
1194    let Some(value) = value else {
1195        return Ok(());
1196    };
1197    let object = value
1198        .as_object()
1199        .ok_or_else(|| invalid(diagnostics.clone()))?;
1200    ensure_only_keys(object, &["reasoning_summary_delivery"], diagnostics)?;
1201    if string_field(object, "reasoning_summary_delivery", diagnostics)? != "sequential_cutoff" {
1202        return Err(wire_error_at(
1203            CodexWireErrorKind::UnsupportedWireCombination,
1204            diagnostics.clone(),
1205            FailureStage::StreamOptions,
1206        ));
1207    }
1208    Ok(())
1209}
1210
1211fn validate_include(values: &[String], diagnostics: &Value) -> Result<(), CodexWireError> {
1212    if values
1213        .iter()
1214        .all(|value| value == "reasoning.encrypted_content")
1215    {
1216        Ok(())
1217    } else {
1218        Err(wire_error_at(
1219            CodexWireErrorKind::UnsupportedCapability,
1220            diagnostics.clone(),
1221            FailureStage::Include,
1222        ))
1223    }
1224}
1225
1226fn validate_service_tier(value: Option<&str>, diagnostics: &Value) -> Result<(), CodexWireError> {
1227    match value {
1228        None | Some("auto" | "default") => Ok(()),
1229        Some(_) => Err(wire_error_at(
1230            CodexWireErrorKind::UnsupportedCapability,
1231            diagnostics.clone(),
1232            FailureStage::ServiceTier,
1233        )),
1234    }
1235}
1236
1237fn validate_optional_enum(
1238    value: Option<&Value>,
1239    allowed: &[&str],
1240    diagnostics: &Value,
1241    failure_stage: FailureStage,
1242) -> Result<(), CodexWireError> {
1243    match value {
1244        None | Some(Value::Null) => Ok(()),
1245        Some(Value::String(value)) if allowed.contains(&value.as_str()) => Ok(()),
1246        Some(_) => Err(invalid_at(diagnostics.clone(), failure_stage)),
1247    }
1248}
1249
1250fn string_field<'a>(
1251    object: &'a Map<String, Value>,
1252    field: &str,
1253    diagnostics: &Value,
1254) -> Result<&'a str, CodexWireError> {
1255    object
1256        .get(field)
1257        .and_then(Value::as_str)
1258        .ok_or_else(|| invalid(diagnostics.clone()))
1259}
1260
1261fn ensure_only_keys(
1262    object: &Map<String, Value>,
1263    allowed: &[&str],
1264    diagnostics: &Value,
1265) -> Result<(), CodexWireError> {
1266    if object.keys().all(|key| allowed.contains(&key.as_str())) {
1267        Ok(())
1268    } else {
1269        Err(invalid(diagnostics.clone()))
1270    }
1271}
1272
1273fn invalid(diagnostics: Value) -> CodexWireError {
1274    wire_error(CodexWireErrorKind::InvalidRequest, diagnostics)
1275}
1276
1277fn invalid_at(diagnostics: Value, failure_stage: FailureStage) -> CodexWireError {
1278    wire_error_at(
1279        CodexWireErrorKind::InvalidRequest,
1280        diagnostics,
1281        failure_stage,
1282    )
1283}
1284
1285fn wire_error(kind: CodexWireErrorKind, diagnostics: Value) -> CodexWireError {
1286    CodexWireError { kind, diagnostics }
1287}
1288
1289fn wire_error_at(
1290    kind: CodexWireErrorKind,
1291    mut diagnostics: Value,
1292    failure_stage: FailureStage,
1293) -> CodexWireError {
1294    if let Some(object) = diagnostics.as_object_mut() {
1295        object.insert(
1296            "failure_stage".to_owned(),
1297            Value::String(failure_stage.as_str().to_owned()),
1298        );
1299    }
1300    CodexWireError { kind, diagnostics }
1301}
1302
1303fn wire_error_at_input_item_type(mut diagnostics: Value, item_type: &str) -> CodexWireError {
1304    if let Some(object) = diagnostics.as_object_mut() {
1305        object.insert(
1306            "input_item_type".to_owned(),
1307            Value::String(item_type.to_owned()),
1308        );
1309    }
1310    wire_error_at(
1311        CodexWireErrorKind::UnsupportedCapability,
1312        diagnostics,
1313        FailureStage::InputItemType,
1314    )
1315}
1316
1317fn structural_diagnostics(value: &Value) -> Value {
1318    let Some(object) = value.as_object() else {
1319        return json!({
1320            "schema": "codex_responses_0_145",
1321            "body_type": json_type(value)
1322        });
1323    };
1324    let top_level_fields = field_types(object, TOP_LEVEL_FIELDS);
1325    let unknown_top_level_field_count = object
1326        .keys()
1327        .filter(|key| !TOP_LEVEL_FIELDS.contains(&key.as_str()))
1328        .count();
1329    let input_item_shapes = array_shapes(object.get("input"), ITEM_FIELDS);
1330    let tool_shapes = array_shapes(object.get("tools"), TOOL_FIELDS);
1331    json!({
1332        "schema": "codex_responses_0_145",
1333        "body_type": "object",
1334        "top_level_fields": top_level_fields,
1335        "unknown_top_level_field_count": unknown_top_level_field_count,
1336        "input_item_shapes": input_item_shapes,
1337        "tool_shapes": tool_shapes
1338    })
1339}
1340
1341fn array_shapes(value: Option<&Value>, allowed: &[&str]) -> Vec<Value> {
1342    value
1343        .and_then(Value::as_array)
1344        .into_iter()
1345        .flatten()
1346        .take(8)
1347        .map(|item| match item.as_object() {
1348            Some(object) => Value::Array(field_types(object, allowed)),
1349            None => json!({ "item_type": json_type(item) }),
1350        })
1351        .collect()
1352}
1353
1354fn field_types(object: &Map<String, Value>, allowed: &[&str]) -> Vec<Value> {
1355    allowed
1356        .iter()
1357        .filter_map(|field| {
1358            object.get(*field).map(|value| {
1359                json!({
1360                    "field": field,
1361                    "json_type": json_type(value)
1362                })
1363            })
1364        })
1365        .collect()
1366}
1367
1368fn json_type(value: &Value) -> &'static str {
1369    match value {
1370        Value::Null => "null",
1371        Value::Bool(_) => "boolean",
1372        Value::Number(_) => "number",
1373        Value::String(_) => "string",
1374        Value::Array(_) => "array",
1375        Value::Object(_) => "object",
1376    }
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::*;
1382
1383    fn identity() -> CodexRequestIdentity {
1384        CodexRequestIdentity {
1385            session_id: Some("session-header".to_owned()),
1386            thread_id: Some("thread-header".to_owned()),
1387            fallback_id: "request-fallback".to_owned(),
1388        }
1389    }
1390
1391    fn codex_request() -> Value {
1392        json!({
1393            "model": "router-auto",
1394            "instructions": "instruction-canary",
1395            "input": [{
1396                "type": "message",
1397                "role": "user",
1398                "content": [{"type": "input_text", "text": "prompt-canary"}]
1399            }],
1400            "tools": [{
1401                "type": "function",
1402                "name": "get_weather",
1403                "description": "description-canary",
1404                "strict": true,
1405                "parameters": {"type": "object", "properties": {}}
1406            }],
1407            "tool_choice": "auto",
1408            "parallel_tool_calls": true,
1409            "reasoning": {"effort": "low", "summary": null},
1410            "store": false,
1411            "stream": true,
1412            "include": ["reasoning.encrypted_content"],
1413            "prompt_cache_key": "cache-canary",
1414            "text": {"verbosity": "medium"},
1415            "client_metadata": {
1416                "session_id": "metadata-session",
1417                "thread_id": "metadata-thread",
1418                "turn_id": "metadata-turn",
1419                "x-codex-turn-metadata": "metadata-canary"
1420            }
1421        })
1422    }
1423
1424    #[test]
1425    fn normalizes_real_codex_0_145_shape_without_losing_supported_intent() {
1426        let body = serde_json::to_vec(&codex_request()).expect("serialize fixture");
1427        let request = normalize_responses_request(&body, identity()).expect("normalize fixture");
1428        assert_eq!(request.model, "router-auto");
1429        assert_eq!(request.harness_instance.as_str(), "codex-cli");
1430        assert_eq!(request.session_id.as_str(), "session-header");
1431        assert_eq!(request.turn_id.as_str(), "metadata-turn");
1432        assert_eq!(request.input.len(), 2);
1433        assert_eq!(request.tools.len(), 1);
1434        assert_eq!(request.tools[0].name, "get_weather");
1435    }
1436
1437    #[test]
1438    fn accepts_responses_lite_empty_developer_message_without_widening_user_messages() {
1439        let mut request = codex_request();
1440        request["instructions"] = Value::String(String::new());
1441        request["input"]
1442            .as_array_mut()
1443            .expect("fixture input")
1444            .insert(0, json!({"type": "message", "role": "developer"}));
1445        let body = serde_json::to_vec(&request).expect("serialize fixture");
1446        let normalized =
1447            normalize_responses_request(&body, identity()).expect("empty developer item");
1448        assert!(matches!(
1449            normalized.input.first(),
1450            Some(InputItem::Message {
1451                role: MessageRole::Developer,
1452                content
1453            }) if content.is_empty()
1454        ));
1455
1456        request["input"][0] = json!({"type": "message", "role": "user"});
1457        let body = serde_json::to_vec(&request).expect("serialize invalid fixture");
1458        normalize_responses_request(&body, identity())
1459            .expect_err("contentless user message remains invalid");
1460    }
1461
1462    #[test]
1463    fn accepts_current_agent_message_only_as_assistant_history() {
1464        let mut request = codex_request();
1465        request["input"] = json!([{
1466            "type": "agent_message",
1467            "id": "agent-history-id",
1468            "role": "assistant",
1469            "content": [{"type": "output_text", "text": "prior answer"}]
1470        }]);
1471        let body = serde_json::to_vec(&request).expect("serialize fixture");
1472        let normalized =
1473            normalize_responses_request(&body, identity()).expect("agent message history");
1474        assert!(normalized.input.iter().any(|item| matches!(
1475            item,
1476            InputItem::Message {
1477                role: MessageRole::Assistant,
1478                content
1479            } if matches!(content.as_slice(), [ContentBlock::Text { text }] if text == "prior answer")
1480        )));
1481
1482        request["input"][0]["role"] = Value::String("developer".to_owned());
1483        let body = serde_json::to_vec(&request).expect("serialize invalid fixture");
1484        normalize_responses_request(&body, identity())
1485            .expect_err("agent message cannot widen to developer role");
1486    }
1487
1488    #[test]
1489    fn additional_tools_are_developer_scoped_and_join_declared_tools() {
1490        let mut request = codex_request();
1491        let tools = request["tools"].take();
1492        request["tools"] = Value::Null;
1493        request["input"] = json!([{
1494            "type": "additional_tools",
1495            "role": "developer",
1496            "tools": tools
1497        }, {
1498            "type": "message",
1499            "role": "user",
1500            "content": [{"type": "input_text", "text": "prompt"}]
1501        }]);
1502        let body = serde_json::to_vec(&request).expect("serialize fixture");
1503        let normalized =
1504            normalize_responses_request(&body, identity()).expect("additional tool declarations");
1505        assert_eq!(normalized.tools.len(), 1);
1506        assert_eq!(normalized.tools[0].name, "get_weather");
1507        assert!(!normalized.input.iter().any(|item| matches!(
1508            item,
1509            InputItem::Message {
1510                role: MessageRole::Developer,
1511                content
1512            } if content.is_empty()
1513        )));
1514
1515        request["input"][0]["role"] = Value::String("user".to_owned());
1516        let body = serde_json::to_vec(&request).expect("serialize invalid fixture");
1517        normalize_responses_request(&body, identity())
1518            .expect_err("additional tools cannot be user-scoped");
1519
1520        request["input"][0] = json!({
1521            "type": "additional_tools",
1522            "role": "developer",
1523            "tools": []
1524        });
1525        let body = serde_json::to_vec(&request).expect("serialize empty declaration");
1526        let normalized =
1527            normalize_responses_request(&body, identity()).expect("empty declaration is a no-op");
1528        assert!(normalized.tools.is_empty());
1529    }
1530
1531    #[test]
1532    fn diagnostics_contain_only_allowlisted_names_and_json_types() {
1533        let mut request = codex_request();
1534        request["unexpected-secret-field-name"] = Value::String("value-canary".to_owned());
1535        let body = serde_json::to_vec(&request).expect("serialize fixture");
1536        let error =
1537            normalize_responses_request(&body, identity()).expect_err("reject unknown field");
1538        let rendered = serde_json::to_string(&error.diagnostics).expect("render diagnostics");
1539        for canary in [
1540            "instruction-canary",
1541            "prompt-canary",
1542            "description-canary",
1543            "cache-canary",
1544            "metadata-canary",
1545            "value-canary",
1546            "unexpected-secret-field-name",
1547        ] {
1548            assert!(!rendered.contains(canary), "diagnostics leaked {canary}");
1549        }
1550        assert!(rendered.contains("top_level_fields"));
1551        assert!(rendered.contains("json_type"));
1552        assert!(rendered.contains("unknown_top_level_field_count"));
1553    }
1554
1555    #[test]
1556    fn unsupported_semantics_fail_closed() {
1557        let mut request = codex_request();
1558        request["tools"][0]["type"] = Value::String("custom".to_owned());
1559        let body = serde_json::to_vec(&request).expect("serialize fixture");
1560        let error = normalize_responses_request(&body, identity()).expect_err("reject custom tool");
1561        assert_eq!(error.kind, CodexWireErrorKind::UnsupportedCapability);
1562        assert_eq!(
1563            error.diagnostics["failure_stage"],
1564            "codex_wire.tool.type_custom"
1565        );
1566        let rendered = serde_json::to_string(&error.diagnostics).expect("render diagnostics");
1567        for canary in [
1568            "instruction-canary",
1569            "prompt-canary",
1570            "description-canary",
1571            "cache-canary",
1572            "metadata-canary",
1573        ] {
1574            assert!(!rendered.contains(canary), "diagnostics leaked {canary}");
1575        }
1576
1577        let mut request = codex_request();
1578        request["store"] = Value::Bool(true);
1579        let body = serde_json::to_vec(&request).expect("serialize fixture");
1580        let error = normalize_responses_request(&body, identity()).expect_err("reject store");
1581        assert_eq!(error.kind, CodexWireErrorKind::UnsupportedWireCombination);
1582        assert_eq!(
1583            error.diagnostics["failure_stage"],
1584            "codex_wire.request.store"
1585        );
1586    }
1587
1588    #[test]
1589    fn failure_stages_are_a_bounded_allowlist() {
1590        let stages = FailureStage::ALL.map(FailureStage::as_str);
1591        assert_eq!(stages.len(), 34);
1592        for (index, stage) in stages.iter().enumerate() {
1593            assert!(stage.starts_with("codex_wire."));
1594            assert!(stage.len() <= 64);
1595            assert!(
1596                stage
1597                    .bytes()
1598                    .all(|byte| { byte.is_ascii_lowercase() || byte == b'.' || byte == b'_' })
1599            );
1600            assert!(!stages[..index].contains(stage), "duplicate failure stage");
1601        }
1602    }
1603
1604    #[test]
1605    fn tool_type_failures_use_specific_fixed_stages_without_echoing_values() {
1606        for (tool_type, expected_stage) in [
1607            ("custom", "codex_wire.tool.type_custom"),
1608            ("tool_search", "codex_wire.tool.type_tool_search"),
1609            ("web_search", "codex_wire.tool.type_web_search"),
1610        ] {
1611            let mut request = codex_request();
1612            request["tools"][0]["type"] = Value::String(tool_type.to_owned());
1613            let body = serde_json::to_vec(&request).expect("serialize fixture");
1614            let error = normalize_responses_request(&body, identity())
1615                .expect_err("known unsupported tool type");
1616            assert_eq!(error.kind, CodexWireErrorKind::UnsupportedCapability);
1617            assert_eq!(error.diagnostics["failure_stage"], expected_stage);
1618        }
1619
1620        let mut request = codex_request();
1621        request["tools"][0]["type"] = Value::String("unknown-type-value-canary".to_owned());
1622        let body = serde_json::to_vec(&request).expect("serialize fixture");
1623        let error = normalize_responses_request(&body, identity()).expect_err("unknown tool type");
1624        assert_eq!(error.kind, CodexWireErrorKind::InvalidRequest);
1625        assert_eq!(
1626            error.diagnostics["failure_stage"],
1627            "codex_wire.tool.type_unknown"
1628        );
1629        let rendered = serde_json::to_string(&error.diagnostics).expect("render diagnostics");
1630        assert!(!rendered.contains("unknown-type-value-canary"));
1631    }
1632
1633    #[test]
1634    fn unsupported_input_types_emit_only_fixed_discriminators() {
1635        for item_type in [
1636            "reasoning",
1637            "apply_patch_call",
1638            "apply_patch_call_output",
1639            "code_interpreter_call",
1640            "computer_call",
1641            "computer_call_output",
1642            "file_search_call",
1643            "item_reference",
1644            "local_shell_call",
1645            "local_shell_call_output",
1646            "custom_tool_call",
1647            "custom_tool_call_output",
1648            "tool_search_call",
1649            "tool_search_output",
1650            "web_search_call",
1651            "image_generation_call",
1652            "compaction",
1653            "compaction_summary",
1654            "compaction_trigger",
1655            "context_compaction",
1656            "mcp_call",
1657            "mcp_call_output",
1658            "mcp_approval_request",
1659            "mcp_approval_response",
1660            "mcp_list_tools",
1661            "mcp_tool_call",
1662            "mcp_tool_call_output",
1663            "shell_call",
1664            "shell_call_output",
1665        ] {
1666            let mut request = codex_request();
1667            request["input"] = json!([{ "type": item_type }]);
1668            let body = serde_json::to_vec(&request).expect("serialize fixture");
1669            let error = normalize_responses_request(&body, identity())
1670                .expect_err("known unsupported input type");
1671            assert_eq!(error.kind, CodexWireErrorKind::UnsupportedCapability);
1672            assert_eq!(error.diagnostics["input_item_type"], item_type);
1673            assert_eq!(error.diagnostics["input_index"], 0);
1674            assert_eq!(
1675                error.diagnostics["failure_stage"],
1676                "codex_wire.input_item.type"
1677            );
1678        }
1679    }
1680
1681    #[test]
1682    fn namespace_tools_are_flattened_with_identity_and_round_trip() {
1683        let mut request = codex_request();
1684        request["tools"] = json!([{
1685            "type": "namespace",
1686            "name": "collaboration",
1687            "description": "namespace-description-canary",
1688            "tools": [{
1689                "type": "function",
1690                "name": "spawn_agent",
1691                "description": "inner-description-canary",
1692                "strict": true,
1693                "parameters": {
1694                    "type": "object",
1695                    "properties": {"task_name": {"type": "string"}},
1696                    "required": ["task_name"],
1697                    "additionalProperties": false
1698                }
1699            }]
1700        }]);
1701        let body = serde_json::to_vec(&request).expect("serialize namespace fixture");
1702        let request = normalize_responses_request(&body, identity()).expect("namespace tool");
1703        assert_eq!(request.tools.len(), 1);
1704        assert_eq!(request.tools[0].namespace.as_deref(), Some("collaboration"));
1705        assert_eq!(request.tools[0].name, "spawn_agent");
1706        assert_eq!(
1707            request.tools[0].parameters,
1708            json!({
1709                "type": "object",
1710                "properties": {"task_name": {"type": "string"}},
1711                "required": ["task_name"],
1712                "additionalProperties": false
1713            })
1714        );
1715        let round_trip = serde_json::to_value(&request).expect("canonical serialization");
1716        assert_eq!(round_trip["tools"][0]["namespace"], "collaboration");
1717        assert_eq!(
1718            round_trip["tools"][0]["parameters"],
1719            request.tools[0].parameters
1720        );
1721    }
1722
1723    #[test]
1724    fn namespaced_function_call_history_preserves_namespace_and_arguments() {
1725        let mut request = codex_request();
1726        request["input"] = json!([{
1727            "type": "function_call",
1728            "call_id": "call-namespace-history",
1729            "namespace": "collaboration",
1730            "name": "spawn_agent",
1731            "arguments": "{\"task\":\"fixture\"}"
1732        }]);
1733        let body = serde_json::to_vec(&request).expect("serialize function call history");
1734        let request =
1735            normalize_responses_request(&body, identity()).expect("function call history");
1736        assert!(request.input.iter().any(|item| matches!(
1737            item,
1738            InputItem::FunctionCall {
1739                namespace: Some(namespace),
1740                name,
1741                arguments,
1742                ..
1743            } if namespace == "collaboration"
1744                && name == "spawn_agent"
1745                && arguments == &json!({"task": "fixture"})
1746        )));
1747    }
1748
1749    #[test]
1750    fn namespace_tools_reject_duplicates_collisions_and_deferred_semantics() {
1751        let namespace = json!({
1752            "type": "namespace",
1753            "name": "collaboration",
1754            "description": "namespace description",
1755            "tools": [{
1756                "type": "function",
1757                "name": "spawn_agent",
1758                "description": "inner description",
1759                "strict": true,
1760                "parameters": {"type": "object", "properties": {}}
1761            }]
1762        });
1763
1764        let mut request = codex_request();
1765        request["tools"] = json!([namespace.clone(), namespace.clone()]);
1766        let body = serde_json::to_vec(&request).expect("serialize duplicate namespace");
1767        let error =
1768            normalize_responses_request(&body, identity()).expect_err("duplicate namespace");
1769        assert_eq!(
1770            error.diagnostics["failure_stage"],
1771            "codex_wire.tool.duplicate"
1772        );
1773
1774        let mut request = codex_request();
1775        request["tools"] = json!([{
1776            "type": "function",
1777            "name": "collaboration",
1778            "description": "direct collision",
1779            "strict": true,
1780            "parameters": {"type": "object", "properties": {}}
1781        }, namespace.clone()]);
1782        let body = serde_json::to_vec(&request).expect("serialize collision");
1783        let error = normalize_responses_request(&body, identity()).expect_err("reserved collision");
1784        assert_eq!(
1785            error.diagnostics["failure_stage"],
1786            "codex_wire.tool.reserved_collision"
1787        );
1788
1789        let mut deferred = namespace;
1790        deferred["tools"][0]["defer_loading"] = Value::Bool(true);
1791        let mut request = codex_request();
1792        request["tools"] = json!([deferred]);
1793        let body = serde_json::to_vec(&request).expect("serialize deferred tool");
1794        let error = normalize_responses_request(&body, identity()).expect_err("deferred namespace");
1795        assert_eq!(error.kind, CodexWireErrorKind::UnsupportedCapability);
1796        assert_eq!(
1797            error.diagnostics["failure_stage"],
1798            "codex_wire.tool.defer_loading"
1799        );
1800    }
1801
1802    #[test]
1803    fn namespace_and_inner_names_are_bounded_identifiers() {
1804        let mut request = codex_request();
1805        request["tools"] = json!([{
1806            "type": "namespace",
1807            "name": "namespace",
1808            "description": "reserved namespace",
1809            "tools": [{
1810                "type": "function",
1811                "name": "tool",
1812                "description": "description",
1813                "strict": true,
1814                "parameters": {}
1815            }]
1816        }]);
1817        let body = serde_json::to_vec(&request).expect("serialize reserved namespace");
1818        let error = normalize_responses_request(&body, identity()).expect_err("reserved namespace");
1819        assert_eq!(
1820            error.diagnostics["failure_stage"],
1821            "codex_wire.tool.namespace.name"
1822        );
1823
1824        request["tools"][0]["name"] = Value::String("valid_namespace".to_owned());
1825        request["tools"][0]["tools"][0]["name"] = Value::String("x".repeat(65));
1826        let body = serde_json::to_vec(&request).expect("serialize long tool name");
1827        let error = normalize_responses_request(&body, identity()).expect_err("long tool name");
1828        assert_eq!(error.diagnostics["failure_stage"], "codex_wire.tool.name");
1829    }
1830
1831    #[test]
1832    fn accepts_exact_plain_text_format_without_creating_structured_output_intent() {
1833        let mut request = codex_request();
1834        request["text"] = json!({
1835            "format": {"type": "text"}
1836        });
1837        let body = serde_json::to_vec(&request).expect("serialize fixture");
1838        let request = normalize_responses_request(&body, identity()).expect("plain text format");
1839        assert_eq!(request.structured_output, None);
1840        assert!(!request.capabilities.structured_output);
1841
1842        let mut request = codex_request();
1843        request["text"] = json!({
1844            "format": {"type": "text", "schema": {}}
1845        });
1846        let body = serde_json::to_vec(&request).expect("serialize fixture");
1847        let error = normalize_responses_request(&body, identity())
1848            .expect_err("plain text format must reject extra semantics");
1849        assert_eq!(error.kind, CodexWireErrorKind::InvalidRequest);
1850    }
1851
1852    #[test]
1853    fn provider_tool_call_normalization_round_trips_flattened_namespaces() {
1854        assert_eq!(
1855            normalize_provider_tool_call("multi_agent_v1__spawn_agent", &json!({})),
1856            (Some("multi_agent_v1".to_owned()), "spawn_agent".to_owned())
1857        );
1858        assert_eq!(
1859            normalize_provider_tool_call("multi_agent_v1__send_input", &json!({})),
1860            (Some("multi_agent_v1".to_owned()), "send_input".to_owned())
1861        );
1862        assert_eq!(
1863            normalize_provider_tool_call("weather__lookup", &json!({})),
1864            (Some("weather".to_owned()), "lookup".to_owned())
1865        );
1866    }
1867
1868    #[test]
1869    fn canonical_multi_agent_context_is_detected_with_fixed_owner_limits() {
1870        let mut request = codex_request();
1871        request["tools"] = json!([{
1872            "type": "namespace",
1873            "name": "multi_agent_v1",
1874            "description": "canonical multi-agent namespace",
1875            "tools": [{
1876                "type": "function",
1877                "name": "spawn_agent",
1878                "description": "spawn an agent",
1879                "strict": true,
1880                "parameters": {"type": "object", "properties": {}}
1881            }]
1882        }]);
1883        let body = serde_json::to_vec(&request).expect("serialize fixture");
1884        let canonical =
1885            normalize_responses_request(&body, identity()).expect("normalize canonical context");
1886        assert_eq!(
1887            canonical_subagent_request_context(&canonical),
1888            Some(CodexSubagentRequestContext {
1889                max_threads: 4,
1890                max_depth: 2,
1891                fork_turns: "none",
1892            })
1893        );
1894
1895        let ordinary = normalize_responses_request(
1896            &serde_json::to_vec(&codex_request()).expect("serialize ordinary fixture"),
1897            identity(),
1898        )
1899        .expect("normalize ordinary fixture");
1900        assert_eq!(canonical_subagent_request_context(&ordinary), None);
1901    }
1902
1903    #[test]
1904    fn provider_tool_call_normalization_disambiguates_bare_multi_agent_by_argument_shape() {
1905        assert_eq!(
1906            normalize_provider_tool_call(
1907                "multi_agent_v1",
1908                &json!({"task_name": "t", "message": "do it"})
1909            ),
1910            (Some("multi_agent_v1".to_owned()), "spawn_agent".to_owned())
1911        );
1912        assert_eq!(
1913            normalize_provider_tool_call(
1914                "multi_agent_v1",
1915                &json!({"target": "agent-1", "message": "continue"})
1916            ),
1917            (Some("multi_agent_v1".to_owned()), "send_input".to_owned())
1918        );
1919        assert_eq!(
1920            normalize_provider_tool_call(
1921                "multi_agent_v1",
1922                &json!({"target": "agent-1", "items": []})
1923            ),
1924            (Some("multi_agent_v1".to_owned()), "send_input".to_owned())
1925        );
1926        assert_eq!(
1927            normalize_provider_tool_call(
1928                "multi_agent_v1",
1929                &json!({"target": "agent-1", "interrupt": true})
1930            ),
1931            (Some("multi_agent_v1".to_owned()), "send_input".to_owned())
1932        );
1933        assert_eq!(
1934            normalize_provider_tool_call("multi_agent_v1", &json!({"id": "agent-1"})),
1935            (Some("multi_agent_v1".to_owned()), "resume_agent".to_owned())
1936        );
1937        assert_eq!(
1938            normalize_provider_tool_call(
1939                "multi_agent_v1",
1940                &json!({"target": "agent-1", "timeout_ms": 30_000})
1941            ),
1942            (Some("multi_agent_v1".to_owned()), "wait_agent".to_owned())
1943        );
1944        assert_eq!(
1945            normalize_provider_tool_call("multi_agent_v1", &json!({"target": "agent-1"})),
1946            (Some("multi_agent_v1".to_owned()), "close_agent".to_owned())
1947        );
1948        assert_eq!(
1949            normalize_provider_tool_call("multi_agent_v1", &json!({})),
1950            (Some("multi_agent_v1".to_owned()), "spawn_agent".to_owned())
1951        );
1952    }
1953
1954    #[test]
1955    fn provider_tool_call_normalization_leaves_plain_and_invalid_names_unchanged() {
1956        assert_eq!(
1957            normalize_provider_tool_call("get_weather", &json!({})),
1958            (None, "get_weather".to_owned())
1959        );
1960        assert_eq!(
1961            normalize_provider_tool_call("function__lookup", &json!({})),
1962            (None, "function__lookup".to_owned())
1963        );
1964        assert_eq!(
1965            normalize_provider_tool_call("multi_agent_v1__", &json!({})),
1966            (None, "multi_agent_v1__".to_owned())
1967        );
1968    }
1969
1970    #[test]
1971    fn four_route_request_side_translation_round_trips_through_normalization() {
1972        // Request-side translation flattens `namespace__name` for backends that
1973        // cannot represent namespaces (Ollama Cloud, OpenRouter, local Ollama)
1974        // and keeps the native namespace spec for OpenAI. The response side
1975        // must reverse the flattened form and inject the namespace field.
1976        let flatten = |namespace: Option<&str>, name: &str| {
1977            namespace.map_or_else(
1978                || name.to_owned(),
1979                |namespace| format!("{namespace}__{name}"),
1980            )
1981        };
1982
1983        // OpenAI: native namespace spec; the backend returns the namespace
1984        // field, so no response-side normalization is required.
1985        assert_eq!(
1986            normalize_provider_tool_call("spawn_agent", &json!({})),
1987            (None, "spawn_agent".to_owned())
1988        );
1989
1990        // Ollama Cloud / OpenRouter / local Ollama: flattened request form.
1991        for route in ["ollama_cloud", "openrouter", "ollama_local"] {
1992            let request_name = flatten(Some("multi_agent_v1"), "spawn_agent");
1993            assert_eq!(request_name, "multi_agent_v1__spawn_agent");
1994            assert_eq!(
1995                normalize_provider_tool_call(&request_name, &json!({})),
1996                (Some("multi_agent_v1".to_owned()), "spawn_agent".to_owned())
1997            );
1998            let _ = route;
1999        }
2000
2001        // Bare container emission (the verified Ollama failure shape) maps to
2002        // the namespace function its argument shape matches.
2003        assert_eq!(
2004            normalize_provider_tool_call(
2005                "multi_agent_v1",
2006                &json!({"target": "agent-1", "message": "continue"})
2007            ),
2008            (Some("multi_agent_v1".to_owned()), "send_input".to_owned())
2009        );
2010    }
2011
2012    #[test]
2013    fn mav2_provider_tool_call_normalization_maps_bare_tools_to_chosen_namespace() {
2014        use router_protocol::MultiAgentNamespace;
2015        for namespace in [
2016            MultiAgentNamespace::Collaboration,
2017            MultiAgentNamespace::Agents,
2018        ] {
2019            for tool in MAV2_TOOL_NAMES {
2020                assert_eq!(
2021                    normalize_mav2_provider_tool_call(tool, &json!({}), namespace),
2022                    (Some(namespace.as_str().to_owned()), tool.to_owned())
2023                );
2024            }
2025        }
2026        // The AC-04 fake emission shape: bare `spawn_agent` with no namespace
2027        // field resolves to the route's chosen namespace.
2028        assert_eq!(
2029            normalize_mav2_provider_tool_call(
2030                "spawn_agent",
2031                &json!({"task_name": "t", "message": "Reply OK"}),
2032                MultiAgentNamespace::Agents
2033            ),
2034            (Some("agents".to_owned()), "spawn_agent".to_owned())
2035        );
2036        assert_eq!(
2037            normalize_mav2_provider_tool_call(
2038                "spawn_agent",
2039                &json!({"task_name": "t"}),
2040                MultiAgentNamespace::Collaboration
2041            ),
2042            (Some("collaboration".to_owned()), "spawn_agent".to_owned())
2043        );
2044        // Flattened request-side form reverses for both namespaces.
2045        for (namespace, expected) in [("collaboration", "collaboration"), ("agents", "agents")] {
2046            assert_eq!(
2047                normalize_mav2_provider_tool_call(
2048                    &format!("{namespace}__send_input"),
2049                    &json!({"target": "a1", "message": "continue"}),
2050                    MultiAgentNamespace::Agents
2051                ),
2052                (Some(expected.to_owned()), "send_input".to_owned())
2053            );
2054        }
2055        // Plain non-MAv2 tool names stay unnamespaced.
2056        assert_eq!(
2057            normalize_mav2_provider_tool_call(
2058                "get_weather",
2059                &json!({"city": "Paris"}),
2060                MultiAgentNamespace::Collaboration
2061            ),
2062            (None, "get_weather".to_owned())
2063        );
2064        // The v1 container name still disambiguates by argument shape.
2065        assert_eq!(
2066            normalize_mav2_provider_tool_call(
2067                "multi_agent_v1",
2068                &json!({"id": "agent-1"}),
2069                MultiAgentNamespace::Agents
2070            ),
2071            (Some("multi_agent_v1".to_owned()), "resume_agent".to_owned())
2072        );
2073    }
2074
2075    #[test]
2076    fn mav2_canonical_subagent_context_is_detected_for_collaboration_agents_and_bare_tools() {
2077        let mut request = codex_request();
2078        request["tools"] = json!([{
2079            "type": "namespace",
2080            "name": "collaboration",
2081            "description": "canonical MAv2 namespace",
2082            "tools": [{
2083                "type": "function",
2084                "name": "spawn_agent",
2085                "description": "spawn an agent",
2086                "strict": true,
2087                "parameters": {"type": "object", "properties": {}}
2088            }]
2089        }]);
2090        let body = serde_json::to_vec(&request).expect("serialize fixture");
2091        let canonical =
2092            normalize_responses_request(&body, identity()).expect("normalize canonical context");
2093        assert_eq!(
2094            canonical_subagent_request_context(&canonical),
2095            Some(CodexSubagentRequestContext {
2096                max_threads: 4,
2097                max_depth: 2,
2098                fork_turns: "none",
2099            })
2100        );
2101
2102        let mut request = codex_request();
2103        request["tools"] = json!([{
2104            "type": "namespace",
2105            "name": "agents",
2106            "description": "non-reserved MAv2 namespace",
2107            "tools": [{
2108                "type": "function",
2109                "name": "spawn_agent",
2110                "description": "spawn an agent",
2111                "strict": true,
2112                "parameters": {"type": "object", "properties": {}}
2113            }]
2114        }]);
2115        let body = serde_json::to_vec(&request).expect("serialize fixture");
2116        let canonical =
2117            normalize_responses_request(&body, identity()).expect("normalize agents context");
2118        assert!(canonical_subagent_request_context(&canonical).is_some());
2119
2120        let mut request = codex_request();
2121        request["tools"] = json!([{
2122            "type": "function",
2123            "name": "spawn_agent",
2124            "description": "bare MAv2 tool declaration",
2125            "strict": true,
2126            "parameters": {"type": "object", "properties": {}}
2127        }]);
2128        let body = serde_json::to_vec(&request).expect("serialize fixture");
2129        let canonical =
2130            normalize_responses_request(&body, identity()).expect("normalize bare MAv2 tool");
2131        assert!(canonical_subagent_request_context(&canonical).is_some());
2132    }
2133
2134    #[test]
2135    fn mav2_spawn_agent_arguments_validate_fork_turns_and_reject_fork_context() {
2136        assert_eq!(
2137            validate_mav2_spawn_agent_arguments(&json!({"task_name": "t"})),
2138            Ok(Mav2SpawnAgentContract {
2139                task_name: "t".to_owned(),
2140                model: None,
2141                reasoning_effort: None,
2142                fork_turns: Mav2ForkTurns::None,
2143            })
2144        );
2145        assert_eq!(
2146            validate_mav2_spawn_agent_arguments(&json!({
2147                "task_name": "t",
2148                "model": "gpt-5.6-terra",
2149                "reasoning_effort": "high",
2150                "fork_turns": "all"
2151            })),
2152            Ok(Mav2SpawnAgentContract {
2153                task_name: "t".to_owned(),
2154                model: Some("gpt-5.6-terra".to_owned()),
2155                reasoning_effort: Some("high".to_owned()),
2156                fork_turns: Mav2ForkTurns::All,
2157            })
2158        );
2159        assert_eq!(
2160            validate_mav2_spawn_agent_arguments(&json!({"fork_turns": 3})),
2161            Ok(Mav2SpawnAgentContract {
2162                task_name: String::new(),
2163                model: None,
2164                reasoning_effort: None,
2165                fork_turns: Mav2ForkTurns::Turns(3),
2166            })
2167        );
2168        assert_eq!(
2169            validate_mav2_spawn_agent_arguments(&json!({"fork_context": "parent"})),
2170            Err(Mav2SpawnAgentError::ForkContextRejected)
2171        );
2172        assert_eq!(
2173            validate_mav2_spawn_agent_arguments(&json!({"fork_turns": 0})),
2174            Err(Mav2SpawnAgentError::InvalidForkTurns)
2175        );
2176        assert_eq!(
2177            validate_mav2_spawn_agent_arguments(&json!({"fork_turns": "sometimes"})),
2178            Err(Mav2SpawnAgentError::InvalidForkTurns)
2179        );
2180        assert_eq!(
2181            validate_mav2_spawn_agent_arguments(&json!({"model": "not a model"})),
2182            Err(Mav2SpawnAgentError::InvalidModelOverride)
2183        );
2184        assert_eq!(
2185            validate_mav2_spawn_agent_arguments(&json!({"reasoning_effort": "turbo"})),
2186            Err(Mav2SpawnAgentError::InvalidReasoningEffortOverride)
2187        );
2188        // Non-object arguments are an argument-shape error, not a fork_turns
2189        // validation error.
2190        assert_eq!(
2191            validate_mav2_spawn_agent_arguments(&json!("spawn a subagent")),
2192            Err(Mav2SpawnAgentError::InvalidArguments)
2193        );
2194        assert_eq!(
2195            validate_mav2_spawn_agent_arguments(&json!(["fork_turns", "all"])),
2196            Err(Mav2SpawnAgentError::InvalidArguments)
2197        );
2198    }
2199
2200    #[test]
2201    fn namespace_call_id_encode_decode_is_idempotent_and_collision_proof() {
2202        use router_protocol::MultiAgentNamespace;
2203        for namespace in [
2204            MultiAgentNamespace::Collaboration,
2205            MultiAgentNamespace::Agents,
2206        ] {
2207            let encoded = encode_namespace_call_id("call-1234", namespace.as_str())
2208                .expect("encode namespace call id");
2209            assert!(encoded.starts_with("call-1234"));
2210            assert!(encoded.contains(NAMESPACE_ID_SMUGGLE_MARKER));
2211            assert_eq!(
2212                decode_namespace_call_id(&encoded),
2213                Some(("call-1234".to_owned(), namespace.as_str().to_owned()))
2214            );
2215            // Idempotent: encoding an already-encoded id is a no-op.
2216            assert_eq!(
2217                encode_namespace_call_id(&encoded, namespace.as_str()).expect("idempotent encode"),
2218                encoded
2219            );
2220        }
2221        // No fixture shows double-injection.
2222        let once = encode_namespace_call_id("call-1", "collaboration").expect("encode once");
2223        let twice = encode_namespace_call_id(&once, "agents").expect("encode twice");
2224        assert_eq!(once, twice);
2225        assert_eq!(once.matches(NAMESPACE_ID_SMUGGLE_MARKER).count(), 1);
2226
2227        // Collision-proof: a raw id that already contains the marker is refused.
2228        assert_eq!(
2229            encode_namespace_call_id("call__ns__already", "collaboration"),
2230            Err(NamespaceIdSmuggleError::MarkerCollision)
2231        );
2232        // Plain call ids decode to None.
2233        assert_eq!(decode_namespace_call_id("call-1234"), None);
2234        // A marker with a non-canonical namespace payload does not decode.
2235        assert_eq!(decode_namespace_call_id("call__ns__b2RkLXRvb2w"), None);
2236    }
2237}