Skip to main content

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

1//! The `2025-06-18` `CallToolResult` codec.
2//!
3//! # What this revision adds to `2025-03-26`
4//!
5//! - **Resource links.** A fifth union arm, inserted *before* the embedded
6//!   resource — so the resource moves from fourth to fifth. The union is
7//!   untagged, which makes every index after this one a place a block can
8//!   arrive as the wrong kind of thing without any error.
9//! - **The union is renamed.** `CallToolResultContentChoice` becomes
10//!   `ContentBlock`. Same shape, different name, and the generated `ContentBlock`
11//!   is an alias for `ContentBlockChoice`.
12//! - **`structuredContent` on the result.** A raw JSON object, carried in the IR
13//!   as [`JsonObject`].
14//! - **`lastModified` on annotations.**
15//! - **`meta` on content blocks.** Which is why a block's `meta` is a field from
16//!   here on rather than a bag member, and why the two earlier revisions are
17//!   not simply missing it.
18//!
19//! # `size` is signed on the wire and unsigned here
20//!
21//! `ResourceLink::size` is `i64` and [`ResourceLinkContent::size`] is `u64`, so
22//! a negative value is not representable and is refused rather than clamped. A
23//! negative size is nonsense as data and silently becoming `0` would be a
24//! number the sender never sent.
25
26use protocol_droid_schema::wire::v2025_06_18 as wire;
27
28use super::{AUDIO_TYPE, IMAGE_TYPE, META_KEY, RESOURCE_LINK_TYPE, RESOURCE_TYPE, TEXT_TYPE};
29use crate::domain::ir::Codec;
30use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
31use crate::domain::ir::{
32    Annotations, AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent,
33    JsonObject, ResourceLinkContent, Role, TextContent, ToolResult,
34};
35use crate::domain::{
36    CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
37    resolve,
38};
39
40fn lift_annotations(annotations: &wire::Annotations) -> Annotations {
41    Annotations {
42        audience: annotations
43            .audience
44            .iter()
45            .flatten()
46            .map(|role| match role {
47                wire::Role::ASSISTANT => Role::Assistant,
48                wire::Role::USER => Role::User,
49            })
50            .collect(),
51        priority: annotations.priority,
52        last_modified: annotations.last_modified.clone(),
53        extensions: Extensions::from_json_object(annotations.extra.clone()),
54    }
55}
56
57fn lower_annotations(annotations: Option<&Annotations>) -> Option<wire::Annotations> {
58    let annotations = annotations?;
59    let extra = annotations.extensions.to_json_object();
60    if annotations.audience.is_empty()
61        && annotations.priority.is_none()
62        && annotations.last_modified.is_none()
63        && extra.is_empty()
64    {
65        return None;
66    }
67    Some(wire::Annotations {
68        audience: (!annotations.audience.is_empty()).then(|| {
69            annotations
70                .audience
71                .iter()
72                .map(|role| match role {
73                    Role::Assistant => wire::Role::ASSISTANT,
74                    Role::User => wire::Role::USER,
75                })
76                .collect()
77        }),
78        last_modified: annotations.last_modified.clone(),
79        priority: annotations.priority,
80        extra,
81    })
82}
83
84/// The key a `structuredContent` that is not a JSON object travels under.
85///
86/// The wire type is a bare `serde_json::Value`, so a peer may legally send an
87/// array or a string. [`JsonObject`] holds only objects, so such a value has
88/// nowhere to go in the typed field — and a lift that dropped it would be the
89/// one outcome a lift may not have. It rides in the bag instead, exactly as
90/// unmodelled `annotations` does at `2024-11-05`, and is written back out
91/// ahead of the typed field.
92const RAW_STRUCTURED_KEY: &str = "structuredContent";
93
94/// A block's own `meta`, taken from the extensions on the way down and put
95/// there on the way up.
96///
97/// A block's `meta` is the same concept as the result's, so it shares
98/// [`META_KEY`] rather than each inventing one. There is no collision: a block
99/// and the result it sits in are separate objects with separate bags.
100fn take_meta(extensions: &Extensions) -> Option<serde_json::Value> {
101    let mut extensions = extensions.clone();
102    extensions
103        .take(META_KEY)
104        .map(|value| serde_json::Value::from(&value))
105}
106
107/// The `2025-06-18` codec.
108#[derive(Debug, Clone, Copy)]
109pub struct V2025_06_18;
110
111impl Codec for V2025_06_18 {
112    type Wire = wire::CallToolResult;
113
114    fn version(&self) -> ProtocolVersion {
115        ProtocolVersion::V2025_06_18
116    }
117
118    fn capabilities(&self) -> FeatureProfile {
119        let caps = CapabilitySet::all_for(self.version());
120        resolve(Negotiation {
121            version: self.version(),
122            client_caps: caps,
123            server_caps: caps,
124            transport: TransportKind::Stdio,
125        })
126    }
127
128    fn lift(&self, wire: &Self::Wire) -> ToolResult {
129        let mut extensions = Extensions::from_json_object(wire.extra.clone());
130        if let Some(meta) = &wire.meta {
131            extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
132        }
133        // A `structuredContent` that is not an object has nowhere in the typed
134        // field to go, so it rides in the bag. `.ok()` would be the bug here:
135        // a lift that quietly discarded an array a peer sent is the outcome
136        // this whole trait exists to prevent.
137        let structured_content = match &wire.structured_content {
138            Some(value) if value.is_object() => JsonObject::new(value.to_string()).ok(),
139            Some(value) => {
140                extensions.insert(RAW_STRUCTURED_KEY, ExtensionValue::from(value.clone()));
141                None
142            }
143            None => None,
144        };
145        ToolResult {
146            content: wire.content.iter().map(lift_block).collect(),
147            structured_content,
148            is_error: wire.is_error.unwrap_or(false),
149            result_type: None,
150            extensions,
151        }
152    }
153
154    fn lower(
155        &self,
156        value: ToolResult,
157        policy: DegradePolicy,
158        report: &mut TranslationReport,
159    ) -> Result<Self::Wire, LowerError> {
160        let (value, lowered) =
161            lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
162        report.absorb(&lowered);
163        // A raw value kept on the way in takes precedence: it is what the peer
164        // actually sent, and re-deriving it from the typed field would be
165        // inventing a shape for a value that never had one.
166        let structured_content = match value.extensions.get(RAW_STRUCTURED_KEY) {
167            Some(raw) => Some(serde_json::Value::from(raw)),
168            None => value
169                .structured_content
170                .as_ref()
171                .and_then(|object| serde_json::from_str(object.as_str()).ok()),
172        };
173        Ok(wire::CallToolResult {
174            meta: value.extensions.get(META_KEY).map(serde_json::Value::from),
175            content: value
176                .content
177                .iter()
178                .map(lower_block)
179                .collect::<Result<Vec<_>, _>>()?,
180            is_error: value.is_error.then_some(true),
181            structured_content,
182            extra: value.extensions.to_json_object(),
183        })
184    }
185}
186
187fn lift_block(block: &wire::ContentBlock) -> Content {
188    match block {
189        wire::ContentBlock::Member0(text) => {
190            let mut extensions = Extensions::from_json_object(text.extra.clone());
191            if let Some(meta) = &text.meta {
192                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
193            }
194            Content::Text(TextContent {
195                text: text.text.clone(),
196                annotations: text.annotations.as_ref().map(lift_annotations),
197                extensions,
198            })
199        }
200        wire::ContentBlock::Member1(image) => {
201            let mut extensions = Extensions::from_json_object(image.extra.clone());
202            if let Some(meta) = &image.meta {
203                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
204            }
205            Content::Image(ImageContent {
206                data: image.data.clone(),
207                mime_type: image.mime_type.clone(),
208                annotations: image.annotations.as_ref().map(lift_annotations),
209                extensions,
210            })
211        }
212        wire::ContentBlock::Member2(audio) => {
213            let mut extensions = Extensions::from_json_object(audio.extra.clone());
214            if let Some(meta) = &audio.meta {
215                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
216            }
217            Content::Audio(AudioContent {
218                data: audio.data.clone(),
219                mime_type: audio.mime_type.clone(),
220                annotations: audio.annotations.as_ref().map(lift_annotations),
221                extensions,
222            })
223        }
224        wire::ContentBlock::Member3(link) => {
225            let mut extensions = Extensions::from_json_object(link.extra.clone());
226            if let Some(meta) = &link.meta {
227                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
228            }
229            Content::ResourceLink(ResourceLinkContent {
230                uri: link.uri.clone(),
231                name: link.name.clone(),
232                description: link.description.clone(),
233                mime_type: link.mime_type.clone(),
234                // Negative sizes are refused on the way down; a value that got
235                // here from a peer is not negative, and `as` is the narrowing
236                // that makes the two sides agree.
237                size: link.size.and_then(|size| u64::try_from(size).ok()),
238                // `icons` arrives at 2025-11-25. A value with that spelling is
239                // preserved in `extra` rather than dropped.
240                icons: Vec::new(),
241                title: link.title.clone(),
242                annotations: link.annotations.as_ref().map(lift_annotations),
243                extensions,
244            })
245        }
246        wire::ContentBlock::Member4(resource) => {
247            let wire::EmbeddedResource {
248                meta,
249                annotations,
250                resource,
251                extra,
252                ..
253            } = resource;
254            let (uri, mime_type, text, blob) = match resource {
255                wire::EmbeddedResourceResourceChoice::Member0(text) => (
256                    text.uri.clone(),
257                    text.mime_type.clone(),
258                    Some(text.text.clone()),
259                    None,
260                ),
261                wire::EmbeddedResourceResourceChoice::Member1(blob) => (
262                    blob.uri.clone(),
263                    blob.mime_type.clone(),
264                    None,
265                    Some(blob.blob.clone()),
266                ),
267            };
268            let mut extensions = Extensions::from_json_object(extra.clone());
269            if let Some(meta) = meta {
270                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
271            }
272            Content::EmbeddedResource(EmbeddedResource {
273                uri,
274                mime_type,
275                text,
276                blob,
277                annotations: annotations.as_ref().map(lift_annotations),
278                extensions,
279            })
280        }
281    }
282}
283
284fn lower_block(block: &Content) -> Result<wire::ContentBlock, LowerError> {
285    let lowered = match block {
286        Content::Text(text) => wire::ContentBlock::Member0(wire::TextContent {
287            meta: take_meta(&text.extensions),
288            annotations: lower_annotations(text.annotations.as_ref()),
289            text: text.text.clone(),
290            r#type: TEXT_TYPE.to_owned(),
291            extra: text.extensions.to_json_object(),
292        }),
293        Content::Image(image) => wire::ContentBlock::Member1(wire::ImageContent {
294            meta: take_meta(&image.extensions),
295            annotations: lower_annotations(image.annotations.as_ref()),
296            data: image.data.clone(),
297            mime_type: image.mime_type.clone(),
298            r#type: IMAGE_TYPE.to_owned(),
299            extra: image.extensions.to_json_object(),
300        }),
301        Content::Audio(audio) => wire::ContentBlock::Member2(wire::AudioContent {
302            meta: take_meta(&audio.extensions),
303            annotations: lower_annotations(audio.annotations.as_ref()),
304            data: audio.data.clone(),
305            mime_type: audio.mime_type.clone(),
306            r#type: AUDIO_TYPE.to_owned(),
307            extra: audio.extensions.to_json_object(),
308        }),
309        Content::ResourceLink(link) => {
310            let size = link.size.map_or(Ok(None), |size| {
311                i64::try_from(size)
312                    .map(Some)
313                    .map_err(|_| LowerError::FieldNotRepresentable {
314                        field: "size".to_owned(),
315                        expected: "a non-negative size",
316                        detail: format!("{size} does not fit the i64 this revision uses"),
317                        target: ProtocolVersion::V2025_06_18,
318                    })
319            })?;
320            if !link.icons.is_empty() {
321                return Err(LowerError::FieldNotRepresentable {
322                    field: "icons".to_owned(),
323                    expected: "not a field of 2025-06-18 resource links",
324                    detail: "icons arrive at 2025-11-25".to_owned(),
325                    target: ProtocolVersion::V2025_06_18,
326                });
327            }
328            wire::ContentBlock::Member3(wire::ResourceLink {
329                meta: take_meta(&link.extensions),
330                annotations: lower_annotations(link.annotations.as_ref()),
331                description: link.description.clone(),
332                mime_type: link.mime_type.clone(),
333                name: link.name.clone(),
334                size,
335                title: link.title.clone(),
336                r#type: RESOURCE_LINK_TYPE.to_owned(),
337                uri: link.uri.clone(),
338                extra: link.extensions.to_json_object(),
339            })
340        }
341        Content::EmbeddedResource(resource) => lower_embedded(resource)?,
342        Content::Unknown(unknown) => {
343            return Err(LowerError::FieldNotRepresentable {
344                field: unknown.type_name.clone(),
345                expected: "a 2025-06-18 content block",
346                detail: "this build does not model the block, and this revision has no \
347                         member that could carry it"
348                    .to_owned(),
349                target: ProtocolVersion::V2025_06_18,
350            });
351        }
352    };
353    Ok(lowered)
354}
355
356/// Lowers an embedded resource, whose body is a union of its own.
357///
358/// Split out of [`lower_block`] because it is the only arm that has to choose
359/// between two wire types rather than fill one in.
360fn lower_embedded(resource: &EmbeddedResource) -> Result<wire::ContentBlock, LowerError> {
361    let contents = if let Some(text) = &resource.text {
362        wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
363            meta: None,
364            mime_type: resource.mime_type.clone(),
365            text: text.clone(),
366            uri: resource.uri.clone(),
367            extra: serde_json::Map::new(),
368        })
369    } else if let Some(blob) = &resource.blob {
370        wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
371            meta: None,
372            blob: blob.clone(),
373            mime_type: resource.mime_type.clone(),
374            uri: resource.uri.clone(),
375            extra: serde_json::Map::new(),
376        })
377    } else {
378        return Err(LowerError::FieldNotRepresentable {
379            field: "resource".to_owned(),
380            expected: "a text or blob body",
381            detail: "the resource has neither `text` nor `blob`".to_owned(),
382            target: ProtocolVersion::V2025_06_18,
383        });
384    };
385    Ok(wire::ContentBlock::Member4(wire::EmbeddedResource {
386        meta: take_meta(&resource.extensions),
387        annotations: lower_annotations(resource.annotations.as_ref()),
388        resource: contents,
389        r#type: RESOURCE_TYPE.to_owned(),
390        extra: resource.extensions.to_json_object(),
391    }))
392}
393#[cfg(test)]
394mod tests {
395    use super::V2025_06_18;
396    use crate::domain::DegradePolicy;
397    use crate::domain::ir::Codec;
398    use crate::domain::ir::lower::{LowerError, TranslationReport};
399    use crate::domain::ir::{Content, ResourceLinkContent, ToolResult};
400    use crate::domain::{Feature, ProtocolVersion};
401
402    /// What a test fails with.
403    ///
404    /// One type for every error a test body can hit, so `?` carries a codec
405    /// error, a JSON error or a bare message alike and the harness prints it
406    /// rather than the test having to choose between three error types.
407    type TestError = Box<dyn std::error::Error>;
408
409    fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
410        let codec = V2025_06_18;
411        let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
412        let mut report = TranslationReport::new(codec.version());
413        let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
414        Ok(serde_json::to_value(&lowered)?)
415    }
416
417    /// A resource link is inserted as the *fourth* arm here, which pushes the
418    /// embedded resource to fifth. The union is untagged, so getting that wrong
419    /// does not fail — a link would arrive as a resource and vice versa.
420    #[test]
421    fn a_link_and_a_resource_land_in_different_arms() -> Result<(), TestError> {
422        let codec = V2025_06_18;
423        let link: super::wire::CallToolResult = serde_json::from_str(
424            r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a"}]}"#,
425        )?;
426        let resource: super::wire::CallToolResult = serde_json::from_str(
427            r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"b"}}]}"#,
428        )?;
429
430        assert!(matches!(
431            codec
432                .lift(&link)
433                .content
434                .first()
435                .ok_or("the link has no blocks")?,
436            Content::ResourceLink(_)
437        ));
438        assert!(matches!(
439            codec
440                .lift(&resource)
441                .content
442                .first()
443                .ok_or("the resource has no blocks")?,
444            Content::EmbeddedResource(_)
445        ));
446        Ok(())
447    }
448
449    #[test]
450    fn a_result_round_trips_through_json() -> Result<(), TestError> {
451        for json in [
452            r#"{"content":[{"type":"text","text":"hello"}]}"#,
453            r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"body"}}]}"#,
454            r#"{"content":[],"structuredContent":{"a":1}}"#,
455            r#"{"content":[{"type":"text","text":"x","annotations":{"lastModified":"2025-06-18T00:00:00Z","priority":0.1}}]}"#,
456        ] {
457            assert_eq!(
458                round_trip(json)?,
459                serde_json::from_str::<serde_json::Value>(json)?,
460                "did not survive the crossing"
461            );
462        }
463        Ok(())
464    }
465
466    /// A block gains its own `_meta` at this revision, and it has to come back
467    /// as a field rather than as a member of the catch-all bag.
468    #[test]
469    fn a_blocks_meta_survives_as_a_field() -> Result<(), TestError> {
470        let json = r#"{"content":[{"type":"text","text":"x","_meta":{"tag":"a"}}]}"#;
471        let codec = V2025_06_18;
472        let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
473        let ir = codec.lift(&wire);
474
475        let mut report = TranslationReport::new(codec.version());
476        let lowered = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
477        let block = lowered.content.first().ok_or("the result has no blocks")?;
478        let super::wire::ContentBlock::Member0(text) = block else {
479            return Err("a text block must lower to the text arm".into());
480        };
481        assert_eq!(text.meta, Some(serde_json::json!({"tag": "a"})));
482        Ok(())
483    }
484
485    /// The wire type is a bare `Value`, so a peer may send a `structuredContent`
486    /// that is not an object. The IR's field holds only objects, so the value
487    /// rides in the bag — dropping it would be the one thing a lift may not do.
488    #[test]
489    fn a_non_object_structured_content_survives_whole() -> Result<(), TestError> {
490        let json = r#"{"content":[],"structuredContent":[1,2]}"#;
491        assert_eq!(
492            round_trip(json)?,
493            serde_json::from_str::<serde_json::Value>(json)?
494        );
495        Ok(())
496    }
497
498    /// `size` is `i64` on the wire and `u64` here, so a size past `i64::MAX`
499    /// has no representation and is refused rather than clamped to something the
500    /// sender did not write.
501    #[test]
502    fn a_size_this_revision_cannot_hold_is_refused() -> Result<(), TestError> {
503        let codec = V2025_06_18;
504        let ir = ToolResult::new(vec![Content::ResourceLink(ResourceLinkContent {
505            size: Some(u64::MAX),
506            ..ResourceLinkContent::new("file:///a", "a")
507        })]);
508
509        let mut report = TranslationReport::new(codec.version());
510        let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
511            return Err("i64 cannot hold this size, so it must be refused".into());
512        };
513        assert!(error.to_string().contains("size"), "{error}");
514        Ok(())
515    }
516
517    /// The gate refuses `icons` before the codec is reached.
518    ///
519    /// `ResourceLinkContent::required_features` raises the feature when the
520    /// list is non-empty, and the matrix puts it at `2025-11-25`, so a
521    /// `2025-06-18` peer has no field to put them in. Asserted on the error's
522    /// value rather than its `Display`, for the reason given in the
523    /// `2025-03-26` codec's equivalent test.
524    #[test]
525    fn icons_are_refused_by_the_gate_rather_than_dropped() -> Result<(), TestError> {
526        use std::assert_matches;
527
528        let codec = V2025_06_18;
529        let link = ResourceLinkContent {
530            icons: vec![crate::domain::ir::ResourceIcon::new("file:///i.png")],
531            ..ResourceLinkContent::new("file:///a", "a")
532        };
533
534        let mut report = TranslationReport::new(codec.version());
535        let Err(error) = codec.lower(
536            ToolResult::new(vec![Content::ResourceLink(link)]),
537            DegradePolicy::strict(),
538            &mut report,
539        ) else {
540            return Err("icons arrive at 2025-11-25, so this must be refused".into());
541        };
542        assert_matches!(
543            error,
544            LowerError::FeatureUnavailable {
545                feature: Feature::ToolResourceLinkIcons,
546                negotiated: ProtocolVersion::V2025_06_18,
547            }
548        );
549        Ok(())
550    }
551}