Skip to main content

aft/subc/
wire.rs

1//! Frame encoding and writer-queue helpers used by the subc transport edge.
2
3#[cfg(test)]
4use serde::ser::{SerializeMap, SerializeStruct};
5#[cfg(test)]
6use serde::{Serialize, Serializer};
7
8use super::{
9    control_flags, fmt, mpsc, Arc, AtomicUsize, BindTrust, DispatchPathMetrics, ErrorBody, Flags,
10    Frame, FrameType, Ordering, PathBuf, Response, RouteChannel, ToolCallResult, Value,
11    CONTROL_SEND_TIMEOUT, RELIABLE_WRITER_RETRY_INITIAL_BACKOFF, RELIABLE_WRITER_RETRY_MAX_BACKOFF,
12};
13use crate::run_tool_call::{PhaseTrace, ToolCallEgressTiming, ToolCallPhaseDurations};
14use std::borrow::Cow;
15use subc_protocol::{FrameBuildError, MAX_FRAME_BODY_LEN};
16
17pub(super) type WriterSender = mpsc::Sender<WriterFrame>;
18
19pub(super) struct ToolResponseWriteTrace {
20    phase_trace: PhaseTrace,
21    name: String,
22    root: PathBuf,
23    session: String,
24    channel: u16,
25    corr: u64,
26    enqueued_at: Option<std::time::Instant>,
27    queue_depth: usize,
28    writer_active_at_enqueue: bool,
29    writer_queue_was_full: bool,
30    reserve_timeouts: u32,
31}
32
33impl ToolResponseWriteTrace {
34    pub(super) fn new(
35        phase_trace: PhaseTrace,
36        name: String,
37        root: PathBuf,
38        session: String,
39        channel: u16,
40        corr: u64,
41    ) -> Self {
42        Self {
43            phase_trace,
44            name,
45            root,
46            session,
47            channel,
48            corr,
49            enqueued_at: None,
50            queue_depth: 0,
51            writer_active_at_enqueue: false,
52            writer_queue_was_full: false,
53            reserve_timeouts: 0,
54        }
55    }
56
57    fn mark_writer_queue_full(&mut self) {
58        self.writer_queue_was_full = true;
59    }
60
61    fn mark_reserve_timeout(&mut self) {
62        self.reserve_timeouts = self.reserve_timeouts.saturating_add(1);
63    }
64
65    fn mark_enqueued(&mut self, queue_depth: usize, writer_active: bool) {
66        self.enqueued_at = Some(std::time::Instant::now());
67        self.queue_depth = queue_depth;
68        self.writer_active_at_enqueue = writer_active;
69    }
70
71    pub(super) fn finish(
72        self,
73        dequeued: std::time::Instant,
74        write_started: std::time::Instant,
75        write_finished: std::time::Instant,
76        frame_bytes: usize,
77    ) -> Option<CompletedToolResponseTrace> {
78        let phases = self.phase_trace.finish(ToolCallEgressTiming {
79            enqueued: self.enqueued_at?,
80            dequeued,
81            write_started,
82            write_finished,
83            frame_bytes,
84            queue_depth: self.queue_depth,
85            writer_active_at_enqueue: self.writer_active_at_enqueue,
86            writer_queue_was_full: self.writer_queue_was_full,
87            reserve_timeouts: self.reserve_timeouts,
88        })?;
89        Some(CompletedToolResponseTrace {
90            name: self.name,
91            root: self.root,
92            session: self.session,
93            channel: self.channel,
94            corr: self.corr,
95            phases,
96        })
97    }
98}
99
100pub(super) struct CompletedToolResponseTrace {
101    pub(super) name: String,
102    pub(super) root: PathBuf,
103    pub(super) session: String,
104    pub(super) channel: u16,
105    pub(super) corr: u64,
106    pub(super) phases: ToolCallPhaseDurations,
107}
108
109enum WriterFrameBody {
110    Owned,
111    SharedPush(Arc<Vec<u8>>),
112}
113
114pub(super) struct WriterFrame {
115    /// The route-specific header. Shared Push bodies remain outside this frame.
116    pub(super) frame: Frame,
117    body: WriterFrameBody,
118    pub(super) tool_response_trace: Option<ToolResponseWriteTrace>,
119}
120
121impl std::ops::Deref for WriterFrame {
122    type Target = Frame;
123
124    fn deref(&self) -> &Self::Target {
125        &self.frame
126    }
127}
128
129impl WriterFrame {
130    pub(super) fn plain(frame: Frame) -> Self {
131        Self {
132            frame,
133            body: WriterFrameBody::Owned,
134            tool_response_trace: None,
135        }
136    }
137
138    pub(super) fn shared_push(frame: Frame, body: Arc<Vec<u8>>) -> Self {
139        debug_assert_eq!(frame.header.len as usize, body.len());
140        Self {
141            frame,
142            body: WriterFrameBody::SharedPush(body),
143            tool_response_trace: None,
144        }
145    }
146
147    fn traced_tool_response(frame: Frame, trace: ToolResponseWriteTrace) -> Self {
148        Self {
149            frame,
150            body: WriterFrameBody::Owned,
151            tool_response_trace: Some(trace),
152        }
153    }
154
155    pub(super) fn frame(&self) -> &Frame {
156        &self.frame
157    }
158
159    pub(super) fn body(&self) -> &[u8] {
160        match &self.body {
161            WriterFrameBody::Owned => &self.frame.body,
162            WriterFrameBody::SharedPush(body) => body,
163        }
164    }
165
166    #[cfg(test)]
167    pub(super) fn shared_push_body_strong_count(&self) -> Option<usize> {
168        match &self.body {
169            WriterFrameBody::Owned => None,
170            WriterFrameBody::SharedPush(body) => Some(Arc::strong_count(body)),
171        }
172    }
173
174    fn mark_writer_queue_full(&mut self) {
175        if let Some(trace) = self.tool_response_trace.as_mut() {
176            trace.mark_writer_queue_full();
177        }
178    }
179
180    fn mark_reserve_timeout(&mut self) {
181        if let Some(trace) = self.tool_response_trace.as_mut() {
182            trace.mark_reserve_timeout();
183        }
184    }
185
186    fn mark_enqueued(&mut self, queue_depth: usize, writer_active: bool) {
187        if let Some(trace) = self.tool_response_trace.as_mut() {
188            trace.mark_enqueued(queue_depth, writer_active);
189        }
190    }
191}
192
193pub(super) enum WriterEnqueueOutcome {
194    Enqueued,
195    Full(WriterFrame),
196    Closed,
197}
198
199impl WriterEnqueueOutcome {
200    #[cfg(test)]
201    pub(super) fn is_enqueued(&self) -> bool {
202        matches!(self, Self::Enqueued)
203    }
204}
205
206pub(super) fn decrement_counted_channel(counter: &AtomicUsize) {
207    let previous = counter.fetch_sub(1, Ordering::Relaxed);
208    debug_assert!(previous > 0, "counted channel depth underflow");
209}
210
211pub(super) async fn send_counted_channel<T>(
212    tx: &mpsc::Sender<T>,
213    counter: &AtomicUsize,
214    item: T,
215) -> Result<(), mpsc::error::SendError<T>> {
216    counter.fetch_add(1, Ordering::Relaxed);
217    match tx.send(item).await {
218        Ok(()) => Ok(()),
219        Err(error) => {
220            decrement_counted_channel(counter);
221            Err(error)
222        }
223    }
224}
225
226fn enqueue_writer_item(
227    permit: mpsc::Permit<'_, WriterFrame>,
228    metrics: &DispatchPathMetrics,
229    mut item: WriterFrame,
230) {
231    let queue_depth = metrics.writer_queued.fetch_add(1, Ordering::Relaxed) + 1;
232    item.mark_enqueued(queue_depth, metrics.writer_active.load(Ordering::Relaxed));
233    permit.send(item);
234}
235
236fn try_enqueue_writer_item(
237    tx: &WriterSender,
238    metrics: &DispatchPathMetrics,
239    mut item: WriterFrame,
240) -> WriterEnqueueOutcome {
241    match tx.try_reserve() {
242        Ok(permit) => {
243            enqueue_writer_item(permit, metrics, item);
244            WriterEnqueueOutcome::Enqueued
245        }
246        Err(mpsc::error::TrySendError::Full(())) => {
247            metrics
248                .writer_saturation_count
249                .fetch_add(1, Ordering::Relaxed);
250            item.mark_writer_queue_full();
251            WriterEnqueueOutcome::Full(item)
252        }
253        Err(mpsc::error::TrySendError::Closed(())) => {
254            drop(item);
255            WriterEnqueueOutcome::Closed
256        }
257    }
258}
259
260pub(super) fn try_enqueue_writer_frame(
261    tx: &WriterSender,
262    metrics: &DispatchPathMetrics,
263    frame: Frame,
264) -> WriterEnqueueOutcome {
265    try_enqueue_writer_item(tx, metrics, WriterFrame::plain(frame))
266}
267
268pub(super) fn try_enqueue_shared_push_frame(
269    tx: &WriterSender,
270    metrics: &DispatchPathMetrics,
271    frame: Frame,
272    body: Arc<Vec<u8>>,
273) -> WriterEnqueueOutcome {
274    try_enqueue_writer_item(tx, metrics, WriterFrame::shared_push(frame, body))
275}
276
277async fn send_reliable_writer_item(
278    tx: &WriterSender,
279    metrics: &DispatchPathMetrics,
280    mut item: WriterFrame,
281    context: &'static str,
282) -> Result<(), SubcError> {
283    let mut warned = false;
284    let mut backoff = RELIABLE_WRITER_RETRY_INITIAL_BACKOFF;
285
286    loop {
287        match try_enqueue_writer_item(tx, metrics, item) {
288            WriterEnqueueOutcome::Enqueued => return Ok(()),
289            WriterEnqueueOutcome::Closed => return Err(SubcError::WriterClosed),
290            WriterEnqueueOutcome::Full(returned_item) => {
291                item = returned_item;
292            }
293        }
294
295        match tokio::time::timeout(CONTROL_SEND_TIMEOUT, tx.reserve()).await {
296            Ok(Ok(permit)) => {
297                enqueue_writer_item(permit, metrics, item);
298                return Ok(());
299            }
300            Ok(Err(_)) => return Err(SubcError::WriterClosed),
301            Err(_) => {
302                metrics
303                    .writer_saturation_count
304                    .fetch_add(1, Ordering::Relaxed);
305                item.mark_reserve_timeout();
306                if !warned {
307                    log::warn!(
308                        "subc attach: writer queue stayed full while sending {context}; retrying reliable frame"
309                    );
310                    warned = true;
311                }
312                tokio::time::sleep(backoff).await;
313                backoff =
314                    std::cmp::min(backoff.saturating_mul(2), RELIABLE_WRITER_RETRY_MAX_BACKOFF);
315            }
316        }
317    }
318}
319
320pub(super) async fn send_reliable_writer_frame(
321    tx: &WriterSender,
322    metrics: &DispatchPathMetrics,
323    frame: Frame,
324    context: &'static str,
325) -> Result<(), SubcError> {
326    send_reliable_writer_item(tx, metrics, WriterFrame::plain(frame), context).await
327}
328
329pub(super) async fn send_traced_tool_response_frame(
330    tx: &WriterSender,
331    metrics: &DispatchPathMetrics,
332    frame: Frame,
333    trace: ToolResponseWriteTrace,
334) -> Result<(), SubcError> {
335    send_reliable_writer_item(
336        tx,
337        metrics,
338        WriterFrame::traced_tool_response(frame, trace),
339        "tool response",
340    )
341    .await
342}
343
344pub(super) async fn send_frame(
345    tx: &WriterSender,
346    metrics: &DispatchPathMetrics,
347    frame: Frame,
348) -> Result<(), SubcError> {
349    match try_enqueue_writer_item(tx, metrics, WriterFrame::plain(frame)) {
350        WriterEnqueueOutcome::Enqueued => Ok(()),
351        WriterEnqueueOutcome::Closed => Err(SubcError::WriterClosed),
352        WriterEnqueueOutcome::Full(item) => {
353            match tokio::time::timeout(CONTROL_SEND_TIMEOUT, tx.reserve()).await {
354                Ok(Ok(permit)) => {
355                    enqueue_writer_item(permit, metrics, item);
356                    Ok(())
357                }
358                Ok(Err(_)) => Err(SubcError::WriterClosed),
359                Err(_) => {
360                    metrics
361                        .writer_saturation_count
362                        .fetch_add(1, Ordering::Relaxed);
363                    Err(SubcError::WriterBackpressureTimeout)
364                }
365            }
366        }
367    }
368}
369
370/// Borrowed flat response matching the standalone NDJSON shape without cloning
371/// the response id or any structured data values.
372#[cfg(test)]
373struct FlatToolResponse<'a> {
374    response: &'a crate::protocol::Response,
375    text: &'a str,
376}
377
378#[cfg(test)]
379impl Serialize for FlatToolResponse<'_> {
380    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
381    where
382        S: Serializer,
383    {
384        let data = self.response.data.as_object();
385        let has_text = data.is_some_and(|data| data.contains_key("text"));
386        let field_count =
387            2 + data.map_or(0, |data| {
388                data.len()
389                    - usize::from(data.contains_key("id"))
390                    - usize::from(data.contains_key("success"))
391            }) + usize::from(!has_text);
392        let mut map = serializer.serialize_map(Some(field_count))?;
393        match data.and_then(|data| data.get("id")) {
394            Some(value) => map.serialize_entry("id", value)?,
395            None => map.serialize_entry("id", &self.response.id)?,
396        }
397        match data.and_then(|data| data.get("success")) {
398            Some(value) => map.serialize_entry("success", value)?,
399            None => map.serialize_entry("success", &self.response.success)?,
400        }
401        if let Some(data) = data {
402            for (key, value) in data {
403                match key.as_str() {
404                    "id" | "success" => {}
405                    "text" => map.serialize_entry(key, self.text)?,
406                    _ => map.serialize_entry(key, value)?,
407                }
408            }
409        }
410        if !has_text {
411            map.serialize_entry("text", self.text)?;
412        }
413        map.end()
414    }
415}
416
417#[cfg(test)]
418struct ToolResponseEnvelope<'a> {
419    result: &'a ToolCallResult,
420    /// First-party binds get the full flat response in `structuredContent`
421    /// for the plugin re-lift; untrusted (MCP) binds get text-only.
422    include_structured: bool,
423}
424
425// A trusted envelope carries the rendered text twice — once as the outer MCP
426// `content`, once inside `structuredContent` — and for reads the raw `content`
427// data field rides along a third time, so a read body crosses the connection
428// roughly 3x. This is deliberate, not an oversight: the bridge re-lifts
429// `structuredContent` to reconstruct the flat response. The bridge's
430// `reliftReply` now tolerates omission of `structuredContent.text`, but AFT
431// still emits it until every supported plugin version includes that fallback.
432// Only then can the duplicate be dropped behind the plugin version floor.
433//
434// Measured on a live daemon: the largest real frames were ~200 KB, with zero
435// egress-write time, writer queue depth 1, never full, and no reserve
436// timeouts — the amplification costs nothing observable. Revisit if
437// If `egress_write` becomes nonzero, the writer queue backs up, or typical
438// frames grow well past a few hundred KB, the duplicate content is costly
439// enough to justify dropping `structuredContent.text` after the plugin floor
440// makes that omission compatible.
441
442#[cfg(test)]
443impl Serialize for ToolResponseEnvelope<'_> {
444    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
445    where
446        S: Serializer,
447    {
448        let fields = if self.include_structured { 3 } else { 2 };
449        let mut envelope = serializer.serialize_struct("ToolResponseEnvelope", fields)?;
450        envelope.serialize_field(
451            "content",
452            &[TextContent {
453                kind: "text",
454                text: &self.result.text,
455            }],
456        )?;
457        envelope.serialize_field("isError", &!self.result.response.success)?;
458        if self.include_structured {
459            envelope.serialize_field(
460                "structuredContent",
461                &FlatToolResponse {
462                    response: &self.result.response,
463                    text: &self.result.text,
464                },
465            )?;
466        }
467        envelope.end()
468    }
469}
470
471#[cfg(test)]
472#[derive(Serialize)]
473struct TextContent<'a> {
474    #[serde(rename = "type")]
475    kind: &'static str,
476    text: &'a str,
477}
478
479const TRANSPORT_MIB: usize = 1024 * 1024;
480const TOOL_RESPONSE_ENVELOPE_MARGIN_DIVISOR: usize = 8;
481const RESPONSE_TOO_LARGE_CODE: &str = "response_too_large";
482const TRANSPORT_TRUNCATION_REASON: &str = "transport_frame_limit";
483
484fn transport_limit_mib(bytes: usize) -> usize {
485    bytes.div_ceil(TRANSPORT_MIB).max(1)
486}
487
488fn tool_response_text_limit(max_body_len: usize, include_structured: bool) -> usize {
489    // Trusted binds carry rendered text in both MCP content and structuredContent.
490    // Keep one eighth free for the envelope and sidecars; anything larger there
491    // is handled by the correlated fallback instead of silently dropping the call.
492    let margin = (max_body_len / TOOL_RESPONSE_ENVELOPE_MARGIN_DIVISOR)
493        .max(1_024)
494        .min(max_body_len / 2);
495    let rendered_copies = if include_structured { 2 } else { 1 };
496    max_body_len.saturating_sub(margin) / rendered_copies
497}
498
499fn truncate_rendered_text(text: &str, limit: usize) -> Cow<'_, str> {
500    if text.len() <= limit {
501        return Cow::Borrowed(text);
502    }
503
504    let notice = format!(
505        "[response truncated at {} MiB: full output exceeds the transport frame limit; use offset/limit or write to a file]",
506        transport_limit_mib(limit)
507    );
508    let suffix = format!("\n{notice}");
509    let mut prefix_end = text.len().min(limit.saturating_sub(suffix.len()));
510    while !text.is_char_boundary(prefix_end) {
511        prefix_end -= 1;
512    }
513
514    let mut truncated = String::with_capacity(prefix_end.saturating_add(suffix.len()));
515    truncated.push_str(&text[..prefix_end]);
516    truncated.push_str(&suffix);
517    Cow::Owned(truncated)
518}
519
520fn serialize_tool_response_body(
521    result: &ToolCallResult,
522    include_structured: bool,
523) -> Result<Vec<u8>, serde_json::Error> {
524    serialize_tool_response_body_with_text(result, include_structured, &result.text, false)
525}
526
527fn serialize_tool_response_body_with_text(
528    result: &ToolCallResult,
529    include_structured: bool,
530    rendered_text: &str,
531    transport_truncated: bool,
532) -> Result<Vec<u8>, serde_json::Error> {
533    let data_capacity = result
534        .response
535        .data
536        .as_object()
537        .and_then(|data| {
538            ["content", "output", "preview_diff"]
539                .into_iter()
540                .find_map(|key| data.get(key).and_then(Value::as_str))
541                .map(|data_text| {
542                    if transport_truncated && data_text == result.text.as_str() {
543                        rendered_text.len()
544                    } else {
545                        data_text.len()
546                    }
547                })
548        })
549        .unwrap_or(0);
550    let capacity = rendered_text
551        .len()
552        .saturating_add(include_structured.then_some(data_capacity).unwrap_or(0))
553        .saturating_add(
554            include_structured
555                .then_some(rendered_text.len())
556                .unwrap_or(0),
557        )
558        .saturating_add(512);
559    let mut body = Vec::with_capacity(capacity);
560
561    body.extend_from_slice(b"{\"content\":[{\"type\":\"text\",\"text\":");
562    let encoded_text_start = body.len();
563    serde_json::to_writer(&mut body, rendered_text)?;
564    let encoded_text_end = body.len();
565    body.extend_from_slice(b"}],\"isError\":");
566    body.extend_from_slice(if result.response.success {
567        b"false"
568    } else {
569        b"true"
570    });
571
572    if include_structured {
573        body.extend_from_slice(b",\"structuredContent\":{\"id\":");
574        let data = result.response.data.as_object();
575        match data.and_then(|data| data.get("id")) {
576            Some(value) => serde_json::to_writer(&mut body, value)?,
577            None => serde_json::to_writer(&mut body, &result.response.id)?,
578        }
579        body.extend_from_slice(b",\"success\":");
580        match data.and_then(|data| data.get("success")) {
581            Some(value) => serde_json::to_writer(&mut body, value)?,
582            None => body.extend_from_slice(if result.response.success {
583                b"true"
584            } else {
585                b"false"
586            }),
587        }
588
589        let mut has_text = false;
590        let mut has_complete = false;
591        let mut has_truncated = false;
592        let mut has_truncation_reason = false;
593        if let Some(data) = data {
594            for (key, value) in data {
595                match key.as_str() {
596                    "id" | "success" => continue,
597                    "text" => has_text = true,
598                    "complete" => has_complete = true,
599                    "truncated" => has_truncated = true,
600                    "truncation_reason" => has_truncation_reason = true,
601                    _ => {}
602                }
603                body.push(b',');
604                serde_json::to_writer(&mut body, key)?;
605                body.push(b':');
606                match key.as_str() {
607                    "text" => body.extend_from_within(encoded_text_start..encoded_text_end),
608                    "complete" if transport_truncated => body.extend_from_slice(b"false"),
609                    "truncated" if transport_truncated => body.extend_from_slice(b"true"),
610                    "truncation_reason" if transport_truncated => {
611                        serde_json::to_writer(&mut body, TRANSPORT_TRUNCATION_REASON)?;
612                    }
613                    _ if value.as_str() == Some(result.text.as_str()) => {
614                        body.extend_from_within(encoded_text_start..encoded_text_end);
615                    }
616                    _ => serde_json::to_writer(&mut body, value)?,
617                }
618            }
619        }
620        if !has_text {
621            body.extend_from_slice(b",\"text\":");
622            body.extend_from_within(encoded_text_start..encoded_text_end);
623        }
624        if transport_truncated {
625            if !has_complete {
626                body.extend_from_slice(b",\"complete\":false");
627            }
628            if !has_truncated {
629                body.extend_from_slice(b",\"truncated\":true");
630            }
631            if !has_truncation_reason {
632                body.extend_from_slice(b",\"truncation_reason\":\"transport_frame_limit\"");
633            }
634        }
635        body.push(b'}');
636    }
637    body.push(b'}');
638    Ok(body)
639}
640
641fn response_too_large_frame(
642    ver: u8,
643    route: RouteChannel,
644    corr: u64,
645    flags: Flags,
646    body_len: usize,
647    max_body_len: usize,
648    include_structured: bool,
649) -> Frame {
650    let message = format!(
651        "tool response serialized to {body_len} bytes, exceeding the daemon transport limit of {max_body_len} bytes; re-run with a narrower range, a smaller limit, or offset+limit paging; output over {} MiB cannot cross the daemon transport",
652        transport_limit_mib(max_body_len)
653    );
654    // Never copy the original response id or data into this backstop. Its bounded
655    // fields make the fallback frame independent of the oversized source payload.
656    let response = Response::error_with_data(
657        format!("subc-{}-{corr}", route.channel),
658        RESPONSE_TOO_LARGE_CODE,
659        message.clone(),
660        serde_json::json!({
661            "complete": false,
662            "truncated": true,
663            "truncation_reason": TRANSPORT_TRUNCATION_REASON,
664        }),
665    );
666    let result = ToolCallResult {
667        text: message,
668        response,
669    };
670    let body = serialize_tool_response_body(&result, include_structured)
671        .expect("fixed response_too_large envelope must serialize");
672    debug_assert!(
673        body.len() <= max_body_len,
674        "fixed response_too_large envelope must fit the effective body limit"
675    );
676    Frame::build_with_version(
677        ver,
678        FrameType::Response,
679        flags,
680        route.channel,
681        route.epoch,
682        corr,
683        body,
684    )
685    .expect("fixed response_too_large envelope must fit the protocol frame limit")
686}
687
688pub(super) fn build_tool_response_frame(
689    ver: u8,
690    route: RouteChannel,
691    corr: u64,
692    flags: Flags,
693    result: &ToolCallResult,
694    trust: BindTrust,
695) -> Result<Frame, SubcError> {
696    build_tool_response_frame_with_limit(
697        ver,
698        route,
699        corr,
700        flags,
701        result,
702        trust,
703        MAX_FRAME_BODY_LEN as usize,
704    )
705}
706
707pub(super) fn build_tool_response_frame_with_limit(
708    ver: u8,
709    route: RouteChannel,
710    corr: u64,
711    flags: Flags,
712    result: &ToolCallResult,
713    trust: BindTrust,
714    max_body_len: usize,
715) -> Result<Frame, SubcError> {
716    // `content`/`isError` is the MCP-native surface a GENERIC host reads. The
717    // FIRST-PARTY AFT plugin instead reads `structuredContent`, which carries
718    // the full flat standalone shape ({id, success, ...data, text}) so every
719    // structured sidecar the plugin drives UI from — status_bar, bg_completions
720    // (in-band drain), preview_diff, code, message, attachments — survives the
721    // route. subc relays the body byte-for-byte, so this reaches the plugin
722    // unchanged. SubcTransport.toolCall re-lifts `structuredContent` straight to
723    // the flat ToolCallResult, so nothing downstream of the transport differs
724    // from the NDJSON path.
725    //
726    // UNTRUSTED binds (MCP hosts via subc-mcp) get text-only replies: they
727    // have no re-lift layer, we declare no outputSchema (so omitting is
728    // MCP-spec-clean), and hosts like Claude Code prefer `structuredContent`
729    // for model input when present — feeding the model a raw JSON dump with
730    // the rendered text buried inside it, at a multiple of the token cost.
731    let include_structured = !matches!(trust, BindTrust::Untrusted);
732    let effective_max_body_len = max_body_len.min(MAX_FRAME_BODY_LEN as usize);
733    let text_limit = tool_response_text_limit(effective_max_body_len, include_structured);
734    let rendered_text = truncate_rendered_text(&result.text, text_limit);
735    let transport_truncated = matches!(rendered_text, Cow::Owned(_));
736    let body = serialize_tool_response_body_with_text(
737        result,
738        include_structured,
739        &rendered_text,
740        transport_truncated,
741    )
742    .map_err(SubcError::Json)?;
743
744    if effective_max_body_len < MAX_FRAME_BODY_LEN as usize && body.len() > effective_max_body_len {
745        return Ok(response_too_large_frame(
746            ver,
747            route,
748            corr,
749            flags,
750            body.len(),
751            effective_max_body_len,
752            include_structured,
753        ));
754    }
755
756    match Frame::build_with_version(
757        ver,
758        FrameType::Response,
759        flags,
760        route.channel,
761        route.epoch,
762        corr,
763        body,
764    ) {
765        Ok(frame) => Ok(frame),
766        Err(FrameBuildError::BodyExceedsMax { body_len, max }) => Ok(response_too_large_frame(
767            ver,
768            route,
769            corr,
770            flags,
771            body_len,
772            max as usize,
773            include_structured,
774        )),
775        Err(FrameBuildError::BodyTooLarge { body_len }) => Ok(response_too_large_frame(
776            ver,
777            route,
778            corr,
779            flags,
780            body_len,
781            MAX_FRAME_BODY_LEN as usize,
782            include_structured,
783        )),
784    }
785}
786
787pub(super) fn build_error_frame(
788    ver: u8,
789    channel: u16,
790    epoch: u32,
791    corr: u64,
792    flags: Flags,
793    code: &str,
794    message: &str,
795) -> Result<Frame, SubcError> {
796    let body = serde_json::to_vec(&ErrorBody {
797        code: code.to_string(),
798        message: message.to_string(),
799    })
800    .map_err(SubcError::Json)?;
801    Frame::build_with_version(ver, FrameType::Error, flags, channel, epoch, corr, body)
802        .map_err(SubcError::FrameBuild)
803}
804
805pub(super) fn build_goodbye_frame(
806    ver: u8,
807    channel: u16,
808    epoch: u32,
809    corr: u64,
810) -> Result<Frame, SubcError> {
811    Frame::build_with_version(
812        ver,
813        FrameType::Goodbye,
814        control_flags(),
815        channel,
816        epoch,
817        corr,
818        Vec::new(),
819    )
820    .map_err(SubcError::FrameBuild)
821}
822
823pub(super) fn response_message(response: &Response, fallback: &str) -> String {
824    response
825        .data
826        .get("message")
827        .and_then(Value::as_str)
828        .map(ToOwned::to_owned)
829        .unwrap_or_else(|| fallback.to_string())
830}
831
832pub(super) fn response_is_fatal_panic(response: &Response) -> bool {
833    !response.success && response.data.get("code").and_then(Value::as_str) == Some("actor_fatal")
834}
835
836#[derive(Debug)]
837pub enum SubcError {
838    Runtime(std::io::Error),
839    ConnectionFile {
840        path: PathBuf,
841        source: subc_transport::ConnectionFileError,
842    },
843    NoEndpoint {
844        path: PathBuf,
845    },
846    InvalidEndpoint {
847        path: PathBuf,
848        endpoint: String,
849    },
850    Connect {
851        endpoint: String,
852        source: std::io::Error,
853    },
854    Auth {
855        endpoint: String,
856        source: subc_transport::AuthError,
857    },
858    FrameIo(subc_transport::FrameIoError),
859    FrameBuild(subc_protocol::FrameBuildError),
860    WriterClosed,
861    WriterBackpressureTimeout,
862    WriterJoin(tokio::task::JoinError),
863    Json(serde_json::Error),
864    ClosedBeforeHelloAck,
865    /// The daemon connection ended (EOF) after attach without a channel-0
866    /// Goodbye. Not a stop request: the process must exit non-zero so the
867    /// supervisor restarts it, because the supervisor reads exit 0 as "asked
868    /// to stop" and never respawns (fleet-wide outage 2026-09-06, 4.5 h).
869    ConnectionLost,
870    /// A worker panicked on a mutating job and the executor marked its actor
871    /// fatal; the module tore itself down. Like `ConnectionLost` this must
872    /// exit non-zero: the first such teardown (2026-09-11, a search-lane
873    /// panic) exited 0, the supervisor read it as "stopped on request", and
874    /// every seat on the host lost its tools until an operator ran a manual
875    /// start.
876    ActorFatal,
877    HelloRejected {
878        body: Option<ErrorBody>,
879    },
880    UnexpectedFrame {
881        ty: FrameType,
882    },
883}
884
885impl fmt::Display for SubcError {
886    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
887        match self {
888            Self::Runtime(e) => write!(f, "failed to build subc tokio runtime: {e}"),
889            Self::ConnectionFile { path, source } => {
890                write!(f, "failed to read subc connection file {path:?}: {source}")
891            }
892            Self::NoEndpoint { path } => {
893                write!(f, "subc connection file {path:?} has no endpoints")
894            }
895            Self::InvalidEndpoint { path, endpoint } => {
896                write!(
897                    f,
898                    "subc connection file {path:?} has invalid endpoint {endpoint}"
899                )
900            }
901            Self::Connect { endpoint, source } => {
902                write!(f, "failed to connect to subc endpoint {endpoint}: {source}")
903            }
904            Self::Auth { endpoint, source } => {
905                write!(
906                    f,
907                    "failed to authenticate to subc endpoint {endpoint}: {source}"
908                )
909            }
910            Self::FrameIo(e) => write!(f, "subc frame I/O error: {e}"),
911            Self::FrameBuild(e) => write!(f, "subc frame build error: {e}"),
912            Self::WriterClosed => write!(f, "subc writer task closed"),
913            Self::WriterBackpressureTimeout => write!(
914                f,
915                "subc writer task stayed backpressured while sending a control frame"
916            ),
917            Self::WriterJoin(e) => write!(f, "subc writer task join error: {e}"),
918            Self::Json(e) => write!(f, "subc JSON error: {e}"),
919            Self::ClosedBeforeHelloAck => {
920                write!(f, "subc daemon closed the connection before HelloAck")
921            }
922            Self::ConnectionLost => write!(
923                f,
924                "subc daemon connection ended without a channel-0 Goodbye; exiting for supervisor restart"
925            ),
926            Self::ActorFatal => write!(
927                f,
928                "an executor actor panicked and the module tore itself down; exiting for supervisor restart"
929            ),
930            Self::HelloRejected { body } => match body {
931                Some(b) => write!(f, "subc rejected ModuleHello: {} ({})", b.code, b.message),
932                None => write!(f, "subc rejected ModuleHello (unparseable error body)"),
933            },
934            Self::UnexpectedFrame { ty } => {
935                write!(f, "subc sent unexpected frame in place of HelloAck: {ty:?}")
936            }
937        }
938    }
939}
940
941impl std::error::Error for SubcError {}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946    use crate::subc::route_key;
947    use serde_json::json;
948    use std::sync::Arc;
949    use std::time::{Duration, Instant};
950    use subc_protocol::PROTOCOL_VERSION;
951
952    #[test]
953    fn writer_depth_counter_tracks_enqueued_frames_until_drain() {
954        let metrics = DispatchPathMetrics::new();
955        let (writer_tx, mut writer_rx) = mpsc::channel::<WriterFrame>(8);
956
957        for corr in 1..=3 {
958            let frame = Frame::build(FrameType::Ping, control_flags(), 0, 0, corr, Vec::new())
959                .expect("test frame");
960            assert!(try_enqueue_writer_frame(&writer_tx, &metrics, frame).is_enqueued());
961        }
962        assert_eq!(metrics.writer_queued.load(Ordering::Relaxed), 3);
963
964        for _ in 0..3 {
965            writer_rx.try_recv().expect("queued writer frame");
966            decrement_counted_channel(&metrics.writer_queued);
967        }
968        assert_eq!(metrics.writer_queued.load(Ordering::Relaxed), 0);
969    }
970
971    #[tokio::test]
972    async fn reliable_writer_send_retries_after_timeout_and_preserves_frame() {
973        let metrics = Arc::new(DispatchPathMetrics::new());
974        let (writer_tx, mut writer_rx) = mpsc::channel::<WriterFrame>(1);
975        writer_tx
976            .try_send(WriterFrame::plain(
977                Frame::build(FrameType::Ping, control_flags(), 0, 0, 1, Vec::new()).unwrap(),
978            ))
979            .expect("prefill writer queue");
980
981        let metrics_for_task = Arc::clone(&metrics);
982        let tx_for_task = writer_tx.clone();
983        let send_task = tokio::spawn(async move {
984            send_reliable_writer_frame(
985                &tx_for_task,
986                &metrics_for_task,
987                Frame::build(FrameType::Pong, control_flags(), 0, 0, 2, Vec::new()).unwrap(),
988                "test reliable frame",
989            )
990            .await
991        });
992
993        tokio::time::timeout(Duration::from_secs(2), async {
994            while metrics.writer_saturation_count.load(Ordering::Relaxed) < 2 {
995                tokio::time::sleep(Duration::from_millis(10)).await;
996            }
997        })
998        .await
999        .expect("reliable send should observe a timed-out full writer queue");
1000
1001        let prefilled = writer_rx.recv().await.expect("prefilled frame");
1002        assert_eq!(prefilled.header.corr, 1);
1003        let result = tokio::time::timeout(Duration::from_secs(2), send_task)
1004            .await
1005            .expect("reliable send should finish after writer drains")
1006            .expect("reliable send task should not panic");
1007        assert!(result.is_ok());
1008        let delivered = writer_rx.recv().await.expect("retried reliable frame");
1009        assert_eq!(delivered.header.corr, 2);
1010    }
1011
1012    #[test]
1013    fn response_is_fatal_panic_only_matches_panic_exclusive_code() {
1014        let tool_error = Response::error("request-1", "internal_error", "ordinary tool error");
1015        let panic_error = Response::error("request-2", "actor_fatal", "mutating panic");
1016
1017        assert!(!response_is_fatal_panic(&tool_error));
1018        assert!(response_is_fatal_panic(&panic_error));
1019    }
1020
1021    #[tokio::test]
1022    async fn control_send_times_out_when_writer_queue_remains_full() {
1023        let (writer_tx, _writer_rx) = mpsc::channel::<WriterFrame>(1);
1024        let metrics = DispatchPathMetrics::new();
1025        writer_tx
1026            .try_send(WriterFrame::plain(
1027                Frame::build(FrameType::Ping, control_flags(), 0, 0, 1, Vec::new()).unwrap(),
1028            ))
1029            .expect("prefill writer queue");
1030        let started = Instant::now();
1031
1032        let result = send_frame(
1033            &writer_tx,
1034            &metrics,
1035            Frame::build(FrameType::Pong, control_flags(), 0, 0, 2, Vec::new()).unwrap(),
1036        )
1037        .await;
1038
1039        assert!(matches!(result, Err(SubcError::WriterBackpressureTimeout)));
1040        assert!(
1041            started.elapsed() < Duration::from_secs(2),
1042            "control send guard should be bounded"
1043        );
1044    }
1045
1046    fn legacy_tool_response_body(result: &ToolCallResult, include_structured: bool) -> Vec<u8> {
1047        serde_json::to_vec(&ToolResponseEnvelope {
1048            result,
1049            include_structured,
1050        })
1051        .expect("serialize legacy tool response envelope")
1052    }
1053
1054    fn assert_tool_response_frame_matches_legacy(result: &ToolCallResult, trust: BindTrust) {
1055        let include_structured = !matches!(trust, BindTrust::Untrusted);
1056        let legacy = Frame::build_with_version(
1057            PROTOCOL_VERSION,
1058            FrameType::Response,
1059            control_flags(),
1060            7,
1061            3,
1062            42,
1063            legacy_tool_response_body(result, include_structured),
1064        )
1065        .expect("build legacy tool response frame");
1066        let optimized = build_tool_response_frame(
1067            PROTOCOL_VERSION,
1068            route_key(7, 3),
1069            42,
1070            control_flags(),
1071            result,
1072            trust,
1073        )
1074        .expect("build optimized tool response frame");
1075        assert_eq!(optimized.header.encode(), legacy.header.encode());
1076        assert_eq!(optimized.body, legacy.body);
1077    }
1078
1079    fn production_shape_result(tool: &str, payload_bytes: usize) -> ToolCallResult {
1080        let payload = "x".repeat(payload_bytes);
1081        let response = match tool {
1082            "read" => Response::success(
1083                "subc-7-42",
1084                json!({
1085                    "content": payload,
1086                    "path": "/workspace/src/fixture.rs",
1087                    "start_line": 1,
1088                    "end_line": payload_bytes / 40 + 1,
1089                    "total_lines": payload_bytes / 40 + 1,
1090                    "truncated": false,
1091                }),
1092            ),
1093            "edit" => Response::success(
1094                "subc-7-42",
1095                json!({
1096                    "path": "/workspace/src/fixture.rs",
1097                    "edits_applied": 1,
1098                    "diff": { "additions": 12, "deletions": 8 },
1099                    "preview_diff": payload,
1100                }),
1101            ),
1102            "bash" => Response::success(
1103                "subc-7-42",
1104                json!({
1105                    "output": payload,
1106                    "exit_code": 0,
1107                    "timed_out": false,
1108                    "status": "completed",
1109                }),
1110            ),
1111            _ => unreachable!("production-shape probe tool"),
1112        };
1113        let text = crate::subc_format::format_response_with_context(
1114            tool,
1115            &response,
1116            &crate::subc_format::FormatContext::default(),
1117        );
1118        ToolCallResult { text, response }
1119    }
1120
1121    #[test]
1122    fn optimized_tool_response_body_matches_legacy_wire_at_production_shapes() {
1123        for tool in ["read", "edit", "bash"] {
1124            for payload_bytes in [1_024, 10 * 1_024, 50 * 1_024] {
1125                let result = production_shape_result(tool, payload_bytes);
1126                for trust in [BindTrust::Untrusted, BindTrust::FirstParty] {
1127                    assert_tool_response_frame_matches_legacy(&result, trust);
1128                }
1129            }
1130        }
1131
1132        let result = ToolCallResult {
1133            text: r#"replacement text with \"escapes\"
1134"#
1135            .to_string(),
1136            response: Response::success(
1137                "outer-id",
1138                json!({
1139                    "id": "data-id",
1140                    "success": false,
1141                    "text": "ignored source text",
1142                    "nested": [null, true, -7, "replacement text with \"escapes\"\n"],
1143                }),
1144            ),
1145        };
1146        assert_tool_response_frame_matches_legacy(&result, BindTrust::FirstParty);
1147    }
1148
1149    fn median_duration(samples: &mut [Duration]) -> Duration {
1150        samples.sort_unstable();
1151        samples[samples.len() / 2]
1152    }
1153
1154    fn time_envelope_builds(
1155        results: &[ToolCallResult],
1156        weights: &[usize],
1157        legacy: bool,
1158        iterations: usize,
1159    ) -> Duration {
1160        let started = Instant::now();
1161        for _ in 0..iterations {
1162            for (result, &weight) in results.iter().zip(weights) {
1163                for _ in 0..weight {
1164                    let body = if legacy {
1165                        legacy_tool_response_body(std::hint::black_box(result), true)
1166                    } else {
1167                        serialize_tool_response_body(std::hint::black_box(result), true)
1168                            .expect("serialize optimized tool response envelope")
1169                    };
1170                    std::hint::black_box(body);
1171                }
1172            }
1173        }
1174        started.elapsed()
1175    }
1176
1177    #[test]
1178    #[ignore = "manual release-mode serving-plane performance probe"]
1179    fn tool_response_envelope_perf_probe() {
1180        let shapes = [
1181            ("read", 1_024),
1182            ("read", 10 * 1_024),
1183            ("read", 50 * 1_024),
1184            ("edit", 1_024),
1185            ("edit", 10 * 1_024),
1186            ("edit", 50 * 1_024),
1187            ("bash", 1_024),
1188            ("bash", 10 * 1_024),
1189            ("bash", 50 * 1_024),
1190        ];
1191        // A 100-call production mix dominated by read/edit/bash, with periodic
1192        // 50 KiB responses and most calls in the 1-10 KiB range.
1193        let weights = [20, 15, 5, 15, 10, 5, 15, 10, 5];
1194        let results: Vec<_> = shapes
1195            .iter()
1196            .map(|&(tool, payload_bytes)| production_shape_result(tool, payload_bytes))
1197            .collect();
1198        for result in &results {
1199            assert_eq!(
1200                serialize_tool_response_body(result, true).unwrap(),
1201                legacy_tool_response_body(result, true)
1202            );
1203        }
1204
1205        let iterations = 40;
1206        let calls_per_sample = iterations * weights.iter().sum::<usize>();
1207        let mut legacy_samples = Vec::new();
1208        let mut optimized_samples = Vec::new();
1209        for _ in 0..7 {
1210            legacy_samples.push(time_envelope_builds(&results, &weights, true, iterations));
1211            optimized_samples.push(time_envelope_builds(&results, &weights, false, iterations));
1212        }
1213        let legacy = median_duration(&mut legacy_samples);
1214        let optimized = median_duration(&mut optimized_samples);
1215        let legacy_us = legacy.as_secs_f64() * 1_000_000.0 / calls_per_sample as f64;
1216        let optimized_us = optimized.as_secs_f64() * 1_000_000.0 / calls_per_sample as f64;
1217        let saved_percent = (legacy_us - optimized_us) / legacy_us * 100.0;
1218        eprintln!(
1219            "trusted mixed tool-response envelope: before={legacy_us:.3} us/call after={optimized_us:.3} us/call saved={saved_percent:.1}%; 7 run medians, {calls_per_sample} calls/run"
1220        );
1221        assert!(optimized < legacy, "optimized envelope regressed");
1222    }
1223
1224    #[test]
1225    fn tool_response_frame_carries_flat_standalone_shape_in_structured_content() {
1226        use crate::protocol::Response;
1227
1228        // A response with sidecars the FIRST-PARTY plugin drives UI from
1229        // (status_bar, bg_completions, code) plus a normal result field.
1230        let response = Response::success(
1231            "req-7",
1232            json!({
1233                "complete": true,
1234                "matches": 3,
1235                "status_bar": { "errors": 0, "warnings": 1 },
1236                "bg_completions": [{ "task_id": "bash-abc" }],
1237            }),
1238        );
1239        let result = ToolCallResult {
1240            text: "rendered text".to_string(),
1241            response,
1242        };
1243
1244        // The flat shape must equal the standalone NDJSON `tool_call` body:
1245        // {id, success, ...data, text}. Build the standalone expectation the
1246        // same way commands::tool_call::response_with_text does.
1247        let expected_flat = json!({
1248            "id": "req-7",
1249            "success": true,
1250            "complete": true,
1251            "matches": 3,
1252            "status_bar": { "errors": 0, "warnings": 1 },
1253            "bg_completions": [{ "task_id": "bash-abc" }],
1254            "text": "rendered text",
1255        });
1256        assert_eq!(
1257            serde_json::to_value(FlatToolResponse {
1258                response: &result.response,
1259                text: &result.text,
1260            })
1261            .unwrap(),
1262            expected_flat,
1263            "structuredContent must be byte-identical to the standalone flat response"
1264        );
1265
1266        // The frame body carries the MCP surface for generic hosts AND the flat
1267        // sidecar shape under structuredContent for the first-party plugin.
1268        let frame = build_tool_response_frame(
1269            PROTOCOL_VERSION,
1270            route_key(1, 1),
1271            42,
1272            control_flags(),
1273            &result,
1274            BindTrust::FirstParty,
1275        )
1276        .unwrap();
1277        let expected_body = serde_json::to_vec(&json!({
1278            "content": [{ "type": "text", "text": "rendered text" }],
1279            "isError": false,
1280            "structuredContent": expected_flat.clone(),
1281        }))
1282        .unwrap();
1283        assert_eq!(
1284            frame.body, expected_body,
1285            "tool response wire bytes drifted"
1286        );
1287        let body: Value = serde_json::from_slice(&frame.body).unwrap();
1288        assert_eq!(body["isError"], json!(false));
1289        assert_eq!(body["content"][0]["type"], json!("text"));
1290        assert_eq!(body["content"][0]["text"], json!("rendered text"));
1291        assert_eq!(body["structuredContent"], expected_flat);
1292
1293        // A failed response flips isError and still carries the flat shape
1294        // (with success:false + code) for the plugin's error path.
1295        let err = Response::error_with_data(
1296            "req-8",
1297            "ambiguous_match",
1298            "batch: edits[0] match 'same' is ambiguous (2 occurrences, expected 1). Use 'occurrence' (1-based) to select one, or 'replaceAll': true to replace every occurrence.",
1299            json!({
1300                "occurrences": [
1301                    { "occurrence": 1, "line": 1, "context": "same same" },
1302                    { "occurrence": 2, "line": 1, "context": "same same" }
1303                ]
1304            }),
1305        );
1306        let err_result = ToolCallResult {
1307            text: "batch: edits[0] match 'same' is ambiguous (2 occurrences, expected 1). Use 'occurrence' (1-based) to select one, or 'replaceAll': true to replace every occurrence.".to_string(),
1308            response: err,
1309        };
1310        let err_frame = build_tool_response_frame(
1311            PROTOCOL_VERSION,
1312            route_key(1, 1),
1313            43,
1314            control_flags(),
1315            &err_result,
1316            BindTrust::FirstParty,
1317        )
1318        .unwrap();
1319        let err_body: Value = serde_json::from_slice(&err_frame.body).unwrap();
1320        assert_eq!(err_body["isError"], json!(true));
1321        assert_eq!(err_body["structuredContent"]["success"], json!(false));
1322        assert_eq!(
1323            err_body["structuredContent"]["code"],
1324            json!("ambiguous_match")
1325        );
1326        assert_eq!(
1327            err_body["structuredContent"]["occurrences"],
1328            json!([
1329                { "occurrence": 1, "line": 1, "context": "same same" },
1330                { "occurrence": 2, "line": 1, "context": "same same" }
1331            ])
1332        );
1333        let err_message = err_body["structuredContent"]["message"]
1334            .as_str()
1335            .expect("structured contract message");
1336        assert!(err_message.contains("occurrence"));
1337        assert!(err_message.contains("1-based"));
1338        assert!(!err_message.contains("0-based"));
1339        assert!(!err_message.contains("0-indexed"));
1340        assert_eq!(err_body["structuredContent"]["text"], json!(err_message));
1341
1342        // UNTRUSTED (MCP) binds get text-only replies: no structuredContent
1343        // key at all. Generic MCP hosts have no re-lift layer, and hosts like
1344        // Claude Code feed structuredContent to the model verbatim when
1345        // present, a raw JSON dump at a multiple of the token cost.
1346        let untrusted_frame = build_tool_response_frame(
1347            PROTOCOL_VERSION,
1348            route_key(1, 1),
1349            44,
1350            control_flags(),
1351            &err_result,
1352            BindTrust::Untrusted,
1353        )
1354        .unwrap();
1355        let untrusted_body: Value = serde_json::from_slice(&untrusted_frame.body).unwrap();
1356        let untrusted_message = untrusted_body["content"][0]["text"]
1357            .as_str()
1358            .expect("untrusted contract message");
1359        assert!(untrusted_message.contains("occurrence"));
1360        assert!(untrusted_message.contains("1-based"));
1361        assert!(!untrusted_message.contains("0-based"));
1362        assert!(!untrusted_message.contains("0-indexed"));
1363        assert_eq!(untrusted_body["isError"], json!(true));
1364        assert!(
1365            untrusted_body.get("structuredContent").is_none(),
1366            "untrusted binds must not receive structuredContent: {untrusted_body}"
1367        );
1368    }
1369
1370    #[test]
1371    fn normal_response_is_byte_identical_with_the_size_guard_enabled() {
1372        let result = production_shape_result("read", 1_024);
1373        let default = build_tool_response_frame(
1374            PROTOCOL_VERSION,
1375            route_key(5, 2),
1376            76,
1377            control_flags(),
1378            &result,
1379            BindTrust::FirstParty,
1380        )
1381        .expect("default response frame");
1382        let guarded = build_tool_response_frame_with_limit(
1383            PROTOCOL_VERSION,
1384            route_key(5, 2),
1385            76,
1386            control_flags(),
1387            &result,
1388            BindTrust::FirstParty,
1389            8 * 1_024,
1390        )
1391        .expect("guarded response frame");
1392
1393        assert_eq!(guarded.header.encode(), default.header.encode());
1394        assert_eq!(guarded.body, default.body);
1395    }
1396
1397    #[test]
1398    fn oversized_rendered_text_is_utf8_safely_truncated_with_an_explicit_gap() {
1399        const TEST_BODY_LIMIT: usize = 8 * 1_024;
1400        let text = "é".repeat(3_000);
1401        let result = ToolCallResult {
1402            text: text.clone(),
1403            response: Response::success("large-text", json!({ "text": text })),
1404        };
1405
1406        let frame = build_tool_response_frame_with_limit(
1407            PROTOCOL_VERSION,
1408            route_key(5, 2),
1409            77,
1410            control_flags(),
1411            &result,
1412            BindTrust::FirstParty,
1413            TEST_BODY_LIMIT,
1414        )
1415        .expect("truncated response frame");
1416
1417        assert!(frame.body.len() <= TEST_BODY_LIMIT);
1418        let body: Value =
1419            serde_json::from_slice(&frame.body).expect("valid truncated response JSON");
1420        let rendered = body["content"][0]["text"]
1421            .as_str()
1422            .expect("rendered response text");
1423        assert!(rendered.ends_with(
1424            "[response truncated at 1 MiB: full output exceeds the transport frame limit; use offset/limit or write to a file]"
1425        ));
1426        assert!(rendered.len() < result.text.len());
1427        assert_eq!(body["isError"], json!(false));
1428        assert_eq!(body["structuredContent"]["success"], json!(true));
1429        assert_eq!(body["structuredContent"]["complete"], json!(false));
1430        assert_eq!(body["structuredContent"]["truncated"], json!(true));
1431        assert_eq!(
1432            body["structuredContent"]["truncation_reason"],
1433            json!(TRANSPORT_TRUNCATION_REASON)
1434        );
1435        assert_eq!(body["structuredContent"]["text"], json!(rendered));
1436    }
1437
1438    #[test]
1439    fn oversized_structured_data_gets_a_correlated_response_too_large_fallback() {
1440        const TEST_BODY_LIMIT: usize = 8 * 1_024;
1441        let text_limit = tool_response_text_limit(TEST_BODY_LIMIT, true);
1442        let between_threshold_and_limit = text_limit + 512;
1443        assert!(between_threshold_and_limit < TEST_BODY_LIMIT);
1444        let result = ToolCallResult {
1445            text: "r".repeat(between_threshold_and_limit),
1446            response: Response::success(
1447                "large-structured",
1448                json!({ "payload": "p".repeat(between_threshold_and_limit) }),
1449            ),
1450        };
1451
1452        let frame = build_tool_response_frame_with_limit(
1453            PROTOCOL_VERSION,
1454            route_key(9, 4),
1455            88,
1456            control_flags(),
1457            &result,
1458            BindTrust::FirstParty,
1459            TEST_BODY_LIMIT,
1460        )
1461        .expect("response_too_large fallback frame");
1462
1463        assert_eq!(frame.header.ty, FrameType::Response);
1464        assert_eq!(frame.header.channel, 9);
1465        assert_eq!(frame.header.epoch, 4);
1466        assert_eq!(frame.header.corr, 88);
1467        assert!(frame.body.len() <= TEST_BODY_LIMIT);
1468        let body: Value =
1469            serde_json::from_slice(&frame.body).expect("valid fallback response JSON");
1470        assert_eq!(body["isError"], json!(true));
1471        assert_eq!(body["structuredContent"]["success"], json!(false));
1472        assert_eq!(
1473            body["structuredContent"]["code"],
1474            json!(RESPONSE_TOO_LARGE_CODE)
1475        );
1476        assert_eq!(body["structuredContent"]["complete"], json!(false));
1477        assert_eq!(body["structuredContent"]["truncated"], json!(true));
1478        let message = body["structuredContent"]["message"]
1479            .as_str()
1480            .expect("fallback message");
1481        assert!(message.contains("serialized to "));
1482        assert!(message.contains("8192 bytes"));
1483        assert!(message.contains("narrower range"));
1484        assert!(message.contains("offset+limit paging"));
1485        assert!(message.contains("output over 1 MiB cannot cross the daemon transport"));
1486    }
1487}