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::QueryResult;
364    use mermaid_domain::{ClipboardRead, MsgKind, Paste, TurnId};
365
366    fn tmpfile(name: &str) -> PathBuf {
367        let dir = std::env::temp_dir().join("mermaid_recorder_tests");
368        let _ = std::fs::create_dir_all(&dir);
369        dir.join(name)
370    }
371
372    fn test_header(ts: DateTime<Local>) -> SessionHeader {
373        SessionHeader {
374            format: RECORDING_FORMAT_VERSION,
375            ts,
376            model_id: "ollama/test".to_string(),
377            cwd: PathBuf::from("/tmp/project"),
378            config: Config::default(),
379            seed_conversation: None,
380        }
381    }
382
383    fn fixed_ts() -> DateTime<Local> {
384        // A fixed instant so assertions are stable.
385        chrono::DateTime::parse_from_rfc3339("2026-07-02T12:00:00.123+00:00")
386            .unwrap()
387            .with_timezone(&Local)
388    }
389
390    #[cfg(unix)]
391    #[test]
392    fn recording_file_is_owner_only() {
393        // #132: recordings hold cleartext prompts/output/file-contents, so they
394        // must be created 0600 rather than inheriting a world-readable umask.
395        use std::os::unix::fs::PermissionsExt;
396        let path = tmpfile("perms.jsonl");
397        let _ = std::fs::remove_file(&path);
398        let _ = Recorder::open(&path).expect("open");
399        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
400        assert_eq!(mode, 0o600, "recording must be created owner-only");
401        let _ = std::fs::remove_file(&path);
402    }
403
404    #[test]
405    fn record_and_replay_roundtrip() {
406        let path = tmpfile("roundtrip.jsonl");
407        let _ = std::fs::remove_file(&path);
408        let ts = fixed_ts();
409
410        {
411            let mut r = Recorder::open(&path).expect("open");
412            r.record_header(&test_header(ts)).expect("header");
413            r.record_msg(ts, &Msg::SessionSaved).expect("record");
414            r.record_msg(
415                ts,
416                &Msg::SubmitPrompt {
417                    text: "hello".to_string(),
418                    attachment_ids: vec![3, 9],
419                },
420            )
421            .expect("record");
422            r.record_msg(
423                ts,
424                &Msg::StreamText {
425                    turn: TurnId(7),
426                    chunk: "partial".to_string(),
427                },
428            )
429            .expect("record");
430            r.flush().expect("flush");
431        }
432
433        let (header, replay) = Replay::open(&path).expect("open replay");
434        assert_eq!(header.model_id, "ollama/test");
435        assert_eq!(header.ts, ts);
436
437        let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read all");
438        assert_eq!(lines.len(), 3);
439        let entries: Vec<&ReplayEntry> = lines
440            .iter()
441            .map(|l| match l {
442                RecordLine::Entry(e) => e,
443                other => panic!("expected entry, got {other:?}"),
444            })
445            .collect();
446        assert_eq!(entries[0].kind, "SessionSaved");
447        assert!(matches!(entries[0].to_msg().unwrap(), Msg::SessionSaved));
448        match entries[1].to_msg().unwrap() {
449            Msg::SubmitPrompt {
450                text,
451                attachment_ids,
452            } => {
453                assert_eq!(text, "hello");
454                assert_eq!(attachment_ids, vec![3, 9]);
455            },
456            other => panic!("expected SubmitPrompt, got {other:?}"),
457        }
458        assert_eq!(entries[2].turn, Some(7));
459        assert_eq!(entries[2].ts, ts);
460
461        let _ = std::fs::remove_file(&path);
462    }
463
464    #[test]
465    fn record_msg_redacts_secrets_in_body() {
466        // A recorded payload carrying a credential (e.g. a `read_file .env`
467        // result or an API error echoing a key) must hit disk scrubbed (#17).
468        let path = tmpfile("redact.jsonl");
469        let _ = std::fs::remove_file(&path);
470        {
471            let mut r = Recorder::open(&path).expect("open");
472            r.record_header(&test_header(fixed_ts())).expect("header");
473            r.record_msg(
474                fixed_ts(),
475                &Msg::StreamText {
476                    turn: TurnId(1),
477                    chunk: "OPENAI_API_KEY=sk-abcdefghijklmnop1234".to_string(),
478                },
479            )
480            .expect("record");
481            r.flush().expect("flush");
482        }
483
484        let raw = std::fs::read_to_string(&path).expect("read back");
485        assert!(
486            !raw.contains("sk-abcdefghijklmnop1234"),
487            "raw secret leaked: {raw}"
488        );
489        assert!(
490            raw.contains("[REDACTED]"),
491            "expected redaction marker: {raw}"
492        );
493
494        let (_, mut replay) = Replay::open(&path).expect("replay");
495        let line = replay.next().expect("one line").expect("io ok");
496        let RecordLine::Entry(entry) = line else {
497            panic!("expected entry");
498        };
499        match entry.to_msg().unwrap() {
500            Msg::StreamText { chunk, .. } => {
501                assert_eq!(chunk, "OPENAI_API_KEY=[REDACTED]");
502            },
503            other => panic!("expected StreamText, got {other:?}"),
504        }
505
506        let _ = std::fs::remove_file(&path);
507    }
508
509    #[test]
510    fn copy_selection_is_recorded_as_placeholder() {
511        let path = tmpfile("copysel.jsonl");
512        let _ = std::fs::remove_file(&path);
513        {
514            let mut r = Recorder::open(&path).expect("open");
515            r.record_header(&test_header(fixed_ts())).expect("header");
516            r.record_msg(
517                fixed_ts(),
518                &Msg::CopySelection("secret transcript".to_string()),
519            )
520            .expect("record");
521        }
522        let raw = std::fs::read_to_string(&path).expect("read");
523        assert!(!raw.contains("secret transcript"));
524        assert!(raw.contains("[17 chars]"));
525        let _ = std::fs::remove_file(&path);
526    }
527
528    #[test]
529    fn image_paste_round_trips_as_base64() {
530        let path = tmpfile("imgpaste.jsonl");
531        let _ = std::fs::remove_file(&path);
532        let bytes = vec![0u8, 1, 2, 250, 255, 128];
533        {
534            let mut r = Recorder::open(&path).expect("open");
535            r.record_header(&test_header(fixed_ts())).expect("header");
536            r.record_msg(
537                fixed_ts(),
538                &Msg::ClipboardRead(ClipboardRead::Image {
539                    bytes: bytes.clone(),
540                    format: "png".to_string(),
541                }),
542            )
543            .expect("record");
544        }
545        let (_, mut replay) = Replay::open(&path).expect("replay");
546        let RecordLine::Entry(entry) = replay.next().unwrap().unwrap() else {
547            panic!("expected entry");
548        };
549        match entry.to_msg().unwrap() {
550            Msg::ClipboardRead(ClipboardRead::Image {
551                bytes: back,
552                format,
553            }) => {
554                assert_eq!(back, bytes, "image bytes must replay bit-exactly");
555                assert_eq!(format, "png");
556            },
557            other => panic!("expected image paste, got {other:?}"),
558        }
559        let _ = std::fs::remove_file(&path);
560    }
561
562    #[test]
563    fn replay_refuses_headerless_recording() {
564        let path = tmpfile("headerless.jsonl");
565        std::fs::write(
566            &path,
567            "{\"ts\":\"2026-07-02T12:00:00Z\",\"kind\":\"Tick\",\"turn\":null,\"msg\":\"Tick\"}\n",
568        )
569        .expect("write");
570        let err = Replay::open(&path).expect_err("must refuse");
571        assert!(err.to_string().contains("session header"), "got: {err:#}");
572        let _ = std::fs::remove_file(&path);
573    }
574
575    #[test]
576    fn replay_classifies_appended_second_session_header() {
577        // `Recorder::open` appends, so reusing a --record path produces
578        // back-to-back sessions. The reader must surface the second header
579        // as a typed line, not a parse error.
580        let path = tmpfile("twosessions.jsonl");
581        let _ = std::fs::remove_file(&path);
582        {
583            let mut r = Recorder::open(&path).expect("open");
584            r.record_header(&test_header(fixed_ts())).expect("header");
585            r.record_msg(fixed_ts(), &Msg::SessionSaved)
586                .expect("record");
587        }
588        {
589            let mut r = Recorder::open(&path).expect("reopen");
590            r.record_header(&test_header(fixed_ts())).expect("header2");
591            r.record_msg(fixed_ts(), &Msg::Quit).expect("record");
592        }
593        let (_, replay) = Replay::open(&path).expect("replay");
594        let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read");
595        assert_eq!(lines.len(), 3);
596        assert!(matches!(lines[0], RecordLine::Entry(_)));
597        assert!(matches!(lines[1], RecordLine::Header(_)));
598        assert!(matches!(lines[2], RecordLine::Entry(_)));
599        let _ = std::fs::remove_file(&path);
600    }
601
602    #[test]
603    fn ticks_are_elided_from_recordings() {
604        // The reducer's Tick arm is a documented no-op (pinned by the
605        // `tick_is_a_reducer_noop` invariant test), so a 60 Hz tick stream
606        // adds nothing but bulk — record_msg drops it.
607        let path = tmpfile("noticks.jsonl");
608        let _ = std::fs::remove_file(&path);
609        {
610            let mut r = Recorder::open(&path).expect("open");
611            r.record_header(&test_header(fixed_ts())).expect("header");
612            r.record_msg(fixed_ts(), &Msg::Tick).expect("tick");
613            r.record_msg(fixed_ts(), &Msg::Quit).expect("quit");
614            r.record_msg(fixed_ts(), &Msg::Tick).expect("tick");
615        }
616        let (_, replay) = Replay::open(&path).expect("replay");
617        let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read");
618        assert_eq!(lines.len(), 1, "only the Quit entry may hit disk");
619        let RecordLine::Entry(entry) = &lines[0] else {
620            panic!("expected entry");
621        };
622        assert_eq!(entry.kind, "Quit");
623        let _ = std::fs::remove_file(&path);
624    }
625
626    #[test]
627    fn trailer_round_trips_and_fingerprint_is_stable() {
628        let path = tmpfile("trailer.jsonl");
629        let _ = std::fs::remove_file(&path);
630        let session = mermaid_domain::State::new(
631            Config::default(),
632            PathBuf::from("/tmp/project"),
633            "ollama/test".to_string(),
634            fixed_ts(),
635            std::path::PathBuf::from("/tmp"),
636        )
637        .session;
638        {
639            let mut r = Recorder::open(&path).expect("open");
640            r.record_header(&test_header(fixed_ts())).expect("header");
641            r.record_trailer(fixed_ts(), &session).expect("trailer");
642        }
643        let (_, mut replay) = Replay::open(&path).expect("replay");
644        let line = replay.next().expect("line").expect("io ok");
645        let RecordLine::Trailer(trailer) = line else {
646            panic!("expected trailer, got {line:?}");
647        };
648        // Same session, same fingerprint — the stability the live-match
649        // verdict rests on.
650        assert_eq!(
651            trailer.final_session_fingerprint,
652            session_fingerprint(&session)
653        );
654        assert!(trailer.final_session_fingerprint.starts_with("sha256:"));
655        let _ = std::fs::remove_file(&path);
656    }
657
658    #[test]
659    fn replay_classifies_malformed_line() {
660        let path = tmpfile("bad.jsonl");
661        let header = serde_json::to_string(&test_header(fixed_ts())).unwrap();
662        std::fs::write(&path, format!("{header}\nnot-json\n")).expect("write");
663        let (_, mut replay) = Replay::open(&path).expect("open");
664        let line = replay.next().expect("line").expect("io ok");
665        assert!(matches!(line, RecordLine::Malformed { .. }));
666        let _ = std::fs::remove_file(&path);
667    }
668
669    #[test]
670    #[expect(
671        clippy::too_many_lines,
672        reason = "predates the lint; see .github/baselines/expect_budget.txt"
673    )]
674    fn every_msg_kind_has_a_round_trip_sample() {
675        // Parity guard: each `MsgKind` gets at least one representative
676        // sample that must survive serialize → deserialize exactly. The
677        // `covered` match is exhaustive over `MsgKind`, so adding a Msg
678        // variant without extending the samples is a compile error here.
679        use mermaid_domain::{
680            ApprovalKind, ContextUsageSnapshot, Key, KeyCode, KeyMods, PromptTokenBreakdown,
681            RuntimeSignal, SlashCmd, StatusKind, ToolCallId, ToolOutcome,
682        };
683        use mermaid_model::models::ReasoningChunk;
684
685        fn covered(kind: MsgKind) -> bool {
686            match kind {
687                MsgKind::Key
688                | MsgKind::Paste
689                | MsgKind::ClipboardRead
690                | MsgKind::SubmitPrompt
691                | MsgKind::Slash
692                | MsgKind::CancelTurn
693                | MsgKind::Confirm
694                | MsgKind::Quit
695                | MsgKind::RuntimeSignal
696                | MsgKind::StreamText
697                | MsgKind::StreamReasoning
698                | MsgKind::StreamToolCall
699                | MsgKind::ContextUsageEstimated
700                | MsgKind::ProviderContextResolved
701                | MsgKind::OllamaPlacementResolved
702                | MsgKind::ProviderVisionResolved
703                | MsgKind::BuiltinToolSchemaTokens
704                | MsgKind::CompactionFinished
705                | MsgKind::CompactionFailed
706                | MsgKind::StreamDone
707                | MsgKind::UpstreamError
708                | MsgKind::ToolStarted
709                | MsgKind::ToolProgress
710                | MsgKind::ToolFinished
711                | MsgKind::ApprovalRequested
712                | MsgKind::QuestionAsked
713                | MsgKind::TasksUpdated
714                | MsgKind::TaskNotice
715                | MsgKind::TurnCancelled
716                | MsgKind::Mcp
717                | MsgKind::HookContext
718                | MsgKind::InstructionsChanged
719                | MsgKind::MemoryChanged
720                | MsgKind::SessionProvenanceResolved
721                | MsgKind::SessionSaved
722                | MsgKind::QueryResult
723                | MsgKind::ScratchpadReady
724                | MsgKind::RuntimeStore
725                | MsgKind::ModelPullFinished
726                | MsgKind::ModelPullProgress
727                | MsgKind::Tick
728                | MsgKind::Resize
729                | MsgKind::MouseScroll
730                | MsgKind::FocusChanged
731                | MsgKind::OpenImageAt
732                | MsgKind::TransientStatus
733                | MsgKind::Toast
734                | MsgKind::EditorReturned
735                | MsgKind::BackgroundAgent
736                | MsgKind::CopySelection => true,
737            }
738        }
739
740        let samples: Vec<Msg> = vec![
741            Msg::TasksUpdated {
742                store: {
743                    let mut store = mermaid_domain::ChecklistStore::default();
744                    store.create(
745                        vec![mermaid_domain::ChecklistSpec {
746                            subject: "sample".to_string(),
747                            active_form: "sampling".to_string(),
748                            description: None,
749                            in_progress: true,
750                        }],
751                        mermaid_domain::ChecklistOrigin::Model,
752                        mermaid_domain::Stamp {
753                            now_epoch: 10,
754                            run_tokens: 20,
755                        },
756                    );
757                    store
758                },
759            },
760            Msg::TaskNotice {
761                text: "The user edited the task checklist: Added task #1 'x'.".to_string(),
762            },
763            Msg::Key(Key {
764                code: KeyCode::Char('x'),
765                modifiers: KeyMods::ctrl(),
766            }),
767            Msg::Key(Key {
768                code: KeyCode::PageUp,
769                modifiers: KeyMods::NONE,
770            }),
771            Msg::Paste(Paste::Text("pasted".to_string())),
772            Msg::ClipboardRead(ClipboardRead::Image {
773                bytes: vec![9, 8, 7],
774                format: "png".to_string(),
775            }),
776            Msg::SubmitPrompt {
777                text: "prompt".to_string(),
778                attachment_ids: vec![1],
779            },
780            Msg::Slash(SlashCmd::Model(Some("anthropic/opus".to_string()))),
781            Msg::HookContext {
782                turn: TurnId(2),
783                texts: vec!["hook says hi".to_string()],
784            },
785            Msg::Slash(SlashCmd::Compact(None)),
786            Msg::CancelTurn,
787            Msg::BackgroundAgentStarted {
788                agent_id: "a7".to_string(),
789                description: "audit docs".to_string(),
790            },
791            Msg::BackgroundAgentProgress {
792                agent_id: "a7".to_string(),
793                activity: "read_file…".to_string(),
794                tokens: 1200,
795            },
796            Msg::BackgroundAgentFinished {
797                agent_id: "a7".to_string(),
798                description: "audit docs".to_string(),
799                report: "all good".to_string(),
800                success: true,
801                cancelled: false,
802                usage: Some(mermaid_model::models::TokenUsage::provider(60_000, 30_000)),
803                tokens: 90_000,
804                duration_secs: 132,
805            },
806            Msg::ConfirmAccepted,
807            Msg::ConfirmDeclined,
808            Msg::Quit,
809            Msg::RuntimeSignal(RuntimeSignal::Terminate),
810            Msg::StreamText {
811                turn: TurnId(1),
812                chunk: "chunk".to_string(),
813            },
814            Msg::StreamReasoning {
815                turn: TurnId(1),
816                chunk: ReasoningChunk {
817                    text: "thinking".to_string(),
818                    signature: Some("sig".to_string()),
819                },
820            },
821            Msg::StreamToolCall {
822                turn: TurnId(1),
823                call: mermaid_model::models::tool_call::ToolCall {
824                    id: Some("call_1".to_string()),
825                    function: mermaid_model::models::tool_call::FunctionCall {
826                        name: "read_file".to_string(),
827                        arguments: serde_json::json!({"path": "src/main.rs"}),
828                    },
829                },
830            },
831            Msg::ContextUsageEstimated {
832                turn: TurnId(1),
833                snapshot: ContextUsageSnapshot::from_estimate(
834                    PromptTokenBreakdown {
835                        system_tokens: 10,
836                        instructions_tokens: 5,
837                        message_tokens: 20,
838                        tool_schema_tokens: 30,
839                        image_count: 0,
840                        message_count: 2,
841                        tool_count: 3,
842                    },
843                    Some(128_000),
844                ),
845            },
846            Msg::ProviderContextResolved {
847                model_id: "m".to_string(),
848                model_max: Some(131_072),
849                effective: Some(32_768),
850                source: None,
851                max_output: Some(64_000),
852            },
853            Msg::OllamaPlacementResolved {
854                model_id: "m".to_string(),
855                size_vram_bytes: 1,
856                total_bytes: 2,
857                suggested_num_ctx: Some(8192),
858            },
859            Msg::ProviderVisionResolved {
860                model_id: "m".to_string(),
861                supports_vision: Some(false),
862                warn: true,
863            },
864            Msg::BuiltinToolSchemaTokens(1234),
865            Msg::CompactionFailed {
866                turn: TurnId(2),
867                trigger: mermaid_domain::CompactionTrigger::Manual,
868                message: "nothing to do".to_string(),
869                kind: StatusKind::Info,
870            },
871            Msg::CompactionFinished {
872                turn: TurnId(2),
873                result: mermaid_domain::CompactionResult {
874                    record: mermaid_domain::CompactionEvent {
875                        id: "c1".to_string(),
876                        trigger: mermaid_domain::CompactionTrigger::Manual,
877                        created_at: fixed_ts(),
878                        before_tokens: 1000,
879                        after_tokens: 100,
880                        archived_message_count: 8,
881                        preserved_message_count: 2,
882                        preserved_turn_count: 1,
883                        summary_tokens: 90,
884                        duration_secs: 1.5,
885                        review_status: mermaid_domain::CompactionReviewStatus::Reviewed,
886                        review_error: None,
887                        focus: None,
888                        archive_path: None,
889                    },
890                    replacement_messages: vec![mermaid_model::models::ChatMessage::system(
891                        "checkpoint",
892                    )],
893                    archived_messages: vec![mermaid_model::models::ChatMessage::user("old")],
894                    before_snapshot: ContextUsageSnapshot::from_estimate(
895                        PromptTokenBreakdown::default(),
896                        Some(128_000),
897                    ),
898                    after_snapshot: ContextUsageSnapshot::from_estimate(
899                        PromptTokenBreakdown::default(),
900                        Some(128_000),
901                    ),
902                    usage: None,
903                    source_boundaries: Vec::new(),
904                },
905            },
906            Msg::UpstreamError {
907                turn: TurnId(1),
908                error: mermaid_model::models::UserFacingError {
909                    summary: "Rate limited".to_string(),
910                    message: "429 too many requests".to_string(),
911                    suggestion: "retry in a moment".to_string(),
912                    category: mermaid_model::models::ErrorCategory::Temporary,
913                    recoverable: true,
914                },
915            },
916            Msg::StreamDone {
917                turn: TurnId(1),
918                usage: Some(mermaid_model::models::TokenUsage::provider(10, 5)),
919                provider_continuation: None,
920                stop_reason: Some(mermaid_model::models::FinishReason::Stop),
921            },
922            Msg::TurnCancelled(TurnId(3)),
923            Msg::ToolStarted {
924                turn: TurnId(1),
925                call_id: ToolCallId(1),
926            },
927            Msg::ToolProgress {
928                turn: TurnId(1),
929                call_id: ToolCallId(1),
930                event: mermaid_domain::ProgressEvent::Artifact {
931                    mime: "image/png".to_string(),
932                    data: vec![1, 2, 3],
933                    caption: Some("shot".to_string()),
934                },
935            },
936            Msg::ToolFinished {
937                turn: TurnId(1),
938                call_id: ToolCallId(1),
939                outcome: ToolOutcome::success("out", "read 3 lines", 0.5),
940            },
941            Msg::ApprovalRequested {
942                turn: TurnId(1),
943                call_id: ToolCallId(2),
944                tool: "execute_command".to_string(),
945                risk: "destructive".to_string(),
946                kind: ApprovalKind::Shell,
947                prompt: "rm -rf build".to_string(),
948                allowlist_scope: "exact".to_string(),
949            },
950            Msg::McpServerReady {
951                name: "srv".to_string(),
952                tools: vec![mermaid_domain::McpToolSpec {
953                    name: "mcp__srv__t".to_string(),
954                    raw_name: "t".to_string(),
955                    description: "d".to_string(),
956                    input_schema: serde_json::json!({"type": "object"}),
957                    read_only_hint: false,
958                }],
959            },
960            Msg::McpServerErrored {
961                name: "srv".to_string(),
962                reason: "exit 1".to_string(),
963            },
964            Msg::McpServerStopped {
965                name: "srv".to_string(),
966            },
967            Msg::InstructionsChanged(None),
968            Msg::MemoryChanged(None),
969            Msg::SessionProvenanceResolved(mermaid_domain::SessionProvenance {
970                git_branch: Some("main".to_string()),
971                git_sha: Some("a614aa9f".to_string()),
972                cli_version: Some("0.21.1".to_string()),
973            }),
974            Msg::SessionSaved,
975            Msg::QueryResult(QueryResult::ConversationLoaded(Box::new(
976                ConversationHistory::new("/p".to_string(), "m".to_string(), fixed_ts()),
977            ))),
978            Msg::QueryResult(QueryResult::ConversationsListed(vec![
979                mermaid_domain::ConversationSummary {
980                    id: "20260702_120000_123".to_string(),
981                    title: "t".to_string(),
982                    message_count: 1,
983                    updated_at: "2026-07-02".to_string(),
984                },
985            ])),
986            Msg::QueryResult(QueryResult::ProjectFilesListed(vec![
987                "src/main.rs".to_string(),
988                "docs/".to_string(),
989            ])),
990            Msg::ScratchpadReady {
991                session_id: "20260702_120000_123".to_string(),
992                path: std::path::PathBuf::from("/data/tmp/scratchpad/-proj/20260702_120000_123"),
993            },
994            Msg::RuntimeText("daemon says hi".to_string()),
995            Msg::QueryResult(QueryResult::RuntimeTasksListed(Vec::new())),
996            Msg::QueryResult(QueryResult::RuntimeTaskLoaded {
997                task: None,
998                events: Vec::new(),
999            }),
1000            Msg::QueryResult(QueryResult::RuntimeProcessesListed(Vec::new())),
1001            Msg::QueryResult(QueryResult::RuntimeApprovalsListed(Vec::new())),
1002            Msg::QueryResult(QueryResult::RuntimeCheckpointsListed(Vec::new())),
1003            Msg::QueryResult(QueryResult::ForkCheckpointsFound(Vec::new())),
1004            Msg::QueryResult(QueryResult::RuntimePluginsListed(Vec::new())),
1005            Msg::ModelPullFinished {
1006                model: "qwen3".to_string(),
1007            },
1008            Msg::ModelPullProgress("pulling 42%".to_string()),
1009            Msg::Tick,
1010            Msg::Resize {
1011                width: 120,
1012                height: 40,
1013            },
1014            Msg::TransientStatus {
1015                text: "saved".to_string(),
1016            },
1017            Msg::MouseScroll { delta: -3 },
1018            Msg::FocusChanged(false),
1019            Msg::OpenImageAt {
1020                message_index: 4,
1021                image_index: 0,
1022                image_number: None,
1023            },
1024            Msg::EditorReturned {
1025                text: Some("edited draft".to_string()),
1026            },
1027            Msg::CopySelection("copied".to_string()),
1028        ];
1029
1030        // Every MsgKind must appear in the sample set. `covered` is the
1031        // compile-time guard (exhaustive match breaks when Msg grows); this
1032        // list is the runtime completeness check against the samples.
1033        let seen: Vec<MsgKind> = samples.iter().map(|m| m.kind()).collect();
1034        let missing: Vec<String> = [
1035            MsgKind::Key,
1036            MsgKind::Paste,
1037            MsgKind::ClipboardRead,
1038            MsgKind::SubmitPrompt,
1039            MsgKind::Slash,
1040            MsgKind::CancelTurn,
1041            MsgKind::Confirm,
1042            MsgKind::Quit,
1043            MsgKind::RuntimeSignal,
1044            MsgKind::StreamText,
1045            MsgKind::StreamReasoning,
1046            MsgKind::StreamToolCall,
1047            MsgKind::ContextUsageEstimated,
1048            MsgKind::ProviderContextResolved,
1049            MsgKind::OllamaPlacementResolved,
1050            MsgKind::ProviderVisionResolved,
1051            MsgKind::BuiltinToolSchemaTokens,
1052            MsgKind::CompactionFinished,
1053            MsgKind::CompactionFailed,
1054            MsgKind::StreamDone,
1055            MsgKind::UpstreamError,
1056            MsgKind::ToolStarted,
1057            MsgKind::ToolProgress,
1058            MsgKind::ToolFinished,
1059            MsgKind::ApprovalRequested,
1060            MsgKind::TurnCancelled,
1061            MsgKind::Mcp,
1062            MsgKind::HookContext,
1063            MsgKind::InstructionsChanged,
1064            MsgKind::MemoryChanged,
1065            MsgKind::SessionProvenanceResolved,
1066            MsgKind::SessionSaved,
1067            MsgKind::QueryResult,
1068            MsgKind::RuntimeStore,
1069            MsgKind::ModelPullFinished,
1070            MsgKind::ModelPullProgress,
1071            MsgKind::Tick,
1072            MsgKind::Resize,
1073            MsgKind::MouseScroll,
1074            MsgKind::FocusChanged,
1075            MsgKind::OpenImageAt,
1076            MsgKind::TransientStatus,
1077            MsgKind::CopySelection,
1078        ]
1079        .iter()
1080        .filter(|k| covered(**k) && !seen.contains(k))
1081        .map(|k| format!("{k:?}"))
1082        .collect();
1083        assert!(
1084            missing.is_empty(),
1085            "MsgKinds without a round-trip sample: {missing:?}"
1086        );
1087
1088        // …and every sample must survive the round trip bit-exactly.
1089        for msg in &samples {
1090            let value = serde_json::to_value(msg).expect("serialize");
1091            let back: Msg = serde_json::from_value(value.clone())
1092                .unwrap_or_else(|e| panic!("deserialize {value}: {e}"));
1093            assert_eq!(
1094                format!("{msg:?}"),
1095                format!("{back:?}"),
1096                "round trip changed the msg"
1097            );
1098        }
1099    }
1100}