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 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 pub fn record_kind(
65 &mut self,
66 kind: MsgKind,
67 turn: Option<TurnId>,
68 mut body: serde_json::Value,
69 ) -> Result<()> {
70 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
96pub 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 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
472pub 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#[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 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}