Skip to main content

switchyard_protocol/
metadata.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Correlation metadata and harness header normalization.
5//!
6//! [`Metadata`] is the correlation/routing envelope carried alongside a request or
7//! response. [`Metadata::from_headers`] normalizes host-specific HTTP headers into
8//! that neutral shape.
9
10use std::{collections::BTreeMap, str::FromStr as _};
11
12use crate::{ModelId, WireFormat};
13
14// Dotted paths addressing fields inside Codex's turn-metadata header JSON value.
15const CODEX_SESSION_ID_PATH: &str = "x-codex-turn-metadata.session_id";
16const CODEX_THREAD_ID_PATH: &str = "x-codex-turn-metadata.thread_id";
17const CODEX_PARENT_THREAD_ID_PATH: &str = "x-codex-turn-metadata.parent_thread_id";
18const CODEX_TURN_ID_PATH: &str = "x-codex-turn-metadata.turn_id";
19const CODEX_SUBAGENT_KIND_PATH: &str = "x-codex-turn-metadata.subagent_kind";
20const CODEX_AGENT_ROLE_PATH: &str = "x-codex-turn-metadata.agent_role";
21const CODEX_TASK_ID_PATH: &str = "x-codex-turn-metadata.task_id";
22const CODEX_TASK_KIND_PATH: &str = "x-codex-turn-metadata.task_kind";
23
24// Explicit Switchyard override headers; these take precedence over harness-native headers.
25const SWITCHYARD_SESSION_ID_HEADER: &str = "x-switchyard-session-id";
26const SWITCHYARD_AGENT_ID_HEADER: &str = "x-switchyard-agent-id";
27const SWITCHYARD_PARENT_AGENT_ID_HEADER: &str = "x-switchyard-parent-agent-id";
28const SWITCHYARD_IS_SUBAGENT_HEADER: &str = "x-switchyard-is-subagent";
29const SWITCHYARD_AGENT_KIND_HEADER: &str = "x-switchyard-agent-kind";
30const SWITCHYARD_AGENT_ROLE_HEADER: &str = "x-switchyard-agent-role";
31const SWITCHYARD_TASK_ID_HEADER: &str = "x-switchyard-task-id";
32const SWITCHYARD_TASK_KIND_HEADER: &str = "x-switchyard-task-kind";
33const SWITCHYARD_TURN_ID_HEADER: &str = "x-switchyard-turn-id";
34const SWITCHYARD_REQUEST_ID_HEADER: &str = "x-switchyard-request-id";
35const SWITCHYARD_SESSION_FINAL_HEADER: &str = "x-switchyard-session-final";
36
37// Correlation-header aliases used by integrating hosts.
38const RELAY_SESSION_ID_HEADER: &str = "x-nemo-relay-session-id";
39const RELAY_SUBAGENT_ID_HEADER: &str = "x-nemo-relay-subagent-id";
40
41// Additional correlation-header aliases used by integrating hosts.
42const DYNAMO_SESSION_ID_HEADER: &str = "x-dynamo-session-id";
43const DYNAMO_PARENT_SESSION_ID_HEADER: &str = "x-dynamo-parent-session-id";
44const DYNAMO_SESSION_FINAL_HEADER: &str = "x-dynamo-session-final";
45
46// Codex compatibility projection of its parent thread id.
47const CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
48
49// OpenAI subagent marker.
50const OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent";
51
52// Claude Code agent-lineage headers.
53const CLAUDE_SESSION_ID_HEADER: &str = "x-claude-code-session-id";
54const CLAUDE_AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
55const CLAUDE_PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
56
57// OpenCode session header — used for session_id correlation only (not a routing signal).
58const OPENCODE_SESSION_ID_HEADER: &str = "x-session-id";
59
60// Generic Codex-compatible correlation headers.
61const SESSION_ID_HEADER: &str = "session-id";
62const THREAD_ID_HEADER: &str = "thread-id";
63const TASK_ID_HEADER: &str = "x-task-id";
64const REQUEST_ID_HEADER: &str = "x-request-id";
65const CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id";
66
67/// Harness-defined sub-agent kinds that carry delegated user work rather than
68/// harness maintenance (`compact`, `memory_consolidation`, ...). Unknown kinds
69/// are excluded deliberately; extend with captured request fixtures.
70const SUBAGENT_WORK_KINDS: &[&str] = &["collab_spawn", "review"];
71
72/// Ordered candidate lookup paths for each correlation field, keyed by the field's
73/// canonical `x-switchyard-*` header name.
74type HeaderConfig = [(&'static str, &'static [&'static str])];
75
76/// Precedence of harness headers for each correlation field. See [`HeaderConfig`].
77const HEADER_CONFIG: &HeaderConfig = &[
78    (
79        SWITCHYARD_SESSION_ID_HEADER,
80        &[
81            SWITCHYARD_SESSION_ID_HEADER,
82            CLAUDE_SESSION_ID_HEADER,
83            RELAY_SESSION_ID_HEADER,
84            OPENCODE_SESSION_ID_HEADER,
85            CODEX_SESSION_ID_PATH,
86            SESSION_ID_HEADER,
87        ],
88    ),
89    (
90        SWITCHYARD_AGENT_ID_HEADER,
91        &[
92            SWITCHYARD_AGENT_ID_HEADER,
93            CLAUDE_AGENT_ID_HEADER,
94            RELAY_SUBAGENT_ID_HEADER,
95            DYNAMO_SESSION_ID_HEADER,
96            CODEX_THREAD_ID_PATH,
97            THREAD_ID_HEADER,
98        ],
99    ),
100    (
101        SWITCHYARD_PARENT_AGENT_ID_HEADER,
102        &[
103            SWITCHYARD_PARENT_AGENT_ID_HEADER,
104            DYNAMO_PARENT_SESSION_ID_HEADER,
105            CODEX_PARENT_THREAD_ID_PATH,
106            CODEX_PARENT_THREAD_ID_HEADER,
107        ],
108    ),
109    (
110        SWITCHYARD_AGENT_KIND_HEADER,
111        &[
112            SWITCHYARD_AGENT_KIND_HEADER,
113            CODEX_SUBAGENT_KIND_PATH,
114            OPENAI_SUBAGENT_HEADER,
115        ],
116    ),
117    (
118        SWITCHYARD_AGENT_ROLE_HEADER,
119        &[SWITCHYARD_AGENT_ROLE_HEADER, CODEX_AGENT_ROLE_PATH],
120    ),
121    (
122        SWITCHYARD_TASK_ID_HEADER,
123        &[
124            SWITCHYARD_TASK_ID_HEADER,
125            CODEX_TASK_ID_PATH,
126            TASK_ID_HEADER,
127        ],
128    ),
129    (
130        SWITCHYARD_TASK_KIND_HEADER,
131        &[SWITCHYARD_TASK_KIND_HEADER, CODEX_TASK_KIND_PATH],
132    ),
133    (
134        SWITCHYARD_TURN_ID_HEADER,
135        &[SWITCHYARD_TURN_ID_HEADER, CODEX_TURN_ID_PATH],
136    ),
137    (
138        SWITCHYARD_REQUEST_ID_HEADER,
139        &[
140            SWITCHYARD_REQUEST_ID_HEADER,
141            REQUEST_ID_HEADER,
142            CLIENT_REQUEST_ID_HEADER,
143        ],
144    ),
145    (
146        SWITCHYARD_SESSION_FINAL_HEADER,
147        &[SWITCHYARD_SESSION_FINAL_HEADER, DYNAMO_SESSION_FINAL_HEADER],
148    ),
149];
150
151/// Correlation and routing metadata attached to a request or response.
152///
153/// All fields are optional (or default-empty); algorithms and observers use whichever
154/// are present (e.g. to key per-session state or emit correlated telemetry). The
155/// agent-lineage fields (`parent_agent_id`, `is_subagent`, `agent_kind`, `agent_role`,
156/// `task_kind`, `turn_id`, `session_final`) are populated for requests from a coding
157/// agent. `extra_metadata` is a free-form escape hatch for host-specific keys.
158#[derive(Clone, Default)]
159pub struct Metadata {
160    /// Stable id for a multi-request session/conversation.
161    pub session_id: Option<String>,
162    /// Id of the agent making the request.
163    pub agent_id: Option<String>,
164    /// Id of the parent agent, when this request comes from a child agent.
165    pub parent_agent_id: Option<String>,
166    /// Whether the harness identified this request as coming from a child agent.
167    pub is_subagent: bool,
168    /// Whether this request carries delegated sub-agent *work* and should be
169    /// routed to the sub-agent target. Computed from raw harness signals only,
170    /// independent of [`Self::agent_kind`], which may be set by an unrelated
171    /// operator label (`x-switchyard-agent-kind`).
172    pub is_delegated_work: bool,
173    /// Harness-defined kind of agent call, such as `collab_spawn` or `review`.
174    pub agent_kind: Option<String>,
175    /// Semantic agent role, such as `explorer`, `worker`, or `reviewer`.
176    pub agent_role: Option<String>,
177    /// Id of the task the request belongs to.
178    pub task_id: Option<String>,
179    /// Semantic task class supplied by the harness.
180    pub task_kind: Option<String>,
181    /// Id of the current agent turn.
182    pub turn_id: Option<String>,
183    /// Whether the harness signalled this is the session's final request (e.g. the
184    /// host may evict per-session state). `None` when the harness said nothing.
185    pub session_final: Option<bool>,
186    /// External trace/request id for joining with the host's telemetry.
187    pub correlation_id: Option<String>,
188    /// Switchyard target that successfully served a response.
189    pub served_model: Option<ModelId>,
190    /// Arbitrary host-defined key/value metadata.
191    pub extra_metadata: Option<BTreeMap<String, String>>,
192    /// HTTP headers to attach when forwarding the request/response, if any.
193    pub http_headers: Option<http::HeaderMap>,
194    /// The wire format the request/response was originally encoded in, if known.
195    pub wire_format: Option<WireFormat>,
196}
197
198impl Metadata {
199    /// Create Metadata
200    pub fn from_headers(headers: &http::HeaderMap) -> Self {
201        let (parent_agent_id, is_subagent, is_delegated_work) = parse_sub_agent(headers);
202
203        Metadata {
204            session_id: sy_header(headers, SWITCHYARD_SESSION_ID_HEADER),
205            agent_id: sy_header(headers, SWITCHYARD_AGENT_ID_HEADER),
206            parent_agent_id,
207            is_subagent,
208            is_delegated_work,
209            agent_kind: sy_header(headers, SWITCHYARD_AGENT_KIND_HEADER),
210            agent_role: sy_header(headers, SWITCHYARD_AGENT_ROLE_HEADER),
211            task_id: sy_header(headers, SWITCHYARD_TASK_ID_HEADER),
212            task_kind: sy_header(headers, SWITCHYARD_TASK_KIND_HEADER),
213            turn_id: sy_header(headers, SWITCHYARD_TURN_ID_HEADER),
214            session_final: sy_header(headers, SWITCHYARD_SESSION_FINAL_HEADER)
215                .as_deref()
216                .and_then(parse_bool),
217            correlation_id: sy_header(headers, SWITCHYARD_REQUEST_ID_HEADER),
218            ..Metadata::default()
219        }
220    }
221
222    /// Whether this request should be routed to the sub-agent target.
223    ///
224    /// Returns `self.is_delegated_work`, which is computed in `parse_sub_agent`
225    /// from raw harness signals only — independent of `agent_kind`, which may
226    /// be populated by an unrelated operator label (`x-switchyard-agent-kind`).
227    pub fn is_subagent_work(&self) -> bool {
228        self.is_delegated_work
229    }
230}
231
232/// Returns `(parent_agent_id, is_subagent, is_delegated_work)` from the headers.
233///
234/// Recognized sub-agent signals include `x-claude-code-agent-id`,
235/// `x-openai-subagent`, `x-codex-turn-metadata.subagent_kind`, and explicit
236/// `x-switchyard-is-subagent`. Other host correlation and parent-session headers
237/// may populate metadata but do not drive sub-agent classification.
238///
239/// `is_delegated_work` is computed from raw harness signals, not from `agent_kind`,
240/// which may be set by an unrelated operator label (`x-switchyard-agent-kind`).
241fn parse_sub_agent(headers: &http::HeaderMap) -> (Option<String>, bool, bool) {
242    let explicit = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool);
243
244    let (claude_parent, claude_subagent) = claude_lineage(headers);
245
246    // Harness routing signal: Codex turn-metadata kind or flat OpenAI subagent header.
247    // `x-switchyard-agent-kind` (operator semantic label) is intentionally excluded.
248    let harness_kind = resolve_path(headers, CODEX_SUBAGENT_KIND_PATH)
249        .or_else(|| header(headers, OPENAI_SUBAGENT_HEADER).map(str::to_string));
250
251    // Resolve the parent through the configured header precedence, then fall back
252    // to the native agent session the child was spawned under.
253    let parent = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER)
254        .or_else(|| claude_parent.map(str::to_string));
255
256    let is_subagent = explicit.unwrap_or(claude_subagent || harness_kind.is_some());
257
258    let is_delegated_work = match explicit {
259        Some(false) => false,
260        Some(true) => harness_kind
261            .as_deref()
262            .map(|k| SUBAGENT_WORK_KINDS.contains(&k))
263            .unwrap_or(true),
264        None => {
265            claude_subagent
266                || harness_kind
267                    .as_deref()
268                    .is_some_and(|k| SUBAGENT_WORK_KINDS.contains(&k))
269        }
270    };
271
272    (parent, is_subagent, is_delegated_work)
273}
274
275/// Claude Code's `(parent_agent, is_subagent)` from its native lineage headers.
276///
277/// Claude Code only sends `x-claude-code-agent-id` for spawned sub-agents and
278/// teammates; root agents omit it. Any non-empty value is therefore a
279/// sub-agent signal. The parent is the explicit parent-agent header when
280/// present, else the session the child was spawned under.
281fn claude_lineage(headers: &http::HeaderMap) -> (Option<&str>, bool) {
282    let session = header(headers, CLAUDE_SESSION_ID_HEADER);
283    let agent = header(headers, CLAUDE_AGENT_ID_HEADER);
284    let is_subagent = agent.is_some();
285    let parent = is_subagent
286        .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(session))
287        .flatten();
288    (parent, is_subagent)
289}
290
291/// Parses the common textual spellings of a boolean header value.
292fn parse_bool(value: &str) -> Option<bool> {
293    match value.trim().to_ascii_lowercase().as_str() {
294        "1" | "true" | "yes" | "on" => Some(true),
295        "0" | "false" | "no" | "off" => Some(false),
296        _ => None,
297    }
298}
299
300/// Resolves the logical field `key` against `headers` using [`HEADER_CONFIG`]'s paths.
301///
302/// Returns the value of the first configured path that resolves, or `None` when the
303/// field is absent from [`HEADER_CONFIG`] or nothing resolves. Descending into JSON
304/// yields owned values, so the result is a `String` rather than a borrow of `headers`.
305fn sy_header(headers: &http::HeaderMap, key: &str) -> Option<String> {
306    let (_, paths) = HEADER_CONFIG
307        .iter()
308        .find(|(field, _)| field.eq_ignore_ascii_case(key))?;
309    paths.iter().find_map(|path| resolve_path(headers, path))
310}
311
312/// Follows one dotted path, descending through a JSON-object header value.
313/// Do not use if you expect multiple values for this header.
314fn resolve_path(headers: &http::HeaderMap, path: &str) -> Option<String> {
315    let (header_name, nested) = match path.split_once('.') {
316        Some((name, rest)) => (name, Some(rest)),
317        None => (path, None),
318    };
319    let raw = headers.get(header_name)?.to_str().ok().map(|s| s.trim())?;
320    if raw.is_empty() {
321        return None;
322    }
323
324    // A bare header name resolves to its value verbatim; no JSON parsing needed.
325    let Some(nested) = nested else {
326        return Some(raw.to_string());
327    };
328
329    // Nested path: parse the header value as JSON and descend key by key.
330    let mut current: serde_json::Value = serde_json::from_str(raw).ok()?;
331    for segment in nested.split('.') {
332        current = current.as_object()?.get(segment)?.clone();
333    }
334
335    match current {
336        serde_json::Value::String(s) => {
337            let value = s.trim();
338            (!value.is_empty()).then(|| value.to_string())
339        }
340        serde_json::Value::Null => None,
341        leaf => Some(leaf.to_string()),
342    }
343}
344
345fn header<'a>(headers: &'a http::HeaderMap, key: &str) -> Option<&'a str> {
346    headers
347        .get(key)
348        .and_then(|s| s.to_str().ok())
349        .map(str::trim)
350        .filter(|s| !s.is_empty())
351}
352
353/// Utility to convert a slice of string pairs into an `http::HeaderMap`.
354pub fn slice_to_header_map(sl: &[(&str, &str)]) -> http::HeaderMap {
355    let mut m = http::HeaderMap::with_capacity(sl.len());
356    for (k, v) in sl {
357        m.insert(
358            http::HeaderName::from_str(k).unwrap(),
359            (*v).try_into().unwrap(),
360        );
361    }
362    m
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    /// Header carrying Codex's structured turn metadata as a JSON object.
370    const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
371
372    fn metadata(headers: &[(&str, &str)]) -> Metadata {
373        Metadata::from_headers(&slice_to_header_map(headers))
374    }
375
376    #[test]
377    fn normalizes_codex_metadata_and_lineage() {
378        let child_body = serde_json::json!({
379            "session_id": "root-session",
380            "thread_id": "child-agent",
381            "parent_thread_id": "root-agent",
382            "turn_id": "turn-7",
383            "subagent_kind": "collab_spawn",
384        })
385        .to_string();
386        let child = metadata(&[(CODEX_TURN_METADATA_HEADER, child_body.as_str())]);
387        assert_eq!(child.session_id.as_deref(), Some("root-session"));
388        assert_eq!(child.agent_id.as_deref(), Some("child-agent"));
389        assert_eq!(child.parent_agent_id.as_deref(), Some("root-agent"));
390        assert!(child.is_subagent);
391
392        let root_body = serde_json::json!({
393            "session_id": "root-session",
394            "thread_id": "root-agent",
395            "turn_id": "turn-1",
396        })
397        .to_string();
398        let root = metadata(&[(CODEX_TURN_METADATA_HEADER, root_body.as_str())]);
399        assert!(!root.is_subagent);
400
401        // Parent-thread-id is correlation data, not a routing signal. A Codex
402        // turn that carries a parent thread id but no `x-openai-subagent` must
403        // not be treated as sub-agent work.
404        let correlated_body = serde_json::json!({
405            "session_id": "root-session",
406            "thread_id": "child-thread",
407            "parent_thread_id": "root-thread",
408            "turn_id": "turn-3",
409        })
410        .to_string();
411        let correlated = metadata(&[(CODEX_TURN_METADATA_HEADER, correlated_body.as_str())]);
412        assert_eq!(correlated.parent_agent_id.as_deref(), Some("root-thread"));
413        assert!(!correlated.is_subagent);
414        assert!(!correlated.is_subagent_work());
415    }
416
417    #[test]
418    fn normalizes_claude_code_metadata_and_lineage() {
419        // Claude Code identifies a session with `x-claude-code-session-id`; session
420        // affinity keys on it so a whole CLI session pins to one tier.
421        let session = metadata(&[(
422            "x-claude-code-session-id",
423            "fb46caae-eac6-4f5f-83fd-8fc8f5743abb",
424        )]);
425        assert_eq!(
426            session.session_id.as_deref(),
427            Some("fb46caae-eac6-4f5f-83fd-8fc8f5743abb")
428        );
429
430        // Any non-empty agent id is a child agent. Without an explicit parent
431        // header the parent is inferred to be the session it was spawned under.
432        let child = metadata(&[
433            ("x-claude-code-session-id", "claude-session"),
434            ("x-claude-code-agent-id", "claude-agent"),
435        ]);
436        assert_eq!(child.session_id.as_deref(), Some("claude-session"));
437        assert_eq!(child.agent_id.as_deref(), Some("claude-agent"));
438        assert_eq!(child.parent_agent_id.as_deref(), Some("claude-session"));
439        assert!(child.is_subagent);
440
441        let child_without_session = metadata(&[("x-claude-code-agent-id", "claude-agent")]);
442        assert_eq!(
443            child_without_session.agent_id.as_deref(),
444            Some("claude-agent")
445        );
446        assert_eq!(child_without_session.parent_agent_id, None);
447        assert!(child_without_session.is_subagent);
448
449        let explicit_parent = metadata(&[
450            ("x-claude-code-session-id", "claude-session"),
451            ("x-claude-code-agent-id", "claude-agent"),
452            ("x-claude-code-parent-agent-id", "claude-parent-agent"),
453        ]);
454        assert_eq!(
455            explicit_parent.parent_agent_id.as_deref(),
456            Some("claude-parent-agent")
457        );
458
459        // Root agents omit x-claude-code-agent-id entirely. A stray parent-agent
460        // header without an agent-id must not mark the request as a child.
461        let root = metadata(&[
462            ("x-claude-code-session-id", "claude-session"),
463            ("x-claude-code-parent-agent-id", "claude-parent-agent"),
464        ]);
465        assert_eq!(root.session_id.as_deref(), Some("claude-session"));
466        assert_eq!(root.agent_id, None);
467        assert_eq!(root.parent_agent_id, None);
468        assert!(!root.is_subagent);
469    }
470
471    #[test]
472    fn normalizes_correlation_and_session_headers_without_routing() {
473        // Integrating-host headers are correlation data, not routing signals.
474        let relay = metadata(&[
475            ("x-nemo-relay-session-id", "relay-session"),
476            ("x-nemo-relay-subagent-id", "relay-child"),
477            ("x-dynamo-parent-session-id", "relay-parent"),
478        ]);
479        assert_eq!(relay.session_id.as_deref(), Some("relay-session"));
480        assert_eq!(relay.agent_id.as_deref(), Some("relay-child"));
481        assert_eq!(relay.parent_agent_id.as_deref(), Some("relay-parent"));
482        assert!(!relay.is_subagent);
483        assert!(!relay.is_subagent_work());
484
485        let opencode = metadata(&[
486            ("x-session-id", "opencode-run"),
487            ("x-parent-session-id", "opencode-parent"),
488        ]);
489        assert_eq!(opencode.session_id.as_deref(), Some("opencode-run"));
490        assert_eq!(opencode.parent_agent_id, None);
491        assert!(!opencode.is_subagent);
492
493        let codex_session = metadata(&[
494            ("session-id", "codex-run"),
495            ("x-parent-session-id", "stray-parent"),
496        ]);
497        assert_eq!(codex_session.session_id.as_deref(), Some("codex-run"));
498        assert_eq!(codex_session.parent_agent_id, None);
499        assert!(!codex_session.is_subagent);
500
501        let final_session = metadata(&[
502            ("x-dynamo-session-id", "generic-run"),
503            ("x-dynamo-parent-session-id", "generic-parent"),
504            ("x-dynamo-session-final", "true"),
505        ]);
506        assert_eq!(final_session.agent_id.as_deref(), Some("generic-run"));
507        assert_eq!(
508            final_session.parent_agent_id.as_deref(),
509            Some("generic-parent")
510        );
511        assert_eq!(final_session.session_final, Some(true));
512
513        let active_session = metadata(&[
514            ("x-dynamo-session-id", "generic-run"),
515            ("x-dynamo-session-final", "false"),
516        ]);
517        assert_eq!(active_session.session_final, Some(false));
518    }
519
520    #[test]
521    fn sy_header_resolves_paths_in_order_and_descends_into_json() {
522        // Only the JSON-nested Codex path is present, so descent supplies the value.
523        let body = serde_json::json!({ "session_id": "codex-session" }).to_string();
524        let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
525        assert_eq!(
526            sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
527            Some("codex-session")
528        );
529
530        // The explicit Switchyard header outranks the Codex path when both resolve.
531        let headers = slice_to_header_map(&[
532            (SWITCHYARD_SESSION_ID_HEADER, "explicit"),
533            (CODEX_TURN_METADATA_HEADER, body.as_str()),
534        ]);
535        assert_eq!(
536            sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
537            Some("explicit")
538        );
539
540        // Nothing resolves for an empty header set or an unknown field.
541        assert_eq!(
542            sy_header(&http::HeaderMap::new(), SWITCHYARD_SESSION_ID_HEADER),
543            None
544        );
545        assert_eq!(sy_header(&headers, "x-not-a-field"), None);
546    }
547
548    // Blank nested metadata must not mask a valid lower-priority session header.
549    #[test]
550    fn nested_metadata_strings_match_flat_header_normalization() {
551        let body = serde_json::json!({ "session_id": "  codex-session  " }).to_string();
552        let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
553        assert_eq!(
554            sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
555            Some("codex-session")
556        );
557
558        let blank_body = serde_json::json!({ "session_id": "   " }).to_string();
559        let headers = slice_to_header_map(&[
560            (CODEX_TURN_METADATA_HEADER, blank_body.as_str()),
561            (SESSION_ID_HEADER, "fallback-session"),
562        ]);
563        assert_eq!(
564            sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
565            Some("fallback-session")
566        );
567    }
568
569    #[test]
570    fn subagent_routing_honors_explicit_signals_and_delegated_work_kinds() {
571        // Explicit `false` wins over presence-based inference even when no
572        // parent id accompanies it; the flag decides in both directions.
573        let explicitly_root = metadata(&[
574            ("x-switchyard-is-subagent", "false"),
575            ("x-openai-subagent", "review"),
576        ]);
577        assert!(!explicitly_root.is_subagent);
578
579        let explicitly_child = metadata(&[("x-switchyard-is-subagent", "true")]);
580        assert!(explicitly_child.is_subagent);
581
582        let child_with_parent = metadata(&[
583            ("x-switchyard-is-subagent", "false"),
584            ("x-switchyard-parent-agent-id", "parent"),
585        ]);
586        assert!(!child_with_parent.is_subagent);
587
588        // Operator labels do not filter routing signals from the harness.
589        let with_openai = metadata(&[
590            ("x-openai-subagent", "review"),
591            ("x-switchyard-agent-kind", "researcher"),
592        ]);
593        assert!(with_openai.is_subagent);
594        assert!(with_openai.is_subagent_work());
595
596        let with_explicit = metadata(&[
597            ("x-switchyard-is-subagent", "true"),
598            ("x-switchyard-agent-kind", "researcher"),
599        ]);
600        assert!(with_explicit.is_subagent);
601        assert!(with_explicit.is_subagent_work());
602
603        // Kindless lineage (Claude Code child agent) counts as delegated work.
604        let claude_child = metadata(&[
605            ("x-claude-code-session-id", "root"),
606            ("x-claude-code-agent-id", "worker"),
607        ]);
608        assert!(claude_child.is_subagent_work());
609
610        // Codex delegated-work kinds route as sub-agent work.
611        let review = metadata(&[("x-openai-subagent", "review")]);
612        assert!(review.is_subagent_work());
613
614        // Harness maintenance and unknown kinds stay on normal routing even
615        // though the lineage fact still marks them as child-agent requests.
616        for kind in ["compact", "memory_consolidation", "brand_new_kind"] {
617            let request = metadata(&[("x-openai-subagent", kind)]);
618            assert!(request.is_subagent, "{kind} keeps the lineage fact");
619            assert!(!request.is_subagent_work(), "{kind} is not routed as work");
620        }
621
622        // A non-subagent request is never work, whatever its kind says.
623        assert!(!Metadata::default().is_subagent_work());
624    }
625}