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