Skip to main content

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}