Skip to main content

protocol_droid/application/bridge/
content.rs

1//! Translating a `tools/call` result across a revision boundary.
2//!
3//! # Where the revisions actually disagree
4//!
5//! At the envelope level a bridge is free: `jsonrpc`, `id`, `method`, `params`,
6//! `result` and `error` mean the same thing in every revision of JSON-RPC, so
7//! moving a message between two peers loses nothing and there is no downgrade to
8//! record. The first place the revisions genuinely disagree is `CallToolResult`,
9//! and that disagreement has to be resolved against the _client's_ profile,
10//! because the client is the side that will fail to read the answer.
11//!
12//! # Why an untranslated reply is handed back as the peer wrote it
13//!
14//! The obvious implementation re-encodes every reply: lift, lower, splice. It is
15//! wrong, and wrong in a way that only shows up against a peer this build does
16//! not fully model.
17//!
18//! The generated content union refuses an unrecognised `type` outright — and
19//! deliberately, so that a malformed block cannot be quietly accepted as
20//! whichever alternative happens to parse. So a server pushing a block type
21//! added after this build deserialised would fail to lift *at all*, and a
22//! bridge that treated that as an error would refuse to forward a message it had
23//! no reason to touch. That is the opposite of what this crate promises: an
24//! unmodelled directive is forwarded, not interpreted.
25//!
26//! So the reply is rebuilt only once a downgrade has actually been authorised.
27//! Everything else — a result the client can already read, a shape this build
28//! cannot read, a reply that is not a result at all — comes back
29//! byte-identical, with an empty report, because nothing was decided.
30//!
31//! # Why a `lift` that reads nothing is not a failure
32//!
33//! It is the same claim from the other side. A value this build cannot read may
34//! be perfectly readable by the client on the far side, which can be newer than
35//! the bridge itself. Refusing it would make the bridge the component that
36//! breaks forward compatibility, and the symptom would appear at the client as a
37//! bridge bug rather than as an extension it does not know.
38
39use std::sync::Arc;
40
41use serde_json::{Map, Value};
42
43use crate::domain::feature::Feature;
44use crate::domain::ir::ToolResult;
45use crate::domain::ir::lower::{LowerError, TranslationReport, check_expressible};
46use crate::domain::{DegradePolicy, Leg, ProtocolVersion};
47
48/// The JSON-RPC member a reply's payload travels under.
49const RESULT: &str = "result";
50
51/// The member a refusal travels under, and which excludes [`RESULT`].
52const ERROR: &str = "error";
53
54/// The `resultType` of an ordinary, finished result.
55const RESULT_TYPE_COMPLETE: &str = "complete";
56
57/// Reads and writes a `ToolResult` in one revision's wire encoding.
58///
59/// Declared by the consumer — this module — and implemented by the codec layer,
60/// which is the only place that knows a revision's generated types. The bridge
61/// names no wire type, so it could not implement this even if it wanted to.
62///
63/// # Why `lift` answers `Option`
64///
65/// `None` means *this build does not read that shape*, and it is a decision
66/// rather than a fault: the caller forwards the value untouched so a peer that
67/// does understand it still receives it. A build that had no wire types at all
68/// for the revision would answer `None` for everything, and the bridge would
69/// degrade into a pure pass-through rather than into a component that breaks a
70/// connection it never had to translate.
71///
72/// # Example
73///
74/// The smallest implementor that could exist. A production one is
75/// [`WireToolResults`](crate::adapters::secondary::codec::result::WireToolResults),
76/// which dispatches to the six per-revision codecs.
77///
78/// ```
79/// use protocol_droid::application::bridge::ToolResultCodec;
80/// use protocol_droid::domain::ir::{LowerError, ToolResult, TranslationReport};
81/// use protocol_droid::domain::{DegradePolicy, ProtocolVersion};
82/// use serde_json::Value;
83///
84/// /// Reads nothing, so every reply through this codec passes through.
85/// struct ReadsNothing;
86///
87/// impl ToolResultCodec for ReadsNothing {
88///     fn lift(&self, _version: ProtocolVersion, _wire: &Value) -> Option<ToolResult> {
89///         None
90///     }
91///
92///     fn lower(
93///         &self,
94///         _version: ProtocolVersion,
95///         _value: ToolResult,
96///         _policy: DegradePolicy,
97///         _report: &mut TranslationReport,
98///     ) -> Result<Value, LowerError> {
99///         Ok(Value::Null)
100///     }
101/// }
102/// ```
103/// A `Bridge` holds its codec across an `await` while the reply is in flight,
104/// and a serving loop may drive several bridges at once, so a codec that is not
105/// `Sync` would make every one of those futures non-`Send`. The bound is on the
106/// port rather than on `Bridge` for one reason: this crate's ports already
107/// require `Send + Sync`, and a codec that only worked on the current thread
108/// would not be a codec a bridge could hold.
109pub trait ToolResultCodec: Send + Sync {
110    /// The [`ToolResult`] `wire` holds at `version`, or `None` if this build
111    /// does not read it.
112    fn lift(&self, version: ProtocolVersion, wire: &Value) -> Option<ToolResult>;
113
114    /// Writes `value` at `version`, recording anything the codec changed in
115    /// `report`.
116    ///
117    /// `value` arrives already lowered — the codec asks
118    /// [`lower_tool_result`](crate::domain::ir::lower::lower_tool_result)
119    /// itself rather than being handed a decision, which is why this is not a
120    /// bare serialisation and why the report comes back from here.
121    ///
122    /// # Errors
123    ///
124    /// [`LowerError::FieldNotRepresentable`] when the value does not fit a
125    /// shape `version` types — the typed-`meta` case.
126    fn lower(
127        &self,
128        version: ProtocolVersion,
129        value: ToolResult,
130        policy: DegradePolicy,
131        report: &mut TranslationReport,
132    ) -> Result<Value, LowerError>;
133}
134
135/// Lets a shared codec be a codec.
136///
137/// `Arc<T>` satisfies none of this crate's ports on its own — it is not a
138/// `FrameSink` and it is not a `FrameSource` either, for the same reason. A
139/// `Bridge` is usually held by a serving loop and a test wants to watch what it
140/// did, and an `Arc` is the only thing that gives both at once.
141impl<C: ToolResultCodec> ToolResultCodec for Arc<C> {
142    fn lift(&self, version: ProtocolVersion, wire: &Value) -> Option<ToolResult> {
143        (**self).lift(version, wire)
144    }
145
146    fn lower(
147        &self,
148        version: ProtocolVersion,
149        value: ToolResult,
150        policy: DegradePolicy,
151        report: &mut TranslationReport,
152    ) -> Result<Value, LowerError> {
153        (**self).lower(version, value, policy, report)
154    }
155}
156
157/// What translating a reply produced.
158#[derive(Debug)]
159pub struct Translated {
160    /// The reply to send downstream.
161    pub reply: Map<String, Value>,
162    /// What the translation changed. Empty whenever the reply passed through.
163    pub report: TranslationReport,
164}
165
166/// Turns a reply into one the downstream client can read.
167#[derive(Debug)]
168pub struct Translator<C> {
169    codec: C,
170}
171
172impl<C: ToolResultCodec> Translator<C> {
173    /// A translator over one codec.
174    #[must_use]
175    pub const fn new(codec: C) -> Self {
176        Self { codec }
177    }
178
179    /// Translates `reply` — which arrived in `from`'s encoding — into something
180    /// the `to` leg negotiated for.
181    ///
182    /// Returns the reply untouched with an empty report whenever there is
183    /// nothing to decide. That is the common case and it is deliberate: see the
184    /// module docs for why rebuilding an untranslated reply is a bug rather than
185    /// a thoroughness.
186    ///
187    /// # Errors
188    ///
189    /// [`LowerError`] when the reply needs a downgrade `policy` does not
190    /// authorise, or when the codec cannot write the lowered value at `to`. The
191    /// caller refuses on the error rather than forwarding a result the client
192    /// cannot read.
193    ///
194    /// # Examples
195    ///
196    /// A result the client can read is handed back exactly as it arrived.
197    ///
198    /// ```
199    /// use protocol_droid::application::bridge::{ToolResultCodec, Translator};
200    /// use protocol_droid::domain::ir::{LowerError, ToolResult, TranslationReport};
201    /// use protocol_droid::domain::{
202    ///     CapabilitySet, DegradePolicy, Leg, Negotiation, ProtocolVersion, TransportKind, resolve,
203    /// };
204    /// use serde_json::{Map, Value, json};
205    ///
206    /// struct ReadsNothing;
207    ///
208    /// impl ToolResultCodec for ReadsNothing {
209    ///     fn lift(&self, _version: ProtocolVersion, _wire: &Value) -> Option<ToolResult> {
210    ///         None
211    ///     }
212    ///
213    ///     fn lower(
214    ///         &self,
215    ///         _version: ProtocolVersion,
216    ///         _value: ToolResult,
217    ///         _policy: DegradePolicy,
218    ///         _report: &mut TranslationReport,
219    ///     ) -> Result<Value, LowerError> {
220    ///         Ok(Value::Null)
221    ///     }
222    /// }
223    ///
224    /// let version = ProtocolVersion::V2025_03_26;
225    /// let caps = CapabilitySet::all_for(version);
226    /// let leg = Leg::new(
227    ///     version,
228    ///     resolve(Negotiation {
229    ///         version,
230    ///         client_caps: caps,
231    ///         server_caps: caps,
232    ///         transport: TransportKind::Stdio,
233    ///     }),
234    /// );
235    ///
236    /// let reply: Map<String, Value> = serde_json::from_value(json!({
237    ///     "jsonrpc": "2.0",
238    ///     "id": 1,
239    ///     "result": { "content": [{ "type": "text", "text": "hi" }] },
240    /// }))?;
241    ///
242    /// let translator = Translator::new(ReadsNothing);
243    /// let translated =
244    ///     translator.translate(reply.clone(), version, &leg, DegradePolicy::strict())?;
245    ///
246    /// assert_eq!(translated.reply, reply);
247    /// assert!(translated.report.is_empty());
248    /// # Ok::<(), Box<dyn std::error::Error>>(())
249    /// ```
250    pub fn translate(
251        &self,
252        reply: Map<String, Value>,
253        from: ProtocolVersion,
254        to: &Leg,
255        policy: DegradePolicy,
256    ) -> Result<Translated, LowerError> {
257        let untouched = TranslationReport::new(to.version);
258
259        // Scoped so the borrow of `reply` ends before it is rebuilt below. The
260        // early returns move it on those paths only; the fall-through path is
261        // the one that goes on to use it.
262        let lifted = {
263            // A refusal is the upstream's own words about a request, not a
264            // result this layer has any opinion on.
265            if reply.contains_key(ERROR) {
266                return Ok(Translated {
267                    reply,
268                    report: untouched,
269                });
270            }
271            let Some(payload) = reply.get(RESULT) else {
272                return Ok(Translated {
273                    reply,
274                    report: untouched,
275                });
276            };
277            // `ToolResult` is an object. A scalar or an array here is not one,
278            // whatever method produced it, so there is nothing to translate.
279            if !payload.is_object() {
280                return Ok(Translated {
281                    reply,
282                    report: untouched,
283                });
284            }
285            self.codec.lift(from, payload)
286        };
287
288        // Read nothing. Forwarded anyway — see the module docs.
289        let Some(lifted) = lifted else {
290            return Ok(Translated {
291                reply,
292                report: untouched,
293            });
294        };
295
296        // `complete` is what a result with no `resultType` means: the
297        // `2026-07-28` text has a client read an absent field as `complete`. A
298        // client at a revision without the field reads it the same way, so
299        // there is nothing to lose here, and refusing would make every tool
300        // result from a new server unreadable to an old client. Any other value
301        // (`input_required`) has no older form and is left for the check below
302        // to refuse.
303        let mut lifted = lifted;
304        if lifted.result_type.as_deref() == Some(RESULT_TYPE_COMPLETE)
305            && !to.profile.allows(Feature::ToolResultType)
306        {
307            lifted.result_type = None;
308        }
309
310        // Nothing the client cannot read means nothing to decide, which is what
311        // makes returning the peer's own bytes correct rather than merely
312        // convenient. Asking before lowering is also what keeps the codec from
313        // re-encoding a message it was not asked to change.
314        if check_expressible(&lifted.required_features(), &to.profile, to.version).is_ok() {
315            return Ok(Translated {
316                reply,
317                report: untouched,
318            });
319        }
320
321        let mut report = TranslationReport::new(to.version);
322        let value = self.codec.lower(to.version, lifted, policy, &mut report)?;
323
324        // Replaces the member. It was read a few lines above and nothing
325        // removed it, so this cannot add a `result` that was not there — and
326        // adding one would invent an answer the upstream never sent.
327        let mut reply = reply;
328        reply.insert(RESULT.to_owned(), value);
329
330        Ok(Translated { reply, report })
331    }
332}
333
334#[cfg(test)]
335pub(in crate::application::bridge) mod doubles {
336    use core::sync::atomic::{AtomicUsize, Ordering};
337
338    use serde_json::{Value, json};
339
340    use super::ToolResultCodec;
341    use crate::domain::ir::{LowerError, ToolResult, TranslationReport};
342    use crate::domain::{DegradePolicy, ProtocolVersion};
343
344    /// A codec that reads one fixed value and counts every crossing.
345    ///
346    /// The counters are atomics rather than a `Mutex` because a poisoned lock
347    /// would add an error type to every assertion for no benefit: nothing here
348    /// can fail while the lock is held.
349    pub(in crate::application::bridge) struct Counting {
350        readable: Option<ToolResult>,
351        lifts: AtomicUsize,
352        lowers: AtomicUsize,
353    }
354
355    impl Counting {
356        /// A codec that reads `readable` back from anything.
357        #[must_use]
358        pub(in crate::application::bridge) fn reading(readable: ToolResult) -> Self {
359            Self {
360                readable: Some(readable),
361                lifts: AtomicUsize::new(0),
362                lowers: AtomicUsize::new(0),
363            }
364        }
365
366        /// A codec that reads nothing, so every reply passes through.
367        #[must_use]
368        pub(in crate::application::bridge) fn reading_nothing() -> Self {
369            Self {
370                readable: None,
371                lifts: AtomicUsize::new(0),
372                lowers: AtomicUsize::new(0),
373            }
374        }
375
376        /// How many times a value was offered to be read.
377        pub(in crate::application::bridge) fn lifts(&self) -> usize {
378            self.lifts.load(Ordering::Relaxed)
379        }
380
381        /// How many times a value was offered to be written.
382        ///
383        /// Counts entries, not successes: the codec is where the policy is
384        /// enforced, so a refusal increments this too.
385        pub(in crate::application::bridge) fn lowers(&self) -> usize {
386            self.lowers.load(Ordering::Relaxed)
387        }
388    }
389
390    impl ToolResultCodec for Counting {
391        fn lift(&self, _version: ProtocolVersion, _wire: &Value) -> Option<ToolResult> {
392            self.lifts.fetch_add(1, Ordering::Relaxed);
393            self.readable.clone()
394        }
395
396        fn lower(
397            &self,
398            _version: ProtocolVersion,
399            value: ToolResult,
400            policy: DegradePolicy,
401            report: &mut TranslationReport,
402        ) -> Result<Value, LowerError> {
403            self.lowers.fetch_add(1, Ordering::Relaxed);
404            let (lowered, lowered_report) = crate::domain::ir::lower::lower_tool_result(
405                value,
406                &crate::domain::FeatureProfile::empty(),
407                ProtocolVersion::V2025_03_26,
408                policy,
409            )?;
410            report.absorb(&lowered_report);
411            // A marker rather than the lowered value: these tests are about
412            // *whether* the result was rebuilt, not about how it reads, which
413            // is the codec's own subject and is covered by its tests.
414            Ok(json!({ "rebuilt": true, "blocks": lowered.content.len() }))
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use std::sync::Arc;
422
423    use serde_json::{Map, Value, json};
424
425    use super::doubles::Counting;
426    use super::{ERROR, RESULT, Translator};
427    use crate::domain::ir::lower::LowerError;
428    use crate::domain::ir::{FeatureSet, ToolResult};
429    use crate::domain::{
430        CapabilitySet, DegradePolicy, Feature, FeatureProfile, Leg, Negotiation, ProtocolVersion,
431        TransportKind, resolve,
432    };
433
434    type TestError = Box<dyn std::error::Error>;
435
436    /// A leg that can express what `2025-03-26` can, and nothing more.
437    fn client_leg() -> Leg {
438        let version = ProtocolVersion::V2025_03_26;
439        let caps = CapabilitySet::all_for(version);
440        Leg::new(
441            version,
442            resolve(Negotiation {
443                version,
444                client_caps: caps,
445                server_caps: caps,
446                transport: TransportKind::Stdio,
447            }),
448        )
449    }
450
451    /// A leg with no features at all, so nothing but plain text is expressible.
452    fn bare_leg() -> Leg {
453        Leg::new(ProtocolVersion::V2024_11_05, FeatureProfile::empty())
454    }
455
456    fn reply(payload: Value) -> Map<String, Value> {
457        let mut reply = Map::new();
458        reply.insert("jsonrpc".to_owned(), json!("2.0"));
459        reply.insert("id".to_owned(), json!(1));
460        reply.insert(RESULT.to_owned(), payload);
461        reply
462    }
463
464    fn structured() -> Result<ToolResult, TestError> {
465        Ok(ToolResult::text("answer").with_structured(r#"{"a":1}"#)?)
466    }
467
468    /// The claim the whole module rests on: nothing to decide means the peer's
469    /// own bytes come back, and the codec is not even asked.
470    #[test]
471    fn a_reply_needing_no_translation_never_reaches_the_codec() -> Result<(), TestError> {
472        let codec = Arc::new(Counting::reading(ToolResult::text("answer")));
473        let sent = reply(json!({ "content": [{ "type": "text", "text": "answer" }] }));
474
475        let translated = Translator::new(Arc::clone(&codec)).translate(
476            sent.clone(),
477            ProtocolVersion::V2026_07_28,
478            &client_leg(),
479            DegradePolicy::strict(),
480        )?;
481
482        assert_eq!(translated.reply, sent, "the peer's own bytes must survive");
483        assert!(translated.report.is_empty());
484        assert_eq!(
485            codec.lifts(),
486            1,
487            "it looked, found nothing to do, and stopped"
488        );
489        assert_eq!(
490            codec.lowers(),
491            0,
492            "a reply with nothing to decide is not rebuilt"
493        );
494        Ok(())
495    }
496
497    /// A finished result from a `2026-07-28` server reads the same to a client
498    /// whose revision has no `resultType`, so it is forwarded rather than refused.
499    #[test]
500    fn a_complete_result_type_does_not_stop_an_older_client() -> Result<(), TestError> {
501        let mut finished = ToolResult::text("answer");
502        finished.result_type = Some("complete".to_owned());
503        let codec = Arc::new(Counting::reading(finished));
504        let sent = reply(json!({
505            "content": [{ "type": "text", "text": "answer" }],
506            "resultType": "complete",
507        }));
508
509        let translated = Translator::new(Arc::clone(&codec)).translate(
510            sent.clone(),
511            ProtocolVersion::V2026_07_28,
512            &client_leg(),
513            DegradePolicy::strict(),
514        )?;
515
516        assert_eq!(translated.reply, sent);
517        assert_eq!(codec.lowers(), 0);
518        Ok(())
519    }
520
521    /// `input_required` asks the client to do something an older revision cannot
522    /// express, and no policy can make it complete.
523    #[test]
524    fn an_input_required_result_is_still_refused_for_an_older_client() {
525        let mut pending = ToolResult::text("answer");
526        pending.result_type = Some("input_required".to_owned());
527        let codec = Arc::new(Counting::reading(pending));
528        let sent = reply(json!({
529            "content": [{ "type": "text", "text": "answer" }],
530            "resultType": "input_required",
531        }));
532
533        let outcome = Translator::new(codec).translate(
534            sent,
535            ProtocolVersion::V2026_07_28,
536            &client_leg(),
537            DegradePolicy::permissive(),
538        );
539
540        assert!(outcome.is_err(), "{outcome:?}");
541    }
542
543    /// A value this build cannot read is forwarded, not refused.
544    ///
545    /// The codec *was* asked — so this is not a claim that the bridge never
546    /// looks — and what came back is the original, untouched.
547    #[test]
548    fn a_shape_this_build_cannot_read_is_forwarded_untouched() -> Result<(), TestError> {
549        let codec = Arc::new(Counting::reading_nothing());
550        let sent =
551            reply(json!({ "content": [{ "type": "annotation", "audience": ["assistant"] }] }));
552
553        let translated = Translator::new(Arc::clone(&codec)).translate(
554            sent.clone(),
555            ProtocolVersion::V2026_07_28,
556            &client_leg(),
557            DegradePolicy::strict(),
558        )?;
559
560        assert_eq!(translated.reply, sent);
561        assert!(translated.report.is_empty());
562        assert_eq!(codec.lifts(), 1);
563        assert_eq!(codec.lowers(), 0);
564        Ok(())
565    }
566
567    /// The `structured-output-downlevel` scenario, on the reply path.
568    #[test]
569    fn an_authorised_downgrade_rebuilds_the_result_and_records_it() -> Result<(), TestError> {
570        let codec = Arc::new(Counting::reading(structured()?));
571        let sent = reply(json!({ "content": [{ "type": "text", "text": "answer" }],
572                                 "structuredContent": { "a": 1 } }));
573
574        let translated = Translator::new(Arc::clone(&codec)).translate(
575            sent,
576            ProtocolVersion::V2026_07_28,
577            &bare_leg(),
578            DegradePolicy::permissive(),
579        )?;
580
581        assert!(!translated.report.is_empty(), "a rebuild must be recorded");
582        assert_eq!(
583            translated.reply.get(RESULT),
584            Some(&json!({ "rebuilt": true, "blocks": 2 }))
585        );
586        assert_eq!(codec.lowers(), 1);
587        Ok(())
588    }
589
590    /// The fail-secure half: the default policy refuses rather than downgrading,
591    /// and the refusal names what the client could not read.
592    ///
593    /// Note that the codec *is* asked, and refuses from inside its own
594    /// `lower`. That is deliberate: the translator cannot pre-check the policy,
595    /// because only the codec knows which features lowering will actually have
596    /// to strip. A second opinion in the translator is how two revisions start
597    /// disagreeing about what they support.
598    #[test]
599    fn a_strict_policy_refuses_rather_than_writing() -> Result<(), TestError> {
600        let codec = Arc::new(Counting::reading(structured()?));
601        let sent = reply(json!({ "content": [], "structuredContent": { "a": 1 } }));
602
603        let outcome = Translator::new(Arc::clone(&codec)).translate(
604            sent,
605            ProtocolVersion::V2026_07_28,
606            &bare_leg(),
607            DegradePolicy::strict(),
608        );
609
610        assert!(
611            matches!(
612                outcome,
613                Err(LowerError::FeatureUnavailable {
614                    feature: Feature::ToolStructuredOutput,
615                    ..
616                })
617            ),
618            "expected the named feature to be refused, got {outcome:?}"
619        );
620        assert_eq!(
621            codec.lowers(),
622            1,
623            "the codec is where the policy is enforced, so it is entered once"
624        );
625        Ok(())
626    }
627
628    /// A refusal is the upstream's own words, not a result to translate.
629    #[test]
630    fn an_error_reply_is_never_rebuilt() -> Result<(), TestError> {
631        let codec = Arc::new(Counting::reading(structured()?));
632        let mut sent = Map::new();
633        sent.insert("jsonrpc".to_owned(), json!("2.0"));
634        sent.insert("id".to_owned(), json!(1));
635        sent.insert(
636            ERROR.to_owned(),
637            json!({ "code": -32601, "message": "no such tool" }),
638        );
639
640        let translated = Translator::new(Arc::clone(&codec)).translate(
641            sent.clone(),
642            ProtocolVersion::V2026_07_28,
643            &bare_leg(),
644            DegradePolicy::permissive(),
645        )?;
646
647        assert_eq!(translated.reply, sent);
648        assert!(translated.report.is_empty());
649        assert_eq!(codec.lifts(), 0, "an error envelope is not a tool result");
650        Ok(())
651    }
652
653    /// A payload that is not an object is not a `ToolResult`, whatever produced
654    /// it. `tools/list` is the real case.
655    #[test]
656    fn a_non_object_payload_is_left_alone() -> Result<(), TestError> {
657        let codec = Arc::new(Counting::reading(structured()?));
658        let sent = reply(json!([{ "name": "echo" }]));
659
660        let translated = Translator::new(Arc::clone(&codec)).translate(
661            sent.clone(),
662            ProtocolVersion::V2026_07_28,
663            &bare_leg(),
664            DegradePolicy::permissive(),
665        )?;
666
667        assert_eq!(translated.reply, sent);
668        assert_eq!(codec.lowers(), 0);
669        Ok(())
670    }
671
672    /// The gate the early return rests on: a value needing nothing is a subset
673    /// of any profile, so "nothing to decide" is reachable rather than dead.
674    #[test]
675    fn a_text_result_needs_nothing_from_any_profile() {
676        let required = ToolResult::text("answer").required_features();
677        let available = FeatureSet::union_of(FeatureProfile::empty().iter());
678        assert!(required.is_subset_of(available));
679    }
680}