Skip to main content

protocol_droid/domain/ir/
content.rs

1//! `Content` — the superset content block every revision can express.
2//!
3//! # The superset, and what "superset" costs
4//!
5//! Every revision's `Content` union is a subset of this enum. `Text` and
6//! `Image` have been there since `2024-11-05`; `Audio` arrived in `2025-03-26`;
7//! `ResourceLink` and `EmbeddedResource` in `2025-06-18`; `Unknown` is not a
8//! content type at all, it is the escape hatch.
9//!
10//! The cost of a superset is that a variant can be present in a value the
11//! target revision cannot express. That is not a modelling detail to paper
12//! over — it is the whole input to T08's lowering. So every variant reports
13//! what it needs through [`Content::required_features`], and lowering asks
14//! rather than guessing. A `Content::Audio` is fine in the IR and a
15//! `ProtocolError` on the wire to a `2024-11-05` peer, and the difference is a
16//! method call, not a version check scattered through the encoder.
17//!
18//! # `Unknown` is not a shrug
19//!
20//! [`Content::Unknown`] carries a content block whose `type` this build does
21//! not recognise. It exists because the alternative is dropping the block, and
22//! a tool result that silently loses a block is worse than one that forwards
23//! one the receiver does not understand — the receiver can ignore it, which
24//! preserves the information that it was there.
25//!
26//! Its [`required_features`](Content::required_features) is empty on purpose.
27//! An unknown block needs nothing *this build* knows how to express, because
28//! the only way to lower it is to forward the original bytes unchanged, and
29//! that is expressible at every revision. T08 pairs it with
30//! [`Feature::Extensions`] as a *warning* rather than a hard requirement, so a
31//! new content type flows through instead of failing the bridge.
32
33use crate::domain::Feature;
34use crate::domain::ir::extensions::Extensions;
35use crate::domain::ir::features::FeatureSet;
36use serde_json::Value as JsonValue;
37
38use crate::domain::ir::json::JsonObject;
39
40/// A content block.
41#[derive(Debug, Clone, PartialEq)]
42pub enum Content {
43    /// A text block. Every revision, every transport.
44    Text(TextContent),
45
46    /// A base64-encoded image. Every revision.
47    Image(ImageContent),
48
49    /// Base64-encoded audio. `2025-03-26`.
50    Audio(AudioContent),
51
52    /// A link to a resource the client can read. `2025-06-18`.
53    ResourceLink(ResourceLinkContent),
54
55    /// The resource's contents inline. Every revision.
56    EmbeddedResource(EmbeddedResource),
57
58    /// A content block this build does not recognise.
59    ///
60    /// Holds the original wire shape so it can be forwarded unchanged. See the
61    /// module docs for why this is not a shrug.
62    Unknown(UnknownContent),
63}
64
65impl Default for Content {
66    /// An empty text block, which is the only content every revision can
67    /// express and the only one with a meaningful zero value.
68    fn default() -> Self {
69        Self::Text(TextContent::default())
70    }
71}
72
73impl TextContent {
74    /// What this block needs, over and above being text at all.
75    #[must_use]
76    pub const fn required_features(&self) -> FeatureSet {
77        FeatureSet::empty()
78    }
79}
80
81impl ImageContent {
82    /// What this block needs, over and above being an image at all.
83    #[must_use]
84    pub const fn required_features(&self) -> FeatureSet {
85        FeatureSet::empty()
86    }
87}
88
89impl AudioContent {
90    /// What this block needs, over and above being audio.
91    #[must_use]
92    pub fn required_features(&self) -> FeatureSet {
93        // "Added support for audio data", `2025-03-26`.
94        FeatureSet::empty().with(Feature::ToolAudioContent)
95    }
96}
97
98impl ResourceLinkContent {
99    /// What this block needs, over and above being a link.
100    #[must_use]
101    pub fn required_features(&self) -> FeatureSet {
102        let mut required = FeatureSet::empty().with(Feature::ToolResourceLinks);
103        if !self.icons.is_empty() {
104            required = required.with(Feature::ToolResourceLinkIcons);
105        }
106        required
107    }
108}
109
110impl EmbeddedResource {
111    /// What this block needs, over and above being an embedded resource.
112    #[must_use]
113    pub const fn required_features(&self) -> FeatureSet {
114        FeatureSet::empty()
115    }
116}
117
118impl Annotations {
119    /// What these annotations need.
120    ///
121    /// Annotations as a *field* need nothing: `2024-11-05` carries an
122    /// untyped `annotations` on every content block, so a block with an
123    /// audience and a priority lowers onto it and the value survives as JSON.
124    /// That is why this is not `Feature::ToolAnnotations`, which gates the
125    /// typed form added in `2025-03-26`.
126    ///
127    /// `lastModified` is different in kind, not degree. It exists only in the
128    /// typed object, so an older revision cannot hold it at all — not as a
129    /// field, not even untyped — and dropping it would lose information the
130    /// sender put there. So it, and only it, requires `2025-06-18`.
131    #[must_use]
132    pub fn required_features(&self) -> FeatureSet {
133        if self.last_modified.is_some() {
134            FeatureSet::empty().with(Feature::ToolAnnotationsLastModified)
135        } else {
136            FeatureSet::empty()
137        }
138    }
139}
140
141impl Content {
142    /// The features this block needs in order to be expressed.
143    ///
144    /// Empty for `Text`, `Image`, `EmbeddedResource` and `Unknown`, because all
145    /// four are expressible at every revision. The rest name the revision that
146    /// introduced them, and those are exactly the revisions where lowering this
147    /// block to a `2024-11-05` peer is a downgrade.
148    #[must_use]
149    pub fn required_features(&self) -> FeatureSet {
150        let mut required = match self {
151            // Text, Image and EmbeddedResource are in `2024-11-05` — but a
152            // block of any kind carrying annotations is not, because
153            // annotations arrived in `2025-03-26`. The block's own kind and the
154            // fields on it are separate questions, and answering only the first
155            // one lets an annotated block lower onto a revision that would drop
156            // the annotations.
157            Self::Text(text) => text.required_features(),
158            Self::Image(image) => image.required_features(),
159            Self::Audio(audio) => audio.required_features(),
160            Self::ResourceLink(link) => link.required_features(),
161            Self::EmbeddedResource(resource) => resource.required_features(),
162            // Carried verbatim, so it needs nothing — the bytes are the wire.
163            Self::Unknown(_) => FeatureSet::empty(),
164        };
165        if let Some(annotations) = self.annotations() {
166            required = required.union(annotations.required_features());
167        }
168        required
169    }
170
171    /// The block's annotations, if it has any.
172    #[must_use]
173    pub const fn annotations(&self) -> Option<&Annotations> {
174        match self {
175            Self::Text(block) => block.annotations.as_ref(),
176            Self::Image(block) => block.annotations.as_ref(),
177            Self::Audio(block) => block.annotations.as_ref(),
178            Self::ResourceLink(block) => block.annotations.as_ref(),
179            Self::EmbeddedResource(block) => block.annotations.as_ref(),
180            Self::Unknown(_) => None,
181        }
182    }
183
184    /// The wire `type` discriminator, for unknown blocks this is the string
185    /// that was on the wire.
186    #[must_use]
187    pub fn type_name(&self) -> &str {
188        match self {
189            Self::Text(_) => "text",
190            Self::Image(_) => "image",
191            Self::Audio(_) => "audio",
192            Self::ResourceLink(_) => "resource_link",
193            Self::EmbeddedResource(_) => "resource",
194            Self::Unknown(unknown) => &unknown.type_name,
195        }
196    }
197
198    /// A short label for logs and downgrade messages.
199    #[must_use]
200    pub const fn kind(&self) -> &'static str {
201        match self {
202            Self::Text(_) => "text",
203            Self::Image(_) => "image",
204            Self::Audio(_) => "audio",
205            Self::ResourceLink(_) => "resourceLink",
206            Self::EmbeddedResource(_) => "embeddedResource",
207            Self::Unknown(_) => "unknown",
208        }
209    }
210}
211
212/// `{"type": "text", "text": "..."}`.
213#[derive(Debug, Clone, PartialEq, Default)]
214pub struct TextContent {
215    /// The text.
216    pub text: String,
217    /// Optional annotations, `2025-03-26` (PR #185).
218    pub annotations: Option<Annotations>,
219    /// Unmodelled fields.
220    pub extensions: Extensions,
221}
222
223impl TextContent {
224    /// A text block with no annotations.
225    #[must_use]
226    pub fn new(text: impl Into<String>) -> Self {
227        Self {
228            text: text.into(),
229            annotations: None,
230            extensions: Extensions::empty(),
231        }
232    }
233}
234
235/// `{"type": "image", "data": "...", "mimeType": "..."}`.
236#[derive(Debug, Clone, PartialEq)]
237pub struct ImageContent {
238    /// Base64 payload.
239    pub data: String,
240    /// The media type.
241    pub mime_type: String,
242    /// Optional annotations, `2025-03-26`.
243    pub annotations: Option<Annotations>,
244    /// Unmodelled fields.
245    pub extensions: Extensions,
246}
247
248impl ImageContent {
249    /// An image block.
250    #[must_use]
251    pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
252        Self {
253            data: data.into(),
254            mime_type: mime_type.into(),
255            annotations: None,
256            extensions: Extensions::empty(),
257        }
258    }
259}
260
261/// The audio block, `2025-03-26` and later.
262///
263/// Structurally identical to [`ImageContent`]. It is a separate type rather
264/// than a shared `BinaryContent` with a flag, because the feature it requires
265/// differs and the IR's job is to make that difference impossible to miss.
266#[derive(Debug, Clone, PartialEq)]
267pub struct AudioContent {
268    /// Base64 payload.
269    pub data: String,
270    /// The media type.
271    pub mime_type: String,
272    /// Optional annotations, `2025-03-26`.
273    pub annotations: Option<Annotations>,
274    /// Unmodelled fields.
275    pub extensions: Extensions,
276}
277
278impl AudioContent {
279    /// An audio block.
280    #[must_use]
281    pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
282        Self {
283            data: data.into(),
284            mime_type: mime_type.into(),
285            annotations: None,
286            extensions: Extensions::empty(),
287        }
288    }
289}
290
291/// A link to a resource, `2025-06-18` and later (PR #603).
292#[derive(Debug, Clone, PartialEq)]
293pub struct ResourceLinkContent {
294    /// The resource's URI.
295    pub uri: String,
296    /// The name to show.
297    pub name: String,
298    /// A human-readable description.
299    pub description: Option<String>,
300    /// The media type, if known.
301    pub mime_type: Option<String>,
302    /// The size in bytes, if known.
303    pub size: Option<u64>,
304    /// Icons for the resource. `2025-11-25`, SEP-973.
305    ///
306    /// <https://github.com/modelcontextprotocol/modelcontextprotocol/issues/973>
307    pub icons: Vec<ResourceIcon>,
308    /// A human-readable title for the resource. `2025-06-18`.
309    ///
310    /// Distinct from `name`: `name` is the identifier a client uses to ask for
311    /// the resource, `title` is a label for a human reading the result. The
312    /// specification defines both, and a link that carried only `title` across a
313    /// crossing would arrive stripped of it.
314    pub title: Option<String>,
315    /// Optional annotations, `2025-03-26`.
316    pub annotations: Option<Annotations>,
317    /// Unmodelled fields.
318    pub extensions: Extensions,
319}
320
321impl ResourceLinkContent {
322    /// A resource link with only the required fields.
323    #[must_use]
324    pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
325        Self {
326            uri: uri.into(),
327            name: name.into(),
328            description: None,
329            mime_type: None,
330            size: None,
331            icons: Vec::new(),
332            title: None,
333            annotations: None,
334            extensions: Extensions::empty(),
335        }
336    }
337}
338
339/// An icon for a resource link. `2025-11-25`, SEP-973.
340///
341/// <https://github.com/modelcontextprotocol/modelcontextprotocol/issues/973>
342#[derive(Debug, Clone, PartialEq)]
343pub struct ResourceIcon {
344    /// The image itself, as a data URI.
345    pub src: String,
346    /// The media type, if the URI does not imply one.
347    pub mime_type: Option<String>,
348    /// The sizes this icon is usable at.
349    pub sizes: Vec<String>,
350    /// How the icon is meant to be themed, when the scheme supports it.
351    ///
352    /// Untyped in every revision that has it, so it is a `JsonValue` rather
353    /// than a modelled shape. It is still a named field, which means a codec
354    /// that left it out would lose it — `extra` would not catch it, because
355    /// the schema already claimed the name.
356    pub theme: Option<JsonValue>,
357    /// Unmodelled fields.
358    pub extensions: Extensions,
359}
360
361impl ResourceIcon {
362    /// An icon with only its source.
363    #[must_use]
364    pub fn new(src: impl Into<String>) -> Self {
365        Self {
366            src: src.into(),
367            mime_type: None,
368            sizes: Vec::new(),
369            theme: None,
370            extensions: Extensions::empty(),
371        }
372    }
373}
374
375/// A resource's contents, inline.
376#[derive(Debug, Clone, PartialEq)]
377pub struct EmbeddedResource {
378    /// The text form, if the resource is text.
379    pub text: Option<String>,
380    /// The blob form, if the resource is binary.
381    pub blob: Option<String>,
382    /// The resource's URI.
383    pub uri: String,
384    /// The media type, if known.
385    pub mime_type: Option<String>,
386    /// Optional annotations, `2025-03-26`.
387    pub annotations: Option<Annotations>,
388    /// Unmodelled fields.
389    pub extensions: Extensions,
390}
391
392impl EmbeddedResource {
393    /// A text resource.
394    #[must_use]
395    pub fn text(uri: impl Into<String>, text: impl Into<String>) -> Self {
396        Self {
397            text: Some(text.into()),
398            blob: None,
399            uri: uri.into(),
400            mime_type: None,
401            annotations: None,
402            extensions: Extensions::empty(),
403        }
404    }
405}
406
407/// Annotations, added in `2025-03-26` (PR #185).
408///
409/// Modeled rather than dropped, because they are advisory metadata a client
410/// uses to decide how to present a result, and a tool that says it is
411/// read-only means something to the caller.
412///
413/// Not `Eq`, and neither is anything that holds one: `priority` is a
414/// floating-point number and neither `f32` nor `f64` is a valid `Eq` type,
415/// because of `NaN`. The cost is that a content block cannot be a `HashMap`
416/// key, which nothing needs — blocks live in a `Vec` inside a result.
417///
418/// `priority` is `f64` rather than the `f32` that would be conventional for a
419/// `0.0`–`1.0` quantity, because every revision's generated `Annotations`
420/// spells it `f64` and this crate's whole job is to carry a peer's value
421/// through unchanged. Narrowing on the way in and widening on the way out is
422/// not a round trip: `0.1_f32` is not `0.1_f64`, and a bridge forwarding
423/// annotations would quietly change a number the sender chose.
424#[derive(Debug, Clone, PartialEq, Default)]
425pub struct Annotations {
426    /// The audience this block is meant for.
427    pub audience: Vec<Role>,
428    /// How important this block is, `0.0`–`1.0`.
429    ///
430    /// `f64` to match the wire exactly; see the type's docs.
431    pub priority: Option<f64>,
432    /// The last time the resource was modified, ISO 8601.
433    pub last_modified: Option<String>,
434    /// Unmodelled fields.
435    pub extensions: Extensions,
436}
437
438impl Annotations {
439    /// Annotations with nothing set.
440    #[must_use]
441    pub const fn empty() -> Self {
442        Self {
443            audience: Vec::new(),
444            priority: None,
445            last_modified: None,
446            extensions: Extensions::empty(),
447        }
448    }
449}
450
451/// Who a content block is meant for.
452#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
453pub enum Role {
454    /// Meant for the user.
455    User,
456    /// Meant for the model.
457    Assistant,
458}
459
460impl Role {
461    /// The wire spelling.
462    #[must_use]
463    pub const fn as_str(self) -> &'static str {
464        match self {
465            Self::User => "user",
466            Self::Assistant => "assistant",
467        }
468    }
469}
470
471impl core::str::FromStr for Role {
472    type Err = UnknownRole;
473
474    fn from_str(text: &str) -> Result<Self, Self::Err> {
475        match text {
476            "user" => Ok(Self::User),
477            "assistant" => Ok(Self::Assistant),
478            other => Err(UnknownRole {
479                role: other.to_owned(),
480            }),
481        }
482    }
483}
484
485/// A role this build does not recognise.
486#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
487#[error("unknown content role {role:?}")]
488pub struct UnknownRole {
489    /// The role, verbatim.
490    pub role: String,
491}
492
493/// A content block whose `type` this build does not know.
494#[derive(Debug, Clone, PartialEq)]
495pub struct UnknownContent {
496    /// The `type` string that was on the wire.
497    pub type_name: String,
498    /// The block exactly as received, so it can be forwarded unchanged.
499    pub raw: JsonObject,
500    /// Unmodelled fields, kept alongside the raw copy so a codec that can model
501    /// them does not have to re-derive them from text.
502    pub extensions: Extensions,
503}
504
505#[cfg(test)]
506mod tests {
507    #![expect(
508        clippy::unwrap_used,
509        reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
510    )]
511
512    use super::{
513        Annotations, AudioContent, Content, EmbeddedResource, ImageContent, ResourceIcon,
514        ResourceLinkContent, Role, TextContent, UnknownContent,
515    };
516    use crate::domain::Feature;
517    use crate::domain::ir::extensions::{ExtensionValue, Extensions};
518
519    use crate::domain::ir::json::JsonObject;
520
521    /// One case per variant, because `required_features` is per-variant and
522    /// the task asks for exactly this test.
523    #[test]
524    fn required_features_per_variant() {
525        let cases: [(Content, &[Feature]); 6] = [
526            (Content::Text(TextContent::new("x")), &[]),
527            (Content::Image(ImageContent::new("AAAA", "image/png")), &[]),
528            (
529                Content::Audio(AudioContent::new("AAAA", "audio/wav")),
530                &[Feature::ToolAudioContent],
531            ),
532            (
533                Content::ResourceLink(ResourceLinkContent::new("file:///a", "a")),
534                &[Feature::ToolResourceLinks],
535            ),
536            (
537                Content::EmbeddedResource(EmbeddedResource::text("file:///a", "x")),
538                &[],
539            ),
540            (
541                Content::Unknown(UnknownContent {
542                    type_name: "video".into(),
543                    raw: JsonObject::new(r#"{"type":"video"}"#).unwrap(),
544                    extensions: Extensions::empty(),
545                }),
546                &[],
547            ),
548        ];
549        for (content, expected_features) in cases {
550            let required = content.required_features();
551            let expected_count = u32::try_from(expected_features.len()).unwrap_or(u32::MAX);
552            assert_eq!(
553                required.len(),
554                expected_count,
555                "{}: {required:?}",
556                content.kind(),
557            );
558            for feature in expected_features {
559                assert!(
560                    required.contains(*feature),
561                    "{} should need {feature}",
562                    content.kind(),
563                );
564            }
565        }
566    }
567
568    /// The requirement has to be one the revision that introduced it actually
569    /// turns on, or lowering would gate on a feature the session never has.
570    #[test]
571    fn required_features_are_turned_on_by_the_revision_that_defines_them() {
572        for (content, version) in [
573            (
574                Content::Audio(AudioContent::new("AAAA", "audio/wav")),
575                crate::domain::ProtocolVersion::V2025_03_26,
576            ),
577            (
578                Content::ResourceLink(ResourceLinkContent::new("file:///a", "a")),
579                crate::domain::ProtocolVersion::V2025_06_18,
580            ),
581        ] {
582            for feature in content.required_features().iter() {
583                let caps = crate::domain::CapabilitySet::all_for(version);
584                let profile = crate::domain::resolve(crate::domain::Negotiation {
585                    version,
586                    client_caps: caps,
587                    server_caps: caps,
588                    transport: crate::domain::TransportKind::Stdio,
589                });
590                assert!(
591                    profile.allows(feature),
592                    "{version} defines {feature} but a full profile omits it",
593                );
594            }
595        }
596    }
597
598    #[test]
599    fn type_names_match_the_wire() {
600        assert_eq!(Content::Text(TextContent::new("x")).type_name(), "text");
601        assert_eq!(
602            Content::Image(ImageContent::new("A", "image/png")).type_name(),
603            "image"
604        );
605        assert_eq!(
606            Content::Audio(AudioContent::new("A", "audio/wav")).type_name(),
607            "audio"
608        );
609        assert_eq!(
610            Content::ResourceLink(ResourceLinkContent::new("u", "n")).type_name(),
611            "resource_link"
612        );
613        assert_eq!(
614            Content::EmbeddedResource(EmbeddedResource::text("u", "t")).type_name(),
615            "resource"
616        );
617        assert_eq!(
618            Content::Unknown(UnknownContent {
619                type_name: "video".into(),
620                raw: JsonObject::new("{}").unwrap(),
621                extensions: Extensions::empty(),
622            })
623            .type_name(),
624            "video",
625        );
626    }
627
628    #[test]
629    fn text_defaults_to_an_empty_string() {
630        assert_eq!(Content::default(), Content::Text(TextContent::new("")));
631    }
632
633    /// Annotations with only `audience` and `priority` do not raise a
634    /// requirement.
635    ///
636    /// This test previously asserted the same equality with a different
637    /// reason: that a `2024-11-05` peer "simply ignores" annotations. It does
638    /// not ignore them — `2024-11-05`'s `TextContent` has an
639    /// `annotations: Option<Value>`, untyped but present. So the value can be
640    /// carried, and requiring a feature for it would refuse something the
641    /// revision can express. The conclusion was right and the reasoning wrong,
642    /// which is worth replacing rather than leaving to mislead the next reader.
643    #[test]
644    fn plain_annotations_do_not_raise_the_requirement() {
645        let plain = Content::Text(TextContent::new("x"));
646        let annotated = Content::Text(TextContent {
647            annotations: Some(Annotations {
648                audience: vec![Role::User, Role::Assistant],
649                priority: Some(0.5),
650                ..Annotations::empty()
651            }),
652            ..TextContent::new("x")
653        });
654        assert_eq!(plain.required_features(), annotated.required_features());
655    }
656
657    /// `lastModified` is the one annotation field an older revision cannot
658    /// hold at all — not even untyped — so it is the one that gates.
659    #[test]
660    fn last_modified_raises_the_requirement() {
661        let mut annotations = Annotations::empty();
662        annotations.audience = vec![Role::User];
663        assert!(annotations.required_features().is_empty());
664
665        annotations.last_modified = Some("2025-06-18T00:00:00Z".to_owned());
666        assert!(
667            annotations
668                .required_features()
669                .contains(Feature::ToolAnnotationsLastModified)
670        );
671    }
672
673    /// `priority` survives the IR bit for bit.
674    ///
675    /// It was an `f32`, which is the obvious choice for a `0.0`–`1.0` number
676    /// and the wrong one here. Every revision's generated `Annotations` spells
677    /// the field `f64`, so narrowing on the way in and widening on the way out
678    /// is not a round trip: `0.1_f32` widened is `0.10000000149011612`, and a
679    /// bridge would hand the peer a different number from the one it sent with
680    /// no error anywhere to notice.
681    ///
682    /// The test asserts the narrowing actually loses the value, so that a
683    /// future narrowing of this field to `f32` fails here rather than passing
684    /// a suite that only ever checked `0.5`.
685    #[test]
686    #[expect(
687        clippy::cast_possible_truncation,
688        reason = "the lossy cast is the subject of the test"
689    )]
690    #[expect(clippy::float_cmp, reason = "exact equality is the claim being made")]
691    fn priority_is_not_narrowed_to_f32() {
692        for value in [0.1_f64, 0.3, 0.7] {
693            assert_ne!(
694                f64::from(value as f32),
695                value,
696                "{value} is exactly representable in f32, so this test no longer \
697                 demonstrates why the field is f64"
698            );
699        }
700    }
701
702    /// Icons are `2025-11-25`, so a link carrying them needs a newer revision
703    /// than a link without. Before this the IR had nowhere to put them.
704    #[test]
705    fn icons_raise_a_link_s_requirement() {
706        let bare = ResourceLinkContent::new("file:///a", "a");
707        assert!(
708            !bare
709                .required_features()
710                .contains(Feature::ToolResourceLinkIcons)
711        );
712
713        let with_icons = ResourceLinkContent {
714            icons: vec![ResourceIcon::new("data:image/png;base64,AAA=")],
715            ..ResourceLinkContent::new("file:///a", "a")
716        };
717        assert!(
718            with_icons
719                .required_features()
720                .contains(Feature::ToolResourceLinkIcons)
721        );
722        assert!(
723            with_icons
724                .required_features()
725                .contains(Feature::ToolResourceLinks)
726        );
727    }
728
729    #[test]
730    fn extensions_ride_along_without_affecting_requirements() {
731        let content = Content::Text(TextContent {
732            extensions: Extensions::empty().with("vendor.x", ExtensionValue::Bool(true)),
733            ..TextContent::new("x")
734        });
735        assert!(content.required_features().is_empty());
736    }
737
738    #[test]
739    fn roles_round_trip_through_their_wire_spelling() {
740        for role in [Role::User, Role::Assistant] {
741            assert_eq!(role.as_str().parse::<Role>().unwrap(), role);
742        }
743        assert!("teapot".parse::<Role>().is_err());
744    }
745
746    #[test]
747    fn binary_content_builders_do_not_set_annotations() {
748        let image = ImageContent::new("AAAA", "image/png");
749        assert!(image.annotations.is_none());
750        assert_eq!(image.mime_type, "image/png");
751        let audio = AudioContent::new("BBBB", "audio/wav");
752        assert!(audio.annotations.is_none());
753        assert_eq!(audio.data, "BBBB");
754    }
755
756    #[test]
757    fn resource_link_optional_fields_default_to_none() {
758        let link = ResourceLinkContent::new("file:///x", "x");
759        assert_eq!(link.uri, "file:///x");
760        assert_eq!(link.name, "x");
761        assert!(link.description.is_none());
762        assert!(link.mime_type.is_none());
763        assert!(link.size.is_none());
764    }
765
766    #[test]
767    fn an_embedded_text_resource_has_no_blob() {
768        let resource = EmbeddedResource::text("file:///x", "body");
769        assert_eq!(resource.text.as_deref(), Some("body"));
770        assert!(resource.blob.is_none());
771        assert_eq!(resource.uri, "file:///x");
772    }
773}