1use 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
35pub struct Recorder {
38 writer: BufWriter<File>,
39 path: PathBuf,
40}
41
42impl Recorder {
43 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
45 let path = path.into();
46 let mut opts = OpenOptions::new();
47 opts.create(true).append(true);
48 #[cfg(unix)]
53 {
54 use std::os::unix::fs::OpenOptionsExt;
55 opts.mode(0o600);
56 }
57 let file = opts
58 .open(&path)
59 .with_context(|| format!("open {} for recording", path.display()))?;
60 #[cfg(unix)]
62 {
63 use std::os::unix::fs::PermissionsExt;
64 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
65 }
66 tracing::warn!(
67 path = %path.display(),
68 "session recording is ON: this file stores prompts, model output, and \
69 tool results (including file contents) in cleartext; only \
70 credential-shaped strings are redacted",
71 );
72 Ok(Self {
73 writer: BufWriter::new(file),
74 path,
75 })
76 }
77
78 pub fn path(&self) -> &Path {
79 &self.path
80 }
81
82 pub fn record_kind(
86 &mut self,
87 kind: MsgKind,
88 turn: Option<TurnId>,
89 mut body: serde_json::Value,
90 ) -> Result<()> {
91 crate::utils::redact_json(&mut body);
96 let entry = serde_json::json!({
97 "ts": Local::now().to_rfc3339(),
98 "kind": format!("{:?}", kind),
99 "turn": turn.map(|t| t.0),
100 "body": body,
101 });
102 writeln!(self.writer, "{}", entry).context("write jsonl line")?;
103 Ok(())
104 }
105
106 pub fn flush(&mut self) -> Result<()> {
107 self.writer.flush().context("flush recorder")
108 }
109}
110
111impl Drop for Recorder {
112 fn drop(&mut self) {
113 let _ = self.writer.flush();
114 }
115}
116
117pub fn record_msg_body(msg: &Msg) -> serde_json::Value {
124 match msg {
125 Msg::Key(key) => serde_json::json!({
126 "code": key_code_body(key.code),
127 "modifiers": {
128 "ctrl": key.modifiers.ctrl,
129 "alt": key.modifiers.alt,
130 "shift": key.modifiers.shift,
131 },
132 }),
133 Msg::Paste(Paste::Text(text)) => serde_json::json!({
134 "type": "text",
135 "text": text,
136 }),
137 Msg::Paste(Paste::Image { bytes, format }) => serde_json::json!({
138 "recordable": false,
139 "reason": "binary paste image omitted",
140 "type": "image",
141 "format": format,
142 "size_bytes": bytes.len(),
143 }),
144 Msg::SubmitPrompt {
145 text,
146 attachment_ids,
147 } => serde_json::json!({
148 "text": text,
149 "attachment_ids": attachment_ids,
150 }),
151 Msg::Slash(cmd) => slash_body(cmd),
152 Msg::CancelTurn => serde_json::json!({}),
153 Msg::ConfirmAccepted => serde_json::json!({"accepted": true}),
154 Msg::ConfirmDeclined => serde_json::json!({"accepted": false}),
155 Msg::Quit => serde_json::json!({}),
156 Msg::RuntimeSignal(signal) => serde_json::json!({
157 "signal": signal.as_str(),
158 }),
159 Msg::StreamText { chunk, .. } => serde_json::json!({"chunk": chunk}),
160 Msg::StreamReasoning { chunk, .. } => serde_json::json!({
161 "text": chunk.text,
162 "signature": chunk.signature,
163 }),
164 Msg::StreamToolCall { call, .. } => serde_json::to_value(call)
165 .unwrap_or_else(|_| unsupported("tool call was not serializable")),
166 Msg::BuiltinToolSchemaTokens(tokens) => serde_json::json!({"tokens": tokens}),
167 Msg::ContextUsageEstimated { snapshot, .. } => serde_json::json!({
168 "used_tokens": snapshot.used_tokens,
169 "max_tokens": snapshot.max_tokens,
170 "remaining_tokens": snapshot.remaining_tokens,
171 "used_percent": snapshot.used_percent,
172 "source": format!("{:?}", snapshot.source),
173 "breakdown": snapshot.breakdown.as_ref().map(|b| serde_json::json!({
174 "system_tokens": b.system_tokens,
175 "instructions_tokens": b.instructions_tokens,
176 "message_tokens": b.message_tokens,
177 "tool_schema_tokens": b.tool_schema_tokens,
178 "image_count": b.image_count,
179 "message_count": b.message_count,
180 "tool_count": b.tool_count,
181 })),
182 }),
183 Msg::ProviderContextResolved {
184 model_max,
185 effective,
186 source,
187 ..
188 } => serde_json::json!({
189 "model_max": model_max,
190 "effective": effective,
191 "source": source.map(|s| s.label()),
192 }),
193 Msg::OllamaPlacementResolved {
194 model_id,
195 size_vram_bytes,
196 total_bytes,
197 suggested_num_ctx,
198 } => serde_json::json!({
199 "model_id": model_id,
200 "size_vram_bytes": size_vram_bytes,
201 "total_bytes": total_bytes,
202 "offloaded": size_vram_bytes < total_bytes,
203 "suggested_num_ctx": suggested_num_ctx,
204 }),
205 Msg::CompactionFinished { result, .. } => serde_json::json!({
206 "id": result.record.id,
207 "trigger": result.record.trigger.as_str(),
208 "before_tokens": result.record.before_tokens,
209 "after_tokens": result.record.after_tokens,
210 "archived_message_count": result.record.archived_message_count,
211 "preserved_message_count": result.record.preserved_message_count,
212 "duration_secs": result.record.duration_secs,
213 }),
214 Msg::CompactionFailed {
215 trigger,
216 message,
217 kind,
218 ..
219 } => serde_json::json!({
220 "trigger": trigger.as_str(),
221 "message": message,
222 "kind": format!("{:?}", kind),
223 }),
224 Msg::StreamDone {
225 usage,
226 thinking_signature,
227 ..
228 } => serde_json::json!({
229 "usage": usage.as_ref().map(|u| serde_json::json!({
230 "prompt_tokens": u.prompt_tokens,
231 "completion_tokens": u.completion_tokens,
232 "total_tokens": u.total_tokens,
233 "cached_input_tokens": u.cached_input_tokens,
234 "cache_creation_input_tokens": u.cache_creation_input_tokens,
235 "reasoning_output_tokens": u.reasoning_output_tokens,
236 "source": format!("{:?}", u.source),
237 })),
238 "thinking_signature": thinking_signature,
239 }),
240 Msg::UpstreamError { error, .. } => serde_json::to_value(error)
241 .unwrap_or_else(|_| unsupported("upstream error was not serializable")),
242 Msg::TurnCancelled(_) => serde_json::json!({}),
243 Msg::ToolStarted { call_id, .. } => serde_json::json!({"call_id": call_id.0}),
244 Msg::ToolProgress { call_id, event, .. } => serde_json::json!({
245 "call_id": call_id.0,
246 "event": progress_body(event),
247 }),
248 Msg::ToolFinished {
249 call_id, outcome, ..
250 } => serde_json::json!({
251 "call_id": call_id.0,
252 "outcome": outcome_body(outcome),
253 }),
254 Msg::ApprovalRequested {
255 call_id,
256 tool,
257 risk,
258 ..
259 } => serde_json::json!({
260 "call_id": call_id.0,
261 "tool": tool,
262 "risk": risk,
263 }),
264 Msg::McpServerReady { name, tools } => serde_json::json!({
265 "name": name,
266 "tools": tools.iter().map(|tool| serde_json::json!({
267 "name": tool.name,
268 "description": tool.description,
269 "input_schema": tool.input_schema,
270 })).collect::<Vec<_>>(),
271 }),
272 Msg::McpServerErrored { name, reason } => serde_json::json!({
273 "name": name,
274 "reason": reason,
275 }),
276 Msg::McpServerStopped { name } => serde_json::json!({"name": name}),
277 Msg::InstructionsChanged(loaded) => match loaded {
278 Some(loaded) => serde_json::json!({
279 "path": loaded.path,
280 "byte_len": loaded.byte_len,
281 "truncated": loaded.truncated,
282 }),
283 None => serde_json::json!({"path": null}),
284 },
285 Msg::MemoryChanged(loaded) => match loaded {
286 Some(loaded) => serde_json::json!({
287 "entries": loaded.entries.len(),
288 "truncated": loaded.truncated,
289 }),
290 None => serde_json::json!({"entries": 0}),
291 },
292 Msg::SessionSaved => serde_json::json!({}),
293 Msg::ConversationLoaded(history) => serde_json::json!({
294 "id": history.id,
295 "message_count": history.messages.len(),
296 "title": history.title,
297 }),
298 Msg::ConversationsListed(summaries) => serde_json::json!({
299 "count": summaries.len(),
300 "ids": summaries.iter().map(|summary| summary.id.as_str()).collect::<Vec<_>>(),
301 }),
302 Msg::RuntimeTasksListed(tasks) => serde_json::json!({
303 "count": tasks.len(),
304 "ids": tasks.iter().map(|task| task.id.as_str()).collect::<Vec<_>>(),
305 }),
306 Msg::RuntimeTaskLoaded { task, events } => serde_json::json!({
307 "id": task.as_ref().map(|task| task.id.as_str()),
308 "found": task.is_some(),
309 "event_count": events.len(),
310 }),
311 Msg::RuntimeProcessesListed(processes) => serde_json::json!({
312 "count": processes.len(),
313 "ids": processes.iter().map(|process| process.id.as_str()).collect::<Vec<_>>(),
314 }),
315 Msg::RuntimeText(text) => serde_json::json!({"chars": text.len()}),
316 Msg::RuntimeApprovalsListed(approvals) => serde_json::json!({
317 "count": approvals.len(),
318 "ids": approvals.iter().map(|approval| approval.id.as_str()).collect::<Vec<_>>(),
319 }),
320 Msg::RuntimeCheckpointsListed(checkpoints) => serde_json::json!({
321 "count": checkpoints.len(),
322 "ids": checkpoints.iter().map(|checkpoint| checkpoint.id.as_str()).collect::<Vec<_>>(),
323 }),
324 Msg::RuntimePluginsListed(plugins) => serde_json::json!({
325 "count": plugins.len(),
326 "ids": plugins.iter().map(|plugin| plugin.id.as_str()).collect::<Vec<_>>(),
327 }),
328 Msg::ModelPullFinished { model } => serde_json::json!({"model": model}),
329 Msg::ModelPullProgress(line) => serde_json::json!({"line": line}),
330 Msg::Tick => serde_json::json!({}),
331 Msg::StatusDismiss => serde_json::json!({}),
332 Msg::Resize { width, height } => serde_json::json!({
333 "width": width,
334 "height": height,
335 }),
336 Msg::TransientStatus {
337 text,
338 kind,
339 dismiss_ms,
340 } => serde_json::json!({
341 "text": text,
342 "kind": format!("{:?}", kind),
343 "dismiss_ms": dismiss_ms,
344 }),
345 Msg::MouseScroll { delta } => serde_json::json!({"delta": delta}),
346 Msg::OpenImageAt {
347 message_index,
348 image_index,
349 } => serde_json::json!({
350 "message_index": message_index,
351 "image_index": image_index,
352 }),
353 Msg::CopySelection(text) => serde_json::json!({ "len": text.chars().count() }),
356 }
357}
358
359fn key_code_body(code: KeyCode) -> serde_json::Value {
360 match code {
361 KeyCode::Char(c) => serde_json::json!({"char": c.to_string()}),
362 KeyCode::F(n) => serde_json::json!({"f": n}),
363 other => serde_json::json!(format!("{:?}", other)),
364 }
365}
366
367fn slash_body(cmd: &SlashCmd) -> serde_json::Value {
368 match cmd {
369 SlashCmd::Model(model) => serde_json::json!({"command": "model", "arg": model}),
370 SlashCmd::Reasoning(level) => serde_json::json!({
371 "command": "reasoning",
372 "arg": level.map(|level| level.as_str()),
373 }),
374 SlashCmd::VisibleReasoning(arg) => {
375 serde_json::json!({"command": "visible-reasoning", "arg": arg})
376 },
377 SlashCmd::Safety(mode) => serde_json::json!({
378 "command": "safety",
379 "arg": mode.map(|m| m.as_str()),
380 }),
381 SlashCmd::Clear => serde_json::json!({"command": "clear"}),
382 SlashCmd::Save(name) => serde_json::json!({"command": "save", "arg": name}),
383 SlashCmd::Load(name) => serde_json::json!({"command": "load", "arg": name}),
384 SlashCmd::List => serde_json::json!({"command": "list"}),
385 SlashCmd::Usage => serde_json::json!({"command": "usage"}),
386 SlashCmd::Context(cmd) => {
387 serde_json::json!({"command": "context", "arg": format!("{cmd:?}")})
388 },
389 SlashCmd::Compact(instructions) => {
390 serde_json::json!({"command": "compact", "arg": instructions})
391 },
392 SlashCmd::Memory => serde_json::json!({"command": "memory"}),
393 SlashCmd::Remember(text) => serde_json::json!({"command": "remember", "arg": text}),
394 SlashCmd::Forget(id) => serde_json::json!({"command": "forget", "arg": id}),
395 SlashCmd::ConsolidateMemory => serde_json::json!({"command": "consolidate-memory"}),
396 SlashCmd::Doctor => serde_json::json!({"command": "doctor"}),
397 SlashCmd::Tasks => serde_json::json!({"command": "tasks"}),
398 SlashCmd::Task(id) => serde_json::json!({"command": "task", "arg": id}),
399 SlashCmd::Pause(id) => serde_json::json!({"command": "pause", "arg": id}),
400 SlashCmd::Resume(id) => serde_json::json!({"command": "resume", "arg": id}),
401 SlashCmd::Cancel(id) => serde_json::json!({"command": "cancel", "arg": id}),
402 SlashCmd::Handoff(id) => serde_json::json!({"command": "handoff", "arg": id}),
403 SlashCmd::Report(id) => serde_json::json!({"command": "report", "arg": id}),
404 SlashCmd::Processes => serde_json::json!({"command": "processes"}),
405 SlashCmd::Logs(id) => serde_json::json!({"command": "logs", "arg": id}),
406 SlashCmd::Stop(id) => serde_json::json!({"command": "stop", "arg": id}),
407 SlashCmd::Restart(id) => serde_json::json!({"command": "restart", "arg": id}),
408 SlashCmd::Open(target) => serde_json::json!({"command": "open", "arg": target}),
409 SlashCmd::Ports => serde_json::json!({"command": "ports"}),
410 SlashCmd::Approvals => serde_json::json!({"command": "approvals"}),
411 SlashCmd::Approve(id) => serde_json::json!({"command": "approve", "arg": id}),
412 SlashCmd::Deny(id) => serde_json::json!({"command": "deny", "arg": id}),
413 SlashCmd::Checkpoint(paths) => serde_json::json!({"command": "checkpoint", "arg": paths}),
414 SlashCmd::Checkpoints => serde_json::json!({"command": "checkpoints"}),
415 SlashCmd::Restore(id) => serde_json::json!({"command": "restore", "arg": id}),
416 SlashCmd::ModelInfo(model) => serde_json::json!({"command": "model-info", "arg": model}),
417 SlashCmd::Plugins => serde_json::json!({"command": "plugins"}),
418 SlashCmd::CloudSetup => serde_json::json!({"command": "cloud-setup"}),
419 SlashCmd::Help => serde_json::json!({"command": "help"}),
420 SlashCmd::Quit => serde_json::json!({"command": "quit"}),
421 SlashCmd::Unknown(name) => serde_json::json!({"command": "unknown", "name": name}),
422 }
423}
424
425fn progress_body(event: &ProgressEvent) -> serde_json::Value {
426 match event {
427 ProgressEvent::Output(text) => serde_json::json!({"type": "output", "text": text}),
428 ProgressEvent::Status(text) => serde_json::json!({"type": "status", "text": text}),
429 ProgressEvent::Bytes { done, total } => serde_json::json!({
430 "type": "bytes",
431 "done": done,
432 "total": total,
433 }),
434 ProgressEvent::Artifact {
435 mime,
436 data,
437 caption,
438 } => serde_json::json!({
439 "recordable": false,
440 "type": "artifact",
441 "mime": mime,
442 "caption": caption,
443 "size_bytes": data.len(),
444 }),
445 ProgressEvent::SubagentToolCall {
446 child_call_id,
447 tool_name,
448 phase,
449 } => serde_json::json!({
450 "type": "subagent_tool_call",
451 "child_call_id": child_call_id.0,
452 "tool_name": tool_name,
453 "phase": subagent_phase(*phase),
454 }),
455 ProgressEvent::SubagentText(text) => {
456 serde_json::json!({"type": "subagent_text", "text": text})
457 },
458 }
459}
460
461fn subagent_phase(phase: SubagentPhase) -> &'static str {
462 match phase {
463 SubagentPhase::Started => "started",
464 SubagentPhase::Finished => "finished",
465 SubagentPhase::Errored => "errored",
466 }
467}
468
469fn outcome_body(outcome: &ToolOutcome) -> serde_json::Value {
470 serde_json::json!({
471 "status": match outcome.status {
472 crate::domain::ToolStatus::Success => "success",
473 crate::domain::ToolStatus::Error => "error",
474 crate::domain::ToolStatus::Cancelled => "cancelled",
475 },
476 "summary": outcome.summary,
477 "model_content": outcome.model_content,
478 "error": outcome.error,
479 "image_count": outcome.images().map(|images| images.len()).unwrap_or(0),
480 "duration_secs": outcome.duration_secs,
481 "metadata": outcome.metadata,
482 "artifacts": outcome.artifacts,
483 })
484}
485
486fn unsupported(reason: &str) -> serde_json::Value {
487 serde_json::json!({
488 "recordable": false,
489 "reason": reason,
490 })
491}
492
493pub struct Replay {
496 lines: std::io::Lines<BufReader<File>>,
497 path: PathBuf,
498}
499
500impl Replay {
501 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
502 let path = path.into();
503 let file =
504 File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
505 Ok(Self {
506 lines: BufReader::new(file).lines(),
507 path,
508 })
509 }
510
511 pub fn path(&self) -> &Path {
512 &self.path
513 }
514}
515
516impl Iterator for Replay {
517 type Item = Result<ReplayEntry>;
518
519 fn next(&mut self) -> Option<Self::Item> {
520 let line = self.lines.next()?;
521 Some(match line {
522 Ok(raw) => serde_json::from_str::<ReplayEntry>(&raw)
523 .with_context(|| format!("parse replay line: {}", raw)),
524 Err(e) => Err(anyhow::Error::from(e)),
525 })
526 }
527}
528
529#[derive(Debug, serde::Serialize, serde::Deserialize)]
532pub struct ReplayEntry {
533 pub ts: String,
534 pub kind: String,
535 pub turn: Option<u64>,
536 pub body: serde_json::Value,
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 fn tmpfile(name: &str) -> PathBuf {
544 let dir = std::env::temp_dir().join("mermaid_recorder_tests");
545 let _ = std::fs::create_dir_all(&dir);
546 dir.join(name)
547 }
548
549 #[cfg(unix)]
550 #[test]
551 fn recording_file_is_owner_only() {
552 use std::os::unix::fs::PermissionsExt;
555 let path = tmpfile("perms.jsonl");
556 let _ = std::fs::remove_file(&path);
557 let _ = Recorder::open(&path).expect("open");
558 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
559 assert_eq!(mode, 0o600, "recording must be created owner-only");
560 let _ = std::fs::remove_file(&path);
561 }
562
563 #[test]
564 fn record_and_replay_roundtrip() {
565 let path = tmpfile("roundtrip.jsonl");
566 let _ = std::fs::remove_file(&path);
567
568 {
569 let mut r = Recorder::open(&path).expect("open");
570 r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
571 .expect("record");
572 r.record_kind(
573 MsgKind::SubmitPrompt,
574 None,
575 serde_json::json!({"text": "hello"}),
576 )
577 .expect("record");
578 r.record_kind(
579 MsgKind::StreamText,
580 Some(TurnId(7)),
581 serde_json::json!({"chunk": "partial"}),
582 )
583 .expect("record");
584 r.flush().expect("flush");
585 }
586
587 let replay = Replay::open(&path).expect("open replay");
588 let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
589 assert_eq!(entries.len(), 3);
590 assert_eq!(entries[0].kind, "Tick");
591 assert_eq!(entries[1].body["text"], "hello");
592 assert_eq!(entries[2].turn, Some(7));
593
594 let _ = std::fs::remove_file(&path);
595 }
596
597 #[test]
598 fn record_kind_redacts_secrets_in_body() {
599 let path = tmpfile("redact.jsonl");
602 let _ = std::fs::remove_file(&path);
603 {
604 let mut r = Recorder::open(&path).expect("open");
605 r.record_kind(
606 MsgKind::StreamText,
607 None,
608 serde_json::json!({
609 "chunk": "OPENAI_API_KEY=sk-abcdefghijklmnop1234",
610 "nested": ["Authorization: Bearer abcdef123456ghijkl"],
611 }),
612 )
613 .expect("record");
614 r.flush().expect("flush");
615 }
616
617 let raw = std::fs::read_to_string(&path).expect("read back");
618 assert!(
619 !raw.contains("sk-abcdefghijklmnop1234"),
620 "raw secret leaked: {raw}"
621 );
622 assert!(
623 !raw.contains("abcdef123456ghijkl"),
624 "bearer token leaked: {raw}"
625 );
626 assert!(
627 raw.contains("[REDACTED]"),
628 "expected redaction marker: {raw}"
629 );
630
631 let replay = Replay::open(&path).expect("replay");
632 let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
633 assert_eq!(entries[0].body["chunk"], "OPENAI_API_KEY=[REDACTED]");
634 assert_eq!(
635 entries[0].body["nested"][0],
636 "Authorization: Bearer [REDACTED]"
637 );
638
639 let _ = std::fs::remove_file(&path);
640 }
641
642 #[test]
643 fn replay_parses_malformed_line_as_err() {
644 let path = tmpfile("bad.jsonl");
645 std::fs::write(&path, "not-json\n").expect("write");
646 let mut replay = Replay::open(&path).expect("open");
647 let first = replay.next().expect("first entry");
648 assert!(first.is_err());
649 let _ = std::fs::remove_file(&path);
650 }
651
652 #[test]
653 fn record_creates_file_on_open() {
654 let path = tmpfile("creates.jsonl");
655 let _ = std::fs::remove_file(&path);
656 assert!(!path.exists());
657 let _ = Recorder::open(&path).expect("open");
658 assert!(path.exists());
659 let _ = std::fs::remove_file(&path);
660 }
661
662 #[test]
663 fn record_append_preserves_existing_lines() {
664 let path = tmpfile("append.jsonl");
665 let _ = std::fs::remove_file(&path);
666 {
667 let mut r = Recorder::open(&path).expect("open");
668 r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
669 .expect("record");
670 }
671 {
672 let mut r = Recorder::open(&path).expect("reopen");
673 r.record_kind(MsgKind::Quit, None, serde_json::json!({}))
674 .expect("record");
675 }
676 let replay = Replay::open(&path).expect("replay");
677 let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
678 assert_eq!(entries.len(), 2);
679 assert_eq!(entries[0].kind, "Tick");
680 assert_eq!(entries[1].kind, "Quit");
681 let _ = std::fs::remove_file(&path);
682 }
683
684 #[test]
685 fn record_msg_body_submit_prompt_keeps_text_and_attachments() {
686 let body = record_msg_body(&crate::domain::Msg::SubmitPrompt {
687 text: "explain main.rs".to_string(),
688 attachment_ids: vec![3, 9],
689 });
690 assert_eq!(body["text"], "explain main.rs");
691 assert_eq!(body["attachment_ids"][0], 3);
692 assert_eq!(body["attachment_ids"][1], 9);
693 }
694
695 #[test]
696 fn record_msg_body_slash_model_keeps_command_and_arg() {
697 let body = record_msg_body(&crate::domain::Msg::Slash(crate::domain::SlashCmd::Model(
698 Some("anthropic/opus".to_string()),
699 )));
700 assert_eq!(body["command"], "model");
701 assert_eq!(body["arg"], "anthropic/opus");
702 }
703
704 #[test]
705 fn record_msg_body_runtime_signal_keeps_signal_name() {
706 let body = record_msg_body(&crate::domain::Msg::RuntimeSignal(
707 crate::domain::RuntimeSignal::Terminate,
708 ));
709 assert_eq!(body["signal"], "terminate");
710 }
711
712 #[test]
713 fn record_msg_body_marks_binary_paste_image_unrecordable() {
714 let body = record_msg_body(&crate::domain::Msg::Paste(crate::domain::Paste::Image {
715 bytes: vec![1, 2, 3],
716 format: "png".to_string(),
717 }));
718 assert_eq!(body["recordable"], false);
719 assert_eq!(body["type"], "image");
720 assert_eq!(body["size_bytes"], 3);
721 }
722}