Skip to main content

capo_agent/
user_message.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3//! Public `UserMessage` and `Attachment` types plus the internal
4//! `prepare_user_message` pipeline that turns them into a
5//! `motosan_agent_loop::Message` for provider dispatch.
6//!
7//! See spec §2 / §3 in
8//! `docs/superpowers/specs/2026-05-20-capo-v0.6-design.md`.
9
10use std::path::PathBuf;
11
12use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
13use serde::{Deserialize, Serialize};
14
15/// A user message comprising a text body and zero-or-more attachments.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct UserMessage {
18    pub text: String,
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub attachments: Vec<Attachment>,
21}
22
23impl UserMessage {
24    /// Convenience for the common text-only case.
25    pub fn text(text: impl Into<String>) -> Self {
26        Self {
27            text: text.into(),
28            attachments: Vec::new(),
29        }
30    }
31}
32
33/// A user-message attachment. `non_exhaustive` because future variants
34/// (inline-bytes, skill, mention) must not break downstream pattern-match.
35#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(tag = "type", rename_all = "snake_case")]
38pub enum Attachment {
39    /// Local image file. Read, sniffed, base64-encoded by `prepare_user_message`
40    /// before reaching the provider.
41    Image { path: PathBuf },
42}
43
44/// Discriminator for `AttachmentError`. Carried over the wire on
45/// `UiEvent::AttachmentError` so RPC clients can branch programmatically.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum AttachmentErrorKind {
49    NotFound,
50    UnsupportedExtension,
51    TooLarge,
52    UnreadableImage,
53}
54
55/// Why `prepare_user_message` rejected an attachment.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum AttachmentError {
58    NotFound { path: PathBuf },
59    UnsupportedExtension { path: PathBuf, ext: String },
60    TooLarge { path: PathBuf, size: u64 },
61    UnreadableImage { path: PathBuf },
62}
63
64impl AttachmentError {
65    pub fn kind(&self) -> AttachmentErrorKind {
66        match self {
67            Self::NotFound { .. } => AttachmentErrorKind::NotFound,
68            Self::UnsupportedExtension { .. } => AttachmentErrorKind::UnsupportedExtension,
69            Self::TooLarge { .. } => AttachmentErrorKind::TooLarge,
70            Self::UnreadableImage { .. } => AttachmentErrorKind::UnreadableImage,
71        }
72    }
73}
74
75impl std::fmt::Display for AttachmentError {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::NotFound { path } => {
79                write!(f, "image not found: {}", path.display())
80            }
81            Self::UnsupportedExtension { path, ext } => write!(
82                f,
83                "image has unsupported extension '.{}': {} (supported: png, jpg, jpeg, gif, webp)",
84                ext,
85                path.display(),
86            ),
87            Self::TooLarge { path, size } => write!(
88                f,
89                "image is {} bytes; capo caps images at 5 MiB (5242880 bytes): {}",
90                size,
91                path.display(),
92            ),
93            Self::UnreadableImage { path } => {
94                write!(
95                    f,
96                    "image could not be read or recognised: {}",
97                    path.display()
98                )
99            }
100        }
101    }
102}
103
104impl std::error::Error for AttachmentError {}
105
106/// Maximum image size (raw file bytes, pre-base64). 5 MiB.
107pub(crate) const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
108
109const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
110
111/// Return the MIME type for a candidate image file, or `None` if neither
112/// magic-byte sniff nor extension lookup recognises it.
113///
114/// Magic-byte sniff is attempted only when `bytes.len() >= 12`. Extension
115/// match is case-insensitive.
116pub(crate) fn sniff_mime(bytes: &[u8], extension: &str) -> Option<&'static str> {
117    if bytes.len() >= 12 {
118        // PNG: 89 50 4E 47 0D 0A 1A 0A
119        if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
120            return Some("image/png");
121        }
122        // JPEG: FF D8 FF
123        if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
124            return Some("image/jpeg");
125        }
126        // GIF: "GIF87a" or "GIF89a"
127        if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
128            return Some("image/gif");
129        }
130        // WEBP: "RIFF????WEBP" — 4 bytes RIFF, 4 byte size, then "WEBP"
131        if bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
132            return Some("image/webp");
133        }
134    }
135    // Extension fallback.
136    match extension.to_ascii_lowercase().as_str() {
137        "png" => Some("image/png"),
138        "jpg" | "jpeg" => Some("image/jpeg"),
139        "gif" => Some("image/gif"),
140        "webp" => Some("image/webp"),
141        _ => None,
142    }
143}
144
145/// Validate every attachment, read+sniff+base64-encode each image, and
146/// assemble a `motosan_agent_loop::Message` (text-then-images, in declaration
147/// order). If `msg.text` is empty, no text part is emitted.
148///
149/// Errors short-circuit on the first bad attachment; later attachments are
150/// not inspected. Callers must surface the error before starting a turn.
151pub(crate) fn prepare_user_message(
152    msg: &UserMessage,
153) -> Result<motosan_agent_loop::Message, AttachmentError> {
154    use motosan_agent_loop::ContentPart;
155
156    let mut parts: Vec<ContentPart> = Vec::with_capacity(msg.attachments.len() + 1);
157    if !msg.text.is_empty() {
158        parts.push(ContentPart::text(&msg.text));
159    }
160
161    for att in &msg.attachments {
162        match att {
163            Attachment::Image { path } => {
164                // 1. Existence.
165                let metadata = match std::fs::metadata(path) {
166                    Ok(m) if m.is_file() => m,
167                    Ok(_) | Err(_) => {
168                        return Err(AttachmentError::NotFound { path: path.clone() });
169                    }
170                };
171
172                // 2. Extension.
173                let ext = path
174                    .extension()
175                    .and_then(|s| s.to_str())
176                    .map(|s| s.to_ascii_lowercase())
177                    .unwrap_or_default();
178                if !SUPPORTED_EXTENSIONS.contains(&ext.as_str()) {
179                    return Err(AttachmentError::UnsupportedExtension {
180                        path: path.clone(),
181                        ext,
182                    });
183                }
184
185                // 3. Size.
186                let size = metadata.len();
187                if size > MAX_IMAGE_BYTES {
188                    return Err(AttachmentError::TooLarge {
189                        path: path.clone(),
190                        size,
191                    });
192                }
193
194                // 4. Read + sniff + encode.
195                let bytes = std::fs::read(path)
196                    .map_err(|_| AttachmentError::UnreadableImage { path: path.clone() })?;
197                let mime = sniff_mime(&bytes, &ext)
198                    .ok_or_else(|| AttachmentError::UnreadableImage { path: path.clone() })?;
199                let data = B64.encode(&bytes);
200
201                parts.push(ContentPart::image_base64(mime.to_string(), data));
202            }
203        }
204    }
205
206    Ok(motosan_agent_loop::Message::user_with_parts(parts))
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    use motosan_agent_loop::{ContentPart, Message, Role};
214    use std::io::Write;
215
216    /// Helper: write `bytes` to a temp file with `extension`, return its path.
217    /// The file is left in the OS tempdir; tests do not need cleanup.
218    fn tempfile_with(extension: &str, bytes: &[u8]) -> PathBuf {
219        let mut path = std::env::temp_dir();
220        let name = format!(
221            "capo-v06-test-{}-{}.{}",
222            std::process::id(),
223            uuid_like_suffix(),
224            extension,
225        );
226        path.push(name);
227        let mut f = std::fs::File::create(&path).expect("create tempfile");
228        f.write_all(bytes).expect("write tempfile");
229        path
230    }
231
232    fn uuid_like_suffix() -> String {
233        use std::sync::atomic::{AtomicU64, Ordering};
234        static COUNTER: AtomicU64 = AtomicU64::new(0);
235        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
236        format!("{n:016x}")
237    }
238
239    fn png_header_bytes() -> Vec<u8> {
240        // Minimal valid PNG: 8-byte magic + IHDR (13 bytes) + IEND (12 bytes).
241        // We don't need a parseable PNG, only correct magic for sniff + non-zero size.
242        let mut v = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
243        v.extend_from_slice(&[0u8; 64]); // padding for total len > 12 bytes
244        v
245    }
246
247    /// Helper: extract content parts from a `Message` known to be a `User`.
248    fn parts(msg: &Message) -> &[ContentPart] {
249        match msg {
250            Message::User { content, .. } => content.as_slice(),
251            other => panic!("expected User message, got {other:?}"),
252        }
253    }
254
255    #[test]
256    fn user_message_text_only_serializes_without_attachments_key() {
257        let msg = UserMessage::text("hi");
258        let json = serde_json::to_string(&msg).expect("serialize");
259        assert_eq!(json, r#"{"text":"hi"}"#);
260    }
261
262    #[test]
263    fn user_message_text_only_deserializes_when_attachments_absent() {
264        let msg: UserMessage = serde_json::from_str(r#"{"text":"hi"}"#).expect("deserialize");
265        assert_eq!(msg, UserMessage::text("hi"));
266    }
267
268    #[test]
269    fn user_message_with_image_attachment_round_trips() {
270        let msg = UserMessage {
271            text: "look".into(),
272            attachments: vec![Attachment::Image {
273                path: PathBuf::from("/tmp/foo.png"),
274            }],
275        };
276        let json = serde_json::to_string(&msg).expect("serialize");
277        assert_eq!(
278            json,
279            r#"{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}"#
280        );
281        let back: UserMessage = serde_json::from_str(&json).expect("deserialize");
282        assert_eq!(back, msg);
283    }
284
285    #[test]
286    fn attachment_error_kind_serializes_to_stable_wire_strings() {
287        let cases = [
288            (AttachmentErrorKind::NotFound, "\"not_found\""),
289            (
290                AttachmentErrorKind::UnsupportedExtension,
291                "\"unsupported_extension\"",
292            ),
293            (AttachmentErrorKind::TooLarge, "\"too_large\""),
294            (AttachmentErrorKind::UnreadableImage, "\"unreadable_image\""),
295        ];
296        for (kind, expected) in cases {
297            let got = serde_json::to_string(&kind).expect("serialize");
298            assert_eq!(got, expected, "kind {kind:?} wire form");
299        }
300    }
301
302    #[test]
303    fn attachment_unknown_type_is_rejected() {
304        // Locks the discriminator: future variants must use a NEW type tag,
305        // not silently parse a misspelled "image" as something else.
306        let err = serde_json::from_str::<Attachment>(r#"{"type":"img","path":"/tmp/x.png"}"#);
307        assert!(err.is_err(), "unknown type tag must fail to deserialize");
308    }
309
310    #[test]
311    fn prepare_text_only_produces_single_text_part() {
312        let msg = UserMessage::text("hello");
313        let out = prepare_user_message(&msg).expect("ok");
314        assert_eq!(out.role(), Role::User);
315        let p = parts(&out);
316        assert_eq!(p.len(), 1);
317        assert!(matches!(&p[0], ContentPart::Text { text, .. } if text == "hello"));
318    }
319
320    #[test]
321    fn prepare_text_plus_one_image_produces_text_then_image() {
322        let path = tempfile_with("png", &png_header_bytes());
323        let msg = UserMessage {
324            text: "look".into(),
325            attachments: vec![Attachment::Image { path }],
326        };
327        let out = prepare_user_message(&msg).expect("ok");
328        let p = parts(&out);
329        assert_eq!(p.len(), 2);
330        assert!(matches!(&p[0], ContentPart::Text { text, .. } if text == "look"));
331        assert!(matches!(&p[1], ContentPart::Image { .. }));
332    }
333
334    #[test]
335    fn prepare_text_plus_two_images_preserves_declared_order() {
336        let p1 = tempfile_with("png", &png_header_bytes());
337        let p2 = tempfile_with("png", &png_header_bytes());
338        let msg = UserMessage {
339            text: "compare".into(),
340            attachments: vec![
341                Attachment::Image { path: p1 },
342                Attachment::Image { path: p2 },
343            ],
344        };
345        let out = prepare_user_message(&msg).expect("ok");
346        let p = parts(&out);
347        assert_eq!(p.len(), 3);
348        assert!(matches!(&p[0], ContentPart::Text { .. }));
349        assert!(matches!(&p[1], ContentPart::Image { .. }));
350        assert!(matches!(&p[2], ContentPart::Image { .. }));
351    }
352
353    #[test]
354    fn prepare_empty_text_plus_one_image_omits_text_part() {
355        let path = tempfile_with("png", &png_header_bytes());
356        let msg = UserMessage {
357            text: "".into(),
358            attachments: vec![Attachment::Image { path }],
359        };
360        let out = prepare_user_message(&msg).expect("ok");
361        let p = parts(&out);
362        assert_eq!(p.len(), 1, "no leading empty text part");
363        assert!(matches!(&p[0], ContentPart::Image { .. }));
364    }
365
366    #[test]
367    fn prepare_user_message_rejects_missing_path() {
368        let msg = UserMessage {
369            text: "look".into(),
370            attachments: vec![Attachment::Image {
371                path: PathBuf::from("/tmp/definitely-does-not-exist-capo-v06.png"),
372            }],
373        };
374        let err = prepare_user_message(&msg).expect_err("should fail");
375        assert!(matches!(err, AttachmentError::NotFound { .. }));
376        assert_eq!(err.kind(), AttachmentErrorKind::NotFound);
377    }
378
379    #[test]
380    fn prepare_user_message_rejects_unsupported_extension() {
381        let path = tempfile_with("txt", b"hello world");
382        let msg = UserMessage {
383            text: "".into(),
384            attachments: vec![Attachment::Image { path }],
385        };
386        let err = prepare_user_message(&msg).expect_err("should fail");
387        assert!(
388            matches!(err, AttachmentError::UnsupportedExtension { ref ext, .. } if ext == "txt")
389        );
390    }
391
392    #[test]
393    fn prepare_user_message_rejects_oversize_file() {
394        let big = vec![0u8; 5 * 1024 * 1024 + 1]; // 5 MiB + 1 byte
395        let path = tempfile_with("png", &big);
396        let msg = UserMessage {
397            text: "".into(),
398            attachments: vec![Attachment::Image { path }],
399        };
400        let err = prepare_user_message(&msg).expect_err("should fail");
401        assert!(
402            matches!(err, AttachmentError::TooLarge { size, .. } if size == 5 * 1024 * 1024 + 1)
403        );
404    }
405
406    #[test]
407    fn sniff_mime_recognises_png_magic_bytes() {
408        let png_header: [u8; 12] = [
409            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x00,
410        ];
411        let got = sniff_mime(&png_header, "png");
412        assert_eq!(got, Some("image/png"));
413    }
414
415    #[test]
416    fn sniff_mime_recognises_jpeg_magic_bytes() {
417        let mut bytes = [0u8; 12];
418        bytes[..3].copy_from_slice(&[0xFF, 0xD8, 0xFF]);
419        let got = sniff_mime(&bytes, "jpg");
420        assert_eq!(got, Some("image/jpeg"));
421    }
422
423    #[test]
424    fn sniff_mime_recognises_gif_magic_bytes() {
425        // "GIF87a..."
426        let bytes = b"GIF87a\0\0\0\0\0\0";
427        let got = sniff_mime(bytes, "gif");
428        assert_eq!(got, Some("image/gif"));
429    }
430
431    #[test]
432    fn sniff_mime_recognises_webp_magic_bytes() {
433        // "RIFF????WEBP"
434        let bytes = b"RIFF\0\0\0\0WEBP";
435        let got = sniff_mime(bytes, "webp");
436        assert_eq!(got, Some("image/webp"));
437    }
438
439    #[test]
440    fn sniff_mime_falls_back_to_extension_when_magic_inconclusive() {
441        // All zero bytes — no magic matches.
442        let bytes = [0u8; 12];
443        assert_eq!(sniff_mime(&bytes, "png"), Some("image/png"));
444        assert_eq!(sniff_mime(&bytes, "JPG"), Some("image/jpeg"));
445        assert_eq!(sniff_mime(&bytes, "Jpeg"), Some("image/jpeg"));
446        assert_eq!(sniff_mime(&bytes, "gif"), Some("image/gif"));
447        assert_eq!(sniff_mime(&bytes, "webp"), Some("image/webp"));
448    }
449
450    #[test]
451    fn sniff_mime_handles_files_shorter_than_12_bytes_via_extension_only() {
452        let bytes = [0u8; 4];
453        // Magic sniff skipped because slice < 12 bytes; extension lookup wins.
454        assert_eq!(sniff_mime(&bytes, "png"), Some("image/png"));
455    }
456
457    #[test]
458    fn sniff_mime_returns_none_when_both_magic_and_extension_unknown() {
459        let bytes = [0u8; 12];
460        assert_eq!(sniff_mime(&bytes, "txt"), None);
461        assert_eq!(sniff_mime(&[], "bmp"), None);
462    }
463}