motosan_agent_loop/message/
content.rs1use serde::{Deserialize, Serialize};
4
5use crate::message::ToolCallRef;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum ImageSource {
17 Base64 { media_type: String, data: String },
19 Url { url: String },
21}
22
23impl ImageSource {
24 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 pub fn url(url: impl Into<String>) -> Self {
34 Self::Url { url: url.into() }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum DocumentSource {
48 Base64 { media_type: String, data: String },
50 Url { url: String },
52}
53
54impl DocumentSource {
55 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 pub fn url(url: impl Into<String>) -> Self {
65 Self::Url { url: url.into() }
66 }
67
68 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "type", rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum ContentPart {
85 Text { text: String },
87 Image { image: ImageSource },
89 Document { document: DocumentSource },
91}
92
93impl ContentPart {
94 pub fn text(s: impl Into<String>) -> Self {
96 Self::Text { text: s.into() }
97 }
98
99 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 pub fn image_url(url: impl Into<String>) -> Self {
108 Self::Image {
109 image: ImageSource::url(url),
110 }
111 }
112
113 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 pub fn document_url(url: impl Into<String>) -> Self {
122 Self::Document {
123 document: DocumentSource::url(url),
124 }
125 }
126
127 pub fn pdf_base64(data: impl Into<String>) -> Self {
129 Self::Document {
130 document: DocumentSource::pdf_base64(data),
131 }
132 }
133
134 pub fn pdf_url(url: impl Into<String>) -> Self {
136 Self::Document {
137 document: DocumentSource::url(url),
138 }
139 }
140
141 pub fn as_text(&self) -> Option<&str> {
143 match self {
144 ContentPart::Text { text } => Some(text.as_str()),
145 _ => None,
146 }
147 }
148
149 pub fn as_image(&self) -> Option<&ImageSource> {
151 match self {
152 ContentPart::Image { image } => Some(image),
153 _ => None,
154 }
155 }
156
157 pub fn as_document(&self) -> Option<&DocumentSource> {
159 match self {
160 ContentPart::Document { document } => Some(document),
161 _ => None,
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "type", rename_all = "snake_case")]
173#[non_exhaustive]
174pub enum AssistantContent {
175 Text { text: String },
177 ToolCall { call: ToolCallRef },
179 Reasoning {
181 text: String,
182 signature: Option<String>,
183 },
184 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}