Skip to main content

claude_codes/io/
wrap_audit.rs

1//! Wire-fidelity ("fully wrapped") auditing for [`ClaudeOutput`] frames.
2//!
3//! A frame is **fully wrapped** when the typed model captures every field the
4//! CLI put on the wire — nothing is silently dropped, and nothing of substance
5//! is left sitting in an untyped `serde_json::Value` escape hatch.
6//!
7//! [`audit_frame`] checks three things for a raw frame:
8//!
9//! 1. It deserializes into a concrete [`ClaudeOutput`] variant at all.
10//! 2. Re-serializing that typed value reproduces every wire field (a lossless
11//!    round-trip), so no field was quietly dropped by the typed struct.
12//! 3. For `system` messages — whose non-`subtype` fields are otherwise absorbed
13//!    by the catch-all [`SystemMessage::data`](crate::io::SystemMessage) —
14//!    the `subtype` is one this crate models *and* its dedicated typed view
15//!    round-trips losslessly against `data`.
16//!
17//! This is most useful for subagent sessions, where the CLI emits
18//! `task_started` / `task_updated` / `task_notification` / `thinking_tokens`
19//! system frames carrying token-accounting fields that downstream consumers
20//! need without poking at raw JSON.
21
22use serde_json::Value;
23
24use super::message_types::SystemSubtype;
25use super::ClaudeOutput;
26
27/// The result of auditing a single raw frame for full typed coverage.
28#[derive(Debug, Clone)]
29pub struct FrameAudit {
30    /// The frame's `type` (e.g. `system`, `assistant`, `result`), or the raw
31    /// `type` string when the frame failed to deserialize.
32    pub message_type: String,
33    /// `true` when the typed model captures every wire field with no escape
34    /// hatch absorbing data.
35    pub fully_wrapped: bool,
36    /// Human-readable description of every wrapping gap found. Empty iff
37    /// [`FrameAudit::fully_wrapped`] is `true`.
38    pub issues: Vec<String>,
39}
40
41/// Audit a single raw frame (one parsed JSONL line) for full typed coverage.
42///
43/// See the [module docs](self) for what "fully wrapped" means.
44pub fn audit_frame(raw: &Value) -> FrameAudit {
45    let mut issues = Vec::new();
46
47    // 1. Must deserialize into a concrete typed variant.
48    let parsed: ClaudeOutput = match serde_json::from_value(raw.clone()) {
49        Ok(parsed) => parsed,
50        Err(e) => {
51            let message_type = raw
52                .get("type")
53                .and_then(Value::as_str)
54                .unwrap_or("<unknown>")
55                .to_string();
56            return FrameAudit {
57                message_type,
58                fully_wrapped: false,
59                issues: vec![format!(
60                    "does not deserialize into a typed ClaudeOutput: {e}"
61                )],
62            };
63        }
64    };
65    let message_type = parsed.message_type();
66
67    // 2. Top-level round-trip must not drop any wire field.
68    match serde_json::to_value(&parsed) {
69        Ok(reserialized) => diff_lost(raw, &reserialized, "", &mut issues),
70        Err(e) => issues.push(format!("typed value failed to re-serialize: {e}")),
71    }
72
73    // 3. System frames hide their payload behind the `data` catch-all, so a
74    //    top-level round-trip can't see field drops. Require a known subtype
75    //    whose dedicated struct round-trips losslessly against `data`.
76    if let ClaudeOutput::System(sys) = &parsed {
77        match (&sys.subtype, sys.typed_value()) {
78            (SystemSubtype::Unknown(s), _) => issues.push(format!(
79                "system subtype '{s}' is not modeled — its fields stay in an untyped Value"
80            )),
81            (_, Some(typed)) => diff_lost(&sys.data, &typed, "", &mut issues),
82            (subtype, None) => issues.push(format!(
83                "system subtype '{subtype}' has no dedicated typed view"
84            )),
85        }
86    }
87
88    FrameAudit {
89        message_type,
90        fully_wrapped: issues.is_empty(),
91        issues,
92    }
93}
94
95/// Panic with a detailed report unless `raw` is fully wrapped.
96pub fn assert_fully_wrapped(raw: &Value) {
97    let audit = audit_frame(raw);
98    assert!(
99        audit.fully_wrapped,
100        "frame (type={}) is not fully wrapped:\n  - {}\nraw frame: {}",
101        audit.message_type,
102        audit.issues.join("\n  - "),
103        raw,
104    );
105}
106
107/// `true` for wire values that hold no information — `null` or an empty
108/// array/object — which a typed model may legitimately omit on serialize.
109fn carries_no_data(v: &Value) -> bool {
110    match v {
111        Value::Null => true,
112        Value::Array(a) => a.is_empty(),
113        Value::Object(o) => o.is_empty(),
114        _ => false,
115    }
116}
117
118/// Record every place where `wire` carries data that `typed` (a typed
119/// re-serialization) lost.
120///
121/// Only *losses* are reported. Keys the typed model adds that the wire omitted
122/// (serde defaults like `permission_denials: []`) and wire `null`s that
123/// serialize away (`skip_serializing_if = "Option::is_none"`) are not data
124/// loss and are ignored.
125fn diff_lost(wire: &Value, typed: &Value, path: &str, out: &mut Vec<String>) {
126    match (wire, typed) {
127        (Value::Object(w), Value::Object(t)) => {
128            for (key, wire_val) in w {
129                let child = format!("{path}/{key}");
130                match t.get(key) {
131                    Some(typed_val) => diff_lost(wire_val, typed_val, &child, out),
132                    // A wire `null` or empty collection carries no data, so a
133                    // `skip_serializing_if` omission of it is not a loss.
134                    None if carries_no_data(wire_val) => {}
135                    None => out.push(format!(
136                        "field `{child}` present on the wire but dropped by the typed model"
137                    )),
138                }
139            }
140        }
141        (Value::Array(w), Value::Array(t)) => {
142            if w.len() != t.len() {
143                out.push(format!(
144                    "array `{path}` has {} element(s) on the wire but {} after typed round-trip",
145                    w.len(),
146                    t.len()
147                ));
148            } else {
149                for (i, (wv, tv)) in w.iter().zip(t.iter()).enumerate() {
150                    diff_lost(wv, tv, &format!("{path}/{i}"), out);
151                }
152            }
153        }
154        _ => {
155            if wire != typed {
156                out.push(format!(
157                    "value at `{path}` changed on typed round-trip (wire={wire}, typed={typed})"
158                ));
159            }
160        }
161    }
162}