Skip to main content

mermaid_cli/app/
recorder.rs

1//! `--record` / `--replay` support.
2//!
3//! The Elm/MVU architecture makes deterministic replay nearly free:
4//! if you capture every `Msg` the reducer sees, you can reconstruct
5//! the exact final `State` by folding over that log. This module
6//! implements both sides.
7//!
8//! Wire format: one JSON object per line (JSONL). Each object is
9//! `{ts, kind, body}`:
10//!   - `ts`: RFC3339 timestamp (for debugging, not replay).
11//!   - `kind`: `MsgKind` variant tag (matches `Msg::kind().into()`).
12//!   - `body`: best-effort structured payload from `record_msg_body`.
13//!
14//! Not every `Msg` field is safely serializable today — raw image
15//! bytes in `Paste::Image`, for example. Unsupported payloads are
16//! marked with `"recordable": false` and compact metadata. Replay is
17//! a best-effort reconstruction.
18//!
19//! For C6 this ships the on-disk shape + a `Recorder` type that
20//! writes; replay reading is available but opt-in (serialize
21//! support is wired on a subset of Msg variants that don't carry
22//! binary payloads). C9 rounds out coverage with the parity
23//! harness.
24
25use std::fs::{File, OpenOptions};
26use std::io::{BufRead, BufReader, BufWriter, Write};
27use std::path::{Path, PathBuf};
28
29use anyhow::{Context, Result};
30use chrono::Local;
31
32use crate::domain::{KeyCode, Msg, MsgKind, Paste, SlashCmd, ToolOutcome, TurnId};
33use crate::providers::{ProgressEvent, SubagentPhase};
34
35/// Append-only recorder. Writes one JSONL line per `Msg` the main
36/// loop chooses to log.
37pub struct Recorder {
38    writer: BufWriter<File>,
39    path: PathBuf,
40}
41
42impl Recorder {
43    /// Open `path` for append. Creates the file if it doesn't exist.
44    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
45        let path = path.into();
46        let file = OpenOptions::new()
47            .create(true)
48            .append(true)
49            .open(&path)
50            .with_context(|| format!("open {} for recording", path.display()))?;
51        Ok(Self {
52            writer: BufWriter::new(file),
53            path,
54        })
55    }
56
57    pub fn path(&self) -> &Path {
58        &self.path
59    }
60
61    /// Record a single `MsgKind` + optional body JSON. Meant for the
62    /// narrow subset of variants that survive round-trip. Full
63    /// Msg-graph coverage comes in C9.
64    pub fn record_kind(
65        &mut self,
66        kind: MsgKind,
67        turn: Option<TurnId>,
68        mut body: serde_json::Value,
69    ) -> Result<()> {
70        // Single redaction choke point: scrub credential-shaped strings out of
71        // every recorded payload before it hits disk. A `read_file .env` result,
72        // a pasted token, or an API error echoing a key would otherwise be
73        // persisted in cleartext in the `--record` log (#17).
74        crate::utils::redact_json(&mut body);
75        let entry = serde_json::json!({
76            "ts": Local::now().to_rfc3339(),
77            "kind": format!("{:?}", kind),
78            "turn": turn.map(|t| t.0),
79            "body": body,
80        });
81        writeln!(self.writer, "{}", entry).context("write jsonl line")?;
82        Ok(())
83    }
84
85    pub fn flush(&mut self) -> Result<()> {
86        self.writer.flush().context("flush recorder")
87    }
88}
89
90impl Drop for Recorder {
91    fn drop(&mut self) {
92        let _ = self.writer.flush();
93    }
94}
95
96/// Compact, best-effort JSON body for a reducer `Msg`.
97///
98/// This deliberately records useful payloads without claiming the full
99/// `Msg` graph can round-trip through serde today. Runtime-only or
100/// binary-heavy variants are marked explicitly so replay tooling can
101/// decide how to handle them.
102pub fn record_msg_body(msg: &Msg) -> serde_json::Value {
103    match msg {
104        Msg::Key(key) => serde_json::json!({
105            "code": key_code_body(key.code),
106            "modifiers": {
107                "ctrl": key.modifiers.ctrl,
108                "alt": key.modifiers.alt,
109                "shift": key.modifiers.shift,
110            },
111        }),
112        Msg::Paste(Paste::Text(text)) => serde_json::json!({
113            "type": "text",
114            "text": text,
115        }),
116        Msg::Paste(Paste::Image { bytes, format }) => serde_json::json!({
117            "recordable": false,
118            "reason": "binary paste image omitted",
119            "type": "image",
120            "format": format,
121            "size_bytes": bytes.len(),
122        }),
123        Msg::SubmitPrompt {
124            text,
125            attachment_ids,
126        } => serde_json::json!({
127            "text": text,
128            "attachment_ids": attachment_ids,
129        }),
130        Msg::Slash(cmd) => slash_body(cmd),
131        Msg::CancelTurn => serde_json::json!({}),
132        Msg::ConfirmAccepted => serde_json::json!({"accepted": true}),
133        Msg::ConfirmDeclined => serde_json::json!({"accepted": false}),
134        Msg::Quit => serde_json::json!({}),
135        Msg::RuntimeSignal(signal) => serde_json::json!({
136            "signal": signal.as_str(),
137        }),
138        Msg::StreamText { chunk, .. } => serde_json::json!({"chunk": chunk}),
139        Msg::StreamReasoning { chunk, .. } => serde_json::json!({
140            "text": chunk.text,
141            "signature": chunk.signature,
142        }),
143        Msg::StreamToolCall { call, .. } => serde_json::to_value(call)
144            .unwrap_or_else(|_| unsupported("tool call was not serializable")),
145        Msg::BuiltinToolSchemaTokens(tokens) => serde_json::json!({"tokens": tokens}),
146        Msg::ContextUsageEstimated { snapshot, .. } => serde_json::json!({
147            "used_tokens": snapshot.used_tokens,
148            "max_tokens": snapshot.max_tokens,
149            "remaining_tokens": snapshot.remaining_tokens,
150            "used_percent": snapshot.used_percent,
151            "source": format!("{:?}", snapshot.source),
152            "breakdown": snapshot.breakdown.as_ref().map(|b| serde_json::json!({
153                "system_tokens": b.system_tokens,
154                "instructions_tokens": b.instructions_tokens,
155                "message_tokens": b.message_tokens,
156                "tool_schema_tokens": b.tool_schema_tokens,
157                "image_count": b.image_count,
158                "message_count": b.message_count,
159                "tool_count": b.tool_count,
160            })),
161        }),
162        Msg::CompactionFinished { result, .. } => serde_json::json!({
163            "id": result.record.id,
164            "trigger": result.record.trigger.as_str(),
165            "before_tokens": result.record.before_tokens,
166            "after_tokens": result.record.after_tokens,
167            "archived_message_count": result.record.archived_message_count,
168            "preserved_message_count": result.record.preserved_message_count,
169            "duration_secs": result.record.duration_secs,
170        }),
171        Msg::CompactionFailed {
172            trigger,
173            message,
174            kind,
175            ..
176        } => serde_json::json!({
177            "trigger": trigger.as_str(),
178            "message": message,
179            "kind": format!("{:?}", kind),
180        }),
181        Msg::StreamDone {
182            usage,
183            thinking_signature,
184            ..
185        } => serde_json::json!({
186            "usage": usage.as_ref().map(|u| serde_json::json!({
187                "prompt_tokens": u.prompt_tokens,
188                "completion_tokens": u.completion_tokens,
189                "total_tokens": u.total_tokens,
190                "cached_input_tokens": u.cached_input_tokens,
191                "cache_creation_input_tokens": u.cache_creation_input_tokens,
192                "reasoning_output_tokens": u.reasoning_output_tokens,
193                "source": format!("{:?}", u.source),
194            })),
195            "thinking_signature": thinking_signature,
196        }),
197        Msg::UpstreamError { error, .. } => serde_json::to_value(error)
198            .unwrap_or_else(|_| unsupported("upstream error was not serializable")),
199        Msg::TurnCancelled(_) => serde_json::json!({}),
200        Msg::ToolStarted { call_id, .. } => serde_json::json!({"call_id": call_id.0}),
201        Msg::ToolProgress { call_id, event, .. } => serde_json::json!({
202            "call_id": call_id.0,
203            "event": progress_body(event),
204        }),
205        Msg::ToolFinished {
206            call_id, outcome, ..
207        } => serde_json::json!({
208            "call_id": call_id.0,
209            "outcome": outcome_body(outcome),
210        }),
211        Msg::ApprovalRequested {
212            call_id,
213            tool,
214            risk,
215            ..
216        } => serde_json::json!({
217            "call_id": call_id.0,
218            "tool": tool,
219            "risk": risk,
220        }),
221        Msg::McpServerReady { name, tools } => serde_json::json!({
222            "name": name,
223            "tools": tools.iter().map(|tool| serde_json::json!({
224                "name": tool.name,
225                "description": tool.description,
226                "input_schema": tool.input_schema,
227            })).collect::<Vec<_>>(),
228        }),
229        Msg::McpServerErrored { name, reason } => serde_json::json!({
230            "name": name,
231            "reason": reason,
232        }),
233        Msg::McpServerStopped { name } => serde_json::json!({"name": name}),
234        Msg::InstructionsChanged(loaded) => match loaded {
235            Some(loaded) => serde_json::json!({
236                "path": loaded.path,
237                "byte_len": loaded.byte_len,
238                "truncated": loaded.truncated,
239            }),
240            None => serde_json::json!({"path": null}),
241        },
242        Msg::MemoryChanged(loaded) => match loaded {
243            Some(loaded) => serde_json::json!({
244                "entries": loaded.entries.len(),
245                "truncated": loaded.truncated,
246            }),
247            None => serde_json::json!({"entries": 0}),
248        },
249        Msg::SessionSaved => serde_json::json!({}),
250        Msg::ConversationLoaded(history) => serde_json::json!({
251            "id": history.id,
252            "message_count": history.messages.len(),
253            "title": history.title,
254        }),
255        Msg::ConversationsListed(summaries) => serde_json::json!({
256            "count": summaries.len(),
257            "ids": summaries.iter().map(|summary| summary.id.as_str()).collect::<Vec<_>>(),
258        }),
259        Msg::RuntimeTasksListed(tasks) => serde_json::json!({
260            "count": tasks.len(),
261            "ids": tasks.iter().map(|task| task.id.as_str()).collect::<Vec<_>>(),
262        }),
263        Msg::RuntimeTaskLoaded { task, events } => serde_json::json!({
264            "id": task.as_ref().map(|task| task.id.as_str()),
265            "found": task.is_some(),
266            "event_count": events.len(),
267        }),
268        Msg::RuntimeProcessesListed(processes) => serde_json::json!({
269            "count": processes.len(),
270            "ids": processes.iter().map(|process| process.id.as_str()).collect::<Vec<_>>(),
271        }),
272        Msg::RuntimeText(text) => serde_json::json!({"chars": text.len()}),
273        Msg::RuntimeApprovalsListed(approvals) => serde_json::json!({
274            "count": approvals.len(),
275            "ids": approvals.iter().map(|approval| approval.id.as_str()).collect::<Vec<_>>(),
276        }),
277        Msg::RuntimeCheckpointsListed(checkpoints) => serde_json::json!({
278            "count": checkpoints.len(),
279            "ids": checkpoints.iter().map(|checkpoint| checkpoint.id.as_str()).collect::<Vec<_>>(),
280        }),
281        Msg::RuntimePluginsListed(plugins) => serde_json::json!({
282            "count": plugins.len(),
283            "ids": plugins.iter().map(|plugin| plugin.id.as_str()).collect::<Vec<_>>(),
284        }),
285        Msg::ModelPullFinished { model } => serde_json::json!({"model": model}),
286        Msg::ModelPullProgress(line) => serde_json::json!({"line": line}),
287        Msg::Tick => serde_json::json!({}),
288        Msg::StatusDismiss => serde_json::json!({}),
289        Msg::Resize { width, height } => serde_json::json!({
290            "width": width,
291            "height": height,
292        }),
293        Msg::TransientStatus {
294            text,
295            kind,
296            dismiss_ms,
297        } => serde_json::json!({
298            "text": text,
299            "kind": format!("{:?}", kind),
300            "dismiss_ms": dismiss_ms,
301        }),
302        Msg::MouseScroll { delta } => serde_json::json!({"delta": delta}),
303        Msg::OpenImageAt {
304            message_index,
305            image_index,
306        } => serde_json::json!({
307            "message_index": message_index,
308            "image_index": image_index,
309        }),
310        // Record only the selection length — the copied text doesn't affect
311        // reducer State (it just emits a clipboard Cmd) and may be large/sensitive.
312        Msg::CopySelection(text) => serde_json::json!({ "len": text.chars().count() }),
313    }
314}
315
316fn key_code_body(code: KeyCode) -> serde_json::Value {
317    match code {
318        KeyCode::Char(c) => serde_json::json!({"char": c.to_string()}),
319        KeyCode::F(n) => serde_json::json!({"f": n}),
320        other => serde_json::json!(format!("{:?}", other)),
321    }
322}
323
324fn slash_body(cmd: &SlashCmd) -> serde_json::Value {
325    match cmd {
326        SlashCmd::Model(model) => serde_json::json!({"command": "model", "arg": model}),
327        SlashCmd::Reasoning(level) => serde_json::json!({
328            "command": "reasoning",
329            "arg": level.map(|level| level.as_str()),
330        }),
331        SlashCmd::VisibleReasoning(arg) => {
332            serde_json::json!({"command": "visible-reasoning", "arg": arg})
333        },
334        SlashCmd::Safety(mode) => serde_json::json!({
335            "command": "safety",
336            "arg": mode.map(|m| m.as_str()),
337        }),
338        SlashCmd::Clear => serde_json::json!({"command": "clear"}),
339        SlashCmd::Save(name) => serde_json::json!({"command": "save", "arg": name}),
340        SlashCmd::Load(name) => serde_json::json!({"command": "load", "arg": name}),
341        SlashCmd::List => serde_json::json!({"command": "list"}),
342        SlashCmd::Usage => serde_json::json!({"command": "usage"}),
343        SlashCmd::Context => serde_json::json!({"command": "context"}),
344        SlashCmd::Compact(instructions) => {
345            serde_json::json!({"command": "compact", "arg": instructions})
346        },
347        SlashCmd::Memory => serde_json::json!({"command": "memory"}),
348        SlashCmd::Remember(text) => serde_json::json!({"command": "remember", "arg": text}),
349        SlashCmd::Forget(id) => serde_json::json!({"command": "forget", "arg": id}),
350        SlashCmd::ConsolidateMemory => serde_json::json!({"command": "consolidate-memory"}),
351        SlashCmd::Doctor => serde_json::json!({"command": "doctor"}),
352        SlashCmd::Tasks => serde_json::json!({"command": "tasks"}),
353        SlashCmd::Task(id) => serde_json::json!({"command": "task", "arg": id}),
354        SlashCmd::Pause(id) => serde_json::json!({"command": "pause", "arg": id}),
355        SlashCmd::Resume(id) => serde_json::json!({"command": "resume", "arg": id}),
356        SlashCmd::Cancel(id) => serde_json::json!({"command": "cancel", "arg": id}),
357        SlashCmd::Handoff(id) => serde_json::json!({"command": "handoff", "arg": id}),
358        SlashCmd::Report(id) => serde_json::json!({"command": "report", "arg": id}),
359        SlashCmd::Processes => serde_json::json!({"command": "processes"}),
360        SlashCmd::Logs(id) => serde_json::json!({"command": "logs", "arg": id}),
361        SlashCmd::Stop(id) => serde_json::json!({"command": "stop", "arg": id}),
362        SlashCmd::Restart(id) => serde_json::json!({"command": "restart", "arg": id}),
363        SlashCmd::Open(target) => serde_json::json!({"command": "open", "arg": target}),
364        SlashCmd::Ports => serde_json::json!({"command": "ports"}),
365        SlashCmd::Approvals => serde_json::json!({"command": "approvals"}),
366        SlashCmd::Approve(id) => serde_json::json!({"command": "approve", "arg": id}),
367        SlashCmd::Deny(id) => serde_json::json!({"command": "deny", "arg": id}),
368        SlashCmd::Checkpoint(paths) => serde_json::json!({"command": "checkpoint", "arg": paths}),
369        SlashCmd::Checkpoints => serde_json::json!({"command": "checkpoints"}),
370        SlashCmd::Restore(id) => serde_json::json!({"command": "restore", "arg": id}),
371        SlashCmd::ModelInfo(model) => serde_json::json!({"command": "model-info", "arg": model}),
372        SlashCmd::Plugins => serde_json::json!({"command": "plugins"}),
373        SlashCmd::CloudSetup => serde_json::json!({"command": "cloud-setup"}),
374        SlashCmd::Help => serde_json::json!({"command": "help"}),
375        SlashCmd::Quit => serde_json::json!({"command": "quit"}),
376        SlashCmd::Unknown(name) => serde_json::json!({"command": "unknown", "name": name}),
377    }
378}
379
380fn progress_body(event: &ProgressEvent) -> serde_json::Value {
381    match event {
382        ProgressEvent::Output(text) => serde_json::json!({"type": "output", "text": text}),
383        ProgressEvent::Status(text) => serde_json::json!({"type": "status", "text": text}),
384        ProgressEvent::Bytes { done, total } => serde_json::json!({
385            "type": "bytes",
386            "done": done,
387            "total": total,
388        }),
389        ProgressEvent::Artifact {
390            mime,
391            data,
392            caption,
393        } => serde_json::json!({
394            "recordable": false,
395            "type": "artifact",
396            "mime": mime,
397            "caption": caption,
398            "size_bytes": data.len(),
399        }),
400        ProgressEvent::SubagentToolCall {
401            child_call_id,
402            tool_name,
403            phase,
404        } => serde_json::json!({
405            "type": "subagent_tool_call",
406            "child_call_id": child_call_id.0,
407            "tool_name": tool_name,
408            "phase": subagent_phase(*phase),
409        }),
410        ProgressEvent::SubagentText(text) => {
411            serde_json::json!({"type": "subagent_text", "text": text})
412        },
413    }
414}
415
416fn subagent_phase(phase: SubagentPhase) -> &'static str {
417    match phase {
418        SubagentPhase::Started => "started",
419        SubagentPhase::Finished => "finished",
420        SubagentPhase::Errored => "errored",
421    }
422}
423
424fn outcome_body(outcome: &ToolOutcome) -> serde_json::Value {
425    serde_json::json!({
426        "status": match outcome.status {
427            crate::domain::ToolStatus::Success => "success",
428            crate::domain::ToolStatus::Error => "error",
429            crate::domain::ToolStatus::Cancelled => "cancelled",
430        },
431        "summary": outcome.summary,
432        "model_content": outcome.model_content,
433        "error": outcome.error,
434        "image_count": outcome.images().map(|images| images.len()).unwrap_or(0),
435        "duration_secs": outcome.duration_secs,
436        "metadata": outcome.metadata,
437        "artifacts": outcome.artifacts,
438    })
439}
440
441fn unsupported(reason: &str) -> serde_json::Value {
442    serde_json::json!({
443        "recordable": false,
444        "reason": reason,
445    })
446}
447
448/// Read a JSONL log back. Iterates one line at a time so a huge
449/// replay doesn't allocate the whole file upfront.
450pub struct Replay {
451    lines: std::io::Lines<BufReader<File>>,
452    path: PathBuf,
453}
454
455impl Replay {
456    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
457        let path = path.into();
458        let file =
459            File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
460        Ok(Self {
461            lines: BufReader::new(file).lines(),
462            path,
463        })
464    }
465
466    pub fn path(&self) -> &Path {
467        &self.path
468    }
469}
470
471impl Iterator for Replay {
472    type Item = Result<ReplayEntry>;
473
474    fn next(&mut self) -> Option<Self::Item> {
475        let line = self.lines.next()?;
476        Some(match line {
477            Ok(raw) => serde_json::from_str::<ReplayEntry>(&raw)
478                .with_context(|| format!("parse replay line: {}", raw)),
479            Err(e) => Err(anyhow::Error::from(e)),
480        })
481    }
482}
483
484/// Parsed JSONL entry. Fields mirror what `Recorder::record_kind`
485/// writes.
486#[derive(Debug, serde::Serialize, serde::Deserialize)]
487pub struct ReplayEntry {
488    pub ts: String,
489    pub kind: String,
490    pub turn: Option<u64>,
491    pub body: serde_json::Value,
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    fn tmpfile(name: &str) -> PathBuf {
499        let dir = std::env::temp_dir().join("mermaid_recorder_tests");
500        let _ = std::fs::create_dir_all(&dir);
501        dir.join(name)
502    }
503
504    #[test]
505    fn record_and_replay_roundtrip() {
506        let path = tmpfile("roundtrip.jsonl");
507        let _ = std::fs::remove_file(&path);
508
509        {
510            let mut r = Recorder::open(&path).expect("open");
511            r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
512                .expect("record");
513            r.record_kind(
514                MsgKind::SubmitPrompt,
515                None,
516                serde_json::json!({"text": "hello"}),
517            )
518            .expect("record");
519            r.record_kind(
520                MsgKind::StreamText,
521                Some(TurnId(7)),
522                serde_json::json!({"chunk": "partial"}),
523            )
524            .expect("record");
525            r.flush().expect("flush");
526        }
527
528        let replay = Replay::open(&path).expect("open replay");
529        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
530        assert_eq!(entries.len(), 3);
531        assert_eq!(entries[0].kind, "Tick");
532        assert_eq!(entries[1].body["text"], "hello");
533        assert_eq!(entries[2].turn, Some(7));
534
535        let _ = std::fs::remove_file(&path);
536    }
537
538    #[test]
539    fn record_kind_redacts_secrets_in_body() {
540        // A recorded payload carrying a credential (e.g. a `read_file .env`
541        // result or an API error echoing a key) must hit disk scrubbed (#17).
542        let path = tmpfile("redact.jsonl");
543        let _ = std::fs::remove_file(&path);
544        {
545            let mut r = Recorder::open(&path).expect("open");
546            r.record_kind(
547                MsgKind::StreamText,
548                None,
549                serde_json::json!({
550                    "chunk": "OPENAI_API_KEY=sk-abcdefghijklmnop1234",
551                    "nested": ["Authorization: Bearer abcdef123456ghijkl"],
552                }),
553            )
554            .expect("record");
555            r.flush().expect("flush");
556        }
557
558        let raw = std::fs::read_to_string(&path).expect("read back");
559        assert!(
560            !raw.contains("sk-abcdefghijklmnop1234"),
561            "raw secret leaked: {raw}"
562        );
563        assert!(
564            !raw.contains("abcdef123456ghijkl"),
565            "bearer token leaked: {raw}"
566        );
567        assert!(
568            raw.contains("[REDACTED]"),
569            "expected redaction marker: {raw}"
570        );
571
572        let replay = Replay::open(&path).expect("replay");
573        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
574        assert_eq!(entries[0].body["chunk"], "OPENAI_API_KEY=[REDACTED]");
575        assert_eq!(
576            entries[0].body["nested"][0],
577            "Authorization: Bearer [REDACTED]"
578        );
579
580        let _ = std::fs::remove_file(&path);
581    }
582
583    #[test]
584    fn replay_parses_malformed_line_as_err() {
585        let path = tmpfile("bad.jsonl");
586        std::fs::write(&path, "not-json\n").expect("write");
587        let mut replay = Replay::open(&path).expect("open");
588        let first = replay.next().expect("first entry");
589        assert!(first.is_err());
590        let _ = std::fs::remove_file(&path);
591    }
592
593    #[test]
594    fn record_creates_file_on_open() {
595        let path = tmpfile("creates.jsonl");
596        let _ = std::fs::remove_file(&path);
597        assert!(!path.exists());
598        let _ = Recorder::open(&path).expect("open");
599        assert!(path.exists());
600        let _ = std::fs::remove_file(&path);
601    }
602
603    #[test]
604    fn record_append_preserves_existing_lines() {
605        let path = tmpfile("append.jsonl");
606        let _ = std::fs::remove_file(&path);
607        {
608            let mut r = Recorder::open(&path).expect("open");
609            r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
610                .expect("record");
611        }
612        {
613            let mut r = Recorder::open(&path).expect("reopen");
614            r.record_kind(MsgKind::Quit, None, serde_json::json!({}))
615                .expect("record");
616        }
617        let replay = Replay::open(&path).expect("replay");
618        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
619        assert_eq!(entries.len(), 2);
620        assert_eq!(entries[0].kind, "Tick");
621        assert_eq!(entries[1].kind, "Quit");
622        let _ = std::fs::remove_file(&path);
623    }
624
625    #[test]
626    fn record_msg_body_submit_prompt_keeps_text_and_attachments() {
627        let body = record_msg_body(&crate::domain::Msg::SubmitPrompt {
628            text: "explain main.rs".to_string(),
629            attachment_ids: vec![3, 9],
630        });
631        assert_eq!(body["text"], "explain main.rs");
632        assert_eq!(body["attachment_ids"][0], 3);
633        assert_eq!(body["attachment_ids"][1], 9);
634    }
635
636    #[test]
637    fn record_msg_body_slash_model_keeps_command_and_arg() {
638        let body = record_msg_body(&crate::domain::Msg::Slash(crate::domain::SlashCmd::Model(
639            Some("anthropic/opus".to_string()),
640        )));
641        assert_eq!(body["command"], "model");
642        assert_eq!(body["arg"], "anthropic/opus");
643    }
644
645    #[test]
646    fn record_msg_body_runtime_signal_keeps_signal_name() {
647        let body = record_msg_body(&crate::domain::Msg::RuntimeSignal(
648            crate::domain::RuntimeSignal::Terminate,
649        ));
650        assert_eq!(body["signal"], "terminate");
651    }
652
653    #[test]
654    fn record_msg_body_marks_binary_paste_image_unrecordable() {
655        let body = record_msg_body(&crate::domain::Msg::Paste(crate::domain::Paste::Image {
656            bytes: vec![1, 2, 3],
657            format: "png".to_string(),
658        }));
659        assert_eq!(body["recordable"], false);
660        assert_eq!(body["type"], "image");
661        assert_eq!(body["size_bytes"], 3);
662    }
663}