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}