Skip to main content

mermaid_cli/app/
replay.rs

1//! `--replay <file>`: fold a recorded `Msg` log back through the pure
2//! reducer and reconstruct the session it describes.
3//!
4//! This is the payoff of the MVU architecture's purity contract: because
5//! `update(State, Msg)` reads its clock from `state.now` (injected data)
6//! and mints ids from an in-state counter, replay is a straight fold —
7//! rebuild the initial `State` from the recording's [`SessionHeader`],
8//! stamp each entry's recorded `ts` into `state.now`, apply the entry's
9//! `Msg`, and drop the emitted `Cmd`s (their real-world results are already
10//! in the log as later `Msg`s).
11//!
12//! Replay is headless and effect-free: no model calls, no tool execution,
13//! no terminal UI, no reads of the live machine's config (the header embeds
14//! a snapshot). It exists for post-mortem debugging — "what state was the
15//! reducer in when this went wrong?" — and as a standing determinism check:
16//! every replay folds the log twice and verifies both folds produce
17//! identical state, failing loudly (exit 1) if the reducer has grown a
18//! nondeterminism bug.
19
20use std::path::Path;
21
22use anyhow::Result;
23use chrono::{DateTime, Local};
24
25use crate::app::recorder::{RecordLine, Replay, SessionHeader, session_fingerprint};
26use crate::domain::{Msg, State, update};
27use crate::models::MessageRole;
28
29/// Everything `--replay` learned from one recording.
30pub struct ReplayReport {
31    pub header: SessionHeader,
32    /// Entry lines seen for this session (excludes the header line).
33    pub total_entries: usize,
34    /// Entries successfully reconstructed and folded through `update`.
35    pub applied: usize,
36    /// `(file line number, reason)` for every line that could not be
37    /// reconstructed — malformed JSON, or a `Msg` variant this build doesn't
38    /// know (log written by a newer mermaid).
39    pub skipped: Vec<(usize, String)>,
40    /// File line number where the fold stopped because the recorded session
41    /// quit (`state.should_exit`), mirroring the live loop's exit.
42    pub stopped_at: Option<usize>,
43    /// Entries after the stop line that were parsed but not applied.
44    pub not_applied_after_stop: usize,
45    /// Line number of a second [`SessionHeader`] if the file holds multiple
46    /// appended sessions. Only the first session is replayed.
47    pub second_session_at: Option<usize>,
48    /// True when folding the log twice produced byte-identical `State`
49    /// debug representations — the reducer-purity invariant.
50    pub deterministic: bool,
51    /// The live session's final fingerprint, when the recording was sealed
52    /// on clean exit. `None` for crashed sessions and pre-trailer logs.
53    pub live_fingerprint: Option<String>,
54    /// Whether the folded session reproduces the live one: `Some(true)` =
55    /// exact match, `Some(false)` = diverged (expected when redaction fired
56    /// mid-session or the reducer changed since recording), `None` = no
57    /// trailer to compare against.
58    pub matches_live: Option<bool>,
59    /// Final state after the fold.
60    pub state: State,
61}
62
63/// Read `path`, fold its first session through the reducer (twice, for the
64/// determinism verdict), and report.
65pub fn replay_recording(path: &Path) -> Result<ReplayReport> {
66    let (header, lines) = Replay::open(path)?;
67
68    // Reconstruct everything up front so the fold can run twice without
69    // re-reading the file.
70    let mut msgs: Vec<(usize, DateTime<Local>, Msg)> = Vec::new();
71    let mut skipped = Vec::new();
72    let mut second_session_at = None;
73    let mut trailer = None;
74    let mut line_no = 1usize; // the header is line 1
75    for line in lines {
76        line_no += 1;
77        match line? {
78            RecordLine::Entry(entry) => match entry.to_msg() {
79                Ok(msg) => msgs.push((line_no, entry.ts, msg)),
80                Err(err) => skipped.push((line_no, format!("{err:#}"))),
81            },
82            RecordLine::Trailer(t) => {
83                // Clean-exit seal: the live session's final fingerprint.
84                // Keep the first (this session's); keep reading in case an
85                // appended second session follows.
86                trailer.get_or_insert(t);
87            },
88            RecordLine::Header(_) => {
89                // `Recorder::open` appends, so a reused --record path holds
90                // several sessions back to back. Replay the first; stop here.
91                second_session_at = Some(line_no);
92                break;
93            },
94            RecordLine::Malformed { error, .. } => {
95                skipped.push((line_no, format!("malformed line: {error}")));
96            },
97        }
98    }
99    let total_entries = msgs.len() + skipped.len();
100
101    let (state, stopped_at) = fold(&header, &msgs);
102    let (second, _) = fold(&header, &msgs);
103    let deterministic = format!("{state:?}") == format!("{second:?}");
104
105    // Verify the fold against the LIVE session's recorded outcome (a
106    // stronger claim than determinism, which only proves self-consistency).
107    let live_fingerprint = trailer.map(|t| t.final_session_fingerprint);
108    let matches_live = live_fingerprint
109        .as_ref()
110        .map(|live| *live == session_fingerprint(&state.session));
111
112    let not_applied_after_stop = match stopped_at {
113        Some(stop) => msgs.iter().filter(|(line, ..)| *line > stop).count(),
114        None => 0,
115    };
116    let applied = msgs.len() - not_applied_after_stop;
117
118    Ok(ReplayReport {
119        header,
120        total_entries,
121        applied,
122        skipped,
123        stopped_at,
124        not_applied_after_stop,
125        second_session_at,
126        deterministic,
127        live_fingerprint,
128        matches_live,
129        state,
130    })
131}
132
133/// The fold itself: initial `State` from the header, then one `update` per
134/// entry under the entry's recorded clock. Emitted `Cmd`s are dropped — the
135/// log already contains every effect result as a later `Msg`. Stops where
136/// the live loop would have: on `should_exit`.
137fn fold(header: &SessionHeader, msgs: &[(usize, DateTime<Local>, Msg)]) -> (State, Option<usize>) {
138    let mut state = State::new(
139        header.config.clone(),
140        header.cwd.clone(),
141        header.model_id.clone(),
142        header.ts,
143    );
144    if let Some(seed) = header.seed_conversation.clone() {
145        state.seed_conversation(seed);
146    }
147    let mut stopped_at = None;
148    for (line, ts, msg) in msgs {
149        state.now = *ts;
150        let (next, _cmds) = update(state, msg.clone());
151        state = next;
152        if state.should_exit {
153            stopped_at = Some(*line);
154            break;
155        }
156    }
157    (state, stopped_at)
158}
159
160/// CLI entry point for `--replay`: fold, print the report + reconstructed
161/// transcript to stdout, and return the determinism verdict (`false` means
162/// the caller should exit non-zero — the purity invariant is broken).
163pub fn run_replay(path: &Path) -> Result<bool> {
164    let report = replay_recording(path)?;
165    print!("{}", render_report(path, &report));
166    Ok(report.deterministic)
167}
168
169/// Plain-text report. Separate from `run_replay` so tests can assert on it.
170fn render_report(path: &Path, report: &ReplayReport) -> String {
171    use std::fmt::Write as _;
172
173    let mut out = String::new();
174    let h = &report.header;
175    let _ = writeln!(out, "replay: {}", path.display());
176    let _ = writeln!(
177        out,
178        "  recorded: {} · model: {} · cwd: {}",
179        h.ts.to_rfc3339(),
180        h.model_id,
181        h.cwd.display()
182    );
183    match &h.seed_conversation {
184        Some(seed) => {
185            let _ = writeln!(
186                out,
187                "  seed: continued from {} ({} messages)",
188                seed.id,
189                seed.messages().len()
190            );
191        },
192        None => {
193            let _ = writeln!(out, "  seed: fresh session");
194        },
195    }
196    let _ = writeln!(
197        out,
198        "  entries: {} total · {} applied · {} skipped",
199        report.total_entries,
200        report.applied,
201        report.skipped.len()
202    );
203    for (line, reason) in &report.skipped {
204        let _ = writeln!(out, "    line {line}: {reason}");
205    }
206    if let Some(stop) = report.stopped_at {
207        let _ = writeln!(
208            out,
209            "  note: session quit at line {stop}; {} later entries not applied",
210            report.not_applied_after_stop
211        );
212    }
213    if let Some(line) = report.second_session_at {
214        let _ = writeln!(
215            out,
216            "  note: a second appended session starts at line {line} — replayed the first only"
217        );
218    }
219    let _ = writeln!(
220        out,
221        "  determinism: {}",
222        if report.deterministic {
223            "PASS — folding the log twice produced identical state"
224        } else {
225            "FAIL — two folds of the same log diverged (reducer purity bug)"
226        }
227    );
228    let _ = writeln!(
229        out,
230        "  live match: {}",
231        match report.matches_live {
232            Some(true) => "yes — the fold reproduces the live session's recorded outcome",
233            Some(false) =>
234                "no — the fold differs from the live session (expected if redaction \
235                 fired mid-session or the reducer changed since recording)",
236            None => "unknown — recording has no clean-exit fingerprint",
237        }
238    );
239
240    let messages = report.state.session.messages();
241    let _ = writeln!(out);
242    let _ = writeln!(
243        out,
244        "transcript: {} — {} messages",
245        report.state.session.conversation.title,
246        messages.len()
247    );
248    for msg in messages {
249        let role = match msg.role {
250            MessageRole::User => "user",
251            MessageRole::Assistant => "assistant",
252            MessageRole::System => "system",
253            MessageRole::Tool => "tool",
254        };
255        // Indent continuation lines so multi-line content stays visually
256        // attached to its role tag.
257        let mut lines = msg.content.lines();
258        let first = lines.next().unwrap_or_default();
259        let _ = writeln!(out, "  [{role}] {first}");
260        for line in lines {
261            let _ = writeln!(out, "    {line}");
262        }
263        if let Some(calls) = &msg.tool_calls
264            && !calls.is_empty()
265        {
266            let names: Vec<&str> = calls.iter().map(|c| c.function.name.as_str()).collect();
267            let _ = writeln!(
268                out,
269                "    ({} tool calls: {})",
270                calls.len(),
271                names.join(", ")
272            );
273        }
274    }
275    out
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::app::Config;
282    use crate::app::recorder::{RECORDING_FORMAT_VERSION, Recorder};
283    use crate::domain::TurnId;
284    use crate::models::{FinishReason, TokenUsage};
285    use std::path::PathBuf;
286
287    fn tmpfile(name: &str) -> PathBuf {
288        let dir = std::env::temp_dir().join("mermaid_replay_tests");
289        let _ = std::fs::create_dir_all(&dir);
290        dir.join(name)
291    }
292
293    fn fixed_ts(offset_secs: i64) -> DateTime<Local> {
294        chrono::DateTime::parse_from_rfc3339("2026-07-02T12:00:00.500+00:00")
295            .unwrap()
296            .with_timezone(&Local)
297            + chrono::Duration::seconds(offset_secs)
298    }
299
300    fn header(ts: DateTime<Local>) -> SessionHeader {
301        SessionHeader {
302            format: RECORDING_FORMAT_VERSION,
303            ts,
304            model_id: "ollama/test".to_string(),
305            cwd: PathBuf::from("/tmp/replay-project"),
306            config: Config::default(),
307            seed_conversation: None,
308        }
309    }
310
311    /// Record a small but realistic session — prompt, streamed answer,
312    /// stream end, quit — exactly as the live driver would.
313    fn record_session(path: &PathBuf) {
314        let _ = std::fs::remove_file(path);
315        let h = header(fixed_ts(0));
316        let mut r = Recorder::open(path).expect("open recorder");
317        r.record_header(&h).expect("header");
318        // Fold while recording, exactly like the live driver: one shared
319        // clock per entry, and the trailer seals the final session.
320        let mut state = State::new(h.config.clone(), h.cwd.clone(), h.model_id.clone(), h.ts);
321        let script: Vec<(i64, Msg)> = vec![
322            (
323                1,
324                Msg::SubmitPrompt {
325                    text: "hello there".to_string(),
326                    attachment_ids: Vec::new(),
327                },
328            ),
329            (
330                2,
331                Msg::StreamText {
332                    turn: TurnId(1),
333                    chunk: "Hi — ".to_string(),
334                },
335            ),
336            (
337                3,
338                Msg::StreamText {
339                    turn: TurnId(1),
340                    chunk: "hello!".to_string(),
341                },
342            ),
343            (
344                4,
345                Msg::StreamDone {
346                    turn: TurnId(1),
347                    usage: Some(TokenUsage::provider(10, 5)),
348                    provider_continuation: None,
349                    stop_reason: Some(FinishReason::Stop),
350                },
351            ),
352            (5, Msg::Quit),
353        ];
354        for (offset, msg) in script {
355            let now = fixed_ts(offset);
356            r.record_msg(now, &msg).expect("record");
357            state.now = now;
358            let (next, _cmds) = update(state, msg);
359            state = next;
360        }
361        r.record_trailer(fixed_ts(9), &state.session)
362            .expect("trailer");
363        r.flush().expect("flush");
364    }
365
366    #[test]
367    fn replay_reconstructs_a_recorded_session_deterministically() {
368        let path = tmpfile("session.jsonl");
369        record_session(&path);
370
371        let report = replay_recording(&path).expect("replay");
372        assert!(
373            report.deterministic,
374            "double fold must produce identical state"
375        );
376        assert_eq!(report.total_entries, 5);
377        assert_eq!(report.applied, 5, "skipped: {:?}", report.skipped);
378        assert!(report.skipped.is_empty());
379        assert_eq!(report.stopped_at, Some(6), "Quit is line 6 of the file");
380        assert!(report.state.should_exit);
381
382        // The reconstructed transcript holds the prompt and the assembled
383        // streamed answer.
384        let messages = report.state.session.messages();
385        let user = messages
386            .iter()
387            .find(|m| m.role == MessageRole::User)
388            .expect("user message");
389        assert_eq!(user.content, "hello there");
390        let assistant = messages
391            .iter()
392            .find(|m| m.role == MessageRole::Assistant)
393            .expect("assistant message");
394        assert_eq!(assistant.content, "Hi — hello!");
395
396        // Clock injection end to end: the initial conversation id derives
397        // from the recorded header ts, not from the machine's wall clock.
398        let expected_id = format!("{}", fixed_ts(0).format("%Y%m%d_%H%M%S_%3f"));
399        assert_eq!(report.state.session.conversation.id, expected_id);
400
401        // Message commit timestamps come from the recorded per-entry clock.
402        assert_eq!(user.timestamp, fixed_ts(1));
403
404        let _ = std::fs::remove_file(&path);
405    }
406
407    #[test]
408    fn replay_report_renders_transcript_and_verdict() {
409        let path = tmpfile("render.jsonl");
410        record_session(&path);
411        let report = replay_recording(&path).expect("replay");
412        let text = render_report(&path, &report);
413        assert!(text.contains("determinism: PASS"));
414        assert!(text.contains("live match: yes"));
415        assert!(text.contains("[user] hello there"));
416        assert!(text.contains("[assistant] Hi — hello!"));
417        assert!(text.contains("5 applied"));
418        let _ = std::fs::remove_file(&path);
419    }
420
421    #[test]
422    fn replay_verifies_against_the_live_fingerprint() {
423        let path = tmpfile("livematch.jsonl");
424        record_session(&path);
425        let report = replay_recording(&path).expect("replay");
426        assert_eq!(
427            report.matches_live,
428            Some(true),
429            "a faithful fold must match the live session's seal"
430        );
431        assert!(
432            report
433                .live_fingerprint
434                .as_deref()
435                .expect("trailer present")
436                .starts_with("sha256:")
437        );
438        let _ = std::fs::remove_file(&path);
439    }
440
441    #[test]
442    fn tampered_recording_fails_live_match_but_stays_deterministic() {
443        let path = tmpfile("tamper.jsonl");
444        record_session(&path);
445        // Alter one streamed chunk after the fact — the fold is still
446        // self-consistent (deterministic) but no longer reproduces what the
447        // live session saw.
448        let raw = std::fs::read_to_string(&path).unwrap();
449        assert!(raw.contains("hello!"));
450        std::fs::write(&path, raw.replace("hello!", "goodbye")).unwrap();
451
452        let report = replay_recording(&path).expect("replay");
453        assert_eq!(
454            report.matches_live,
455            Some(false),
456            "an altered log must not match the live fingerprint"
457        );
458        assert!(
459            report.deterministic,
460            "tampering must not affect fold self-consistency"
461        );
462        let _ = std::fs::remove_file(&path);
463    }
464
465    #[test]
466    fn recording_without_trailer_reports_unknown_live_match() {
467        // A crashed session never writes the clean-exit seal.
468        let path = tmpfile("crash.jsonl");
469        record_session(&path);
470        let raw = std::fs::read_to_string(&path).unwrap();
471        let without_trailer: String = raw
472            .lines()
473            .filter(|l| !l.contains("final_session_fingerprint"))
474            .collect::<Vec<_>>()
475            .join("\n")
476            + "\n";
477        std::fs::write(&path, without_trailer).unwrap();
478
479        let report = replay_recording(&path).expect("replay");
480        assert_eq!(report.matches_live, None);
481        assert!(report.live_fingerprint.is_none());
482        assert!(render_report(&path, &report).contains("live match: unknown"));
483        let _ = std::fs::remove_file(&path);
484    }
485
486    #[test]
487    fn replay_stops_at_second_appended_session() {
488        let path = tmpfile("two.jsonl");
489        record_session(&path);
490        // Append a second session the way a reused --record path would.
491        {
492            let mut r = Recorder::open(&path).expect("reopen");
493            r.record_header(&header(fixed_ts(100))).expect("header2");
494            r.record_msg(fixed_ts(101), &Msg::SessionSaved)
495                .expect("record");
496        }
497        let report = replay_recording(&path).expect("replay");
498        // File: header(1) + 5 entries(2-6) + trailer(7) + header2(8).
499        assert_eq!(report.second_session_at, Some(8));
500        assert_eq!(report.total_entries, 5, "second session must not count");
501        assert_eq!(
502            report.matches_live,
503            Some(true),
504            "the first session's trailer still verifies"
505        );
506        assert!(report.deterministic);
507        let _ = std::fs::remove_file(&path);
508    }
509
510    #[test]
511    fn replay_skips_unknown_msg_variants_with_line_numbers() {
512        let path = tmpfile("unknown.jsonl");
513        record_session(&path);
514        // Simulate a log from a newer mermaid: an entry whose msg variant
515        // this build doesn't know. Insert before the Quit line.
516        let raw = std::fs::read_to_string(&path).unwrap();
517        let mut lines: Vec<&str> = raw.lines().collect();
518        let future = r#"{"ts":"2026-07-02T12:00:04.700+00:00","kind":"HoloDeck","turn":null,"msg":{"HoloDeck":{"program":"bridge"}}}"#;
519        lines.insert(5, future);
520        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
521
522        let report = replay_recording(&path).expect("replay");
523        assert_eq!(report.skipped.len(), 1);
524        assert_eq!(report.skipped[0].0, 6, "inserted at file line 6");
525        assert!(report.skipped[0].1.contains("HoloDeck"));
526        assert!(report.deterministic);
527        // Everything else still applied.
528        assert_eq!(report.applied, 5);
529        let _ = std::fs::remove_file(&path);
530    }
531
532    /// Manual QA helper: writes a small recording to
533    /// `$MERMAID_REPLAY_FIXTURE` (default `/tmp/mermaid-replay-fixture.jsonl`)
534    /// so the real CLI can be exercised end to end:
535    /// `cargo test --lib write_replay_fixture -- --ignored && mermaid --replay <path>`
536    #[test]
537    #[ignore = "writes a fixture for manual --replay QA"]
538    fn write_replay_fixture() {
539        let path = std::env::var("MERMAID_REPLAY_FIXTURE")
540            .unwrap_or_else(|_| "/tmp/mermaid-replay-fixture.jsonl".to_string());
541        let path = PathBuf::from(path);
542        record_session(&path);
543        eprintln!("fixture written: {}", path.display());
544    }
545
546    #[test]
547    fn same_log_folded_under_different_wall_clock_is_identical() {
548        // The whole point of clock injection: replaying tomorrow gives the
549        // same state as replaying today. Fold the same file twice with real
550        // wall time passing between folds and compare.
551        let path = tmpfile("stable.jsonl");
552        record_session(&path);
553        let a = replay_recording(&path).expect("first");
554        std::thread::sleep(std::time::Duration::from_millis(25));
555        let b = replay_recording(&path).expect("second");
556        assert_eq!(
557            format!("{:?}", a.state),
558            format!("{:?}", b.state),
559            "replay must not depend on the machine's wall clock"
560        );
561        let _ = std::fs::remove_file(&path);
562    }
563}