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