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}