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::{ClipboardRead, 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::ClipboardRead(ClipboardRead::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::ClipboardRead(ClipboardRead::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::ClipboardRead
639 | MsgKind::SubmitPrompt
640 | MsgKind::Slash
641 | MsgKind::CancelTurn
642 | MsgKind::Confirm
643 | MsgKind::Quit
644 | MsgKind::RuntimeSignal
645 | MsgKind::StreamText
646 | MsgKind::StreamReasoning
647 | MsgKind::StreamToolCall
648 | MsgKind::ContextUsageEstimated
649 | MsgKind::ProviderContextResolved
650 | MsgKind::OllamaPlacementResolved
651 | MsgKind::ProviderVisionResolved
652 | MsgKind::BuiltinToolSchemaTokens
653 | MsgKind::CompactionFinished
654 | MsgKind::CompactionFailed
655 | MsgKind::StreamDone
656 | MsgKind::UpstreamError
657 | MsgKind::ToolStarted
658 | MsgKind::ToolProgress
659 | MsgKind::ToolFinished
660 | MsgKind::ApprovalRequested
661 | MsgKind::QuestionAsked
662 | MsgKind::TasksUpdated
663 | MsgKind::TaskNotice
664 | MsgKind::TurnCancelled
665 | MsgKind::Mcp
666 | MsgKind::HookContext
667 | MsgKind::InstructionsChanged
668 | MsgKind::MemoryChanged
669 | MsgKind::SessionSaved
670 | MsgKind::ConversationLoaded
671 | MsgKind::ConversationsListed
672 | MsgKind::ProjectFilesListed
673 | MsgKind::ScratchpadReady
674 | MsgKind::RuntimeStore
675 | MsgKind::ModelPullFinished
676 | MsgKind::ModelPullProgress
677 | MsgKind::Tick
678 | MsgKind::Resize
679 | MsgKind::MouseScroll
680 | MsgKind::FocusChanged
681 | MsgKind::OpenImageAt
682 | MsgKind::TransientStatus
683 | MsgKind::EditorReturned
684 | MsgKind::BackgroundAgent
685 | MsgKind::CopySelection => true,
686 }
687 }
688
689 let samples: Vec<Msg> = vec![
690 Msg::TasksUpdated {
691 store: {
692 let mut store = crate::domain::TaskStore::default();
693 store.create(
694 vec![crate::domain::TaskSpec {
695 subject: "sample".to_string(),
696 active_form: "sampling".to_string(),
697 description: None,
698 in_progress: true,
699 }],
700 crate::domain::TaskOrigin::Model,
701 crate::domain::Stamp {
702 now_epoch: 10,
703 run_tokens: 20,
704 },
705 );
706 store
707 },
708 },
709 Msg::TaskNotice {
710 text: "The user edited the task checklist: Added task #1 'x'.".to_string(),
711 },
712 Msg::Key(Key {
713 code: KeyCode::Char('x'),
714 modifiers: KeyMods::ctrl(),
715 }),
716 Msg::Key(Key {
717 code: KeyCode::PageUp,
718 modifiers: KeyMods::NONE,
719 }),
720 Msg::Paste(Paste::Text("pasted".to_string())),
721 Msg::ClipboardRead(ClipboardRead::Image {
722 bytes: vec![9, 8, 7],
723 format: "png".to_string(),
724 }),
725 Msg::SubmitPrompt {
726 text: "prompt".to_string(),
727 attachment_ids: vec![1],
728 },
729 Msg::Slash(SlashCmd::Model(Some("anthropic/opus".to_string()))),
730 Msg::HookContext {
731 turn: TurnId(2),
732 texts: vec!["hook says hi".to_string()],
733 },
734 Msg::Slash(SlashCmd::Compact(None)),
735 Msg::CancelTurn,
736 Msg::BackgroundAgentStarted {
737 agent_id: "a7".to_string(),
738 description: "audit docs".to_string(),
739 },
740 Msg::BackgroundAgentProgress {
741 agent_id: "a7".to_string(),
742 activity: "read_file…".to_string(),
743 tokens: 1200,
744 },
745 Msg::BackgroundAgentFinished {
746 agent_id: "a7".to_string(),
747 description: "audit docs".to_string(),
748 report: "all good".to_string(),
749 success: true,
750 cancelled: false,
751 usage: Some(crate::models::TokenUsage::provider(60_000, 30_000)),
752 tokens: 90_000,
753 duration_secs: 132,
754 },
755 Msg::ConfirmAccepted,
756 Msg::ConfirmDeclined,
757 Msg::Quit,
758 Msg::RuntimeSignal(RuntimeSignal::Terminate),
759 Msg::StreamText {
760 turn: TurnId(1),
761 chunk: "chunk".to_string(),
762 },
763 Msg::StreamReasoning {
764 turn: TurnId(1),
765 chunk: ReasoningChunk {
766 text: "thinking".to_string(),
767 signature: Some("sig".to_string()),
768 },
769 },
770 Msg::StreamToolCall {
771 turn: TurnId(1),
772 call: crate::models::tool_call::ToolCall {
773 id: Some("call_1".to_string()),
774 function: crate::models::tool_call::FunctionCall {
775 name: "read_file".to_string(),
776 arguments: serde_json::json!({"path": "src/main.rs"}),
777 },
778 },
779 },
780 Msg::ContextUsageEstimated {
781 turn: TurnId(1),
782 snapshot: ContextUsageSnapshot::from_estimate(
783 PromptTokenBreakdown {
784 system_tokens: 10,
785 instructions_tokens: 5,
786 message_tokens: 20,
787 tool_schema_tokens: 30,
788 image_count: 0,
789 message_count: 2,
790 tool_count: 3,
791 },
792 Some(128_000),
793 ),
794 },
795 Msg::ProviderContextResolved {
796 model_id: "m".to_string(),
797 model_max: Some(131_072),
798 effective: Some(32_768),
799 source: None,
800 max_output: Some(64_000),
801 },
802 Msg::OllamaPlacementResolved {
803 model_id: "m".to_string(),
804 size_vram_bytes: 1,
805 total_bytes: 2,
806 suggested_num_ctx: Some(8192),
807 },
808 Msg::ProviderVisionResolved {
809 model_id: "m".to_string(),
810 supports_vision: Some(false),
811 warn: true,
812 },
813 Msg::BuiltinToolSchemaTokens(1234),
814 Msg::CompactionFailed {
815 turn: TurnId(2),
816 trigger: crate::domain::CompactionTrigger::Manual,
817 message: "nothing to do".to_string(),
818 kind: StatusKind::Info,
819 },
820 Msg::CompactionFinished {
821 turn: TurnId(2),
822 result: crate::domain::CompactionResult {
823 record: crate::domain::CompactionRecord {
824 id: "c1".to_string(),
825 trigger: crate::domain::CompactionTrigger::Manual,
826 created_at: fixed_ts(),
827 before_tokens: 1000,
828 after_tokens: 100,
829 archived_message_count: 8,
830 preserved_message_count: 2,
831 preserved_turn_count: 1,
832 summary_tokens: 90,
833 duration_secs: 1.5,
834 review_status: crate::domain::CompactionReviewStatus::Reviewed,
835 review_error: None,
836 focus: None,
837 archive_path: None,
838 },
839 replacement_messages: vec![crate::models::ChatMessage::system("checkpoint")],
840 archived_messages: vec![crate::models::ChatMessage::user("old")],
841 before_snapshot: ContextUsageSnapshot::from_estimate(
842 PromptTokenBreakdown::default(),
843 Some(128_000),
844 ),
845 after_snapshot: ContextUsageSnapshot::from_estimate(
846 PromptTokenBreakdown::default(),
847 Some(128_000),
848 ),
849 usage: None,
850 source_boundaries: Vec::new(),
851 },
852 },
853 Msg::UpstreamError {
854 turn: TurnId(1),
855 error: crate::models::UserFacingError {
856 summary: "Rate limited".to_string(),
857 message: "429 too many requests".to_string(),
858 suggestion: "retry in a moment".to_string(),
859 category: crate::models::ErrorCategory::Temporary,
860 recoverable: true,
861 },
862 },
863 Msg::StreamDone {
864 turn: TurnId(1),
865 usage: Some(crate::models::TokenUsage::provider(10, 5)),
866 provider_continuation: None,
867 stop_reason: Some(crate::models::FinishReason::Stop),
868 },
869 Msg::TurnCancelled(TurnId(3)),
870 Msg::ToolStarted {
871 turn: TurnId(1),
872 call_id: ToolCallId(1),
873 },
874 Msg::ToolProgress {
875 turn: TurnId(1),
876 call_id: ToolCallId(1),
877 event: crate::providers::ProgressEvent::Artifact {
878 mime: "image/png".to_string(),
879 data: vec![1, 2, 3],
880 caption: Some("shot".to_string()),
881 },
882 },
883 Msg::ToolFinished {
884 turn: TurnId(1),
885 call_id: ToolCallId(1),
886 outcome: ToolOutcome::success("out", "read 3 lines", 0.5),
887 },
888 Msg::ApprovalRequested {
889 turn: TurnId(1),
890 call_id: ToolCallId(2),
891 tool: "execute_command".to_string(),
892 risk: "destructive".to_string(),
893 kind: ApprovalKind::Shell,
894 prompt: "rm -rf build".to_string(),
895 allowlist_scope: "exact".to_string(),
896 },
897 Msg::McpServerReady {
898 name: "srv".to_string(),
899 tools: vec![crate::domain::McpToolSpec {
900 name: "mcp__srv__t".to_string(),
901 raw_name: "t".to_string(),
902 description: "d".to_string(),
903 input_schema: serde_json::json!({"type": "object"}),
904 read_only_hint: false,
905 }],
906 },
907 Msg::McpServerErrored {
908 name: "srv".to_string(),
909 reason: "exit 1".to_string(),
910 },
911 Msg::McpServerStopped {
912 name: "srv".to_string(),
913 },
914 Msg::InstructionsChanged(None),
915 Msg::MemoryChanged(None),
916 Msg::SessionSaved,
917 Msg::ConversationLoaded(ConversationHistory::new(
918 "/p".to_string(),
919 "m".to_string(),
920 fixed_ts(),
921 )),
922 Msg::ConversationsListed(vec![crate::domain::ConversationSummary {
923 id: "20260702_120000_123".to_string(),
924 title: "t".to_string(),
925 message_count: 1,
926 updated_at: "2026-07-02".to_string(),
927 }]),
928 Msg::ProjectFilesListed(vec!["src/main.rs".to_string(), "docs/".to_string()]),
929 Msg::ScratchpadReady {
930 session_id: "20260702_120000_123".to_string(),
931 path: std::path::PathBuf::from("/data/tmp/scratchpad/-proj/20260702_120000_123"),
932 },
933 Msg::RuntimeText("daemon says hi".to_string()),
934 Msg::RuntimeTasksListed(Vec::new()),
935 Msg::RuntimeTaskLoaded {
936 task: None,
937 events: Vec::new(),
938 },
939 Msg::RuntimeProcessesListed(Vec::new()),
940 Msg::RuntimeApprovalsListed(Vec::new()),
941 Msg::RuntimeCheckpointsListed(Vec::new()),
942 Msg::ForkCheckpointsFound(Vec::new()),
943 Msg::RuntimePluginsListed(Vec::new()),
944 Msg::ModelPullFinished {
945 model: "qwen3".to_string(),
946 },
947 Msg::ModelPullProgress("pulling 42%".to_string()),
948 Msg::Tick,
949 Msg::Resize {
950 width: 120,
951 height: 40,
952 },
953 Msg::TransientStatus {
954 text: "saved".to_string(),
955 },
956 Msg::MouseScroll { delta: -3 },
957 Msg::FocusChanged(false),
958 Msg::OpenImageAt {
959 message_index: 4,
960 image_index: 0,
961 image_number: None,
962 },
963 Msg::EditorReturned {
964 text: Some("edited draft".to_string()),
965 },
966 Msg::CopySelection("copied".to_string()),
967 ];
968
969 let seen: Vec<MsgKind> = samples.iter().map(|m| m.kind()).collect();
973 let missing: Vec<String> = [
974 MsgKind::Key,
975 MsgKind::Paste,
976 MsgKind::ClipboardRead,
977 MsgKind::SubmitPrompt,
978 MsgKind::Slash,
979 MsgKind::CancelTurn,
980 MsgKind::Confirm,
981 MsgKind::Quit,
982 MsgKind::RuntimeSignal,
983 MsgKind::StreamText,
984 MsgKind::StreamReasoning,
985 MsgKind::StreamToolCall,
986 MsgKind::ContextUsageEstimated,
987 MsgKind::ProviderContextResolved,
988 MsgKind::OllamaPlacementResolved,
989 MsgKind::ProviderVisionResolved,
990 MsgKind::BuiltinToolSchemaTokens,
991 MsgKind::CompactionFinished,
992 MsgKind::CompactionFailed,
993 MsgKind::StreamDone,
994 MsgKind::UpstreamError,
995 MsgKind::ToolStarted,
996 MsgKind::ToolProgress,
997 MsgKind::ToolFinished,
998 MsgKind::ApprovalRequested,
999 MsgKind::TurnCancelled,
1000 MsgKind::Mcp,
1001 MsgKind::HookContext,
1002 MsgKind::InstructionsChanged,
1003 MsgKind::MemoryChanged,
1004 MsgKind::SessionSaved,
1005 MsgKind::ConversationLoaded,
1006 MsgKind::ConversationsListed,
1007 MsgKind::ProjectFilesListed,
1008 MsgKind::RuntimeStore,
1009 MsgKind::ModelPullFinished,
1010 MsgKind::ModelPullProgress,
1011 MsgKind::Tick,
1012 MsgKind::Resize,
1013 MsgKind::MouseScroll,
1014 MsgKind::FocusChanged,
1015 MsgKind::OpenImageAt,
1016 MsgKind::TransientStatus,
1017 MsgKind::CopySelection,
1018 ]
1019 .iter()
1020 .filter(|k| covered(**k) && !seen.contains(k))
1021 .map(|k| format!("{k:?}"))
1022 .collect();
1023 assert!(
1024 missing.is_empty(),
1025 "MsgKinds without a round-trip sample: {missing:?}"
1026 );
1027
1028 for msg in &samples {
1030 let value = serde_json::to_value(msg).expect("serialize");
1031 let back: Msg = serde_json::from_value(value.clone())
1032 .unwrap_or_else(|e| panic!("deserialize {value}: {e}"));
1033 assert_eq!(
1034 format!("{msg:?}"),
1035 format!("{back:?}"),
1036 "round trip changed the msg"
1037 );
1038 }
1039 }
1040}