ailoop_core/message.rs
1//! Conversation message model: [`Message`], its block enums, and the
2//! [`SystemPrompt`] / [`CacheControl`] support types.
3
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7
8/// Cache breakpoint placed on a content block, system prompt block, or
9/// tool definition. Providers that support prompt caching (Anthropic
10/// today) read these to decide which prefix is cacheable and at what
11/// TTL; providers without explicit caching ignore the field. The
12/// presence of `cache_control` only declares intent — the actual cache
13/// hit/miss is reported via [`crate::Usage::cached_input_tokens`] and
14/// the cache-creation counters.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum CacheControl {
18 /// Ephemeral cache with the provider's default TTL (5 minutes on
19 /// Anthropic). Equivalent to omitting `ttl` on the wire.
20 Ephemeral,
21 /// Ephemeral cache with an explicit TTL. Anthropic accepts only
22 /// `5m` and `1h`; the adapter rounds to the nearest supported value
23 /// and warns if neither fits cleanly.
24 EphemeralWithTtl(Duration),
25}
26
27/// One turn in the conversation history exchanged with a provider.
28///
29/// `Message` is the wire shape: every provider adapter maps this enum
30/// to its own block model (Anthropic Messages, OpenAI Chat
31/// Completions, etc.). Only the user and assistant roles live here —
32/// system instructions are passed separately through
33/// [`crate::ChatRequest::system_prompt`] because most providers
34/// represent them out-of-band.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[non_exhaustive]
37pub enum Message {
38 /// A user-authored turn: free text, tool results from the previous
39 /// step, or a mix.
40 User {
41 /// Blocks rendered in order. Tool results live here because, on
42 /// the wire, they are sent back to the model as user content.
43 blocks: Vec<UserBlock>,
44 },
45 /// A turn produced by the model: visible text, tool calls,
46 /// reasoning. Tool results are in the *next* `User` turn.
47 Assistant {
48 /// Blocks rendered in order. Ordering is provider-significant
49 /// for reasoning + tool-use chains (Anthropic extended
50 /// thinking).
51 blocks: Vec<AssistantBlock>,
52 },
53}
54
55impl Message {
56 /// Shorthand for a user turn containing a single text block.
57 pub fn user(text: impl Into<String>) -> Message {
58 Message::User {
59 blocks: vec![UserBlock::text(text)],
60 }
61 }
62
63 /// Shorthand for an assistant turn containing a single text block.
64 /// Use the [`Message::Assistant`] variant directly when seeding
65 /// history with tool calls or reasoning.
66 pub fn assistant_text(text: impl Into<String>) -> Message {
67 Message::Assistant {
68 blocks: vec![AssistantBlock::text(text)],
69 }
70 }
71}
72
73/// One block inside a [`Message::User`] turn.
74///
75/// Free user text, tool results, and inline media (images, documents)
76/// all live here because providers route tool results — and the rest
77/// of the multimodal surface — back through the user role on the wire.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[non_exhaustive]
80pub enum UserBlock {
81 /// Free user text.
82 Text {
83 /// Text content.
84 text: String,
85 /// Per-request cache hint. `#[serde(skip)]` — the cache
86 /// breakpoint is a per-call directive to the provider, not
87 /// part of the persisted conversation state, so it is dropped
88 /// on snapshot round-trip and restored as `None`.
89 #[serde(skip, default)]
90 cache_control: Option<CacheControl>,
91 },
92 /// Result of a tool invocation paired with the assistant
93 /// [`AssistantBlock::ToolCall`] of the previous turn (matched by
94 /// id).
95 ToolResult {
96 /// Matches the `id` on the originating [`AssistantBlock::ToolCall`].
97 call_id: String,
98 /// The tool's reply. `content.is_error` flags tool-reported
99 /// failures separately from the block list, so an error reply
100 /// can still carry images.
101 content: ToolResultContent,
102 /// See [`UserBlock::Text::cache_control`].
103 #[serde(skip, default)]
104 cache_control: Option<CacheControl>,
105 },
106 /// Image content rendered inline. Adapters map this to the
107 /// provider's image content type (Anthropic `image`, Chat
108 /// Completions `image_url`). Adapters that cannot represent the
109 /// chosen [`Source`] (e.g. a Chat Completions deployment with no
110 /// vision support) surface a typed error.
111 Image {
112 /// Image source: base64, URL, or provider-side file ID.
113 source: Source,
114 /// See [`UserBlock::Text::cache_control`].
115 #[serde(skip, default)]
116 cache_control: Option<CacheControl>,
117 },
118 /// Document content rendered inline (PDF and similar). Anthropic
119 /// has a dedicated `document` content type; Chat Completions does
120 /// not, so the Azure adapter surfaces a typed
121 /// `UnsupportedContent` error and callers downgrade via
122 /// `ChatMiddleware::on_chat_request` if they want a text
123 /// substitute.
124 Document {
125 /// Document source: base64, URL, or provider-side file ID.
126 source: Source,
127 /// See [`UserBlock::Text::cache_control`].
128 #[serde(skip, default)]
129 cache_control: Option<CacheControl>,
130 },
131}
132
133impl UserBlock {
134 /// Build a [`UserBlock::Text`] with no cache breakpoint.
135 pub fn text(text: impl Into<String>) -> Self {
136 Self::Text {
137 text: text.into(),
138 cache_control: None,
139 }
140 }
141
142 /// Build a [`UserBlock::ToolResult`] with no cache breakpoint. The
143 /// `content` argument is anything that converts into
144 /// [`ToolResultContent`] — `String` and `&str` produce a single
145 /// text block with `is_error = false`. Use
146 /// [`ToolResultContent::error`] to flag a tool-reported failure or
147 /// [`ToolResultContent::from_blocks`] to build a multi-block reply.
148 pub fn tool_result(call_id: impl Into<String>, content: impl Into<ToolResultContent>) -> Self {
149 Self::ToolResult {
150 call_id: call_id.into(),
151 content: content.into(),
152 cache_control: None,
153 }
154 }
155
156 /// Build a [`UserBlock::Image`] with no cache breakpoint.
157 pub fn image(source: Source) -> Self {
158 Self::Image {
159 source,
160 cache_control: None,
161 }
162 }
163
164 /// Build a [`UserBlock::Document`] with no cache breakpoint.
165 pub fn document(source: Source) -> Self {
166 Self::Document {
167 source,
168 cache_control: None,
169 }
170 }
171
172 /// Builder-style helper: set or replace the cache breakpoint on this
173 /// block. Use `None` to clear.
174 pub fn with_cache_control(mut self, cache_control: Option<CacheControl>) -> Self {
175 match &mut self {
176 Self::Text {
177 cache_control: cc, ..
178 }
179 | Self::ToolResult {
180 cache_control: cc, ..
181 }
182 | Self::Image {
183 cache_control: cc, ..
184 }
185 | Self::Document {
186 cache_control: cc, ..
187 } => *cc = cache_control,
188 }
189 self
190 }
191
192 /// Read the current cache breakpoint, if any.
193 pub fn cache_control(&self) -> Option<&CacheControl> {
194 match self {
195 Self::Text { cache_control, .. }
196 | Self::ToolResult { cache_control, .. }
197 | Self::Image { cache_control, .. }
198 | Self::Document { cache_control, .. } => cache_control.as_ref(),
199 }
200 }
201}
202
203/// One block inside a [`Message::Assistant`] turn.
204///
205/// Block ordering is preserved on replay because some providers
206/// (Anthropic extended thinking) require the original sequence on
207/// every subsequent request.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[non_exhaustive]
210pub enum AssistantBlock {
211 /// Visible model-authored text.
212 Text {
213 /// Text content.
214 text: String,
215 /// See [`UserBlock::Text::cache_control`].
216 #[serde(skip, default)]
217 cache_control: Option<CacheControl>,
218 },
219 /// A tool invocation request from the model. Pair with a
220 /// [`UserBlock::ToolResult`] in the next user turn that matches
221 /// `id` to `call_id`.
222 ToolCall {
223 /// Provider-assigned id; mirrors back as `call_id` on the
224 /// matching [`UserBlock::ToolResult`].
225 id: String,
226 /// Tool name as registered in the [`crate::ChatRequest::tools`]
227 /// list.
228 name: String,
229 /// JSON arguments. Adapters serialize this through to the
230 /// provider verbatim; the engine does not validate the schema.
231 args: serde_json::Value,
232 /// See [`UserBlock::Text::cache_control`].
233 #[serde(skip, default)]
234 cache_control: Option<CacheControl>,
235 },
236 /// Visible reasoning emitted by the model. `signature` is provider-issued
237 /// material that must be replayed verbatim on subsequent turns when tools
238 /// are involved (Anthropic extended thinking). Providers without a
239 /// signature concept (e.g. OpenAI reasoning) leave it `None`.
240 ///
241 /// Reasoning blocks intentionally have no `cache_control` slot:
242 /// Anthropic does not accept the field on `thinking` /
243 /// `redacted_thinking` blocks. Place breakpoints on adjacent text or
244 /// tool blocks instead.
245 Reasoning {
246 /// Visible reasoning text.
247 text: String,
248 /// Provider signature (Anthropic extended thinking). Replay
249 /// verbatim on subsequent turns when tools are involved;
250 /// `None` for providers without a signature concept.
251 signature: Option<String>,
252 },
253 /// Opaque reasoning block whose content the provider chose to hide.
254 /// `data` is verbatim provider material — store it untouched and replay
255 /// it back when the next request continues a tool-use chain.
256 RedactedReasoning {
257 /// Verbatim provider payload; treat as opaque bytes.
258 data: String,
259 },
260}
261
262impl AssistantBlock {
263 /// Build an [`AssistantBlock::Text`] with no cache breakpoint.
264 pub fn text(text: impl Into<String>) -> Self {
265 Self::Text {
266 text: text.into(),
267 cache_control: None,
268 }
269 }
270
271 /// Build an [`AssistantBlock::ToolCall`] with no cache breakpoint.
272 pub fn tool_call(
273 id: impl Into<String>,
274 name: impl Into<String>,
275 args: serde_json::Value,
276 ) -> Self {
277 Self::ToolCall {
278 id: id.into(),
279 name: name.into(),
280 args,
281 cache_control: None,
282 }
283 }
284
285 /// Builder-style helper: set or replace the cache breakpoint on this
286 /// block. No-op for reasoning variants (they do not carry cache
287 /// breakpoints on the wire).
288 pub fn with_cache_control(mut self, cache_control: Option<CacheControl>) -> Self {
289 match &mut self {
290 Self::Text {
291 cache_control: cc, ..
292 } => *cc = cache_control,
293 Self::ToolCall {
294 cache_control: cc, ..
295 } => *cc = cache_control,
296 Self::Reasoning { .. } | Self::RedactedReasoning { .. } => {}
297 }
298 self
299 }
300
301 /// Read the current cache breakpoint. Always `None` for the
302 /// reasoning variants (they do not carry breakpoints on the wire).
303 pub fn cache_control(&self) -> Option<&CacheControl> {
304 match self {
305 Self::Text { cache_control, .. } | Self::ToolCall { cache_control, .. } => {
306 cache_control.as_ref()
307 }
308 Self::Reasoning { .. } | Self::RedactedReasoning { .. } => None,
309 }
310 }
311}
312
313/// Source of an image or document content block.
314///
315/// Three forms cover the providers we ship adapters for today:
316/// - `Base64` carries the binary inline. Always works but inflates the
317/// request body and any persisted snapshot — prefer `Url` or
318/// `FileId` for large media.
319/// - `Url` points the provider at an external resource. Subject to
320/// the provider's own fetch limits and accessibility rules.
321/// - `FileId` references a provider-side file (Anthropic Files Beta,
322/// OpenAI Files). Adapters that do not understand the id surface a
323/// typed error rather than degrading silently.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[non_exhaustive]
326#[serde(tag = "type", rename_all = "snake_case")]
327pub enum Source {
328 /// Inline base64-encoded bytes.
329 Base64 {
330 /// MIME type of the payload (e.g. `image/png`, `application/pdf`).
331 media_type: String,
332 /// Base64-encoded data.
333 data: String,
334 },
335 /// External URL the provider fetches.
336 Url {
337 /// HTTP(S) URL.
338 url: String,
339 },
340 /// Provider-side file ID (Anthropic Files Beta, OpenAI Files). The
341 /// id is opaque to the adapter — the provider resolves it on its
342 /// side.
343 FileId {
344 /// Provider-issued identifier.
345 id: String,
346 },
347}
348
349/// One block inside a [`ToolResultContent::blocks`] list.
350///
351/// Several blocks can be interleaved inside a single tool reply
352/// (e.g. text + a rendered chart), so a tool that generates an image
353/// alongside an explanation does not have to choose. Error semantics
354/// live on the parent [`ToolResultContent::is_error`], not per-block,
355/// so a failed reply can still carry images.
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357#[non_exhaustive]
358#[serde(tag = "type", rename_all = "snake_case")]
359pub enum ToolResultBlock {
360 /// Plain text segment of the tool reply.
361 Text {
362 /// Text content.
363 text: String,
364 },
365 /// Image segment of the tool reply. Adapters that cannot represent
366 /// images inside tool results surface a typed error.
367 Image {
368 /// Image source.
369 source: Source,
370 },
371}
372
373impl ToolResultBlock {
374 /// Build a [`ToolResultBlock::Text`].
375 pub fn text(text: impl Into<String>) -> Self {
376 Self::Text { text: text.into() }
377 }
378
379 /// Build a [`ToolResultBlock::Image`] from the given source.
380 pub fn image(source: Source) -> Self {
381 Self::Image { source }
382 }
383}
384
385/// Body of a tool reply sent back to the model in a
386/// [`UserBlock::ToolResult`].
387///
388/// The body is a list of [`ToolResultBlock`]s (text, image, …) plus an
389/// `is_error` flag. The flag is the wire-level error signal — Anthropic
390/// emits it as `tool_result.is_error`; Chat Completions has no field
391/// for it and treats the body as the error message.
392///
393/// `is_error` is **not** a Rust [`Result::Err`] — both forms represent
394/// successful tool calls whose outcome the engine relays to the model.
395/// `is_error = true` flags the reply as a failure the model should
396/// account for (e.g. "the API returned 404"). Engine-level errors
397/// (panic in the handler, arguments that don't deserialize, registry
398/// lookup miss) are converted to a synthesized error reply so the
399/// loop can continue; transport errors propagate through `Result`
400/// channels instead.
401///
402/// Most callers build replies through the [`Self::text`] /
403/// [`Self::error`] constructors; multi-block replies use
404/// [`Self::from_blocks`].
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406#[non_exhaustive]
407pub struct ToolResultContent {
408 /// Content blocks in order. A "normal" text reply has a single
409 /// [`ToolResultBlock::Text`] here.
410 pub blocks: Vec<ToolResultBlock>,
411 /// `true` flags the reply as a tool-reported failure (Anthropic
412 /// `is_error: true`). Adapters that don't speak the flag on the
413 /// wire just emit the text body.
414 #[serde(default, skip_serializing_if = "is_false")]
415 pub is_error: bool,
416}
417
418fn is_false(b: &bool) -> bool {
419 !*b
420}
421
422impl ToolResultContent {
423 /// Build a successful text-only tool reply (`is_error = false`).
424 pub fn text(text: impl Into<String>) -> Self {
425 Self {
426 blocks: vec![ToolResultBlock::text(text)],
427 is_error: false,
428 }
429 }
430
431 /// Build a failing text-only tool reply (`is_error = true`).
432 pub fn error(text: impl Into<String>) -> Self {
433 Self {
434 blocks: vec![ToolResultBlock::text(text)],
435 is_error: true,
436 }
437 }
438
439 /// Build a successful image-only tool reply (`is_error = false`).
440 pub fn image(source: Source) -> Self {
441 Self {
442 blocks: vec![ToolResultBlock::image(source)],
443 is_error: false,
444 }
445 }
446
447 /// Build a reply from arbitrary blocks. Defaults `is_error: false`;
448 /// chain [`Self::with_is_error`] to flag failure.
449 pub fn from_blocks(blocks: Vec<ToolResultBlock>) -> Self {
450 Self {
451 blocks,
452 is_error: false,
453 }
454 }
455
456 /// Builder-style helper: set the `is_error` flag.
457 pub fn with_is_error(mut self, is_error: bool) -> Self {
458 self.is_error = is_error;
459 self
460 }
461
462 /// First [`ToolResultBlock::Text`] body, if any. Useful when the
463 /// caller only cares about the text portion of a reply.
464 pub fn as_text(&self) -> Option<&str> {
465 self.blocks.iter().find_map(|b| match b {
466 ToolResultBlock::Text { text } => Some(text.as_str()),
467 _ => None,
468 })
469 }
470
471 /// Concatenate every [`ToolResultBlock::Text`] body in order,
472 /// joined by newlines. Returns an empty string when there are no
473 /// text blocks (the reply was image-only).
474 pub fn collect_text(&self) -> String {
475 self.blocks
476 .iter()
477 .filter_map(|b| match b {
478 ToolResultBlock::Text { text } => Some(text.as_str()),
479 _ => None,
480 })
481 .collect::<Vec<_>>()
482 .join("\n")
483 }
484}
485
486impl From<String> for ToolResultContent {
487 fn from(value: String) -> Self {
488 Self::text(value)
489 }
490}
491
492impl From<&str> for ToolResultContent {
493 fn from(value: &str) -> Self {
494 Self::text(value)
495 }
496}
497
498/// System prompt passed to the provider. `Plain(String)` matches the
499/// pre-caching API and is what `From<String>` / `From<&str>` produce —
500/// callers that don't care about prompt caching keep using strings.
501/// `Blocks(...)` opts in to per-block cache breakpoints (Anthropic emits
502/// the `system` field as an array; other providers concatenate the
503/// block texts).
504#[derive(Debug, Clone)]
505#[non_exhaustive]
506pub enum SystemPrompt {
507 /// Single string passed to the provider as-is. The default for
508 /// callers that don't care about prompt caching; matches `From<&str>`
509 /// / `From<String>`.
510 Plain(String),
511 /// Sequence of blocks with optional per-block cache breakpoints.
512 /// Anthropic emits this as the wire `system` array; providers
513 /// without per-block caching concatenate the texts.
514 Blocks(Vec<SystemBlock>),
515}
516
517/// One entry inside a [`SystemPrompt::Blocks`] sequence.
518#[derive(Debug, Clone)]
519#[non_exhaustive]
520pub struct SystemBlock {
521 /// Text content of this block.
522 pub text: String,
523 /// Optional cache breakpoint for this block.
524 pub cache_control: Option<CacheControl>,
525}
526
527impl SystemBlock {
528 /// Build a block with the given text and no cache breakpoint.
529 pub fn new(text: impl Into<String>) -> Self {
530 Self {
531 text: text.into(),
532 cache_control: None,
533 }
534 }
535
536 /// Builder-style helper: attach a cache breakpoint to this block.
537 pub fn with_cache_control(mut self, cache_control: CacheControl) -> Self {
538 self.cache_control = Some(cache_control);
539 self
540 }
541}
542
543impl From<String> for SystemPrompt {
544 fn from(value: String) -> Self {
545 Self::Plain(value)
546 }
547}
548
549impl From<&str> for SystemPrompt {
550 fn from(value: &str) -> Self {
551 Self::Plain(value.to_string())
552 }
553}
554
555impl SystemPrompt {
556 /// Concatenate all blocks into a single string. Used by adapters
557 /// without per-block caching (Chat Completions) and as a debug aid.
558 pub fn as_text(&self) -> String {
559 match self {
560 Self::Plain(s) => s.clone(),
561 Self::Blocks(bs) => bs
562 .iter()
563 .map(|b| b.text.as_str())
564 .collect::<Vec<_>>()
565 .join("\n\n"),
566 }
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use serde_json::json;
574
575 fn round_trip(msg: &Message) -> Message {
576 let json = serde_json::to_string(msg).expect("serialize");
577 serde_json::from_str(&json).expect("deserialize")
578 }
579
580 fn assert_user_text_eq(msg: &Message, expected: &str) {
581 match msg {
582 Message::User { blocks } => match &blocks[0] {
583 UserBlock::Text {
584 text,
585 cache_control,
586 } => {
587 assert_eq!(text, expected);
588 assert!(cache_control.is_none(), "cache_control must not round-trip");
589 }
590 other => panic!("expected UserBlock::Text, got {other:?}"),
591 },
592 other => panic!("expected Message::User, got {other:?}"),
593 }
594 }
595
596 #[test]
597 fn round_trip_user_text_drops_cache_control() {
598 let msg = Message::User {
599 blocks: vec![
600 UserBlock::text("hello").with_cache_control(Some(CacheControl::Ephemeral)),
601 ],
602 };
603 let restored = round_trip(&msg);
604 assert_user_text_eq(&restored, "hello");
605 }
606
607 #[test]
608 fn round_trip_user_tool_result() {
609 let msg = Message::User {
610 blocks: vec![UserBlock::tool_result(
611 "call-1",
612 ToolResultContent::text("ok"),
613 )],
614 };
615 let restored = round_trip(&msg);
616 match &restored {
617 Message::User { blocks } => match &blocks[0] {
618 UserBlock::ToolResult {
619 call_id,
620 content,
621 cache_control,
622 } => {
623 assert_eq!(call_id, "call-1");
624 assert_eq!(content.as_text(), Some("ok"));
625 assert!(!content.is_error);
626 assert!(cache_control.is_none());
627 }
628 other => panic!("expected ToolResult, got {other:?}"),
629 },
630 other => panic!("expected User, got {other:?}"),
631 }
632 }
633
634 #[test]
635 fn round_trip_assistant_text_and_tool_call() {
636 let msg = Message::Assistant {
637 blocks: vec![
638 AssistantBlock::text("thinking out loud"),
639 AssistantBlock::tool_call("c1", "fetch", json!({"q": "x"})),
640 ],
641 };
642 let restored = round_trip(&msg);
643 match &restored {
644 Message::Assistant { blocks } => {
645 assert_eq!(blocks.len(), 2);
646 match &blocks[0] {
647 AssistantBlock::Text { text, .. } => assert_eq!(text, "thinking out loud"),
648 other => panic!("expected Text, got {other:?}"),
649 }
650 match &blocks[1] {
651 AssistantBlock::ToolCall { id, name, args, .. } => {
652 assert_eq!(id, "c1");
653 assert_eq!(name, "fetch");
654 assert_eq!(args, &json!({"q": "x"}));
655 }
656 other => panic!("expected ToolCall, got {other:?}"),
657 }
658 }
659 other => panic!("expected Assistant, got {other:?}"),
660 }
661 }
662
663 #[test]
664 fn round_trip_assistant_reasoning_variants() {
665 let msg = Message::Assistant {
666 blocks: vec![
667 AssistantBlock::Reasoning {
668 text: "consider X".into(),
669 signature: Some("sig-1".into()),
670 },
671 AssistantBlock::RedactedReasoning {
672 data: "opaque".into(),
673 },
674 ],
675 };
676 let restored = round_trip(&msg);
677 match &restored {
678 Message::Assistant { blocks } => match (&blocks[0], &blocks[1]) {
679 (
680 AssistantBlock::Reasoning { text, signature },
681 AssistantBlock::RedactedReasoning { data },
682 ) => {
683 assert_eq!(text, "consider X");
684 assert_eq!(signature.as_deref(), Some("sig-1"));
685 assert_eq!(data, "opaque");
686 }
687 other => panic!("unexpected blocks: {other:?}"),
688 },
689 other => panic!("expected Assistant, got {other:?}"),
690 }
691 }
692
693 #[test]
694 fn round_trip_tool_result_error_variant() {
695 let content = ToolResultContent::error("boom");
696 let json = serde_json::to_string(&content).unwrap();
697 let back: ToolResultContent = serde_json::from_str(&json).unwrap();
698 assert_eq!(back.as_text(), Some("boom"));
699 assert!(back.is_error);
700 }
701
702 #[test]
703 fn tool_result_content_is_error_omitted_when_false() {
704 let content = ToolResultContent::text("ok");
705 let json = serde_json::to_value(&content).unwrap();
706 assert!(
707 json.get("is_error").is_none(),
708 "is_error must be skipped when false, got {json}"
709 );
710 }
711
712 #[test]
713 fn tool_result_content_multi_block_round_trip() {
714 let content = ToolResultContent::from_blocks(vec![
715 ToolResultBlock::text("see chart"),
716 ToolResultBlock::image(Source::Url {
717 url: "https://example.com/chart.png".into(),
718 }),
719 ]);
720 let json = serde_json::to_string(&content).unwrap();
721 let back: ToolResultContent = serde_json::from_str(&json).unwrap();
722 assert_eq!(back.blocks.len(), 2);
723 assert!(!back.is_error);
724 }
725
726 #[test]
727 fn round_trip_user_image_block() {
728 let msg = Message::User {
729 blocks: vec![UserBlock::image(Source::Base64 {
730 media_type: "image/png".into(),
731 data: "AAAA".into(),
732 })],
733 };
734 let restored = round_trip(&msg);
735 match &restored {
736 Message::User { blocks } => match &blocks[0] {
737 UserBlock::Image {
738 source,
739 cache_control,
740 } => {
741 assert!(matches!(
742 source,
743 Source::Base64 { media_type, data }
744 if media_type == "image/png" && data == "AAAA"
745 ));
746 assert!(cache_control.is_none());
747 }
748 other => panic!("expected Image, got {other:?}"),
749 },
750 other => panic!("expected User, got {other:?}"),
751 }
752 }
753}