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::ProviderContextResolved {
163            model_max,
164            effective,
165            source,
166            ..
167        } => serde_json::json!({
168            "model_max": model_max,
169            "effective": effective,
170            "source": source.map(|s| s.label()),
171        }),
172        Msg::OllamaPlacementResolved {
173            model_id,
174            size_vram_bytes,
175            total_bytes,
176            suggested_num_ctx,
177        } => serde_json::json!({
178            "model_id": model_id,
179            "size_vram_bytes": size_vram_bytes,
180            "total_bytes": total_bytes,
181            "offloaded": size_vram_bytes < total_bytes,
182            "suggested_num_ctx": suggested_num_ctx,
183        }),
184        Msg::CompactionFinished { result, .. } => serde_json::json!({
185            "id": result.record.id,
186            "trigger": result.record.trigger.as_str(),
187            "before_tokens": result.record.before_tokens,
188            "after_tokens": result.record.after_tokens,
189            "archived_message_count": result.record.archived_message_count,
190            "preserved_message_count": result.record.preserved_message_count,
191            "duration_secs": result.record.duration_secs,
192        }),
193        Msg::CompactionFailed {
194            trigger,
195            message,
196            kind,
197            ..
198        } => serde_json::json!({
199            "trigger": trigger.as_str(),
200            "message": message,
201            "kind": format!("{:?}", kind),
202        }),
203        Msg::StreamDone {
204            usage,
205            thinking_signature,
206            ..
207        } => serde_json::json!({
208            "usage": usage.as_ref().map(|u| serde_json::json!({
209                "prompt_tokens": u.prompt_tokens,
210                "completion_tokens": u.completion_tokens,
211                "total_tokens": u.total_tokens,
212                "cached_input_tokens": u.cached_input_tokens,
213                "cache_creation_input_tokens": u.cache_creation_input_tokens,
214                "reasoning_output_tokens": u.reasoning_output_tokens,
215                "source": format!("{:?}", u.source),
216            })),
217            "thinking_signature": thinking_signature,
218        }),
219        Msg::UpstreamError { error, .. } => serde_json::to_value(error)
220            .unwrap_or_else(|_| unsupported("upstream error was not serializable")),
221        Msg::TurnCancelled(_) => serde_json::json!({}),
222        Msg::ToolStarted { call_id, .. } => serde_json::json!({"call_id": call_id.0}),
223        Msg::ToolProgress { call_id, event, .. } => serde_json::json!({
224            "call_id": call_id.0,
225            "event": progress_body(event),
226        }),
227        Msg::ToolFinished {
228            call_id, outcome, ..
229        } => serde_json::json!({
230            "call_id": call_id.0,
231            "outcome": outcome_body(outcome),
232        }),
233        Msg::ApprovalRequested {
234            call_id,
235            tool,
236            risk,
237            ..
238        } => serde_json::json!({
239            "call_id": call_id.0,
240            "tool": tool,
241            "risk": risk,
242        }),
243        Msg::McpServerReady { name, tools } => serde_json::json!({
244            "name": name,
245            "tools": tools.iter().map(|tool| serde_json::json!({
246                "name": tool.name,
247                "description": tool.description,
248                "input_schema": tool.input_schema,
249            })).collect::<Vec<_>>(),
250        }),
251        Msg::McpServerErrored { name, reason } => serde_json::json!({
252            "name": name,
253            "reason": reason,
254        }),
255        Msg::McpServerStopped { name } => serde_json::json!({"name": name}),
256        Msg::InstructionsChanged(loaded) => match loaded {
257            Some(loaded) => serde_json::json!({
258                "path": loaded.path,
259                "byte_len": loaded.byte_len,
260                "truncated": loaded.truncated,
261            }),
262            None => serde_json::json!({"path": null}),
263        },
264        Msg::MemoryChanged(loaded) => match loaded {
265            Some(loaded) => serde_json::json!({
266                "entries": loaded.entries.len(),
267                "truncated": loaded.truncated,
268            }),
269            None => serde_json::json!({"entries": 0}),
270        },
271        Msg::SessionSaved => serde_json::json!({}),
272        Msg::ConversationLoaded(history) => serde_json::json!({
273            "id": history.id,
274            "message_count": history.messages.len(),
275            "title": history.title,
276        }),
277        Msg::ConversationsListed(summaries) => serde_json::json!({
278            "count": summaries.len(),
279            "ids": summaries.iter().map(|summary| summary.id.as_str()).collect::<Vec<_>>(),
280        }),
281        Msg::RuntimeTasksListed(tasks) => serde_json::json!({
282            "count": tasks.len(),
283            "ids": tasks.iter().map(|task| task.id.as_str()).collect::<Vec<_>>(),
284        }),
285        Msg::RuntimeTaskLoaded { task, events } => serde_json::json!({
286            "id": task.as_ref().map(|task| task.id.as_str()),
287            "found": task.is_some(),
288            "event_count": events.len(),
289        }),
290        Msg::RuntimeProcessesListed(processes) => serde_json::json!({
291            "count": processes.len(),
292            "ids": processes.iter().map(|process| process.id.as_str()).collect::<Vec<_>>(),
293        }),
294        Msg::RuntimeText(text) => serde_json::json!({"chars": text.len()}),
295        Msg::RuntimeApprovalsListed(approvals) => serde_json::json!({
296            "count": approvals.len(),
297            "ids": approvals.iter().map(|approval| approval.id.as_str()).collect::<Vec<_>>(),
298        }),
299        Msg::RuntimeCheckpointsListed(checkpoints) => serde_json::json!({
300            "count": checkpoints.len(),
301            "ids": checkpoints.iter().map(|checkpoint| checkpoint.id.as_str()).collect::<Vec<_>>(),
302        }),
303        Msg::RuntimePluginsListed(plugins) => serde_json::json!({
304            "count": plugins.len(),
305            "ids": plugins.iter().map(|plugin| plugin.id.as_str()).collect::<Vec<_>>(),
306        }),
307        Msg::ModelPullFinished { model } => serde_json::json!({"model": model}),
308        Msg::ModelPullProgress(line) => serde_json::json!({"line": line}),
309        Msg::Tick => serde_json::json!({}),
310        Msg::StatusDismiss => serde_json::json!({}),
311        Msg::Resize { width, height } => serde_json::json!({
312            "width": width,
313            "height": height,
314        }),
315        Msg::TransientStatus {
316            text,
317            kind,
318            dismiss_ms,
319        } => serde_json::json!({
320            "text": text,
321            "kind": format!("{:?}", kind),
322            "dismiss_ms": dismiss_ms,
323        }),
324        Msg::MouseScroll { delta } => serde_json::json!({"delta": delta}),
325        Msg::OpenImageAt {
326            message_index,
327            image_index,
328        } => serde_json::json!({
329            "message_index": message_index,
330            "image_index": image_index,
331        }),
332        // Record only the selection length — the copied text doesn't affect
333        // reducer State (it just emits a clipboard Cmd) and may be large/sensitive.
334        Msg::CopySelection(text) => serde_json::json!({ "len": text.chars().count() }),
335    }
336}
337
338fn key_code_body(code: KeyCode) -> serde_json::Value {
339    match code {
340        KeyCode::Char(c) => serde_json::json!({"char": c.to_string()}),
341        KeyCode::F(n) => serde_json::json!({"f": n}),
342        other => serde_json::json!(format!("{:?}", other)),
343    }
344}
345
346fn slash_body(cmd: &SlashCmd) -> serde_json::Value {
347    match cmd {
348        SlashCmd::Model(model) => serde_json::json!({"command": "model", "arg": model}),
349        SlashCmd::Reasoning(level) => serde_json::json!({
350            "command": "reasoning",
351            "arg": level.map(|level| level.as_str()),
352        }),
353        SlashCmd::VisibleReasoning(arg) => {
354            serde_json::json!({"command": "visible-reasoning", "arg": arg})
355        },
356        SlashCmd::Safety(mode) => serde_json::json!({
357            "command": "safety",
358            "arg": mode.map(|m| m.as_str()),
359        }),
360        SlashCmd::Clear => serde_json::json!({"command": "clear"}),
361        SlashCmd::Save(name) => serde_json::json!({"command": "save", "arg": name}),
362        SlashCmd::Load(name) => serde_json::json!({"command": "load", "arg": name}),
363        SlashCmd::List => serde_json::json!({"command": "list"}),
364        SlashCmd::Usage => serde_json::json!({"command": "usage"}),
365        SlashCmd::Context(cmd) => {
366            serde_json::json!({"command": "context", "arg": format!("{cmd:?}")})
367        },
368        SlashCmd::Compact(instructions) => {
369            serde_json::json!({"command": "compact", "arg": instructions})
370        },
371        SlashCmd::Memory => serde_json::json!({"command": "memory"}),
372        SlashCmd::Remember(text) => serde_json::json!({"command": "remember", "arg": text}),
373        SlashCmd::Forget(id) => serde_json::json!({"command": "forget", "arg": id}),
374        SlashCmd::ConsolidateMemory => serde_json::json!({"command": "consolidate-memory"}),
375        SlashCmd::Doctor => serde_json::json!({"command": "doctor"}),
376        SlashCmd::Tasks => serde_json::json!({"command": "tasks"}),
377        SlashCmd::Task(id) => serde_json::json!({"command": "task", "arg": id}),
378        SlashCmd::Pause(id) => serde_json::json!({"command": "pause", "arg": id}),
379        SlashCmd::Resume(id) => serde_json::json!({"command": "resume", "arg": id}),
380        SlashCmd::Cancel(id) => serde_json::json!({"command": "cancel", "arg": id}),
381        SlashCmd::Handoff(id) => serde_json::json!({"command": "handoff", "arg": id}),
382        SlashCmd::Report(id) => serde_json::json!({"command": "report", "arg": id}),
383        SlashCmd::Processes => serde_json::json!({"command": "processes"}),
384        SlashCmd::Logs(id) => serde_json::json!({"command": "logs", "arg": id}),
385        SlashCmd::Stop(id) => serde_json::json!({"command": "stop", "arg": id}),
386        SlashCmd::Restart(id) => serde_json::json!({"command": "restart", "arg": id}),
387        SlashCmd::Open(target) => serde_json::json!({"command": "open", "arg": target}),
388        SlashCmd::Ports => serde_json::json!({"command": "ports"}),
389        SlashCmd::Approvals => serde_json::json!({"command": "approvals"}),
390        SlashCmd::Approve(id) => serde_json::json!({"command": "approve", "arg": id}),
391        SlashCmd::Deny(id) => serde_json::json!({"command": "deny", "arg": id}),
392        SlashCmd::Checkpoint(paths) => serde_json::json!({"command": "checkpoint", "arg": paths}),
393        SlashCmd::Checkpoints => serde_json::json!({"command": "checkpoints"}),
394        SlashCmd::Restore(id) => serde_json::json!({"command": "restore", "arg": id}),
395        SlashCmd::ModelInfo(model) => serde_json::json!({"command": "model-info", "arg": model}),
396        SlashCmd::Plugins => serde_json::json!({"command": "plugins"}),
397        SlashCmd::CloudSetup => serde_json::json!({"command": "cloud-setup"}),
398        SlashCmd::Help => serde_json::json!({"command": "help"}),
399        SlashCmd::Quit => serde_json::json!({"command": "quit"}),
400        SlashCmd::Unknown(name) => serde_json::json!({"command": "unknown", "name": name}),
401    }
402}
403
404fn progress_body(event: &ProgressEvent) -> serde_json::Value {
405    match event {
406        ProgressEvent::Output(text) => serde_json::json!({"type": "output", "text": text}),
407        ProgressEvent::Status(text) => serde_json::json!({"type": "status", "text": text}),
408        ProgressEvent::Bytes { done, total } => serde_json::json!({
409            "type": "bytes",
410            "done": done,
411            "total": total,
412        }),
413        ProgressEvent::Artifact {
414            mime,
415            data,
416            caption,
417        } => serde_json::json!({
418            "recordable": false,
419            "type": "artifact",
420            "mime": mime,
421            "caption": caption,
422            "size_bytes": data.len(),
423        }),
424        ProgressEvent::SubagentToolCall {
425            child_call_id,
426            tool_name,
427            phase,
428        } => serde_json::json!({
429            "type": "subagent_tool_call",
430            "child_call_id": child_call_id.0,
431            "tool_name": tool_name,
432            "phase": subagent_phase(*phase),
433        }),
434        ProgressEvent::SubagentText(text) => {
435            serde_json::json!({"type": "subagent_text", "text": text})
436        },
437    }
438}
439
440fn subagent_phase(phase: SubagentPhase) -> &'static str {
441    match phase {
442        SubagentPhase::Started => "started",
443        SubagentPhase::Finished => "finished",
444        SubagentPhase::Errored => "errored",
445    }
446}
447
448fn outcome_body(outcome: &ToolOutcome) -> serde_json::Value {
449    serde_json::json!({
450        "status": match outcome.status {
451            crate::domain::ToolStatus::Success => "success",
452            crate::domain::ToolStatus::Error => "error",
453            crate::domain::ToolStatus::Cancelled => "cancelled",
454        },
455        "summary": outcome.summary,
456        "model_content": outcome.model_content,
457        "error": outcome.error,
458        "image_count": outcome.images().map(|images| images.len()).unwrap_or(0),
459        "duration_secs": outcome.duration_secs,
460        "metadata": outcome.metadata,
461        "artifacts": outcome.artifacts,
462    })
463}
464
465fn unsupported(reason: &str) -> serde_json::Value {
466    serde_json::json!({
467        "recordable": false,
468        "reason": reason,
469    })
470}
471
472/// Read a JSONL log back. Iterates one line at a time so a huge
473/// replay doesn't allocate the whole file upfront.
474pub struct Replay {
475    lines: std::io::Lines<BufReader<File>>,
476    path: PathBuf,
477}
478
479impl Replay {
480    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
481        let path = path.into();
482        let file =
483            File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
484        Ok(Self {
485            lines: BufReader::new(file).lines(),
486            path,
487        })
488    }
489
490    pub fn path(&self) -> &Path {
491        &self.path
492    }
493}
494
495impl Iterator for Replay {
496    type Item = Result<ReplayEntry>;
497
498    fn next(&mut self) -> Option<Self::Item> {
499        let line = self.lines.next()?;
500        Some(match line {
501            Ok(raw) => serde_json::from_str::<ReplayEntry>(&raw)
502                .with_context(|| format!("parse replay line: {}", raw)),
503            Err(e) => Err(anyhow::Error::from(e)),
504        })
505    }
506}
507
508/// Parsed JSONL entry. Fields mirror what `Recorder::record_kind`
509/// writes.
510#[derive(Debug, serde::Serialize, serde::Deserialize)]
511pub struct ReplayEntry {
512    pub ts: String,
513    pub kind: String,
514    pub turn: Option<u64>,
515    pub body: serde_json::Value,
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    fn tmpfile(name: &str) -> PathBuf {
523        let dir = std::env::temp_dir().join("mermaid_recorder_tests");
524        let _ = std::fs::create_dir_all(&dir);
525        dir.join(name)
526    }
527
528    #[test]
529    fn record_and_replay_roundtrip() {
530        let path = tmpfile("roundtrip.jsonl");
531        let _ = std::fs::remove_file(&path);
532
533        {
534            let mut r = Recorder::open(&path).expect("open");
535            r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
536                .expect("record");
537            r.record_kind(
538                MsgKind::SubmitPrompt,
539                None,
540                serde_json::json!({"text": "hello"}),
541            )
542            .expect("record");
543            r.record_kind(
544                MsgKind::StreamText,
545                Some(TurnId(7)),
546                serde_json::json!({"chunk": "partial"}),
547            )
548            .expect("record");
549            r.flush().expect("flush");
550        }
551
552        let replay = Replay::open(&path).expect("open replay");
553        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
554        assert_eq!(entries.len(), 3);
555        assert_eq!(entries[0].kind, "Tick");
556        assert_eq!(entries[1].body["text"], "hello");
557        assert_eq!(entries[2].turn, Some(7));
558
559        let _ = std::fs::remove_file(&path);
560    }
561
562    #[test]
563    fn record_kind_redacts_secrets_in_body() {
564        // A recorded payload carrying a credential (e.g. a `read_file .env`
565        // result or an API error echoing a key) must hit disk scrubbed (#17).
566        let path = tmpfile("redact.jsonl");
567        let _ = std::fs::remove_file(&path);
568        {
569            let mut r = Recorder::open(&path).expect("open");
570            r.record_kind(
571                MsgKind::StreamText,
572                None,
573                serde_json::json!({
574                    "chunk": "OPENAI_API_KEY=sk-abcdefghijklmnop1234",
575                    "nested": ["Authorization: Bearer abcdef123456ghijkl"],
576                }),
577            )
578            .expect("record");
579            r.flush().expect("flush");
580        }
581
582        let raw = std::fs::read_to_string(&path).expect("read back");
583        assert!(
584            !raw.contains("sk-abcdefghijklmnop1234"),
585            "raw secret leaked: {raw}"
586        );
587        assert!(
588            !raw.contains("abcdef123456ghijkl"),
589            "bearer token leaked: {raw}"
590        );
591        assert!(
592            raw.contains("[REDACTED]"),
593            "expected redaction marker: {raw}"
594        );
595
596        let replay = Replay::open(&path).expect("replay");
597        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
598        assert_eq!(entries[0].body["chunk"], "OPENAI_API_KEY=[REDACTED]");
599        assert_eq!(
600            entries[0].body["nested"][0],
601            "Authorization: Bearer [REDACTED]"
602        );
603
604        let _ = std::fs::remove_file(&path);
605    }
606
607    #[test]
608    fn replay_parses_malformed_line_as_err() {
609        let path = tmpfile("bad.jsonl");
610        std::fs::write(&path, "not-json\n").expect("write");
611        let mut replay = Replay::open(&path).expect("open");
612        let first = replay.next().expect("first entry");
613        assert!(first.is_err());
614        let _ = std::fs::remove_file(&path);
615    }
616
617    #[test]
618    fn record_creates_file_on_open() {
619        let path = tmpfile("creates.jsonl");
620        let _ = std::fs::remove_file(&path);
621        assert!(!path.exists());
622        let _ = Recorder::open(&path).expect("open");
623        assert!(path.exists());
624        let _ = std::fs::remove_file(&path);
625    }
626
627    #[test]
628    fn record_append_preserves_existing_lines() {
629        let path = tmpfile("append.jsonl");
630        let _ = std::fs::remove_file(&path);
631        {
632            let mut r = Recorder::open(&path).expect("open");
633            r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
634                .expect("record");
635        }
636        {
637            let mut r = Recorder::open(&path).expect("reopen");
638            r.record_kind(MsgKind::Quit, None, serde_json::json!({}))
639                .expect("record");
640        }
641        let replay = Replay::open(&path).expect("replay");
642        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
643        assert_eq!(entries.len(), 2);
644        assert_eq!(entries[0].kind, "Tick");
645        assert_eq!(entries[1].kind, "Quit");
646        let _ = std::fs::remove_file(&path);
647    }
648
649    #[test]
650    fn record_msg_body_submit_prompt_keeps_text_and_attachments() {
651        let body = record_msg_body(&crate::domain::Msg::SubmitPrompt {
652            text: "explain main.rs".to_string(),
653            attachment_ids: vec![3, 9],
654        });
655        assert_eq!(body["text"], "explain main.rs");
656        assert_eq!(body["attachment_ids"][0], 3);
657        assert_eq!(body["attachment_ids"][1], 9);
658    }
659
660    #[test]
661    fn record_msg_body_slash_model_keeps_command_and_arg() {
662        let body = record_msg_body(&crate::domain::Msg::Slash(crate::domain::SlashCmd::Model(
663            Some("anthropic/opus".to_string()),
664        )));
665        assert_eq!(body["command"], "model");
666        assert_eq!(body["arg"], "anthropic/opus");
667    }
668
669    #[test]
670    fn record_msg_body_runtime_signal_keeps_signal_name() {
671        let body = record_msg_body(&crate::domain::Msg::RuntimeSignal(
672            crate::domain::RuntimeSignal::Terminate,
673        ));
674        assert_eq!(body["signal"], "terminate");
675    }
676
677    #[test]
678    fn record_msg_body_marks_binary_paste_image_unrecordable() {
679        let body = record_msg_body(&crate::domain::Msg::Paste(crate::domain::Paste::Image {
680            bytes: vec![1, 2, 3],
681            format: "png".to_string(),
682        }));
683        assert_eq!(body["recordable"], false);
684        assert_eq!(body["type"], "image");
685        assert_eq!(body["size_bytes"], 3);
686    }
687}