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, BindTrust, DispatchPathMetrics, ErrorBody, Flags, Frame,
8    FrameType, Ordering, PathBuf, Response, RouteChannel, ToolCallResult, Value,
9    CONTROL_SEND_TIMEOUT, 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    /// First-party binds get the full flat response in `structuredContent`
368    /// for the plugin re-lift; untrusted (MCP) binds get text-only.
369    include_structured: bool,
370}
371
372impl Serialize for ToolResponseEnvelope<'_> {
373    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
374    where
375        S: Serializer,
376    {
377        let fields = if self.include_structured { 3 } else { 2 };
378        let mut envelope = serializer.serialize_struct("ToolResponseEnvelope", fields)?;
379        envelope.serialize_field(
380            "content",
381            &[TextContent {
382                kind: "text",
383                text: &self.result.text,
384            }],
385        )?;
386        envelope.serialize_field("isError", &!self.result.response.success)?;
387        if self.include_structured {
388            envelope.serialize_field(
389                "structuredContent",
390                &FlatToolResponse {
391                    response: &self.result.response,
392                    text: &self.result.text,
393                },
394            )?;
395        }
396        envelope.end()
397    }
398}
399
400#[derive(Serialize)]
401struct TextContent<'a> {
402    #[serde(rename = "type")]
403    kind: &'static str,
404    text: &'a str,
405}
406
407pub(super) fn build_tool_response_frame(
408    ver: u8,
409    route: RouteChannel,
410    corr: u64,
411    flags: Flags,
412    result: &ToolCallResult,
413    trust: BindTrust,
414) -> Result<Frame, SubcError> {
415    // `content`/`isError` is the MCP-native surface a GENERIC host reads. The
416    // FIRST-PARTY AFT plugin instead reads `structuredContent`, which carries
417    // the full flat standalone shape ({id, success, ...data, text}) so every
418    // structured sidecar the plugin drives UI from — status_bar, bg_completions
419    // (in-band drain), preview_diff, code, message, attachments — survives the
420    // route. subc relays the body byte-for-byte, so this reaches the plugin
421    // unchanged. SubcTransport.toolCall re-lifts `structuredContent` straight to
422    // the flat ToolCallResult, so nothing downstream of the transport differs
423    // from the NDJSON path.
424    //
425    // UNTRUSTED binds (MCP hosts via subc-mcp) get text-only replies: they
426    // have no re-lift layer, we declare no outputSchema (so omitting is
427    // MCP-spec-clean), and hosts like Claude Code prefer `structuredContent`
428    // for model input when present — feeding the model a raw JSON dump with
429    // the rendered text buried inside it, at a multiple of the token cost.
430    let include_structured = !matches!(trust, BindTrust::Untrusted);
431    let body = serde_json::to_vec(&ToolResponseEnvelope {
432        result,
433        include_structured,
434    })
435    .map_err(SubcError::Json)?;
436
437    Frame::build_with_version(
438        ver,
439        FrameType::Response,
440        flags,
441        route.channel,
442        route.epoch,
443        corr,
444        body,
445    )
446    .map_err(SubcError::FrameBuild)
447}
448
449pub(super) fn build_error_frame(
450    ver: u8,
451    channel: u16,
452    epoch: u32,
453    corr: u64,
454    flags: Flags,
455    code: &str,
456    message: &str,
457) -> Result<Frame, SubcError> {
458    let body = serde_json::to_vec(&ErrorBody {
459        code: code.to_string(),
460        message: message.to_string(),
461    })
462    .map_err(SubcError::Json)?;
463    Frame::build_with_version(ver, FrameType::Error, flags, channel, epoch, corr, body)
464        .map_err(SubcError::FrameBuild)
465}
466
467pub(super) fn build_goodbye_frame(
468    ver: u8,
469    channel: u16,
470    epoch: u32,
471    corr: u64,
472) -> Result<Frame, SubcError> {
473    Frame::build_with_version(
474        ver,
475        FrameType::Goodbye,
476        control_flags(),
477        channel,
478        epoch,
479        corr,
480        Vec::new(),
481    )
482    .map_err(SubcError::FrameBuild)
483}
484
485pub(super) fn response_message(response: &Response, fallback: &str) -> String {
486    response
487        .data
488        .get("message")
489        .and_then(Value::as_str)
490        .map(ToOwned::to_owned)
491        .unwrap_or_else(|| fallback.to_string())
492}
493
494pub(super) fn response_is_fatal_panic(response: &Response) -> bool {
495    !response.success && response.data.get("code").and_then(Value::as_str) == Some("actor_fatal")
496}
497
498#[derive(Debug)]
499pub enum SubcError {
500    Runtime(std::io::Error),
501    ConnectionFile {
502        path: PathBuf,
503        source: subc_transport::ConnectionFileError,
504    },
505    NoEndpoint {
506        path: PathBuf,
507    },
508    InvalidEndpoint {
509        path: PathBuf,
510        endpoint: String,
511    },
512    Connect {
513        endpoint: String,
514        source: std::io::Error,
515    },
516    Auth {
517        endpoint: String,
518        source: subc_transport::AuthError,
519    },
520    FrameIo(subc_transport::FrameIoError),
521    FrameBuild(subc_protocol::FrameBuildError),
522    WriterClosed,
523    WriterBackpressureTimeout,
524    WriterJoin(tokio::task::JoinError),
525    Json(serde_json::Error),
526    ClosedBeforeHelloAck,
527    HelloRejected {
528        body: Option<ErrorBody>,
529    },
530    UnexpectedFrame {
531        ty: FrameType,
532    },
533}
534
535impl fmt::Display for SubcError {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        match self {
538            Self::Runtime(e) => write!(f, "failed to build subc tokio runtime: {e}"),
539            Self::ConnectionFile { path, source } => {
540                write!(f, "failed to read subc connection file {path:?}: {source}")
541            }
542            Self::NoEndpoint { path } => {
543                write!(f, "subc connection file {path:?} has no endpoints")
544            }
545            Self::InvalidEndpoint { path, endpoint } => {
546                write!(
547                    f,
548                    "subc connection file {path:?} has invalid endpoint {endpoint}"
549                )
550            }
551            Self::Connect { endpoint, source } => {
552                write!(f, "failed to connect to subc endpoint {endpoint}: {source}")
553            }
554            Self::Auth { endpoint, source } => {
555                write!(
556                    f,
557                    "failed to authenticate to subc endpoint {endpoint}: {source}"
558                )
559            }
560            Self::FrameIo(e) => write!(f, "subc frame I/O error: {e}"),
561            Self::FrameBuild(e) => write!(f, "subc frame build error: {e}"),
562            Self::WriterClosed => write!(f, "subc writer task closed"),
563            Self::WriterBackpressureTimeout => write!(
564                f,
565                "subc writer task stayed backpressured while sending a control frame"
566            ),
567            Self::WriterJoin(e) => write!(f, "subc writer task join error: {e}"),
568            Self::Json(e) => write!(f, "subc JSON error: {e}"),
569            Self::ClosedBeforeHelloAck => {
570                write!(f, "subc daemon closed the connection before HelloAck")
571            }
572            Self::HelloRejected { body } => match body {
573                Some(b) => write!(f, "subc rejected ModuleHello: {} ({})", b.code, b.message),
574                None => write!(f, "subc rejected ModuleHello (unparseable error body)"),
575            },
576            Self::UnexpectedFrame { ty } => {
577                write!(f, "subc sent unexpected frame in place of HelloAck: {ty:?}")
578            }
579        }
580    }
581}
582
583impl std::error::Error for SubcError {}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::subc::route_key;
589    use serde_json::json;
590    use std::sync::Arc;
591    use std::time::{Duration, Instant};
592    use subc_protocol::PROTOCOL_VERSION;
593
594    #[test]
595    fn writer_depth_counter_tracks_enqueued_frames_until_drain() {
596        let metrics = DispatchPathMetrics::new();
597        let (writer_tx, mut writer_rx) = mpsc::channel::<WriterFrame>(8);
598
599        for corr in 1..=3 {
600            let frame = Frame::build(FrameType::Ping, control_flags(), 0, 0, corr, Vec::new())
601                .expect("test frame");
602            assert!(try_enqueue_writer_frame(&writer_tx, &metrics, frame).is_enqueued());
603        }
604        assert_eq!(metrics.writer_queued.load(Ordering::Relaxed), 3);
605
606        for _ in 0..3 {
607            writer_rx.try_recv().expect("queued writer frame");
608            decrement_counted_channel(&metrics.writer_queued);
609        }
610        assert_eq!(metrics.writer_queued.load(Ordering::Relaxed), 0);
611    }
612
613    #[tokio::test]
614    async fn reliable_writer_send_retries_after_timeout_and_preserves_frame() {
615        let metrics = Arc::new(DispatchPathMetrics::new());
616        let (writer_tx, mut writer_rx) = mpsc::channel::<WriterFrame>(1);
617        writer_tx
618            .try_send(WriterFrame::plain(
619                Frame::build(FrameType::Ping, control_flags(), 0, 0, 1, Vec::new()).unwrap(),
620            ))
621            .expect("prefill writer queue");
622
623        let metrics_for_task = Arc::clone(&metrics);
624        let tx_for_task = writer_tx.clone();
625        let send_task = tokio::spawn(async move {
626            send_reliable_writer_frame(
627                &tx_for_task,
628                &metrics_for_task,
629                Frame::build(FrameType::Pong, control_flags(), 0, 0, 2, Vec::new()).unwrap(),
630                "test reliable frame",
631            )
632            .await
633        });
634
635        tokio::time::timeout(Duration::from_secs(2), async {
636            while metrics.writer_saturation_count.load(Ordering::Relaxed) < 2 {
637                tokio::time::sleep(Duration::from_millis(10)).await;
638            }
639        })
640        .await
641        .expect("reliable send should observe a timed-out full writer queue");
642
643        let prefilled = writer_rx.recv().await.expect("prefilled frame");
644        assert_eq!(prefilled.header.corr, 1);
645        let result = tokio::time::timeout(Duration::from_secs(2), send_task)
646            .await
647            .expect("reliable send should finish after writer drains")
648            .expect("reliable send task should not panic");
649        assert!(result.is_ok());
650        let delivered = writer_rx.recv().await.expect("retried reliable frame");
651        assert_eq!(delivered.header.corr, 2);
652    }
653
654    #[test]
655    fn response_is_fatal_panic_only_matches_panic_exclusive_code() {
656        let tool_error = Response::error("request-1", "internal_error", "ordinary tool error");
657        let panic_error = Response::error("request-2", "actor_fatal", "mutating panic");
658
659        assert!(!response_is_fatal_panic(&tool_error));
660        assert!(response_is_fatal_panic(&panic_error));
661    }
662
663    #[tokio::test]
664    async fn control_send_times_out_when_writer_queue_remains_full() {
665        let (writer_tx, _writer_rx) = mpsc::channel::<WriterFrame>(1);
666        let metrics = DispatchPathMetrics::new();
667        writer_tx
668            .try_send(WriterFrame::plain(
669                Frame::build(FrameType::Ping, control_flags(), 0, 0, 1, Vec::new()).unwrap(),
670            ))
671            .expect("prefill writer queue");
672        let started = Instant::now();
673
674        let result = send_frame(
675            &writer_tx,
676            &metrics,
677            Frame::build(FrameType::Pong, control_flags(), 0, 0, 2, Vec::new()).unwrap(),
678        )
679        .await;
680
681        assert!(matches!(result, Err(SubcError::WriterBackpressureTimeout)));
682        assert!(
683            started.elapsed() < Duration::from_secs(2),
684            "control send guard should be bounded"
685        );
686    }
687
688    #[test]
689    fn tool_response_frame_carries_flat_standalone_shape_in_structured_content() {
690        use crate::protocol::Response;
691
692        // A response with sidecars the FIRST-PARTY plugin drives UI from
693        // (status_bar, bg_completions, code) plus a normal result field.
694        let response = Response::success(
695            "req-7",
696            json!({
697                "complete": true,
698                "matches": 3,
699                "status_bar": { "errors": 0, "warnings": 1 },
700                "bg_completions": [{ "task_id": "bash-abc" }],
701            }),
702        );
703        let result = ToolCallResult {
704            text: "rendered text".to_string(),
705            response,
706        };
707
708        // The flat shape must equal the standalone NDJSON `tool_call` body:
709        // {id, success, ...data, text}. Build the standalone expectation the
710        // same way commands::tool_call::response_with_text does.
711        let expected_flat = json!({
712            "id": "req-7",
713            "success": true,
714            "complete": true,
715            "matches": 3,
716            "status_bar": { "errors": 0, "warnings": 1 },
717            "bg_completions": [{ "task_id": "bash-abc" }],
718            "text": "rendered text",
719        });
720        assert_eq!(
721            serde_json::to_value(FlatToolResponse {
722                response: &result.response,
723                text: &result.text,
724            })
725            .unwrap(),
726            expected_flat,
727            "structuredContent must be byte-identical to the standalone flat response"
728        );
729
730        // The frame body carries the MCP surface for generic hosts AND the flat
731        // sidecar shape under structuredContent for the first-party plugin.
732        let frame = build_tool_response_frame(
733            PROTOCOL_VERSION,
734            route_key(1, 1),
735            42,
736            control_flags(),
737            &result,
738            BindTrust::FirstParty,
739        )
740        .unwrap();
741        let expected_body = serde_json::to_vec(&json!({
742            "content": [{ "type": "text", "text": "rendered text" }],
743            "isError": false,
744            "structuredContent": expected_flat.clone(),
745        }))
746        .unwrap();
747        assert_eq!(
748            frame.body, expected_body,
749            "tool response wire bytes drifted"
750        );
751        let body: Value = serde_json::from_slice(&frame.body).unwrap();
752        assert_eq!(body["isError"], json!(false));
753        assert_eq!(body["content"][0]["type"], json!("text"));
754        assert_eq!(body["content"][0]["text"], json!("rendered text"));
755        assert_eq!(body["structuredContent"], expected_flat);
756
757        // A failed response flips isError and still carries the flat shape
758        // (with success:false + code) for the plugin's error path.
759        let err = Response::error_with_data(
760            "req-8",
761            "ambiguous_match",
762            "too many matches",
763            json!({ "candidates": ["a", "b"] }),
764        );
765        let err_result = ToolCallResult {
766            text: "error text".to_string(),
767            response: err,
768        };
769        let err_frame = build_tool_response_frame(
770            PROTOCOL_VERSION,
771            route_key(1, 1),
772            43,
773            control_flags(),
774            &err_result,
775            BindTrust::FirstParty,
776        )
777        .unwrap();
778        let err_body: Value = serde_json::from_slice(&err_frame.body).unwrap();
779        assert_eq!(err_body["isError"], json!(true));
780        assert_eq!(err_body["structuredContent"]["success"], json!(false));
781        assert_eq!(
782            err_body["structuredContent"]["code"],
783            json!("ambiguous_match")
784        );
785        assert_eq!(
786            err_body["structuredContent"]["candidates"],
787            json!(["a", "b"])
788        );
789        assert_eq!(err_body["structuredContent"]["text"], json!("error text"));
790
791        // UNTRUSTED (MCP) binds get text-only replies: no structuredContent
792        // key at all. Generic MCP hosts have no re-lift layer, and hosts like
793        // Claude Code feed structuredContent to the model verbatim when
794        // present, a raw JSON dump at a multiple of the token cost.
795        let untrusted_frame = build_tool_response_frame(
796            PROTOCOL_VERSION,
797            route_key(1, 1),
798            44,
799            control_flags(),
800            &result,
801            BindTrust::Untrusted,
802        )
803        .unwrap();
804        let untrusted_body: Value = serde_json::from_slice(&untrusted_frame.body).unwrap();
805        assert_eq!(untrusted_body["content"][0]["text"], json!("rendered text"));
806        assert_eq!(untrusted_body["isError"], json!(false));
807        assert!(
808            untrusted_body.get("structuredContent").is_none(),
809            "untrusted binds must not receive structuredContent: {untrusted_body}"
810        );
811    }
812}