Skip to main content

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}