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 crate::app::Config;
43use crate::domain::{Msg, Session};
44use crate::session::ConversationHistory;
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> {
77 let path = path.into();
78 let mut opts = OpenOptions::new();
79 opts.create(true).append(true);
80 #[cfg(unix)]
85 {
86 use std::os::unix::fs::OpenOptionsExt;
87 opts.mode(0o600);
88 }
89 let file = opts
90 .open(&path)
91 .with_context(|| format!("open {} for recording", path.display()))?;
92 #[cfg(unix)]
94 {
95 use std::os::unix::fs::PermissionsExt;
96 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
97 }
98 tracing::warn!(
99 path = %path.display(),
100 "session recording is ON: this file stores prompts, model output, and \
101 tool results (including file contents) in cleartext; only \
102 credential-shaped strings are redacted",
103 );
104 Ok(Self {
105 writer: BufWriter::new(file),
106 })
107 }
108
109 pub fn record_header(&mut self, header: &SessionHeader) -> Result<()> {
113 let mut value = serde_json::to_value(header).context("serialize session header")?;
114 crate::utils::redact_json(&mut value);
115 writeln!(self.writer, "{}", value).context("write header line")?;
116 self.flush()
117 }
118
119 pub fn record_msg(&mut self, now: DateTime<Local>, msg: &Msg) -> Result<()> {
131 if matches!(msg, Msg::Tick) {
132 return Ok(());
133 }
134 let sanitized;
138 let msg = match msg {
139 Msg::CopySelection(text) => {
140 sanitized = Msg::CopySelection(format!("[{} chars]", text.chars().count()));
141 &sanitized
142 },
143 other => other,
144 };
145 let mut body = serde_json::to_value(msg).context("serialize msg")?;
146 crate::utils::redact_json(&mut body);
151 let entry = serde_json::json!({
152 "ts": now,
153 "kind": format!("{:?}", msg.kind()),
154 "turn": msg.turn_id().map(|t| t.0),
155 "msg": body,
156 });
157 writeln!(self.writer, "{}", entry).context("write jsonl line")?;
158 Ok(())
159 }
160
161 pub fn record_trailer(&mut self, now: DateTime<Local>, session: &Session) -> Result<()> {
166 let trailer = SessionTrailer {
167 ts: now,
168 final_session_fingerprint: session_fingerprint(session),
169 };
170 let line = serde_json::to_string(&trailer).context("serialize session trailer")?;
171 writeln!(self.writer, "{}", line).context("write trailer line")?;
172 self.flush()
173 }
174
175 pub fn flush(&mut self) -> Result<()> {
176 self.writer.flush().context("flush recorder")
177 }
178}
179
180impl Drop for Recorder {
181 fn drop(&mut self) {
182 let _ = self.writer.flush();
183 }
184}
185
186pub fn session_fingerprint(session: &Session) -> String {
198 use sha2::{Digest, Sha256};
199 use std::fmt::Write as _;
200 let mut hasher = Sha256::new();
201 hasher.update(format!("{session:?}").as_bytes());
202 let digest = hasher.finalize();
203 let mut out = String::with_capacity("sha256:".len() + digest.len() * 2);
204 out.push_str("sha256:");
205 for byte in digest {
206 let _ = write!(out, "{byte:02x}");
207 }
208 out
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct SessionTrailer {
215 pub ts: DateTime<Local>,
216 pub final_session_fingerprint: String,
217}
218
219#[derive(Debug, Serialize, Deserialize)]
221pub struct ReplayEntry {
222 pub ts: DateTime<Local>,
223 pub kind: String,
224 pub turn: Option<u64>,
225 pub msg: serde_json::Value,
226}
227
228impl ReplayEntry {
229 pub fn to_msg(&self) -> Result<Msg> {
233 serde_json::from_value(self.msg.clone())
234 .with_context(|| format!("reconstruct recorded {} msg", self.kind))
235 }
236}
237
238#[derive(Debug)]
240pub enum RecordLine {
241 Entry(ReplayEntry),
243 Trailer(SessionTrailer),
245 Header(Box<SessionHeader>),
249 Malformed { raw: String, error: String },
251}
252
253#[derive(Debug)]
256pub struct Replay {
257 lines: std::io::Lines<BufReader<File>>,
258}
259
260impl Replay {
261 pub fn open(path: impl Into<PathBuf>) -> Result<(SessionHeader, Self)> {
265 let path = path.into();
266 let file =
267 File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
268 let mut lines = BufReader::new(file).lines();
269 let first = lines
270 .next()
271 .context("recording is empty — no session header")?
272 .context("read session header line")?;
273 let header: SessionHeader = serde_json::from_str(&first).context(
274 "recording has no parseable session header — \
275 was it written by an older mermaid or truncated at byte 0?",
276 )?;
277 anyhow::ensure!(
278 header.format == RECORDING_FORMAT_VERSION,
279 "recording format {} is not supported (this build reads format {})",
280 header.format,
281 RECORDING_FORMAT_VERSION,
282 );
283 Ok((header, Self { lines }))
284 }
285}
286
287impl Iterator for Replay {
288 type Item = std::io::Result<RecordLine>;
289
290 fn next(&mut self) -> Option<Self::Item> {
291 let raw = match self.lines.next()? {
292 Ok(raw) => raw,
293 Err(e) => return Some(Err(e)),
294 };
295 let line = match serde_json::from_str::<ReplayEntry>(&raw) {
299 Ok(entry) => RecordLine::Entry(entry),
300 Err(entry_err) => match serde_json::from_str::<SessionTrailer>(&raw) {
301 Ok(trailer) => RecordLine::Trailer(trailer),
302 Err(_) => match serde_json::from_str::<SessionHeader>(&raw) {
303 Ok(header) => RecordLine::Header(Box::new(header)),
304 Err(_) => RecordLine::Malformed {
305 raw,
306 error: entry_err.to_string(),
307 },
308 },
309 },
310 };
311 Some(Ok(line))
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::domain::{MsgKind, Paste, TurnId};
319
320 fn tmpfile(name: &str) -> PathBuf {
321 let dir = std::env::temp_dir().join("mermaid_recorder_tests");
322 let _ = std::fs::create_dir_all(&dir);
323 dir.join(name)
324 }
325
326 fn test_header(ts: DateTime<Local>) -> SessionHeader {
327 SessionHeader {
328 format: RECORDING_FORMAT_VERSION,
329 ts,
330 model_id: "ollama/test".to_string(),
331 cwd: PathBuf::from("/tmp/project"),
332 config: Config::default(),
333 seed_conversation: None,
334 }
335 }
336
337 fn fixed_ts() -> DateTime<Local> {
338 chrono::DateTime::parse_from_rfc3339("2026-07-02T12:00:00.123+00:00")
340 .unwrap()
341 .with_timezone(&Local)
342 }
343
344 #[cfg(unix)]
345 #[test]
346 fn recording_file_is_owner_only() {
347 use std::os::unix::fs::PermissionsExt;
350 let path = tmpfile("perms.jsonl");
351 let _ = std::fs::remove_file(&path);
352 let _ = Recorder::open(&path).expect("open");
353 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
354 assert_eq!(mode, 0o600, "recording must be created owner-only");
355 let _ = std::fs::remove_file(&path);
356 }
357
358 #[test]
359 fn record_and_replay_roundtrip() {
360 let path = tmpfile("roundtrip.jsonl");
361 let _ = std::fs::remove_file(&path);
362 let ts = fixed_ts();
363
364 {
365 let mut r = Recorder::open(&path).expect("open");
366 r.record_header(&test_header(ts)).expect("header");
367 r.record_msg(ts, &Msg::SessionSaved).expect("record");
368 r.record_msg(
369 ts,
370 &Msg::SubmitPrompt {
371 text: "hello".to_string(),
372 attachment_ids: vec![3, 9],
373 },
374 )
375 .expect("record");
376 r.record_msg(
377 ts,
378 &Msg::StreamText {
379 turn: TurnId(7),
380 chunk: "partial".to_string(),
381 },
382 )
383 .expect("record");
384 r.flush().expect("flush");
385 }
386
387 let (header, replay) = Replay::open(&path).expect("open replay");
388 assert_eq!(header.model_id, "ollama/test");
389 assert_eq!(header.ts, ts);
390
391 let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read all");
392 assert_eq!(lines.len(), 3);
393 let entries: Vec<&ReplayEntry> = lines
394 .iter()
395 .map(|l| match l {
396 RecordLine::Entry(e) => e,
397 other => panic!("expected entry, got {other:?}"),
398 })
399 .collect();
400 assert_eq!(entries[0].kind, "SessionSaved");
401 assert!(matches!(entries[0].to_msg().unwrap(), Msg::SessionSaved));
402 match entries[1].to_msg().unwrap() {
403 Msg::SubmitPrompt {
404 text,
405 attachment_ids,
406 } => {
407 assert_eq!(text, "hello");
408 assert_eq!(attachment_ids, vec![3, 9]);
409 },
410 other => panic!("expected SubmitPrompt, got {other:?}"),
411 }
412 assert_eq!(entries[2].turn, Some(7));
413 assert_eq!(entries[2].ts, ts);
414
415 let _ = std::fs::remove_file(&path);
416 }
417
418 #[test]
419 fn record_msg_redacts_secrets_in_body() {
420 let path = tmpfile("redact.jsonl");
423 let _ = std::fs::remove_file(&path);
424 {
425 let mut r = Recorder::open(&path).expect("open");
426 r.record_header(&test_header(fixed_ts())).expect("header");
427 r.record_msg(
428 fixed_ts(),
429 &Msg::StreamText {
430 turn: TurnId(1),
431 chunk: "OPENAI_API_KEY=sk-abcdefghijklmnop1234".to_string(),
432 },
433 )
434 .expect("record");
435 r.flush().expect("flush");
436 }
437
438 let raw = std::fs::read_to_string(&path).expect("read back");
439 assert!(
440 !raw.contains("sk-abcdefghijklmnop1234"),
441 "raw secret leaked: {raw}"
442 );
443 assert!(
444 raw.contains("[REDACTED]"),
445 "expected redaction marker: {raw}"
446 );
447
448 let (_, mut replay) = Replay::open(&path).expect("replay");
449 let line = replay.next().expect("one line").expect("io ok");
450 let RecordLine::Entry(entry) = line else {
451 panic!("expected entry");
452 };
453 match entry.to_msg().unwrap() {
454 Msg::StreamText { chunk, .. } => {
455 assert_eq!(chunk, "OPENAI_API_KEY=[REDACTED]");
456 },
457 other => panic!("expected StreamText, got {other:?}"),
458 }
459
460 let _ = std::fs::remove_file(&path);
461 }
462
463 #[test]
464 fn copy_selection_is_recorded_as_placeholder() {
465 let path = tmpfile("copysel.jsonl");
466 let _ = std::fs::remove_file(&path);
467 {
468 let mut r = Recorder::open(&path).expect("open");
469 r.record_header(&test_header(fixed_ts())).expect("header");
470 r.record_msg(
471 fixed_ts(),
472 &Msg::CopySelection("secret transcript".to_string()),
473 )
474 .expect("record");
475 }
476 let raw = std::fs::read_to_string(&path).expect("read");
477 assert!(!raw.contains("secret transcript"));
478 assert!(raw.contains("[17 chars]"));
479 let _ = std::fs::remove_file(&path);
480 }
481
482 #[test]
483 fn image_paste_round_trips_as_base64() {
484 let path = tmpfile("imgpaste.jsonl");
485 let _ = std::fs::remove_file(&path);
486 let bytes = vec![0u8, 1, 2, 250, 255, 128];
487 {
488 let mut r = Recorder::open(&path).expect("open");
489 r.record_header(&test_header(fixed_ts())).expect("header");
490 r.record_msg(
491 fixed_ts(),
492 &Msg::Paste(Paste::Image {
493 bytes: bytes.clone(),
494 format: "png".to_string(),
495 }),
496 )
497 .expect("record");
498 }
499 let (_, mut replay) = Replay::open(&path).expect("replay");
500 let RecordLine::Entry(entry) = replay.next().unwrap().unwrap() else {
501 panic!("expected entry");
502 };
503 match entry.to_msg().unwrap() {
504 Msg::Paste(Paste::Image {
505 bytes: back,
506 format,
507 }) => {
508 assert_eq!(back, bytes, "image bytes must replay bit-exactly");
509 assert_eq!(format, "png");
510 },
511 other => panic!("expected image paste, got {other:?}"),
512 }
513 let _ = std::fs::remove_file(&path);
514 }
515
516 #[test]
517 fn replay_refuses_headerless_recording() {
518 let path = tmpfile("headerless.jsonl");
519 std::fs::write(
520 &path,
521 "{\"ts\":\"2026-07-02T12:00:00Z\",\"kind\":\"Tick\",\"turn\":null,\"msg\":\"Tick\"}\n",
522 )
523 .expect("write");
524 let err = Replay::open(&path).expect_err("must refuse");
525 assert!(err.to_string().contains("session header"), "got: {err:#}");
526 let _ = std::fs::remove_file(&path);
527 }
528
529 #[test]
530 fn replay_classifies_appended_second_session_header() {
531 let path = tmpfile("twosessions.jsonl");
535 let _ = std::fs::remove_file(&path);
536 {
537 let mut r = Recorder::open(&path).expect("open");
538 r.record_header(&test_header(fixed_ts())).expect("header");
539 r.record_msg(fixed_ts(), &Msg::SessionSaved)
540 .expect("record");
541 }
542 {
543 let mut r = Recorder::open(&path).expect("reopen");
544 r.record_header(&test_header(fixed_ts())).expect("header2");
545 r.record_msg(fixed_ts(), &Msg::Quit).expect("record");
546 }
547 let (_, replay) = Replay::open(&path).expect("replay");
548 let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read");
549 assert_eq!(lines.len(), 3);
550 assert!(matches!(lines[0], RecordLine::Entry(_)));
551 assert!(matches!(lines[1], RecordLine::Header(_)));
552 assert!(matches!(lines[2], RecordLine::Entry(_)));
553 let _ = std::fs::remove_file(&path);
554 }
555
556 #[test]
557 fn ticks_are_elided_from_recordings() {
558 let path = tmpfile("noticks.jsonl");
562 let _ = std::fs::remove_file(&path);
563 {
564 let mut r = Recorder::open(&path).expect("open");
565 r.record_header(&test_header(fixed_ts())).expect("header");
566 r.record_msg(fixed_ts(), &Msg::Tick).expect("tick");
567 r.record_msg(fixed_ts(), &Msg::Quit).expect("quit");
568 r.record_msg(fixed_ts(), &Msg::Tick).expect("tick");
569 }
570 let (_, replay) = Replay::open(&path).expect("replay");
571 let lines: Vec<_> = replay.collect::<std::io::Result<_>>().expect("read");
572 assert_eq!(lines.len(), 1, "only the Quit entry may hit disk");
573 let RecordLine::Entry(entry) = &lines[0] else {
574 panic!("expected entry");
575 };
576 assert_eq!(entry.kind, "Quit");
577 let _ = std::fs::remove_file(&path);
578 }
579
580 #[test]
581 fn trailer_round_trips_and_fingerprint_is_stable() {
582 let path = tmpfile("trailer.jsonl");
583 let _ = std::fs::remove_file(&path);
584 let session = crate::domain::State::new(
585 Config::default(),
586 PathBuf::from("/tmp/project"),
587 "ollama/test".to_string(),
588 fixed_ts(),
589 )
590 .session;
591 {
592 let mut r = Recorder::open(&path).expect("open");
593 r.record_header(&test_header(fixed_ts())).expect("header");
594 r.record_trailer(fixed_ts(), &session).expect("trailer");
595 }
596 let (_, mut replay) = Replay::open(&path).expect("replay");
597 let line = replay.next().expect("line").expect("io ok");
598 let RecordLine::Trailer(trailer) = line else {
599 panic!("expected trailer, got {line:?}");
600 };
601 assert_eq!(
604 trailer.final_session_fingerprint,
605 session_fingerprint(&session)
606 );
607 assert!(trailer.final_session_fingerprint.starts_with("sha256:"));
608 let _ = std::fs::remove_file(&path);
609 }
610
611 #[test]
612 fn replay_classifies_malformed_line() {
613 let path = tmpfile("bad.jsonl");
614 let header = serde_json::to_string(&test_header(fixed_ts())).unwrap();
615 std::fs::write(&path, format!("{header}\nnot-json\n")).expect("write");
616 let (_, mut replay) = Replay::open(&path).expect("open");
617 let line = replay.next().expect("line").expect("io ok");
618 assert!(matches!(line, RecordLine::Malformed { .. }));
619 let _ = std::fs::remove_file(&path);
620 }
621
622 #[test]
623 fn every_msg_kind_has_a_round_trip_sample() {
624 use crate::domain::{
629 ApprovalKind, ContextUsageSnapshot, Key, KeyCode, KeyMods, PromptTokenBreakdown,
630 RuntimeSignal, SlashCmd, StatusKind, ToolCallId, ToolOutcome,
631 };
632 use crate::models::ReasoningChunk;
633
634 fn covered(kind: MsgKind) -> bool {
635 match kind {
636 MsgKind::Key
637 | MsgKind::Paste
638 | MsgKind::SubmitPrompt
639 | MsgKind::Slash
640 | MsgKind::CancelTurn
641 | MsgKind::Confirm
642 | MsgKind::Quit
643 | MsgKind::RuntimeSignal
644 | MsgKind::StreamText
645 | MsgKind::StreamReasoning
646 | MsgKind::StreamToolCall
647 | MsgKind::ContextUsageEstimated
648 | MsgKind::ProviderContextResolved
649 | MsgKind::OllamaPlacementResolved
650 | MsgKind::BuiltinToolSchemaTokens
651 | MsgKind::CompactionFinished
652 | MsgKind::CompactionFailed
653 | MsgKind::StreamDone
654 | MsgKind::UpstreamError
655 | MsgKind::ToolStarted
656 | MsgKind::ToolProgress
657 | MsgKind::ToolFinished
658 | MsgKind::ApprovalRequested
659 | MsgKind::TurnCancelled
660 | MsgKind::Mcp
661 | MsgKind::InstructionsChanged
662 | MsgKind::MemoryChanged
663 | MsgKind::SessionSaved
664 | MsgKind::ConversationLoaded
665 | MsgKind::ConversationsListed
666 | MsgKind::RuntimeStore
667 | MsgKind::ModelPullFinished
668 | MsgKind::ModelPullProgress
669 | MsgKind::Tick
670 | MsgKind::Resize
671 | MsgKind::MouseScroll
672 | MsgKind::OpenImageAt
673 | MsgKind::TransientStatus
674 | MsgKind::CopySelection => true,
675 }
676 }
677
678 let samples: Vec<Msg> = vec![
679 Msg::Key(Key {
680 code: KeyCode::Char('x'),
681 modifiers: KeyMods::ctrl(),
682 }),
683 Msg::Key(Key {
684 code: KeyCode::PageUp,
685 modifiers: KeyMods::NONE,
686 }),
687 Msg::Paste(Paste::Text("pasted".to_string())),
688 Msg::Paste(Paste::Image {
689 bytes: vec![9, 8, 7],
690 format: "png".to_string(),
691 }),
692 Msg::SubmitPrompt {
693 text: "prompt".to_string(),
694 attachment_ids: vec![1],
695 },
696 Msg::Slash(SlashCmd::Model(Some("anthropic/opus".to_string()))),
697 Msg::Slash(SlashCmd::Compact(None)),
698 Msg::CancelTurn,
699 Msg::ConfirmAccepted,
700 Msg::ConfirmDeclined,
701 Msg::Quit,
702 Msg::RuntimeSignal(RuntimeSignal::Terminate),
703 Msg::StreamText {
704 turn: TurnId(1),
705 chunk: "chunk".to_string(),
706 },
707 Msg::StreamReasoning {
708 turn: TurnId(1),
709 chunk: ReasoningChunk {
710 text: "thinking".to_string(),
711 signature: Some("sig".to_string()),
712 },
713 },
714 Msg::StreamToolCall {
715 turn: TurnId(1),
716 call: crate::models::tool_call::ToolCall {
717 id: Some("call_1".to_string()),
718 function: crate::models::tool_call::FunctionCall {
719 name: "read_file".to_string(),
720 arguments: serde_json::json!({"path": "src/main.rs"}),
721 },
722 },
723 },
724 Msg::ContextUsageEstimated {
725 turn: TurnId(1),
726 snapshot: ContextUsageSnapshot::from_estimate(
727 PromptTokenBreakdown {
728 system_tokens: 10,
729 instructions_tokens: 5,
730 message_tokens: 20,
731 tool_schema_tokens: 30,
732 image_count: 0,
733 message_count: 2,
734 tool_count: 3,
735 },
736 Some(128_000),
737 ),
738 },
739 Msg::ProviderContextResolved {
740 model_id: "m".to_string(),
741 model_max: Some(131_072),
742 effective: Some(32_768),
743 source: None,
744 },
745 Msg::OllamaPlacementResolved {
746 model_id: "m".to_string(),
747 size_vram_bytes: 1,
748 total_bytes: 2,
749 suggested_num_ctx: Some(8192),
750 },
751 Msg::BuiltinToolSchemaTokens(1234),
752 Msg::CompactionFailed {
753 turn: TurnId(2),
754 trigger: crate::domain::CompactionTrigger::Manual,
755 message: "nothing to do".to_string(),
756 kind: StatusKind::Info,
757 },
758 Msg::CompactionFinished {
759 turn: TurnId(2),
760 result: crate::domain::CompactionResult {
761 record: crate::domain::CompactionRecord {
762 id: "c1".to_string(),
763 trigger: crate::domain::CompactionTrigger::Manual,
764 created_at: fixed_ts(),
765 before_tokens: 1000,
766 after_tokens: 100,
767 archived_message_count: 8,
768 preserved_message_count: 2,
769 summary_tokens: 90,
770 duration_secs: 1.5,
771 verified: true,
772 verification_error: None,
773 focus: None,
774 archive_path: None,
775 },
776 replacement_messages: vec![crate::models::ChatMessage::system("checkpoint")],
777 archived_messages: vec![crate::models::ChatMessage::user("old")],
778 before_snapshot: ContextUsageSnapshot::from_estimate(
779 PromptTokenBreakdown::default(),
780 Some(128_000),
781 ),
782 after_snapshot: ContextUsageSnapshot::from_estimate(
783 PromptTokenBreakdown::default(),
784 Some(128_000),
785 ),
786 usage: None,
787 },
788 },
789 Msg::UpstreamError {
790 turn: TurnId(1),
791 error: crate::models::UserFacingError {
792 summary: "Rate limited".to_string(),
793 message: "429 too many requests".to_string(),
794 suggestion: "retry in a moment".to_string(),
795 category: crate::models::ErrorCategory::Temporary,
796 recoverable: true,
797 },
798 },
799 Msg::StreamDone {
800 turn: TurnId(1),
801 usage: Some(crate::models::TokenUsage::provider(10, 5, 15)),
802 thinking_signature: None,
803 stop_reason: Some(crate::models::FinishReason::Stop),
804 },
805 Msg::TurnCancelled(TurnId(3)),
806 Msg::ToolStarted {
807 turn: TurnId(1),
808 call_id: ToolCallId(1),
809 },
810 Msg::ToolProgress {
811 turn: TurnId(1),
812 call_id: ToolCallId(1),
813 event: crate::providers::ProgressEvent::Artifact {
814 mime: "image/png".to_string(),
815 data: vec![1, 2, 3],
816 caption: Some("shot".to_string()),
817 },
818 },
819 Msg::ToolFinished {
820 turn: TurnId(1),
821 call_id: ToolCallId(1),
822 outcome: ToolOutcome::success("out", "read 3 lines", 0.5),
823 },
824 Msg::ApprovalRequested {
825 turn: TurnId(1),
826 call_id: ToolCallId(2),
827 tool: "execute_command".to_string(),
828 risk: "destructive".to_string(),
829 kind: ApprovalKind::Shell,
830 prompt: "rm -rf build".to_string(),
831 allowlist_scope: "exact".to_string(),
832 },
833 Msg::McpServerReady {
834 name: "srv".to_string(),
835 tools: vec![crate::domain::McpToolSpec {
836 name: "t".to_string(),
837 description: "d".to_string(),
838 input_schema: serde_json::json!({"type": "object"}),
839 }],
840 },
841 Msg::McpServerErrored {
842 name: "srv".to_string(),
843 reason: "exit 1".to_string(),
844 },
845 Msg::McpServerStopped {
846 name: "srv".to_string(),
847 },
848 Msg::InstructionsChanged(None),
849 Msg::MemoryChanged(None),
850 Msg::SessionSaved,
851 Msg::ConversationLoaded(ConversationHistory::new(
852 "/p".to_string(),
853 "m".to_string(),
854 fixed_ts(),
855 )),
856 Msg::ConversationsListed(vec![crate::domain::ConversationSummary {
857 id: "20260702_120000_123".to_string(),
858 title: "t".to_string(),
859 message_count: 1,
860 updated_at: "2026-07-02".to_string(),
861 }]),
862 Msg::RuntimeText("daemon says hi".to_string()),
863 Msg::RuntimeTasksListed(Vec::new()),
864 Msg::RuntimeTaskLoaded {
865 task: None,
866 events: Vec::new(),
867 },
868 Msg::RuntimeProcessesListed(Vec::new()),
869 Msg::RuntimeApprovalsListed(Vec::new()),
870 Msg::RuntimeCheckpointsListed(Vec::new()),
871 Msg::RuntimePluginsListed(Vec::new()),
872 Msg::ModelPullFinished {
873 model: "qwen3".to_string(),
874 },
875 Msg::ModelPullProgress("pulling 42%".to_string()),
876 Msg::Tick,
877 Msg::Resize {
878 width: 120,
879 height: 40,
880 },
881 Msg::TransientStatus {
882 text: "saved".to_string(),
883 },
884 Msg::MouseScroll { delta: -3 },
885 Msg::OpenImageAt {
886 message_index: 4,
887 image_index: 0,
888 },
889 Msg::CopySelection("copied".to_string()),
890 ];
891
892 let seen: Vec<MsgKind> = samples.iter().map(|m| m.kind()).collect();
896 let missing: Vec<String> = [
897 MsgKind::Key,
898 MsgKind::Paste,
899 MsgKind::SubmitPrompt,
900 MsgKind::Slash,
901 MsgKind::CancelTurn,
902 MsgKind::Confirm,
903 MsgKind::Quit,
904 MsgKind::RuntimeSignal,
905 MsgKind::StreamText,
906 MsgKind::StreamReasoning,
907 MsgKind::StreamToolCall,
908 MsgKind::ContextUsageEstimated,
909 MsgKind::ProviderContextResolved,
910 MsgKind::OllamaPlacementResolved,
911 MsgKind::BuiltinToolSchemaTokens,
912 MsgKind::CompactionFinished,
913 MsgKind::CompactionFailed,
914 MsgKind::StreamDone,
915 MsgKind::UpstreamError,
916 MsgKind::ToolStarted,
917 MsgKind::ToolProgress,
918 MsgKind::ToolFinished,
919 MsgKind::ApprovalRequested,
920 MsgKind::TurnCancelled,
921 MsgKind::Mcp,
922 MsgKind::InstructionsChanged,
923 MsgKind::MemoryChanged,
924 MsgKind::SessionSaved,
925 MsgKind::ConversationLoaded,
926 MsgKind::ConversationsListed,
927 MsgKind::RuntimeStore,
928 MsgKind::ModelPullFinished,
929 MsgKind::ModelPullProgress,
930 MsgKind::Tick,
931 MsgKind::Resize,
932 MsgKind::MouseScroll,
933 MsgKind::OpenImageAt,
934 MsgKind::TransientStatus,
935 MsgKind::CopySelection,
936 ]
937 .iter()
938 .filter(|k| covered(**k) && !seen.contains(k))
939 .map(|k| format!("{k:?}"))
940 .collect();
941 assert!(
942 missing.is_empty(),
943 "MsgKinds without a round-trip sample: {missing:?}"
944 );
945
946 for msg in &samples {
948 let value = serde_json::to_value(msg).expect("serialize");
949 let back: Msg = serde_json::from_value(value.clone())
950 .unwrap_or_else(|e| panic!("deserialize {value}: {e}"));
951 assert_eq!(
952 format!("{msg:?}"),
953 format!("{back:?}"),
954 "round trip changed the msg"
955 );
956 }
957 }
958}