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