Skip to main content

aft/subc/
wire.rs

1//! Frame encoding and writer-queue helpers used by the subc transport edge.
2
3use serde::ser::{SerializeMap, SerializeStruct};
4use serde::{Serialize, Serializer};
5
6use super::{
7    control_flags, fmt, mpsc, AtomicUsize, DispatchPathMetrics, ErrorBody, Flags, Frame, FrameType,
8    Ordering, PathBuf, Response, RouteChannel, ToolCallResult, Value, CONTROL_SEND_TIMEOUT,
9    RELIABLE_WRITER_RETRY_INITIAL_BACKOFF, RELIABLE_WRITER_RETRY_MAX_BACKOFF,
10};
11use crate::run_tool_call::{PhaseTrace, ToolCallEgressTiming, ToolCallPhaseDurations};
12
13pub(super) type WriterSender = mpsc::Sender<WriterFrame>;
14
15pub(super) struct ToolResponseWriteTrace {
16    phase_trace: PhaseTrace,
17    name: String,
18    root: PathBuf,
19    session: String,
20    channel: u16,
21    corr: u64,
22    enqueued_at: Option<std::time::Instant>,
23    queue_depth: usize,
24    writer_active_at_enqueue: bool,
25    writer_queue_was_full: bool,
26    reserve_timeouts: u32,
27}
28
29impl ToolResponseWriteTrace {
30    pub(super) fn new(
31        phase_trace: PhaseTrace,
32        name: String,
33        root: PathBuf,
34        session: String,
35        channel: u16,
36        corr: u64,
37    ) -> Self {
38        Self {
39            phase_trace,
40            name,
41            root,
42            session,
43            channel,
44            corr,
45            enqueued_at: None,
46            queue_depth: 0,
47            writer_active_at_enqueue: false,
48            writer_queue_was_full: false,
49            reserve_timeouts: 0,
50        }
51    }
52
53    fn mark_writer_queue_full(&mut self) {
54        self.writer_queue_was_full = true;
55    }
56
57    fn mark_reserve_timeout(&mut self) {
58        self.reserve_timeouts = self.reserve_timeouts.saturating_add(1);
59    }
60
61    fn mark_enqueued(&mut self, queue_depth: usize, writer_active: bool) {
62        self.enqueued_at = Some(std::time::Instant::now());
63        self.queue_depth = queue_depth;
64        self.writer_active_at_enqueue = writer_active;
65    }
66
67    pub(super) fn finish(
68        self,
69        dequeued: std::time::Instant,
70        write_started: std::time::Instant,
71        write_finished: std::time::Instant,
72        frame_bytes: usize,
73    ) -> Option<CompletedToolResponseTrace> {
74        let phases = self.phase_trace.finish(ToolCallEgressTiming {
75            enqueued: self.enqueued_at?,
76            dequeued,
77            write_started,
78            write_finished,
79            frame_bytes,
80            queue_depth: self.queue_depth,
81            writer_active_at_enqueue: self.writer_active_at_enqueue,
82            writer_queue_was_full: self.writer_queue_was_full,
83            reserve_timeouts: self.reserve_timeouts,
84        })?;
85        Some(CompletedToolResponseTrace {
86            name: self.name,
87            root: self.root,
88            session: self.session,
89            channel: self.channel,
90            corr: self.corr,
91            phases,
92        })
93    }
94}
95
96pub(super) struct CompletedToolResponseTrace {
97    pub(super) name: String,
98    pub(super) root: PathBuf,
99    pub(super) session: String,
100    pub(super) channel: u16,
101    pub(super) corr: u64,
102    pub(super) phases: ToolCallPhaseDurations,
103}
104
105pub(super) struct WriterFrame {
106    pub(super) frame: Frame,
107    pub(super) tool_response_trace: Option<ToolResponseWriteTrace>,
108}
109
110impl std::ops::Deref for WriterFrame {
111    type Target = Frame;
112
113    fn deref(&self) -> &Self::Target {
114        &self.frame
115    }
116}
117
118impl WriterFrame {
119    pub(super) fn plain(frame: Frame) -> Self {
120        Self {
121            frame,
122            tool_response_trace: None,
123        }
124    }
125
126    fn traced_tool_response(frame: Frame, trace: ToolResponseWriteTrace) -> Self {
127        Self {
128            frame,
129            tool_response_trace: Some(trace),
130        }
131    }
132
133    fn mark_writer_queue_full(&mut self) {
134        if let Some(trace) = self.tool_response_trace.as_mut() {
135            trace.mark_writer_queue_full();
136        }
137    }
138
139    fn mark_reserve_timeout(&mut self) {
140        if let Some(trace) = self.tool_response_trace.as_mut() {
141            trace.mark_reserve_timeout();
142        }
143    }
144
145    fn mark_enqueued(&mut self, queue_depth: usize, writer_active: bool) {
146        if let Some(trace) = self.tool_response_trace.as_mut() {
147            trace.mark_enqueued(queue_depth, writer_active);
148        }
149    }
150}
151
152pub(super) enum WriterEnqueueOutcome {
153    Enqueued,
154    Full(WriterFrame),
155    Closed,
156}
157
158impl WriterEnqueueOutcome {
159    #[cfg(test)]
160    pub(super) fn is_enqueued(&self) -> bool {
161        matches!(self, Self::Enqueued)
162    }
163}
164
165pub(super) fn decrement_counted_channel(counter: &AtomicUsize) {
166    let previous = counter.fetch_sub(1, Ordering::Relaxed);
167    debug_assert!(previous > 0, "counted channel depth underflow");
168}
169
170pub(super) async fn send_counted_channel<T>(
171    tx: &mpsc::Sender<T>,
172    counter: &AtomicUsize,
173    item: T,
174) -> Result<(), mpsc::error::SendError<T>> {
175    counter.fetch_add(1, Ordering::Relaxed);
176    match tx.send(item).await {
177        Ok(()) => Ok(()),
178        Err(error) => {
179            decrement_counted_channel(counter);
180            Err(error)
181        }
182    }
183}
184
185fn enqueue_writer_item(
186    permit: mpsc::Permit<'_, WriterFrame>,
187    metrics: &DispatchPathMetrics,
188    mut item: WriterFrame,
189) {
190    let queue_depth = metrics.writer_queued.fetch_add(1, Ordering::Relaxed) + 1;
191    item.mark_enqueued(queue_depth, metrics.writer_active.load(Ordering::Relaxed));
192    permit.send(item);
193}
194
195fn try_enqueue_writer_item(
196    tx: &WriterSender,
197    metrics: &DispatchPathMetrics,
198    mut item: WriterFrame,
199) -> WriterEnqueueOutcome {
200    match tx.try_reserve() {
201        Ok(permit) => {
202            enqueue_writer_item(permit, metrics, item);
203            WriterEnqueueOutcome::Enqueued
204        }
205        Err(mpsc::error::TrySendError::Full(())) => {
206            metrics
207                .writer_saturation_count
208                .fetch_add(1, Ordering::Relaxed);
209            item.mark_writer_queue_full();
210            WriterEnqueueOutcome::Full(item)
211        }
212        Err(mpsc::error::TrySendError::Closed(())) => {
213            drop(item);
214            WriterEnqueueOutcome::Closed
215        }
216    }
217}
218
219pub(super) fn try_enqueue_writer_frame(
220    tx: &WriterSender,
221    metrics: &DispatchPathMetrics,
222    frame: Frame,
223) -> WriterEnqueueOutcome {
224    try_enqueue_writer_item(tx, metrics, WriterFrame::plain(frame))
225}
226
227async fn send_reliable_writer_item(
228    tx: &WriterSender,
229    metrics: &DispatchPathMetrics,
230    mut item: WriterFrame,
231    context: &'static str,
232) -> Result<(), SubcError> {
233    let mut warned = false;
234    let mut backoff = RELIABLE_WRITER_RETRY_INITIAL_BACKOFF;
235
236    loop {
237        match try_enqueue_writer_item(tx, metrics, item) {
238            WriterEnqueueOutcome::Enqueued => return Ok(()),
239            WriterEnqueueOutcome::Closed => return Err(SubcError::WriterClosed),
240            WriterEnqueueOutcome::Full(returned_item) => {
241                item = returned_item;
242            }
243        }
244
245        match tokio::time::timeout(CONTROL_SEND_TIMEOUT, tx.reserve()).await {
246            Ok(Ok(permit)) => {
247                enqueue_writer_item(permit, metrics, item);
248                return Ok(());
249            }
250            Ok(Err(_)) => return Err(SubcError::WriterClosed),
251            Err(_) => {
252                metrics
253                    .writer_saturation_count
254                    .fetch_add(1, Ordering::Relaxed);
255                item.mark_reserve_timeout();
256                if !warned {
257                    log::warn!(
258                        "subc attach: writer queue stayed full while sending {context}; retrying reliable frame"
259                    );
260                    warned = true;
261                }
262                tokio::time::sleep(backoff).await;
263                backoff =
264                    std::cmp::min(backoff.saturating_mul(2), RELIABLE_WRITER_RETRY_MAX_BACKOFF);
265            }
266        }
267    }
268}
269
270pub(super) async fn send_reliable_writer_frame(
271    tx: &WriterSender,
272    metrics: &DispatchPathMetrics,
273    frame: Frame,
274    context: &'static str,
275) -> Result<(), SubcError> {
276    send_reliable_writer_item(tx, metrics, WriterFrame::plain(frame), context).await
277}
278
279pub(super) async fn send_traced_tool_response_frame(
280    tx: &WriterSender,
281    metrics: &DispatchPathMetrics,
282    frame: Frame,
283    trace: ToolResponseWriteTrace,
284) -> Result<(), SubcError> {
285    send_reliable_writer_item(
286        tx,
287        metrics,
288        WriterFrame::traced_tool_response(frame, trace),
289        "tool response",
290    )
291    .await
292}
293
294pub(super) async fn send_frame(
295    tx: &WriterSender,
296    metrics: &DispatchPathMetrics,
297    frame: Frame,
298) -> Result<(), SubcError> {
299    match try_enqueue_writer_item(tx, metrics, WriterFrame::plain(frame)) {
300        WriterEnqueueOutcome::Enqueued => Ok(()),
301        WriterEnqueueOutcome::Closed => Err(SubcError::WriterClosed),
302        WriterEnqueueOutcome::Full(item) => {
303            match tokio::time::timeout(CONTROL_SEND_TIMEOUT, tx.reserve()).await {
304                Ok(Ok(permit)) => {
305                    enqueue_writer_item(permit, metrics, item);
306                    Ok(())
307                }
308                Ok(Err(_)) => Err(SubcError::WriterClosed),
309                Err(_) => {
310                    metrics
311                        .writer_saturation_count
312                        .fetch_add(1, Ordering::Relaxed);
313                    Err(SubcError::WriterBackpressureTimeout)
314                }
315            }
316        }
317    }
318}
319
320/// Borrowed flat response matching the standalone NDJSON shape without cloning
321/// the response id or any structured data values.
322struct FlatToolResponse<'a> {
323    response: &'a crate::protocol::Response,
324    text: &'a str,
325}
326
327impl Serialize for FlatToolResponse<'_> {
328    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
329    where
330        S: Serializer,
331    {
332        let data = self.response.data.as_object();
333        let has_text = data.is_some_and(|data| data.contains_key("text"));
334        let field_count =
335            2 + data.map_or(0, |data| {
336                data.len()
337                    - usize::from(data.contains_key("id"))
338                    - usize::from(data.contains_key("success"))
339            }) + usize::from(!has_text);
340        let mut map = serializer.serialize_map(Some(field_count))?;
341        match data.and_then(|data| data.get("id")) {
342            Some(value) => map.serialize_entry("id", value)?,
343            None => map.serialize_entry("id", &self.response.id)?,
344        }
345        match data.and_then(|data| data.get("success")) {
346            Some(value) => map.serialize_entry("success", value)?,
347            None => map.serialize_entry("success", &self.response.success)?,
348        }
349        if let Some(data) = data {
350            for (key, value) in data {
351                match key.as_str() {
352                    "id" | "success" => {}
353                    "text" => map.serialize_entry(key, self.text)?,
354                    _ => map.serialize_entry(key, value)?,
355                }
356            }
357        }
358        if !has_text {
359            map.serialize_entry("text", self.text)?;
360        }
361        map.end()
362    }
363}
364
365struct ToolResponseEnvelope<'a> {
366    result: &'a ToolCallResult,
367}
368
369impl Serialize for ToolResponseEnvelope<'_> {
370    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
371    where
372        S: Serializer,
373    {
374        let mut envelope = serializer.serialize_struct("ToolResponseEnvelope", 3)?;
375        envelope.serialize_field(
376            "content",
377            &[TextContent {
378                kind: "text",
379                text: &self.result.text,
380            }],
381        )?;
382        envelope.serialize_field("isError", &!self.result.response.success)?;
383        envelope.serialize_field(
384            "structuredContent",
385            &FlatToolResponse {
386                response: &self.result.response,
387                text: &self.result.text,
388            },
389        )?;
390        envelope.end()
391    }
392}
393
394#[derive(Serialize)]
395struct TextContent<'a> {
396    #[serde(rename = "type")]
397    kind: &'static str,
398    text: &'a str,
399}
400
401pub(super) fn build_tool_response_frame(
402    ver: u8,
403    route: RouteChannel,
404    corr: u64,
405    flags: Flags,
406    result: &ToolCallResult,
407) -> Result<Frame, SubcError> {
408    // `content`/`isError` is the MCP-native surface a GENERIC host reads (and a
409    // generic host ignores `structuredContent`, per the MCP spec). The
410    // FIRST-PARTY AFT plugin instead reads `structuredContent`, which carries
411    // the full flat standalone shape ({id, success, ...data, text}) so every
412    // structured sidecar the plugin drives UI from — status_bar, bg_completions
413    // (in-band drain), preview_diff, code, message, attachments — survives the
414    // route. subc relays the body byte-for-byte, so this reaches the plugin
415    // unchanged. SubcTransport.toolCall re-lifts `structuredContent` straight to
416    // the flat ToolCallResult, so nothing downstream of the transport differs
417    // from the NDJSON path.
418    let body = serde_json::to_vec(&ToolResponseEnvelope { result }).map_err(SubcError::Json)?;
419
420    Frame::build_with_version(
421        ver,
422        FrameType::Response,
423        flags,
424        route.channel,
425        route.epoch,
426        corr,
427        body,
428    )
429    .map_err(SubcError::FrameBuild)
430}
431
432pub(super) fn build_error_frame(
433    ver: u8,
434    channel: u16,
435    epoch: u32,
436    corr: u64,
437    flags: Flags,
438    code: &str,
439    message: &str,
440) -> Result<Frame, SubcError> {
441    let body = serde_json::to_vec(&ErrorBody {
442        code: code.to_string(),
443        message: message.to_string(),
444    })
445    .map_err(SubcError::Json)?;
446    Frame::build_with_version(ver, FrameType::Error, flags, channel, epoch, corr, body)
447        .map_err(SubcError::FrameBuild)
448}
449
450pub(super) fn build_goodbye_frame(
451    ver: u8,
452    channel: u16,
453    epoch: u32,
454    corr: u64,
455) -> Result<Frame, SubcError> {
456    Frame::build_with_version(
457        ver,
458        FrameType::Goodbye,
459        control_flags(),
460        channel,
461        epoch,
462        corr,
463        Vec::new(),
464    )
465    .map_err(SubcError::FrameBuild)
466}
467
468pub(super) fn response_message(response: &Response, fallback: &str) -> String {
469    response
470        .data
471        .get("message")
472        .and_then(Value::as_str)
473        .map(ToOwned::to_owned)
474        .unwrap_or_else(|| fallback.to_string())
475}
476
477pub(super) fn response_is_fatal_panic(response: &Response) -> bool {
478    !response.success && response.data.get("code").and_then(Value::as_str) == Some("actor_fatal")
479}
480
481#[derive(Debug)]
482pub enum SubcError {
483    Runtime(std::io::Error),
484    ConnectionFile {
485        path: PathBuf,
486        source: subc_transport::ConnectionFileError,
487    },
488    NoEndpoint {
489        path: PathBuf,
490    },
491    InvalidEndpoint {
492        path: PathBuf,
493        endpoint: String,
494    },
495    Connect {
496        endpoint: String,
497        source: std::io::Error,
498    },
499    Auth {
500        endpoint: String,
501        source: subc_transport::AuthError,
502    },
503    FrameIo(subc_transport::FrameIoError),
504    FrameBuild(subc_protocol::FrameBuildError),
505    WriterClosed,
506    WriterBackpressureTimeout,
507    WriterJoin(tokio::task::JoinError),
508    Json(serde_json::Error),
509    ClosedBeforeHelloAck,
510    HelloRejected {
511        body: Option<ErrorBody>,
512    },
513    UnexpectedFrame {
514        ty: FrameType,
515    },
516}
517
518impl fmt::Display for SubcError {
519    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520        match self {
521            Self::Runtime(e) => write!(f, "failed to build subc tokio runtime: {e}"),
522            Self::ConnectionFile { path, source } => {
523                write!(f, "failed to read subc connection file {path:?}: {source}")
524            }
525            Self::NoEndpoint { path } => {
526                write!(f, "subc connection file {path:?} has no endpoints")
527            }
528            Self::InvalidEndpoint { path, endpoint } => {
529                write!(
530                    f,
531                    "subc connection file {path:?} has invalid endpoint {endpoint}"
532                )
533            }
534            Self::Connect { endpoint, source } => {
535                write!(f, "failed to connect to subc endpoint {endpoint}: {source}")
536            }
537            Self::Auth { endpoint, source } => {
538                write!(
539                    f,
540                    "failed to authenticate to subc endpoint {endpoint}: {source}"
541                )
542            }
543            Self::FrameIo(e) => write!(f, "subc frame I/O error: {e}"),
544            Self::FrameBuild(e) => write!(f, "subc frame build error: {e}"),
545            Self::WriterClosed => write!(f, "subc writer task closed"),
546            Self::WriterBackpressureTimeout => write!(
547                f,
548                "subc writer task stayed backpressured while sending a control frame"
549            ),
550            Self::WriterJoin(e) => write!(f, "subc writer task join error: {e}"),
551            Self::Json(e) => write!(f, "subc JSON error: {e}"),
552            Self::ClosedBeforeHelloAck => {
553                write!(f, "subc daemon closed the connection before HelloAck")
554            }
555            Self::HelloRejected { body } => match body {
556                Some(b) => write!(f, "subc rejected ModuleHello: {} ({})", b.code, b.message),
557                None => write!(f, "subc rejected ModuleHello (unparseable error body)"),
558            },
559            Self::UnexpectedFrame { ty } => {
560                write!(f, "subc sent unexpected frame in place of HelloAck: {ty:?}")
561            }
562        }
563    }
564}
565
566impl std::error::Error for SubcError {}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::subc::route_key;
572    use serde_json::json;
573    use std::sync::Arc;
574    use std::time::{Duration, Instant};
575    use subc_protocol::PROTOCOL_VERSION;
576
577    #[test]
578    fn writer_depth_counter_tracks_enqueued_frames_until_drain() {
579        let metrics = DispatchPathMetrics::new();
580        let (writer_tx, mut writer_rx) = mpsc::channel::<WriterFrame>(8);
581
582        for corr in 1..=3 {
583            let frame = Frame::build(FrameType::Ping, control_flags(), 0, 0, corr, Vec::new())
584                .expect("test frame");
585            assert!(try_enqueue_writer_frame(&writer_tx, &metrics, frame).is_enqueued());
586        }
587        assert_eq!(metrics.writer_queued.load(Ordering::Relaxed), 3);
588
589        for _ in 0..3 {
590            writer_rx.try_recv().expect("queued writer frame");
591            decrement_counted_channel(&metrics.writer_queued);
592        }
593        assert_eq!(metrics.writer_queued.load(Ordering::Relaxed), 0);
594    }
595
596    #[tokio::test]
597    async fn reliable_writer_send_retries_after_timeout_and_preserves_frame() {
598        let metrics = Arc::new(DispatchPathMetrics::new());
599        let (writer_tx, mut writer_rx) = mpsc::channel::<WriterFrame>(1);
600        writer_tx
601            .try_send(WriterFrame::plain(
602                Frame::build(FrameType::Ping, control_flags(), 0, 0, 1, Vec::new()).unwrap(),
603            ))
604            .expect("prefill writer queue");
605
606        let metrics_for_task = Arc::clone(&metrics);
607        let tx_for_task = writer_tx.clone();
608        let send_task = tokio::spawn(async move {
609            send_reliable_writer_frame(
610                &tx_for_task,
611                &metrics_for_task,
612                Frame::build(FrameType::Pong, control_flags(), 0, 0, 2, Vec::new()).unwrap(),
613                "test reliable frame",
614            )
615            .await
616        });
617
618        tokio::time::timeout(Duration::from_secs(2), async {
619            while metrics.writer_saturation_count.load(Ordering::Relaxed) < 2 {
620                tokio::time::sleep(Duration::from_millis(10)).await;
621            }
622        })
623        .await
624        .expect("reliable send should observe a timed-out full writer queue");
625
626        let prefilled = writer_rx.recv().await.expect("prefilled frame");
627        assert_eq!(prefilled.header.corr, 1);
628        let result = tokio::time::timeout(Duration::from_secs(2), send_task)
629            .await
630            .expect("reliable send should finish after writer drains")
631            .expect("reliable send task should not panic");
632        assert!(result.is_ok());
633        let delivered = writer_rx.recv().await.expect("retried reliable frame");
634        assert_eq!(delivered.header.corr, 2);
635    }
636
637    #[test]
638    fn response_is_fatal_panic_only_matches_panic_exclusive_code() {
639        let tool_error = Response::error("request-1", "internal_error", "ordinary tool error");
640        let panic_error = Response::error("request-2", "actor_fatal", "mutating panic");
641
642        assert!(!response_is_fatal_panic(&tool_error));
643        assert!(response_is_fatal_panic(&panic_error));
644    }
645
646    #[tokio::test]
647    async fn control_send_times_out_when_writer_queue_remains_full() {
648        let (writer_tx, _writer_rx) = mpsc::channel::<WriterFrame>(1);
649        let metrics = DispatchPathMetrics::new();
650        writer_tx
651            .try_send(WriterFrame::plain(
652                Frame::build(FrameType::Ping, control_flags(), 0, 0, 1, Vec::new()).unwrap(),
653            ))
654            .expect("prefill writer queue");
655        let started = Instant::now();
656
657        let result = send_frame(
658            &writer_tx,
659            &metrics,
660            Frame::build(FrameType::Pong, control_flags(), 0, 0, 2, Vec::new()).unwrap(),
661        )
662        .await;
663
664        assert!(matches!(result, Err(SubcError::WriterBackpressureTimeout)));
665        assert!(
666            started.elapsed() < Duration::from_secs(2),
667            "control send guard should be bounded"
668        );
669    }
670
671    #[test]
672    fn tool_response_frame_carries_flat_standalone_shape_in_structured_content() {
673        use crate::protocol::Response;
674
675        // A response with sidecars the FIRST-PARTY plugin drives UI from
676        // (status_bar, bg_completions, code) plus a normal result field.
677        let response = Response::success(
678            "req-7",
679            json!({
680                "complete": true,
681                "matches": 3,
682                "status_bar": { "errors": 0, "warnings": 1 },
683                "bg_completions": [{ "task_id": "bash-abc" }],
684            }),
685        );
686        let result = ToolCallResult {
687            text: "rendered text".to_string(),
688            response,
689        };
690
691        // The flat shape must equal the standalone NDJSON `tool_call` body:
692        // {id, success, ...data, text}. Build the standalone expectation the
693        // same way commands::tool_call::response_with_text does.
694        let expected_flat = json!({
695            "id": "req-7",
696            "success": true,
697            "complete": true,
698            "matches": 3,
699            "status_bar": { "errors": 0, "warnings": 1 },
700            "bg_completions": [{ "task_id": "bash-abc" }],
701            "text": "rendered text",
702        });
703        assert_eq!(
704            serde_json::to_value(FlatToolResponse {
705                response: &result.response,
706                text: &result.text,
707            })
708            .unwrap(),
709            expected_flat,
710            "structuredContent must be byte-identical to the standalone flat response"
711        );
712
713        // The frame body carries the MCP surface for generic hosts AND the flat
714        // sidecar shape under structuredContent for the first-party plugin.
715        let frame = build_tool_response_frame(
716            PROTOCOL_VERSION,
717            route_key(1, 1),
718            42,
719            control_flags(),
720            &result,
721        )
722        .unwrap();
723        let expected_body = serde_json::to_vec(&json!({
724            "content": [{ "type": "text", "text": "rendered text" }],
725            "isError": false,
726            "structuredContent": expected_flat.clone(),
727        }))
728        .unwrap();
729        assert_eq!(
730            frame.body, expected_body,
731            "tool response wire bytes drifted"
732        );
733        let body: Value = serde_json::from_slice(&frame.body).unwrap();
734        assert_eq!(body["isError"], json!(false));
735        assert_eq!(body["content"][0]["type"], json!("text"));
736        assert_eq!(body["content"][0]["text"], json!("rendered text"));
737        assert_eq!(body["structuredContent"], expected_flat);
738
739        // A failed response flips isError and still carries the flat shape
740        // (with success:false + code) for the plugin's error path.
741        let err = Response::error_with_data(
742            "req-8",
743            "ambiguous_match",
744            "too many matches",
745            json!({ "candidates": ["a", "b"] }),
746        );
747        let err_result = ToolCallResult {
748            text: "error text".to_string(),
749            response: err,
750        };
751        let err_frame = build_tool_response_frame(
752            PROTOCOL_VERSION,
753            route_key(1, 1),
754            43,
755            control_flags(),
756            &err_result,
757        )
758        .unwrap();
759        let err_body: Value = serde_json::from_slice(&err_frame.body).unwrap();
760        assert_eq!(err_body["isError"], json!(true));
761        assert_eq!(err_body["structuredContent"]["success"], json!(false));
762        assert_eq!(
763            err_body["structuredContent"]["code"],
764            json!("ambiguous_match")
765        );
766        assert_eq!(
767            err_body["structuredContent"]["candidates"],
768            json!(["a", "b"])
769        );
770        assert_eq!(err_body["structuredContent"]["text"], json!("error text"));
771    }
772}