1use 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
231pub const MULTI_AGENT_NAMESPACE: &str = "multi_agent_v1";
233
234#[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
260pub 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
293const MULTI_AGENT_DEFAULT_FUNCTION: &str = "spawn_agent";
296
297fn 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
323pub 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
345pub const MAV2_COLLABORATION_NAMESPACE: &str = "collaboration";
349
350pub const MAV2_AGENTS_NAMESPACE: &str = "agents";
354
355pub const MAV2_TOOL_NAMES: [&str; 5] = [
357 "spawn_agent",
358 "send_input",
359 "resume_agent",
360 "wait_agent",
361 "close_agent",
362];
363
364pub fn is_subagent_namespace(namespace: &str) -> bool {
367 matches!(
368 namespace,
369 MULTI_AGENT_NAMESPACE | MAV2_COLLABORATION_NAMESPACE | MAV2_AGENTS_NAMESPACE
370 )
371}
372
373pub fn is_mav2_tool_name(name: &str) -> bool {
375 MAV2_TOOL_NAMES.contains(&name)
376}
377
378pub 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
398pub 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
416pub 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
435pub 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#[derive(Clone, Debug, PartialEq, Eq)]
454pub struct Mav2SpawnAgentContract {
455 pub task_name: String,
456 pub model: Option<String>,
460 pub reasoning_effort: Option<String>,
461 pub fork_turns: Mav2ForkTurns,
462}
463
464#[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
496const MAV2_REASONING_EFFORT_VALUES: [&str; 6] =
499 ["none", "minimal", "low", "medium", "high", "xhigh"];
500
501pub 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 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 assert_eq!(
1986 normalize_provider_tool_call("spawn_agent", &json!({})),
1987 (None, "spawn_agent".to_owned())
1988 );
1989
1990 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 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 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 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 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 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 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 assert_eq!(
2217 encode_namespace_call_id(&encoded, namespace.as_str()).expect("idempotent encode"),
2218 encoded
2219 );
2220 }
2221 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 assert_eq!(
2229 encode_namespace_call_id("call__ns__already", "collaboration"),
2230 Err(NamespaceIdSmuggleError::MarkerCollision)
2231 );
2232 assert_eq!(decode_namespace_call_id("call-1234"), None);
2234 assert_eq!(decode_namespace_call_id("call__ns__b2RkLXRvb2w"), None);
2236 }
2237}