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::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 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 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 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 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 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 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 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 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 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}