Skip to main content

protocol_droid/domain/ir/lower/
mod.rs

1//! Lowering — making an IR value expressible at a target revision, or refusing.
2//!
3//! # The port trait lives here, not in an adapter
4//!
5//! [`Lowering`] is declared by the consumer, which is this module — the
6//! translation path — and implemented wherever the wire format lives. That is
7//! the project's rule, and it is worth restating why: a trait in an adapter
8//! makes the adapter the owner of the vocabulary, so a second consumer has to
9//! import from the first. A trait next to the thing that uses it means the
10//! vocabulary belongs to the use case.
11//!
12//! # What lowering decides, and what it does not
13//!
14//! This module decides *whether* and *how*. It does not decide the wire bytes —
15//! that is the adapter's job, and the reason the trait is here at all.
16//!
17//! The decision has exactly two outcomes, and which one you get depends on the
18//! [`DegradePolicy`]:
19//!
20//! - The value's [`FeatureSet`] is a subset of what the target session has. It
21//!   goes through unchanged, and the report is empty.
22//! - It is not. Under a strict policy that is
23//!   [`LowerError::FeatureUnavailable`], with nothing written. Under a policy
24//!   that names a rule for the missing feature, the rule applies and the report
25//!   says so.
26//!
27//! The third outcome is not a downgrade but a refusal of a different kind:
28//! [`LowerError::FieldNotRepresentable`], where the value is fine as untyped
29//! data but does not fit a struct the target revision requires. That one is a
30//! property of the value rather than of the revision, so no policy can authorise
31//! it; see the variant for why the alternatives are worse.
32//!
33//! What still holds is the property worth stating: lowering never returns a
34//! value it cannot account for. A caller that gets `Ok` either has a lossless
35//! translation or a report that names what was lost.
36
37mod report;
38mod rule;
39
40pub use report::{TranslationReport, TranslationStep};
41pub use rule::{DegradeRule, Loss};
42
43use crate::domain::ir::features::FeatureSet;
44use crate::domain::ir::tool::ToolResult;
45use crate::domain::{DegradePolicy, Feature, FeatureProfile, ProtocolVersion};
46
47/// Why a value could not be expressed at the target revision.
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49pub enum LowerError {
50    /// The value needs a feature the negotiated revision cannot express, and
51    /// no policy authorises a downgrade.
52    #[error("FeatureUnavailable({feature:?}, {negotiated})")]
53    FeatureUnavailable {
54        /// The feature the value needs.
55        feature: Feature,
56        /// The revision it could not be expressed at.
57        negotiated: ProtocolVersion,
58    },
59
60    /// The policy names a feature it can degrade, but names no rule for it.
61    ///
62    /// Distinct from [`LowerError::FeatureUnavailable`] because the fix is
63    /// different: the caller asked for a downgrade and did not say how. Failing
64    /// here rather than picking a rule is what keeps a policy from quietly
65    /// choosing the destructive option.
66    #[error("policy allows degrading {feature:?} but names no rule for it")]
67    NoRuleForFeature {
68        /// The feature with no rule.
69        feature: Feature,
70    },
71
72    /// A field the target revision types as a struct does not fit it.
73    ///
74    /// This is the `2026-07-28` `meta` case, and it is deliberately *not* a
75    /// [`LowerError::FeatureUnavailable`]. That variant says a revision cannot
76    /// express a shape at all, so the fix is to negotiate something else or
77    /// authorise a downgrade. This one says a *value* does not fit a shape: the
78    /// same value lowers cleanly at `2025-06-18`, where `meta` is an untyped
79    /// `serde_json::Value`, and fails at every revision that types it. Reporting
80    /// it as a missing feature would put a false claim in the error — that the
81    /// field is unavailable at the older revision — and would refuse messages
82    /// that lower perfectly well.
83    ///
84    /// # Why no `DegradeRule` applies
85    ///
86    /// Every other variant here can be talked out of by a policy, and three
87    /// options were considered for this one. All three are worse than refusing:
88    ///
89    /// - **Synthesise the missing member.** A fabricated `version` reads as
90    ///   authoritative and was never sent by anyone.
91    /// - **Drop the field, keep the rest of `meta`.** Silent data loss, and
92    ///   `meta` is exactly where a sender puts something it meant to be
93    ///   understood.
94    /// - **Move the value into the struct's `extra` bag.** This one is a trap.
95    ///   `ResultMetaObject` really does carry `#[serde(flatten)] extra`, so
96    ///   putting `io.modelcontextprotocol/serverInfo` in there serialises
97    ///   straight back out and *looks* lossless. It is not: the reader treats
98    ///   that key as a known member and tries to parse it as an `Implementation`,
99    ///   which is the parse that just failed here. So the document is unreadable
100    ///   by its intended reader, and building it anyway launders the rejection
101    ///   out of this layer and onto the peer — where the bridge looks healthy
102    ///   and the error lands a long way from its cause.
103    ///
104    /// Refusing is the only option that leaves the failure where it can be
105    /// diagnosed. Note this leaves nothing for a [`DegradePolicy`] to do, and
106    /// that is the intent rather than an omission: the only rule available would
107    /// discard the sender's data.
108    ///
109    /// # The blast radius is narrower than it looks
110    ///
111    /// The IR holds `meta` as a raw value, so a `meta` lifted from a
112    /// `2026-07-28` peer has already been through that peer's deserialiser and
113    /// will always re-parse. Only a value from a revision that treats `meta` as
114    /// untyped, or one built by hand, can reach this.
115    #[error("`{field}` does not fit {expected} required at {target}: {detail}")]
116    FieldNotRepresentable {
117        /// The JSON key that could not be represented.
118        field: String,
119        /// The type the target revision requires there.
120        expected: &'static str,
121        /// Why the value did not fit it.
122        detail: String,
123        /// The revision that types the field.
124        target: ProtocolVersion,
125    },
126}
127
128/// Translates a value into one a target revision can express.
129///
130/// Implemented by the codec layer, consumed here.
131pub trait Lowering {
132    /// The value this lowers.
133    type Value;
134
135    /// Lowers `value` to `target`, recording anything changed in `report`.
136    ///
137    /// # Errors
138    ///
139    /// [`LowerError`] when the value needs something the target cannot express
140    /// and no authorised rule applies.
141    fn lower(
142        &self,
143        value: Self::Value,
144        target: ProtocolVersion,
145        policy: DegradePolicy,
146        report: &mut TranslationReport,
147    ) -> Result<Self::Value, LowerError>;
148}
149
150/// The gate every lowering path shares.
151///
152/// `required ⊆ available` is the whole question, and asking it in one place is
153/// what stops each codec from answering it slightly differently. Returns the
154/// first missing feature, in the order [`FeatureSet::iter`] yields, so the
155/// error is deterministic for a given value — a bridge logging errors has to
156/// be able to tell whether two peers hit the same wall.
157///
158/// # Examples
159///
160/// ```
161/// use protocol_droid::domain::ir::lower::check_expressible;
162/// use protocol_droid::domain::ir::{FeatureSet, ToolResult};
163/// use protocol_droid::domain::{Feature, ProtocolVersion, resolve, Negotiation, CapabilitySet, TransportKind};
164///
165/// let caps = CapabilitySet::all_for(ProtocolVersion::V2025_03_26);
166/// let session = resolve(Negotiation {
167///     version: ProtocolVersion::V2025_03_26,
168///     client_caps: caps,
169///     server_caps: caps,
170///     transport: TransportKind::Stdio,
171/// });
172///
173/// // A plain text result needs nothing, so it fits anywhere.
174/// let plain = ToolResult::text("hello");
175/// assert!(check_expressible(&plain.required_features(), &session, ProtocolVersion::V2025_03_26).is_ok());
176///
177/// // Structured output does not fit at 2025-03-26.
178/// let structured = ToolResult::text("hello").with_structured(r#"{"a":1}"#).unwrap();
179/// let error = check_expressible(
180///     &structured.required_features(), &session, ProtocolVersion::V2025_03_26,
181/// ).unwrap_err();
182/// assert!(matches!(error, _ if error.to_string().contains("ToolStructuredOutput")));
183/// # let _ = Feature::Tools;
184/// ```
185pub fn check_expressible(
186    required: &FeatureSet,
187    available: &FeatureProfile,
188    negotiated: ProtocolVersion,
189) -> Result<(), LowerError> {
190    let available = FeatureSet::union_of(available.iter());
191    if required.is_subset_of(available) {
192        return Ok(());
193    }
194    let missing = required
195        .iter()
196        .find(|feature| !available.contains(*feature));
197    Err(LowerError::FeatureUnavailable {
198        // `required` is non-empty whenever it is not a subset, so `iter` yields
199        // at least one. Reported as an error rather than unwrapped, because the
200        // invariant is worth stating rather than asserting.
201        feature: missing.unwrap_or(Feature::Tools),
202        negotiated,
203    })
204}
205
206/// The rule to use for a feature, under a policy.
207///
208/// `None` when the policy does not authorise degrading the feature at all.
209/// Otherwise the first rule for that feature the policy allows, chosen in
210/// [`DegradeRule::ALL`] order so the choice does not depend on enum ordering
211/// accidents.
212#[must_use]
213pub fn rule_for(feature: Feature, policy: DegradePolicy) -> Option<DegradeRule> {
214    if !policy.allows(feature) {
215        return None;
216    }
217    DegradeRule::ALL
218        .iter()
219        .copied()
220        .find(|rule| rule.is_fallback_for(feature))
221}
222
223/// Applies the authorised downgrades to a `ToolResult`.
224///
225/// The one place the degrade *decisions* and the IR's *content* meet, and
226/// deliberately so: `ToolResult::downgrade_structured_output` owns how a text
227/// block is built, and this owns when it is built. Splitting them the other way
228/// round would put a content-shaping decision inside a policy loop.
229///
230/// Returns the value and a report naming everything that was given up. Under a
231/// strict policy the report is always empty, because anything non-empty would
232/// have been an error instead.
233pub fn lower_tool_result(
234    value: ToolResult,
235    available: &FeatureProfile,
236    target: ProtocolVersion,
237    policy: DegradePolicy,
238) -> Result<(ToolResult, TranslationReport), LowerError> {
239    let required = value.required_features();
240    let available = FeatureSet::union_of(available.iter());
241
242    if required.is_subset_of(available) {
243        return Ok((value, TranslationReport::new(target)));
244    }
245
246    let mut result = value;
247    let mut report = TranslationReport::new(target);
248
249    // Resolve every missing feature up front, so a policy that allows a
250    // downgrade but names no rule fails before the value is touched. Applying
251    // some rules and then erroring would leave a half-lowered value the caller
252    // has to remember to discard.
253    let mut pending: Vec<DegradeRule> = Vec::new();
254    for feature in required.iter() {
255        if available.contains(feature) {
256            continue;
257        }
258        let rule = rule_for(feature, policy).ok_or_else(|| {
259            if policy.allows(feature) {
260                LowerError::NoRuleForFeature { feature }
261            } else {
262                LowerError::FeatureUnavailable {
263                    feature,
264                    negotiated: target,
265                }
266            }
267        })?;
268        pending.push(rule);
269    }
270
271    for rule in pending {
272        match rule {
273            // Handled in the IR, where the text block is built.
274            DegradeRule::StructuredContentToText => {
275                if result.downgrade_structured_output().is_some() {
276                    report.record(rule);
277                }
278            }
279            DegradeRule::DropAnnotations => {
280                result = drop_annotations(&result);
281                report.record(rule);
282            }
283            DegradeRule::DropAudio | DegradeRule::DropResourceLink => {
284                result = drop_blocks(&result, rule);
285                report.record(rule);
286            }
287            // Not reachable: `pending` is built from `rule_for`, which only
288            // returns rules in `ALL`, and these three are opt-in reductions
289            // rather than fallbacks. Delegated rather than ignored so that if
290            // that ever changes the behaviour is the reduction and not a
291            // silent no-op that would report a downgrade it did not perform.
292            DegradeRule::AudioToTextPlaceholder
293            | DegradeRule::DropImage
294            | DegradeRule::DropResource => {
295                if reduce_tool_result(&mut result, rule) {
296                    report.record(rule);
297                }
298            }
299        }
300    }
301
302    Ok((result, report))
303}
304
305/// Strips annotations from every content block.
306fn drop_annotations(result: &ToolResult) -> ToolResult {
307    use crate::domain::ir::content::Content;
308    let mut result = result.clone();
309    for block in &mut result.content {
310        match block {
311            Content::Text(block) => block.annotations = None,
312            Content::Image(block) => block.annotations = None,
313            Content::Audio(block) => block.annotations = None,
314            Content::ResourceLink(block) => block.annotations = None,
315            Content::EmbeddedResource(block) => block.annotations = None,
316            Content::Unknown(_) => {}
317        }
318    }
319    result
320}
321
322/// Removes the content blocks a rule cannot express.
323fn drop_blocks(result: &ToolResult, rule: DegradeRule) -> ToolResult {
324    use crate::domain::ir::content::Content;
325    let mut result = result.clone();
326    result.content.retain(|block| match block {
327        Content::Audio(_) => !matches!(rule, DegradeRule::DropAudio),
328        Content::ResourceLink(_) => !matches!(rule, DegradeRule::DropResourceLink),
329        Content::Text(_)
330        | Content::Image(_)
331        | Content::EmbeddedResource(_)
332        | Content::Unknown(_) => true,
333    });
334    result
335}
336
337/// Applies an opt-in reduction that no [`DegradePolicy`] can select, in place.
338///
339/// Three rules are reachable only this way, and the split is the point:
340///
341/// - [`DegradeRule::AudioToTextPlaceholder`] — the specification does not
342///   sanction it, and there is no textual stand-in for audio.
343/// - [`DegradeRule::DropImage`] and [`DegradeRule::DropResource`] — images and
344///   embedded resources are expressible at every revision, so dropping one is
345///   never *required*; a caller who wants a smaller message asks for it.
346///
347/// Returns whether anything changed, so a caller can record a step only when a
348/// reduction actually happened and a loop over rules needs no pre-check.
349/// Returns `false` unchanged for a fallback, because reaching one here would be
350/// a caller mistake and doing nothing is the honest response.
351pub fn reduce_tool_result(result: &mut ToolResult, rule: DegradeRule) -> bool {
352    use crate::domain::ir::content::{Content, TextContent};
353    let mut replaced = 0_usize;
354    match rule {
355        DegradeRule::AudioToTextPlaceholder => {
356            for block in &mut result.content {
357                if matches!(block, Content::Audio(_)) {
358                    *block = Content::Text(TextContent::new("[audio omitted]"));
359                    replaced += 1;
360                }
361            }
362        }
363        DegradeRule::DropImage | DegradeRule::DropResource => {
364            let before = result.content.len();
365            result.content.retain(|block| match block {
366                Content::Image(_) => !matches!(rule, DegradeRule::DropImage),
367                Content::EmbeddedResource(_) => !matches!(rule, DegradeRule::DropResource),
368                _ => true,
369            });
370            replaced = before - result.content.len();
371        }
372        // A fallback, not a reduction: doing nothing is better than applying it
373        // without the policy check that is the whole point of the fallback path.
374        _ => return false,
375    }
376    replaced > 0
377}
378
379#[cfg(test)]
380mod tests {
381    #![expect(
382        clippy::panic,
383        clippy::unwrap_used,
384        reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
385    )]
386
387    use super::{
388        DegradeRule, LowerError, Lowering, TranslationReport, check_expressible, lower_tool_result,
389        reduce_tool_result, rule_for,
390    };
391    use crate::domain::ir::lower::Loss;
392    use crate::domain::ir::{AudioContent, Content, ImageContent, TextContent, ToolResult};
393    use crate::domain::{
394        CapabilitySet, DegradePolicy, Feature, FeatureProfile, Negotiation, ProtocolVersion,
395        TransportKind, resolve,
396    };
397
398    /// The two failures are different in kind, and a codec that reports a value
399    /// problem as a capability problem refuses messages that lower cleanly.
400    ///
401    /// `FieldNotRepresentable` is not `FeatureUnavailable` and must not be
402    /// spelled as one. The same `meta` lowers at `2025-06-18` — where the field
403    /// is an untyped value — and fails at `2026-07-28`, where the revision types
404    /// it as a struct with required members. Reading that as "the field needs a
405    /// feature `2025-06-18` lacks" would put a false claim in the error and
406    /// refuse the older revision, which can carry the value untouched.
407    #[test]
408    fn a_value_that_does_not_fit_is_not_reported_as_a_missing_feature() {
409        let not_representable = LowerError::FieldNotRepresentable {
410            field: "io.modelcontextprotocol/serverInfo".to_owned(),
411            expected: "Implementation",
412            detail: "missing field `version`".to_owned(),
413            target: ProtocolVersion::V2026_07_28,
414        };
415        let unavailable = LowerError::FeatureUnavailable {
416            feature: Feature::ToolResultType,
417            negotiated: ProtocolVersion::V2025_06_18,
418        };
419
420        assert_ne!(not_representable, unavailable);
421        assert!(not_representable.to_string().contains("serverInfo"));
422        assert!(!not_representable.to_string().contains("ToolResultType"));
423    }
424
425    /// The error names the target revision, because the same value is fine at
426    /// another one and the diagnostic is useless without it.
427    #[test]
428    fn the_unrepresentable_field_error_names_where_it_failed() {
429        let error = LowerError::FieldNotRepresentable {
430            field: "io.modelcontextprotocol/serverInfo".to_owned(),
431            expected: "Implementation",
432            detail: "missing field `version`".to_owned(),
433            target: ProtocolVersion::V2026_07_28,
434        };
435
436        let rendered = error.to_string();
437        assert!(rendered.contains("2026-07-28"), "{rendered}");
438        assert!(rendered.contains("missing field `version`"), "{rendered}");
439    }
440
441    /// A session at `version` with both sides advertising everything that
442    /// version defines.
443    fn session(version: ProtocolVersion) -> FeatureProfile {
444        let caps = CapabilitySet::all_for(version);
445        resolve(Negotiation {
446            version,
447            client_caps: caps,
448            server_caps: caps,
449            transport: TransportKind::Stdio,
450        })
451    }
452
453    fn downgrade(feature: Feature) -> DegradePolicy {
454        DegradePolicy::strict().with(feature)
455    }
456
457    /// A value that fits goes through untouched, and the report says nothing
458    /// happened.
459    #[test]
460    fn a_value_that_fits_is_unchanged_and_unreported() {
461        let value = ToolResult::text("hello");
462        let (result, report) = lower_tool_result(
463            value.clone(),
464            &session(ProtocolVersion::V2025_03_26),
465            ProtocolVersion::V2025_03_26,
466            DegradePolicy::strict(),
467        )
468        .unwrap();
469        assert_eq!(result, value);
470        assert!(report.is_empty());
471        assert!(report.is_lossless());
472    }
473
474    /// The named scenario, at the lowering layer.
475    #[test]
476    fn structured_output_downlevel_scenario() {
477        let source = ToolResult::new(vec![Content::Text(TextContent::new("ok"))])
478            .with_structured(r#"{"temperature":0.7}"#)
479            .unwrap();
480        let target = ProtocolVersion::V2025_03_26;
481
482        // Strict: refused, with the feature named.
483        let error = lower_tool_result(
484            source.clone(),
485            &session(target),
486            target,
487            DegradePolicy::strict(),
488        )
489        .unwrap_err();
490        assert!(matches!(error, LowerError::FeatureUnavailable { .. }));
491        assert!(
492            error.to_string().contains("ToolStructuredOutput"),
493            "{error}"
494        );
495
496        // Opted in: the text fallback, recorded, and lossless.
497        let (result, report) = lower_tool_result(
498            source,
499            &session(target),
500            target,
501            downgrade(Feature::ToolStructuredOutput),
502        )
503        .unwrap();
504        assert!(result.structured_content.is_none());
505        assert_eq!(result.content.len(), 2);
506        assert_eq!(report.len(), 1);
507        let Some((step, _)) = report.steps().split_first() else {
508            panic!("expected one step");
509        };
510        assert_eq!(step.rule, DegradeRule::StructuredContentToText);
511        assert!(report.is_lossless(), "the fallback loses nothing");
512    }
513
514    /// The `elicitation-on-old-session` shape, at the gate.
515    #[test]
516    fn a_missing_feature_is_reported_with_its_revision() {
517        let required = crate::domain::ir::FeatureSet::empty().with(Feature::Elicitation);
518        let error = check_expressible(
519            &required,
520            &session(ProtocolVersion::V2025_03_26),
521            ProtocolVersion::V2025_03_26,
522        )
523        .unwrap_err();
524        assert_eq!(
525            error,
526            LowerError::FeatureUnavailable {
527                feature: Feature::Elicitation,
528                negotiated: ProtocolVersion::V2025_03_26,
529            },
530        );
531    }
532
533    #[test]
534    fn the_error_message_names_the_feature_and_the_revision() {
535        let required = crate::domain::ir::FeatureSet::empty().with(Feature::Elicitation);
536        let error = check_expressible(
537            &required,
538            &session(ProtocolVersion::V2025_03_26),
539            ProtocolVersion::V2025_03_26,
540        )
541        .unwrap_err();
542        assert_eq!(
543            error.to_string(),
544            "FeatureUnavailable(Elicitation, 2025-03-26)"
545        );
546    }
547
548    /// The gate and the value-level lowering must agree, or one of them is
549    /// lying about what is expressible.
550    #[test]
551    fn the_gate_and_the_value_agree() {
552        for version in ProtocolVersion::KNOWN {
553            let available = session(version);
554            for value in [
555                ToolResult::text("x"),
556                ToolResult::new(vec![Content::Audio(AudioContent::new("A", "audio/wav"))]),
557                ToolResult::new(vec![Content::Image(ImageContent::new("A", "image/png"))]),
558            ] {
559                let gate = check_expressible(&value.required_features(), &available, version);
560                let lower =
561                    lower_tool_result(value.clone(), &available, version, DegradePolicy::strict());
562                assert_eq!(
563                    gate.is_ok(),
564                    lower.is_ok(),
565                    "{version} / {:?}: gate said {:?}, lower said {:?}",
566                    value.content.first().map(Content::kind),
567                    gate.err(),
568                    lower.err(),
569                );
570            }
571        }
572    }
573
574    /// A destructive rule is only reachable when a caller names it, and the
575    /// report says so loudly.
576    #[test]
577    fn a_destructive_downgrade_is_recorded_as_destructive() {
578        let value = ToolResult::new(vec![Content::Audio(AudioContent::new("A", "audio/wav"))]);
579        let target = ProtocolVersion::V2024_11_05;
580
581        assert!(
582            lower_tool_result(
583                value.clone(),
584                &session(target),
585                target,
586                DegradePolicy::strict()
587            )
588            .is_err()
589        );
590
591        let policy = downgrade(Feature::ToolAudioContent);
592        let (result, report) = lower_tool_result(value, &session(target), target, policy).unwrap();
593        assert!(result.content.is_empty(), "the block was dropped");
594        assert_eq!(report.worst_loss(), Loss::Destructive);
595        assert!(!report.is_lossless_or_flagged());
596    }
597
598    #[test]
599    fn a_destructive_placeholder_is_opt_in_only() {
600        let mut value = ToolResult::new(vec![Content::Audio(AudioContent::new("A", "audio/wav"))]);
601        assert!(reduce_tool_result(
602            &mut value,
603            DegradeRule::AudioToTextPlaceholder
604        ));
605        // A placeholder says something was there; an empty array does not.
606        assert_eq!(value.content.len(), 1);
607        let Some(block) = value.content.first() else {
608            panic!("expected one block");
609        };
610        assert_eq!(block.kind(), "text");
611    }
612
613    /// A result that already holds a text block must not be reported as
614    /// changed — the placeholder replaced an audio block, not the presence of
615    /// text.
616    #[test]
617    fn a_reduction_that_changes_nothing_says_so() {
618        let mut value = ToolResult::text("x");
619        assert!(!reduce_tool_result(
620            &mut value,
621            DegradeRule::AudioToTextPlaceholder
622        ));
623        assert!(!reduce_tool_result(&mut value, DegradeRule::DropImage));
624        assert_eq!(value.content.len(), 1);
625    }
626
627    #[test]
628    fn asking_for_a_fallback_as_a_reduction_does_nothing() {
629        let mut value = ToolResult::text("x").with_structured(r#"{"a":1}"#).unwrap();
630        assert!(!reduce_tool_result(
631            &mut value,
632            DegradeRule::StructuredContentToText
633        ));
634        assert!(value.structured_content.is_some(), "not applied");
635    }
636
637    #[test]
638    fn rule_lookup_requires_the_policy_to_name_the_feature() {
639        assert!(rule_for(Feature::ToolStructuredOutput, DegradePolicy::strict()).is_none());
640        assert_eq!(
641            rule_for(
642                Feature::ToolStructuredOutput,
643                downgrade(Feature::ToolStructuredOutput)
644            ),
645            Some(DegradeRule::StructuredContentToText),
646        );
647    }
648
649    /// A permissive policy cannot reach a rule that does not exist for a
650    /// feature, so it must never produce `NoRuleForFeature` for a feature that
651    /// does have one.
652    #[test]
653    fn a_permissive_policy_never_hits_the_no_rule_error() {
654        let value = ToolResult::new(vec![Content::Audio(AudioContent::new("A", "audio/wav"))]);
655        let result = lower_tool_result(
656            value,
657            &session(ProtocolVersion::V2024_11_05),
658            ProtocolVersion::V2024_11_05,
659            DegradePolicy::permissive(),
660        );
661        assert!(result.is_ok());
662    }
663
664    /// A worked implementor of the port trait, because a port trait nobody has
665    /// implemented is a guess about what implementors need rather than a
666    /// contract. This one delegates to [`lower_tool_result`] and shows the whole
667    /// shape: an associated type, an in-place report, and domain errors out.
668    struct SessionLowering {
669        available: FeatureProfile,
670    }
671
672    impl Lowering for SessionLowering {
673        type Value = ToolResult;
674
675        fn lower(
676            &self,
677            value: ToolResult,
678            target: ProtocolVersion,
679            policy: DegradePolicy,
680            report: &mut TranslationReport,
681        ) -> Result<ToolResult, LowerError> {
682            let (lowered, own) = lower_tool_result(value, &self.available, target, policy)?;
683            report.absorb(&own);
684            Ok(lowered)
685        }
686    }
687
688    #[test]
689    fn a_port_implementor_reports_into_the_callers_report() {
690        let target = ProtocolVersion::V2025_03_26;
691        let codec = SessionLowering {
692            available: session(target),
693        };
694        let value = ToolResult::text("ok")
695            .with_structured(r#"{"a":1}"#)
696            .unwrap();
697
698        let mut report = TranslationReport::new(target);
699        let lowered = codec
700            .lower(
701                value,
702                target,
703                downgrade(Feature::ToolStructuredOutput),
704                &mut report,
705            )
706            .unwrap();
707        assert!(lowered.structured_content.is_none());
708        // The implementor wrote into the caller's report rather than its own.
709        assert_eq!(report.len(), 1);
710        assert!(report.is_lossless());
711    }
712
713    #[test]
714    fn a_port_implementor_errors_rather_than_returning_a_lossy_value() {
715        let target = ProtocolVersion::V2025_03_26;
716        let codec = SessionLowering {
717            available: session(target),
718        };
719        let value = ToolResult::text("ok")
720            .with_structured(r#"{"a":1}"#)
721            .unwrap();
722        let mut report = TranslationReport::new(target);
723        let result = codec.lower(value, target, DegradePolicy::strict(), &mut report);
724        assert!(result.is_err());
725        // Nothing was written, so nothing has to be undone.
726        assert!(report.is_empty());
727    }
728
729    /// Multiple missing features each need a rule, and every applied rule is
730    /// recorded — a caller that asked for two downgrades gets two steps.
731    #[test]
732    fn every_applied_rule_is_recorded() {
733        let value = ToolResult::new(vec![
734            Content::Audio(AudioContent::new("A", "audio/wav")),
735            Content::Image(ImageContent::new("B", "image/png")),
736        ])
737        .with_structured(r#"{"a":1}"#)
738        .unwrap();
739        let target = ProtocolVersion::V2024_11_05;
740        let policy = DegradePolicy::permissive();
741        let (result, report) = lower_tool_result(value, &session(target), target, policy).unwrap();
742        assert!(report.len() >= 2, "{report:?}");
743        assert!(!report.is_lossless_or_flagged());
744        assert!(result.structured_content.is_none());
745        assert!(!result.content.is_empty(), "the text fallback survived");
746    }
747}