Skip to main content

mermaid_cli/app/
recorder.rs

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