Skip to main content

agent_sdk_providers/
streaming.rs

1//! Streaming types for LLM responses.
2//!
3//! This module provides types for handling streaming responses from LLM providers.
4//! The [`StreamDelta`] enum represents individual events in a streaming response,
5//! and [`StreamAccumulator`] helps collect these events into a final response.
6
7use agent_sdk_foundation::llm::{ContentBlock, StopReason, Usage};
8#[cfg(any(feature = "openai", feature = "openai-codex"))]
9use bytes::BytesMut;
10use futures::Stream;
11use std::collections::HashMap;
12use std::pin::Pin;
13
14/// Upper bound on the block index [`StreamAccumulator`] will materialize.
15///
16/// `block_index` is taken verbatim from provider wire data (the SSE `index`
17/// field) and `base_url` is user-configurable (any OpenAI-compatible endpoint),
18/// so a corrupted or hostile event carrying a huge index could otherwise drive
19/// an unbounded `Vec` allocation and exhaust host memory. Text/thinking deltas
20/// whose index exceeds this bound are dropped with a warning rather than grown
21/// into.
22const MAX_BLOCK_INDEX: usize = 4096;
23
24/// Incremental splitter for line-delimited SSE byte streams.
25///
26/// `reqwest`'s `bytes_stream` yields arbitrary byte boundaries, so a multi-byte
27/// UTF-8 character can land split across two network chunks. Decoding each raw
28/// chunk independently with `String::from_utf8_lossy` permanently corrupts such
29/// characters into `U+FFFD` in user-visible text deltas. This buffer instead
30/// accumulates raw bytes and only UTF-8-decodes *complete* lines (terminated by
31/// `\n`); because a newline byte (`0x0A`) can never be part of a multi-byte
32/// UTF-8 sequence, the end of a complete line is always a valid character
33/// boundary and decodes losslessly.
34///
35/// It also avoids the quadratic `buffer = buffer[pos + 1..].to_string()` copy of
36/// the naive splitter: [`BytesMut::split_to`] advances the read cursor without
37/// copying the unconsumed tail, so splitting is amortized O(1) per line instead
38/// of O(remaining-buffer).
39#[cfg(any(feature = "openai", feature = "openai-codex"))]
40#[derive(Debug, Default)]
41pub(crate) struct SseLineBuffer {
42    buf: BytesMut,
43}
44
45#[cfg(any(feature = "openai", feature = "openai-codex"))]
46impl SseLineBuffer {
47    /// Create an empty buffer.
48    #[must_use]
49    pub(crate) fn new() -> Self {
50        Self::default()
51    }
52
53    /// Append a freshly received network chunk.
54    pub(crate) fn extend(&mut self, chunk: &[u8]) {
55        self.buf.extend_from_slice(chunk);
56    }
57
58    /// Pop the next complete line (without its trailing `\n`), or `None` when no
59    /// full line is buffered yet. Incomplete trailing bytes — including a
60    /// multi-byte character split across a chunk boundary — stay buffered for the
61    /// next call.
62    pub(crate) fn next_line(&mut self) -> Option<String> {
63        let newline = self.buf.iter().position(|&b| b == b'\n')?;
64        let mut line = self.buf.split_to(newline + 1);
65        line.truncate(newline);
66        Some(String::from_utf8_lossy(&line).into_owned())
67    }
68}
69
70/// Events yielded during streaming LLM responses.
71///
72/// Each variant represents a different type of event that can occur
73/// during a streaming response from an LLM provider.
74#[derive(Debug, Clone)]
75#[non_exhaustive]
76pub enum StreamDelta {
77    /// A text delta for streaming text content.
78    TextDelta {
79        /// The text fragment to append
80        delta: String,
81        /// Index of the content block being streamed
82        block_index: usize,
83    },
84
85    /// A thinking delta for streaming thinking/reasoning content.
86    ThinkingDelta {
87        /// The thinking fragment to append
88        delta: String,
89        /// Index of the content block being streamed
90        block_index: usize,
91    },
92
93    /// Start of a tool use block (name and id are known).
94    ToolUseStart {
95        /// Unique identifier for this tool call
96        id: String,
97        /// Name of the tool being called
98        name: String,
99        /// Index of the content block
100        block_index: usize,
101        /// Optional thought signature (used by Gemini 3.x models)
102        thought_signature: Option<String>,
103    },
104
105    /// Incremental JSON for tool input (partial/incomplete JSON).
106    ToolInputDelta {
107        /// Tool call ID this delta belongs to
108        id: String,
109        /// JSON fragment to append
110        delta: String,
111        /// Index of the content block
112        block_index: usize,
113    },
114
115    /// Usage information (typically at stream end).
116    Usage(Usage),
117
118    /// Stream completed with stop reason.
119    Done {
120        /// Why the stream ended
121        stop_reason: Option<StopReason>,
122    },
123
124    /// A signature delta for a thinking block.
125    SignatureDelta {
126        /// The signature fragment to append
127        delta: String,
128        /// Index of the content block being streamed
129        block_index: usize,
130    },
131
132    /// A redacted thinking block received at `content_block_start`.
133    RedactedThinking {
134        /// Opaque data payload
135        data: String,
136        /// Index of the content block
137        block_index: usize,
138    },
139
140    /// A complete provider-owned reasoning-state item.
141    ///
142    /// Unlike text/thinking deltas this item is not user-visible and must not
143    /// be interpreted. It is carried through the stream solely so agent
144    /// history can replay it to the provider that owns it.
145    OpaqueReasoning {
146        /// Provider protocol that owns the payload.
147        provider: String,
148        /// Exact provider response item to preserve.
149        data: serde_json::Value,
150        /// Index used to retain the provider's output-item ordering.
151        block_index: usize,
152    },
153
154    /// Error during streaming.
155    Error {
156        /// Error message
157        message: String,
158        /// Categorization of the error so downstream consumers can map
159        /// it back to the correct [`agent_sdk_foundation::llm::ChatOutcome`]
160        /// variant or audit-record `TurnAttemptOutcome` without losing
161        /// the rate-limit / server-error / invalid-request distinction.
162        kind: StreamErrorKind,
163    },
164}
165
166/// Classification of a [`StreamDelta::Error`] event.
167///
168/// Mirrors [`ChatOutcome`](agent_sdk_foundation::llm::ChatOutcome)'s error
169/// variants so providers that emit errors via streaming preserve the
170/// same precision that non-streaming `chat()` callers see — every
171/// supported provider can map its underlying error (HTTP status,
172/// validation failure, mid-stream disconnect) directly onto one of
173/// these categories at the construction site.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175#[non_exhaustive]
176pub enum StreamErrorKind {
177    /// Provider returned HTTP 429 / explicit rate-limit signal.
178    RateLimited,
179    /// Provider returned HTTP 5xx, the connection dropped mid-stream,
180    /// or the provider reported a transient runtime failure.
181    ServerError,
182    /// Caller-side error: validation failure before dispatch, HTTP
183    /// 4xx other than 429, or a non-retriable provider rejection.
184    InvalidRequest,
185    /// Escape hatch for a streaming error a provider could not classify
186    /// into one of the categories above.
187    ///
188    /// Producers should prefer a specific variant whenever the
189    /// underlying signal (HTTP status, validation failure, mid-stream
190    /// disconnect) allows it; `Unknown` exists so future error sources
191    /// and providers can be added without a breaking change. It is
192    /// treated as non-recoverable by [`StreamErrorKind::is_recoverable`]
193    /// (callers should not blindly retry an unclassified failure).
194    Unknown,
195}
196
197impl StreamErrorKind {
198    /// `true` when the error is potentially transient and the caller
199    /// may retry.  Rate-limit and server errors are recoverable;
200    /// invalid-request is not.
201    #[must_use]
202    pub const fn is_recoverable(self) -> bool {
203        matches!(self, Self::RateLimited | Self::ServerError)
204    }
205}
206
207/// Type alias for a boxed stream of stream deltas.
208pub type StreamBox<'a> = Pin<Box<dyn Stream<Item = anyhow::Result<StreamDelta>> + Send + 'a>>;
209
210/// Helper to accumulate streamed content into a final response.
211///
212/// This struct collects [`StreamDelta`] events and can convert them
213/// into the final content blocks once the stream is complete.
214#[derive(Debug, Default)]
215pub struct StreamAccumulator {
216    /// Accumulated text for each block index
217    text_blocks: Vec<String>,
218    /// Accumulated thinking blocks for each block index
219    thinking_blocks: Vec<String>,
220    /// Accumulated signatures keyed by block index
221    thinking_signatures: HashMap<usize, String>,
222    /// Redacted thinking blocks: (`block_index`, data)
223    redacted_thinking_blocks: Vec<(usize, String)>,
224    /// Provider-owned opaque reasoning: (`block_index`, provider, data)
225    opaque_reasoning_blocks: Vec<(usize, String, serde_json::Value)>,
226    /// Accumulated tool use calls
227    tool_uses: Vec<ToolUseAccumulator>,
228    /// Usage information from the stream
229    usage: Option<Usage>,
230    /// Stop reason from the stream
231    stop_reason: Option<StopReason>,
232}
233
234/// Accumulator for a single tool use during streaming.
235#[derive(Debug, Default)]
236pub struct ToolUseAccumulator {
237    /// Tool call ID
238    pub id: String,
239    /// Tool name
240    pub name: String,
241    /// Accumulated JSON input (may be incomplete during streaming)
242    pub input_json: String,
243    /// Block index for ordering
244    pub block_index: usize,
245    /// Optional thought signature (used by Gemini 3.x models)
246    pub thought_signature: Option<String>,
247}
248
249impl StreamAccumulator {
250    /// Create a new empty accumulator.
251    #[must_use]
252    pub fn new() -> Self {
253        Self::default()
254    }
255
256    /// Apply a stream delta to the accumulator.
257    pub fn apply(&mut self, delta: &StreamDelta) {
258        match delta {
259            StreamDelta::TextDelta { delta, block_index } => {
260                if *block_index > MAX_BLOCK_INDEX {
261                    log::warn!(
262                        "dropping TextDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
263                    );
264                    return;
265                }
266                while self.text_blocks.len() <= *block_index {
267                    self.text_blocks.push(String::new());
268                }
269                self.text_blocks[*block_index].push_str(delta);
270            }
271            StreamDelta::ThinkingDelta { delta, block_index } => {
272                if *block_index > MAX_BLOCK_INDEX {
273                    log::warn!(
274                        "dropping ThinkingDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
275                    );
276                    return;
277                }
278                while self.thinking_blocks.len() <= *block_index {
279                    self.thinking_blocks.push(String::new());
280                }
281                self.thinking_blocks[*block_index].push_str(delta);
282            }
283            StreamDelta::ToolUseStart {
284                id,
285                name,
286                block_index,
287                thought_signature,
288            } => {
289                self.tool_uses.push(ToolUseAccumulator {
290                    id: id.clone(),
291                    name: name.clone(),
292                    input_json: String::new(),
293                    block_index: *block_index,
294                    thought_signature: thought_signature.clone(),
295                });
296            }
297            StreamDelta::ToolInputDelta { id, delta, .. } => {
298                if let Some(tool) = self.tool_uses.iter_mut().find(|t| t.id == *id) {
299                    tool.input_json.push_str(delta);
300                }
301            }
302            StreamDelta::SignatureDelta { delta, block_index } => {
303                self.thinking_signatures
304                    .entry(*block_index)
305                    .or_default()
306                    .push_str(delta);
307            }
308            StreamDelta::RedactedThinking { data, block_index } => {
309                self.redacted_thinking_blocks
310                    .push((*block_index, data.clone()));
311            }
312            StreamDelta::OpaqueReasoning {
313                provider,
314                data,
315                block_index,
316            } => {
317                self.opaque_reasoning_blocks
318                    .push((*block_index, provider.clone(), data.clone()));
319            }
320            StreamDelta::Usage(u) => {
321                self.usage = Some(u.clone());
322            }
323            StreamDelta::Done { stop_reason } => {
324                self.stop_reason = *stop_reason;
325            }
326            StreamDelta::Error { .. } => {}
327        }
328    }
329
330    /// Get the accumulated usage information.
331    #[must_use]
332    pub const fn usage(&self) -> Option<&Usage> {
333        self.usage.as_ref()
334    }
335
336    /// Get the stop reason.
337    #[must_use]
338    pub const fn stop_reason(&self) -> Option<&StopReason> {
339        self.stop_reason.as_ref()
340    }
341
342    /// Convert accumulated content to `ContentBlock`s.
343    ///
344    /// This consumes the accumulator and returns the final content blocks.
345    /// Tool use JSON is parsed at this point; invalid JSON results in a null input.
346    #[must_use]
347    pub fn into_content_blocks(self) -> Vec<ContentBlock> {
348        let mut blocks: Vec<(usize, ContentBlock)> = Vec::new();
349
350        // Add thinking blocks with their indices, attaching signatures
351        let mut signatures = self.thinking_signatures;
352        for (idx, thinking) in self.thinking_blocks.into_iter().enumerate() {
353            if !thinking.is_empty() {
354                let signature = signatures.remove(&idx).filter(|s| !s.is_empty());
355                blocks.push((
356                    idx,
357                    ContentBlock::Thinking {
358                        thinking,
359                        signature,
360                    },
361                ));
362            }
363        }
364
365        // Add redacted thinking blocks
366        for (idx, data) in self.redacted_thinking_blocks {
367            blocks.push((idx, ContentBlock::RedactedThinking { data }));
368        }
369
370        // Add provider-owned reasoning state without interpreting its payload.
371        for (idx, provider, data) in self.opaque_reasoning_blocks {
372            blocks.push((idx, ContentBlock::OpaqueReasoning { provider, data }));
373        }
374
375        // Add text blocks with their indices
376        for (idx, text) in self.text_blocks.into_iter().enumerate() {
377            if !text.is_empty() {
378                blocks.push((idx, ContentBlock::Text { text }));
379            }
380        }
381
382        // Add tool uses with their indices
383        for tool in self.tool_uses {
384            let input: serde_json::Value =
385                serde_json::from_str(&tool.input_json).unwrap_or_else(|e| {
386                    log::warn!(
387                        "Failed to parse streamed tool input JSON for tool '{}' (id={}): {} — \
388                         input_json ({} bytes): '{}'",
389                        tool.name,
390                        tool.id,
391                        e,
392                        tool.input_json.len(),
393                        tool.input_json.chars().take(500).collect::<String>(),
394                    );
395                    serde_json::json!({})
396                });
397            blocks.push((
398                tool.block_index,
399                ContentBlock::ToolUse {
400                    id: tool.id,
401                    name: tool.name,
402                    input,
403                    thought_signature: tool.thought_signature,
404                },
405            ));
406        }
407
408        // Sort by block index to maintain order
409        blocks.sort_by_key(|(idx, _)| *idx);
410
411        blocks.into_iter().map(|(_, block)| block).collect()
412    }
413
414    /// Take ownership of accumulated usage.
415    pub const fn take_usage(&mut self) -> Option<Usage> {
416        self.usage.take()
417    }
418
419    /// Take ownership of stop reason.
420    pub const fn take_stop_reason(&mut self) -> Option<StopReason> {
421        self.stop_reason.take()
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn test_accumulator_text_deltas() {
431        let mut acc = StreamAccumulator::new();
432
433        acc.apply(&StreamDelta::TextDelta {
434            delta: "Hello".to_string(),
435            block_index: 0,
436        });
437        acc.apply(&StreamDelta::TextDelta {
438            delta: " world".to_string(),
439            block_index: 0,
440        });
441
442        let blocks = acc.into_content_blocks();
443        assert_eq!(blocks.len(), 1);
444        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello world"));
445    }
446
447    #[test]
448    fn test_accumulator_multiple_text_blocks() {
449        let mut acc = StreamAccumulator::new();
450
451        acc.apply(&StreamDelta::TextDelta {
452            delta: "First".to_string(),
453            block_index: 0,
454        });
455        acc.apply(&StreamDelta::TextDelta {
456            delta: "Second".to_string(),
457            block_index: 1,
458        });
459
460        let blocks = acc.into_content_blocks();
461        assert_eq!(blocks.len(), 2);
462        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "First"));
463        assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Second"));
464    }
465
466    #[test]
467    fn test_accumulator_thinking_signature() {
468        let mut acc = StreamAccumulator::new();
469
470        acc.apply(&StreamDelta::ThinkingDelta {
471            delta: "Reasoning".to_string(),
472            block_index: 0,
473        });
474        acc.apply(&StreamDelta::SignatureDelta {
475            delta: "sig_123".to_string(),
476            block_index: 0,
477        });
478
479        let blocks = acc.into_content_blocks();
480        assert_eq!(blocks.len(), 1);
481        assert!(matches!(
482            &blocks[0],
483            ContentBlock::Thinking { thinking, signature }
484            if thinking == "Reasoning" && signature.as_deref() == Some("sig_123")
485        ));
486    }
487
488    #[test]
489    fn accumulator_preserves_opaque_reasoning_payload_and_order() {
490        let mut acc = StreamAccumulator::new();
491        acc.apply(&StreamDelta::TextDelta {
492            delta: "visible".to_owned(),
493            block_index: 2,
494        });
495        acc.apply(&StreamDelta::OpaqueReasoning {
496            provider: "test-provider".to_owned(),
497            data: serde_json::json!({
498                "id": "reasoning_1",
499                "encrypted_content": "do-not-inspect"
500            }),
501            block_index: 1,
502        });
503
504        let blocks = acc.into_content_blocks();
505        assert_eq!(blocks.len(), 2);
506        assert!(matches!(
507            &blocks[0],
508            ContentBlock::OpaqueReasoning { provider, data }
509                if provider == "test-provider"
510                    && data["id"] == "reasoning_1"
511                    && data["encrypted_content"] == "do-not-inspect"
512        ));
513        assert!(matches!(
514            &blocks[1],
515            ContentBlock::Text { text } if text == "visible"
516        ));
517    }
518
519    #[test]
520    fn test_accumulator_tool_use() {
521        let mut acc = StreamAccumulator::new();
522
523        acc.apply(&StreamDelta::ToolUseStart {
524            id: "call_123".to_string(),
525            name: "read_file".to_string(),
526            block_index: 0,
527            thought_signature: None,
528        });
529        acc.apply(&StreamDelta::ToolInputDelta {
530            id: "call_123".to_string(),
531            delta: r#"{"path":"#.to_string(),
532            block_index: 0,
533        });
534        acc.apply(&StreamDelta::ToolInputDelta {
535            id: "call_123".to_string(),
536            delta: r#""test.txt"}"#.to_string(),
537            block_index: 0,
538        });
539
540        let blocks = acc.into_content_blocks();
541        assert_eq!(blocks.len(), 1);
542        match &blocks[0] {
543            ContentBlock::ToolUse {
544                id, name, input, ..
545            } => {
546                assert_eq!(id, "call_123");
547                assert_eq!(name, "read_file");
548                assert_eq!(input["path"], "test.txt");
549            }
550            _ => panic!("Expected ToolUse block"),
551        }
552    }
553
554    #[test]
555    fn test_accumulator_mixed_content() {
556        let mut acc = StreamAccumulator::new();
557
558        acc.apply(&StreamDelta::TextDelta {
559            delta: "Let me read that file.".to_string(),
560            block_index: 0,
561        });
562        acc.apply(&StreamDelta::ToolUseStart {
563            id: "call_456".to_string(),
564            name: "read_file".to_string(),
565            block_index: 1,
566            thought_signature: None,
567        });
568        acc.apply(&StreamDelta::ToolInputDelta {
569            id: "call_456".to_string(),
570            delta: r#"{"path":"file.txt"}"#.to_string(),
571            block_index: 1,
572        });
573        acc.apply(&StreamDelta::Usage(Usage {
574            input_tokens: 100,
575            output_tokens: 50,
576            cached_input_tokens: 0,
577            cache_creation_input_tokens: 0,
578        }));
579        acc.apply(&StreamDelta::Done {
580            stop_reason: Some(StopReason::ToolUse),
581        });
582
583        assert!(acc.usage().is_some());
584        assert_eq!(acc.usage().map(|u| u.input_tokens), Some(100));
585        assert!(matches!(acc.stop_reason(), Some(StopReason::ToolUse)));
586
587        let blocks = acc.into_content_blocks();
588        assert_eq!(blocks.len(), 2);
589        assert!(matches!(&blocks[0], ContentBlock::Text { .. }));
590        assert!(matches!(&blocks[1], ContentBlock::ToolUse { .. }));
591    }
592
593    #[test]
594    fn test_accumulator_invalid_tool_json() {
595        let mut acc = StreamAccumulator::new();
596
597        acc.apply(&StreamDelta::ToolUseStart {
598            id: "call_789".to_string(),
599            name: "test_tool".to_string(),
600            block_index: 0,
601            thought_signature: None,
602        });
603        acc.apply(&StreamDelta::ToolInputDelta {
604            id: "call_789".to_string(),
605            delta: "invalid json {".to_string(),
606            block_index: 0,
607        });
608
609        let blocks = acc.into_content_blocks();
610        assert_eq!(blocks.len(), 1);
611        match &blocks[0] {
612            ContentBlock::ToolUse { input, .. } => {
613                assert!(input.is_object());
614            }
615            _ => panic!("Expected ToolUse block"),
616        }
617    }
618
619    #[test]
620    fn test_accumulator_empty_tool_input_falls_back_to_empty_object() {
621        // If no ToolInputDelta is received (e.g., stream interrupted or
622        // deltas had mismatched IDs), the tool use block should still be
623        // produced with an empty object so that the error is attributable
624        // to the tool rather than silently lost.
625        let mut acc = StreamAccumulator::new();
626
627        acc.apply(&StreamDelta::ToolUseStart {
628            id: "call_empty".to_string(),
629            name: "read".to_string(),
630            block_index: 0,
631            thought_signature: None,
632        });
633        // No ToolInputDelta applied
634
635        let blocks = acc.into_content_blocks();
636        assert_eq!(blocks.len(), 1);
637        match &blocks[0] {
638            ContentBlock::ToolUse { input, name, .. } => {
639                assert_eq!(name, "read");
640                assert_eq!(input, &serde_json::json!({}));
641            }
642            _ => panic!("Expected ToolUse block"),
643        }
644    }
645
646    #[test]
647    fn test_accumulator_mismatched_delta_id_drops_input() {
648        // If ToolInputDelta has a different ID than any ToolUseStart,
649        // the input is silently dropped (the tool gets empty {}).
650        let mut acc = StreamAccumulator::new();
651
652        acc.apply(&StreamDelta::ToolUseStart {
653            id: "call_A".to_string(),
654            name: "bash".to_string(),
655            block_index: 0,
656            thought_signature: None,
657        });
658        // Delta with wrong ID
659        acc.apply(&StreamDelta::ToolInputDelta {
660            id: "call_B".to_string(),
661            delta: r#"{"command":"ls"}"#.to_string(),
662            block_index: 0,
663        });
664
665        let blocks = acc.into_content_blocks();
666        assert_eq!(blocks.len(), 1);
667        match &blocks[0] {
668            ContentBlock::ToolUse { input, .. } => {
669                // Input should be empty because the delta had a mismatched ID
670                assert_eq!(input, &serde_json::json!({}));
671            }
672            _ => panic!("Expected ToolUse block"),
673        }
674    }
675
676    #[test]
677    fn test_accumulator_empty() {
678        let acc = StreamAccumulator::new();
679        let blocks = acc.into_content_blocks();
680        assert!(blocks.is_empty());
681    }
682
683    #[test]
684    fn test_accumulator_skips_empty_text() {
685        let mut acc = StreamAccumulator::new();
686
687        acc.apply(&StreamDelta::TextDelta {
688            delta: String::new(),
689            block_index: 0,
690        });
691        acc.apply(&StreamDelta::TextDelta {
692            delta: "Hello".to_string(),
693            block_index: 1,
694        });
695
696        let blocks = acc.into_content_blocks();
697        assert_eq!(blocks.len(), 1);
698        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello"));
699    }
700
701    #[test]
702    fn test_accumulator_ignores_out_of_range_block_index() {
703        // A hostile/corrupted event with a huge block_index must not drive an
704        // unbounded Vec allocation. The delta is dropped, leaving the accumulator
705        // tiny rather than allocating billions of empty Strings.
706        let mut acc = StreamAccumulator::new();
707
708        acc.apply(&StreamDelta::TextDelta {
709            delta: "ok".to_string(),
710            block_index: 0,
711        });
712        acc.apply(&StreamDelta::TextDelta {
713            delta: "boom".to_string(),
714            block_index: usize::MAX,
715        });
716        acc.apply(&StreamDelta::ThinkingDelta {
717            delta: "boom".to_string(),
718            block_index: usize::MAX,
719        });
720
721        let blocks = acc.into_content_blocks();
722        assert_eq!(blocks.len(), 1);
723        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "ok"));
724    }
725
726    #[cfg(any(feature = "openai", feature = "openai-codex"))]
727    #[test]
728    fn test_sse_line_buffer_splits_multiple_lines() {
729        let mut buf = SseLineBuffer::new();
730        buf.extend(b"data: one\ndata: two\n");
731        assert_eq!(buf.next_line().as_deref(), Some("data: one"));
732        assert_eq!(buf.next_line().as_deref(), Some("data: two"));
733        assert_eq!(buf.next_line(), None);
734    }
735
736    #[cfg(any(feature = "openai", feature = "openai-codex"))]
737    #[test]
738    fn test_sse_line_buffer_buffers_partial_line_until_newline() {
739        let mut buf = SseLineBuffer::new();
740        buf.extend(b"data: par");
741        assert_eq!(buf.next_line(), None);
742        buf.extend(b"tial\n");
743        assert_eq!(buf.next_line().as_deref(), Some("data: partial"));
744    }
745
746    #[cfg(any(feature = "openai", feature = "openai-codex"))]
747    #[test]
748    fn test_sse_line_buffer_handles_utf8_split_across_chunks() {
749        // "café" — the 'é' is the two bytes 0xC3 0xA9. Split the chunk boundary
750        // *inside* that character: the naive per-chunk from_utf8_lossy would emit
751        // a U+FFFD replacement char; the line buffer must decode it losslessly
752        // because it only decodes the complete line.
753        let mut buf = SseLineBuffer::new();
754        let line = "data: café\n";
755        let bytes = line.as_bytes();
756        let split = bytes.len() - 2; // between 0xC3 and 0xA9
757        buf.extend(&bytes[..split]);
758        assert_eq!(buf.next_line(), None);
759        buf.extend(&bytes[split..]);
760        assert_eq!(buf.next_line().as_deref(), Some("data: café"));
761    }
762}