Skip to main content

protocol_droid/adapters/secondary/codec/result/
v2025_11_25.rs

1//! The `2025-11-25` `CallToolResult` codec.
2//!
3//! # What this revision adds to `2025-06-18`
4//!
5//! Exactly one field on one type: **`icons` on a resource link.** The
6//! `Icon` carries `src`, `mimeType`, `sizes` and `theme`, and all four are
7//! modelled — `theme` as a raw value, because every revision that has it
8//! writes it as free JSON.
9//!
10//! This is the first revision where a resource link's icon list is a typed
11//! field rather than something the earlier codecs have to preserve in `extra`.
12//! They still do: a `2025-06-18` link carrying `icons` keeps them in its
13//! `extra` bag, so a bridge forwards them unchanged rather than dropping them.
14//!
15//! Everything else about the content union is unchanged from `2025-06-18` —
16//! same five arms in the same order, so `Member3` is still the resource link.
17//! That is worth stating because the wire union is untagged: an arm index is
18//! not self-describing, and the arm order is the only thing keeping
19//! [`Content::Audio`] from arriving as an image.
20//!
21//! # `size` is signed on the wire and unsigned here
22//!
23//! `ResourceLink::size` is `i64` and [`ResourceLinkContent::size`] is `u64`,
24//! so a negative value is not representable and is refused rather than
25//! clamped. A negative size is nonsense as data, and silently becoming `0`
26//! would be a number the sender never sent.
27
28use protocol_droid_schema::wire::v2025_11_25 as wire;
29
30use super::{AUDIO_TYPE, IMAGE_TYPE, META_KEY, RESOURCE_LINK_TYPE, RESOURCE_TYPE, TEXT_TYPE};
31use crate::domain::ir::Codec;
32use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
33use crate::domain::ir::{
34    Annotations, AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent,
35    JsonObject, ResourceIcon, ResourceLinkContent, Role, TextContent, ToolResult,
36};
37use crate::domain::{
38    CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
39    resolve,
40};
41
42fn lift_annotations(annotations: &wire::Annotations) -> Annotations {
43    Annotations {
44        audience: annotations
45            .audience
46            .iter()
47            .flatten()
48            .map(|role| match role {
49                wire::Role::ASSISTANT => Role::Assistant,
50                wire::Role::USER => Role::User,
51            })
52            .collect(),
53        priority: annotations.priority,
54        last_modified: annotations.last_modified.clone(),
55        extensions: Extensions::from_json_object(annotations.extra.clone()),
56    }
57}
58
59fn lower_annotations(annotations: Option<&Annotations>) -> Option<wire::Annotations> {
60    let annotations = annotations?;
61    let extra = annotations.extensions.to_json_object();
62    if annotations.audience.is_empty()
63        && annotations.priority.is_none()
64        && annotations.last_modified.is_none()
65        && extra.is_empty()
66    {
67        return None;
68    }
69    Some(wire::Annotations {
70        audience: (!annotations.audience.is_empty()).then(|| {
71            annotations
72                .audience
73                .iter()
74                .map(|role| match role {
75                    Role::Assistant => wire::Role::ASSISTANT,
76                    Role::User => wire::Role::USER,
77                })
78                .collect()
79        }),
80        last_modified: annotations.last_modified.clone(),
81        priority: annotations.priority,
82        extra,
83    })
84}
85
86/// The key a `structuredContent` that is not a JSON object travels under.
87///
88/// The wire type is a bare `serde_json::Value`, so a peer may legally send an
89/// array or a string. [`JsonObject`] holds only objects, so such a value has
90/// nowhere to go in the typed field — and a lift that dropped it would be the
91/// one outcome a lift may not have. It rides in the bag instead, exactly as
92/// unmodelled `annotations` does at `2024-11-05`, and is written back out
93/// ahead of the typed field.
94const RAW_STRUCTURED_KEY: &str = "structuredContent";
95
96/// A block's own `meta`, taken from the extensions on the way down and put
97/// there on the way up.
98///
99/// A block's `meta` is the same concept as the result's, so it shares
100/// [`META_KEY`] rather than each inventing one. There is no collision: a block
101/// and the result it sits in are separate objects with separate bags.
102fn take_meta(extensions: &Extensions) -> Option<serde_json::Value> {
103    let mut extensions = extensions.clone();
104    extensions
105        .take(META_KEY)
106        .map(|value| serde_json::Value::from(&value))
107}
108
109/// The `2025-11-25` codec.
110#[derive(Debug, Clone, Copy)]
111pub struct V2025_11_25;
112
113impl Codec for V2025_11_25 {
114    type Wire = wire::CallToolResult;
115
116    fn version(&self) -> ProtocolVersion {
117        ProtocolVersion::V2025_11_25
118    }
119
120    fn capabilities(&self) -> FeatureProfile {
121        let caps = CapabilitySet::all_for(self.version());
122        resolve(Negotiation {
123            version: self.version(),
124            client_caps: caps,
125            server_caps: caps,
126            transport: TransportKind::Stdio,
127        })
128    }
129
130    fn lift(&self, wire: &Self::Wire) -> ToolResult {
131        let mut extensions = Extensions::from_json_object(wire.extra.clone());
132        if let Some(meta) = &wire.meta {
133            extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
134        }
135        // A `structuredContent` that is not an object has nowhere in the typed
136        // field to go, so it rides in the bag. `.ok()` would be the bug here:
137        // a lift that quietly discarded an array a peer sent is the outcome
138        // this whole trait exists to prevent.
139        let structured_content = match &wire.structured_content {
140            Some(value) if value.is_object() => JsonObject::new(value.to_string()).ok(),
141            Some(value) => {
142                extensions.insert(RAW_STRUCTURED_KEY, ExtensionValue::from(value.clone()));
143                None
144            }
145            None => None,
146        };
147        ToolResult {
148            content: wire.content.iter().map(lift_block).collect(),
149            structured_content,
150            is_error: wire.is_error.unwrap_or(false),
151            result_type: None,
152            extensions,
153        }
154    }
155
156    fn lower(
157        &self,
158        value: ToolResult,
159        policy: DegradePolicy,
160        report: &mut TranslationReport,
161    ) -> Result<Self::Wire, LowerError> {
162        let (value, lowered) =
163            lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
164        report.absorb(&lowered);
165        // A raw value kept on the way in takes precedence: it is what the peer
166        // actually sent, and re-deriving it from the typed field would be
167        // inventing a shape for a value that never had one.
168        let structured_content = match value.extensions.get(RAW_STRUCTURED_KEY) {
169            Some(raw) => Some(serde_json::Value::from(raw)),
170            None => value
171                .structured_content
172                .as_ref()
173                .and_then(|object| serde_json::from_str(object.as_str()).ok()),
174        };
175        Ok(wire::CallToolResult {
176            meta: value.extensions.get(META_KEY).map(serde_json::Value::from),
177            content: value
178                .content
179                .iter()
180                .map(lower_block)
181                .collect::<Result<Vec<_>, _>>()?,
182            is_error: value.is_error.then_some(true),
183            structured_content,
184            extra: value.extensions.to_json_object(),
185        })
186    }
187}
188
189/// Lifts one wire [`Icon`][w].
190///
191/// [w]: wire::Icon
192fn lift_icon(icon: &wire::Icon) -> ResourceIcon {
193    ResourceIcon {
194        src: icon.src.clone(),
195        mime_type: icon.mime_type.clone(),
196        // The wire carries an absent `sizes` and an empty one as the same
197        // thing, and the IR spells the empty case as an empty `Vec`. `flatten`
198        // is what does that without an `unwrap_or_default`, which would hide
199        // a decision rather than record it.
200        sizes: icon.sizes.clone().unwrap_or_default(),
201        theme: icon.theme.clone(),
202        extensions: Extensions::from_json_object(icon.extra.clone()),
203    }
204}
205
206/// Lowers one [`ResourceIcon`] to the wire type.
207///
208/// `theme` is untyped in the specification — every revision that has it writes
209/// it as free JSON — so it is carried as a raw value and put back exactly as
210/// it arrived, with no parsing in either direction. Anything narrower here
211/// would reject a value the schema accepts.
212fn lower_icon(icon: &ResourceIcon) -> wire::Icon {
213    wire::Icon {
214        src: icon.src.clone(),
215        mime_type: icon.mime_type.clone(),
216        sizes: (!icon.sizes.is_empty()).then(|| icon.sizes.clone()),
217        theme: icon.theme.clone(),
218        extra: icon.extensions.to_json_object(),
219    }
220}
221
222fn lift_block(block: &wire::ContentBlock) -> Content {
223    match block {
224        wire::ContentBlock::Member0(text) => {
225            let mut extensions = Extensions::from_json_object(text.extra.clone());
226            if let Some(meta) = &text.meta {
227                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
228            }
229            Content::Text(TextContent {
230                text: text.text.clone(),
231                annotations: text.annotations.as_ref().map(lift_annotations),
232                extensions,
233            })
234        }
235        wire::ContentBlock::Member1(image) => {
236            let mut extensions = Extensions::from_json_object(image.extra.clone());
237            if let Some(meta) = &image.meta {
238                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
239            }
240            Content::Image(ImageContent {
241                data: image.data.clone(),
242                mime_type: image.mime_type.clone(),
243                annotations: image.annotations.as_ref().map(lift_annotations),
244                extensions,
245            })
246        }
247        wire::ContentBlock::Member2(audio) => {
248            let mut extensions = Extensions::from_json_object(audio.extra.clone());
249            if let Some(meta) = &audio.meta {
250                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
251            }
252            Content::Audio(AudioContent {
253                data: audio.data.clone(),
254                mime_type: audio.mime_type.clone(),
255                annotations: audio.annotations.as_ref().map(lift_annotations),
256                extensions,
257            })
258        }
259        wire::ContentBlock::Member3(link) => {
260            let mut extensions = Extensions::from_json_object(link.extra.clone());
261            if let Some(meta) = &link.meta {
262                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
263            }
264            Content::ResourceLink(ResourceLinkContent {
265                uri: link.uri.clone(),
266                name: link.name.clone(),
267                description: link.description.clone(),
268                mime_type: link.mime_type.clone(),
269                // Negative sizes are refused on the way down; a value that got
270                // here from a peer is not negative, and `as` is the narrowing
271                // that makes the two sides agree.
272                size: link.size.and_then(|size| u64::try_from(size).ok()),
273                icons: link.icons.iter().flatten().map(lift_icon).collect(),
274                title: link.title.clone(),
275                annotations: link.annotations.as_ref().map(lift_annotations),
276                extensions,
277            })
278        }
279        wire::ContentBlock::Member4(resource) => {
280            let wire::EmbeddedResource {
281                meta,
282                annotations,
283                resource,
284                extra,
285                ..
286            } = resource;
287            let (uri, mime_type, text, blob) = match resource {
288                wire::EmbeddedResourceResourceChoice::Member0(text) => (
289                    text.uri.clone(),
290                    text.mime_type.clone(),
291                    Some(text.text.clone()),
292                    None,
293                ),
294                wire::EmbeddedResourceResourceChoice::Member1(blob) => (
295                    blob.uri.clone(),
296                    blob.mime_type.clone(),
297                    None,
298                    Some(blob.blob.clone()),
299                ),
300            };
301            let mut extensions = Extensions::from_json_object(extra.clone());
302            if let Some(meta) = meta {
303                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
304            }
305            Content::EmbeddedResource(EmbeddedResource {
306                uri,
307                mime_type,
308                text,
309                blob,
310                annotations: annotations.as_ref().map(lift_annotations),
311                extensions,
312            })
313        }
314    }
315}
316
317fn lower_block(block: &Content) -> Result<wire::ContentBlock, LowerError> {
318    let lowered = match block {
319        Content::Text(text) => wire::ContentBlock::Member0(wire::TextContent {
320            meta: take_meta(&text.extensions),
321            annotations: lower_annotations(text.annotations.as_ref()),
322            text: text.text.clone(),
323            r#type: TEXT_TYPE.to_owned(),
324            extra: text.extensions.to_json_object(),
325        }),
326        Content::Image(image) => wire::ContentBlock::Member1(wire::ImageContent {
327            meta: take_meta(&image.extensions),
328            annotations: lower_annotations(image.annotations.as_ref()),
329            data: image.data.clone(),
330            mime_type: image.mime_type.clone(),
331            r#type: IMAGE_TYPE.to_owned(),
332            extra: image.extensions.to_json_object(),
333        }),
334        Content::Audio(audio) => wire::ContentBlock::Member2(wire::AudioContent {
335            meta: take_meta(&audio.extensions),
336            annotations: lower_annotations(audio.annotations.as_ref()),
337            data: audio.data.clone(),
338            mime_type: audio.mime_type.clone(),
339            r#type: AUDIO_TYPE.to_owned(),
340            extra: audio.extensions.to_json_object(),
341        }),
342        Content::ResourceLink(link) => {
343            let size = link.size.map_or(Ok(None), |size| {
344                i64::try_from(size)
345                    .map(Some)
346                    .map_err(|_| LowerError::FieldNotRepresentable {
347                        field: "size".to_owned(),
348                        expected: "a non-negative size",
349                        detail: format!("{size} does not fit the i64 this revision uses"),
350                        target: ProtocolVersion::V2025_11_25,
351                    })
352            })?;
353            wire::ContentBlock::Member3(wire::ResourceLink {
354                meta: take_meta(&link.extensions),
355                annotations: lower_annotations(link.annotations.as_ref()),
356                description: link.description.clone(),
357                mime_type: link.mime_type.clone(),
358                name: link.name.clone(),
359                size,
360                title: link.title.clone(),
361                r#type: RESOURCE_LINK_TYPE.to_owned(),
362                uri: link.uri.clone(),
363                icons: (!link.icons.is_empty())
364                    .then(|| link.icons.iter().map(lower_icon).collect()),
365                extra: link.extensions.to_json_object(),
366            })
367        }
368        Content::EmbeddedResource(resource) => {
369            let contents = if let Some(text) = &resource.text {
370                wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
371                    meta: None,
372                    mime_type: resource.mime_type.clone(),
373                    text: text.clone(),
374                    uri: resource.uri.clone(),
375                    extra: serde_json::Map::new(),
376                })
377            } else if let Some(blob) = &resource.blob {
378                wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
379                    meta: None,
380                    blob: blob.clone(),
381                    mime_type: resource.mime_type.clone(),
382                    uri: resource.uri.clone(),
383                    extra: serde_json::Map::new(),
384                })
385            } else {
386                return Err(LowerError::FieldNotRepresentable {
387                    field: "resource".to_owned(),
388                    expected: "a text or blob body",
389                    detail: "the resource has neither `text` nor `blob`".to_owned(),
390                    target: ProtocolVersion::V2025_11_25,
391                });
392            };
393            wire::ContentBlock::Member4(wire::EmbeddedResource {
394                meta: take_meta(&resource.extensions),
395                annotations: lower_annotations(resource.annotations.as_ref()),
396                resource: contents,
397                r#type: RESOURCE_TYPE.to_owned(),
398                extra: resource.extensions.to_json_object(),
399            })
400        }
401        Content::Unknown(unknown) => {
402            return Err(LowerError::FieldNotRepresentable {
403                field: unknown.type_name.clone(),
404                expected: "a 2025-11-25 content block",
405                detail: "this build does not model the block, and this revision has no \
406                         member that could carry it"
407                    .to_owned(),
408                target: ProtocolVersion::V2025_11_25,
409            });
410        }
411    };
412    Ok(lowered)
413}
414
415#[cfg(test)]
416mod tests {
417    /// What a test fails with.
418    ///
419    /// One type for every error a test body can hit, so `?` carries a codec
420    /// error, a JSON error or a bare message alike and the harness prints it
421    /// rather than the test having to choose between three error types.
422    type TestError = Box<dyn std::error::Error>;
423
424    use super::V2025_11_25;
425    use crate::domain::DegradePolicy;
426    use crate::domain::ir::Codec;
427    use crate::domain::ir::lower::TranslationReport;
428    use crate::domain::ir::{Content, ResourceIcon, ResourceLinkContent, ToolResult};
429
430    fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
431        let codec = V2025_11_25;
432        let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
433        let mut report = TranslationReport::new(codec.version());
434        let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
435        Ok(serde_json::to_value(&lowered)?)
436    }
437
438    /// The revision is otherwise identical to `2025-06-18`, so the arm order
439    /// is the same claim: `Member3` is the resource link, `Member4` the
440    /// embedded resource. Getting that wrong makes a link arrive as a
441    /// resource or the reverse, with no error anywhere.
442    #[test]
443    fn a_resource_link_is_the_fourth_arm() -> Result<(), TestError> {
444        let value =
445            round_trip(r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a"}]}"#)?;
446        let content = value
447            .get("content")
448            .and_then(|c| c.as_array())
449            .and_then(|a| a.first())
450            .ok_or("the result has no blocks")?;
451        assert_eq!(
452            content.get("type").and_then(|t| t.as_str()),
453            Some("resource_link")
454        );
455        Ok(())
456    }
457
458    /// An audio block is `{data, mimeType}` and an image block is the same
459    /// shape. Tried in order, audio lands in the image arm and comes back
460    /// claiming to be one.
461    #[test]
462    fn an_audio_block_is_not_returned_as_an_image() -> Result<(), TestError> {
463        let value =
464            round_trip(r#"{"content":[{"type":"audio","data":"AAA","mimeType":"audio/wav"}]}"#)?;
465        let content = value
466            .get("content")
467            .and_then(|c| c.as_array())
468            .and_then(|a| a.first())
469            .ok_or("the result has no blocks")?;
470        assert_eq!(content.get("type").and_then(|t| t.as_str()), Some("audio"));
471        Ok(())
472    }
473
474    /// The whole point of this revision. Every modelled `Icon` member has to
475    /// survive, and `theme` has to come back as the value that was sent rather
476    /// than as a stringified one.
477    #[test]
478    fn every_icon_member_survives_the_crossing() -> Result<(), TestError> {
479        let value = round_trip(
480            r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
481                 "icons":[{"src":"file:///i.png","mimeType":"image/png",
482                           "sizes":["48x48","any"],"theme":"dark"},
483                          {"src":"file:///j.svg"}]}]}"#,
484        )?;
485        let icons = value
486            .get("content")
487            .and_then(|c| c.as_array())
488            .and_then(|a| a.first())
489            .and_then(|b| b.get("icons"))
490            .and_then(|i| i.as_array())
491            .ok_or("the fixture has no blocks where the test expects one")?;
492        assert_eq!(icons.len(), 2);
493        assert_eq!(
494            icons
495                .first()
496                .ok_or("the fixture has no icon there")?
497                .get("src")
498                .and_then(|s| s.as_str()),
499            Some("file:///i.png")
500        );
501        assert_eq!(
502            icons
503                .first()
504                .ok_or("the fixture has no icon there")?
505                .get("mimeType")
506                .and_then(|s| s.as_str()),
507            Some("image/png")
508        );
509        assert_eq!(
510            icons
511                .first()
512                .ok_or("the fixture has no icon there")?
513                .get("sizes")
514                .and_then(|s| s.as_array())
515                .map(Vec::len),
516            Some(2)
517        );
518        assert_eq!(
519            icons
520                .first()
521                .ok_or("the fixture has no icon there")?
522                .get("theme")
523                .and_then(|t| t.as_str()),
524            Some("dark")
525        );
526        // An icon with only `src` must not gain the members it never sent.
527        assert_eq!(
528            icons
529                .get(1)
530                .ok_or("the fixture has no icon there")?
531                .get("src")
532                .and_then(|s| s.as_str()),
533            Some("file:///j.svg")
534        );
535        assert!(
536            icons
537                .get(1)
538                .ok_or("the fixture has no icon there")?
539                .get("sizes")
540                .is_none(),
541            "an absent list is not an empty one"
542        );
543        Ok(())
544    }
545
546    /// An empty icon list and an absent one are the same thing on the wire, and
547    /// a codec that re-emits `"icons": []` makes a bridge's output differ from
548    /// its input for no protocol reason.
549    #[test]
550    fn an_empty_icon_list_is_not_written_out() -> Result<(), TestError> {
551        let value = round_trip(
552            r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
553                 "icons":[]}]}"#,
554        )?;
555        let block = value
556            .get("content")
557            .and_then(|c| c.as_array())
558            .and_then(|a| a.first())
559            .ok_or("the result has no blocks")?;
560        assert!(
561            block.get("icons").is_none(),
562            "got {}",
563            block.get("icons").unwrap_or(&serde_json::Value::Null)
564        );
565        Ok(())
566    }
567
568    /// An `Icon` the schema does not name still has to come back. The bag is
569    /// the whole reason a bridge can forward a field the peer sent that this
570    /// build has never heard of.
571    #[test]
572    fn an_unmodelled_icon_member_is_returned_whole() -> Result<(), TestError> {
573        let value = round_trip(
574            r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
575                 "icons":[{"src":"file:///i.png","unheardOf":true}]}]}"#,
576        )?;
577        let icon = value
578            .get("content")
579            .and_then(|c| c.as_array())
580            .and_then(|a| a.first())
581            .and_then(|b| b.get("icons"))
582            .and_then(|i| i.as_array())
583            .and_then(|a| a.first())
584            .ok_or("the result has no blocks")?;
585        assert_eq!(
586            icon.get("unheardOf").and_then(serde_json::Value::as_bool),
587            Some(true)
588        );
589        Ok(())
590    }
591
592    /// The IR type, built by hand rather than lifted, has to reach the wire
593    /// with every member intact. A lift-only test cannot catch a lowering that
594    /// drops a field the lift happens to always fill.
595    #[test]
596    fn a_hand_built_icon_reaches_the_wire() -> Result<(), TestError> {
597        let codec = V2025_11_25;
598        let ir = ToolResult::new(vec![Content::ResourceLink(ResourceLinkContent {
599            icons: vec![ResourceIcon {
600                src: "file:///i.png".to_owned(),
601                mime_type: Some("image/svg+xml".to_owned()),
602                sizes: vec!["96x96".to_owned()],
603                ..ResourceIcon::new("")
604            }],
605            ..ResourceLinkContent::new("file:///a", "a")
606        })]);
607        let mut report = TranslationReport::new(codec.version());
608        let wire = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
609        let value = serde_json::to_value(&wire)?;
610        let icon = value
611            .get("content")
612            .and_then(|c| c.as_array())
613            .and_then(|a| a.first())
614            .and_then(|b| b.get("icons"))
615            .and_then(|i| i.as_array())
616            .and_then(|a| a.first())
617            .ok_or("the result has no blocks")?;
618        assert_eq!(
619            icon.get("mimeType").and_then(|s| s.as_str()),
620            Some("image/svg+xml")
621        );
622        assert_eq!(
623            icon.get("sizes").and_then(|s| s.as_array()).map(Vec::len),
624            Some(1)
625        );
626        Ok(())
627    }
628}