1use 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
320struct 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 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 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 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 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 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 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 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}