Skip to main content

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

1//! The `2024-11-05` `CallToolResult` codec.
2//!
3//! # Why this one is written out and not generated
4//!
5//! The other five revisions differ from this one *cumulatively* — each adds to
6//! what came before and nothing is taken away — so once the pattern here is
7//! right, levels 1 to 4 are a table rather than a design. Writing this one by
8//! hand first is deliberate: the variation is small enough to check by eye, and
9//! a macro that hides a wrong field is worse than no macro at all.
10//!
11//! # What is different about `2024-11-05`
12//!
13//! - `content` is `Vec<CallToolResultContentChoice>`. From `2025-06-18` it is
14//!   `Vec<ContentBlock>`. The members are otherwise in the same order.
15//! - The union has three arms: text, image, and an embedded resource. Audio
16//!   arrives at `2025-03-26` and resource links at `2025-06-18`.
17//! - `annotations` is an untyped `serde_json::Value`, not an `Annotations`
18//!   struct. It is present, though, which is why carrying annotations needs no
19//!   feature at this revision.
20//! - Content blocks have no `meta`. `CallToolResult` itself does, and has done
21//!   since the first revision.
22//!
23//! # The member indices
24//!
25//! The union is untagged: serde takes the first member that round-trips, and
26//! the JSON carries no discriminant. An image and an audio block are both a
27//! base64 string and a media type and are each structurally valid as the
28//! other, so an off-by-one produces no error — just a block that arrived as
29//! the wrong kind of thing. `content_member_order.rs` in the schema crate is
30//! what catches drift here.
31
32use protocol_droid_schema::wire::v2024_11_05 as wire;
33
34use super::{IMAGE_TYPE, META_KEY, RESOURCE_TYPE, TEXT_TYPE};
35
36use crate::domain::ir::Codec;
37use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
38use crate::domain::ir::{
39    Annotations, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent, Role,
40    TextContent, ToolResult,
41};
42use crate::domain::{
43    CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
44    resolve,
45};
46
47/// The key untyped `annotations` travel under at this revision.
48///
49/// The field is a bare `serde_json::Value` here. Anything this codec can model
50/// is lifted into [`Annotations`]; anything it cannot is kept whole under this
51/// key, so a value shaped nothing like the spec still survives the crossing.
52const RAW_ANNOTATIONS_KEY: &str = "annotations";
53
54/// Lifts a value into typed annotations, keeping every unmodelled member.
55///
56/// Total, like every lift. A `priority` that is a string, an `audience` that
57/// is a number, and a role this build has never heard of all land in
58/// [`Annotations::extensions`] rather than failing — a bridge that dropped a
59/// message over a surprising annotation would be worse than one that forwarded
60/// it unparsed.
61///
62/// Members that *are* modelled are lifted even alongside unmodelled ones, so a
63/// value keeps the parts that are understood as well as the parts that are not.
64fn lift_annotations(value: &serde_json::Value) -> Annotations {
65    let Some(object) = value.as_object() else {
66        return Annotations {
67            extensions: Extensions::empty()
68                .with(RAW_ANNOTATIONS_KEY, ExtensionValue::from(value.clone())),
69            ..Annotations::empty()
70        };
71    };
72
73    let mut annotations = Annotations::empty();
74
75    if let Some(priority) = object.get("priority").and_then(serde_json::Value::as_f64) {
76        annotations.priority = Some(priority);
77    }
78    if let Some(last_modified) = object
79        .get("lastModified")
80        .and_then(serde_json::Value::as_str)
81    {
82        annotations.last_modified = Some(last_modified.to_owned());
83    }
84    if let Some(roles) = object.get("audience").and_then(serde_json::Value::as_array) {
85        annotations.audience = roles
86            .iter()
87            .filter_map(|role| role.as_str())
88            .filter_map(|role| {
89                if role == Role::User.as_str() {
90                    Some(Role::User)
91                } else if role == Role::Assistant.as_str() {
92                    Some(Role::Assistant)
93                } else {
94                    None
95                }
96            })
97            .collect();
98    }
99
100    for (key, value) in object {
101        if !matches!(key.as_str(), "priority" | "lastModified" | "audience") {
102            annotations
103                .extensions
104                .insert(key.clone(), ExtensionValue::from(value.clone()));
105        }
106    }
107    annotations
108}
109
110/// Renders typed annotations back into the untyped value this revision wants.
111///
112/// A modelled field is written in the spec's spelling; everything in
113/// [`Annotations::extensions`] is written alongside it, and anything left under
114/// the raw key is written last so a value that never modelled in the first
115/// place comes back out byte-for-byte.
116fn lower_annotations(annotations: Option<&Annotations>) -> Option<serde_json::Value> {
117    let annotations = annotations?;
118
119    let mut object = annotations.extensions.to_json_object();
120
121    // A value that never parsed the first time must not be re-rendered from a
122    // partial model now, or the unmodelled part would be lost to a shape the
123    // codec guessed at.
124    if let Some(raw) = object.remove(RAW_ANNOTATIONS_KEY) {
125        return Some(raw);
126    }
127
128    if !annotations.audience.is_empty() {
129        object.insert(
130            "audience".to_owned(),
131            serde_json::Value::Array(
132                annotations
133                    .audience
134                    .iter()
135                    .map(|role| serde_json::Value::String((*role).as_str().to_owned()))
136                    .collect(),
137            ),
138        );
139    }
140    if let Some(priority) = annotations.priority {
141        object.insert(
142            "priority".to_owned(),
143            serde_json::Number::from_f64(priority)
144                .map_or(serde_json::Value::Null, serde_json::Value::Number),
145        );
146    }
147    if let Some(last_modified) = &annotations.last_modified {
148        object.insert(
149            "lastModified".to_owned(),
150            serde_json::Value::String(last_modified.clone()),
151        );
152    }
153    if object.is_empty() {
154        return None;
155    }
156    Some(serde_json::Value::Object(object))
157}
158
159/// The `2024-11-05` codec.
160#[derive(Debug, Clone, Copy)]
161pub struct V2024_11_05;
162
163impl Codec for V2024_11_05 {
164    type Wire = wire::CallToolResult;
165
166    fn version(&self) -> ProtocolVersion {
167        ProtocolVersion::V2024_11_05
168    }
169
170    fn capabilities(&self) -> FeatureProfile {
171        let caps = CapabilitySet::all_for(self.version());
172        resolve(Negotiation {
173            version: self.version(),
174            client_caps: caps,
175            server_caps: caps,
176            transport: TransportKind::Stdio,
177        })
178    }
179
180    fn lift(&self, wire: &Self::Wire) -> ToolResult {
181        let mut extensions = Extensions::from_json_object(wire.extra.clone());
182        if let Some(meta) = &wire.meta {
183            extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
184        }
185
186        ToolResult {
187            content: wire.content.iter().map(lift_block).collect(),
188            structured_content: None,
189            is_error: wire.is_error.unwrap_or(false),
190            result_type: None,
191            extensions,
192        }
193    }
194
195    fn lower(
196        &self,
197        value: ToolResult,
198        policy: DegradePolicy,
199        report: &mut TranslationReport,
200    ) -> Result<Self::Wire, LowerError> {
201        let (value, lowered) =
202            lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
203        report.absorb(&lowered);
204
205        let extra = value.extensions.to_json_object();
206        let meta = value.extensions.get(META_KEY).map(serde_json::Value::from);
207
208        let content = value
209            .content
210            .iter()
211            .map(lower_block)
212            .collect::<Result<Vec<_>, _>>()?;
213
214        Ok(wire::CallToolResult {
215            meta,
216            content,
217            is_error: value.is_error.then_some(true),
218            extra,
219        })
220    }
221}
222
223/// Lifts one union member.
224///
225/// Total, so the `else` arm is the interesting one: a block this build does
226/// not recognise keeps its JSON whole rather than being dropped, which is what
227/// lets a bridge forward a message from a peer newer than it is.
228fn lift_block(block: &wire::CallToolResultContentChoice) -> Content {
229    match block {
230        wire::CallToolResultContentChoice::Member0(text) => Content::Text(TextContent {
231            text: text.text.clone(),
232            annotations: text.annotations.as_ref().map(lift_annotations),
233            extensions: Extensions::from_json_object(text.extra.clone()),
234        }),
235        wire::CallToolResultContentChoice::Member1(image) => Content::Image(ImageContent {
236            data: image.data.clone(),
237            mime_type: image.mime_type.clone(),
238            annotations: image.annotations.as_ref().map(lift_annotations),
239            extensions: Extensions::from_json_object(image.extra.clone()),
240        }),
241        wire::CallToolResultContentChoice::Member2(resource) => {
242            let wire::EmbeddedResource {
243                annotations,
244                resource,
245                extra,
246                ..
247            } = resource;
248            let (uri, mime_type, text, blob) = match resource {
249                wire::EmbeddedResourceResourceChoice::Member0(text) => (
250                    text.uri.clone(),
251                    text.mime_type.clone(),
252                    Some(text.text.clone()),
253                    None,
254                ),
255                wire::EmbeddedResourceResourceChoice::Member1(blob) => (
256                    blob.uri.clone(),
257                    blob.mime_type.clone(),
258                    None,
259                    Some(blob.blob.clone()),
260                ),
261            };
262            Content::EmbeddedResource(EmbeddedResource {
263                uri,
264                mime_type,
265                text,
266                blob,
267                annotations: annotations.as_ref().map(lift_annotations),
268                extensions: Extensions::from_json_object(extra.clone()),
269            })
270        }
271    }
272}
273
274/// Lowers one block.
275///
276/// Every arm is a member of this revision's union or a refusal. There is no
277/// fallback arm, because a fallback is how a codec starts inventing shapes: a
278/// block with no member here is reported, not forced into the nearest arm that
279/// happens to exist.
280///
281/// The refusals are not all the same kind. Audio and resource links are already
282/// gone by the gate — they require features `2024-11-05` does not have — so
283/// reaching one would mean the gate let something through, and saying so is
284/// more useful than a silent skip. An [`Content::Unknown`] block is different:
285/// it raises no feature requirement, because the IR cannot know which union
286/// member would hold it, so this is the one case that arrives here honestly and
287/// has to be refused on its own account.
288fn lower_block(block: &Content) -> Result<wire::CallToolResultContentChoice, LowerError> {
289    let lowered = match block {
290        Content::Text(text) => wire::CallToolResultContentChoice::Member0(wire::TextContent {
291            annotations: lower_annotations(text.annotations.as_ref()),
292            text: text.text.clone(),
293            r#type: TEXT_TYPE.to_owned(),
294            extra: text.extensions.to_json_object(),
295        }),
296        Content::Image(image) => wire::CallToolResultContentChoice::Member1(wire::ImageContent {
297            annotations: lower_annotations(image.annotations.as_ref()),
298            data: image.data.clone(),
299            mime_type: image.mime_type.clone(),
300            r#type: IMAGE_TYPE.to_owned(),
301            extra: image.extensions.to_json_object(),
302        }),
303        Content::EmbeddedResource(resource) => {
304            let contents = if let Some(text) = &resource.text {
305                wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
306                    mime_type: resource.mime_type.clone(),
307                    text: text.clone(),
308                    uri: resource.uri.clone(),
309                    extra: serde_json::Map::new(),
310                })
311            } else if let Some(blob) = &resource.blob {
312                wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
313                    blob: blob.clone(),
314                    mime_type: resource.mime_type.clone(),
315                    uri: resource.uri.clone(),
316                    extra: serde_json::Map::new(),
317                })
318            } else {
319                // A resource with neither a body nor a blob has nothing to put
320                // in the union. Refusing here beats writing a member that
321                // re-serialises to something the peer cannot read.
322                return Err(LowerError::FieldNotRepresentable {
323                    field: "resource".to_owned(),
324                    expected: "a text or blob body",
325                    detail: "the resource has neither `text` nor `blob`".to_owned(),
326                    target: ProtocolVersion::V2024_11_05,
327                });
328            };
329            wire::CallToolResultContentChoice::Member2(wire::EmbeddedResource {
330                annotations: lower_annotations(resource.annotations.as_ref()),
331                resource: contents,
332                r#type: RESOURCE_TYPE.to_owned(),
333                extra: resource.extensions.to_json_object(),
334            })
335        }
336        Content::Unknown(unknown) => {
337            return Err(LowerError::FieldNotRepresentable {
338                field: unknown.type_name.clone(),
339                expected: "a 2024-11-05 content block",
340                detail: "this build does not model the block, and this revision has no \
341                         member that could carry it"
342                    .to_owned(),
343                target: ProtocolVersion::V2024_11_05,
344            });
345        }
346        Content::Audio(_) | Content::ResourceLink(_) => {
347            return Err(LowerError::FieldNotRepresentable {
348                field: "content".to_owned(),
349                expected: "a 2024-11-05 content block",
350                detail: "the gate admitted a block this revision cannot express".to_owned(),
351                target: ProtocolVersion::V2024_11_05,
352            });
353        }
354    };
355    Ok(lowered)
356}
357
358#[cfg(test)]
359mod tests {
360
361    /// What a test fails with.
362    ///
363    /// One type for every error a test body can hit, so `?` carries a codec
364    /// error, a JSON error or a bare message alike and the harness prints it
365    /// rather than the test having to choose between three error types.
366    type TestError = Box<dyn std::error::Error>;
367
368    use super::{V2024_11_05, lift_annotations, lower_annotations, wire};
369    use crate::domain::DegradePolicy;
370    use crate::domain::ir::Codec;
371    use crate::domain::ir::lower::{TranslationReport, lower_tool_result};
372    use crate::domain::ir::{
373        Annotations, Content, EmbeddedResource, ExtensionValue, ImageContent, JsonObject, Role,
374        TextContent, ToolResult, UnknownContent,
375    };
376
377    /// Lifts, lowers, and returns the message as the peer would receive it.
378    ///
379    /// Compared as a parsed [`serde_json::Value`] rather than as text, because
380    /// member order is insignificant under RFC 8259 and `serde_json::Map` sorts
381    /// its keys. A textual comparison would fail on `{"text":..,"type":..}`
382    /// against `{"type":..,"text":..}` — the same message — and passing it would
383    /// mean asserting an ordering the wire format does not define.
384    fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
385        let codec = V2024_11_05;
386        let wire: wire::CallToolResult = serde_json::from_str(json)?;
387        let mut report = TranslationReport::new(codec.version());
388        let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
389        Ok(serde_json::to_value(&lowered)?)
390    }
391
392    /// The parsed form of a fixture, for the expected side.
393    fn parsed(json: &str) -> Result<serde_json::Value, TestError> {
394        Ok(serde_json::from_str(json)?)
395    }
396
397    /// The contract: a message the peer sent comes back out the same.
398    ///
399    /// Compared as JSON rather than as structs, because the struct comparison
400    /// would pass on a value that had been through a lossy detour and arrived
401    /// with the same fields filled in. JSON is what the peer actually sees.
402    #[test]
403    fn a_result_round_trips_through_json() -> Result<(), TestError> {
404        for json in [
405            r#"{"content":[{"type":"text","text":"hello"}]}"#,
406            r#"{"content":[{"type":"text","text":"hi","annotations":{"audience":["user"],"priority":0.5}}]}"#,
407            r#"{"content":[{"type":"image","data":"AAA","mimeType":"image/png"}]}"#,
408            r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"body"}}]}"#,
409            r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","blob":"AAA"}}]}"#,
410            r#"{"content":[],"isError":true}"#,
411        ] {
412            assert_eq!(
413                round_trip(json)?,
414                parsed(json)?,
415                "did not survive the crossing"
416            );
417        }
418        Ok(())
419    }
420
421    /// `meta` has no field in the IR, so it rides in the extensions. The test
422    /// is that it comes back out as a *field* rather than as a bag member —
423    /// the peer sent `meta`, and `meta` is what it must receive.
424    #[test]
425    fn meta_survives_as_a_field_rather_than_a_bag_member() -> Result<(), TestError> {
426        // The wire key is `_meta`; the generated field is `meta`, because
427        // `_meta` is not an identifier. Writing `meta` in a fixture puts the
428        // value in `extra` instead, which is the same mistake the key's
429        // existence is meant to prevent.
430        let json = r#"{"_meta":{"progress":0.5},"content":[]}"#;
431        let codec = V2024_11_05;
432        let wire: wire::CallToolResult = serde_json::from_str(json)?;
433        let ir = codec.lift(&wire);
434        assert!(
435            ir.extensions
436                .contains(crate::adapters::secondary::codec::result::META_KEY)
437        );
438
439        let mut report = TranslationReport::new(codec.version());
440        let lowered = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
441        assert_eq!(lowered.meta, Some(serde_json::json!({"progress": 0.5})));
442        assert_eq!(serde_json::to_value(&lowered)?, parsed(json)?);
443        Ok(())
444    }
445
446    /// Annotations a newer peer sends are unmodelled here, and must come back
447    /// whole rather than being re-rendered from the parts this build knows.
448    ///
449    /// A codec that kept what it recognised and re-serialised would still
450    /// produce a plausible-looking object — just missing the member the
451    /// sender actually sent, which is the failure this pins.
452    #[test]
453    fn unmodelled_annotations_are_returned_whole() -> Result<(), TestError> {
454        let json = r#"{"content":[{"type":"text","text":"x","annotations":{"priority":0.5,"unheardOf":true}}]}"#;
455        assert_eq!(round_trip(json)?, parsed(json)?);
456        Ok(())
457    }
458
459    /// A modelled annotation is lifted into the IR rather than left as text, so
460    /// the rest of the system can reason about it.
461    #[test]
462    fn modelled_annotations_are_typed_in_the_ir() {
463        let value = serde_json::json!({"audience": ["user", "assistant"], "priority": 0.25});
464        let annotations = lift_annotations(&value);
465        assert_eq!(annotations.audience, vec![Role::User, Role::Assistant]);
466        assert_eq!(annotations.priority, Some(0.25));
467        assert_eq!(lower_annotations(Some(&annotations)), Some(value));
468    }
469
470    /// A member this build does not recognise is kept, not dropped, and not
471    /// merged into the modelled fields — otherwise a value with both a known
472    /// and an unknown member would come back with the unknown one missing.
473    #[test]
474    fn an_unrecognised_member_is_kept_alongside_the_modelled_ones() {
475        let value = serde_json::json!({"priority": 0.5, "unheardOf": true});
476        let annotations = lift_annotations(&value);
477        assert_eq!(annotations.priority, Some(0.5));
478        assert_eq!(
479            annotations.extensions.get("unheardOf"),
480            Some(&ExtensionValue::Bool(true))
481        );
482        assert_eq!(lower_annotations(Some(&annotations)), Some(value));
483    }
484
485    /// An annotations value that is not an object at all cannot be modelled, so
486    /// it is carried whole. Losing it would be the one outcome a lift may not
487    /// have.
488    #[test]
489    fn annotations_that_are_not_an_object_survive_whole() {
490        let value = serde_json::json!("just a string");
491        let annotations = lift_annotations(&value);
492        assert!(annotations.audience.is_empty());
493        assert_eq!(lower_annotations(Some(&annotations)), Some(value));
494    }
495
496    /// `priority` is `f64` on the wire and in the IR, so a value the peer sent
497    /// is the value the peer gets back.
498    #[test]
499    fn priority_is_not_narrowed_on_the_way_through() -> Result<(), TestError> {
500        let json = r#"{"content":[{"type":"text","text":"x","annotations":{"priority":0.1}}]}"#;
501        assert_eq!(round_trip(json)?, parsed(json)?);
502        Ok(())
503    }
504
505    /// A block this build does not model cannot be written at a revision with
506    /// no member for it, and saying so beats inventing a shape or dropping the
507    /// message. The gate admits it — an unknown block raises no requirement,
508    /// because the IR cannot know which member would hold it — so this is the
509    /// codec refusing on its own account.
510    #[test]
511    fn an_unmodelled_block_is_refused_rather_than_dropped() -> Result<(), TestError> {
512        let codec = V2024_11_05;
513        let mut ir = ToolResult::new(vec![Content::Text(TextContent::new("x"))]);
514        ir.content.push(Content::Unknown(UnknownContent {
515            type_name: "video".to_owned(),
516            raw: JsonObject::new(r#"{"type":"video"}"#.to_owned())?,
517            extensions: crate::domain::ir::Extensions::empty(),
518        }));
519
520        let mut report = TranslationReport::new(codec.version());
521        let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
522            return Err("this must be refused".into());
523        };
524        assert!(
525            error.to_string().contains("video"),
526            "the error must name the block: {error}"
527        );
528        Ok(())
529    }
530
531    /// Audio arrives at `2025-03-26`, so the gate refuses it here before the
532    /// codec is ever reached. This is the gate working, and the codec must not
533    /// be a second place that decides.
534    #[test]
535    fn audio_is_refused_by_the_gate_before_the_codec_sees_it() -> Result<(), TestError> {
536        let codec = V2024_11_05;
537        let ir = ToolResult::new(vec![Content::Audio(crate::domain::ir::AudioContent::new(
538            "AAA",
539            "audio/wav",
540        ))]);
541
542        let mut report = TranslationReport::new(codec.version());
543        let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
544            return Err("this must be refused".into());
545        };
546
547        let gate = lower_tool_result(
548            ToolResult::new(vec![Content::Audio(crate::domain::ir::AudioContent::new(
549                "AAA",
550                "audio/wav",
551            ))]),
552            &codec.capabilities(),
553            codec.version(),
554            DegradePolicy::strict(),
555        );
556        assert!(gate.is_err(), "the gate is what refuses, not the codec");
557        assert!(!error.to_string().is_empty());
558        Ok(())
559    }
560
561    /// The IR's own round trip, independent of the wire, so a failure here
562    /// points at the IR rather than at the codec.
563    #[test]
564    fn a_typed_result_survives_lift_and_lower() -> Result<(), TestError> {
565        let codec = V2024_11_05;
566        let ir = ToolResult::new(vec![
567            Content::Text(TextContent::new("hello")),
568            Content::Image(ImageContent::new("AAA", "image/png")),
569            Content::EmbeddedResource(EmbeddedResource::text("file:///a", "body")),
570        ]);
571
572        let mut report = TranslationReport::new(codec.version());
573        let wire = codec.lower(ir.clone(), DegradePolicy::strict(), &mut report)?;
574        assert!(report.is_empty());
575        assert_eq!(codec.lift(&wire), ir);
576        Ok(())
577    }
578
579    /// `Annotations::empty()` on the wire is `None`, not `Some({})`. Writing an
580    /// empty object where the peer sent nothing would be a change the peer
581    /// could see.
582    #[test]
583    fn absent_annotations_stay_absent() {
584        assert_eq!(lower_annotations(None), None);
585        assert_eq!(lower_annotations(Some(&Annotations::empty())), None);
586    }
587}