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}