Skip to main content

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

1//! The `2025-03-26` `CallToolResult` codec.
2//!
3//! # What this revision adds to `2024-11-05`
4//!
5//! - **Audio content.** "Added support for audio data" — the union's third arm
6//!   is audio and the embedded resource moves to fourth. The order matters:
7//!   the union is untagged, so every index below this one shifts too.
8//! - **Typed `annotations`.** A real `Annotations` struct rather than a bare
9//!   `serde_json::Value`, with an `extra` bag for what it does not name.
10//!
11//! # What it does not have
12//!
13//! No `lastModified` on annotations — that arrives at `2025-06-18`. No `meta`
14//! on content blocks. No resource links. No `structuredContent` on the result.
15//!
16//! The one consequence worth stating: an `Annotations` carrying `lastModified`
17//! cannot be written here, because the struct has no field to put it in and
18//! `extra` is not a substitute — see
19//! [`LowerError::FieldNotRepresentable`](crate::domain::ir::lower::LowerError).
20//! The gate catches it first, on `ToolAnnotationsLastModified`.
21
22use protocol_droid_schema::wire::v2025_03_26 as wire;
23
24use super::{AUDIO_TYPE, IMAGE_TYPE, META_KEY, RESOURCE_TYPE, TEXT_TYPE};
25use crate::domain::ir::Codec;
26use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
27use crate::domain::ir::{
28    Annotations, AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent,
29    Role, TextContent, ToolResult,
30};
31use crate::domain::{
32    CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
33    resolve,
34};
35
36/// Lifts this revision's typed `annotations`.
37///
38/// The `extra` bag goes into [`Annotations::extensions`] so a member this build
39/// does not model is still carried. A total lift: there is no value a peer can
40/// legitimately send here that this cannot represent.
41fn lift_annotations(annotations: &wire::Annotations) -> Annotations {
42    Annotations {
43        audience: annotations
44            .audience
45            .iter()
46            .flatten()
47            .map(|role| match role {
48                wire::Role::ASSISTANT => Role::Assistant,
49                wire::Role::USER => Role::User,
50            })
51            .collect(),
52        priority: annotations.priority,
53        // `lastModified` does not exist at this revision, so there is no field
54        // it could have arrived in. Anything with that spelling is in `extra`,
55        // where the lift below preserves it.
56        last_modified: None,
57        extensions: Extensions::from_json_object(annotations.extra.clone()),
58    }
59}
60
61/// Renders typed `annotations` back out.
62///
63/// `last_modified` is refused rather than dropped. The gate is what should have
64/// caught it, on `ToolAnnotationsLastModified`; reaching here means the gate
65/// admitted something it should not have, and writing the value into `extra`
66/// would be the laundered version of the same failure the level-4 `meta` case
67/// is about — a document the reader cannot parse, built by us instead of
68/// reported by us.
69fn lower_annotations(
70    annotations: Option<&Annotations>,
71) -> Result<Option<wire::Annotations>, LowerError> {
72    let Some(annotations) = annotations else {
73        return Ok(None);
74    };
75    if annotations.last_modified.is_some() {
76        return Err(LowerError::FieldNotRepresentable {
77            field: "annotations.lastModified".to_owned(),
78            expected: "not a field of 2025-03-26 annotations",
79            detail: "the value carries `lastModified`, which this revision's \
80                     `Annotations` struct has no member for"
81                .to_owned(),
82            target: ProtocolVersion::V2025_03_26,
83        });
84    }
85
86    let extra = annotations.extensions.to_json_object();
87    if annotations.audience.is_empty() && annotations.priority.is_none() && extra.is_empty() {
88        return Ok(None);
89    }
90    Ok(Some(wire::Annotations {
91        audience: (!annotations.audience.is_empty()).then(|| {
92            annotations
93                .audience
94                .iter()
95                .map(|role| match role {
96                    Role::Assistant => wire::Role::ASSISTANT,
97                    Role::User => wire::Role::USER,
98                })
99                .collect()
100        }),
101        priority: annotations.priority,
102        extra,
103    }))
104}
105
106/// The `2025-03-26` codec.
107#[derive(Debug, Clone, Copy)]
108pub struct V2025_03_26;
109
110impl Codec for V2025_03_26 {
111    type Wire = wire::CallToolResult;
112
113    fn version(&self) -> ProtocolVersion {
114        ProtocolVersion::V2025_03_26
115    }
116
117    fn capabilities(&self) -> FeatureProfile {
118        let caps = CapabilitySet::all_for(self.version());
119        resolve(Negotiation {
120            version: self.version(),
121            client_caps: caps,
122            server_caps: caps,
123            transport: TransportKind::Stdio,
124        })
125    }
126
127    fn lift(&self, wire: &Self::Wire) -> ToolResult {
128        let mut extensions = Extensions::from_json_object(wire.extra.clone());
129        if let Some(meta) = &wire.meta {
130            extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
131        }
132        ToolResult {
133            content: wire.content.iter().map(lift_block).collect(),
134            // `structuredContent` does not exist at this revision. A value with
135            // one cannot have been lifted from this revision, and a codec that
136            // invented a place to put it would be inventing a field.
137            structured_content: None,
138            is_error: wire.is_error.unwrap_or(false),
139            result_type: None,
140            extensions,
141        }
142    }
143
144    fn lower(
145        &self,
146        value: ToolResult,
147        policy: DegradePolicy,
148        report: &mut TranslationReport,
149    ) -> Result<Self::Wire, LowerError> {
150        let (value, lowered) =
151            lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
152        report.absorb(&lowered);
153        Ok(wire::CallToolResult {
154            meta: value.extensions.get(META_KEY).map(serde_json::Value::from),
155            content: value
156                .content
157                .iter()
158                .map(lower_block)
159                .collect::<Result<Vec<_>, _>>()?,
160            is_error: value.is_error.then_some(true),
161            extra: value.extensions.to_json_object(),
162        })
163    }
164}
165
166fn lift_block(block: &wire::CallToolResultContentChoice) -> Content {
167    match block {
168        wire::CallToolResultContentChoice::Member0(text) => Content::Text(TextContent {
169            text: text.text.clone(),
170            annotations: text.annotations.as_ref().map(lift_annotations),
171            extensions: Extensions::from_json_object(text.extra.clone()),
172        }),
173        wire::CallToolResultContentChoice::Member1(image) => Content::Image(ImageContent {
174            data: image.data.clone(),
175            mime_type: image.mime_type.clone(),
176            annotations: image.annotations.as_ref().map(lift_annotations),
177            extensions: Extensions::from_json_object(image.extra.clone()),
178        }),
179        wire::CallToolResultContentChoice::Member2(audio) => Content::Audio(AudioContent {
180            data: audio.data.clone(),
181            mime_type: audio.mime_type.clone(),
182            annotations: audio.annotations.as_ref().map(lift_annotations),
183            extensions: Extensions::from_json_object(audio.extra.clone()),
184        }),
185        wire::CallToolResultContentChoice::Member3(resource) => {
186            let wire::EmbeddedResource {
187                annotations,
188                resource,
189                extra,
190                ..
191            } = resource;
192            let (uri, mime_type, text, blob) = match resource {
193                wire::EmbeddedResourceResourceChoice::Member0(text) => (
194                    text.uri.clone(),
195                    text.mime_type.clone(),
196                    Some(text.text.clone()),
197                    None,
198                ),
199                wire::EmbeddedResourceResourceChoice::Member1(blob) => (
200                    blob.uri.clone(),
201                    blob.mime_type.clone(),
202                    None,
203                    Some(blob.blob.clone()),
204                ),
205            };
206            Content::EmbeddedResource(EmbeddedResource {
207                uri,
208                mime_type,
209                text,
210                blob,
211                annotations: annotations.as_ref().map(lift_annotations),
212                extensions: Extensions::from_json_object(extra.clone()),
213            })
214        }
215    }
216}
217
218fn lower_block(block: &Content) -> Result<wire::CallToolResultContentChoice, LowerError> {
219    let lowered = match block {
220        Content::Text(text) => wire::CallToolResultContentChoice::Member0(wire::TextContent {
221            annotations: lower_annotations(text.annotations.as_ref())?,
222            text: text.text.clone(),
223            r#type: TEXT_TYPE.to_owned(),
224            extra: text.extensions.to_json_object(),
225        }),
226        Content::Image(image) => wire::CallToolResultContentChoice::Member1(wire::ImageContent {
227            annotations: lower_annotations(image.annotations.as_ref())?,
228            data: image.data.clone(),
229            mime_type: image.mime_type.clone(),
230            r#type: IMAGE_TYPE.to_owned(),
231            extra: image.extensions.to_json_object(),
232        }),
233        Content::Audio(audio) => wire::CallToolResultContentChoice::Member2(wire::AudioContent {
234            annotations: lower_annotations(audio.annotations.as_ref())?,
235            data: audio.data.clone(),
236            mime_type: audio.mime_type.clone(),
237            r#type: AUDIO_TYPE.to_owned(),
238            extra: audio.extensions.to_json_object(),
239        }),
240        Content::EmbeddedResource(resource) => {
241            let contents = if let Some(text) = &resource.text {
242                wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
243                    mime_type: resource.mime_type.clone(),
244                    text: text.clone(),
245                    uri: resource.uri.clone(),
246                    extra: serde_json::Map::new(),
247                })
248            } else if let Some(blob) = &resource.blob {
249                wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
250                    blob: blob.clone(),
251                    mime_type: resource.mime_type.clone(),
252                    uri: resource.uri.clone(),
253                    extra: serde_json::Map::new(),
254                })
255            } else {
256                return Err(LowerError::FieldNotRepresentable {
257                    field: "resource".to_owned(),
258                    expected: "a text or blob body",
259                    detail: "the resource has neither `text` nor `blob`".to_owned(),
260                    target: ProtocolVersion::V2025_03_26,
261                });
262            };
263            wire::CallToolResultContentChoice::Member3(wire::EmbeddedResource {
264                annotations: lower_annotations(resource.annotations.as_ref())?,
265                resource: contents,
266                r#type: RESOURCE_TYPE.to_owned(),
267                extra: resource.extensions.to_json_object(),
268            })
269        }
270        Content::Unknown(unknown) => {
271            return Err(LowerError::FieldNotRepresentable {
272                field: unknown.type_name.clone(),
273                expected: "a 2025-03-26 content block",
274                detail: "this build does not model the block, and this revision has no \
275                         member that could carry it"
276                    .to_owned(),
277                target: ProtocolVersion::V2025_03_26,
278            });
279        }
280        Content::ResourceLink(_) => {
281            return Err(LowerError::FieldNotRepresentable {
282                field: "content".to_owned(),
283                expected: "a 2025-03-26 content block",
284                detail: "resource links arrive at 2025-06-18".to_owned(),
285                target: ProtocolVersion::V2025_03_26,
286            });
287        }
288    };
289    Ok(lowered)
290}
291
292#[cfg(test)]
293mod tests {
294
295    /// What a test fails with.
296    ///
297    /// One type for every error a test body can hit, so `?` carries a codec
298    /// error, a JSON error or a bare message alike and the harness prints it
299    /// rather than the test having to choose between three error types.
300    type TestError = Box<dyn std::error::Error>;
301
302    use super::V2025_03_26;
303    use std::assert_matches;
304
305    use crate::domain::DegradePolicy;
306    use crate::domain::ir::Codec;
307    use crate::domain::ir::lower::{LowerError, TranslationReport};
308    use crate::domain::ir::{Content, Role, ToolResult};
309    use crate::domain::{Feature, ProtocolVersion};
310
311    fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
312        let codec = V2025_03_26;
313        let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
314        let mut report = TranslationReport::new(codec.version());
315        let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
316        Ok(serde_json::to_value(&lowered)?)
317    }
318
319    /// The union is untagged, and audio and image are the same shape: a `type`,
320    /// a `data` and a `mimeType`. Nothing in the JSON says which is which, so
321    /// the member index is the only thing telling them apart and an off-by-one
322    /// would swap them with no error at all.
323    ///
324    /// This is the test that fails if the arm order is wrong, which is why the
325    /// assertion is on the IR variant rather than on re-serialisation: a block
326    /// that arrived as an image re-serialises to a valid image.
327    /// KNOWN BUG -- codegen does not enforce the `type` const.
328    ///
329    /// `ImageContent::r#type` is a plain `String` with `#[serde(rename =
330    /// "type")]`, so an `ImageContent` accepts `{"type":"audio", ...}` without
331    /// complaint. The union is untagged and tries members in order, so every
332    /// `{type, data, mimeType}` block deserialises as the *image* arm and an
333    /// audio block silently becomes an image -- the codec then writes
334    /// `"type":"image"` and the data is corrupted with no error anywhere.
335    ///
336    /// The fix is in the generator: emit the specification's `const` as a
337    /// single-valued type rather than a string. Until then this fails, and
338    /// failing is the correct state -- it is the test that should be loud.
339    #[test]
340    fn audio_and_image_land_in_different_arms() -> Result<(), TestError> {
341        let codec = V2025_03_26;
342        let audio: super::wire::CallToolResult = serde_json::from_str(
343            r#"{"content":[{"type":"audio","data":"AAA","mimeType":"audio/wav"}]}"#,
344        )?;
345        let image: super::wire::CallToolResult = serde_json::from_str(
346            r#"{"content":[{"type":"image","data":"AAA","mimeType":"image/png"}]}"#,
347        )?;
348
349        assert!(matches!(
350            codec.lift(&audio).content.first().ok_or("no audio block")?,
351            Content::Audio(_)
352        ));
353        assert!(matches!(
354            codec.lift(&image).content.first().ok_or("no image block")?,
355            Content::Image(_)
356        ));
357        Ok(())
358    }
359
360    #[test]
361    fn a_result_round_trips_through_json() -> Result<(), TestError> {
362        for json in [
363            r#"{"content":[{"type":"text","text":"hello"}]}"#,
364            r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"body"}}]}"#,
365            r#"{"content":[{"type":"text","text":"x","annotations":{"audience":["user"],"priority":0.5}}]}"#,
366            r#"{"content":[],"isError":true}"#,
367        ] {
368            assert_eq!(
369                round_trip(json)?,
370                serde_json::from_str::<serde_json::Value>(json)?,
371                "did not survive the crossing"
372            );
373        }
374        Ok(())
375    }
376
377    /// Typed `annotations` means an unmodelled member lands in the struct's
378    /// `extra` bag rather than anywhere else, and has to come back out.
379    #[test]
380    fn an_unmodelled_annotation_member_survives_the_structs_extra_bag() -> Result<(), TestError> {
381        let json = r#"{"content":[{"type":"text","text":"x","annotations":{"audience":["user"],"unheardOf":true}}]}"#;
382        assert_eq!(
383            round_trip(json)?,
384            serde_json::from_str::<serde_json::Value>(json)?
385        );
386        Ok(())
387    }
388
389    /// Annotations are typed here, so `lastModified` has no field to land in.
390    /// The gate refuses the value on `ToolAnnotationsLastModified` before the
391    /// codec sees it; the codec refusing it too is belt and braces, and what
392    /// matters is that it refuses rather than laundering the value into `extra`
393    /// where the reader would choke on it.
394    /// The gate refuses `lastModified` before the codec is reached.
395    ///
396    /// Asserted on the error's _value_ rather than on its `Display`: the string
397    /// form carries the feature as `{feature:?}`, which is the Rust variant
398    /// name, so a substring test against the wire slug is a test of the
399    /// formatting rather than of the behaviour. Matching the variant is what
400    /// says the refusal was for the right reason.
401    #[test]
402    fn last_modified_is_refused_by_the_gate() -> Result<(), TestError> {
403        let codec = V2025_03_26;
404        let ir = ToolResult::new(vec![Content::Text(crate::domain::ir::TextContent {
405            text: "x".to_owned(),
406            annotations: Some(crate::domain::ir::Annotations {
407                last_modified: Some("2025-06-18T00:00:00Z".to_owned()),
408                audience: vec![Role::User],
409                ..crate::domain::ir::Annotations::empty()
410            }),
411            extensions: crate::domain::ir::Extensions::empty(),
412        })]);
413
414        let mut report = TranslationReport::new(codec.version());
415        let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
416            return Err("this must be refused".into());
417        };
418        assert_matches!(
419            error,
420            LowerError::FeatureUnavailable {
421                feature: Feature::ToolAnnotationsLastModified,
422                negotiated: ProtocolVersion::V2025_03_26,
423            }
424        );
425        Ok(())
426    }
427
428    /// `structuredContent` does not exist at this revision, so there is no
429    /// field to write it into and no way to carry it.
430    #[test]
431    fn structured_content_is_refused_by_the_gate() -> Result<(), TestError> {
432        let codec = V2025_03_26;
433        let ir = ToolResult::new(vec![Content::Text(crate::domain::ir::TextContent::new(
434            "x",
435        ))])
436        .with_structured(r#"{"a":1}"#)?;
437
438        let mut report = TranslationReport::new(codec.version());
439        let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
440            return Err("this must be refused".into());
441        };
442        assert!(!error.to_string().is_empty());
443        Ok(())
444    }
445}