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}