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