Skip to main content

motosan_agent_loop/message/
content.rs

1//! Content-part enums — what's inside a message's `content` field.
2
3use serde::{Deserialize, Serialize};
4
5use crate::message::ToolCallRef;
6
7/// Source for an image content block. Mirrors
8/// [`motosan_ai::types::ImageSource`] 0.15 exactly so the adapter can
9/// translate variant-for-variant.
10///
11/// `#[non_exhaustive]` so future SDK additions (e.g. `FileId` when
12/// upstream gains it) can land without a semver break.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum ImageSource {
17    /// Inline base64-encoded image bytes with explicit mime.
18    Base64 { media_type: String, data: String },
19    /// Remote URL. Provider-dependent whether mime is inferred.
20    Url { url: String },
21}
22
23impl ImageSource {
24    /// Convenience: build a `Base64` source.
25    pub fn base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
26        Self::Base64 {
27            media_type: media_type.into(),
28            data: data.into(),
29        }
30    }
31
32    /// Convenience: build a `Url` source.
33    pub fn url(url: impl Into<String>) -> Self {
34        Self::Url { url: url.into() }
35    }
36}
37
38/// Source for a document content block (PDF and future formats). Mirrors
39/// [`motosan_ai::types::DocumentSource`] 0.15 exactly so the adapter can
40/// translate variant-for-variant.
41///
42/// `#[non_exhaustive]` so future SDK additions can land without a semver break.
43/// Currently PDF is the only provider-supported media type (Anthropic).
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum DocumentSource {
48    /// Inline base64-encoded document bytes with explicit mime.
49    Base64 { media_type: String, data: String },
50    /// Remote URL. Provider-dependent whether mime is inferred.
51    Url { url: String },
52}
53
54impl DocumentSource {
55    /// Convenience: build a `Base64` source.
56    pub fn base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
57        Self::Base64 {
58            media_type: media_type.into(),
59            data: data.into(),
60        }
61    }
62
63    /// Convenience: build a `Url` source.
64    pub fn url(url: impl Into<String>) -> Self {
65        Self::Url { url: url.into() }
66    }
67
68    /// Convenience: build a `Base64` source with `application/pdf` media type.
69    pub fn pdf_base64(data: impl Into<String>) -> Self {
70        Self::Base64 {
71            media_type: "application/pdf".into(),
72            data: data.into(),
73        }
74    }
75}
76
77/// A single part of a user-visible or tool-result content array.
78///
79/// Provider-neutral. The motosan-ai adapter translates these into
80/// `motosan_ai::types::ContentBlock` at dispatch time.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "type", rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum ContentPart {
85    /// Plain UTF-8 text.
86    Text { text: String },
87    /// An image, provided inline or by URL.
88    Image { image: ImageSource },
89    /// A document (e.g. PDF), provided inline or by URL.
90    Document { document: DocumentSource },
91}
92
93impl ContentPart {
94    /// Convenience constructor for the common text case.
95    pub fn text(s: impl Into<String>) -> Self {
96        Self::Text { text: s.into() }
97    }
98
99    /// Convenience constructor for a base64-encoded image.
100    pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
101        Self::Image {
102            image: ImageSource::base64(media_type, data),
103        }
104    }
105
106    /// Convenience constructor for a URL image.
107    pub fn image_url(url: impl Into<String>) -> Self {
108        Self::Image {
109            image: ImageSource::url(url),
110        }
111    }
112
113    /// Convenience constructor for a base64-encoded document.
114    pub fn document_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
115        Self::Document {
116            document: DocumentSource::base64(media_type, data),
117        }
118    }
119
120    /// Convenience constructor for a URL document.
121    pub fn document_url(url: impl Into<String>) -> Self {
122        Self::Document {
123            document: DocumentSource::url(url),
124        }
125    }
126
127    /// Convenience constructor for a base64-encoded PDF (media type fixed to `application/pdf`).
128    pub fn pdf_base64(data: impl Into<String>) -> Self {
129        Self::Document {
130            document: DocumentSource::pdf_base64(data),
131        }
132    }
133
134    /// Convenience constructor for a PDF URL.
135    pub fn pdf_url(url: impl Into<String>) -> Self {
136        Self::Document {
137            document: DocumentSource::url(url),
138        }
139    }
140
141    /// If this part is a `Text`, return its string. Non-text variants return `None`.
142    pub fn as_text(&self) -> Option<&str> {
143        match self {
144            ContentPart::Text { text } => Some(text.as_str()),
145            _ => None,
146        }
147    }
148
149    /// If this part is an `Image`, return its source.
150    pub fn as_image(&self) -> Option<&ImageSource> {
151        match self {
152            ContentPart::Image { image } => Some(image),
153            _ => None,
154        }
155    }
156
157    /// If this part is a `Document`, return its source.
158    pub fn as_document(&self) -> Option<&DocumentSource> {
159        match self {
160            ContentPart::Document { document } => Some(document),
161            _ => None,
162        }
163    }
164}
165
166/// A single part of an assistant-produced content array.
167///
168/// Mirrors Claude's wire format: text / tool_use / thinking / compaction
169/// can appear in any order in a single assistant turn. `#[non_exhaustive]`
170/// so provider-new block types can be added without semver break.
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "type", rename_all = "snake_case")]
173#[non_exhaustive]
174pub enum AssistantContent {
175    /// Plain text produced by the assistant.
176    Text { text: String },
177    /// A tool invocation requested by the assistant.
178    ToolCall { call: ToolCallRef },
179    /// Reasoning / chain-of-thought block.
180    Reasoning {
181        text: String,
182        signature: Option<String>,
183    },
184    /// Server-side compaction block.
185    Compaction { content: String },
186}
187
188impl AssistantContent {
189    pub fn text(s: impl Into<String>) -> Self {
190        Self::Text { text: s.into() }
191    }
192
193    pub fn tool_call(call: ToolCallRef) -> Self {
194        Self::ToolCall { call }
195    }
196
197    pub fn as_text(&self) -> Option<&str> {
198        match self {
199            AssistantContent::Text { text } => Some(text.as_str()),
200            _ => None,
201        }
202    }
203
204    pub fn as_tool_call(&self) -> Option<&ToolCallRef> {
205        match self {
206            AssistantContent::ToolCall { call } => Some(call),
207            _ => None,
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn image_source_base64_serialises_tagged() {
218        let src = ImageSource::Base64 {
219            media_type: "image/png".into(),
220            data: "aGVsbG8=".into(),
221        };
222        let s = serde_json::to_string(&src).unwrap();
223        assert!(s.contains("\"type\":\"base64\""), "shape: {s}");
224        assert!(s.contains("\"media_type\":\"image/png\""));
225        assert!(s.contains("\"data\":\"aGVsbG8=\""));
226        let back: ImageSource = serde_json::from_str(&s).unwrap();
227        assert_eq!(back, src);
228    }
229
230    #[test]
231    fn image_source_url_serialises_tagged() {
232        let src = ImageSource::Url {
233            url: "https://example.com/cat.png".into(),
234        };
235        let s = serde_json::to_string(&src).unwrap();
236        assert!(s.contains("\"type\":\"url\""), "shape: {s}");
237        assert!(s.contains("\"url\":\"https://example.com/cat.png\""));
238        let back: ImageSource = serde_json::from_str(&s).unwrap();
239        assert_eq!(back, src);
240    }
241
242    #[test]
243    fn document_source_base64_serialises_tagged() {
244        let src = DocumentSource::Base64 {
245            media_type: "application/pdf".into(),
246            data: "JVBERi0=".into(),
247        };
248        let s = serde_json::to_string(&src).unwrap();
249        assert!(s.contains("\"type\":\"base64\""), "shape: {s}");
250        assert!(s.contains("\"media_type\":\"application/pdf\""));
251        assert!(s.contains("\"data\":\"JVBERi0=\""));
252        let back: DocumentSource = serde_json::from_str(&s).unwrap();
253        assert_eq!(back, src);
254    }
255
256    #[test]
257    fn document_source_url_serialises_tagged() {
258        let src = DocumentSource::Url {
259            url: "https://example.com/spec.pdf".into(),
260        };
261        let s = serde_json::to_string(&src).unwrap();
262        assert!(s.contains("\"type\":\"url\""), "shape: {s}");
263        assert!(s.contains("\"url\":\"https://example.com/spec.pdf\""));
264        let back: DocumentSource = serde_json::from_str(&s).unwrap();
265        assert_eq!(back, src);
266    }
267
268    #[test]
269    fn content_part_text_constructor_and_accessor() {
270        let p = ContentPart::text("hi");
271        assert_eq!(p.as_text(), Some("hi"));
272    }
273
274    #[test]
275    fn content_part_image_helpers() {
276        let p = ContentPart::image_base64("image/jpeg", "AAAA");
277        match &p {
278            ContentPart::Image {
279                image: ImageSource::Base64 { media_type, data },
280            } => {
281                assert_eq!(media_type, "image/jpeg");
282                assert_eq!(data, "AAAA");
283            }
284            _ => panic!("expected Image/Base64, got: {:?}", p),
285        }
286        assert!(p.as_text().is_none());
287
288        let u = ContentPart::image_url("https://cdn/x.png");
289        match &u {
290            ContentPart::Image {
291                image: ImageSource::Url { url },
292            } => {
293                assert_eq!(url, "https://cdn/x.png");
294            }
295            _ => panic!("expected Image/Url, got: {:?}", u),
296        }
297    }
298
299    #[test]
300    fn content_part_serialises_with_tag() {
301        let p = ContentPart::text("hi");
302        let s = serde_json::to_string(&p).unwrap();
303        assert!(s.contains("\"type\":\"text\""), "expected tagged repr: {s}");
304        assert!(s.contains("\"text\":\"hi\""));
305        let back: ContentPart = serde_json::from_str(&s).unwrap();
306        assert_eq!(back, p);
307    }
308
309    #[test]
310    fn content_part_image_serialises_tagged() {
311        let p = ContentPart::image_base64("image/png", "aGVsbG8=");
312        let s = serde_json::to_string(&p).unwrap();
313        assert!(s.contains("\"type\":\"image\""), "shape: {s}");
314        assert!(s.contains("\"image\""));
315        let back: ContentPart = serde_json::from_str(&s).unwrap();
316        assert_eq!(back, p);
317    }
318
319    #[test]
320    fn content_part_image_as_image() {
321        let p = ContentPart::image_url("https://x/y.png");
322        assert!(matches!(p.as_image(), Some(ImageSource::Url { .. })));
323        let t = ContentPart::text("hi");
324        assert!(t.as_image().is_none());
325    }
326
327    #[test]
328    fn content_part_document_helpers() {
329        let p = ContentPart::document_base64("application/pdf", "JVBERi0=");
330        match &p {
331            ContentPart::Document {
332                document: DocumentSource::Base64 { media_type, data },
333            } => {
334                assert_eq!(media_type, "application/pdf");
335                assert_eq!(data, "JVBERi0=");
336            }
337            _ => panic!("expected Document/Base64, got: {:?}", p),
338        }
339        assert!(p.as_text().is_none());
340        assert!(p.as_image().is_none());
341
342        let u = ContentPart::document_url("https://cdn/report.pdf");
343        match &u {
344            ContentPart::Document {
345                document: DocumentSource::Url { url },
346            } => assert_eq!(url, "https://cdn/report.pdf"),
347            _ => panic!("expected Document/Url, got: {:?}", u),
348        }
349
350        let pdf = ContentPart::pdf_base64("JVBERi0=");
351        match &pdf {
352            ContentPart::Document {
353                document: DocumentSource::Base64 { media_type, data },
354            } => {
355                assert_eq!(media_type, "application/pdf");
356                assert_eq!(data, "JVBERi0=");
357            }
358            _ => panic!("expected Document/Base64(application/pdf)"),
359        }
360
361        let pdf_url = ContentPart::pdf_url("https://cdn/doc.pdf");
362        assert!(matches!(
363            pdf_url,
364            ContentPart::Document {
365                document: DocumentSource::Url { .. }
366            }
367        ));
368    }
369
370    #[test]
371    fn content_part_document_serialises_tagged() {
372        let p = ContentPart::document_base64("application/pdf", "JVBERi0=");
373        let s = serde_json::to_string(&p).unwrap();
374        assert!(s.contains("\"type\":\"document\""), "shape: {s}");
375        assert!(s.contains("\"document\""));
376        let back: ContentPart = serde_json::from_str(&s).unwrap();
377        assert_eq!(back, p);
378    }
379
380    #[test]
381    fn content_part_document_as_document() {
382        let p = ContentPart::document_url("https://cdn/x.pdf");
383        assert!(matches!(p.as_document(), Some(DocumentSource::Url { .. })));
384        let t = ContentPart::text("hi");
385        assert!(t.as_document().is_none());
386        let i = ContentPart::image_url("https://cdn/x.png");
387        assert!(i.as_document().is_none());
388    }
389
390    #[test]
391    fn assistant_content_text() {
392        let c = AssistantContent::text("hello");
393        assert_eq!(c.as_text(), Some("hello"));
394        assert!(c.as_tool_call().is_none());
395    }
396
397    #[test]
398    fn assistant_content_tool_call() {
399        let call = ToolCallRef {
400            id: "c1".into(),
401            name: "search".into(),
402            args: serde_json::json!({"q": "rust"}),
403        };
404        let c = AssistantContent::tool_call(call.clone());
405        assert_eq!(c.as_tool_call().map(|r| r.name.as_str()), Some("search"));
406        assert!(c.as_text().is_none());
407    }
408
409    #[test]
410    fn assistant_content_reasoning_round_trips() {
411        let c = AssistantContent::Reasoning {
412            text: "thought".into(),
413            signature: Some("sig".into()),
414        };
415        let s = serde_json::to_string(&c).unwrap();
416        let back: AssistantContent = serde_json::from_str(&s).unwrap();
417        assert_eq!(back, c);
418    }
419
420    #[test]
421    fn assistant_content_compaction_round_trips() {
422        let c = AssistantContent::Compaction {
423            content: "summary".into(),
424        };
425        let s = serde_json::to_string(&c).unwrap();
426        let back: AssistantContent = serde_json::from_str(&s).unwrap();
427        assert_eq!(back, c);
428    }
429}