1pub 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
15pub const IMAGE_APPROX_CHAR_EQUIVALENT: usize = 6400;
24
25pub const DOCUMENT_APPROX_CHAR_EQUIVALENT: usize = 16_000;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub enum Role {
39 System,
40 User,
41 Assistant,
42 Tool,
43}
44
45#[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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}