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}