forge_test_harness/sdk_wire.rs
1//! Utilities for the wire-conformance harness.
2//!
3//! Intentionally separate from `forge-sdk` so the SDK crate surface
4//! stays focused on what library consumers use. Nothing here is public
5//! SDK API.
6//!
7//! ## Two testing modes
8//!
9//! Each scenario runs in one of two modes:
10//!
11//! - **Live mode** (`FORGE_WIRE_CAPTURE=1`): spawn real `claude`, drive
12//! forge-sdk through the scenario, capture the full stdin/stdout
13//! trace to `target/wire-traces/`. Burns API tokens. Updates the
14//! committed baseline on request.
15//! - **Replay mode** (default): load the committed baseline trace
16//! fixture for the scenario, feed every inbound line through
17//! `forge_sdk::transport::codec::decode_dispatch`, assert everything
18//! decodes cleanly (including no `DecodedLine::Unknown` variants
19//! unless explicitly expected). Runs on every `cargo check` /
20//! `just check` - no API cost.
21//!
22//! ## Baselines
23//!
24//! Committed baselines live under `crates/forge-test-harness/baselines/sdk/
25//! <cli-version>/<scenario>.jsonl`. Each scenario knows its own name;
26//! the `cli-version` dir rotates when we bump the pinned CLI version
27//! through the upgrade ritual.
28
29pub mod session_redact;
30
31use std::sync::Arc;
32
33use forge_sdk::Error;
34use forge_sdk::OptionsBuilder;
35use forge_sdk::transport::codec::{DecodedLine, decode_dispatch};
36use parking_lot::Mutex;
37
38/// One captured line in a trace.
39#[derive(Default, Debug)]
40pub struct TraceLog {
41 /// `(direction, line)` pairs in the order they happened on the wire.
42 /// Direction is `"in"` (CLI → SDK, from stdout) or `"out"` (SDK → CLI, to stdin).
43 entries: Vec<(&'static str, String)>,
44}
45
46impl TraceLog {
47 /// Serialise as JSONL: one `{"dir":"in"|"out","line":"..."}` per line,
48 /// redacting on the way out.
49 ///
50 /// This is the redaction point for both live-capture write sites and
51 /// for baseline promotion, which copies the file this produces. Two
52 /// committed artifacts bypass it: the `real_session_*` baselines,
53 /// redacted harder by [`session_redact::redact_session_path`], and
54 /// the reference captures under `.claude/skills/claude-cli-upgrade/`,
55 /// redacted by nothing.
56 ///
57 /// # Errors
58 ///
59 /// If any entry fails to serialise or cannot be redacted safely.
60 pub fn to_jsonl(&self) -> Result<String, String> {
61 let redactor = session_redact::WireRedactor::for_trace(
62 self.entries.iter().map(|(_, line)| line.as_str()),
63 )?;
64 self.encode(|line| redactor.redact_line(line))
65 }
66
67 /// Serialise without redacting, for a diagnostic dump that is never
68 /// promoted - a spawn failure is diagnosed from the real cwd.
69 ///
70 /// # Errors
71 ///
72 /// If any entry fails to serialise.
73 fn to_jsonl_verbatim(&self) -> Result<String, String> {
74 self.encode(|line| Ok(line.to_string()))
75 }
76
77 /// Serialise for `kind`.
78 ///
79 /// # Errors
80 ///
81 /// As the chosen serialisation.
82 fn to_jsonl_for(&self, kind: DumpKind) -> Result<String, String> {
83 match kind {
84 DumpKind::Promotable => self.to_jsonl(),
85 DumpKind::Diagnostic => self.to_jsonl_verbatim(),
86 }
87 }
88
89 fn encode(
90 &self,
91 mut per_line: impl FnMut(&str) -> Result<String, String>,
92 ) -> Result<String, String> {
93 let mut body = String::new();
94 for (dir, line) in &self.entries {
95 let obj = serde_json::json!({ "dir": dir, "line": per_line(line)? });
96 body.push_str(&serde_json::to_string(&obj).map_err(|e| format!("serialise: {e}"))?);
97 body.push('\n');
98 }
99 Ok(body)
100 }
101
102 /// Slice of inbound lines (CLI → SDK).
103 pub fn inbound(&self) -> Vec<&str> {
104 self.entries.iter().filter(|(d, _)| *d == "in").map(|(_, l)| l.as_str()).collect()
105 }
106
107 /// Slice of outbound lines (SDK → CLI).
108 pub fn outbound(&self) -> Vec<&str> {
109 self.entries.iter().filter(|(d, _)| *d == "out").map(|(_, l)| l.as_str()).collect()
110 }
111}
112
113/// What a dump is for, deciding its filename prefix AND whether it is
114/// redacted together, so the two cannot disagree: `README.md` promotes
115/// with `cp target/wire-traces/capture-*.jsonl`, so anything that glob
116/// reaches must be redacted.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118enum DumpKind {
119 /// Inside the promotion glob, so redacted.
120 Promotable,
121 /// Outside it, so it keeps the paths a failed run is diagnosed from.
122 Diagnostic,
123}
124
125impl DumpKind {
126 fn prefix(self) -> &'static str {
127 match self {
128 Self::Promotable => "capture",
129 Self::Diagnostic => "diag",
130 }
131 }
132}
133
134/// Install inbound + outbound wire-tee callbacks on a builder so the
135/// resulting [`forge_sdk::Options`] captures every stream-json line
136/// the SDK exchanges with the `claude` subprocess. Returns the
137/// configured builder + a shared handle to the trace log. Wire
138/// capture is a spawn-time configuration concern, not a
139/// transport-injection one.
140pub fn attach_recording(builder: OptionsBuilder) -> (OptionsBuilder, Arc<Mutex<TraceLog>>) {
141 let log = Arc::new(Mutex::new(TraceLog::default()));
142 let log_in = log.clone();
143 let log_out = log.clone();
144 let builder = builder
145 .tee_inbound(move |line: &str| {
146 log_in.lock().entries.push(("in", line.to_string()));
147 })
148 .tee_outbound(move |line: &str| {
149 log_out.lock().entries.push(("out", line.to_string()));
150 });
151 (builder, log)
152}
153
154/// Pinned CLI version these baselines were captured against.
155///
156/// Bumping it means re-capturing the live-capture baselines against
157/// the new CLI and promoting them into a fresh
158/// `baselines/sdk/<version>/`. Replay is expected to fail in between.
159///
160/// `just conformance-capture-sdk <test>` takes a NEXTEST TEST NAME, not
161/// a baseline name; the two namespaces diverge (`worker_spawn.jsonl`
162/// comes from `worker_spawn_scenario`). It substring-matches, so a
163/// loose argument fires several live captures and bills for all of
164/// them. The `real_session_*` baselines are not capture-produced; their
165/// recipe is in `.claude/skills/claude-cli-upgrade/`, which also holds
166/// the rest of the ritual.
167pub const PINNED_CLI_VERSION: &str = "2.1.280";
168
169/// Directory holding the committed trace baselines for the pinned CLI
170/// version. Resolves to
171/// `crates/forge-test-harness/baselines/sdk/<PINNED_CLI_VERSION>/`.
172pub fn baseline_dir() -> std::path::PathBuf {
173 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
174 .join("baselines")
175 .join("sdk")
176 .join(PINNED_CLI_VERSION)
177}
178
179/// Directory holding the legacy-surface corpus for the pinned CLI
180/// version: the same scenarios captured with the CLI's ambient routing
181/// on a model id it does not recognize. The CLI tailors its tool surface
182/// per model, so this corpus records the surface a proxy-backed account
183/// actually gets. That surface is neither identical to the recognized
184/// one nor a superset of it: measure both rather than assuming the older
185/// "unrecognized ids keep the full legacy surface" story still holds.
186/// Resolves to `baseline_dir()`'s `legacy-surface/` subdirectory, which
187/// keeps the two corpora out of each other's diffs.
188pub fn legacy_baseline_dir() -> std::path::PathBuf {
189 baseline_dir().join("legacy-surface")
190}
191
192/// Load a trace fixture by scenario name.
193///
194/// Looks up `baselines/<PINNED_CLI_VERSION>/<scenario>.jsonl` and returns
195/// the parsed `(direction, line)` pairs. Panics if the baseline is
196/// missing - scenarios are expected to ship their baseline on creation.
197///
198/// # Panics
199///
200/// If the fixture file is missing, unreadable, or malformed.
201pub fn load_baseline(scenario: &str) -> TraceLog {
202 load_baseline_from(&baseline_dir(), scenario)
203}
204
205/// [`load_baseline`] against an explicit baseline directory - the
206/// legacy-surface corpus lives in a sibling directory of the pinned
207/// one and is loaded the same way.
208///
209/// # Panics
210///
211/// If the fixture file is missing, unreadable, or malformed.
212pub fn load_baseline_from(dir: &std::path::Path, scenario: &str) -> TraceLog {
213 let path = dir.join(format!("{scenario}.jsonl"));
214 let body = std::fs::read_to_string(&path).unwrap_or_else(|e| {
215 panic!(
216 "missing baseline for scenario '{scenario}' at {}: {e}. \
217 Run `FORGE_WIRE_CAPTURE=1 cargo nextest run -p forge-test-harness \
218 --run-ignored only <live_capture_test>` to capture it.",
219 path.display()
220 )
221 });
222 let mut log = TraceLog::default();
223 for (i, line) in body.lines().enumerate() {
224 if line.trim().is_empty() {
225 continue;
226 }
227 let obj: serde_json::Value = serde_json::from_str(line).unwrap_or_else(|e| {
228 panic!("{}:{}: malformed baseline entry: {e}", path.display(), i + 1)
229 });
230 let dir_str = obj.get("dir").and_then(|v| v.as_str()).unwrap_or("");
231 let line_val = obj.get("line").and_then(|v| v.as_str()).unwrap_or("").to_string();
232 let dir_static: &'static str = match dir_str {
233 "in" => "in",
234 "out" => "out",
235 other => {
236 panic!("{}:{}: bad dir '{other}' (expected 'in' or 'out')", path.display(), i + 1)
237 }
238 };
239 log.entries.push((dir_static, line_val));
240 }
241 log
242}
243
244/// Outcome of running every inbound line in a log through
245/// `decode_dispatch`. Split across known decode categories plus Unknowns
246/// and outright errors.
247#[derive(Debug, Default)]
248pub struct DecodeReport {
249 /// Count of inbound lines that decoded to a regular `Message`.
250 pub messages: usize,
251 /// Count of inbound lines that decoded to a `ControlRequest`.
252 pub controls: usize,
253 /// Count of `control_cancel_request` frames.
254 pub control_cancels: usize,
255 /// Count of `control_response` frames (replies from the CLI to our
256 /// outbound `control_requests` - initialize, `set_model`, interrupt, …).
257 pub control_responses: usize,
258 /// Unrecognised `type` values seen. Each entry is the `type` string
259 /// the CLI sent.
260 pub unknown_types: Vec<String>,
261 /// Unrecognised `control_request.subtype` values seen.
262 pub unknown_control_subtypes: Vec<String>,
263 /// `system` subtypes that fell through to the generic
264 /// `Message::System` bucket and are not in
265 /// [`EXPECTED_GENERIC_SYSTEM_SUBTYPES`].
266 pub unmodelled_system_subtypes: Vec<String>,
267 /// Content-block `type` values that decoded to
268 /// `ContentBlock::Unknown`.
269 pub unmodelled_content_block_types: Vec<String>,
270 /// Wire values a `#[serde(other)]` catch-all absorbed. Each entry is
271 /// `<json pointer>: <wire value> -> <fallback>`.
272 pub absorbed_by_catch_all: Vec<String>,
273 /// Hard decode errors - line was recognised but inner shape was
274 /// invalid, or JSON malformed.
275 pub decode_errors: Vec<(usize, String)>,
276}
277
278impl DecodeReport {
279 /// True if no Unknowns or decode errors were seen.
280 pub fn is_clean(&self) -> bool {
281 self.unknown_types.is_empty()
282 && self.unknown_control_subtypes.is_empty()
283 && self.unmodelled_system_subtypes.is_empty()
284 && self.unmodelled_content_block_types.is_empty()
285 && self.absorbed_by_catch_all.is_empty()
286 && self.decode_errors.is_empty()
287 }
288}
289
290/// `system` subtypes the decoder deliberately leaves generic. Everything
291/// else reaching `Message::System` is a subtype nobody has modelled yet.
292///
293/// The decoder answers "is this modelled?" on its own - a typed subtype
294/// never reaches `Message::System` - so this list holds only the
295/// exceptions rather than mirroring every modelled subtype. A new CLI
296/// subtype is absent from the list and goes red.
297///
298/// An entry is only harmless while that subtype has no typed variant.
299/// `SystemRepr` is untagged, so a subtype that IS modelled degrades into
300/// the generic bucket when its payload shape drifts, and today that is
301/// caught only because the subtype is missing from this list. Give `init`
302/// or `status` a typed variant and its entry here turns load-bearing: it
303/// starts suppressing payload drift on a shape the decoder claims to
304/// model. Remove the entry in the same change that adds the variant.
305pub const EXPECTED_GENERIC_SYSTEM_SUBTYPES: &[&str] = &["init", "status"];
306
307/// Serialised forms of the catch-all variants that stand in for a wire
308/// value the decoder does not model (`#[serde(other)]`).
309///
310/// `"other"` is not defensive padding. Seven of the eight reachable
311/// catch-alls are unit variants that serialise to a bare `"unknown"`;
312/// `WorkflowProgressEvent::Other` is internally tagged and serialises to
313/// `{"type":"other"}`, so what changes is the nested `type` key, which
314/// exists on both sides and is reached through the object walk.
315const CATCH_ALL_MARKERS: &[&str] = &["unknown", "other"];
316
317/// Run a live-capture scenario end-to-end: build options, spawn a recorded
318/// `claude`, drive the scenario to a `Result` frame, dump the trace to
319/// `target/wire-traces/`, assert every inbound line decodes cleanly.
320///
321/// Caller supplies:
322/// - `scenario`: a short slug (e.g. `"bash_tool"`) used in trace filenames.
323/// - `options`: fully-built [`forge_sdk::Options`] - set tools,
324/// `permission_mode`, hooks, MCP servers, etc. here.
325/// - `drive`: async closure that drives the scenario once the client is
326/// ready. Typically calls `send_user_message(...)` and may register
327/// turn-specific state.
328///
329/// # Skip semantics
330///
331/// When `FORGE_WIRE_CAPTURE` is unset, returns `Ok(None)` immediately
332/// without touching the network - scenarios compile and link in CI but
333/// only run when the developer opts in.
334///
335/// # Errors
336///
337/// Any [`forge_sdk::Error`] surfaced during spawn / drive / drain.
338/// Panics on trace-dump failure or decode-completeness failure - those
339/// are harness invariants, not recoverable errors.
340///
341/// # Panics
342///
343/// - Transport panics are propagated.
344/// - Trace file I/O failure.
345/// - Decode-completeness regression in the captured inbound frames.
346pub async fn run_live_scenario<F, Fut>(
347 scenario: &str,
348 options: forge_sdk::Options,
349 drive: F,
350) -> Result<Option<()>, Error>
351where
352 F: FnOnce(forge_sdk::Client, forge_sdk::ClientEvents) -> Fut,
353 Fut: std::future::Future<Output = Result<(forge_sdk::Client, forge_sdk::ClientEvents), Error>>,
354{
355 if std::env::var("FORGE_WIRE_CAPTURE").is_err() {
356 eprintln!("FORGE_WIRE_CAPTURE not set; skipping scenario {scenario}");
357 return Ok(None);
358 }
359
360 // Install wire-recording tees on the supplied options. The harness
361 // takes a fully-configured Options (built by the scenario), so we
362 // wrap it: rebuild via `OptionsBuilder::from_options` if it
363 // existed, else mutate the fields directly.
364 let log_arc = Arc::new(Mutex::new(TraceLog::default()));
365 let options = {
366 let log_in = log_arc.clone();
367 let log_out = log_arc.clone();
368 let mut opts = options;
369 opts.tee_inbound = Some(Arc::new(move |line: &str| {
370 log_in.lock().entries.push(("in", line.to_string()));
371 }));
372 opts.tee_outbound = Some(Arc::new(move |line: &str| {
373 log_out.lock().entries.push(("out", line.to_string()));
374 }));
375 opts
376 };
377
378 // Dump the trace regardless of how the test ends.
379 let dump = |log: &TraceLog, tag: &str, kind: DumpKind| -> std::path::PathBuf {
380 let target =
381 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/wire-traces");
382 std::fs::create_dir_all(&target).expect("create wire-traces dir");
383 let ts = std::time::SystemTime::now()
384 .duration_since(std::time::UNIX_EPOCH)
385 .map_or(0, |d| d.as_secs());
386 let path = target.join(format!("{}-{tag}-{ts}.jsonl", kind.prefix()));
387 let body = log.to_jsonl_for(kind).expect("redact + serialise trace");
388 std::fs::write(&path, body).expect("write trace");
389 path
390 };
391
392 let (client, events) = forge_sdk::Client::spawn(options).await.inspect_err(|_e| {
393 let log = log_arc.lock();
394 let path = dump(&log, &format!("{scenario}-spawn-failed"), DumpKind::Diagnostic);
395 eprintln!(
396 "{scenario}: Client::spawn failed, trace at {} [in={} out={}]",
397 path.display(),
398 log.inbound().len(),
399 log.outbound().len()
400 );
401 })?;
402
403 // Hand off to the scenario driver - on failure dump a partial trace.
404 let (client, mut events) = match drive(client, events).await {
405 Ok(pair) => pair,
406 Err(e) => {
407 let log = log_arc.lock();
408 let path = dump(&log, &format!("{scenario}-drive-failed"), DumpKind::Diagnostic);
409 eprintln!(
410 "{scenario}: drive failed, trace at {} [in={} out={}]",
411 path.display(),
412 log.inbound().len(),
413 log.outbound().len()
414 );
415 return Err(e);
416 }
417 };
418
419 // Drain until a `Result` frame, then close stdin and drain to EOF.
420 //
421 // We can't close stdin BEFORE the drain - the CLI is still
422 // emitting `hook_callback` / `mcp_message` control_requests during
423 // the turn, and our handlers reply on stdin. Closing too early
424 // breaks the pipe mid-handler. So the order is:
425 //
426 // 1. Read until `Result` (with a short per-read timeout in case
427 // `drive` already drained everything inside its closure - in
428 // that case the timeout fires, we assume drain is done, and
429 // fall through to end_input).
430 // 2. Close stdin so the CLI exits cleanly.
431 // 3. Drain any trailing frames to EOF.
432 // Per-read, not total: it bounds SILENCE between frames. A real
433 // compaction is the longest legitimate gap - the CLI acknowledges
434 // `/compact`, then says nothing until the boundary frame - and at
435 // 30s it was cut off mid-compaction, capturing a scenario that
436 // never reached the frame it exists to record.
437 let read_timeout = std::time::Duration::from_secs(120);
438 let mut summary: Option<(u64, Option<f64>, u64)> = None;
439 loop {
440 match tokio::time::timeout(read_timeout, events.recv()).await {
441 Ok(Some(Ok(msg))) => {
442 if let forge_primitives::Message::Result {
443 num_turns,
444 total_cost_usd,
445 duration_ms,
446 ..
447 } = &msg
448 {
449 summary = Some((*num_turns, *total_cost_usd, *duration_ms));
450 break;
451 }
452 }
453 Ok(None) => break,
454 Ok(Some(Err(e))) => {
455 let log = log_arc.lock();
456 let path = dump(&log, &format!("{scenario}-drain-failed"), DumpKind::Diagnostic);
457 eprintln!(
458 "{scenario}: drain failed, trace at {} [in={} out={}]",
459 path.display(),
460 log.inbound().len(),
461 log.outbound().len()
462 );
463 return Err(e);
464 }
465 Err(_timeout) => {
466 // No frame for `read_timeout`. Two legitimate causes:
467 // (a) `drive` already drained the `Result` inside its
468 // closure - common for scenarios that issue
469 // follow-up control_requests after consuming the
470 // turn (e.g. `context_usage`).
471 // (b) The CLI is genuinely hung. The harness can't
472 // distinguish the two without a signal from
473 // `drive`. Log loudly so a real hang surfaces in
474 // the test output instead of silently passing as
475 // "no Result frame seen". `summary` stays `None`,
476 // which the post-drain summary line already
477 // reports as "NO Result frame".
478 eprintln!(
479 "{scenario}: read_timeout fired with no Result \
480 frame seen - `drive` may have drained it OR the \
481 CLI is hung. Proceeding to cleanup."
482 );
483 break;
484 }
485 }
486 }
487
488 // Close stdin now that no more handler writes are expected.
489 if let Err(e) = client.end_input().await {
490 eprintln!("{scenario}: end_input failed, continuing to disconnect: {e}");
491 }
492 // Drain any trailing frames that arrive between end_input and EOF
493 // (some CLI paths emit a final `system:close` or trailing
494 // `rate_limit_event`). Errors from writes during trailing
495 // handlers (`BrokenPipe`) are expected here - stdin is closed.
496 // Other errors (decoder regressions, malformed JSON, mid-drain
497 // shape drift) are NOT expected and would otherwise be hidden;
498 // log them loudly so they surface in the captured trace's
499 // diagnostics instead of being promoted into a baseline.
500 let trailing_timeout = std::time::Duration::from_secs(5);
501 while let Ok(evt) = tokio::time::timeout(trailing_timeout, events.recv()).await {
502 match evt {
503 Some(Ok(_)) => {}
504 None => break,
505 Some(Err(Error::Io(io))) if io.kind() == std::io::ErrorKind::BrokenPipe => break,
506 Some(Err(e)) => {
507 eprintln!(
508 "{scenario}: trailing-drain saw non-BrokenPipe error: {e}. \
509 Continuing to dump partial trace."
510 );
511 break;
512 }
513 }
514 }
515 if let Err(e) = client.disconnect().await {
516 eprintln!("{scenario}: disconnect failed (non-fatal, trace already captured): {e}");
517 }
518
519 // Successful (or at least drained) run - dump the trace, verify every
520 // inbound line decodes. Failure here is a hard panic.
521 let log = log_arc.lock();
522 let trace_path = dump(&log, scenario, DumpKind::Promotable);
523 let report = decode_all_inbound(&log);
524 assert!(
525 report.is_clean(),
526 "{scenario}: decode regressions in captured trace\n\
527 trace: {}\n\
528 report: {report:#?}",
529 trace_path.display()
530 );
531 match summary {
532 Some((turns, cost, dur)) => eprintln!(
533 "{scenario}: captured in={} out={} | turns={turns} cost_usd={cost:?} \
534 duration_ms={dur} | trace={}",
535 log.inbound().len(),
536 log.outbound().len(),
537 trace_path.display()
538 ),
539 None => eprintln!(
540 "{scenario}: captured in={} out={} | NO Result frame (early termination, \
541 drive likely drained it) | trace={}",
542 log.inbound().len(),
543 log.outbound().len(),
544 trace_path.display()
545 ),
546 }
547
548 Ok(Some(()))
549}
550
551/// Record every fallback the decoder took on `msg`, which decoded from
552/// `line`.
553///
554/// `decode_dispatch` only reports a fallback for the top-level `type` and
555/// for `control_request.subtype`. Every other discriminator the decoder
556/// does not model is absorbed silently, in one of two ways that need
557/// different detection:
558///
559/// - **Preserved.** `Message::System` and `ContentBlock::Unknown` keep
560/// the payload verbatim, so the decoded value is indistinguishable from
561/// a modelled one. Detected by asking which bucket it landed in.
562/// - **Replaced.** A `#[serde(other)]` variant discards the wire value,
563/// so re-encoding emits the marker instead. Detected by re-encoding and
564/// comparing, which the preserved kind is invisible to because it
565/// round-trips exactly.
566fn record_fallbacks(msg: &forge_primitives::Message, line: &str, report: &mut DecodeReport) {
567 use forge_primitives::{ContentBlock, Message};
568
569 if let Message::System { subtype, .. } = msg
570 && !EXPECTED_GENERIC_SYSTEM_SUBTYPES.contains(&subtype.as_str())
571 {
572 report.unmodelled_system_subtypes.push(subtype.clone());
573 }
574
575 let blocks = match msg {
576 Message::Assistant { message, .. } => Some(&message.content),
577 Message::User { message, .. } => Some(&message.content),
578 _ => None,
579 };
580 for block in blocks.into_iter().flatten() {
581 if let ContentBlock::Unknown { type_str, .. } = block {
582 report.unmodelled_content_block_types.push(type_str.clone());
583 }
584 }
585
586 let (Ok(raw), Ok(reencoded)) =
587 (serde_json::from_str::<serde_json::Value>(line), serde_json::to_value(msg))
588 else {
589 return;
590 };
591 collect_catch_all_drift(&raw, &reencoded, "", &mut report.absorbed_by_catch_all);
592}
593
594/// Walk `raw` and `reencoded` together, recording every shared key whose
595/// value the decoder replaced with a catch-all marker.
596///
597/// Only keys present on both sides are compared, so a field the decoder
598/// drops is not mistaken for one it corrupted, and only a change *into* a
599/// marker counts - which is why the two benign round-trip differences in
600/// the committed baselines (user `content` normalising a bare string into
601/// a block list, and `0` formatting as `0.0`) need no exception here.
602fn collect_catch_all_drift(
603 raw: &serde_json::Value,
604 reencoded: &serde_json::Value,
605 path: &str,
606 out: &mut Vec<String>,
607) {
608 use serde_json::Value;
609 match (raw, reencoded) {
610 (Value::Object(a), Value::Object(b)) => {
611 for (key, av) in a {
612 if let Some(bv) = b.get(key) {
613 collect_catch_all_drift(av, bv, &format!("{path}/{key}"), out);
614 }
615 }
616 }
617 (Value::Array(a), Value::Array(b)) if a.len() == b.len() => {
618 for (i, (av, bv)) in a.iter().zip(b).enumerate() {
619 collect_catch_all_drift(av, bv, &format!("{path}/{i}"), out);
620 }
621 }
622 (_, Value::String(marker))
623 if CATCH_ALL_MARKERS.contains(&marker.as_str()) && raw != reencoded =>
624 {
625 out.push(format!("{path}: {raw} -> \"{marker}\""));
626 }
627 _ => {}
628 }
629}
630
631/// Run every inbound line from `log` through `decode_dispatch`, returning
632/// a categorised report.
633pub fn decode_all_inbound(log: &TraceLog) -> DecodeReport {
634 use forge_sdk::control::ControlRequestKind;
635 let mut report = DecodeReport::default();
636 for (idx, line) in log.inbound().iter().enumerate() {
637 if line.trim().is_empty() {
638 continue;
639 }
640 match decode_dispatch(line, (idx + 1) as u64) {
641 DecodedLine::Message(msg) => {
642 report.messages += 1;
643 record_fallbacks(&msg, line, &mut report);
644 }
645 DecodedLine::Control(req) => {
646 report.controls += 1;
647 if let ControlRequestKind::Unknown { subtype, .. } = &req.request {
648 report.unknown_control_subtypes.push(subtype.clone());
649 }
650 }
651 DecodedLine::ControlCancel { .. } => report.control_cancels += 1,
652 DecodedLine::ControlResponse { .. } => report.control_responses += 1,
653 DecodedLine::Unknown { type_str, .. } => {
654 report.unknown_types.push(type_str);
655 }
656 DecodedLine::Malformed { line, reason } => {
657 report.decode_errors.push((idx, format!("line {line}: {reason}")));
658 }
659 }
660 }
661 report
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 /// Serialisation is the chokepoint both trace-write sites go
669 /// through, so neither can put an unredacted line on disk. The
670 /// owner name in the second entry is only reachable because the
671 /// first entry named a home path.
672 #[test]
673 fn to_jsonl_redacts_every_entry() {
674 let mut log = TraceLog::default();
675 log.entries.push((
676 "in",
677 r#"{"type":"system","cwd":"/Users/alexandra/Projects/forge"}"#.to_string(),
678 ));
679 log.entries.push((
680 "in",
681 concat!(
682 r#"{"type":"control_response","response":{"response":{"#,
683 r#""account":{"email":"a@b.co"},"#,
684 r#""note":"see ~/.claude-alt and `alexandra/proxy`"}}}"#
685 )
686 .to_string(),
687 ));
688
689 let body = log.to_jsonl().expect("jsonl serialise");
690
691 for leak in ["alexandra", "a@b.co", ".claude-alt"] {
692 assert!(!body.contains(leak), "to_jsonl leaked {leak}:\n{body}");
693 }
694 }
695
696 /// Both halves together: what the promotion glob reaches is
697 /// redacted, and what keeps its paths sits outside it. Asserted
698 /// apart, either could drift alone.
699 #[test]
700 fn only_the_dump_outside_the_promotion_glob_keeps_its_paths() {
701 let mut log = TraceLog::default();
702 log.entries.push((
703 "in",
704 r#"{"type":"system","cwd":"/Users/alexandra/Projects/forge"}"#.to_string(),
705 ));
706 let cwd = "/Users/alexandra/Projects/forge";
707
708 let promotable = log.to_jsonl_for(DumpKind::Promotable).expect("jsonl serialise");
709 let diagnostic = log.to_jsonl_for(DumpKind::Diagnostic).expect("jsonl serialise");
710
711 assert_eq!(DumpKind::Promotable.prefix(), "capture");
712 assert!(!promotable.contains(cwd), "a promotable dump kept the path: {promotable}");
713 assert_ne!(DumpKind::Diagnostic.prefix(), "capture");
714 assert!(diagnostic.contains(cwd), "a diagnostic dump lost the path: {diagnostic}");
715 }
716
717 fn report_for(line: &str) -> DecodeReport {
718 let mut log = TraceLog::default();
719 log.entries.push(("in", line.to_string()));
720 decode_all_inbound(&log)
721 }
722
723 /// Nothing else may fire, or the assertion above it proves nothing:
724 /// a report can go dirty for a reason that has no bearing on the
725 /// discriminator under test.
726 fn assert_only(report: &DecodeReport, field: &str) {
727 let mut others: Vec<&str> = Vec::new();
728 if field != "system" && !report.unmodelled_system_subtypes.is_empty() {
729 others.push("system");
730 }
731 if field != "block" && !report.unmodelled_content_block_types.is_empty() {
732 others.push("block");
733 }
734 if field != "catch_all" && !report.absorbed_by_catch_all.is_empty() {
735 others.push("catch_all");
736 }
737 assert!(report.unknown_types.is_empty(), "unknown_types fired: {report:#?}");
738 assert!(report.decode_errors.is_empty(), "decode_errors fired: {report:#?}");
739 assert!(others.is_empty(), "expected only {field}, also got {others:?}: {report:#?}");
740 }
741
742 const NEW_SUBTYPE: &str = r#"{"type":"system","subtype":"context_compaction_started",
743 "session_id":"s1","uuid":"u1","reason":"auto"}"#;
744
745 #[test]
746 fn unmodelled_system_subtype_is_reported() {
747 let report = report_for(NEW_SUBTYPE);
748 assert_eq!(report.unmodelled_system_subtypes, ["context_compaction_started"]);
749 assert_only(&report, "system");
750 assert!(!report.is_clean());
751 }
752
753 #[test]
754 fn intentionally_generic_system_subtype_stays_clean() {
755 let report = report_for(r#"{"type":"system","subtype":"init","session_id":"s1"}"#);
756 assert!(report.is_clean(), "{report:#?}");
757 assert_eq!(report.messages, 1);
758 }
759
760 #[test]
761 fn modelled_system_subtype_stays_clean() {
762 let report = report_for(
763 r#"{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,
764 "estimated_tokens_delta":50,"uuid":"u1","session_id":"s1"}"#,
765 );
766 assert!(report.is_clean(), "{report:#?}");
767 assert!(report.unmodelled_system_subtypes.is_empty());
768 }
769
770 #[test]
771 fn unmodelled_content_block_type_is_reported() {
772 let report = report_for(
773 r#"{"type":"assistant","session_id":"s1","parent_tool_use_id":null,
774 "message":{"id":"m1","role":"assistant","model":"claude-opus-5",
775 "content":[{"type":"redacted_thinking","data":"abc"}]}}"#,
776 );
777 assert_eq!(report.unmodelled_content_block_types, ["redacted_thinking"]);
778 assert_only(&report, "block");
779 assert!(!report.is_clean());
780 }
781
782 #[test]
783 fn catch_all_absorbing_a_wire_value_is_reported() {
784 let report = report_for(
785 r#"{"type":"assistant","session_id":"s1","parent_tool_use_id":null,
786 "message":{"id":"m1","role":"assistant","model":"claude-opus-5",
787 "stop_reason":"refusal","content":[{"type":"text","text":"hi"}]}}"#,
788 );
789 assert_eq!(
790 report.absorbed_by_catch_all,
791 [r#"/message/stop_reason: "refusal" -> "unknown""#]
792 );
793 assert_only(&report, "catch_all");
794 assert!(!report.is_clean());
795 }
796
797 /// The two round-trip differences the committed baselines actually
798 /// contain. Neither is a catch-all, and the check must say so.
799 #[test]
800 fn benign_round_trip_differences_stay_clean() {
801 let bare_string_content = report_for(
802 r#"{"type":"user","session_id":"s1","parent_tool_use_id":null,
803 "message":{"role":"user","content":"<local-command-stdout>ok</local-command-stdout>"}}"#,
804 );
805 assert!(bare_string_content.is_clean(), "{bare_string_content:#?}");
806
807 let integral_cost = report_for(
808 r#"{"type":"result","subtype":"success","session_id":"s1","is_error":false,
809 "num_turns":1,"duration_ms":10,"duration_api_ms":9,"total_cost_usd":0}"#,
810 );
811 assert!(integral_cost.is_clean(), "{integral_cost:#?}");
812 }
813
814 /// The `"other"` half of [`CATCH_ALL_MARKERS`]: this one changes a
815 /// nested `type` key rather than a leaf value, so it is only found
816 /// because the walk descends into arrays and objects.
817 #[test]
818 fn nested_other_marker_is_reported() {
819 let report = report_for(
820 r#"{"type":"system","subtype":"task_progress","task_id":"t1",
821 "description":"d","uuid":"u1","session_id":"s1",
822 "usage":{"total_tokens":1,"tool_uses":2,"duration_ms":3},
823 "workflow_progress":[{"type":"workflowRetry","index":0}]}"#,
824 );
825 assert_eq!(
826 report.absorbed_by_catch_all,
827 [r#"/workflow_progress/0/type: "workflowRetry" -> "other""#]
828 );
829 assert_only(&report, "catch_all");
830 assert!(!report.is_clean());
831 }
832
833 /// `SystemRepr` is untagged, so a modelled subtype whose payload
834 /// drifts falls through to the generic bucket rather than erroring.
835 /// This is what makes an allowlist entry stop being harmless once
836 /// its subtype grows a typed variant, and the reason
837 /// `EXPECTED_GENERIC_SYSTEM_SUBTYPES` says to drop the entry in the
838 /// same change.
839 #[test]
840 fn modelled_subtype_with_a_drifted_payload_is_reported() {
841 // thinking_tokens without its required estimated_tokens field.
842 let report = report_for(
843 r#"{"type":"system","subtype":"thinking_tokens",
844 "estimated_tokens_delta":50,"uuid":"u1","session_id":"s1"}"#,
845 );
846 assert_eq!(report.unmodelled_system_subtypes, ["thinking_tokens"]);
847 assert_only(&report, "system");
848 assert!(!report.is_clean());
849 }
850
851 /// Guards the shape the whole thing exists for: the frame that now
852 /// goes red used to be counted as an ordinary message.
853 #[test]
854 fn an_unmodelled_subtype_still_decodes_as_a_plain_message() {
855 let report = report_for(NEW_SUBTYPE);
856 assert_eq!(report.messages, 1);
857 assert!(report.unknown_types.is_empty());
858 }
859}