Skip to main content

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

1//! The codec the bridge actually calls: one entry point, six revisions.
2//!
3//! # Why this exists
4//!
5//! The six per-revision codecs are deliberately six types rather than one
6//! generic over a wire enum — the member unions differ, and collapsing them
7//! would reintroduce the least-common-denominator shape the IR exists to avoid
8//! (see [`super`]).
9//!
10//! What the bridge needs is the opposite shape: a revision on one side and a
11//! [`ToolResult`] on the other, without naming which of the six it got. That is
12//! this file, and nothing else: a `match` from a [`ProtocolVersion`] to the
13//! codec for it, plus `serde_json`'s crossing between a generated type and a
14//! [`Value`].
15//!
16//! # No decisions live here
17//!
18//! Whether a value *can* be expressed at a revision, and what to give up if it
19//! cannot, is the gate's and the policy's answer. This dispatches to a codec,
20//! which asks `lower_tool_result` itself, and reports whatever came back. A
21//! second opinion formed here is how two revisions start disagreeing about what
22//! they support.
23//!
24//! # An unknown revision is refused, never guessed
25//!
26//! This build generates wire types for the revisions it vendors and no others,
27//! so there is nothing to dispatch to for [`ProtocolVersion::Unknown`] — the
28//! same rule the transport gate follows for the same reason. `lift` answers
29//! `None`, which forwards the value untouched; `lower` refuses, because
30//! fabricating a result at a revision whose schema was never read is the one
31//! outcome a bridge must not produce.
32//!
33//! A `Bridge` cannot reach the `lower` arm for a real negotiation, because both
34//! legs come from a `handshake` against a revision this build speaks. The arm
35//! exists so that the claim is true rather than assumed, and it is asserted
36//! directly below rather than left as a branch nothing exercises.
37//!
38//! [`Value`]: serde_json::Value
39
40use protocol_droid_schema::wire::{
41    v2024_11_05 as schema_2024_11_05, v2025_03_26 as schema_2025_03_26,
42    v2025_06_18 as schema_2025_06_18, v2025_11_25 as schema_2025_11_25,
43    v2026_07_28 as schema_2026_07_28,
44};
45use serde::Serialize;
46use serde::de::DeserializeOwned;
47use serde_json::Value;
48
49use super::{
50    v2024_11_05::V2024_11_05, v2025_03_26::V2025_03_26, v2025_06_18::V2025_06_18,
51    v2025_11_25::V2025_11_25, v2026_07_28::V2026_07_28,
52};
53use crate::application::bridge::ToolResultCodec;
54use crate::domain::ir::{Codec, LowerError, ToolResult, TranslationReport};
55use crate::domain::{DegradePolicy, ProtocolVersion};
56
57/// Reads and writes a `ToolResult` at whichever revision it is given.
58///
59/// A zero-sized type: there is no state to hold, because all the work is in the
60/// match.
61#[derive(Debug, Clone, Copy, Default)]
62pub struct WireToolResults;
63
64/// The per-revision codecs, addressed by revision.
65///
66/// One `match` on each half rather than six copies of it, so adding a revision
67/// is two arms and nothing can drift out of step with the other. The helpers
68/// below are generic over the *generated* type rather than over the codec —
69/// `Codec::Wire` carries no `Deserialize` bound, and a type parameter is not a
70/// value, so a helper generic over the codec could not name either.
71fn read<T: DeserializeOwned>(wire: &Value) -> Option<T> {
72    serde_json::from_value::<T>(wire.clone()).ok()
73}
74
75fn encode(version: ProtocolVersion, wire: impl Serialize) -> Result<Value, LowerError> {
76    serde_json::to_value(wire).map_err(|error| LowerError::FieldNotRepresentable {
77        field: "result".to_owned(),
78        expected: "a result that serialises",
79        detail: error.to_string(),
80        target: version,
81    })
82}
83
84impl ToolResultCodec for WireToolResults {
85    /// `None` covers both "this build has no wire types for that revision" and
86    /// "the value is not a result this revision can read" — the caller
87    /// forwards either, and cannot tell the two apart or need to.
88    fn lift(&self, version: ProtocolVersion, wire: &Value) -> Option<ToolResult> {
89        match version {
90            ProtocolVersion::V2024_11_05 => read::<schema_2024_11_05::CallToolResult>(wire)
91                .map(|typed| V2024_11_05.lift(&typed)),
92            ProtocolVersion::V2025_03_26 => read::<schema_2025_03_26::CallToolResult>(wire)
93                .map(|typed| V2025_03_26.lift(&typed)),
94            ProtocolVersion::V2025_06_18 => read::<schema_2025_06_18::CallToolResult>(wire)
95                .map(|typed| V2025_06_18.lift(&typed)),
96            ProtocolVersion::V2025_11_25 => read::<schema_2025_11_25::CallToolResult>(wire)
97                .map(|typed| V2025_11_25.lift(&typed)),
98            ProtocolVersion::V2026_07_28 => read::<schema_2026_07_28::CallToolResult>(wire)
99                .map(|typed| V2026_07_28.lift(&typed)),
100            // A revision this build did not vendor, and `draft` too — it
101            // reports `2026-07-28` as its version, because upstream's floating
102            // schema declares the same `LATEST_PROTOCOL_VERSION`, so a version
103            // alone cannot select it. That is a real limitation rather than an
104            // omission: a caller that wants the floating schema has to name
105            // [`Draft`](super::draft::Draft) directly.
106            //
107            // Named rather than wildcarded so that vendoring a sixth revision
108            // is a compile error here instead of a revision this build quietly
109            // cannot speak.
110            ProtocolVersion::Unknown(_) => None,
111        }
112    }
113
114    fn lower(
115        &self,
116        version: ProtocolVersion,
117        value: ToolResult,
118        policy: DegradePolicy,
119        report: &mut TranslationReport,
120    ) -> Result<Value, LowerError> {
121        match version {
122            ProtocolVersion::V2024_11_05 => {
123                encode(version, V2024_11_05.lower(value, policy, report)?)
124            }
125            ProtocolVersion::V2025_03_26 => {
126                encode(version, V2025_03_26.lower(value, policy, report)?)
127            }
128            ProtocolVersion::V2025_06_18 => {
129                encode(version, V2025_06_18.lower(value, policy, report)?)
130            }
131            ProtocolVersion::V2025_11_25 => {
132                encode(version, V2025_11_25.lower(value, policy, report)?)
133            }
134            ProtocolVersion::V2026_07_28 => {
135                encode(version, V2026_07_28.lower(value, policy, report)?)
136            }
137            // Refused rather than guessed: fabricating a result at a revision
138            // whose schema was never read is the one outcome a bridge must not
139            // produce. `draft` is here for the same reason as in `lift`, and
140            // this arm is named for the same reason as the one above.
141            ProtocolVersion::Unknown(_) => Err(LowerError::FieldNotRepresentable {
142                field: "result".to_owned(),
143                expected: "a revision this build has generated wire types for",
144                detail: format!(
145                    "{version} has no schema vendored here, so there is no \
146                     CallToolResult shape to write the value into"
147                ),
148                target: version,
149            }),
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use serde_json::json;
157
158    use super::WireToolResults;
159    use crate::application::bridge::ToolResultCodec;
160    use crate::domain::ir::lower::check_expressible;
161    use crate::domain::ir::{LowerError, ToolResult, TranslationReport};
162    use crate::domain::{
163        CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
164        resolve,
165    };
166
167    type TestError = Box<dyn std::error::Error>;
168
169    /// Every revision this build vendored, so a new one cannot be added without
170    /// a decision about whether it dispatches.
171    fn vendored() -> Vec<ProtocolVersion> {
172        vec![
173            ProtocolVersion::V2024_11_05,
174            ProtocolVersion::V2025_03_26,
175            ProtocolVersion::V2025_06_18,
176            ProtocolVersion::V2025_11_25,
177            ProtocolVersion::V2026_07_28,
178        ]
179    }
180
181    /// A value that needs nothing, so it is expressible at every revision and
182    /// the round trip is about the *codec*, not about a downgrade.
183    fn plain() -> ToolResult {
184        ToolResult::text("hello")
185    }
186
187    /// The property the dispatch exists to preserve: a value this revision can
188    /// express comes back as the value that went in.
189    #[test]
190    fn every_vendored_revision_reads_back_what_it_wrote() -> Result<(), TestError> {
191        let codec = WireToolResults;
192        let value = plain();
193
194        for version in vendored() {
195            let mut report = TranslationReport::new(version);
196            let wire = codec.lower(
197                version,
198                value.clone(),
199                DegradePolicy::permissive(),
200                &mut report,
201            )?;
202            let back = codec
203                .lift(version, &wire)
204                .ok_or("the codec refused to read back its own output")?;
205
206            assert_eq!(back.content, value.content, "content differs at {version}");
207            assert_eq!(
208                back.is_error, value.is_error,
209                "isError differs at {version}"
210            );
211            assert!(
212                report.is_empty(),
213                "{version} downgraded a plain text result"
214            );
215        }
216        Ok(())
217    }
218
219    /// A content block this build does not model reads as nothing rather than
220    /// as a failure, which is what lets the bridge forward it.
221    #[test]
222    fn a_block_this_build_cannot_read_lifts_to_nothing() {
223        let codec = WireToolResults;
224        let wire = json!({
225            "content": [{ "type": "annotation", "audience": ["assistant"] }]
226        });
227        assert_eq!(codec.lift(ProtocolVersion::V2026_07_28, &wire), None);
228    }
229
230    /// The same for a payload that is not a result at all.
231    #[test]
232    fn a_payload_that_is_not_a_result_lifts_to_nothing() {
233        let codec = WireToolResults;
234        let wire = json!([{ "name": "echo" }]);
235        assert_eq!(codec.lift(ProtocolVersion::V2025_03_26, &wire), None);
236    }
237
238    /// A revision with no vendored schema reads as nothing, and is refused on
239    /// the way down. The `lower` arm is unreachable from a real negotiation —
240    /// both legs come from a handshake against a revision this build speaks —
241    /// so it is asserted here rather than left as a branch nothing exercises.
242    #[test]
243    fn an_unknown_revision_is_refused_rather_than_guessed() -> Result<(), TestError> {
244        let version = ProtocolVersion::Unknown("2027-01-01".parse()?);
245        let codec = WireToolResults;
246
247        assert_eq!(
248            codec.lift(version, &json!({ "content": [] })),
249            None,
250            "an unknown revision is forwarded, not read"
251        );
252
253        let mut report = TranslationReport::new(version);
254        let outcome = codec.lower(version, plain(), DegradePolicy::permissive(), &mut report);
255
256        assert!(
257            matches!(outcome, Err(LowerError::FieldNotRepresentable { .. })),
258            "an unknown revision must refuse rather than fabricate a shape, got {outcome:?}"
259        );
260        Ok(())
261    }
262
263    /// `draft` and `2026-07-28` report the same version string, so the
264    /// dispatcher has to reach `draft` deliberately or not at all. This pins
265    /// that the *published* revision answers for `2026-07-28`, and that the two
266    /// schemas are not interchangeable.
267    #[test]
268    fn the_published_revision_answers_for_its_version() -> Result<(), TestError> {
269        let version = ProtocolVersion::V2026_07_28;
270        let caps = CapabilitySet::all_for(version);
271        let profile = resolve(Negotiation {
272            version,
273            client_caps: caps,
274            server_caps: caps,
275            transport: TransportKind::Stdio,
276        });
277
278        // A value that only the newest schema can express: `resultType` is
279        // `2026-07-28`-and-later, and has no lower revision to be compared to.
280        let value = ToolResult {
281            result_type: Some("search".to_owned()),
282            ..ToolResult::text("hello")
283        };
284        assert!(
285            check_expressible(&value.required_features(), &profile, version).is_ok(),
286            "the fixture must be expressible at its own revision"
287        );
288
289        let mut report = TranslationReport::new(version);
290        let wire = WireToolResults.lower(version, value, DegradePolicy::strict(), &mut report)?;
291
292        assert_eq!(
293            wire.get("resultType"),
294            Some(&json!("search")),
295            "the newest schema must have been used"
296        );
297        Ok(())
298    }
299
300    /// The other half of the previous claim: `draft` is reachable and still
301    /// speaks its own schema, so dispatch has not simply forgotten it.
302    #[test]
303    fn draft_is_still_a_working_codec() -> Result<(), TestError> {
304        let mut report = TranslationReport::new(ProtocolVersion::V2026_07_28);
305        let wire = WireToolResults.lower(
306            ProtocolVersion::V2026_07_28,
307            plain(),
308            DegradePolicy::strict(),
309            &mut report,
310        )?;
311        let back = WireToolResults.lift(ProtocolVersion::V2026_07_28, &wire);
312        assert!(back.is_some(), "the published revision must round-trip");
313        Ok(())
314    }
315
316    /// The gate the bridge consults before it touches a codec, exercised here
317    /// so that this file's dependency on it is visible rather than implied.
318    #[test]
319    fn a_profile_with_no_features_expresses_only_what_needs_nothing() {
320        assert!(
321            check_expressible(
322                &plain().required_features(),
323                &FeatureProfile::empty(),
324                ProtocolVersion::V2024_11_05,
325            )
326            .is_ok()
327        );
328    }
329}