Skip to main content

motosan_agent_loop/message/
mod.rs

1//! Conversation messages — the unit exchanged between the agent loop and the LLM.
2
3pub mod content;
4pub mod id;
5pub mod meta;
6pub mod turn_boundary;
7
8pub use content::{AssistantContent, ContentPart, DocumentSource, ImageSource};
9pub use id::{new_message_id, MessageId};
10pub use meta::MessageMeta;
11pub use turn_boundary::{find_cut_points, is_safe_cut};
12
13use serde::{Deserialize, Serialize};
14
15/// Approximate per-image character cost for token estimators.
16///
17/// An Anthropic image costs roughly 1600 tokens at worst; at the project's
18/// `len()/4` heuristic that is ~6400 equivalent characters. This lets
19/// token-budget policies reason about image-bearing history without
20/// counting the raw base64 payload (which can be megabytes).
21///
22/// Phase 3 replaces this heuristic with policy-driven estimation.
23pub const IMAGE_APPROX_CHAR_EQUIVALENT: usize = 6400;
24
25/// Approximate per-document character cost for token estimators.
26///
27/// A Claude-ingested PDF typically costs roughly 4000 tokens (around ten
28/// pages). At the project's `len()/4` heuristic that is ~16,000 equivalent
29/// characters. Larger than the per-image constant because documents are
30/// multi-page by default.
31///
32/// Phase 5+ replaces this heuristic with policy-driven estimation.
33pub const DOCUMENT_APPROX_CHAR_EQUIVALENT: usize = 16_000;
34
35/// The role of a message participant. Kept as a lightweight enum so
36/// downstream code can keep writing `msg.role() == Role::User`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub enum Role {
39    System,
40    User,
41    Assistant,
42    Tool,
43}
44
45/// A reference to a tool call requested by the assistant.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct ToolCallRef {
48    pub id: String,
49    pub name: String,
50    pub args: serde_json::Value,
51}
52
53/// A single message in the conversation history.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(tag = "role", rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum Message {
58    System {
59        #[serde(default = "new_message_id")]
60        id: MessageId,
61        #[serde(default)]
62        meta: MessageMeta,
63        content: String,
64    },
65    User {
66        #[serde(default = "new_message_id")]
67        id: MessageId,
68        #[serde(default)]
69        meta: MessageMeta,
70        content: Vec<ContentPart>,
71    },
72    Assistant {
73        #[serde(default = "new_message_id")]
74        id: MessageId,
75        #[serde(default)]
76        meta: MessageMeta,
77        content: Vec<AssistantContent>,
78    },
79    Tool {
80        #[serde(default = "new_message_id")]
81        id: MessageId,
82        #[serde(default)]
83        meta: MessageMeta,
84        tool_call_id: String,
85        content: Vec<ContentPart>,
86    },
87}
88
89impl Message {
90    pub fn system(content: &str) -> Self {
91        Self::System {
92            id: new_message_id(),
93            meta: MessageMeta::default(),
94            content: content.to_string(),
95        }
96    }
97
98    pub fn user(content: &str) -> Self {
99        Self::User {
100            id: new_message_id(),
101            meta: MessageMeta::default(),
102            content: vec![ContentPart::text(content)],
103        }
104    }
105
106    pub fn assistant(content: &str) -> Self {
107        Self::Assistant {
108            id: new_message_id(),
109            meta: MessageMeta::default(),
110            content: if content.is_empty() {
111                Vec::new()
112            } else {
113                vec![AssistantContent::text(content)]
114            },
115        }
116    }
117
118    pub fn assistant_with_tool_call(content: &str, call: ToolCallRef) -> Self {
119        let mut parts: Vec<AssistantContent> = Vec::new();
120        if !content.is_empty() {
121            parts.push(AssistantContent::text(content));
122        }
123        parts.push(AssistantContent::tool_call(call));
124        Self::Assistant {
125            id: new_message_id(),
126            meta: MessageMeta::default(),
127            content: parts,
128        }
129    }
130
131    pub fn assistant_with_tool_calls(content: &str, calls: Vec<ToolCallRef>) -> Self {
132        let mut parts: Vec<AssistantContent> = Vec::new();
133        if !content.is_empty() {
134            parts.push(AssistantContent::text(content));
135        }
136        for c in calls {
137            parts.push(AssistantContent::tool_call(c));
138        }
139        Self::Assistant {
140            id: new_message_id(),
141            meta: MessageMeta::default(),
142            content: parts,
143        }
144    }
145
146    pub fn tool_result(id: impl Into<String>, content: &str) -> Self {
147        Self::Tool {
148            id: new_message_id(),
149            meta: MessageMeta::default(),
150            tool_call_id: id.into(),
151            content: vec![ContentPart::text(content)],
152        }
153    }
154
155    /// Construct a User message from an arbitrary list of content parts.
156    /// Order is preserved on the wire.
157    pub fn user_with_parts(parts: Vec<ContentPart>) -> Self {
158        Self::User {
159            id: new_message_id(),
160            meta: MessageMeta::default(),
161            content: parts,
162        }
163    }
164
165    /// Construct a Tool-result message from an arbitrary list of content parts.
166    pub fn tool_result_with_parts(
167        tool_call_id: impl Into<String>,
168        parts: Vec<ContentPart>,
169    ) -> Self {
170        Self::Tool {
171            id: new_message_id(),
172            meta: MessageMeta::default(),
173            tool_call_id: tool_call_id.into(),
174            content: parts,
175        }
176    }
177
178    /// Convenience: User message with a text prompt followed by a base64 image.
179    /// If `prompt` is empty, the text part is omitted.
180    pub fn user_with_image_base64(
181        prompt: &str,
182        media_type: impl Into<String>,
183        data: impl Into<String>,
184    ) -> Self {
185        let mut parts: Vec<ContentPart> = Vec::new();
186        if !prompt.is_empty() {
187            parts.push(ContentPart::text(prompt));
188        }
189        parts.push(ContentPart::image_base64(media_type, data));
190        Self::user_with_parts(parts)
191    }
192
193    /// Convenience: User message with a text prompt followed by a URL image.
194    /// If `prompt` is empty, the text part is omitted.
195    pub fn user_with_image_url(prompt: &str, url: impl Into<String>) -> Self {
196        let mut parts: Vec<ContentPart> = Vec::new();
197        if !prompt.is_empty() {
198            parts.push(ContentPart::text(prompt));
199        }
200        parts.push(ContentPart::image_url(url));
201        Self::user_with_parts(parts)
202    }
203
204    /// Convenience: Tool result with a leading text followed by a base64 image.
205    /// If `text` is empty, the text part is omitted.
206    pub fn tool_result_with_image_base64(
207        tool_call_id: impl Into<String>,
208        text: &str,
209        media_type: impl Into<String>,
210        data: impl Into<String>,
211    ) -> Self {
212        let mut parts: Vec<ContentPart> = Vec::new();
213        if !text.is_empty() {
214            parts.push(ContentPart::text(text));
215        }
216        parts.push(ContentPart::image_base64(media_type, data));
217        Self::tool_result_with_parts(tool_call_id, parts)
218    }
219
220    /// Convenience: Tool result with a leading text followed by a URL image.
221    /// If `text` is empty, the text part is omitted.
222    pub fn tool_result_with_image_url(
223        tool_call_id: impl Into<String>,
224        text: &str,
225        url: impl Into<String>,
226    ) -> Self {
227        let mut parts: Vec<ContentPart> = Vec::new();
228        if !text.is_empty() {
229            parts.push(ContentPart::text(text));
230        }
231        parts.push(ContentPart::image_url(url));
232        Self::tool_result_with_parts(tool_call_id, parts)
233    }
234
235    /// Convenience: User message with a text prompt followed by a base64 document.
236    /// If `prompt` is empty, the text part is omitted.
237    pub fn user_with_document_base64(
238        prompt: &str,
239        media_type: impl Into<String>,
240        data: impl Into<String>,
241    ) -> Self {
242        let mut parts: Vec<ContentPart> = Vec::new();
243        if !prompt.is_empty() {
244            parts.push(ContentPart::text(prompt));
245        }
246        parts.push(ContentPart::document_base64(media_type, data));
247        Self::user_with_parts(parts)
248    }
249
250    /// Convenience: User message with a text prompt followed by a URL document.
251    /// If `prompt` is empty, the text part is omitted.
252    pub fn user_with_document_url(prompt: &str, url: impl Into<String>) -> Self {
253        let mut parts: Vec<ContentPart> = Vec::new();
254        if !prompt.is_empty() {
255            parts.push(ContentPart::text(prompt));
256        }
257        parts.push(ContentPart::document_url(url));
258        Self::user_with_parts(parts)
259    }
260
261    /// Hardcodes `application/pdf` as the media type. If `prompt` is empty, the
262    /// text part is omitted.
263    pub fn user_with_pdf_base64(prompt: &str, data: impl Into<String>) -> Self {
264        Self::user_with_document_base64(prompt, "application/pdf", data)
265    }
266
267    /// Convenience: User message with a text prompt followed by a PDF URL.
268    /// If `prompt` is empty, the text part is omitted.
269    pub fn user_with_pdf_url(prompt: &str, url: impl Into<String>) -> Self {
270        Self::user_with_document_url(prompt, url)
271    }
272
273    pub fn role(&self) -> Role {
274        match self {
275            Message::System { .. } => Role::System,
276            Message::User { .. } => Role::User,
277            Message::Assistant { .. } => Role::Assistant,
278            Message::Tool { .. } => Role::Tool,
279        }
280    }
281
282    pub fn id(&self) -> &MessageId {
283        match self {
284            Message::System { id, .. }
285            | Message::User { id, .. }
286            | Message::Assistant { id, .. }
287            | Message::Tool { id, .. } => id,
288        }
289    }
290
291    pub fn meta(&self) -> &MessageMeta {
292        match self {
293            Message::System { meta, .. }
294            | Message::User { meta, .. }
295            | Message::Assistant { meta, .. }
296            | Message::Tool { meta, .. } => meta,
297        }
298    }
299
300    pub fn meta_mut(&mut self) -> &mut MessageMeta {
301        match self {
302            Message::System { meta, .. }
303            | Message::User { meta, .. }
304            | Message::Assistant { meta, .. }
305            | Message::Tool { meta, .. } => meta,
306        }
307    }
308
309    /// All text content concatenated. Non-text parts are skipped.
310    pub fn text(&self) -> String {
311        match self {
312            Message::System { content, .. } => content.clone(),
313            Message::User { content, .. } | Message::Tool { content, .. } => content
314                .iter()
315                .filter_map(|p| p.as_text())
316                .collect::<Vec<_>>()
317                .join(""),
318            Message::Assistant { content, .. } => content
319                .iter()
320                .filter_map(|c| c.as_text())
321                .collect::<Vec<_>>()
322                .join(""),
323        }
324    }
325
326    /// Approximate number of prompt-visible characters for token estimation.
327    ///
328    /// Includes all currently-known assistant block types so estimators do not
329    /// undercount when non-`Text` blocks (e.g. `Reasoning`, `Compaction`) are
330    /// present. Unknown future block variants fall back to serialized length.
331    pub fn approx_visible_chars(&self) -> usize {
332        match self {
333            Message::System { content, .. } => content.len(),
334            Message::User { content, .. } | Message::Tool { content, .. } => content
335                .iter()
336                .map(|part| match part {
337                    ContentPart::Text { text } => text.len(),
338                    ContentPart::Image { .. } => IMAGE_APPROX_CHAR_EQUIVALENT,
339                    ContentPart::Document { .. } => DOCUMENT_APPROX_CHAR_EQUIVALENT,
340                    #[allow(unreachable_patterns)]
341                    _ => serde_json::to_string(part).map(|s| s.len()).unwrap_or(0),
342                })
343                .sum(),
344            Message::Assistant { content, .. } => content
345                .iter()
346                .map(|part| match part {
347                    AssistantContent::Text { text } => text.len(),
348                    AssistantContent::ToolCall { call } => {
349                        call.name.len() + call.args.to_string().len()
350                    }
351                    AssistantContent::Reasoning { text, signature } => {
352                        text.len() + signature.as_ref().map(|s| s.len()).unwrap_or(0)
353                    }
354                    AssistantContent::Compaction { content } => content.len(),
355                    #[allow(unreachable_patterns)]
356                    _ => serde_json::to_string(part).map(|s| s.len()).unwrap_or(0),
357                })
358                .sum(),
359        }
360    }
361
362    /// Tool call references on an assistant message; empty for other roles.
363    pub fn tool_calls(&self) -> Vec<&ToolCallRef> {
364        match self {
365            Message::Assistant { content, .. } => {
366                content.iter().filter_map(|c| c.as_tool_call()).collect()
367            }
368            _ => Vec::new(),
369        }
370    }
371
372    /// For `Tool` role messages, the id of the tool call this is a result for.
373    pub fn tool_call_id(&self) -> Option<&str> {
374        match self {
375            Message::Tool { tool_call_id, .. } => Some(tool_call_id.as_str()),
376            _ => None,
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn system_message() {
387        let msg = Message::system("You are helpful.");
388        assert_eq!(msg.role(), Role::System);
389        assert_eq!(msg.text(), "You are helpful.");
390        assert!(msg.tool_call_id().is_none());
391        assert!(msg.tool_calls().is_empty());
392    }
393
394    #[test]
395    fn user_message() {
396        let msg = Message::user("Hello");
397        assert_eq!(msg.role(), Role::User);
398        assert_eq!(msg.text(), "Hello");
399        assert!(msg.tool_call_id().is_none());
400        assert!(msg.tool_calls().is_empty());
401    }
402
403    #[test]
404    fn assistant_message() {
405        let msg = Message::assistant("Hi there");
406        assert_eq!(msg.role(), Role::Assistant);
407        assert_eq!(msg.text(), "Hi there");
408        assert!(msg.tool_call_id().is_none());
409        assert!(msg.tool_calls().is_empty());
410    }
411
412    #[test]
413    fn assistant_with_tool_call_message() {
414        let call = ToolCallRef {
415            id: "call_abc".to_string(),
416            name: "search".to_string(),
417            args: serde_json::json!({"q": "rust"}),
418        };
419        let msg = Message::assistant_with_tool_call("Let me search that.", call);
420        assert_eq!(msg.role(), Role::Assistant);
421        assert_eq!(msg.text(), "Let me search that.");
422        assert!(msg.tool_call_id().is_none());
423        let tcs = msg.tool_calls();
424        assert_eq!(tcs.len(), 1);
425        assert_eq!(tcs[0].id, "call_abc");
426        assert_eq!(tcs[0].name, "search");
427        assert_eq!(tcs[0].args, serde_json::json!({"q": "rust"}));
428    }
429
430    #[test]
431    fn assistant_with_empty_text_and_tool_call_omits_text_block() {
432        let call = ToolCallRef {
433            id: "c".into(),
434            name: "t".into(),
435            args: serde_json::json!({}),
436        };
437        let msg = Message::assistant_with_tool_call("", call);
438        if let Message::Assistant { content, .. } = &msg {
439            assert_eq!(
440                content.len(),
441                1,
442                "expected only ToolCall, got: {:?}",
443                content
444            );
445            assert!(matches!(content[0], AssistantContent::ToolCall { .. }));
446        } else {
447            panic!("expected Assistant variant");
448        }
449    }
450
451    #[test]
452    fn tool_result_message() {
453        let msg = Message::tool_result("call_abc", "search result here");
454        assert_eq!(msg.role(), Role::Tool);
455        assert_eq!(msg.text(), "search result here");
456        assert_eq!(msg.tool_call_id(), Some("call_abc"));
457        assert!(msg.tool_calls().is_empty());
458    }
459
460    #[test]
461    fn tool_result_accepts_string() {
462        let id = String::from("call_xyz");
463        let msg = Message::tool_result(id, "output");
464        assert_eq!(msg.tool_call_id(), Some("call_xyz"));
465    }
466
467    #[test]
468    fn each_new_message_has_unique_id() {
469        let a = Message::user("a");
470        let b = Message::user("b");
471        assert_ne!(a.id(), b.id());
472    }
473
474    #[test]
475    fn meta_mut_lets_extensions_write() {
476        let mut msg = Message::user("hi");
477        msg.meta_mut().tags.push("reviewed".into());
478        assert_eq!(msg.meta().tags, vec!["reviewed".to_string()]);
479    }
480
481    #[test]
482    fn system_round_trips_json() {
483        let m = Message::system("sys");
484        let s = serde_json::to_string(&m).unwrap();
485        assert!(s.contains("\"role\":\"system\""), "shape: {s}");
486        let back: Message = serde_json::from_str(&s).unwrap();
487        assert_eq!(back.role(), Role::System);
488        assert_eq!(back.text(), "sys");
489    }
490
491    #[test]
492    fn user_round_trips_json() {
493        let m = Message::user("hi");
494        let s = serde_json::to_string(&m).unwrap();
495        let back: Message = serde_json::from_str(&s).unwrap();
496        assert_eq!(back.role(), Role::User);
497        assert_eq!(back.text(), "hi");
498    }
499
500    #[test]
501    fn assistant_with_tool_call_round_trips_json() {
502        let call = ToolCallRef {
503            id: "c1".into(),
504            name: "n".into(),
505            args: serde_json::json!({"k": "v"}),
506        };
507        let m = Message::assistant_with_tool_call("thinking…", call);
508        let s = serde_json::to_string(&m).unwrap();
509        let back: Message = serde_json::from_str(&s).unwrap();
510        assert_eq!(back.text(), "thinking…");
511        assert_eq!(back.tool_calls().len(), 1);
512        assert_eq!(back.tool_calls()[0].name, "n");
513    }
514
515    #[test]
516    fn tool_round_trips_json() {
517        let m = Message::tool_result("c1", "out");
518        let s = serde_json::to_string(&m).unwrap();
519        let back: Message = serde_json::from_str(&s).unwrap();
520        assert_eq!(back.role(), Role::Tool);
521        assert_eq!(back.tool_call_id(), Some("c1"));
522        assert_eq!(back.text(), "out");
523    }
524
525    #[test]
526    fn approx_visible_chars_counts_non_text_assistant_blocks() {
527        let m = Message::Assistant {
528            id: new_message_id(),
529            meta: MessageMeta::default(),
530            content: vec![
531                AssistantContent::Reasoning {
532                    text: "abcd".into(),
533                    signature: Some("sig".into()),
534                },
535                AssistantContent::Compaction {
536                    content: "summary".into(),
537                },
538            ],
539        };
540        assert_eq!(m.text(), "");
541        assert!(m.approx_visible_chars() >= 14);
542    }
543
544    #[test]
545    fn approx_visible_chars_uses_fixed_cost_for_images() {
546        let huge_b64 = "A".repeat(500_000);
547        let m = Message::User {
548            id: new_message_id(),
549            meta: MessageMeta::default(),
550            content: vec![
551                ContentPart::text("look at this"),
552                ContentPart::image_base64("image/png", huge_b64),
553            ],
554        };
555        let est = m.approx_visible_chars();
556        assert!(
557            est < 100_000,
558            "image char cost should be fixed, got {est} for 500KB payload"
559        );
560        assert!(
561            est >= 12 + crate::message::IMAGE_APPROX_CHAR_EQUIVALENT,
562            "text + image constant floor not met, got {est}"
563        );
564    }
565
566    #[test]
567    fn approx_visible_chars_tool_image_counted() {
568        let huge_b64 = "B".repeat(200_000);
569        let m = Message::Tool {
570            id: new_message_id(),
571            meta: MessageMeta::default(),
572            tool_call_id: "call_1".into(),
573            content: vec![ContentPart::image_base64("image/png", huge_b64)],
574        };
575        let est = m.approx_visible_chars();
576        assert!(est < 50_000, "tool image fixed cost, got {est}");
577        assert!(est >= crate::message::IMAGE_APPROX_CHAR_EQUIVALENT);
578    }
579
580    #[test]
581    fn approx_visible_chars_uses_fixed_cost_for_documents() {
582        let huge_b64 = "A".repeat(2_000_000);
583        let m = Message::User {
584            id: new_message_id(),
585            meta: MessageMeta::default(),
586            content: vec![
587                ContentPart::text("summarize me"),
588                ContentPart::document_base64("application/pdf", huge_b64),
589            ],
590        };
591        let est = m.approx_visible_chars();
592        assert!(
593            est < 100_000,
594            "document char cost should be fixed, got {est} for multi-MB payload"
595        );
596        assert!(
597            est >= 12 + crate::message::DOCUMENT_APPROX_CHAR_EQUIVALENT,
598            "text + document constant floor not met, got {est}"
599        );
600    }
601
602    #[test]
603    fn approx_visible_chars_tool_document_counted() {
604        let huge_b64 = "C".repeat(300_000);
605        let m = Message::Tool {
606            id: new_message_id(),
607            meta: MessageMeta::default(),
608            tool_call_id: "call_doc".into(),
609            content: vec![ContentPart::document_base64("application/pdf", huge_b64)],
610        };
611        let est = m.approx_visible_chars();
612        assert!(est < 100_000, "tool document fixed cost, got {est}");
613        assert!(est >= crate::message::DOCUMENT_APPROX_CHAR_EQUIVALENT);
614    }
615
616    #[test]
617    fn user_with_parts_preserves_order() {
618        let parts = vec![
619            ContentPart::text("before"),
620            ContentPart::image_url("https://x/1.png"),
621            ContentPart::text("after"),
622        ];
623        let m = Message::user_with_parts(parts);
624        assert_eq!(m.role(), Role::User);
625        if let Message::User { content, .. } = &m {
626            assert_eq!(content.len(), 3);
627            assert!(matches!(content[0], ContentPart::Text { .. }));
628            assert!(matches!(content[1], ContentPart::Image { .. }));
629            assert!(matches!(content[2], ContentPart::Text { .. }));
630        } else {
631            panic!("expected User variant");
632        }
633        assert_eq!(m.text(), "beforeafter");
634    }
635
636    #[test]
637    fn tool_result_with_parts_preserves_call_id_and_order() {
638        let parts = vec![
639            ContentPart::text("result:"),
640            ContentPart::image_base64("image/png", "AAAA"),
641        ];
642        let m = Message::tool_result_with_parts("call_42", parts);
643        assert_eq!(m.role(), Role::Tool);
644        assert_eq!(m.tool_call_id(), Some("call_42"));
645        if let Message::Tool { content, .. } = &m {
646            assert_eq!(content.len(), 2);
647        } else {
648            panic!("expected Tool variant");
649        }
650        assert_eq!(m.text(), "result:");
651    }
652
653    #[test]
654    fn user_with_image_base64_convenience() {
655        let m = Message::user_with_image_base64("what is this?", "image/png", "AAAA");
656        assert_eq!(m.role(), Role::User);
657        if let Message::User { content, .. } = &m {
658            assert_eq!(content.len(), 2);
659            assert!(matches!(content[0], ContentPart::Text { .. }));
660            assert!(matches!(content[1], ContentPart::Image { .. }));
661        } else {
662            panic!("expected User");
663        }
664    }
665
666    #[test]
667    fn user_with_image_url_convenience() {
668        let m = Message::user_with_image_url("caption", "https://cdn/x.jpg");
669        if let Message::User { content, .. } = &m {
670            assert!(matches!(
671                &content[1],
672                ContentPart::Image {
673                    image: ImageSource::Url { .. }
674                }
675            ));
676        }
677    }
678
679    #[test]
680    fn user_with_image_base64_empty_prompt_omits_text_part() {
681        let m = Message::user_with_image_base64("", "image/png", "AAAA");
682        if let Message::User { content, .. } = &m {
683            assert_eq!(content.len(), 1, "empty prompt should yield image-only");
684            assert!(matches!(content[0], ContentPart::Image { .. }));
685        } else {
686            panic!("expected User");
687        }
688    }
689
690    #[test]
691    fn user_with_image_round_trips_json() {
692        let m = Message::user_with_image_base64("describe", "image/png", "aGVsbG8=");
693        let s = serde_json::to_string(&m).unwrap();
694        assert!(s.contains("\"role\":\"user\""), "shape: {s}");
695        assert!(s.contains("\"type\":\"image\""));
696        assert!(s.contains("\"media_type\":\"image/png\""));
697        let back: Message = serde_json::from_str(&s).unwrap();
698        assert_eq!(back.role(), Role::User);
699        assert_eq!(back.text(), "describe");
700        if let Message::User { content, .. } = back {
701            assert_eq!(content.len(), 2);
702            assert!(matches!(&content[1], ContentPart::Image { .. }));
703        } else {
704            panic!("expected User");
705        }
706    }
707
708    #[test]
709    fn tool_with_image_round_trips_json() {
710        let m = Message::tool_result_with_parts(
711            "call_9",
712            vec![
713                ContentPart::text("screenshot:"),
714                ContentPart::image_url("https://cdn/shot.png"),
715            ],
716        );
717        let s = serde_json::to_string(&m).unwrap();
718        let back: Message = serde_json::from_str(&s).unwrap();
719        assert_eq!(back.role(), Role::Tool);
720        assert_eq!(back.tool_call_id(), Some("call_9"));
721        assert_eq!(back.text(), "screenshot:");
722    }
723
724    #[test]
725    fn user_with_document_base64_convenience() {
726        let m = Message::user_with_document_base64(
727            "summarize this spec:",
728            "application/pdf",
729            "JVBERi0=",
730        );
731        assert_eq!(m.role(), Role::User);
732        if let Message::User { content, .. } = &m {
733            assert_eq!(content.len(), 2);
734            assert!(matches!(content[0], ContentPart::Text { .. }));
735            assert!(matches!(content[1], ContentPart::Document { .. }));
736        } else {
737            panic!("expected User");
738        }
739    }
740
741    #[test]
742    fn user_with_document_url_convenience() {
743        let m = Message::user_with_document_url("read:", "https://cdn/spec.pdf");
744        if let Message::User { content, .. } = &m {
745            assert!(matches!(
746                &content[1],
747                ContentPart::Document {
748                    document: DocumentSource::Url { .. }
749                }
750            ));
751        }
752    }
753
754    #[test]
755    fn user_with_pdf_base64_hardcodes_media_type() {
756        let m = Message::user_with_pdf_base64("", "JVBERi0=");
757        if let Message::User { content, .. } = &m {
758            assert_eq!(content.len(), 1, "empty prompt should yield document-only");
759            match &content[0] {
760                ContentPart::Document {
761                    document: DocumentSource::Base64 { media_type, .. },
762                } => assert_eq!(media_type, "application/pdf"),
763                other => panic!("expected PDF base64 document, got {other:?}"),
764            }
765        } else {
766            panic!("expected User");
767        }
768    }
769
770    #[test]
771    fn user_with_pdf_url_convenience() {
772        let m = Message::user_with_pdf_url("check", "https://cdn/doc.pdf");
773        if let Message::User { content, .. } = &m {
774            assert!(matches!(
775                &content[1],
776                ContentPart::Document {
777                    document: DocumentSource::Url { .. }
778                }
779            ));
780        }
781    }
782
783    #[test]
784    fn user_with_document_round_trips_json() {
785        let m = Message::user_with_pdf_base64("describe", "JVBERi0=");
786        let s = serde_json::to_string(&m).unwrap();
787        assert!(s.contains("\"role\":\"user\""), "shape: {s}");
788        assert!(s.contains("\"type\":\"document\""));
789        assert!(s.contains("\"media_type\":\"application/pdf\""));
790        let back: Message = serde_json::from_str(&s).unwrap();
791        assert_eq!(back.role(), Role::User);
792        assert_eq!(back.text(), "describe");
793        if let Message::User { content, .. } = back {
794            assert_eq!(content.len(), 2);
795            assert!(matches!(&content[1], ContentPart::Document { .. }));
796        } else {
797            panic!("expected User");
798        }
799    }
800}