Skip to main content

protocol_droid/domain/ir/
codec.rs

1//! The `Codec` port trait — the seam between the canonical IR and one revision.
2//!
3//! # Why this trait, and why here
4//!
5//! The canonical model is the point of the crate: application code speaks one
6//! version-agnostic IR, and per-revision codecs lift wire messages into it and
7//! lower them back out for whatever version was negotiated. This trait is that
8//! seam, and it is deliberately narrow — one associated wire type, two
9//! directions.
10//!
11//! It lives beside its vocabulary rather than in the adapter because the
12//! *terms* belong to the translation path, not to the serialisation crate. An
13//! adapter that owned the trait would make itself the owner of "what a codec
14//! is", and the second consumer — a bridge, a prober, a conformance runner —
15//! would have to import from the first to say what it wants.
16//!
17//! # The two directions are not symmetric, and the asymmetry is the design
18//!
19//! [`Codec::lift`] is **total**: every well-formed wire value produces an IR
20//! value, because there is no such thing as a message the peer sent that the
21//! bridge may refuse to represent. Fields this build does not name go to
22//! [`Extensions`] and unrecognised content blocks go to
23//! [`UnknownContent`](crate::domain::ir::UnknownContent), so a message survives
24//! the crossing intact. A lift that could fail would mean some inbound message
25//! was dropped silently, which is the one outcome a version-translating bridge
26//! cannot have.
27//!
28//! [`Codec::lower`] is **partial**, and the failure is not a limitation to work
29//! around but the whole point. A value may need a feature the target revision
30//! cannot express. Then lowering either refuses with
31//! [`LowerError::FeatureUnavailable`], or applies a rule the caller's
32//! [`DegradePolicy`] named and records it in the [`TranslationReport`]. It never
33//! emits a value it cannot account for — see [`lower::lower_tool_result`],
34//! which is the decision half of this and is shared by every implementation.
35//!
36//! # What an implementor actually writes
37//!
38//! A codec is mostly mechanical translation between a generated wire struct
39//! and the IR. Two things it must *not* decide:
40//!
41//! - Whether the value is expressible. That is the gate, and it is the same
42//!   answer for every revision — asking it per-codec is how two revisions start
43//!   disagreeing about what they support.
44//! - How a value is degraded. [`DegradeRule`] owns that, so the report means
45//!   the same thing whoever ran it.
46//!
47//! What an implementor *does* own is the shape: which fields its revision has,
48//! how its content union is ordered, and how its own extra-field bag maps onto
49//! [`Extensions`]. The generated types are the source of truth for all three,
50//! and the union ordering in particular is not a detail — an `untagged` enum
51//! matches the first member that round-trips, so a codec that assumes the wrong
52//! order will silently accept an audio block as an image block.
53//!
54//! # Implementors
55//!
56//! See `adapters/secondary/codec` for the per-revision implementations. The
57//! example below is the shape in miniature: a revision with one content kind
58//! and no extra fields.
59//!
60//! ```
61//! use protocol_droid::domain::ir::Codec;
62//! use protocol_droid::domain::ir::lower::{LowerError, TranslationReport};
63//! use protocol_droid::domain::ir::{Content, TextContent, ToolResult};
64//! use protocol_droid::domain::{DegradePolicy, ProtocolVersion};
65//!
66//! /// A wire type, as the generated code would produce it.
67//! #[derive(Debug, Clone, PartialEq)]
68//! struct WireResult {
69//!     content: Vec<WireText>,
70//!     is_error: bool,
71//! }
72//!
73//! #[derive(Debug, Clone, PartialEq)]
74//! struct WireText {
75//!     type_name: String,
76//!     text: String,
77//! }
78//!
79//! /// The revision that can only say one thing.
80//! #[derive(Debug, Clone, Copy)]
81//! struct TextOnlyRevision;
82//!
83//! impl Codec for TextOnlyRevision {
84//!     type Wire = WireResult;
85//!
86//!     fn version(&self) -> ProtocolVersion {
87//!         ProtocolVersion::V2024_11_05
88//!     }
89//!
90//!     fn lift(&self, wire: &Self::Wire) -> ToolResult {
91//!         let mut result = ToolResult::new(
92//!             wire.content
93//!                 .iter()
94//!                 .map(|block| Content::Text(TextContent::new(block.text.clone())))
95//!                 .collect(),
96//!         );
97//!         if wire.is_error {
98//!             result = result.error();
99//!         }
100//!         result
101//!     }
102//!
103//!     fn lower(
104//!         &self,
105//!         value: ToolResult,
106//!         policy: DegradePolicy,
107//!         report: &mut TranslationReport,
108//!     ) -> Result<Self::Wire, LowerError> {
109//!         // Every revision gates the same way, so ask once and delegate the
110//!         // decisions to the shared path rather than re-deriving them here.
111//!         let (lowered, _own) = protocol_droid::domain::ir::lower::lower_tool_result(
112//!             value,
113//!             &self.capabilities(),
114//!             self.version(),
115//!             policy,
116//!         )?;
117//!         let _ = report;
118//!         Ok(WireResult {
119//!             content: lowered
120//!                 .content
121//!                 .iter()
122//!                 .map(|block| WireText {
123//!                     type_name: "text".to_owned(),
124//!                     text: match block {
125//!                         Content::Text(text) => text.text.clone(),
126//!                         // Unreachable: the gate refused anything else.
127//!                         other => format!("{other:?}"),
128//!                     },
129//!                 })
130//!                 .collect(),
131//!             is_error: lowered.is_error,
132//!         })
133//!     }
134//!
135//!     fn capabilities(&self) -> protocol_droid::domain::FeatureProfile {
136//!         let caps = protocol_droid::domain::CapabilitySet::all_for(self.version());
137//!         protocol_droid::domain::resolve(protocol_droid::domain::Negotiation {
138//!             version: self.version(),
139//!             client_caps: caps,
140//!             server_caps: caps,
141//!             transport: protocol_droid::domain::TransportKind::Stdio,
142//!         })
143//!     }
144//! }
145//!
146//! // It lifts and lowers, and a value it can express round-trips unchanged.
147//! let codec = TextOnlyRevision;
148//! let wire = WireResult {
149//!     content: vec![WireText {
150//!         type_name: "text".to_owned(),
151//!         text: "hello".to_owned(),
152//!     }],
153//!     is_error: false,
154//! };
155//! let lifted = codec.lift(&wire);
156//! assert_eq!(lifted.content.len(), 1);
157//!
158//! let mut report = TranslationReport::new(codec.version());
159//! let lowered = codec
160//!     .lower(lifted.clone(), DegradePolicy::strict(), &mut report)
161//!     .expect("expressible");
162//! assert_eq!(lowered, wire);
163//! assert!(report.is_empty());
164//! ```
165
166use crate::domain::ir::ToolResult;
167use crate::domain::ir::lower::{LowerError, TranslationReport};
168use crate::domain::{DegradePolicy, FeatureProfile, ProtocolVersion};
169
170/// Translates between one revision's wire type and the canonical IR.
171///
172/// See the module docs at the top of this file for the contract. The short version: [`lift`](Self::lift)
173/// is total, [`lower`](Self::lower) is gated.
174pub trait Codec {
175    /// The generated wire type for this revision.
176    ///
177    /// Named per implementation rather than fixed, because the revisions do not
178    /// agree on their shapes: `CallToolResult.content` is
179    /// `Vec<CallToolResultContentChoice>` at `2024-11-05` and `Vec<ContentBlock>`
180    /// from `2025-06-18`, and only `structuredContent` distinguishes the
181    /// revisions that can carry it. A single wire type would have to be the
182    /// least common denominator, which is a type that cannot express anything
183    /// — the mistake the IR exists to avoid.
184    type Wire;
185
186    /// The revision this codec speaks.
187    fn version(&self) -> ProtocolVersion;
188
189    /// What this revision can express, as a negotiated profile.
190    ///
191    /// Used by the gate, so it must be the *real* profile for a peer speaking
192    /// this revision — the intersection of what both sides advertise, not what
193    /// the revision permits in the abstract. A codec that answers with the
194    /// revision's maximum would let a value through that the actual peer cannot
195    /// read.
196    fn capabilities(&self) -> FeatureProfile;
197
198    /// Wire to IR.
199    ///
200    /// Infallible by design: a message the peer sent always has an IR form. Use
201    /// [`UnknownContent`](crate::domain::ir::UnknownContent) for a block this
202    /// build does not recognise, and [`Extensions`](crate::domain::ir::Extensions) for fields
203    /// it does not
204    /// name, so nothing is lost in the crossing.
205    fn lift(&self, wire: &Self::Wire) -> ToolResult;
206
207    /// IR to wire.
208    ///
209    /// # Errors
210    ///
211    /// [`LowerError::FeatureUnavailable`] when the value needs something this
212    /// revision cannot express and the policy authorises no rule for it. Any
213    /// rule the policy *does* authorise is applied and recorded in `report`.
214    fn lower(
215        &self,
216        value: ToolResult,
217        policy: DegradePolicy,
218        report: &mut TranslationReport,
219    ) -> Result<Self::Wire, LowerError>;
220}
221
222#[cfg(test)]
223mod tests {
224    #![expect(
225        clippy::expect_used,
226        reason = "tests assert on fallible calls; propagating would replace a clear failure with noise"
227    )]
228
229    use super::Codec;
230    use crate::domain::ir::lower::{TranslationReport, lower_tool_result};
231    use crate::domain::ir::{Content, TextContent, ToolResult};
232    use crate::domain::{
233        CapabilitySet, DegradePolicy, Feature, Negotiation, ProtocolVersion, TransportKind, resolve,
234    };
235
236    /// A minimal revision: text only, no extra fields, nothing structured.
237    #[derive(Debug, Clone, Copy)]
238    struct TextOnly;
239
240    impl Codec for TextOnly {
241        type Wire = Vec<String>;
242
243        fn version(&self) -> ProtocolVersion {
244            ProtocolVersion::V2024_11_05
245        }
246
247        fn capabilities(&self) -> crate::domain::FeatureProfile {
248            let caps = CapabilitySet::all_for(self.version());
249            resolve(Negotiation {
250                version: self.version(),
251                client_caps: caps,
252                server_caps: caps,
253                transport: TransportKind::Stdio,
254            })
255        }
256
257        fn lift(&self, wire: &Self::Wire) -> ToolResult {
258            ToolResult::new(
259                wire.iter()
260                    .map(|text| Content::Text(TextContent::new(text.clone())))
261                    .collect(),
262            )
263        }
264
265        fn lower(
266            &self,
267            value: ToolResult,
268            _policy: DegradePolicy,
269            _report: &mut TranslationReport,
270        ) -> Result<Self::Wire, super::LowerError> {
271            Ok(value
272                .content
273                .iter()
274                .map(|block| match block {
275                    Content::Text(text) => text.text.clone(),
276                    other => format!("{other:?}"),
277                })
278                .collect())
279        }
280    }
281
282    /// A codec that can express what its wire type can, round-trips
283    /// unchanged. The contract every implementation has to keep.
284    #[test]
285    fn a_value_the_revision_expresses_round_trips() {
286        let codec = TextOnly;
287        let ir = ToolResult::text("hello");
288        let mut report = TranslationReport::new(codec.version());
289        let wire = codec
290            .lower(ir.clone(), DegradePolicy::strict(), &mut report)
291            .expect("expressible");
292        assert_eq!(wire, vec!["hello".to_owned()]);
293        assert_eq!(codec.lift(&wire), ir);
294        assert!(report.is_empty());
295    }
296
297    /// A lift is total: whatever is on the wire produces an IR value. The
298    /// contract that keeps a bridge from dropping a message it did not expect.
299    #[test]
300    fn lifting_never_fails() {
301        let codec = TextOnly;
302        for wire in [
303            vec![],
304            vec!["a".to_owned()],
305            vec!["a".to_owned(), "b".to_owned()],
306        ] {
307            let ir = codec.lift(&wire);
308            assert_eq!(ir.content.len(), wire.len());
309        }
310    }
311
312    /// The gate is the same answer whoever asks, so a codec cannot disagree
313    /// with `lower_tool_result` about expressibility.
314    #[test]
315    fn a_codec_and_the_gate_agree() {
316        let codec = TextOnly;
317        let profile = codec.capabilities();
318        for value in [
319            ToolResult::text("x"),
320            ToolResult::text("x")
321                .with_structured(r#"{"a":1}"#)
322                .expect("hand-built"),
323        ] {
324            let fits = value
325                .required_features()
326                .is_subset_of(crate::domain::ir::FeatureSet::union_of(profile.iter()));
327            let gated = lower_tool_result(
328                value.clone(),
329                &profile,
330                codec.version(),
331                DegradePolicy::strict(),
332            );
333            assert_eq!(fits, gated.is_ok(), "{value:?}");
334        }
335    }
336
337    /// `capabilities` has to describe the revision, not an idealised peer.
338    #[test]
339    fn capabilities_describe_this_revision() {
340        let codec = TextOnly;
341        let profile = codec.capabilities();
342        // `FeatureProfile` is a bare bitset — the version lives on the codec,
343        // not on the profile, so a codec cannot report a version it does not
344        // speak.
345        // 2024-11-05 has no structuredContent and no audio.
346        assert!(!profile.allows(Feature::ToolStructuredOutput));
347        assert!(!profile.allows(Feature::ToolAudioContent));
348        // But it does have tools.
349        assert!(profile.allows(Feature::Tools));
350    }
351}