Skip to main content

mermaid_cli/app/
recorder.rs

1//! `--record` / `--replay` support.
2//!
3//! The Elm/MVU architecture makes deterministic replay nearly free:
4//! `update(State, Msg)` is a pure function of its inputs (the wall clock is
5//! injected as `state.now`), so capturing every `Msg` the reducer sees —
6//! plus the clock value it was reduced under — lets `--replay` reconstruct
7//! the exact final `State` by folding over the log.
8//!
9//! Wire format (version 1), one JSON object per line (JSONL):
10//!   - Line 1 — [`SessionHeader`]: everything replay needs to rebuild the
11//!     initial `State` (format version, startup clock, model, cwd, the full
12//!     `Config`, and any `--continue` seed conversation). Self-contained: a
13//!     recording replays without reading the machine's live config.
14//!   - Every later line — [`ReplayEntry`] `{ts, kind, turn, msg}`:
15//!     - `ts`: the exact `state.now` the reducer saw for this input. The
16//!       driver stamps one clock per tick and shares it between the
17//!       recording and the reducer, so replay reproduces the fold bit-exactly.
18//!     - `kind` / `turn`: denormalized copies of `Msg::kind()` /
19//!       `Msg::turn_id()` for grepping a log by hand; replay reads `msg`.
20//!     - `msg`: the full `Msg`, serde-serialized (externally tagged). Binary
21//!       payloads (pasted images, tool artifacts) ride as base64 and replay
22//!       bit-exactly; new `Msg` variants round-trip automatically.
23//!
24//! Two deliberate divergences from live state, both security-driven:
25//!   - Credential-shaped strings are redacted before hitting disk (#17), so
26//!     a session where a secret crossed the reducer replays the *redacted*
27//!     transcript. Replay is deterministic with respect to the log — folding
28//!     the same log twice always produces identical state — and identical to
29//!     the live session whenever no redaction fired.
30//!   - The copied-selection payload (`Msg::CopySelection`) is recorded as a
31//!     placeholder: the text is already in the transcript, can be huge, and
32//!     the reducer ignores it (the payload only feeds a clipboard `Cmd`).
33
34use std::fs::{File, OpenOptions};
35use std::io::{BufRead, BufReader, BufWriter, Write};
36use std::path::PathBuf;
37
38use anyhow::{Context, Result};
39use chrono::{DateTime, Local};
40use serde::{Deserialize, Serialize};
41
42use crate::app::Config;
43use crate::domain::{Msg, Session};
44use crate::session::ConversationHistory;
45
46/// Bumped when the wire shape changes incompatibly. Replay refuses logs
47/// written by a different version rather than folding garbage.
48pub const RECORDING_FORMAT_VERSION: u32 = 1;
49
50/// First line of every recording: everything `--replay` needs to rebuild the
51/// session's initial `State` without touching the live machine's config.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SessionHeader {
54    pub format: u32,
55    /// Startup clock — seeds `State::new`'s injected `now`, which derives the
56    /// initial conversation id/title.
57    pub ts: DateTime<Local>,
58    pub model_id: String,
59    pub cwd: PathBuf,
60    /// Full config snapshot (redacted). `State::new` derives MCP seeding and
61    /// per-model reasoning from it.
62    pub config: Config,
63    /// `--continue` / `--sessions` seed applied before the first frame.
64    #[serde(default)]
65    pub seed_conversation: Option<ConversationHistory>,
66}
67
68/// Append-only recorder. Writes the session header once, then one JSONL
69/// line per `Msg` the main loop feeds the reducer.
70pub struct Recorder {
71    writer: BufWriter<File>,
72}
73
74impl Recorder {
75    /// Open `path` for append. Creates the file if it doesn't exist.
76    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
77        let path = path.into();
78        let mut opts = OpenOptions::new();
79        opts.create(true).append(true);
80        // A recording stores the full conversation — prompts, model output, and
81        // tool results (e.g. a `read_file` of a private doc) — in cleartext;
82        // only credential-shaped strings are scrubbed. Create it owner-only so a
83        // shared temp/cwd doesn't leak it (#132).
84        #[cfg(unix)]
85        {
86            use std::os::unix::fs::OpenOptionsExt;
87            opts.mode(0o600);
88        }
89        let file = opts
90            .open(&path)
91            .with_context(|| format!("open {} for recording", path.display()))?;
92        // `mode` only applies on create; tighten an existing recording too.
93        #[cfg(unix)]
94        {
95            use std::os::unix::fs::PermissionsExt;
96            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
97        }
98        tracing::warn!(
99            path = %path.display(),
100            "session recording is ON: this file stores prompts, model output, and \
101             tool results (including file contents) in cleartext; only \
102             credential-shaped strings are redacted",
103        );
104        Ok(Self {
105            writer: BufWriter::new(file),
106        })
107    }
108
109    /// Write the session header. Called once, before the first `record_msg`,
110    /// and flushed immediately — a replay of a crashed session should still
111    /// find a parseable header.
112    pub fn record_header(&mut self, header: &SessionHeader) -> Result<()> {
113        let mut value = serde_json::to_value(header).context("serialize session header")?;
114        crate::utils::redact_json(&mut value);
115        writeln!(self.writer, "{}", value).context("write header line")?;
116        self.flush()
117    }
118
119    /// Record one reducer input. `now` must be the same value the driver
120    /// stamps into `state.now` for this tick — that identity is what makes
121    /// the recorded `ts` a faithful replay clock.
122    ///
123    /// `Msg::Tick` is deliberately NOT recorded: the reducer's Tick arm is a
124    /// documented no-op (render derives the spinner from `state.now`), so a
125    /// 60 Hz tick stream would bloat the log by megabytes per hour for zero
126    /// replay fidelity. The `tick_is_a_reducer_noop` invariant test pins
127    /// this — if Tick ever grows state effects, that test fails and ticks
128    /// must be recorded again. Old logs containing Tick entries still fold
129    /// fine (a no-op replays as a no-op).
130    pub fn record_msg(&mut self, now: DateTime<Local>, msg: &Msg) -> Result<()> {
131        if matches!(msg, Msg::Tick) {
132            return Ok(());
133        }
134        // The copied selection is already visible in the transcript and the
135        // reducer never reads the payload (it only feeds `Cmd::CopyToClipboard`),
136        // so persist a placeholder instead of duplicating potentially-huge text.
137        let sanitized;
138        let msg = match msg {
139            Msg::CopySelection(text) => {
140                sanitized = Msg::CopySelection(format!("[{} chars]", text.chars().count()));
141                &sanitized
142            },
143            other => other,
144        };
145        let mut body = serde_json::to_value(msg).context("serialize msg")?;
146        // Single redaction choke point: scrub credential-shaped strings out of
147        // every recorded payload before it hits disk. A `read_file .env` result,
148        // a pasted token, or an API error echoing a key would otherwise be
149        // persisted in cleartext in the `--record` log (#17).
150        crate::utils::redact_json(&mut body);
151        let entry = serde_json::json!({
152            "ts": now,
153            "kind": format!("{:?}", msg.kind()),
154            "turn": msg.turn_id().map(|t| t.0),
155            "msg": body,
156        });
157        writeln!(self.writer, "{}", entry).context("write jsonl line")?;
158        Ok(())
159    }
160
161    /// Seal the recording with a fingerprint of the final session state, so
162    /// a future `--replay` can verify its fold reproduces what this live
163    /// session actually saw (not merely that the fold is self-consistent).
164    /// Written on clean exit; a crashed session simply has no trailer.
165    pub fn record_trailer(&mut self, now: DateTime<Local>, session: &Session) -> Result<()> {
166        let trailer = SessionTrailer {
167            ts: now,
168            final_session_fingerprint: session_fingerprint(session),
169        };
170        let line = serde_json::to_string(&trailer).context("serialize session trailer")?;
171        writeln!(self.writer, "{}", line).context("write trailer line")?;
172        self.flush()
173    }
174
175    pub fn flush(&mut self) -> Result<()> {
176        self.writer.flush().context("flush recorder")
177    }
178}
179
180impl Drop for Recorder {
181    fn drop(&mut self) {
182        let _ = self.writer.flush();
183    }
184}
185
186/// Stable fingerprint of the session outcome — the durable, user-visible
187/// half of `State`: the full conversation (messages, ids, titles,
188/// timestamps), model id, reasoning/safety modes, and token accounting.
189///
190/// Deliberately hashes ONLY `state.session`, excluding machine-derived
191/// fields (`temp_dir`) and `settings` (whose recorded copy is redacted), so
192/// the same log folds to the same fingerprint on any machine. A mismatch
193/// against a recorded trailer therefore means exactly one thing: the fold
194/// no longer reproduces the live session's outcome — expected when
195/// redaction fired mid-session or the reducer changed since recording,
196/// alarming otherwise.
197pub fn session_fingerprint(session: &Session) -> String {
198    use sha2::{Digest, Sha256};
199    use std::fmt::Write as _;
200    let mut hasher = Sha256::new();
201    hasher.update(format!("{session:?}").as_bytes());
202    let digest = hasher.finalize();
203    let mut out = String::with_capacity("sha256:".len() + digest.len() * 2);
204    out.push_str("sha256:");
205    for byte in digest {
206        let _ = write!(out, "{byte:02x}");
207    }
208    out
209}
210
211/// Final line of a cleanly-exited recording: the fingerprint `--replay`
212/// verifies its fold against.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct SessionTrailer {
215    pub ts: DateTime<Local>,
216    pub final_session_fingerprint: String,
217}
218
219/// Parsed JSONL entry. Fields mirror what [`Recorder::record_msg`] writes.
220#[derive(Debug, Serialize, Deserialize)]
221pub struct ReplayEntry {
222    pub ts: DateTime<Local>,
223    pub kind: String,
224    pub turn: Option<u64>,
225    pub msg: serde_json::Value,
226}
227
228impl ReplayEntry {
229    /// Reconstruct the reducer input. The error names the recorded `kind` so
230    /// a replay report can say what it skipped (e.g. a variant this build
231    /// doesn't know because the log came from a newer mermaid).
232    pub fn to_msg(&self) -> Result<Msg> {
233        serde_json::from_value(self.msg.clone())
234            .with_context(|| format!("reconstruct recorded {} msg", self.kind))
235    }
236}
237
238/// One classified line of a recording, after the leading header.
239#[derive(Debug)]
240pub enum RecordLine {
241    /// A normal `{ts, kind, turn, msg}` entry.
242    Entry(ReplayEntry),
243    /// The clean-exit seal: a fingerprint of the live session's final state.
244    Trailer(SessionTrailer),
245    /// Another session header: `Recorder::open` appends, so a reused
246    /// `--record` path holds multiple sessions back to back. Replay folds
247    /// the first session and stops here.
248    Header(Box<SessionHeader>),
249    /// Neither an entry, trailer, nor header — corrupt or truncated write.
250    Malformed { raw: String, error: String },
251}
252
253/// Read a recording back. Yields one classified [`RecordLine`] at a time so
254/// a huge log doesn't allocate the whole file upfront.
255#[derive(Debug)]
256pub struct Replay {
257    lines: std::io::Lines<BufReader<File>>,
258}
259
260impl Replay {
261    /// Open a recording and parse its leading [`SessionHeader`]. Refuses
262    /// files without one (pre-v1 logs) and format versions this build
263    /// doesn't understand.
264    pub fn open(path: impl Into<PathBuf>) -> Result<(SessionHeader, Self)> {
265        let path = path.into();
266        let file =
267            File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
268        let mut lines = BufReader::new(file).lines();
269        let first = lines
270            .next()
271            .context("recording is empty — no session header")?
272            .context("read session header line")?;
273        let header: SessionHeader = serde_json::from_str(&first).context(
274            "recording has no parseable session header — \
275             was it written by an older mermaid or truncated at byte 0?",
276        )?;
277        anyhow::ensure!(
278            header.format == RECORDING_FORMAT_VERSION,
279            "recording format {} is not supported (this build reads format {})",
280            header.format,
281            RECORDING_FORMAT_VERSION,
282        );
283        Ok((header, Self { lines }))
284    }
285}
286
287impl Iterator for Replay {
288    type Item = std::io::Result<RecordLine>;
289
290    fn next(&mut self) -> Option<Self::Item> {
291        let raw = match self.lines.next()? {
292            Ok(raw) => raw,
293            Err(e) => return Some(Err(e)),
294        };
295        // Entries are the overwhelmingly common case; a trailer seals a
296        // cleanly-exited session; a header mid-file marks the start of an
297        // appended second session.
298        let line = match serde_json::from_str::<ReplayEntry>(&raw) {
299            Ok(entry) => RecordLine::Entry(entry),
300            Err(entry_err) => match serde_json::from_str::<SessionTrailer>(&raw) {
301                Ok(trailer) => RecordLine::Trailer(trailer),
302                Err(_) => match serde_json::from_str::<SessionHeader>(&raw) {
303                    Ok(header) => RecordLine::Header(Box::new(header)),
304                    Err(_) => RecordLine::Malformed {
305                        raw,
306                        error: entry_err.to_string(),
307                    },
308                },
309            },
310        };
311        Some(Ok(line))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::domain::{ClipboardRead, MsgKind, Paste, TurnId};
319
320    fn tmpfile(name: &str) -> PathBuf {
321        let dir = std::env::temp_dir().join("mermaid_recorder_tests");
322        let _ = std::fs::create_dir_all(&dir);
323        dir.join(name)
324    }
325
326    fn test_header(ts: DateTime<Local>) -> SessionHeader {
327        SessionHeader {
328            format: RECORDING_FORMAT_VERSION,
329            ts,
330            model_id: "ollama/test".to_string(),
331            cwd: PathBuf::from("/tmp/project"),
332            config: Config::default(),
333            seed_conversation: None,
334        }
335    }
336
337    fn fixed_ts() -> DateTime<Local> {
338        // A fixed instant so assertions are stable.
339        chrono::DateTime::parse_from_rfc3339("2026-07-02T12:00:00.123+00:00")
340            .unwrap()
341            .with_timezone(&Local)
342    }
343
344    #[cfg(unix)]
345    #[test]
346    fn recording_file_is_owner_only() {
347        // #132: recordings hold cleartext prompts/output/file-contents, so they
348        // must be created 0600 rather than inheriting a world-readable umask.
349        use std::os::unix::fs::PermissionsExt;
350        let path = tmpfile("perms.jsonl");
351        let _ = std::fs::remove_file(&path);
352        let _ = Recorder::open(&path).expect("open");
353        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
354        assert_eq!(mode, 0o600, "recording must be created owner-only");
355        let _ = std::fs::remove_file(&path);
356    }
357
358    #[test]
359    fn record_and_replay_roundtrip() {
360        let path = tmpfile("roundtrip.jsonl");
361        let _ = std::fs::remove_file(&path);
362        let ts = fixed_ts();
363
364        {
365            let mut r = Recorder::open(&path).expect("open");
366            r.record_header(&test_header(ts)).expect("header");
367            r.record_msg(ts, &Msg::SessionSaved).expect("record");
368            r.record_msg(
369                ts,
370                &Msg::SubmitPrompt {
371                    text: "hello".to_string(),
372                    attachment_ids: vec![3, 9],
373                },
374            )
375            .expect("record");
376            r.record_msg(
377                ts,
378                &Msg::StreamText {
379                    turn: TurnId(7),
380                    chunk: "partial".to_string(),
381                },
382            )
383            .expect("record");
384            r.flush().expect("flush");
385        }
386
387        let (header, replay) = Replay::open(&path).expect("open replay");
388        assert_eq!(header.model_id, "ollama/test");
389        assert_eq!(header.ts, ts);
390
391        let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read all");
392        assert_eq!(lines.len(), 3);
393        let entries: Vec<&ReplayEntry> = lines
394            .iter()
395            .map(|l| match l {
396                RecordLine::Entry(e) => e,
397                other => panic!("expected entry, got {other:?}"),
398            })
399            .collect();
400        assert_eq!(entries[0].kind, "SessionSaved");
401        assert!(matches!(entries[0].to_msg().unwrap(), Msg::SessionSaved));
402        match entries[1].to_msg().unwrap() {
403            Msg::SubmitPrompt {
404                text,
405                attachment_ids,
406            } => {
407                assert_eq!(text, "hello");
408                assert_eq!(attachment_ids, vec![3, 9]);
409            },
410            other => panic!("expected SubmitPrompt, got {other:?}"),
411        }
412        assert_eq!(entries[2].turn, Some(7));
413        assert_eq!(entries[2].ts, ts);
414
415        let _ = std::fs::remove_file(&path);
416    }
417
418    #[test]
419    fn record_msg_redacts_secrets_in_body() {
420        // A recorded payload carrying a credential (e.g. a `read_file .env`
421        // result or an API error echoing a key) must hit disk scrubbed (#17).
422        let path = tmpfile("redact.jsonl");
423        let _ = std::fs::remove_file(&path);
424        {
425            let mut r = Recorder::open(&path).expect("open");
426            r.record_header(&test_header(fixed_ts())).expect("header");
427            r.record_msg(
428                fixed_ts(),
429                &Msg::StreamText {
430                    turn: TurnId(1),
431                    chunk: "OPENAI_API_KEY=sk-abcdefghijklmnop1234".to_string(),
432                },
433            )
434            .expect("record");
435            r.flush().expect("flush");
436        }
437
438        let raw = std::fs::read_to_string(&path).expect("read back");
439        assert!(
440            !raw.contains("sk-abcdefghijklmnop1234"),
441            "raw secret leaked: {raw}"
442        );
443        assert!(
444            raw.contains("[REDACTED]"),
445            "expected redaction marker: {raw}"
446        );
447
448        let (_, mut replay) = Replay::open(&path).expect("replay");
449        let line = replay.next().expect("one line").expect("io ok");
450        let RecordLine::Entry(entry) = line else {
451            panic!("expected entry");
452        };
453        match entry.to_msg().unwrap() {
454            Msg::StreamText { chunk, .. } => {
455                assert_eq!(chunk, "OPENAI_API_KEY=[REDACTED]");
456            },
457            other => panic!("expected StreamText, got {other:?}"),
458        }
459
460        let _ = std::fs::remove_file(&path);
461    }
462
463    #[test]
464    fn copy_selection_is_recorded_as_placeholder() {
465        let path = tmpfile("copysel.jsonl");
466        let _ = std::fs::remove_file(&path);
467        {
468            let mut r = Recorder::open(&path).expect("open");
469            r.record_header(&test_header(fixed_ts())).expect("header");
470            r.record_msg(
471                fixed_ts(),
472                &Msg::CopySelection("secret transcript".to_string()),
473            )
474            .expect("record");
475        }
476        let raw = std::fs::read_to_string(&path).expect("read");
477        assert!(!raw.contains("secret transcript"));
478        assert!(raw.contains("[17 chars]"));
479        let _ = std::fs::remove_file(&path);
480    }
481
482    #[test]
483    fn image_paste_round_trips_as_base64() {
484        let path = tmpfile("imgpaste.jsonl");
485        let _ = std::fs::remove_file(&path);
486        let bytes = vec![0u8, 1, 2, 250, 255, 128];
487        {
488            let mut r = Recorder::open(&path).expect("open");
489            r.record_header(&test_header(fixed_ts())).expect("header");
490            r.record_msg(
491                fixed_ts(),
492                &Msg::ClipboardRead(ClipboardRead::Image {
493                    bytes: bytes.clone(),
494                    format: "png".to_string(),
495                }),
496            )
497            .expect("record");
498        }
499        let (_, mut replay) = Replay::open(&path).expect("replay");
500        let RecordLine::Entry(entry) = replay.next().unwrap().unwrap() else {
501            panic!("expected entry");
502        };
503        match entry.to_msg().unwrap() {
504            Msg::ClipboardRead(ClipboardRead::Image {
505                bytes: back,
506                format,
507            }) => {
508                assert_eq!(back, bytes, "image bytes must replay bit-exactly");
509                assert_eq!(format, "png");
510            },
511            other => panic!("expected image paste, got {other:?}"),
512        }
513        let _ = std::fs::remove_file(&path);
514    }
515
516    #[test]
517    fn replay_refuses_headerless_recording() {
518        let path = tmpfile("headerless.jsonl");
519        std::fs::write(
520            &path,
521            "{\"ts\":\"2026-07-02T12:00:00Z\",\"kind\":\"Tick\",\"turn\":null,\"msg\":\"Tick\"}\n",
522        )
523        .expect("write");
524        let err = Replay::open(&path).expect_err("must refuse");
525        assert!(err.to_string().contains("session header"), "got: {err:#}");
526        let _ = std::fs::remove_file(&path);
527    }
528
529    #[test]
530    fn replay_classifies_appended_second_session_header() {
531        // `Recorder::open` appends, so reusing a --record path produces
532        // back-to-back sessions. The reader must surface the second header
533        // as a typed line, not a parse error.
534        let path = tmpfile("twosessions.jsonl");
535        let _ = std::fs::remove_file(&path);
536        {
537            let mut r = Recorder::open(&path).expect("open");
538            r.record_header(&test_header(fixed_ts())).expect("header");
539            r.record_msg(fixed_ts(), &Msg::SessionSaved)
540                .expect("record");
541        }
542        {
543            let mut r = Recorder::open(&path).expect("reopen");
544            r.record_header(&test_header(fixed_ts())).expect("header2");
545            r.record_msg(fixed_ts(), &Msg::Quit).expect("record");
546        }
547        let (_, replay) = Replay::open(&path).expect("replay");
548        let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read");
549        assert_eq!(lines.len(), 3);
550        assert!(matches!(lines[0], RecordLine::Entry(_)));
551        assert!(matches!(lines[1], RecordLine::Header(_)));
552        assert!(matches!(lines[2], RecordLine::Entry(_)));
553        let _ = std::fs::remove_file(&path);
554    }
555
556    #[test]
557    fn ticks_are_elided_from_recordings() {
558        // The reducer's Tick arm is a documented no-op (pinned by the
559        // `tick_is_a_reducer_noop` invariant test), so a 60 Hz tick stream
560        // adds nothing but bulk — record_msg drops it.
561        let path = tmpfile("noticks.jsonl");
562        let _ = std::fs::remove_file(&path);
563        {
564            let mut r = Recorder::open(&path).expect("open");
565            r.record_header(&test_header(fixed_ts())).expect("header");
566            r.record_msg(fixed_ts(), &Msg::Tick).expect("tick");
567            r.record_msg(fixed_ts(), &Msg::Quit).expect("quit");
568            r.record_msg(fixed_ts(), &Msg::Tick).expect("tick");
569        }
570        let (_, replay) = Replay::open(&path).expect("replay");
571        let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read");
572        assert_eq!(lines.len(), 1, "only the Quit entry may hit disk");
573        let RecordLine::Entry(entry) = &lines[0] else {
574            panic!("expected entry");
575        };
576        assert_eq!(entry.kind, "Quit");
577        let _ = std::fs::remove_file(&path);
578    }
579
580    #[test]
581    fn trailer_round_trips_and_fingerprint_is_stable() {
582        let path = tmpfile("trailer.jsonl");
583        let _ = std::fs::remove_file(&path);
584        let session = crate::domain::State::new(
585            Config::default(),
586            PathBuf::from("/tmp/project"),
587            "ollama/test".to_string(),
588            fixed_ts(),
589        )
590        .session;
591        {
592            let mut r = Recorder::open(&path).expect("open");
593            r.record_header(&test_header(fixed_ts())).expect("header");
594            r.record_trailer(fixed_ts(), &session).expect("trailer");
595        }
596        let (_, mut replay) = Replay::open(&path).expect("replay");
597        let line = replay.next().expect("line").expect("io ok");
598        let RecordLine::Trailer(trailer) = line else {
599            panic!("expected trailer, got {line:?}");
600        };
601        // Same session, same fingerprint — the stability the live-match
602        // verdict rests on.
603        assert_eq!(
604            trailer.final_session_fingerprint,
605            session_fingerprint(&session)
606        );
607        assert!(trailer.final_session_fingerprint.starts_with("sha256:"));
608        let _ = std::fs::remove_file(&path);
609    }
610
611    #[test]
612    fn replay_classifies_malformed_line() {
613        let path = tmpfile("bad.jsonl");
614        let header = serde_json::to_string(&test_header(fixed_ts())).unwrap();
615        std::fs::write(&path, format!("{header}\nnot-json\n")).expect("write");
616        let (_, mut replay) = Replay::open(&path).expect("open");
617        let line = replay.next().expect("line").expect("io ok");
618        assert!(matches!(line, RecordLine::Malformed { .. }));
619        let _ = std::fs::remove_file(&path);
620    }
621
622    #[test]
623    fn every_msg_kind_has_a_round_trip_sample() {
624        // Parity guard: each `MsgKind` gets at least one representative
625        // sample that must survive serialize → deserialize exactly. The
626        // `covered` match is exhaustive over `MsgKind`, so adding a Msg
627        // variant without extending the samples is a compile error here.
628        use crate::domain::{
629            ApprovalKind, ContextUsageSnapshot, Key, KeyCode, KeyMods, PromptTokenBreakdown,
630            RuntimeSignal, SlashCmd, StatusKind, ToolCallId, ToolOutcome,
631        };
632        use crate::models::ReasoningChunk;
633
634        fn covered(kind: MsgKind) -> bool {
635            match kind {
636                MsgKind::Key
637                | MsgKind::Paste
638                | MsgKind::ClipboardRead
639                | MsgKind::SubmitPrompt
640                | MsgKind::Slash
641                | MsgKind::CancelTurn
642                | MsgKind::Confirm
643                | MsgKind::Quit
644                | MsgKind::RuntimeSignal
645                | MsgKind::StreamText
646                | MsgKind::StreamReasoning
647                | MsgKind::StreamToolCall
648                | MsgKind::ContextUsageEstimated
649                | MsgKind::ProviderContextResolved
650                | MsgKind::OllamaPlacementResolved
651                | MsgKind::ProviderVisionResolved
652                | MsgKind::BuiltinToolSchemaTokens
653                | MsgKind::CompactionFinished
654                | MsgKind::CompactionFailed
655                | MsgKind::StreamDone
656                | MsgKind::UpstreamError
657                | MsgKind::ToolStarted
658                | MsgKind::ToolProgress
659                | MsgKind::ToolFinished
660                | MsgKind::ApprovalRequested
661                | MsgKind::QuestionAsked
662                | MsgKind::TasksUpdated
663                | MsgKind::TaskNotice
664                | MsgKind::TurnCancelled
665                | MsgKind::Mcp
666                | MsgKind::HookContext
667                | MsgKind::InstructionsChanged
668                | MsgKind::MemoryChanged
669                | MsgKind::SessionSaved
670                | MsgKind::ConversationLoaded
671                | MsgKind::ConversationsListed
672                | MsgKind::ProjectFilesListed
673                | MsgKind::ScratchpadReady
674                | MsgKind::RuntimeStore
675                | MsgKind::ModelPullFinished
676                | MsgKind::ModelPullProgress
677                | MsgKind::Tick
678                | MsgKind::Resize
679                | MsgKind::MouseScroll
680                | MsgKind::FocusChanged
681                | MsgKind::OpenImageAt
682                | MsgKind::TransientStatus
683                | MsgKind::EditorReturned
684                | MsgKind::BackgroundAgent
685                | MsgKind::CopySelection => true,
686            }
687        }
688
689        let samples: Vec<Msg> = vec![
690            Msg::TasksUpdated {
691                store: {
692                    let mut store = crate::domain::TaskStore::default();
693                    store.create(
694                        vec![crate::domain::TaskSpec {
695                            subject: "sample".to_string(),
696                            active_form: "sampling".to_string(),
697                            description: None,
698                            in_progress: true,
699                        }],
700                        crate::domain::TaskOrigin::Model,
701                        crate::domain::Stamp {
702                            now_epoch: 10,
703                            run_tokens: 20,
704                        },
705                    );
706                    store
707                },
708            },
709            Msg::TaskNotice {
710                text: "The user edited the task checklist: Added task #1 'x'.".to_string(),
711            },
712            Msg::Key(Key {
713                code: KeyCode::Char('x'),
714                modifiers: KeyMods::ctrl(),
715            }),
716            Msg::Key(Key {
717                code: KeyCode::PageUp,
718                modifiers: KeyMods::NONE,
719            }),
720            Msg::Paste(Paste::Text("pasted".to_string())),
721            Msg::ClipboardRead(ClipboardRead::Image {
722                bytes: vec![9, 8, 7],
723                format: "png".to_string(),
724            }),
725            Msg::SubmitPrompt {
726                text: "prompt".to_string(),
727                attachment_ids: vec![1],
728            },
729            Msg::Slash(SlashCmd::Model(Some("anthropic/opus".to_string()))),
730            Msg::HookContext {
731                turn: TurnId(2),
732                texts: vec!["hook says hi".to_string()],
733            },
734            Msg::Slash(SlashCmd::Compact(None)),
735            Msg::CancelTurn,
736            Msg::BackgroundAgentStarted {
737                agent_id: "a7".to_string(),
738                description: "audit docs".to_string(),
739            },
740            Msg::BackgroundAgentProgress {
741                agent_id: "a7".to_string(),
742                activity: "read_file…".to_string(),
743                tokens: 1200,
744            },
745            Msg::BackgroundAgentFinished {
746                agent_id: "a7".to_string(),
747                description: "audit docs".to_string(),
748                report: "all good".to_string(),
749                success: true,
750                cancelled: false,
751                usage: Some(crate::models::TokenUsage::provider(60_000, 30_000)),
752                tokens: 90_000,
753                duration_secs: 132,
754            },
755            Msg::ConfirmAccepted,
756            Msg::ConfirmDeclined,
757            Msg::Quit,
758            Msg::RuntimeSignal(RuntimeSignal::Terminate),
759            Msg::StreamText {
760                turn: TurnId(1),
761                chunk: "chunk".to_string(),
762            },
763            Msg::StreamReasoning {
764                turn: TurnId(1),
765                chunk: ReasoningChunk {
766                    text: "thinking".to_string(),
767                    signature: Some("sig".to_string()),
768                },
769            },
770            Msg::StreamToolCall {
771                turn: TurnId(1),
772                call: crate::models::tool_call::ToolCall {
773                    id: Some("call_1".to_string()),
774                    function: crate::models::tool_call::FunctionCall {
775                        name: "read_file".to_string(),
776                        arguments: serde_json::json!({"path": "src/main.rs"}),
777                    },
778                },
779            },
780            Msg::ContextUsageEstimated {
781                turn: TurnId(1),
782                snapshot: ContextUsageSnapshot::from_estimate(
783                    PromptTokenBreakdown {
784                        system_tokens: 10,
785                        instructions_tokens: 5,
786                        message_tokens: 20,
787                        tool_schema_tokens: 30,
788                        image_count: 0,
789                        message_count: 2,
790                        tool_count: 3,
791                    },
792                    Some(128_000),
793                ),
794            },
795            Msg::ProviderContextResolved {
796                model_id: "m".to_string(),
797                model_max: Some(131_072),
798                effective: Some(32_768),
799                source: None,
800                max_output: Some(64_000),
801            },
802            Msg::OllamaPlacementResolved {
803                model_id: "m".to_string(),
804                size_vram_bytes: 1,
805                total_bytes: 2,
806                suggested_num_ctx: Some(8192),
807            },
808            Msg::ProviderVisionResolved {
809                model_id: "m".to_string(),
810                supports_vision: Some(false),
811                warn: true,
812            },
813            Msg::BuiltinToolSchemaTokens(1234),
814            Msg::CompactionFailed {
815                turn: TurnId(2),
816                trigger: crate::domain::CompactionTrigger::Manual,
817                message: "nothing to do".to_string(),
818                kind: StatusKind::Info,
819            },
820            Msg::CompactionFinished {
821                turn: TurnId(2),
822                result: crate::domain::CompactionResult {
823                    record: crate::domain::CompactionRecord {
824                        id: "c1".to_string(),
825                        trigger: crate::domain::CompactionTrigger::Manual,
826                        created_at: fixed_ts(),
827                        before_tokens: 1000,
828                        after_tokens: 100,
829                        archived_message_count: 8,
830                        preserved_message_count: 2,
831                        preserved_turn_count: 1,
832                        summary_tokens: 90,
833                        duration_secs: 1.5,
834                        review_status: crate::domain::CompactionReviewStatus::Reviewed,
835                        review_error: None,
836                        focus: None,
837                        archive_path: None,
838                    },
839                    replacement_messages: vec![crate::models::ChatMessage::system("checkpoint")],
840                    archived_messages: vec![crate::models::ChatMessage::user("old")],
841                    before_snapshot: ContextUsageSnapshot::from_estimate(
842                        PromptTokenBreakdown::default(),
843                        Some(128_000),
844                    ),
845                    after_snapshot: ContextUsageSnapshot::from_estimate(
846                        PromptTokenBreakdown::default(),
847                        Some(128_000),
848                    ),
849                    usage: None,
850                    source_boundaries: Vec::new(),
851                },
852            },
853            Msg::UpstreamError {
854                turn: TurnId(1),
855                error: crate::models::UserFacingError {
856                    summary: "Rate limited".to_string(),
857                    message: "429 too many requests".to_string(),
858                    suggestion: "retry in a moment".to_string(),
859                    category: crate::models::ErrorCategory::Temporary,
860                    recoverable: true,
861                },
862            },
863            Msg::StreamDone {
864                turn: TurnId(1),
865                usage: Some(crate::models::TokenUsage::provider(10, 5)),
866                provider_continuation: None,
867                stop_reason: Some(crate::models::FinishReason::Stop),
868            },
869            Msg::TurnCancelled(TurnId(3)),
870            Msg::ToolStarted {
871                turn: TurnId(1),
872                call_id: ToolCallId(1),
873            },
874            Msg::ToolProgress {
875                turn: TurnId(1),
876                call_id: ToolCallId(1),
877                event: crate::providers::ProgressEvent::Artifact {
878                    mime: "image/png".to_string(),
879                    data: vec![1, 2, 3],
880                    caption: Some("shot".to_string()),
881                },
882            },
883            Msg::ToolFinished {
884                turn: TurnId(1),
885                call_id: ToolCallId(1),
886                outcome: ToolOutcome::success("out", "read 3 lines", 0.5),
887            },
888            Msg::ApprovalRequested {
889                turn: TurnId(1),
890                call_id: ToolCallId(2),
891                tool: "execute_command".to_string(),
892                risk: "destructive".to_string(),
893                kind: ApprovalKind::Shell,
894                prompt: "rm -rf build".to_string(),
895                allowlist_scope: "exact".to_string(),
896            },
897            Msg::McpServerReady {
898                name: "srv".to_string(),
899                tools: vec![crate::domain::McpToolSpec {
900                    name: "mcp__srv__t".to_string(),
901                    raw_name: "t".to_string(),
902                    description: "d".to_string(),
903                    input_schema: serde_json::json!({"type": "object"}),
904                    read_only_hint: false,
905                }],
906            },
907            Msg::McpServerErrored {
908                name: "srv".to_string(),
909                reason: "exit 1".to_string(),
910            },
911            Msg::McpServerStopped {
912                name: "srv".to_string(),
913            },
914            Msg::InstructionsChanged(None),
915            Msg::MemoryChanged(None),
916            Msg::SessionSaved,
917            Msg::ConversationLoaded(ConversationHistory::new(
918                "/p".to_string(),
919                "m".to_string(),
920                fixed_ts(),
921            )),
922            Msg::ConversationsListed(vec![crate::domain::ConversationSummary {
923                id: "20260702_120000_123".to_string(),
924                title: "t".to_string(),
925                message_count: 1,
926                updated_at: "2026-07-02".to_string(),
927            }]),
928            Msg::ProjectFilesListed(vec!["src/main.rs".to_string(), "docs/".to_string()]),
929            Msg::ScratchpadReady {
930                session_id: "20260702_120000_123".to_string(),
931                path: std::path::PathBuf::from("/data/tmp/scratchpad/-proj/20260702_120000_123"),
932            },
933            Msg::RuntimeText("daemon says hi".to_string()),
934            Msg::RuntimeTasksListed(Vec::new()),
935            Msg::RuntimeTaskLoaded {
936                task: None,
937                events: Vec::new(),
938            },
939            Msg::RuntimeProcessesListed(Vec::new()),
940            Msg::RuntimeApprovalsListed(Vec::new()),
941            Msg::RuntimeCheckpointsListed(Vec::new()),
942            Msg::ForkCheckpointsFound(Vec::new()),
943            Msg::RuntimePluginsListed(Vec::new()),
944            Msg::ModelPullFinished {
945                model: "qwen3".to_string(),
946            },
947            Msg::ModelPullProgress("pulling 42%".to_string()),
948            Msg::Tick,
949            Msg::Resize {
950                width: 120,
951                height: 40,
952            },
953            Msg::TransientStatus {
954                text: "saved".to_string(),
955            },
956            Msg::MouseScroll { delta: -3 },
957            Msg::FocusChanged(false),
958            Msg::OpenImageAt {
959                message_index: 4,
960                image_index: 0,
961                image_number: None,
962            },
963            Msg::EditorReturned {
964                text: Some("edited draft".to_string()),
965            },
966            Msg::CopySelection("copied".to_string()),
967        ];
968
969        // Every MsgKind must appear in the sample set. `covered` is the
970        // compile-time guard (exhaustive match breaks when Msg grows); this
971        // list is the runtime completeness check against the samples.
972        let seen: Vec<MsgKind> = samples.iter().map(|m| m.kind()).collect();
973        let missing: Vec<String> = [
974            MsgKind::Key,
975            MsgKind::Paste,
976            MsgKind::ClipboardRead,
977            MsgKind::SubmitPrompt,
978            MsgKind::Slash,
979            MsgKind::CancelTurn,
980            MsgKind::Confirm,
981            MsgKind::Quit,
982            MsgKind::RuntimeSignal,
983            MsgKind::StreamText,
984            MsgKind::StreamReasoning,
985            MsgKind::StreamToolCall,
986            MsgKind::ContextUsageEstimated,
987            MsgKind::ProviderContextResolved,
988            MsgKind::OllamaPlacementResolved,
989            MsgKind::ProviderVisionResolved,
990            MsgKind::BuiltinToolSchemaTokens,
991            MsgKind::CompactionFinished,
992            MsgKind::CompactionFailed,
993            MsgKind::StreamDone,
994            MsgKind::UpstreamError,
995            MsgKind::ToolStarted,
996            MsgKind::ToolProgress,
997            MsgKind::ToolFinished,
998            MsgKind::ApprovalRequested,
999            MsgKind::TurnCancelled,
1000            MsgKind::Mcp,
1001            MsgKind::HookContext,
1002            MsgKind::InstructionsChanged,
1003            MsgKind::MemoryChanged,
1004            MsgKind::SessionSaved,
1005            MsgKind::ConversationLoaded,
1006            MsgKind::ConversationsListed,
1007            MsgKind::ProjectFilesListed,
1008            MsgKind::RuntimeStore,
1009            MsgKind::ModelPullFinished,
1010            MsgKind::ModelPullProgress,
1011            MsgKind::Tick,
1012            MsgKind::Resize,
1013            MsgKind::MouseScroll,
1014            MsgKind::FocusChanged,
1015            MsgKind::OpenImageAt,
1016            MsgKind::TransientStatus,
1017            MsgKind::CopySelection,
1018        ]
1019        .iter()
1020        .filter(|k| covered(**k) && !seen.contains(k))
1021        .map(|k| format!("{k:?}"))
1022        .collect();
1023        assert!(
1024            missing.is_empty(),
1025            "MsgKinds without a round-trip sample: {missing:?}"
1026        );
1027
1028        // …and every sample must survive the round trip bit-exactly.
1029        for msg in &samples {
1030            let value = serde_json::to_value(msg).expect("serialize");
1031            let back: Msg = serde_json::from_value(value.clone())
1032                .unwrap_or_else(|e| panic!("deserialize {value}: {e}"));
1033            assert_eq!(
1034                format!("{msg:?}"),
1035                format!("{back:?}"),
1036                "round trip changed the msg"
1037            );
1038        }
1039    }
1040}