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