Skip to main content

agy_bridge/streaming/
mod.rs

1//! Streaming response bridge for the Antigravity SDK.
2//!
3//! Bridges the SDK's `ChatResponse` (Python async iterator) to tokio channels
4//! so Rust consumers can stream text tokens, thinking tokens, and tool call
5//! events independently.
6
7mod handle;
8mod types;
9mod writer;
10
11use std::sync::{Arc, Mutex};
12
13use tokio::sync::mpsc;
14
15use self::types::{StreamReceivers, StreamSubscriptions};
16pub use self::{
17    handle::ChatResponseHandle,
18    types::{
19        ChatResponseSharedState, ChatResult, ResponseEvent, StreamChunk, StreamError, ToolCallEvent,
20    },
21    writer::{ChatResponseWriter, WriterError},
22};
23
24/// Default channel buffer size. Large enough to avoid backpressure during
25/// normal operation while bounding memory usage.
26pub(crate) const DEFAULT_CHANNEL_BUFFER: usize = 256;
27
28/// Create a paired `(ChatResponseWriter, ChatResponseHandle)`.
29///
30/// The writer is handed to the Python bridge thread; the handle is returned
31/// to the Rust caller.
32#[must_use]
33pub fn channel() -> (ChatResponseWriter, ChatResponseHandle) {
34    channel_with_buffer(DEFAULT_CHANNEL_BUFFER)
35}
36
37/// Create a paired `(ChatResponseWriter, ChatResponseHandle)` with a custom
38/// channel buffer size.
39///
40/// Prefer [`channel()`] for default buffer sizing. Use this when you need
41/// to tune memory usage or backpressure behavior.
42#[must_use]
43pub fn channel_with_buffer(buffer: usize) -> (ChatResponseWriter, ChatResponseHandle) {
44    let (text_tx, text_rx) = mpsc::channel(buffer);
45    let (thought_tx, thought_rx) = mpsc::channel(buffer);
46    let (tool_call_tx, tool_call_rx) = mpsc::channel(buffer);
47    let (error_tx, error_rx) = mpsc::channel(1);
48    let (event_tx, event_rx) = mpsc::channel(buffer);
49    let (step_tx, step_rx) = mpsc::channel(buffer);
50    let (chunk_tx, chunk_rx) = mpsc::channel(buffer);
51
52    let shared_state = Arc::new(Mutex::new(ChatResponseSharedState::default()));
53    let subs = Arc::new(StreamSubscriptions::default());
54
55    let writer = ChatResponseWriter {
56        text_tx,
57        thought_tx,
58        tool_call_tx,
59        error_tx,
60        event_tx,
61        step_tx,
62        chunk_tx,
63        subs: Arc::clone(&subs),
64        shared_state: Arc::clone(&shared_state),
65    };
66
67    let handle = ChatResponseHandle {
68        rx: StreamReceivers::new(
69            text_rx,
70            thought_rx,
71            tool_call_rx,
72            error_rx,
73            event_rx,
74            step_rx,
75            chunk_rx,
76        ),
77        subs,
78        usage: None,
79        structured_output_value: None,
80        shared_state,
81    };
82
83    (writer, handle)
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[tokio::test]
91    async fn streaming_receives_all_tokens_in_order() {
92        let (writer, mut handle) = channel();
93
94        let tokens = ["Hello", " ", "world", "!"];
95        let expected: String = tokens.iter().copied().collect();
96
97        // Simulate the Python bridge sending tokens
98        let send_task = tokio::spawn(async move {
99            for token in &["Hello", " ", "world", "!"] {
100                writer
101                    .text_tx
102                    .send((*token).to_owned())
103                    .await
104                    .expect("send should succeed");
105            }
106            // Dropping writer closes the channel
107        });
108
109        // Consume via the stream receiver
110        let mut rx = handle.take_text_stream().expect("should get receiver");
111        let mut received = Vec::new();
112        while let Some(token) = rx.recv().await {
113            received.push(token);
114        }
115
116        send_task.await.expect("send task should complete");
117        let full: String = received.iter().map(String::as_str).collect();
118        assert_eq!(full, expected);
119    }
120
121    #[tokio::test]
122    async fn text_returns_complete_response() {
123        let (writer, handle) = channel();
124
125        tokio::spawn(async move {
126            for token in &["The ", "answer ", "is ", "42."] {
127                writer
128                    .text_tx
129                    .send((*token).to_owned())
130                    .await
131                    .expect("send");
132            }
133        });
134
135        let text = handle.text().await.expect("should succeed");
136        assert_eq!(text, "The answer is 42.");
137    }
138
139    #[tokio::test]
140    async fn text_returns_empty_when_no_tokens() {
141        let (writer, handle) = channel();
142        // Drop the writer immediately to close the channel
143        drop(writer);
144
145        let text = handle.text().await.expect("should succeed");
146        assert!(text.is_empty());
147    }
148
149    #[tokio::test]
150    async fn stream_error_propagated() {
151        let (writer, handle) = channel();
152
153        tokio::spawn(async move {
154            writer
155                .text_tx
156                .send("partial".to_owned())
157                .await
158                .expect("send");
159            writer
160                .error_tx
161                .send(StreamError {
162                    message: "Python exception: quota exceeded".to_owned(),
163                })
164                .await
165                .expect("send error");
166        });
167
168        let result = handle.text().await;
169        assert!(result.is_err());
170        let err = result.unwrap_err();
171        assert!(err.message.contains("quota exceeded"));
172    }
173
174    #[tokio::test]
175    async fn thought_stream_works() {
176        let (writer, mut handle) = channel();
177
178        tokio::spawn(async move {
179            writer
180                .thought_tx
181                .send("thinking...".to_owned())
182                .await
183                .expect("send");
184            writer
185                .thought_tx
186                .send("done.".to_owned())
187                .await
188                .expect("send");
189        });
190
191        let mut rx = handle.take_thought_stream().expect("should get receiver");
192        let mut thoughts = Vec::new();
193        while let Some(t) = rx.recv().await {
194            thoughts.push(t);
195        }
196        assert_eq!(thoughts, vec!["thinking...", "done."]);
197    }
198
199    #[tokio::test]
200    async fn tool_call_stream_works() {
201        let (writer, mut handle) = channel();
202
203        let event = ToolCallEvent {
204            name: "view_file".to_owned(),
205            args: serde_json::json!({"path": "/tmp/test.txt"}),
206            id: Some("call_1".to_owned()),
207            canonical_path: None,
208        };
209
210        let event_clone = event.clone();
211        tokio::spawn(async move {
212            writer.tool_call_tx.send(event_clone).await.expect("send");
213        });
214
215        let mut rx = handle.take_tool_call_stream().expect("should get receiver");
216        let received = rx.recv().await.expect("should receive event");
217        assert_eq!(received.name, "view_file");
218        assert_eq!(received.id, Some("call_1".to_owned()));
219    }
220
221    #[tokio::test]
222    async fn usage_metadata_available_after_finalize() {
223        let (writer, mut handle) = channel();
224        assert!(handle.usage_metadata().is_none());
225
226        writer.set_usage(crate::types::UsageMetadata {
227            prompt_token_count: Some(100),
228            cached_content_token_count: Some(10),
229            candidates_token_count: Some(50),
230            thoughts_token_count: Some(20),
231            total_token_count: Some(170),
232        });
233        drop(writer);
234        handle.finalize();
235
236        let usage = handle.usage_metadata().expect("should have usage");
237        assert_eq!(usage.prompt_token_count, Some(100));
238        assert_eq!(usage.total_token_count, Some(170));
239    }
240
241    #[test]
242    fn take_text_stream_returns_none_second_time() {
243        let (_writer, mut handle) = channel();
244        assert!(handle.take_text_stream().is_some());
245        assert!(handle.take_text_stream().is_none());
246    }
247
248    #[test]
249    fn tool_call_event_serde_roundtrip() {
250        let event = ToolCallEvent {
251            name: "run_command".to_owned(),
252            args: serde_json::json!({"command": "ls"}),
253            id: Some("call_42".to_owned()),
254            canonical_path: None,
255        };
256        let json = serde_json::to_string(&event).expect("serialize");
257        let parsed: ToolCallEvent = serde_json::from_str(&json).expect("deserialize");
258        assert_eq!(parsed.name, event.name);
259        assert_eq!(parsed.args, event.args);
260        assert_eq!(parsed.id, event.id);
261    }
262
263    #[test]
264    fn take_thought_stream_returns_none_second_time() {
265        let (_writer, mut handle) = channel();
266        assert!(handle.take_thought_stream().is_some());
267        assert!(handle.take_thought_stream().is_none());
268    }
269
270    #[test]
271    fn take_tool_call_stream_returns_none_second_time() {
272        let (_writer, mut handle) = channel();
273        assert!(handle.take_tool_call_stream().is_some());
274        assert!(handle.take_tool_call_stream().is_none());
275    }
276
277    #[test]
278    fn stream_error_display() {
279        let err = StreamError {
280            message: "quota exceeded".to_owned(),
281        };
282        assert_eq!(format!("{err}"), "stream error: quota exceeded");
283    }
284
285    #[test]
286    fn stream_error_is_std_error() {
287        let err = StreamError {
288            message: "test".to_owned(),
289        };
290        // Verify it implements std::error::Error
291        let _: &dyn std::error::Error = &err;
292    }
293
294    #[tokio::test]
295    async fn concurrent_text_and_thought_streams() {
296        let (writer, mut handle) = channel();
297
298        tokio::spawn(async move {
299            writer
300                .text_tx
301                .send("Hello".to_owned())
302                .await
303                .expect("send text");
304            writer
305                .thought_tx
306                .send("thinking...".to_owned())
307                .await
308                .expect("send thought");
309        });
310
311        let mut text_rx = handle.take_text_stream().expect("text rx");
312        let mut thought_rx = handle.take_thought_stream().expect("thought rx");
313
314        let text = text_rx.recv().await.expect("receive text");
315        let thought = thought_rx.recv().await.expect("receive thought");
316
317        assert_eq!(text, "Hello");
318        assert_eq!(thought, "thinking...");
319    }
320
321    #[tokio::test]
322    async fn writer_dropped_without_sending_closes_text() {
323        let (writer, handle) = channel();
324        drop(writer);
325
326        let text = handle.text().await.expect("should succeed");
327        assert!(text.is_empty());
328    }
329
330    #[tokio::test]
331    async fn writer_dropped_without_sending_closes_thought_stream() {
332        let (writer, mut handle) = channel();
333        drop(writer);
334
335        let mut thought_rx = handle.take_thought_stream().expect("rx");
336        assert!(thought_rx.recv().await.is_none());
337    }
338
339    #[test]
340    fn tool_call_event_without_id() {
341        let event = ToolCallEvent {
342            name: "custom".to_owned(),
343            args: serde_json::json!(null),
344            id: None,
345            canonical_path: None,
346        };
347        let json = serde_json::to_string(&event).expect("serialize");
348        let parsed: ToolCallEvent = serde_json::from_str(&json).expect("deserialize");
349        assert_eq!(parsed.name, "custom");
350        assert_eq!(parsed.args, serde_json::json!(null));
351    }
352
353    #[tokio::test]
354    async fn large_token_stream() {
355        let (writer, handle) = channel();
356        let token_count = 200;
357
358        tokio::spawn(async move {
359            for i in 0..token_count {
360                writer.text_tx.send(format!("t{i}")).await.expect("send");
361            }
362        });
363
364        let text = handle.text().await.expect("should succeed");
365        // Verify all 200 tokens were collected
366        for i in 0..token_count {
367            assert!(
368                text.contains(&format!("t{i}")),
369                "Missing token t{i} in output"
370            );
371        }
372    }
373
374    #[tokio::test]
375    async fn resolve_returns_events_in_order() {
376        let (writer, handle) = channel();
377
378        let tool_event = ToolCallEvent {
379            name: "view_file".to_owned(),
380            args: serde_json::json!({"path": "/tmp/x.rs"}),
381            id: Some("call_1".to_owned()),
382            canonical_path: None,
383        };
384
385        let tool_clone = tool_event.clone();
386        tokio::spawn(async move {
387            writer
388                .event_tx
389                .send(ResponseEvent::TextChunk("Hello ".to_owned()))
390                .await
391                .expect("send");
392            writer
393                .event_tx
394                .send(ResponseEvent::ThoughtChunk("hmm".to_owned()))
395                .await
396                .expect("send");
397            writer
398                .event_tx
399                .send(ResponseEvent::ToolCall(tool_clone))
400                .await
401                .expect("send");
402            writer
403                .event_tx
404                .send(ResponseEvent::TextChunk("world".to_owned()))
405                .await
406                .expect("send");
407            writer
408                .event_tx
409                .send(ResponseEvent::ToolResult(crate::types::ToolResult {
410                    name: "view_file".to_owned(),
411                    id: Some("call_1".to_owned()),
412                    result: serde_json::json!({"output": "file contents"}),
413                    error: None,
414                }))
415                .await
416                .expect("send");
417            // Drop writer to close the channel
418        });
419
420        let events = handle.resolve().await;
421        assert_eq!(events.len(), 5, "Expected 5 events, got {}", events.len());
422
423        // Verify ordering and types
424        assert!(
425            matches!(&events[0], ResponseEvent::TextChunk(s) if s == "Hello "),
426            "events[0] should be TextChunk(\"Hello \")"
427        );
428        assert!(
429            matches!(&events[1], ResponseEvent::ThoughtChunk(s) if s == "hmm"),
430            "events[1] should be ThoughtChunk(\"hmm\")"
431        );
432        assert!(
433            matches!(&events[2], ResponseEvent::ToolCall(tc) if tc.name == "view_file"),
434            "events[2] should be ToolCall(view_file)"
435        );
436        assert!(
437            matches!(&events[3], ResponseEvent::TextChunk(s) if s == "world"),
438            "events[3] should be TextChunk(\"world\")"
439        );
440        assert!(
441            matches!(&events[4], ResponseEvent::ToolResult(tr) if tr.name == "view_file"),
442            "events[4] should be ToolResult(view_file)"
443        );
444    }
445
446    #[test]
447    fn response_event_serde_roundtrip() {
448        let events = vec![
449            ResponseEvent::TextChunk("hello".to_owned()),
450            ResponseEvent::ThoughtChunk("thinking".to_owned()),
451            ResponseEvent::ToolCall(ToolCallEvent {
452                name: "run_command".to_owned(),
453                args: serde_json::json!({"cmd": "ls"}),
454                id: Some("c1".to_owned()),
455                canonical_path: None,
456            }),
457            ResponseEvent::ToolResult(crate::types::ToolResult {
458                name: "run_command".to_owned(),
459                id: Some("c1".to_owned()),
460                result: serde_json::json!({"output": "done"}),
461                error: None,
462            }),
463        ];
464
465        let json = serde_json::to_string(&events).expect("serialize");
466        let parsed: Vec<ResponseEvent> = serde_json::from_str(&json).expect("deserialize");
467        assert_eq!(parsed.len(), events.len());
468    }
469
470    // ── receive_chunks / receive_steps tests ─────────────────────────────
471
472    #[tokio::test]
473    async fn receive_chunks_returns_chunks_in_order() {
474        use tokio_stream::StreamExt;
475
476        let (writer, mut handle) = channel();
477
478        tokio::spawn(async move {
479            writer
480                .chunk_tx
481                .send(StreamChunk::Text("hello".to_owned()))
482                .await
483                .expect("send");
484            writer
485                .chunk_tx
486                .send(StreamChunk::Thought("hmm".to_owned()))
487                .await
488                .expect("send");
489            writer
490                .chunk_tx
491                .send(StreamChunk::ToolCall(ToolCallEvent {
492                    name: "view_file".to_owned(),
493                    args: serde_json::json!({}),
494                    id: None,
495                    canonical_path: None,
496                }))
497                .await
498                .expect("send");
499            writer
500                .chunk_tx
501                .send(StreamChunk::Text(" world".to_owned()))
502                .await
503                .expect("send");
504        });
505
506        let mut stream = handle.receive_chunks().expect("should get stream");
507        let mut items = Vec::new();
508        while let Some(chunk) = stream.next().await {
509            items.push(chunk);
510        }
511
512        assert_eq!(items.len(), 4);
513        assert!(matches!(&items[0], StreamChunk::Text(t) if t == "hello"));
514        assert!(matches!(&items[1], StreamChunk::Thought(t) if t == "hmm"));
515        assert!(matches!(&items[2], StreamChunk::ToolCall(tc) if tc.name == "view_file"));
516        assert!(matches!(&items[3], StreamChunk::Text(t) if t == " world"));
517    }
518
519    #[tokio::test]
520    async fn receive_steps_returns_steps() {
521        use tokio_stream::StreamExt;
522
523        let (writer, mut handle) = channel();
524
525        tokio::spawn(async move {
526            writer
527                .step_tx
528                .send(crate::types::Step {
529                    id: "step-0".to_owned(),
530                    step_index: 0,
531                    step_type: crate::types::StepType::TextResponse,
532                    source: crate::types::StepSource::Model,
533                    target: crate::types::StepTarget::User,
534                    status: crate::types::StepStatus::Done,
535                    content: "Hello".to_owned(),
536                    content_delta: "Hello".to_owned(),
537                    thinking: String::new(),
538                    thinking_delta: String::new(),
539                    tool_calls: vec![],
540                    error: String::new(),
541                    http_code: 0,
542                    is_complete_response: Some(true),
543                    structured_output: None,
544                    usage_metadata: None,
545                })
546                .await
547                .expect("send");
548        });
549
550        let mut stream = handle.receive_steps().expect("should get stream");
551        let step = stream.next().await.expect("should get a step");
552        assert_eq!(step.id, "step-0");
553        assert_eq!(step.step_type, crate::types::StepType::TextResponse);
554        assert_eq!(step.content, "Hello");
555    }
556
557    #[tokio::test]
558    async fn existing_channels_work_alongside_chunk_stream() {
559        use tokio_stream::StreamExt;
560
561        let (writer, mut handle) = channel();
562
563        tokio::spawn(async move {
564            // Send through both the dedicated text channel and the chunk channel.
565            writer
566                .text_tx
567                .send("text-tok".to_owned())
568                .await
569                .expect("send text");
570            writer
571                .chunk_tx
572                .send(StreamChunk::Text("text-tok".to_owned()))
573                .await
574                .expect("send chunk");
575        });
576
577        let mut text_rx = handle.take_text_stream().expect("text rx");
578        let text = text_rx.recv().await.expect("receive text");
579        assert_eq!(text, "text-tok");
580
581        let mut chunk_stream = handle.receive_chunks().expect("chunk stream");
582        let chunk = chunk_stream.next().await.expect("receive chunk");
583        assert!(matches!(chunk, StreamChunk::Text(t) if t == "text-tok"));
584    }
585
586    #[test]
587    fn receive_chunks_returns_none_on_second_call() {
588        let (_writer, mut handle) = channel();
589        assert!(handle.receive_chunks().is_some());
590        assert!(handle.receive_chunks().is_none());
591    }
592
593    #[test]
594    fn receive_steps_returns_none_on_second_call() {
595        let (_writer, mut handle) = channel();
596        assert!(handle.receive_steps().is_some());
597        assert!(handle.receive_steps().is_none());
598    }
599
600    #[test]
601    fn take_event_stream_returns_none_second_time() {
602        let (_writer, mut handle) = channel();
603        assert!(handle.take_event_stream().is_some());
604        assert!(handle.take_event_stream().is_none());
605    }
606
607    #[test]
608    fn take_chunk_stream_returns_none_second_time() {
609        let (_writer, mut handle) = channel();
610        assert!(handle.take_chunk_stream().is_some());
611        assert!(handle.take_chunk_stream().is_none());
612    }
613
614    /// Regression: `event_tx` uses *blocking* sends and is bounded by
615    /// `CHANNEL_BUFFER`. A consumer that drains it concurrently must be able to
616    /// receive far more than one buffer's worth of events without the writer
617    /// deadlocking. This guards the backpressure-stall class of bug where an
618    /// undrained fan-out channel silently halts the entire stream.
619    #[tokio::test]
620    async fn draining_event_stream_avoids_backpressure_beyond_buffer() {
621        let (writer, mut handle) = channel();
622        let total = DEFAULT_CHANNEL_BUFFER * 3;
623
624        let producer = tokio::spawn(async move {
625            for i in 0..total {
626                writer
627                    .event_tx
628                    .send(ResponseEvent::TextChunk(format!("e{i}")))
629                    .await
630                    .expect("send should not fail while consumer drains");
631            }
632        });
633
634        let mut rx = handle.take_event_stream().expect("event rx");
635        let mut count = 0usize;
636        while (rx.recv().await).is_some() {
637            count += 1;
638        }
639
640        producer.await.expect("producer task");
641        assert_eq!(
642            count, total,
643            "all {total} events must flow when the channel is drained concurrently"
644        );
645    }
646
647    /// Regression: the subscription-gated `fan_out` must never block on a view
648    /// that nobody subscribed to. A consumer wanting only `text` (the common
649    /// case) leaves `event`/`thought`/`chunk`/... undrained; the writer must
650    /// skip those entirely rather than filling their buffers and stalling.
651    ///
652    /// This drives *many* buffers' worth through an unsubscribed `event`
653    /// channel interleaved with a subscribed, actively-drained `text` channel.
654    /// If gating regressed to a blocking send, the unsubscribed channel would
655    /// fill after `DEFAULT_CHANNEL_BUFFER` items and this test would hang (caught by the
656    /// harness timeout).
657    #[tokio::test]
658    async fn unsubscribed_view_never_blocks_writer() {
659        let (writer, mut handle) = channel();
660        // Subscribe to text ONLY. `event` stays unsubscribed and undrained.
661        let mut text_rx = handle.take_text_stream().expect("text rx");
662        let total = DEFAULT_CHANNEL_BUFFER * 4;
663
664        let producer = async move {
665            for i in 0..total {
666                // Unsubscribed → must be skipped, never blocking, even though
667                // its receiver is alive inside `handle` and never drained.
668                ChatResponseWriter::fan_out(
669                    &writer.subs.event,
670                    &writer.event_tx,
671                    ResponseEvent::TextChunk(format!("e{i}")),
672                    "event",
673                )
674                .await;
675                // Subscribed + drained → must deliver every item.
676                ChatResponseWriter::fan_out(
677                    &writer.subs.text,
678                    &writer.text_tx,
679                    format!("t{i}"),
680                    "text",
681                )
682                .await;
683            }
684            drop(writer);
685        };
686
687        let consumer = async {
688            let mut n = 0usize;
689            while text_rx.recv().await.is_some() {
690                n += 1;
691            }
692            n
693        };
694
695        let ((), delivered) = tokio::join!(producer, consumer);
696        assert_eq!(
697            delivered, total,
698            "every subscribed text item must be delivered while the unsubscribed \
699             event view is skipped without blocking"
700        );
701        // The undrained event receiver must still be present (never taken) —
702        // proving the writer skipped it rather than requiring a drainer.
703        assert!(handle.take_event_stream().is_some());
704    }
705
706    #[test]
707    fn stream_chunk_serde_roundtrip() {
708        let chunks = vec![
709            StreamChunk::Text("hello".to_owned()),
710            StreamChunk::Thought("hmm".to_owned()),
711            StreamChunk::ToolCall(ToolCallEvent {
712                name: "run".to_owned(),
713                args: serde_json::json!({"cmd": "ls"}),
714                id: Some("c1".to_owned()),
715                canonical_path: None,
716            }),
717        ];
718        for chunk in &chunks {
719            let json = serde_json::to_string(chunk).expect("serialize");
720            let parsed: StreamChunk = serde_json::from_str(&json).expect("deserialize");
721            // Verify discriminant matches.
722            match (chunk, &parsed) {
723                (StreamChunk::Text(a), StreamChunk::Text(b))
724                | (StreamChunk::Thought(a), StreamChunk::Thought(b)) => assert_eq!(a, b),
725                (StreamChunk::ToolCall(a), StreamChunk::ToolCall(b)) => {
726                    assert_eq!(a.name, b.name);
727                    assert_eq!(a.id, b.id);
728                }
729                _ => panic!("variant mismatch after roundtrip"),
730            }
731        }
732    }
733
734    #[tokio::test]
735    async fn usage_metadata_populated_from_writer_after_resolve() {
736        let (writer, handle) = channel();
737
738        tokio::spawn(async move {
739            writer
740                .event_tx
741                .send(ResponseEvent::TextChunk("hello".to_owned()))
742                .await
743                .unwrap();
744            writer.set_usage(crate::types::UsageMetadata {
745                prompt_token_count: Some(5),
746                cached_content_token_count: None,
747                candidates_token_count: Some(1),
748                thoughts_token_count: None,
749                total_token_count: Some(6),
750            });
751            writer.set_structured_output(serde_json::json!({"key": "value"}));
752        });
753
754        // resolve() consumes the handle but finalize() runs internally,
755        // so we verify via the shared state directly instead.
756        let shared = handle.shared_state();
757        let events = handle.resolve().await;
758        assert_eq!(events.len(), 1);
759
760        let state = shared.lock().expect("lock shared state");
761        assert_eq!(state.usage.as_ref().unwrap().total_token_count, Some(6));
762        assert_eq!(
763            state.structured_output.as_ref().unwrap(),
764            &serde_json::json!({"key": "value"})
765        );
766    }
767
768    #[test]
769    fn chat_result_into_string() {
770        let (writer, handle) = channel();
771        drop(writer);
772        let rt = tokio::runtime::Runtime::new().unwrap();
773        let result = rt.block_on(handle.text()).unwrap();
774        let s: String = result.into();
775        assert!(s.is_empty());
776    }
777
778    #[tokio::test]
779    async fn chat_result_ergonomics() {
780        let (writer, handle) = channel();
781        tokio::spawn(async move {
782            writer
783                .text_tx
784                .send("hello world".to_owned())
785                .await
786                .expect("send");
787        });
788
789        let result = handle.text().await.expect("text");
790
791        // Deref<Target = str> — str methods work directly on ChatResult.
792        assert_eq!(result.len(), 11);
793        assert!(result.contains("world"));
794        assert_eq!(result.text(), "hello world");
795
796        // PartialEq<&str> and PartialEq<String>.
797        assert_eq!(result, "hello world");
798        assert_eq!(result, "hello world".to_owned());
799
800        // Display forwards to the inner text.
801        assert_eq!(format!("{result}"), "hello world");
802
803        // No usage / structured output was sent.
804        assert!(result.usage().is_none());
805        assert!(result.structured_output().is_none());
806
807        // into_string() yields the owned inner String.
808        assert_eq!(result.into_string(), "hello world");
809    }
810
811    // ── Error routing tests (bug: error steps not routed to error_tx) ──────
812
813    #[tokio::test]
814    async fn error_step_routed_to_error_channel() {
815        // Simulate what the streaming pipeline does: error_tx receives the
816        // error, handle.text() must return Err, not Ok("").
817        let (writer, handle) = channel();
818
819        // Destructure the writer so we can drop text_tx explicitly to unblock
820        // handle.text()'s drain loop, then send to error_tx and step_tx.
821        let ChatResponseWriter {
822            text_tx,
823            error_tx,
824            step_tx,
825            ..
826        } = writer;
827
828        let producer = async move {
829            // Simulate a backend 503 error step — this is what the SDK sends
830            // when GenerateContent fails after exhausting retries.
831            error_tx
832                .try_send(StreamError {
833                    message: "Agent execution terminated due to error. (request failed (code 503): APP_ERROR(2))".to_owned(),
834                })
835                .expect("error_tx should accept");
836            step_tx
837                .send(crate::types::Step {
838                    status: crate::types::StepStatus::Error,
839                    error: "Agent execution terminated due to error.".to_owned(),
840                    ..crate::types::Step::default()
841                })
842                .await
843                .expect("step_tx should accept");
844            // Close the text channel so handle.text() can finish draining.
845            drop(text_tx);
846        };
847
848        let consumer = handle.text();
849
850        let ((), result) = tokio::join!(producer, consumer);
851        let err = result.expect_err("handle.text() must return Err when error step is sent");
852        assert!(
853            err.message.contains("503") || err.message.contains("Agent execution terminated"),
854            "Error message should contain the backend error: {}",
855            err.message
856        );
857    }
858
859    #[tokio::test]
860    async fn error_step_without_error_channel_returns_empty_ok() {
861        // Verify the OLD behavior (before fix): if only step_tx gets the error
862        // but error_tx does NOT, handle.text() returns Ok("").
863        // This documents the bug and ensures the fix is needed.
864        let (writer, handle) = channel();
865
866        let ChatResponseWriter {
867            text_tx, step_tx, ..
868        } = writer;
869
870        let producer = async move {
871            // Only send to step_tx (NOT error_tx) — the old broken behavior.
872            step_tx
873                .send(crate::types::Step {
874                    status: crate::types::StepStatus::Error,
875                    error: "Agent execution terminated".to_owned(),
876                    ..crate::types::Step::default()
877                })
878                .await
879                .expect("step_tx should accept");
880            // Close text channel so handle.text() can finish draining.
881            drop(text_tx);
882        };
883
884        let consumer = handle.text();
885
886        // Without the error_tx send, text() returns Ok("") — the bug!
887        let ((), result) = tokio::join!(producer, consumer);
888        let text =
889            result.expect("Without error_tx, text() should return Ok (demonstrating the old bug)");
890        assert!(text.is_empty(), "Without error_tx, text should be empty");
891    }
892
893    #[tokio::test]
894    async fn error_tx_capacity_one_first_error_wins() {
895        // error_tx has capacity 1. Multiple sends should not block.
896        let (writer, handle) = channel();
897
898        let ChatResponseWriter {
899            text_tx, error_tx, ..
900        } = writer;
901
902        let producer = async move {
903            // First error — should succeed
904            error_tx
905                .try_send(StreamError {
906                    message: "first error".to_owned(),
907                })
908                .expect("first try_send should succeed");
909            // Second error — should fail (channel full), not block
910            let second = error_tx.try_send(StreamError {
911                message: "second error".to_owned(),
912            });
913            second.expect_err("Second try_send should fail (channel full)");
914            // Close text channel so handle.text() can finish draining.
915            drop(text_tx);
916        };
917
918        let consumer = handle.text();
919
920        let ((), result) = tokio::join!(producer, consumer);
921        assert!(result.is_err());
922        assert_eq!(
923            result.unwrap_err().message,
924            "first error",
925            "First error should win"
926        );
927    }
928
929    #[tokio::test]
930    async fn error_step_with_partial_text_still_returns_error() {
931        // Even if some text was streamed before the error, the error wins.
932        let (writer, handle) = channel();
933
934        let ChatResponseWriter {
935            text_tx, error_tx, ..
936        } = writer;
937
938        let producer = async move {
939            // Some text tokens arrive first
940            text_tx
941                .send("partial response...".to_owned())
942                .await
943                .expect("text send");
944            // Then backend error
945            error_tx
946                .try_send(StreamError {
947                    message: "connection reset during streaming".to_owned(),
948                })
949                .expect("error send");
950            // text_tx is dropped here, closing the text channel.
951        };
952
953        let consumer = handle.text();
954
955        let ((), result) = tokio::join!(producer, consumer);
956        let err = result.expect_err("Error should take priority over partial text");
957        assert!(
958            err.message.contains("connection reset"),
959            "Should contain the error message"
960        );
961    }
962
963    #[tokio::test]
964    async fn step_stream_receives_error_steps() {
965        // Even with the fix, step consumers should still see error steps.
966        use tokio_stream::StreamExt;
967
968        let (writer, mut handle) = channel();
969
970        let error_step = crate::types::Step {
971            id: "err-step".to_owned(),
972            step_index: 0,
973            status: crate::types::StepStatus::Error,
974            error: "model 503".to_owned(),
975            ..crate::types::Step::default()
976        };
977
978        let ChatResponseWriter {
979            error_tx, step_tx, ..
980        } = writer;
981
982        let producer = async move {
983            error_tx
984                .try_send(StreamError {
985                    message: "model 503".to_owned(),
986                })
987                .expect("error send");
988            step_tx.send(error_step).await.expect("step send");
989        };
990
991        let consumer = async {
992            let mut stream = handle.receive_steps().expect("should get stream");
993            let step = stream.next().await.expect("should get error step");
994            assert_eq!(step.status, crate::types::StepStatus::Error);
995            assert_eq!(step.error, "model 503");
996        };
997
998        tokio::join!(producer, consumer);
999    }
1000}