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 = "a parity test: one hand-built sample per Msg variant, checked by a match that \
673 is exhaustive over MsgKind so a new variant fails to compile until it has a sample; the \
674 sample list is the test, and it grows with Msg by design"
675 )]
676 fn every_msg_kind_has_a_round_trip_sample() {
677 use mermaid_domain::{
682 ApprovalKind, ContextUsageSnapshot, Key, KeyCode, KeyMods, PromptTokenBreakdown,
683 RuntimeSignal, SlashCmd, StatusKind, ToolCallId, ToolOutcome,
684 };
685 use mermaid_model::models::ReasoningChunk;
686
687 fn covered(kind: MsgKind) -> bool {
688 match kind {
689 MsgKind::Key
690 | MsgKind::Paste
691 | MsgKind::ClipboardRead
692 | MsgKind::SubmitPrompt
693 | MsgKind::Slash
694 | MsgKind::CancelTurn
695 | MsgKind::Confirm
696 | MsgKind::Quit
697 | MsgKind::RuntimeSignal
698 | MsgKind::StreamText
699 | MsgKind::StreamReasoning
700 | MsgKind::StreamToolCall
701 | MsgKind::ContextUsageEstimated
702 | MsgKind::ProviderContextResolved
703 | MsgKind::OllamaPlacementResolved
704 | MsgKind::ProviderVisionResolved
705 | MsgKind::BuiltinToolSchemaTokens
706 | MsgKind::CompactionFinished
707 | MsgKind::CompactionFailed
708 | MsgKind::StreamDone
709 | MsgKind::UpstreamError
710 | MsgKind::ToolStarted
711 | MsgKind::ToolProgress
712 | MsgKind::ToolFinished
713 | MsgKind::ApprovalRequested
714 | MsgKind::QuestionAsked
715 | MsgKind::TasksUpdated
716 | MsgKind::TaskNotice
717 | MsgKind::TurnCancelled
718 | MsgKind::Mcp
719 | MsgKind::HookContext
720 | MsgKind::InstructionsChanged
721 | MsgKind::MemoryChanged
722 | MsgKind::SessionProvenanceResolved
723 | MsgKind::SessionSaved
724 | MsgKind::QueryResult
725 | MsgKind::ScratchpadReady
726 | MsgKind::RuntimeStore
727 | MsgKind::ModelPullFinished
728 | MsgKind::ModelPullProgress
729 | MsgKind::Tick
730 | MsgKind::Resize
731 | MsgKind::MouseScroll
732 | MsgKind::FocusChanged
733 | MsgKind::OpenImageAt
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::QueryResult(QueryResult::ConversationLoaded(Box::new(
978 ConversationHistory::new("/p".to_string(), "m".to_string(), fixed_ts()),
979 ))),
980 Msg::QueryResult(QueryResult::ConversationsListed(vec![
981 mermaid_domain::ConversationSummary {
982 id: "20260702_120000_123".to_string(),
983 title: "t".to_string(),
984 message_count: 1,
985 updated_at: "2026-07-02".to_string(),
986 },
987 ])),
988 Msg::QueryResult(QueryResult::ProjectFilesListed(vec![
989 "src/main.rs".to_string(),
990 "docs/".to_string(),
991 ])),
992 Msg::ScratchpadReady {
993 session_id: "20260702_120000_123".to_string(),
994 path: std::path::PathBuf::from("/data/tmp/scratchpad/-proj/20260702_120000_123"),
995 },
996 Msg::RuntimeText("daemon says hi".to_string()),
997 Msg::QueryResult(QueryResult::RuntimeTasksListed(Vec::new())),
998 Msg::QueryResult(QueryResult::RuntimeTaskLoaded {
999 task: None,
1000 events: Vec::new(),
1001 }),
1002 Msg::QueryResult(QueryResult::RuntimeProcessesListed(Vec::new())),
1003 Msg::QueryResult(QueryResult::RuntimeApprovalsListed(Vec::new())),
1004 Msg::QueryResult(QueryResult::RuntimeCheckpointsListed(Vec::new())),
1005 Msg::QueryResult(QueryResult::ForkCheckpointsFound(Vec::new())),
1006 Msg::QueryResult(QueryResult::RuntimePluginsListed(Vec::new())),
1007 Msg::ModelPullFinished {
1008 model: "qwen3".to_string(),
1009 },
1010 Msg::ModelPullProgress("pulling 42%".to_string()),
1011 Msg::Tick,
1012 Msg::Resize {
1013 width: 120,
1014 height: 40,
1015 },
1016 Msg::TransientStatus {
1017 text: "saved".to_string(),
1018 },
1019 Msg::MouseScroll { delta: -3 },
1020 Msg::FocusChanged(false),
1021 Msg::OpenImageAt {
1022 message_index: 4,
1023 image_index: 0,
1024 image_number: None,
1025 },
1026 Msg::EditorReturned {
1027 text: Some("edited draft".to_string()),
1028 },
1029 Msg::CopySelection("copied".to_string()),
1030 ];
1031
1032 let seen: Vec<MsgKind> = samples.iter().map(|m| m.kind()).collect();
1036 let missing: Vec<String> = [
1037 MsgKind::Key,
1038 MsgKind::Paste,
1039 MsgKind::ClipboardRead,
1040 MsgKind::SubmitPrompt,
1041 MsgKind::Slash,
1042 MsgKind::CancelTurn,
1043 MsgKind::Confirm,
1044 MsgKind::Quit,
1045 MsgKind::RuntimeSignal,
1046 MsgKind::StreamText,
1047 MsgKind::StreamReasoning,
1048 MsgKind::StreamToolCall,
1049 MsgKind::ContextUsageEstimated,
1050 MsgKind::ProviderContextResolved,
1051 MsgKind::OllamaPlacementResolved,
1052 MsgKind::ProviderVisionResolved,
1053 MsgKind::BuiltinToolSchemaTokens,
1054 MsgKind::CompactionFinished,
1055 MsgKind::CompactionFailed,
1056 MsgKind::StreamDone,
1057 MsgKind::UpstreamError,
1058 MsgKind::ToolStarted,
1059 MsgKind::ToolProgress,
1060 MsgKind::ToolFinished,
1061 MsgKind::ApprovalRequested,
1062 MsgKind::TurnCancelled,
1063 MsgKind::Mcp,
1064 MsgKind::HookContext,
1065 MsgKind::InstructionsChanged,
1066 MsgKind::MemoryChanged,
1067 MsgKind::SessionProvenanceResolved,
1068 MsgKind::SessionSaved,
1069 MsgKind::QueryResult,
1070 MsgKind::RuntimeStore,
1071 MsgKind::ModelPullFinished,
1072 MsgKind::ModelPullProgress,
1073 MsgKind::Tick,
1074 MsgKind::Resize,
1075 MsgKind::MouseScroll,
1076 MsgKind::FocusChanged,
1077 MsgKind::OpenImageAt,
1078 MsgKind::TransientStatus,
1079 MsgKind::CopySelection,
1080 ]
1081 .iter()
1082 .filter(|k| covered(**k) && !seen.contains(k))
1083 .map(|k| format!("{k:?}"))
1084 .collect();
1085 assert!(
1086 missing.is_empty(),
1087 "MsgKinds without a round-trip sample: {missing:?}"
1088 );
1089
1090 for msg in &samples {
1092 let value = serde_json::to_value(msg).expect("serialize");
1093 let back: Msg = serde_json::from_value(value.clone())
1094 .unwrap_or_else(|e| panic!("deserialize {value}: {e}"));
1095 assert_eq!(
1096 format!("{msg:?}"),
1097 format!("{back:?}"),
1098 "round trip changed the msg"
1099 );
1100 }
1101 }
1102}