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