Skip to main content

protocol_droid/application/bridge/
cross_bridge_peer.rs

1//! The cross-protocol bridge peer: a `BridgePeer` that translates
2//! requests and replies through a [`CrossProtocolCodec`] before the
3//! inner upstream peer ever sees them.
4//!
5//! # What this type is
6//!
7//! A bridge to an A2A agent from an MCP client (or vice versa) needs to
8//! rename methods and reshape parameters on the way out, and rename +
9//! reshape the reply on the way back. The same `BridgePeer` trait the
10//! MCP-only bridge uses is the seam — a cross-protocol peer is just a
11//! `BridgePeer` whose `send` runs the envelope through a codec first
12//! and the reply through the codec again, and whose `notify` does the
13//! request half of the same.
14//!
15//! # What this type is *not*
16//!
17//! It is not a `Bridge` in the sense of [`super::forward::Bridge`]. The
18//! same-protocol bridge is a use case over a peer; this is the peer
19//! that lets that use case talk across protocols. A loop that holds a
20//! `CrossBridgePeer` as its upstream runs unchanged, because the
21//! downstream leg never sees the codec — it sees envelopes in its own
22//! language on the way in and its own language on the way out.
23//!
24//! # Why the wrapper looks the same shape as the inner peer
25//!
26//! A `CrossBridgePeer` *contains* an `Arc<U>` and a `CrossProtocolCodec`,
27//! and exposes a `BridgePeer` impl. The inner peer is reachable through
28//! [`inner`](Self::inner) for the rare operator who needs it; the codec
29//! is reachable through [`codec`](Self::codec) for diagnostics. A
30//! bridge that needed a *second* upstream for the same downstream
31//! would need a different type, because it has different ownership —
32//! not because the trait was hidden.
33//!
34//! # What happens when the codec cannot bridge the method
35//!
36//! A `SendMessage` from an A2A client is bridged; a `GetTask` is not.
37//! The wrapper hands `GetTask` to the codec, the codec returns
38//! [`CrossProtocolError::MethodNotBridged`], the wrapper returns
39//! [`BridgeError::MethodNotBridged`] carrying the same name, and the
40//! loop answers the client with `-32601 Method not found`. The
41//! alternative — passthrough-shipping a method the upstream rejects
42//! with `-32601` of its own — would look the same on the wire but
43//! would lose the codec's `RequestCodecReport`, which the operator
44//! needs when a method they expected to bridge comes back unbridged.
45
46use std::sync::Arc;
47
48use serde_json::{Map, Value};
49
50use super::cross_protocol::{
51    CrossProtocolCodec, CrossProtocolError, ProtocolSide, RequestCodecReport,
52};
53use super::forward::{BridgeError, BridgePeer, SendFuture};
54
55/// A bridge peer that runs every request and reply through a
56/// cross-protocol codec before reaching the inner peer.
57///
58/// Constructed cheaply: the only state is the inner peer's `Arc` and
59/// the codec. Both are expected to be `Send + Sync` (the codec is
60/// required to be, and the inner peer is a `BridgePeer`, which already
61/// is).
62#[derive(Debug)]
63pub struct CrossBridgePeer<U: BridgePeer + ?Sized> {
64    /// The protocol the downstream leg speaks, fixed at construction.
65    downstream_side: ProtocolSide,
66    /// The codec that does the translation. Held by `Arc` so the same
67    /// codec can be shared with the diagnostics surface (T28 follow-up)
68    /// without copying the rules.
69    codec: Arc<dyn CrossProtocolCodec>,
70    /// The upstream peer the codec writes to and reads from.
71    inner: Arc<U>,
72}
73
74impl<U: BridgePeer + ?Sized> CrossBridgePeer<U> {
75    /// A wrapper around `inner` that translates through `codec`.
76    ///
77    /// `downstream_side` is the protocol the *downstream* leg speaks;
78    /// the wrapper passes it to every call so the codec knows which
79    /// direction to drive. A symmetric codec that handles both
80    /// directions declares one of them in
81    /// [`downstream_protocol`](CrossProtocolCodec::downstream_protocol)
82    /// and lets the bridge pick the other; the bridge's
83    /// `downstream_side` is the source of truth, not the codec's
84    /// declaration.
85    pub fn new(
86        downstream_side: ProtocolSide,
87        codec: Arc<dyn CrossProtocolCodec>,
88        inner: Arc<U>,
89    ) -> Result<Self, BridgeError> {
90        Ok(Self {
91            downstream_side,
92            codec,
93            inner,
94        })
95    }
96
97    /// The protocol this peer reports for the downstream leg.
98    #[must_use]
99    pub const fn downstream_side(&self) -> ProtocolSide {
100        self.downstream_side
101    }
102
103    /// The codec the wrapper runs envelopes through.
104    #[must_use]
105    pub fn codec(&self) -> &Arc<dyn CrossProtocolCodec> {
106        &self.codec
107    }
108
109    /// The inner upstream peer the codec writes to.
110    #[must_use]
111    pub const fn inner(&self) -> &Arc<U> {
112        &self.inner
113    }
114
115    /// Whether the codec bridges the given IR method.
116    ///
117    /// The bridge loop asks this once at feature-check time, so a
118    /// method that is not bridged is refused with `-32601` *before*
119    /// the codec is invoked and the inner peer is contacted. The
120    /// check is the codec's `wire_method` table — the same table the
121    /// codec uses to translate — so the answer cannot disagree with
122    /// the codec.
123    #[must_use]
124    pub fn bridges(&self, ir_method: &str) -> bool {
125        self.downstream_side.wire_method(ir_method).is_some()
126    }
127}
128
129impl<U: BridgePeer + ?Sized> BridgePeer for CrossBridgePeer<U> {
130    fn send(
131        &self,
132        envelope: Map<String, Value>,
133    ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
134        Box::pin(async move {
135            let ir_method = ir_method_of(&envelope);
136            let mut report = RequestCodecReport::empty();
137            let outbound = self
138                .codec
139                .encode_request(self.downstream_side, ir_method, &envelope, &mut report)
140                .map_err(|e| bridge_error_from(e, self.downstream_side))?;
141            let reply = self.inner.send(outbound).await?;
142            self.codec
143                .decode_reply(self.downstream_side, ir_method, &reply, &mut report)
144                .map_err(|e| bridge_error_from(e, self.downstream_side))
145        })
146    }
147
148    fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
149        Box::pin(async move {
150            let ir_method = ir_method_of(&envelope);
151            let mut report = RequestCodecReport::empty();
152            let outbound = self
153                .codec
154                .encode_request(self.downstream_side, ir_method, &envelope, &mut report)
155                .map_err(|e| bridge_error_from(e, self.downstream_side))?;
156            self.inner.notify(outbound).await
157        })
158    }
159
160    fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
161        self.inner.close()
162    }
163
164    fn listen(&self) -> SendFuture<'_, Result<(), BridgeError>> {
165        self.inner.listen()
166    }
167}
168
169/// The IR method name the codec needs.
170///
171/// Equal to the envelope's `method` member, because the bridge lifts
172/// the wire method verbatim into the IR — the IR has no per-protocol
173/// names yet, and a method the bridge cannot lift is one the codec
174/// should never see.
175///
176/// Returns the empty string when the envelope has no `method`, which
177/// is the case for replies (an inbound reply has no `method`, only
178/// `id` + `result` / `error`). A reply that reaches this function
179/// already passed through the inner peer; the codec will see it on
180/// `decode_reply`, not here.
181fn ir_method_of(envelope: &Map<String, Value>) -> &str {
182    envelope.get("method").and_then(Value::as_str).unwrap_or("")
183}
184
185/// Map a codec error into a bridge error the loop can answer.
186fn bridge_error_from(err: CrossProtocolError, downstream: ProtocolSide) -> BridgeError {
187    match err {
188        CrossProtocolError::MethodNotBridged(method, _) => {
189            BridgeError::MethodNotBridged { method, downstream }
190        }
191        CrossProtocolError::UnknownDownstream(side) => BridgeError::MethodNotBridged {
192            method: format!("<codec for {side:?}>"),
193            downstream,
194        },
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use std::sync::{Arc, Mutex, TryLockError};
201
202    use serde_json::{Map, Value, json};
203
204    use super::{
205        super::cross_protocol::{
206            CrossProtocolCodec, CrossProtocolError, ProtocolSide, RequestCodecReport,
207            RequestCodecStep,
208        },
209        super::forward::{BridgeError, BridgePeer, SendFuture},
210        CrossBridgePeer,
211    };
212
213    /// A codec that renames the method but does nothing else.
214    ///
215    /// Built for the test that proves the wrapper translates and the
216    /// test that proves a wrong-side constructor is refused, both of
217    /// which are about the wrapper, not the real A2A ↔ MCP rules.
218    #[derive(Debug)]
219    struct RenameOnlyCodec {
220        downstream: ProtocolSide,
221        from: &'static str,
222        to: &'static str,
223    }
224
225    impl CrossProtocolCodec for RenameOnlyCodec {
226        fn downstream_protocol(&self) -> ProtocolSide {
227            self.downstream
228        }
229
230        fn encode_request(
231            &self,
232            _downstream_side: ProtocolSide,
233            ir_method: &str,
234            envelope: &Map<String, Value>,
235            report: &mut RequestCodecReport,
236        ) -> Result<Map<String, Value>, CrossProtocolError> {
237            if ir_method != self.from {
238                return Err(CrossProtocolError::MethodNotBridged(
239                    ir_method.to_owned(),
240                    self.downstream,
241                ));
242            }
243            report.record(RequestCodecStep::MethodRenamed {
244                from: self.from,
245                to: self.to,
246            });
247            let mut out = envelope.clone();
248            out.insert("method".to_owned(), Value::String(self.to.to_owned()));
249            Ok(out)
250        }
251
252        fn decode_reply(
253            &self,
254            _downstream_side: ProtocolSide,
255            _ir_method: &str,
256            reply: &Map<String, Value>,
257            _report: &mut RequestCodecReport,
258        ) -> Result<Map<String, Value>, CrossProtocolError> {
259            Ok(reply.clone())
260        }
261    }
262
263    /// A peer that records what it was sent and answers with a fixed reply.
264    #[derive(Debug, Default)]
265    struct RecorderPeer {
266        sent: Mutex<Vec<Map<String, Value>>>,
267        reply: Map<String, Value>,
268    }
269
270    impl RecorderPeer {
271        fn answering_with(reply: Map<String, Value>) -> Self {
272            Self {
273                sent: Mutex::new(Vec::new()),
274                reply,
275            }
276        }
277    }
278
279    impl RecorderPeer {
280        /// Records `envelope` if the mutex is healthy, otherwise returns the
281        /// poison as a `BridgeError`. The lock is taken and released before
282        /// the future is returned so the guard is not held across an `await`
283        /// — clippy's `significant_drop_tightening` flag and the loop's
284        /// expectation that `send` does not block are the same preference.
285        fn record(&self, envelope: Map<String, Value>) -> Result<(), BridgeError> {
286            self.sent
287                .lock()
288                .map_err(|_| BridgeError::Close("recorder mutex poisoned".to_owned()))
289                .map(|mut guard| guard.push(envelope))
290        }
291    }
292
293    impl BridgePeer for RecorderPeer {
294        fn send(
295            &self,
296            envelope: Map<String, Value>,
297        ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
298            let reply = self.reply.clone();
299            // Recording happens synchronously so the recorder is correct
300            // even if the future is later dropped before it polls. A
301            // poisoned mutex becomes a `Close` error in the future, which
302            // is the same shape the real bridge uses for "I could not
303            // record the frame" — the recorder does not invent a new
304            // error type.
305            let Ok(()) = self.record(envelope) else {
306                return Box::pin(async {
307                    Err(BridgeError::Close("recorder mutex poisoned".to_owned()))
308                });
309            };
310            Box::pin(async move { Ok(reply) })
311        }
312
313        fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
314            let Ok(()) = self.record(envelope) else {
315                return Box::pin(async {
316                    Err(BridgeError::Close("recorder mutex poisoned".to_owned()))
317                });
318            };
319            Box::pin(async move { Ok(()) })
320        }
321
322        fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
323            Box::pin(async { Ok(()) })
324        }
325    }
326
327    fn make_envelope(method: &str) -> Map<String, Value> {
328        let mut map = Map::new();
329        map.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
330        map.insert("id".to_owned(), Value::Number(1.into()));
331        map.insert("method".to_owned(), Value::String(method.to_owned()));
332        map.insert("params".to_owned(), Value::Object(Map::new()));
333        map
334    }
335
336    fn reply_with(message: &str) -> Map<String, Value> {
337        let mut map = Map::new();
338        map.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
339        map.insert("id".to_owned(), Value::Number(1.into()));
340        map.insert("result".to_owned(), Value::String(message.to_owned()));
341        map
342    }
343
344    fn codec_renaming_send_message_to_tools_call() -> Arc<dyn CrossProtocolCodec> {
345        Arc::new(RenameOnlyCodec {
346            downstream: ProtocolSide::A2a,
347            from: "SendMessage",
348            to: "tools/call",
349        })
350    }
351
352    type TestError = Failure;
353
354    /// What a single test can fail with, collapsed into one error type.
355    ///
356    /// Each variant's `From` impl lets the test use `?` without the
357    /// `.expect()` calls clippy forbids. The text in the messages is
358    /// what `cargo test` shows on failure, so each one names the
359    /// specific thing the test could not get at.
360    #[derive(Debug)]
361    enum Failure {
362        RecorderPoisoned,
363        RecorderEmpty,
364        RecordedEnvelopeMissingMethod,
365        ReplyMissingResult,
366        BridgeError(String),
367    }
368
369    impl core::fmt::Display for Failure {
370        fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
371            match self {
372                Self::RecorderPoisoned => formatter.write_str("the recorder mutex was poisoned"),
373                Self::RecorderEmpty => formatter.write_str("the recorder saw nothing"),
374                Self::RecordedEnvelopeMissingMethod => {
375                    formatter.write_str("recorded envelope had no `method` member")
376                }
377                Self::ReplyMissingResult => formatter.write_str("reply had no `result` member"),
378                Self::BridgeError(message) => formatter.write_str(message),
379            }
380        }
381    }
382
383    impl std::error::Error for Failure {}
384
385    impl<T> From<TryLockError<T>> for Failure {
386        fn from(_: TryLockError<T>) -> Self {
387            Self::RecorderPoisoned
388        }
389    }
390
391    impl From<BridgeError> for Failure {
392        fn from(err: BridgeError) -> Self {
393            Self::BridgeError(format!("{err:?}"))
394        }
395    }
396
397    /// Locks `mutex` once and yields a guard, with a typed error on
398    /// poisoning. The clippy-forbidden `.lock().expect("...")` is the
399    /// pattern this replaces; the `From` impl above makes the call
400    /// site a single `?`.
401    fn lock<T>(mutex: &Mutex<T>) -> Result<std::sync::MutexGuard<'_, T>, Failure> {
402        mutex.lock().map_err(|_| Failure::RecorderPoisoned)
403    }
404
405    #[tokio::test]
406    async fn a_send_envelope_is_translated_then_forwarded_then_reversed() -> Result<(), TestError> {
407        let peer = Arc::new(RecorderPeer::answering_with(reply_with("ok")));
408        let codec = codec_renaming_send_message_to_tools_call();
409        let wrapper = CrossBridgePeer::new(ProtocolSide::A2a, codec, peer.clone())?;
410
411        let sent = make_envelope("SendMessage");
412        let reply = wrapper.send(sent).await?;
413
414        // The inner peer saw `tools/call`, not `SendMessage`.
415        let recorded = lock(&peer.sent)?;
416        let only = recorded.first().ok_or(Failure::RecorderEmpty)?;
417        let method = only
418            .get("method")
419            .and_then(Value::as_str)
420            .ok_or(Failure::RecordedEnvelopeMissingMethod)?;
421        assert_eq!(method, "tools/call");
422        drop(recorded);
423
424        // The reply made it back to the caller unchanged: the codec
425        // returned it on `decode_reply` and the wrapper passed it through.
426        let result = reply
427            .get("result")
428            .and_then(Value::as_str)
429            .ok_or(Failure::ReplyMissingResult)?;
430        assert_eq!(result, "ok");
431        Ok(())
432    }
433
434    #[tokio::test]
435    async fn a_method_the_codec_does_not_bridge_is_refused_with_method_not_bridged()
436    -> Result<(), TestError> {
437        let peer = Arc::new(RecorderPeer::default());
438        let codec = codec_renaming_send_message_to_tools_call();
439        let wrapper = CrossBridgePeer::new(ProtocolSide::A2a, codec, peer.clone())?;
440
441        let sent = make_envelope("GetTask");
442        let Err(err) = wrapper.send(sent).await else {
443            return Err(Failure::BridgeError(
444                "unbridged method must be refused".to_owned(),
445            ));
446        };
447
448        match err {
449            BridgeError::MethodNotBridged { method, downstream } => {
450                assert_eq!(method, "GetTask");
451                assert_eq!(downstream, ProtocolSide::A2a);
452            }
453            other => {
454                return Err(Failure::BridgeError(format!(
455                    "expected MethodNotBridged, got {other:?}"
456                )));
457            }
458        }
459        // The inner peer was never contacted.
460        let recorded = lock(&peer.sent)?;
461        let empty = recorded.is_empty();
462        drop(recorded);
463        assert!(empty, "recorder saw an envelope it should not have");
464        Ok(())
465    }
466
467    #[tokio::test]
468    async fn a_notify_envelope_is_translated_then_forwarded() -> Result<(), TestError> {
469        let peer = Arc::new(RecorderPeer::default());
470        let codec = codec_renaming_send_message_to_tools_call();
471        let wrapper = CrossBridgePeer::new(ProtocolSide::A2a, codec, peer.clone())?;
472
473        let mut envelope = Map::new();
474        envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
475        envelope.insert("method".to_owned(), Value::String("SendMessage".to_owned()));
476        envelope.insert("params".to_owned(), json!({}));
477        wrapper.notify(envelope).await?;
478
479        let recorded = lock(&peer.sent)?;
480        let only = recorded.first().ok_or(Failure::RecorderEmpty)?;
481        let method = only
482            .get("method")
483            .and_then(Value::as_str)
484            .ok_or(Failure::RecordedEnvelopeMissingMethod)?;
485        assert_eq!(method, "tools/call");
486        drop(recorded);
487        Ok(())
488    }
489
490    #[test]
491    fn a_symmetric_codec_can_be_wrapped_for_either_downstream() -> Result<(), TestError> {
492        // The codec declares `Mcp` as its downstream, but the
493        // constructor accepts it for an A2a downstream too — the
494        // bridge's `downstream_side` is the source of truth, not the
495        // codec's declaration, and a symmetric codec handles both.
496        let peer: Arc<RecorderPeer> = Arc::new(RecorderPeer::default());
497        let codec: Arc<dyn CrossProtocolCodec> = Arc::new(RenameOnlyCodec {
498            downstream: ProtocolSide::Mcp,
499            from: "tools/call",
500            to: "SendMessage",
501        });
502        let wrapper = match CrossBridgePeer::new(ProtocolSide::A2a, codec, peer) {
503            Ok(wrapper) => wrapper,
504            Err(err) => {
505                return Err(Failure::BridgeError(format!(
506                    "construction should succeed: {err:?}"
507                )));
508            }
509        };
510        assert_eq!(wrapper.downstream_side(), ProtocolSide::A2a);
511        Ok(())
512    }
513
514    fn assert_send_sync<T: Send + Sync>(_: &T) {}
515
516    #[test]
517    fn the_wrapper_is_send_and_sync_via_arc() -> Result<(), TestError> {
518        let peer: Arc<RecorderPeer> = Arc::new(RecorderPeer::default());
519        let codec = codec_renaming_send_message_to_tools_call();
520        let wrapper = match CrossBridgePeer::new(ProtocolSide::A2a, codec, peer) {
521            Ok(wrapper) => wrapper,
522            Err(err) => {
523                return Err(Failure::BridgeError(format!("construction: {err:?}")));
524            }
525        };
526        assert_send_sync(&wrapper);
527        Ok(())
528    }
529
530    #[test]
531    fn bridges_reports_true_only_for_methods_in_the_codec_table() -> Result<(), TestError> {
532        let peer: Arc<RecorderPeer> = Arc::new(RecorderPeer::default());
533        let codec = codec_renaming_send_message_to_tools_call();
534        let wrapper = match CrossBridgePeer::new(ProtocolSide::A2a, codec, peer) {
535            Ok(wrapper) => wrapper,
536            Err(err) => {
537                return Err(Failure::BridgeError(format!("construction: {err:?}")));
538            }
539        };
540        assert!(wrapper.bridges("SendMessage"));
541        assert!(!wrapper.bridges("GetTask"));
542        assert!(!wrapper.bridges("tools/call"));
543        Ok(())
544    }
545
546    /// The wire-level end-to-end: an A2A `SendMessage` request goes
547    /// through the wrapper with the real `A2aMcpCodec` and a fake
548    /// upstream MCP peer that answers with a `CallToolResult`, and the
549    /// bridge returns an A2A-shaped reply.
550    #[tokio::test]
551    async fn the_real_a2a_mcp_codec_round_trips_through_the_wrapper() -> Result<(), TestError> {
552        use crate::application::bridge::A2aMcpCodec;
553
554        // The fake MCP peer: answers `tools/call` with a one-line
555        // `CallToolResult` whose content is a `TextContent`.
556        let mcp_reply: Map<String, Value> = serde_json::from_str(
557            r#"{
558                "jsonrpc": "2.0",
559                "id": 1,
560                "result": {
561                    "content": [{"type": "text", "text": "hello"}],
562                    "isError": false
563                }
564            }"#,
565        )
566        .map_err(|err| Failure::BridgeError(format!("fixture parse: {err}")))?;
567        let peer = Arc::new(RecorderPeer::answering_with(mcp_reply));
568
569        let codec: Arc<dyn CrossProtocolCodec> = Arc::new(A2aMcpCodec::new());
570        let wrapper = CrossBridgePeer::new(ProtocolSide::A2a, codec, peer.clone())
571            .map_err(|err| Failure::BridgeError(format!("construction: {err:?}")))?;
572
573        // The A2A client sent: a `SendMessage` whose `message.metadata`
574        // names the skill "echo" and whose `message.parts` carry a
575        // data part with `{"message": "hi"}`.
576        let mut envelope = Map::new();
577        envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
578        envelope.insert("id".to_owned(), Value::Number(1.into()));
579        envelope.insert("method".to_owned(), Value::String("SendMessage".to_owned()));
580        envelope.insert(
581            "params".to_owned(),
582            json!({
583                "message": {
584                    "role": "user",
585                    "parts": [
586                        {"kind": "data", "data": {"message": "hi"}}
587                    ]
588                },
589                "metadata": {"bridge_skill": "echo"}
590            }),
591        );
592
593        let reply = wrapper.send(envelope).await?;
594
595        // The inner MCP peer saw `tools/call`, not `SendMessage`.
596        let recorded = lock(&peer.sent)?;
597        let only = recorded.first().ok_or(Failure::RecorderEmpty)?;
598        let method = only
599            .get("method")
600            .and_then(Value::as_str)
601            .ok_or(Failure::RecordedEnvelopeMissingMethod)?;
602        assert_eq!(method, "tools/call");
603        let params = only
604            .get("params")
605            .and_then(Value::as_object)
606            .ok_or_else(|| Failure::BridgeError("params was not an object".to_owned()))?;
607        let name = params
608            .get("name")
609            .and_then(Value::as_str)
610            .ok_or_else(|| Failure::BridgeError("`name` was not a string".to_owned()))?;
611        assert_eq!(name, "echo");
612        drop(recorded);
613
614        // The reply came back shaped like an A2A Message, not an MCP
615        // CallToolResult.
616        let result = reply.get("result").ok_or(Failure::ReplyMissingResult)?;
617        let message = result
618            .as_object()
619            .and_then(|m| m.get("message"))
620            .and_then(Value::as_object)
621            .ok_or_else(|| {
622                Failure::BridgeError("reply.result.message was not an object".to_owned())
623            })?;
624        let role = message
625            .get("role")
626            .and_then(Value::as_str)
627            .ok_or_else(|| Failure::BridgeError("`role` was not a string".to_owned()))?;
628        assert_eq!(role, "assistant");
629        let parts = message
630            .get("parts")
631            .and_then(Value::as_array)
632            .ok_or_else(|| Failure::BridgeError("`parts` was not an array".to_owned()))?;
633        assert_eq!(parts.len(), 1, "expected one part in the reply");
634        Ok(())
635    }
636}