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;
11
12use crate::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    /// Arbitrary host-defined key/value metadata.
189    pub extra_metadata: Option<BTreeMap<String, String>>,
190    /// HTTP headers to attach when forwarding the request/response, if any.
191    pub http_headers: Option<BTreeMap<String, String>>,
192    /// The wire format the request/response was originally encoded in, if known.
193    pub wire_format: Option<WireFormat>,
194}
195
196impl Metadata {
197    /// Normalizes harness-specific request headers into correlation metadata.
198    ///
199    /// Explicit `x-switchyard-*` headers win. Compatible host headers can populate
200    /// correlation and session metadata, but do not by themselves mark a request as
201    /// delegated work. Structured turn metadata is preferred over compatibility
202    /// projections. A request with a non-empty `x-claude-code-agent-id` is treated
203    /// as a child agent (its parent
204    /// inferred from the session when not stated). Sub-agent routing status is taken
205    /// from an explicit `x-switchyard-is-subagent` header when present, and otherwise
206    /// inferred from native agent lineage or structured harness signals.
207    pub fn from_headers(headers: &BTreeMap<String, String>) -> Self {
208        let headers = &normalize_headers(headers);
209
210        let (parent_agent_id, is_subagent, is_delegated_work) = parse_sub_agent(headers);
211
212        Metadata {
213            session_id: sy_header(headers, SWITCHYARD_SESSION_ID_HEADER),
214            agent_id: sy_header(headers, SWITCHYARD_AGENT_ID_HEADER),
215            parent_agent_id,
216            is_subagent,
217            is_delegated_work,
218            agent_kind: sy_header(headers, SWITCHYARD_AGENT_KIND_HEADER),
219            agent_role: sy_header(headers, SWITCHYARD_AGENT_ROLE_HEADER),
220            task_id: sy_header(headers, SWITCHYARD_TASK_ID_HEADER),
221            task_kind: sy_header(headers, SWITCHYARD_TASK_KIND_HEADER),
222            turn_id: sy_header(headers, SWITCHYARD_TURN_ID_HEADER),
223            session_final: sy_header(headers, SWITCHYARD_SESSION_FINAL_HEADER)
224                .as_deref()
225                .and_then(parse_bool),
226            correlation_id: sy_header(headers, SWITCHYARD_REQUEST_ID_HEADER),
227            ..Metadata::default()
228        }
229    }
230
231    /// Whether this request should be routed to the sub-agent target.
232    ///
233    /// Returns `self.is_delegated_work`, which is computed in `parse_sub_agent`
234    /// from raw harness signals only — independent of `agent_kind`, which may
235    /// be populated by an unrelated operator label (`x-switchyard-agent-kind`).
236    pub fn is_subagent_work(&self) -> bool {
237        self.is_delegated_work
238    }
239}
240
241/// Returns `(parent_agent_id, is_subagent, is_delegated_work)` from the headers.
242///
243/// Recognized sub-agent signals include `x-claude-code-agent-id`,
244/// `x-openai-subagent`, `x-codex-turn-metadata.subagent_kind`, and explicit
245/// `x-switchyard-is-subagent`. Other host correlation and parent-session headers
246/// may populate metadata but do not drive sub-agent classification.
247///
248/// `is_delegated_work` is computed from raw harness signals, not from `agent_kind`,
249/// which may be set by an unrelated operator label (`x-switchyard-agent-kind`).
250fn parse_sub_agent(headers: &BTreeMap<String, String>) -> (Option<String>, bool, bool) {
251    let explicit = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool);
252
253    let (claude_parent, claude_subagent) = claude_lineage(headers);
254
255    // Harness routing signal: Codex turn-metadata kind or flat OpenAI subagent header.
256    // `x-switchyard-agent-kind` (operator semantic label) is intentionally excluded.
257    let harness_kind = resolve_path(headers, CODEX_SUBAGENT_KIND_PATH)
258        .or_else(|| header(headers, OPENAI_SUBAGENT_HEADER).map(str::to_string));
259
260    // Resolve the parent through the configured header precedence, then fall back
261    // to the native agent session the child was spawned under.
262    let parent = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER)
263        .or_else(|| claude_parent.map(str::to_string));
264
265    let is_subagent = explicit.unwrap_or(claude_subagent || harness_kind.is_some());
266
267    let is_delegated_work = match explicit {
268        Some(false) => false,
269        Some(true) => harness_kind
270            .as_deref()
271            .map(|k| SUBAGENT_WORK_KINDS.contains(&k))
272            .unwrap_or(true),
273        None => {
274            claude_subagent
275                || harness_kind
276                    .as_deref()
277                    .is_some_and(|k| SUBAGENT_WORK_KINDS.contains(&k))
278        }
279    };
280
281    (parent, is_subagent, is_delegated_work)
282}
283
284/// Claude Code's `(parent_agent, is_subagent)` from its native lineage headers.
285///
286/// Claude Code only sends `x-claude-code-agent-id` for spawned sub-agents and
287/// teammates; root agents omit it. Any non-empty value is therefore a
288/// sub-agent signal. The parent is the explicit parent-agent header when
289/// present, else the session the child was spawned under.
290fn claude_lineage(headers: &BTreeMap<String, String>) -> (Option<&str>, bool) {
291    let session = header(headers, CLAUDE_SESSION_ID_HEADER);
292    let agent = header(headers, CLAUDE_AGENT_ID_HEADER);
293    let is_subagent = agent.is_some();
294    let parent = is_subagent
295        .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(session))
296        .flatten();
297    (parent, is_subagent)
298}
299
300/// Parses the common textual spellings of a boolean header value.
301fn parse_bool(value: &str) -> Option<bool> {
302    match value.trim().to_ascii_lowercase().as_str() {
303        "1" | "true" | "yes" | "on" => Some(true),
304        "0" | "false" | "no" | "off" => Some(false),
305        _ => None,
306    }
307}
308
309/// Lowercases header names and keeps the first non-empty, trimmed value per name.
310fn normalize_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
311    let mut normalized = BTreeMap::new();
312    for (key, value) in headers {
313        let lower_key = key.to_ascii_lowercase();
314        let trimmed_value = value.trim();
315        if !normalized.contains_key(&lower_key) && !trimmed_value.is_empty() {
316            normalized.insert(lower_key, trimmed_value.to_string());
317        }
318    }
319
320    normalized
321}
322
323/// Resolves the logical field `key` against `headers` using [`HEADER_CONFIG`]'s paths.
324///
325/// Returns the value of the first configured path that resolves, or `None` when the
326/// field is absent from [`HEADER_CONFIG`] or nothing resolves. Descending into JSON
327/// yields owned values, so the result is a `String` rather than a borrow of `headers`.
328fn sy_header(headers: &BTreeMap<String, String>, key: &str) -> Option<String> {
329    let (_, paths) = HEADER_CONFIG
330        .iter()
331        .find(|(field, _)| field.eq_ignore_ascii_case(key))?;
332    paths.iter().find_map(|path| resolve_path(headers, path))
333}
334
335/// Follows one dotted path, descending through a JSON-object header value.
336fn resolve_path(headers: &BTreeMap<String, String>, path: &str) -> Option<String> {
337    let (header_name, nested) = match path.split_once('.') {
338        Some((name, rest)) => (name, Some(rest)),
339        None => (path, None),
340    };
341    let raw = headers.get(&header_name.to_ascii_lowercase())?;
342
343    // A bare header name resolves to its value verbatim; no JSON parsing needed.
344    let Some(nested) = nested else {
345        return Some(raw.clone());
346    };
347
348    // Nested path: parse the header value as JSON and descend key by key.
349    let mut current: serde_json::Value = serde_json::from_str(raw).ok()?;
350    for segment in nested.split('.') {
351        current = current.as_object()?.get(segment)?.clone();
352    }
353
354    match current {
355        serde_json::Value::String(s) => Some(s),
356        serde_json::Value::Null => None,
357        leaf => Some(leaf.to_string()),
358    }
359}
360
361fn header<'a>(headers: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
362    let lower_key = key.to_ascii_lowercase();
363    headers.get(&lower_key).map(|s| s.as_str())
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    /// Header carrying Codex's structured turn metadata as a JSON object.
371    const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
372
373    #[test]
374    fn normalizes_codex_child_metadata() {
375        let headers = BTreeMap::from([(
376            CODEX_TURN_METADATA_HEADER.to_string(),
377            serde_json::json!({
378                "session_id": "root-session",
379                "thread_id": "child-agent",
380                "parent_thread_id": "root-agent",
381                "turn_id": "turn-7",
382                "subagent_kind": "collab_spawn",
383            })
384            .to_string(),
385        )]);
386
387        let metadata = Metadata::from_headers(&headers);
388        assert_eq!(metadata.session_id.as_deref(), Some("root-session"));
389        assert_eq!(metadata.agent_id.as_deref(), Some("child-agent"));
390        assert!(metadata.is_subagent);
391        assert_eq!(metadata.parent_agent_id.as_deref(), Some("root-agent"));
392    }
393
394    #[test]
395    fn root_codex_metadata_is_not_inferred_as_a_subagent() {
396        let headers = BTreeMap::from([(
397            CODEX_TURN_METADATA_HEADER.to_string(),
398            serde_json::json!({
399                "session_id": "root-session",
400                "thread_id": "root-agent",
401                "turn_id": "turn-1",
402            })
403            .to_string(),
404        )]);
405
406        let metadata = Metadata::from_headers(&headers);
407        assert!(!metadata.is_subagent);
408    }
409
410    #[test]
411    fn codex_parent_thread_id_alone_is_not_a_subagent_signal() {
412        // Parent-thread-id is correlation data, not a routing signal. A Codex
413        // turn that carries a parent thread id but no `x-openai-subagent` must
414        // not be treated as sub-agent work.
415        let headers = BTreeMap::from([(
416            CODEX_TURN_METADATA_HEADER.to_string(),
417            serde_json::json!({
418                "session_id": "root-session",
419                "thread_id": "child-thread",
420                "parent_thread_id": "root-thread",
421                "turn_id": "turn-3",
422                // no subagent_kind
423            })
424            .to_string(),
425        )]);
426
427        let metadata = Metadata::from_headers(&headers);
428        // Parent id is still captured for observability.
429        assert_eq!(metadata.parent_agent_id.as_deref(), Some("root-thread"));
430        // But it must not drive routing.
431        assert!(!metadata.is_subagent);
432        assert!(!metadata.is_subagent_work());
433    }
434
435    #[test]
436    fn explicit_switchyard_subagent_flag_overrides_inference() {
437        let headers = BTreeMap::from([
438            ("x-switchyard-is-subagent".to_string(), "false".to_string()),
439            (
440                "x-switchyard-parent-agent-id".to_string(),
441                "parent".to_string(),
442            ),
443        ]);
444
445        let metadata = Metadata::from_headers(&headers);
446        assert!(!metadata.is_subagent);
447    }
448
449    #[test]
450    fn normalizes_claude_code_session_header() {
451        // Claude Code identifies a session with `x-claude-code-session-id`; session
452        // affinity keys on it so a whole CLI session pins to one tier.
453        let headers = BTreeMap::from([(
454            "x-claude-code-session-id".to_string(),
455            "fb46caae-eac6-4f5f-83fd-8fc8f5743abb".to_string(),
456        )]);
457
458        let metadata = Metadata::from_headers(&headers);
459        assert_eq!(
460            metadata.session_id.as_deref(),
461            Some("fb46caae-eac6-4f5f-83fd-8fc8f5743abb")
462        );
463    }
464
465    #[test]
466    fn normalizes_relay_and_dynamo_child_headers() {
467        // Integrating-host headers are correlation data, not routing signals.
468        // They populate observability fields but must not trigger sub-agent routing.
469        let headers = BTreeMap::from([
470            (
471                "x-nemo-relay-session-id".to_string(),
472                "relay-session".to_string(),
473            ),
474            (
475                "x-nemo-relay-subagent-id".to_string(),
476                "relay-child".to_string(),
477            ),
478            (
479                "x-dynamo-parent-session-id".to_string(),
480                "relay-parent".to_string(),
481            ),
482        ]);
483
484        let metadata = Metadata::from_headers(&headers);
485        assert_eq!(metadata.session_id.as_deref(), Some("relay-session"));
486        assert_eq!(metadata.agent_id.as_deref(), Some("relay-child"));
487        assert_eq!(metadata.parent_agent_id.as_deref(), Some("relay-parent"));
488        assert!(!metadata.is_subagent);
489        assert!(!metadata.is_subagent_work());
490    }
491
492    #[test]
493    fn claude_code_agent_lineage_marks_subagent_and_infers_parent() {
494        // Any non-empty agent id is a child agent. Without an explicit parent
495        // header the parent is inferred to be the session it was spawned under.
496        let metadata = Metadata::from_headers(&BTreeMap::from([
497            (
498                "x-claude-code-session-id".to_string(),
499                "claude-session".to_string(),
500            ),
501            (
502                "x-claude-code-agent-id".to_string(),
503                "claude-agent".to_string(),
504            ),
505        ]));
506        assert_eq!(metadata.session_id.as_deref(), Some("claude-session"));
507        assert_eq!(metadata.agent_id.as_deref(), Some("claude-agent"));
508        assert!(metadata.is_subagent);
509        assert_eq!(metadata.parent_agent_id.as_deref(), Some("claude-session"));
510    }
511
512    #[test]
513    fn claude_code_agent_id_alone_marks_subagent() {
514        // The agent-id header is the detection predicate; the session header is
515        // correlation data. A request with only agent-id is still a child agent.
516        let metadata = Metadata::from_headers(&BTreeMap::from([(
517            "x-claude-code-agent-id".to_string(),
518            "claude-agent".to_string(),
519        )]));
520        assert!(metadata.is_subagent);
521        assert_eq!(metadata.agent_id.as_deref(), Some("claude-agent"));
522        assert_eq!(metadata.parent_agent_id, None);
523    }
524
525    #[test]
526    fn explicit_claude_parent_agent_overrides_inferred_session() {
527        let metadata = Metadata::from_headers(&BTreeMap::from([
528            (
529                "x-claude-code-session-id".to_string(),
530                "claude-session".to_string(),
531            ),
532            (
533                "x-claude-code-agent-id".to_string(),
534                "claude-agent".to_string(),
535            ),
536            (
537                "x-claude-code-parent-agent-id".to_string(),
538                "claude-parent-agent".to_string(),
539            ),
540        ]));
541        assert_eq!(
542            metadata.parent_agent_id.as_deref(),
543            Some("claude-parent-agent")
544        );
545    }
546
547    #[test]
548    fn claude_root_agent_without_agent_id_is_not_a_subagent() {
549        // Root agents omit x-claude-code-agent-id entirely. A stray parent-agent
550        // header without an agent-id must not mark the request as a child.
551        let metadata = Metadata::from_headers(&BTreeMap::from([
552            (
553                "x-claude-code-session-id".to_string(),
554                "claude-session".to_string(),
555            ),
556            (
557                "x-claude-code-parent-agent-id".to_string(),
558                "claude-parent-agent".to_string(),
559            ),
560        ]));
561        assert_eq!(metadata.session_id.as_deref(), Some("claude-session"));
562        assert_eq!(metadata.agent_id, None);
563        assert!(!metadata.is_subagent);
564        assert_eq!(metadata.parent_agent_id, None);
565    }
566
567    #[test]
568    fn opencode_session_headers_are_correlation_only() {
569        // OpenCode's x-session-id / x-parent-session-id are correlation headers;
570        // they populate session_id for observability but do not trigger routing.
571        let metadata = Metadata::from_headers(&BTreeMap::from([
572            ("x-session-id".to_string(), "opencode-run".to_string()),
573            (
574                "x-parent-session-id".to_string(),
575                "opencode-parent".to_string(),
576            ),
577        ]));
578        assert_eq!(metadata.session_id.as_deref(), Some("opencode-run"));
579        assert!(!metadata.is_subagent);
580        assert_eq!(metadata.parent_agent_id, None);
581    }
582
583    #[test]
584    fn opencode_parent_header_is_not_a_parent_agent_id_source() {
585        // x-parent-session-id is not listed in HEADER_CONFIG for parent_agent_id;
586        // it must not surface as a parent regardless of adjacent session headers.
587        let metadata = Metadata::from_headers(&BTreeMap::from([
588            ("session-id".to_string(), "codex-run".to_string()),
589            (
590                "x-parent-session-id".to_string(),
591                "stray-parent".to_string(),
592            ),
593        ]));
594        assert_eq!(metadata.session_id.as_deref(), Some("codex-run"));
595        assert!(!metadata.is_subagent);
596        assert_eq!(metadata.parent_agent_id, None);
597    }
598
599    #[test]
600    fn dynamo_session_final_is_captured() {
601        let metadata = Metadata::from_headers(&BTreeMap::from([
602            ("x-dynamo-session-id".to_string(), "generic-run".to_string()),
603            (
604                "x-dynamo-parent-session-id".to_string(),
605                "generic-parent".to_string(),
606            ),
607            ("x-dynamo-session-final".to_string(), "true".to_string()),
608        ]));
609        assert_eq!(metadata.agent_id.as_deref(), Some("generic-run"));
610        assert_eq!(metadata.parent_agent_id.as_deref(), Some("generic-parent"));
611        assert_eq!(metadata.session_final, Some(true));
612
613        let not_final = Metadata::from_headers(&BTreeMap::from([
614            ("x-dynamo-session-id".to_string(), "generic-run".to_string()),
615            ("x-dynamo-session-final".to_string(), "false".to_string()),
616        ]));
617        assert_eq!(not_final.session_final, Some(false));
618    }
619
620    #[test]
621    fn sy_header_resolves_paths_in_order_and_descends_into_json() {
622        // Only the JSON-nested Codex path is present, so descent supplies the value.
623        let headers = BTreeMap::from([(
624            CODEX_TURN_METADATA_HEADER.to_string(),
625            serde_json::json!({ "session_id": "codex-session" }).to_string(),
626        )]);
627        assert_eq!(
628            sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
629            Some("codex-session")
630        );
631
632        // The explicit Switchyard header outranks the Codex path when both resolve.
633        let headers = BTreeMap::from([
634            (
635                SWITCHYARD_SESSION_ID_HEADER.to_string(),
636                "explicit".to_string(),
637            ),
638            (
639                CODEX_TURN_METADATA_HEADER.to_string(),
640                serde_json::json!({ "session_id": "codex-session" }).to_string(),
641            ),
642        ]);
643        assert_eq!(
644            sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
645            Some("explicit")
646        );
647
648        // Nothing resolves for an empty header set or an unknown field.
649        assert_eq!(
650            sy_header(&BTreeMap::new(), SWITCHYARD_SESSION_ID_HEADER),
651            None
652        );
653        assert_eq!(sy_header(&headers, "x-not-a-field"), None);
654    }
655
656    #[test]
657    fn codex_session_header_is_case_insensitive() {
658        let metadata = Metadata::from_headers(&BTreeMap::from([(
659            "Session-ID".to_string(),
660            "codex-run".to_string(),
661        )]));
662        assert_eq!(metadata.session_id.as_deref(), Some("codex-run"));
663    }
664
665    #[test]
666    fn explicit_subagent_flag_decides_without_a_parent_header() {
667        // Explicit `false` wins over presence-based inference even when no
668        // parent id accompanies it; the flag decides in both directions.
669        let metadata = Metadata::from_headers(&BTreeMap::from([
670            ("x-switchyard-is-subagent".to_string(), "false".to_string()),
671            ("x-openai-subagent".to_string(), "review".to_string()),
672        ]));
673        assert!(!metadata.is_subagent);
674
675        let metadata = Metadata::from_headers(&BTreeMap::from([(
676            "x-switchyard-is-subagent".to_string(),
677            "true".to_string(),
678        )]));
679        assert!(metadata.is_subagent);
680    }
681
682    #[test]
683    fn operator_agent_kind_does_not_suppress_harness_subagent_routing() {
684        // x-switchyard-agent-kind is an operator semantic label and must not filter
685        // routing signals from the harness (x-openai-subagent, x-switchyard-is-subagent).
686        let with_openai = Metadata::from_headers(&BTreeMap::from([
687            ("x-openai-subagent".to_string(), "review".to_string()),
688            (
689                "x-switchyard-agent-kind".to_string(),
690                "researcher".to_string(),
691            ),
692        ]));
693        assert!(with_openai.is_subagent);
694        assert!(with_openai.is_subagent_work());
695
696        let with_explicit = Metadata::from_headers(&BTreeMap::from([
697            ("x-switchyard-is-subagent".to_string(), "true".to_string()),
698            (
699                "x-switchyard-agent-kind".to_string(),
700                "researcher".to_string(),
701            ),
702        ]));
703        assert!(with_explicit.is_subagent);
704        assert!(with_explicit.is_subagent_work());
705    }
706
707    #[test]
708    fn subagent_work_requires_a_delegated_work_kind_when_kinded() {
709        // Kindless lineage (Claude Code child agent) counts as delegated work.
710        let claude_child = Metadata::from_headers(&BTreeMap::from([
711            ("x-claude-code-session-id".to_string(), "root".to_string()),
712            ("x-claude-code-agent-id".to_string(), "worker".to_string()),
713        ]));
714        assert!(claude_child.is_subagent_work());
715
716        // Codex delegated-work kinds route as sub-agent work.
717        let review = Metadata::from_headers(&BTreeMap::from([(
718            "x-openai-subagent".to_string(),
719            "review".to_string(),
720        )]));
721        assert!(review.is_subagent_work());
722
723        // Harness maintenance and unknown kinds stay on normal routing even
724        // though the lineage fact still marks them as child-agent requests.
725        for kind in ["compact", "memory_consolidation", "brand_new_kind"] {
726            let metadata = Metadata::from_headers(&BTreeMap::from([(
727                "x-openai-subagent".to_string(),
728                kind.to_string(),
729            )]));
730            assert!(metadata.is_subagent, "{kind} keeps the lineage fact");
731            assert!(!metadata.is_subagent_work(), "{kind} is not routed as work");
732        }
733
734        // A non-subagent request is never work, whatever its kind says.
735        assert!(!Metadata::default().is_subagent_work());
736    }
737}