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;
13use std::time::Duration;
14
15/// Upper bound on the block index [`StreamAccumulator`] will materialize.
16///
17/// `block_index` is taken verbatim from provider wire data (the SSE `index`
18/// field) and `base_url` is user-configurable (any OpenAI-compatible endpoint),
19/// so a corrupted or hostile event carrying a huge index could otherwise drive
20/// an unbounded `Vec` allocation and exhaust host memory. Text/thinking deltas
21/// whose index exceeds this bound are dropped with a warning rather than grown
22/// into.
23const MAX_BLOCK_INDEX: usize = 4096;
24
25/// Incremental splitter for line-delimited SSE byte streams.
26///
27/// `reqwest`'s `bytes_stream` yields arbitrary byte boundaries, so a multi-byte
28/// UTF-8 character can land split across two network chunks. Decoding each raw
29/// chunk independently with `String::from_utf8_lossy` permanently corrupts such
30/// characters into `U+FFFD` in user-visible text deltas. This buffer instead
31/// accumulates raw bytes and only UTF-8-decodes *complete* lines (terminated by
32/// `\n`); because a newline byte (`0x0A`) can never be part of a multi-byte
33/// UTF-8 sequence, the end of a complete line is always a valid character
34/// boundary and decodes losslessly.
35///
36/// It also avoids the quadratic `buffer = buffer[pos + 1..].to_string()` copy of
37/// the naive splitter: [`BytesMut::split_to`] advances the read cursor without
38/// copying the unconsumed tail, so splitting is amortized O(1) per line instead
39/// of O(remaining-buffer).
40#[cfg(any(feature = "openai", feature = "openai-codex"))]
41#[derive(Debug, Default)]
42pub(crate) struct SseLineBuffer {
43    buf: BytesMut,
44}
45
46#[cfg(any(feature = "openai", feature = "openai-codex"))]
47impl SseLineBuffer {
48    /// Create an empty buffer.
49    #[must_use]
50    pub(crate) fn new() -> Self {
51        Self::default()
52    }
53
54    /// Append a freshly received network chunk.
55    pub(crate) fn extend(&mut self, chunk: &[u8]) {
56        self.buf.extend_from_slice(chunk);
57    }
58
59    /// Pop the next complete line (without its trailing `\n`), or `None` when no
60    /// full line is buffered yet. Incomplete trailing bytes — including a
61    /// multi-byte character split across a chunk boundary — stay buffered for the
62    /// next call.
63    pub(crate) fn next_line(&mut self) -> Option<String> {
64        let newline = self.buf.iter().position(|&b| b == b'\n')?;
65        let mut line = self.buf.split_to(newline + 1);
66        line.truncate(newline);
67        Some(String::from_utf8_lossy(&line).into_owned())
68    }
69}
70
71/// Events yielded during streaming LLM responses.
72///
73/// Each variant represents a different type of event that can occur
74/// during a streaming response from an LLM provider.
75#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub enum StreamDelta {
78    /// A text delta for streaming text content.
79    TextDelta {
80        /// The text fragment to append
81        delta: String,
82        /// Index of the content block being streamed
83        block_index: usize,
84    },
85
86    /// A thinking delta for streaming thinking/reasoning content.
87    ThinkingDelta {
88        /// The thinking fragment to append
89        delta: String,
90        /// Index of the content block being streamed
91        block_index: usize,
92    },
93
94    /// Start of a tool use block (name and id are known).
95    ToolUseStart {
96        /// Unique identifier for this tool call
97        id: String,
98        /// Name of the tool being called
99        name: String,
100        /// Index of the content block
101        block_index: usize,
102        /// Optional thought signature (used by Gemini 3.x models)
103        thought_signature: Option<String>,
104    },
105
106    /// Incremental JSON for tool input (partial/incomplete JSON).
107    ToolInputDelta {
108        /// Tool call ID this delta belongs to
109        id: String,
110        /// JSON fragment to append
111        delta: String,
112        /// Index of the content block
113        block_index: usize,
114    },
115
116    /// Usage information (typically at stream end).
117    Usage(Usage),
118
119    /// Stream completed with stop reason.
120    Done {
121        /// Why the stream ended
122        stop_reason: Option<StopReason>,
123        /// The [`LlmProvider::route`](crate::provider::LlmProvider::route) of
124        /// the provider that actually served this stream. Concrete providers
125        /// stamp their own route; wrappers (fallback / router / refresh)
126        /// forward it untouched, so after a mid-chain failover it names the
127        /// backend that produced the outcome, not the configured primary.
128        /// `None` from providers that predate the field — consumers fall back
129        /// to the dispatch handle's `route()`.
130        served_route: Option<String>,
131    },
132
133    /// A signature delta for a thinking block.
134    SignatureDelta {
135        /// The signature fragment to append
136        delta: String,
137        /// Index of the content block being streamed
138        block_index: usize,
139    },
140
141    /// A redacted thinking block received at `content_block_start`.
142    RedactedThinking {
143        /// Opaque data payload
144        data: String,
145        /// Index of the content block
146        block_index: usize,
147    },
148
149    /// A complete provider-owned reasoning-state item.
150    ///
151    /// Unlike text/thinking deltas this item is not user-visible and must not
152    /// be interpreted. It is carried through the stream solely so agent
153    /// history can replay it to the provider that owns it.
154    OpaqueReasoning {
155        /// Provider protocol that owns the payload.
156        provider: String,
157        /// Exact provider response item to preserve.
158        data: serde_json::Value,
159        /// Index used to retain the provider's output-item ordering.
160        block_index: usize,
161    },
162
163    /// Error during streaming.
164    Error {
165        /// Error message
166        message: String,
167        /// Categorization of the error so downstream consumers can map
168        /// it back to the correct [`agent_sdk_foundation::llm::ChatOutcome`]
169        /// variant or audit-record `TurnAttemptOutcome` without losing
170        /// the rate-limit / server-error / invalid-request distinction.
171        kind: StreamErrorKind,
172    },
173}
174
175/// Classification of a [`StreamDelta::Error`] event.
176///
177/// Mirrors [`ChatOutcome`](agent_sdk_foundation::llm::ChatOutcome)'s error
178/// variants so providers that emit errors via streaming preserve the
179/// same precision that non-streaming `chat()` callers see — every
180/// supported provider can map its underlying error (HTTP status,
181/// validation failure, mid-stream disconnect) directly onto one of
182/// these categories at the construction site.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184#[non_exhaustive]
185pub enum StreamErrorKind {
186    /// The request could not establish a provider connection because DNS,
187    /// routing, or the network is unavailable. Callers may wait indefinitely
188    /// for connectivity, provided cancellation remains cooperative.
189    Connectivity,
190    /// An established provider response stream lost its underlying network
191    /// connection. It has the same wait policy as [`Self::Connectivity`], but
192    /// durable runtimes must close that provider call's audit attempt before
193    /// retrying it.
194    ConnectionLost,
195    /// Provider returned HTTP 429 / explicit rate-limit signal.
196    ///
197    /// Carries the server-supplied retry delay when the provider gave one —
198    /// a `Retry-After` header, or a hint embedded in the error body (Gemini's
199    /// `google.rpc.RetryInfo`, `OpenAI`'s "try again in 20s"). `None` when the
200    /// provider supplied no usable hint, in which case callers use backoff.
201    RateLimited(Option<Duration>),
202    /// Provider returned HTTP 5xx or reported a transient runtime failure.
203    ServerError,
204    /// Caller-side error: validation failure before dispatch, HTTP
205    /// 4xx other than 429, or a non-retriable provider rejection.
206    InvalidRequest,
207    /// Escape hatch for a streaming error a provider could not classify
208    /// into one of the categories above.
209    ///
210    /// Producers should prefer a specific variant whenever the
211    /// underlying signal (HTTP status, validation failure, mid-stream
212    /// disconnect) allows it; `Unknown` exists so future error sources
213    /// and providers can be added without a breaking change. It is
214    /// treated as non-recoverable by [`StreamErrorKind::is_recoverable`]
215    /// (callers should not blindly retry an unclassified failure).
216    Unknown,
217}
218
219impl StreamErrorKind {
220    /// `true` when the error is potentially transient and the caller
221    /// may retry. Connectivity, rate-limit, and server errors are
222    /// recoverable; invalid-request is not.
223    #[must_use]
224    pub const fn is_recoverable(self) -> bool {
225        matches!(
226            self,
227            Self::Connectivity | Self::ConnectionLost | Self::RateLimited(_) | Self::ServerError
228        )
229    }
230
231    /// The server-supplied retry delay carried by a rate-limit error, if any.
232    #[must_use]
233    pub const fn retry_after(self) -> Option<Duration> {
234        match self {
235            Self::RateLimited(retry_after) => retry_after,
236            _ => None,
237        }
238    }
239
240    /// `true` for failures governed by the unbounded, cancellable offline wait.
241    #[must_use]
242    pub const fn is_connectivity(self) -> bool {
243        matches!(self, Self::Connectivity | Self::ConnectionLost)
244    }
245
246    /// Stable `snake_case` label naming this failure class on the wire.
247    ///
248    /// Durable runtimes persist this on a task's terminal reason and
249    /// republish it as `TerminalReason.provider_error_kind`, so operators
250    /// can group terminals by provider failure class. The match is
251    /// exhaustive **inside this crate** — `#[non_exhaustive]` only binds
252    /// downstream — so adding a variant is a compile error here rather
253    /// than a silent relabel at a distant consumer.
254    ///
255    /// The rate-limit delay hint is deliberately dropped: the label is a
256    /// grouping key, and folding a per-response duration into it would
257    /// produce an unbounded cardinality of "kinds".
258    #[must_use]
259    pub const fn wire_label(self) -> &'static str {
260        match self {
261            Self::Connectivity => "connectivity",
262            Self::ConnectionLost => "connection_lost",
263            Self::RateLimited(_) => "rate_limited",
264            Self::ServerError => "server_error",
265            Self::InvalidRequest => "invalid_request",
266            Self::Unknown => "unknown",
267        }
268    }
269}
270
271/// Classify a typed HTTP client failure without relying on display text.
272#[must_use]
273pub fn classify_reqwest_error(error: &reqwest::Error) -> StreamErrorKind {
274    if is_proxy_tunnel_rejection(error) || is_tls_rejection(error) {
275        StreamErrorKind::ServerError
276    } else if error.is_connect() {
277        StreamErrorKind::Connectivity
278    } else if error.is_timeout() || has_connectivity_io_source(error) {
279        StreamErrorKind::ConnectionLost
280    } else {
281        StreamErrorKind::ServerError
282    }
283}
284
285fn is_proxy_tunnel_rejection(error: &reqwest::Error) -> bool {
286    if error.status() == Some(reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED) {
287        return true;
288    }
289    let mut source = std::error::Error::source(error);
290    while let Some(cause) = source {
291        let message = cause.to_string();
292        if message.contains("tunnel error: unsuccessful")
293            || message.contains("proxy authorization required")
294        {
295            return true;
296        }
297        source = cause.source();
298    }
299    false
300}
301
302/// `true` when a TLS peer answered the handshake and rejected the session
303/// (certificate validation, hostname mismatch, protocol or cipher
304/// negotiation, an interception proxy presenting the wrong identity, …).
305///
306/// A peer that speaks TLS at us is reachable, so these are bounded server
307/// errors, never connectivity waits: retrying cannot fix a policy or
308/// configuration rejection, and misreading one as "offline" would park the
309/// caller in an indefinite wait on a failure that is deterministic. The one
310/// exception is a TLS-wrapped *transport* death — a socket that EOFs or
311/// resets mid-handshake — which stays on the connectivity path.
312fn is_tls_rejection(error: &reqwest::Error) -> bool {
313    if has_connectivity_io_source(error) {
314        return false;
315    }
316    let mut source = std::error::Error::source(error);
317    while let Some(cause) = source {
318        if cause.downcast_ref::<native_tls::Error>().is_some() {
319            let message = cause.to_string().to_ascii_lowercase();
320            let transport_death = ["eof", "close", "reset", "broken pipe", "timed out"]
321                .iter()
322                .any(|marker| message.contains(marker));
323            return !transport_death;
324        }
325        source = cause.source();
326    }
327    false
328}
329
330fn has_connectivity_io_source(error: &reqwest::Error) -> bool {
331    let mut source = std::error::Error::source(error);
332    while let Some(cause) = source {
333        if let Some(io_error) = cause.downcast_ref::<std::io::Error>()
334            && matches!(
335                io_error.kind(),
336                std::io::ErrorKind::NotConnected
337                    | std::io::ErrorKind::ConnectionRefused
338                    | std::io::ErrorKind::ConnectionReset
339                    | std::io::ErrorKind::ConnectionAborted
340                    | std::io::ErrorKind::BrokenPipe
341                    | std::io::ErrorKind::UnexpectedEof
342                    | std::io::ErrorKind::TimedOut
343                    | std::io::ErrorKind::NetworkDown
344                    | std::io::ErrorKind::NetworkUnreachable
345                    | std::io::ErrorKind::HostUnreachable
346            )
347        {
348            return true;
349        }
350        source = cause.source();
351    }
352    false
353}
354
355#[must_use]
356pub fn reqwest_error_delta(context: &str, error: &reqwest::Error) -> StreamDelta {
357    StreamDelta::Error {
358        message: format!("{context}: {error}"),
359        kind: classify_reqwest_error(error),
360    }
361}
362
363#[must_use]
364pub fn reqwest_body_error_delta(context: &str, error: &reqwest::Error) -> StreamDelta {
365    let kind = match classify_reqwest_error(error) {
366        StreamErrorKind::Connectivity => StreamErrorKind::ConnectionLost,
367        other => other,
368    };
369    StreamDelta::Error {
370        message: format!("{context}: {error}"),
371        kind,
372    }
373}
374
375/// Type alias for a boxed stream of stream deltas.
376pub type StreamBox<'a> = Pin<Box<dyn Stream<Item = anyhow::Result<StreamDelta>> + Send + 'a>>;
377
378/// Sum two usage readings, saturating so token counters never wrap.
379fn add_usage(carried: Option<&Usage>, usage: &Usage) -> Usage {
380    let Some(carried) = carried else {
381        return usage.clone();
382    };
383    Usage {
384        input_tokens: carried.input_tokens.saturating_add(usage.input_tokens),
385        output_tokens: carried.output_tokens.saturating_add(usage.output_tokens),
386        cached_input_tokens: carried
387            .cached_input_tokens
388            .saturating_add(usage.cached_input_tokens),
389        cache_creation_input_tokens: carried
390            .cache_creation_input_tokens
391            .saturating_add(usage.cache_creation_input_tokens),
392    }
393}
394
395/// Preserves usage across a stream-splicing wrapper's attempt boundary.
396///
397/// A wrapper that abandons one inner stream and splices in another (failover,
398/// credential refresh) has a hazard: [`StreamAccumulator`] keeps only the LAST
399/// `Usage` delta it sees, so the retried stream's usage would erase the
400/// abandoned one — silently un-billing tokens the provider already charged for.
401///
402/// The carry closes that: on every outgoing `Usage` delta, the wrapper calls
403/// [`running_total`](Self::running_total) to rewrite it to the sum of all
404/// abandoned attempts plus this stream's latest reading; when it abandons a
405/// stream to retry, it calls [`abandon`](Self::abandon) to fold that stream's
406/// usage into the carry. The final delta the consumer sees — the only one the
407/// accumulator keeps — is therefore the true total across every attempt.
408#[derive(Default)]
409pub(crate) struct UsageCarry {
410    /// Usage billed by abandoned attempts.
411    carried: Option<Usage>,
412    /// The current attempt's latest usage reading (last-wins within an attempt).
413    current: Option<Usage>,
414}
415
416impl UsageCarry {
417    pub(crate) const fn new() -> Self {
418        Self {
419            carried: None,
420            current: None,
421        }
422    }
423
424    /// Record `usage` as the current attempt's reading and return the running
425    /// total (abandoned attempts + this reading) to yield in its place.
426    pub(crate) fn running_total(&mut self, usage: Usage) -> Usage {
427        let total = add_usage(self.carried.as_ref(), &usage);
428        self.current = Some(usage);
429        total
430    }
431
432    /// Fold the current attempt's usage into the carry because its stream is
433    /// being abandoned for a retry.
434    pub(crate) fn abandon(&mut self) {
435        if let Some(current) = self.current.take() {
436            self.carried = Some(add_usage(self.carried.as_ref(), &current));
437        }
438    }
439}
440
441/// Helper to accumulate streamed content into a final response.
442///
443/// This struct collects [`StreamDelta`] events and can convert them
444/// into the final content blocks once the stream is complete.
445#[derive(Debug, Default)]
446pub struct StreamAccumulator {
447    /// Accumulated text for each block index
448    text_blocks: Vec<String>,
449    /// Accumulated thinking blocks for each block index
450    thinking_blocks: Vec<String>,
451    /// Accumulated signatures keyed by block index
452    thinking_signatures: HashMap<usize, String>,
453    /// Redacted thinking blocks: (`block_index`, data)
454    redacted_thinking_blocks: Vec<(usize, String)>,
455    /// Provider-owned opaque reasoning: (`block_index`, provider, data)
456    opaque_reasoning_blocks: Vec<(usize, String, serde_json::Value)>,
457    /// Accumulated tool use calls
458    tool_uses: Vec<ToolUseAccumulator>,
459    /// Usage information from the stream
460    usage: Option<Usage>,
461    /// Stop reason from the stream
462    stop_reason: Option<StopReason>,
463    /// Serving route reported by the stream's `Done` marker
464    served_route: Option<String>,
465}
466
467/// Accumulator for a single tool use during streaming.
468#[derive(Debug, Default)]
469pub struct ToolUseAccumulator {
470    /// Tool call ID
471    pub id: String,
472    /// Tool name
473    pub name: String,
474    /// Accumulated JSON input (may be incomplete during streaming)
475    pub input_json: String,
476    /// Block index for ordering
477    pub block_index: usize,
478    /// Optional thought signature (used by Gemini 3.x models)
479    pub thought_signature: Option<String>,
480}
481
482impl StreamAccumulator {
483    /// Create a new empty accumulator.
484    #[must_use]
485    pub fn new() -> Self {
486        Self::default()
487    }
488
489    /// Apply a stream delta to the accumulator.
490    pub fn apply(&mut self, delta: &StreamDelta) {
491        match delta {
492            StreamDelta::TextDelta { delta, block_index } => {
493                if *block_index > MAX_BLOCK_INDEX {
494                    log::warn!(
495                        "dropping TextDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
496                    );
497                    return;
498                }
499                while self.text_blocks.len() <= *block_index {
500                    self.text_blocks.push(String::new());
501                }
502                self.text_blocks[*block_index].push_str(delta);
503            }
504            StreamDelta::ThinkingDelta { delta, block_index } => {
505                if *block_index > MAX_BLOCK_INDEX {
506                    log::warn!(
507                        "dropping ThinkingDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
508                    );
509                    return;
510                }
511                while self.thinking_blocks.len() <= *block_index {
512                    self.thinking_blocks.push(String::new());
513                }
514                self.thinking_blocks[*block_index].push_str(delta);
515            }
516            StreamDelta::ToolUseStart {
517                id,
518                name,
519                block_index,
520                thought_signature,
521            } => {
522                self.tool_uses.push(ToolUseAccumulator {
523                    id: id.clone(),
524                    name: name.clone(),
525                    input_json: String::new(),
526                    block_index: *block_index,
527                    thought_signature: thought_signature.clone(),
528                });
529            }
530            StreamDelta::ToolInputDelta { id, delta, .. } => {
531                if let Some(tool) = self.tool_uses.iter_mut().find(|t| t.id == *id) {
532                    tool.input_json.push_str(delta);
533                }
534            }
535            StreamDelta::SignatureDelta { delta, block_index } => {
536                self.thinking_signatures
537                    .entry(*block_index)
538                    .or_default()
539                    .push_str(delta);
540            }
541            StreamDelta::RedactedThinking { data, block_index } => {
542                self.redacted_thinking_blocks
543                    .push((*block_index, data.clone()));
544            }
545            StreamDelta::OpaqueReasoning {
546                provider,
547                data,
548                block_index,
549            } => {
550                self.opaque_reasoning_blocks
551                    .push((*block_index, provider.clone(), data.clone()));
552            }
553            StreamDelta::Usage(u) => {
554                self.usage = Some(u.clone());
555            }
556            StreamDelta::Done {
557                stop_reason,
558                served_route,
559            } => {
560                self.stop_reason = *stop_reason;
561                self.served_route.clone_from(served_route);
562            }
563            StreamDelta::Error { .. } => {}
564        }
565    }
566
567    /// Get the accumulated usage information.
568    #[must_use]
569    pub const fn usage(&self) -> Option<&Usage> {
570        self.usage.as_ref()
571    }
572
573    /// Get the stop reason.
574    #[must_use]
575    pub const fn stop_reason(&self) -> Option<&StopReason> {
576        self.stop_reason.as_ref()
577    }
578
579    /// The serving route the stream's `Done` marker reported, if any.
580    #[must_use]
581    pub fn served_route(&self) -> Option<&str> {
582        self.served_route.as_deref()
583    }
584
585    /// Convert accumulated content to `ContentBlock`s.
586    ///
587    /// This consumes the accumulator and returns the final content blocks.
588    /// Tool use JSON is parsed at this point; invalid JSON results in a null input.
589    #[must_use]
590    pub fn into_content_blocks(self) -> Vec<ContentBlock> {
591        let mut blocks: Vec<(usize, ContentBlock)> = Vec::new();
592
593        // Add thinking blocks with their indices, attaching signatures
594        let mut signatures = self.thinking_signatures;
595        for (idx, thinking) in self.thinking_blocks.into_iter().enumerate() {
596            if !thinking.is_empty() {
597                let signature = signatures.remove(&idx).filter(|s| !s.is_empty());
598                blocks.push((
599                    idx,
600                    ContentBlock::Thinking {
601                        thinking,
602                        signature,
603                    },
604                ));
605            }
606        }
607
608        // Add redacted thinking blocks
609        for (idx, data) in self.redacted_thinking_blocks {
610            blocks.push((idx, ContentBlock::RedactedThinking { data }));
611        }
612
613        // Add provider-owned reasoning state without interpreting its payload.
614        for (idx, provider, data) in self.opaque_reasoning_blocks {
615            blocks.push((idx, ContentBlock::OpaqueReasoning { provider, data }));
616        }
617
618        // Add text blocks with their indices
619        for (idx, text) in self.text_blocks.into_iter().enumerate() {
620            if !text.is_empty() {
621                blocks.push((idx, ContentBlock::Text { text }));
622            }
623        }
624
625        // Add tool uses with their indices
626        for tool in self.tool_uses {
627            let input: serde_json::Value =
628                serde_json::from_str(&tool.input_json).unwrap_or_else(|e| {
629                    log::warn!(
630                        "Failed to parse streamed tool input JSON for tool '{}' (id={}): {} — \
631                         input_json ({} bytes): '{}'",
632                        tool.name,
633                        tool.id,
634                        e,
635                        tool.input_json.len(),
636                        tool.input_json.chars().take(500).collect::<String>(),
637                    );
638                    serde_json::json!({})
639                });
640            blocks.push((
641                tool.block_index,
642                ContentBlock::ToolUse {
643                    id: tool.id,
644                    name: tool.name,
645                    input,
646                    thought_signature: tool.thought_signature,
647                },
648            ));
649        }
650
651        // Sort by block index to maintain order
652        blocks.sort_by_key(|(idx, _)| *idx);
653
654        blocks.into_iter().map(|(_, block)| block).collect()
655    }
656
657    /// Take ownership of accumulated usage.
658    pub const fn take_usage(&mut self) -> Option<Usage> {
659        self.usage.take()
660    }
661
662    /// Take ownership of stop reason.
663    pub const fn take_stop_reason(&mut self) -> Option<StopReason> {
664        self.stop_reason.take()
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_accumulator_text_deltas() {
674        let mut acc = StreamAccumulator::new();
675
676        acc.apply(&StreamDelta::TextDelta {
677            delta: "Hello".to_string(),
678            block_index: 0,
679        });
680        acc.apply(&StreamDelta::TextDelta {
681            delta: " world".to_string(),
682            block_index: 0,
683        });
684
685        let blocks = acc.into_content_blocks();
686        assert_eq!(blocks.len(), 1);
687        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello world"));
688    }
689
690    #[test]
691    fn test_accumulator_multiple_text_blocks() {
692        let mut acc = StreamAccumulator::new();
693
694        acc.apply(&StreamDelta::TextDelta {
695            delta: "First".to_string(),
696            block_index: 0,
697        });
698        acc.apply(&StreamDelta::TextDelta {
699            delta: "Second".to_string(),
700            block_index: 1,
701        });
702
703        let blocks = acc.into_content_blocks();
704        assert_eq!(blocks.len(), 2);
705        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "First"));
706        assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Second"));
707    }
708
709    #[test]
710    fn test_accumulator_thinking_signature() {
711        let mut acc = StreamAccumulator::new();
712
713        acc.apply(&StreamDelta::ThinkingDelta {
714            delta: "Reasoning".to_string(),
715            block_index: 0,
716        });
717        acc.apply(&StreamDelta::SignatureDelta {
718            delta: "sig_123".to_string(),
719            block_index: 0,
720        });
721
722        let blocks = acc.into_content_blocks();
723        assert_eq!(blocks.len(), 1);
724        assert!(matches!(
725            &blocks[0],
726            ContentBlock::Thinking { thinking, signature }
727            if thinking == "Reasoning" && signature.as_deref() == Some("sig_123")
728        ));
729    }
730
731    #[test]
732    fn accumulator_preserves_opaque_reasoning_payload_and_order() {
733        let mut acc = StreamAccumulator::new();
734        acc.apply(&StreamDelta::TextDelta {
735            delta: "visible".to_owned(),
736            block_index: 2,
737        });
738        acc.apply(&StreamDelta::OpaqueReasoning {
739            provider: "test-provider".to_owned(),
740            data: serde_json::json!({
741                "id": "reasoning_1",
742                "encrypted_content": "do-not-inspect"
743            }),
744            block_index: 1,
745        });
746
747        let blocks = acc.into_content_blocks();
748        assert_eq!(blocks.len(), 2);
749        assert!(matches!(
750            &blocks[0],
751            ContentBlock::OpaqueReasoning { provider, data }
752                if provider == "test-provider"
753                    && data["id"] == "reasoning_1"
754                    && data["encrypted_content"] == "do-not-inspect"
755        ));
756        assert!(matches!(
757            &blocks[1],
758            ContentBlock::Text { text } if text == "visible"
759        ));
760    }
761
762    #[test]
763    fn test_accumulator_tool_use() {
764        let mut acc = StreamAccumulator::new();
765
766        acc.apply(&StreamDelta::ToolUseStart {
767            id: "call_123".to_string(),
768            name: "read_file".to_string(),
769            block_index: 0,
770            thought_signature: None,
771        });
772        acc.apply(&StreamDelta::ToolInputDelta {
773            id: "call_123".to_string(),
774            delta: r#"{"path":"#.to_string(),
775            block_index: 0,
776        });
777        acc.apply(&StreamDelta::ToolInputDelta {
778            id: "call_123".to_string(),
779            delta: r#""test.txt"}"#.to_string(),
780            block_index: 0,
781        });
782
783        let blocks = acc.into_content_blocks();
784        assert_eq!(blocks.len(), 1);
785        match &blocks[0] {
786            ContentBlock::ToolUse {
787                id, name, input, ..
788            } => {
789                assert_eq!(id, "call_123");
790                assert_eq!(name, "read_file");
791                assert_eq!(input["path"], "test.txt");
792            }
793            _ => panic!("Expected ToolUse block"),
794        }
795    }
796
797    #[test]
798    fn test_accumulator_mixed_content() {
799        let mut acc = StreamAccumulator::new();
800
801        acc.apply(&StreamDelta::TextDelta {
802            delta: "Let me read that file.".to_string(),
803            block_index: 0,
804        });
805        acc.apply(&StreamDelta::ToolUseStart {
806            id: "call_456".to_string(),
807            name: "read_file".to_string(),
808            block_index: 1,
809            thought_signature: None,
810        });
811        acc.apply(&StreamDelta::ToolInputDelta {
812            id: "call_456".to_string(),
813            delta: r#"{"path":"file.txt"}"#.to_string(),
814            block_index: 1,
815        });
816        acc.apply(&StreamDelta::Usage(Usage {
817            input_tokens: 100,
818            output_tokens: 50,
819            cached_input_tokens: 0,
820            cache_creation_input_tokens: 0,
821        }));
822        acc.apply(&StreamDelta::Done {
823            stop_reason: Some(StopReason::ToolUse),
824            served_route: None,
825        });
826
827        assert!(acc.usage().is_some());
828        assert_eq!(acc.usage().map(|u| u.input_tokens), Some(100));
829        assert!(matches!(acc.stop_reason(), Some(StopReason::ToolUse)));
830
831        let blocks = acc.into_content_blocks();
832        assert_eq!(blocks.len(), 2);
833        assert!(matches!(&blocks[0], ContentBlock::Text { .. }));
834        assert!(matches!(&blocks[1], ContentBlock::ToolUse { .. }));
835    }
836
837    #[test]
838    fn accumulator_captures_the_done_markers_served_route() {
839        let mut acc = StreamAccumulator::new();
840        assert_eq!(acc.served_route(), None);
841        acc.apply(&StreamDelta::Done {
842            stop_reason: Some(StopReason::EndTurn),
843            served_route: Some("openrouter".to_owned()),
844        });
845        assert_eq!(acc.served_route(), Some("openrouter"));
846
847        let mut without = StreamAccumulator::new();
848        without.apply(&StreamDelta::Done {
849            stop_reason: Some(StopReason::EndTurn),
850            served_route: None,
851        });
852        assert_eq!(without.served_route(), None);
853    }
854
855    #[test]
856    fn test_accumulator_invalid_tool_json() {
857        let mut acc = StreamAccumulator::new();
858
859        acc.apply(&StreamDelta::ToolUseStart {
860            id: "call_789".to_string(),
861            name: "test_tool".to_string(),
862            block_index: 0,
863            thought_signature: None,
864        });
865        acc.apply(&StreamDelta::ToolInputDelta {
866            id: "call_789".to_string(),
867            delta: "invalid json {".to_string(),
868            block_index: 0,
869        });
870
871        let blocks = acc.into_content_blocks();
872        assert_eq!(blocks.len(), 1);
873        match &blocks[0] {
874            ContentBlock::ToolUse { input, .. } => {
875                assert!(input.is_object());
876            }
877            _ => panic!("Expected ToolUse block"),
878        }
879    }
880
881    #[test]
882    fn test_accumulator_empty_tool_input_falls_back_to_empty_object() {
883        // If no ToolInputDelta is received (e.g., stream interrupted or
884        // deltas had mismatched IDs), the tool use block should still be
885        // produced with an empty object so that the error is attributable
886        // to the tool rather than silently lost.
887        let mut acc = StreamAccumulator::new();
888
889        acc.apply(&StreamDelta::ToolUseStart {
890            id: "call_empty".to_string(),
891            name: "read".to_string(),
892            block_index: 0,
893            thought_signature: None,
894        });
895        // No ToolInputDelta applied
896
897        let blocks = acc.into_content_blocks();
898        assert_eq!(blocks.len(), 1);
899        match &blocks[0] {
900            ContentBlock::ToolUse { input, name, .. } => {
901                assert_eq!(name, "read");
902                assert_eq!(input, &serde_json::json!({}));
903            }
904            _ => panic!("Expected ToolUse block"),
905        }
906    }
907
908    #[test]
909    fn test_accumulator_mismatched_delta_id_drops_input() {
910        // If ToolInputDelta has a different ID than any ToolUseStart,
911        // the input is silently dropped (the tool gets empty {}).
912        let mut acc = StreamAccumulator::new();
913
914        acc.apply(&StreamDelta::ToolUseStart {
915            id: "call_A".to_string(),
916            name: "bash".to_string(),
917            block_index: 0,
918            thought_signature: None,
919        });
920        // Delta with wrong ID
921        acc.apply(&StreamDelta::ToolInputDelta {
922            id: "call_B".to_string(),
923            delta: r#"{"command":"ls"}"#.to_string(),
924            block_index: 0,
925        });
926
927        let blocks = acc.into_content_blocks();
928        assert_eq!(blocks.len(), 1);
929        match &blocks[0] {
930            ContentBlock::ToolUse { input, .. } => {
931                // Input should be empty because the delta had a mismatched ID
932                assert_eq!(input, &serde_json::json!({}));
933            }
934            _ => panic!("Expected ToolUse block"),
935        }
936    }
937
938    #[test]
939    fn test_accumulator_empty() {
940        let acc = StreamAccumulator::new();
941        let blocks = acc.into_content_blocks();
942        assert!(blocks.is_empty());
943    }
944
945    #[test]
946    fn test_accumulator_skips_empty_text() {
947        let mut acc = StreamAccumulator::new();
948
949        acc.apply(&StreamDelta::TextDelta {
950            delta: String::new(),
951            block_index: 0,
952        });
953        acc.apply(&StreamDelta::TextDelta {
954            delta: "Hello".to_string(),
955            block_index: 1,
956        });
957
958        let blocks = acc.into_content_blocks();
959        assert_eq!(blocks.len(), 1);
960        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello"));
961    }
962
963    #[test]
964    fn test_accumulator_ignores_out_of_range_block_index() {
965        // A hostile/corrupted event with a huge block_index must not drive an
966        // unbounded Vec allocation. The delta is dropped, leaving the accumulator
967        // tiny rather than allocating billions of empty Strings.
968        let mut acc = StreamAccumulator::new();
969
970        acc.apply(&StreamDelta::TextDelta {
971            delta: "ok".to_string(),
972            block_index: 0,
973        });
974        acc.apply(&StreamDelta::TextDelta {
975            delta: "boom".to_string(),
976            block_index: usize::MAX,
977        });
978        acc.apply(&StreamDelta::ThinkingDelta {
979            delta: "boom".to_string(),
980            block_index: usize::MAX,
981        });
982
983        let blocks = acc.into_content_blocks();
984        assert_eq!(blocks.len(), 1);
985        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "ok"));
986    }
987
988    #[tokio::test]
989    async fn classifies_typed_connect_failure_as_connectivity() -> anyhow::Result<()> {
990        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
991        let address = listener.local_addr()?;
992        drop(listener);
993
994        let result = reqwest::Client::new()
995            .get(format!("http://{address}"))
996            .send()
997            .await;
998        let Err(error) = result else {
999            anyhow::bail!("closed local port unexpectedly accepted a connection")
1000        };
1001        assert_eq!(
1002            classify_reqwest_error(&error),
1003            StreamErrorKind::Connectivity
1004        );
1005        Ok(())
1006    }
1007
1008    #[tokio::test]
1009    async fn proxy_tunnel_rejection_is_not_connectivity() -> anyhow::Result<()> {
1010        use tokio::io::AsyncWriteExt as _;
1011
1012        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1013        let address = listener.local_addr()?;
1014        let server = tokio::spawn(async move {
1015            let (mut socket, _) = listener.accept().await?;
1016            socket
1017                .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
1018                .await?;
1019            anyhow::Ok(())
1020        });
1021        let client = reqwest::Client::builder()
1022            .proxy(reqwest::Proxy::all(format!("http://{address}"))?)
1023            .build()?;
1024        let Err(error) = client.get("https://example.invalid").send().await else {
1025            anyhow::bail!("rejected proxy tunnel unexpectedly succeeded")
1026        };
1027        assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1028        server.await??;
1029        Ok(())
1030    }
1031
1032    #[tokio::test]
1033    async fn tls_handshake_transport_drop_is_connectivity() -> anyhow::Result<()> {
1034        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1035        let address = listener.local_addr()?;
1036        let server = tokio::spawn(async move {
1037            let (socket, _) = listener.accept().await?;
1038            drop(socket);
1039            anyhow::Ok(())
1040        });
1041        let client = reqwest::Client::builder().no_proxy().build()?;
1042        let Err(error) = client.get(format!("https://{address}")).send().await else {
1043            anyhow::bail!("dropped TLS handshake unexpectedly succeeded")
1044        };
1045        assert_eq!(
1046            classify_reqwest_error(&error),
1047            StreamErrorKind::Connectivity
1048        );
1049        server.await??;
1050        Ok(())
1051    }
1052
1053    /// A live TLS peer that fails certificate validation is a policy
1054    /// rejection, not an outage — waiting for connectivity cannot fix it, so
1055    /// it must stay on the bounded path. The server presents a self-signed
1056    /// certificate the client refuses; the assertion also pins the
1057    /// `native_tls::Error` downcast in `is_tls_rejection` against a version
1058    /// drift between reqwest's native-tls and the workspace's.
1059    #[tokio::test]
1060    async fn tls_certificate_rejection_is_bounded_server_error() -> anyhow::Result<()> {
1061        const SELF_SIGNED_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----
1062MIIDJzCCAg+gAwIBAgIUPiG3JI6c72crNdzYks8mo1pmHMEwDQYJKoZIhvcNAQEL
1063BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcxNDE4NDYzMloYDzIxMjYw
1064NjIwMTg0NjMyWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
1065AQUAA4IBDwAwggEKAoIBAQCtBOh4EAP48fjE59F+L9qNEp/yUlOJXYJbm6m4nzTg
106600RNc+dqsfrObIWJDuAaiKimunkGrSy77ELNAHlJmtOSkq8hu1C5/k6LW0GvPHuC
1067faPFEevCmxbERVZnt1f9IQ2e77oZz752cNzDlUIKyy5v3LpGaL8vT1bLAFuHT9z/
10683mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsvfAY4mhbV8uRjKj+IZnOV1WYqQ62o
1069xbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyjmUszRpfh7YX2wkk3UqZgN1+zsRDK
1070MBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/R8TJmQpXAgMBAAGjbzBtMB0GA1Ud
1071DgQWBBT8LxETkCZh4h6qjMlLJMooNHTgkTAfBgNVHSMEGDAWgBT8LxETkCZh4h6q
1072jMlLJMooNHTgkTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z
1073dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEACjZ8oqjFooFxjS3BnbhrNrF29/Jv
1074PbX32Tg3+3qUkS5+XnO64mLm+pQzUGs16+TyqdEkck//51KkyvzrnnGRYGc5eHEQ
1075zorkR1zlE+c8sjKcenvVkkLEKWaWNtEvpb+U0Ps6rP2Y1Jo4/AxTuxXrYxQ+XSTy
1076V4HyKriK6utlmhGpKUZhTZPTiTC/GaAwimCFgfw4wDuWGow92z3AnR9Q3KFpgrTP
1077B5z+i0oiNv6GpalGq3oe1ucKt+fduYWsC2Vea/PObZowciqbsA0mv3oHlyT9jPFT
1078hY9YjeYgUtEnf0BlrUrgbpd9DnVd5TNU0nDbPC7bv/yu8nF1nKUFWa2nsw==
1079-----END CERTIFICATE-----";
1080        const SELF_SIGNED_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
1081MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtBOh4EAP48fjE
108259F+L9qNEp/yUlOJXYJbm6m4nzTg00RNc+dqsfrObIWJDuAaiKimunkGrSy77ELN
1083AHlJmtOSkq8hu1C5/k6LW0GvPHuCfaPFEevCmxbERVZnt1f9IQ2e77oZz752cNzD
1084lUIKyy5v3LpGaL8vT1bLAFuHT9z/3mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsv
1085fAY4mhbV8uRjKj+IZnOV1WYqQ62oxbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyj
1086mUszRpfh7YX2wkk3UqZgN1+zsRDKMBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/
1087R8TJmQpXAgMBAAECggEAAk8G9RctnmRIMARx4K+tyGUfukGL+NDFHQjSNnL1Zyya
1088hDgQNfXDBX8gNwh6SBBbw8HIPKUR7D4GVCr181v8B8AqUxZnSNwSWzyv/zEc6sxX
1089Y5lOHo4oOx07vm2NYITQ5DaJsq95eKYf5AI5W+CDMZ3t5GOgbXavD01la0RPDCD7
1090d+H9WI7RiKlCaiD174FQfSSwcAHpesrUcopPxMfZzpjxYClGdmMp7/RTmSVg8jex
1091eGceJvZujmjTnYczIce0Ibtozbq91qbwro32U2wbkvNpbU8GTG+st6nRlNRGmHeF
1092AJnOw+CiY9x7KaG4ZhsEY4VRk8YRJo/cLrPcx87JwQKBgQDv76cDYUgFzFXKWH+1
1093hc+oTLuUcn+X6E3ljvMKk9P4nQDgTRDxx5bBm6lHVv3IZoszi60Hvzblqr2HIO5S
1094Gl9KVBkHCLaYc8ny4rYQKVjKLA2/TnDE8Y8FhFnZTpBeEWhb2axQE5zb8WA6Ku6L
1095gEl04OSHjMlqAWt2Va5PZnFQQQKBgQC4mlfFFkfu92RKkYfhXkXUd5psSL4/1C1S
1096wYnqyL7rmAMmKO+y2MdnS1SAwSFGtmexibEcpDu8OASPQoovy4O5De+p/wL7v7aJ
1097+X2J9zaM2ggQN3tYz/HWCdCSpZJy+ufHtLwW9ESu0wW2G0ESRUxtEKvmBB/b/nrO
1098pK7VWxW0lwKBgQCWPG1LRIKgfs3JIZj1xI++Ri2+SeNy7ta3wsaT/PRhW43M5PST
1099L/JJ0HoyXVoTPYI0CGWT0DtDm6GJFymi5zh7hiUVrnMHCpmNKD/v5rPeA6+n9inO
1100Z6KyRaks1HC5NhUuTiIDEgTKA13JjlBHsVBNivQNnC4R3km3kvbOaMrTAQKBgAoR
11016U3H/F6NwjvLGoVxtg90Asl7Yl1q/pnwEszq7Hc/kJRpUUIJTz9UPaTUZDNOSfPG
1102VhIA531J9P23nIAk8ueKWhOE5K3E9HksUevPv3sJfb0cua7LkR6i5GzLeWSqSTB8
1103rHH4GzMKMdqQPAl6HEQqz6W5fd9rT1msZBkhYdq7AoGBAJFTgwSK84D707FxGASw
1104SyuZBVIVd3iF341tsgX48Q1SVq70Uu6AQ0qPJHyxk6pe8aCiermlvVX26nqSQqr7
1105RrpkOaRQNnAfmLmSHvHWZmErDzlsl7pKdIByHK5nx1ccE8xspPEfHsg00E/SWWD+
1106CQR0IwmxMNda1bOi/AL4rcN3
1107-----END PRIVATE KEY-----";
1108
1109        use anyhow::Context as _;
1110
1111        let identity = native_tls::Identity::from_pkcs8(SELF_SIGNED_CERT_PEM, SELF_SIGNED_KEY_PEM)?;
1112        let acceptor = native_tls::TlsAcceptor::new(identity)?;
1113        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1114        let address = listener.local_addr()?;
1115        let server = std::thread::spawn(move || {
1116            if let Ok((socket, _)) = listener.accept() {
1117                // The client aborts after rejecting the certificate, so the
1118                // server-side handshake result is an expected error.
1119                drop(acceptor.accept(socket));
1120            }
1121        });
1122
1123        let client = reqwest::Client::builder().no_proxy().build()?;
1124        let result = client
1125            .get(format!("https://localhost:{}", address.port()))
1126            .send()
1127            .await;
1128        let Err(error) = result else {
1129            anyhow::bail!("self-signed certificate unexpectedly accepted")
1130        };
1131        assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1132        server
1133            .join()
1134            .ok()
1135            .context("TLS test server thread panicked")?;
1136        Ok(())
1137    }
1138
1139    #[tokio::test]
1140    async fn classifies_premature_http_eof_as_connection_lost() -> anyhow::Result<()> {
1141        use tokio::io::AsyncWriteExt as _;
1142
1143        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1144        let address = listener.local_addr()?;
1145        let server = tokio::spawn(async move {
1146            let (mut socket, _) = listener.accept().await?;
1147            socket
1148                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nx")
1149                .await?;
1150            anyhow::Ok(())
1151        });
1152
1153        let response = reqwest::Client::new()
1154            .get(format!("http://{address}"))
1155            .send()
1156            .await?;
1157        let Err(error) = response.bytes().await else {
1158            anyhow::bail!("truncated HTTP body unexpectedly completed")
1159        };
1160        let StreamDelta::Error { kind, .. } = reqwest_body_error_delta("stream error", &error)
1161        else {
1162            anyhow::bail!("body error helper did not return an error delta")
1163        };
1164        assert_eq!(kind, StreamErrorKind::ConnectionLost);
1165        server.await??;
1166        Ok(())
1167    }
1168
1169    #[cfg(any(feature = "openai", feature = "openai-codex"))]
1170    #[test]
1171    fn test_sse_line_buffer_splits_multiple_lines() {
1172        let mut buf = SseLineBuffer::new();
1173        buf.extend(b"data: one\ndata: two\n");
1174        assert_eq!(buf.next_line().as_deref(), Some("data: one"));
1175        assert_eq!(buf.next_line().as_deref(), Some("data: two"));
1176        assert_eq!(buf.next_line(), None);
1177    }
1178
1179    #[cfg(any(feature = "openai", feature = "openai-codex"))]
1180    #[test]
1181    fn test_sse_line_buffer_buffers_partial_line_until_newline() {
1182        let mut buf = SseLineBuffer::new();
1183        buf.extend(b"data: par");
1184        assert_eq!(buf.next_line(), None);
1185        buf.extend(b"tial\n");
1186        assert_eq!(buf.next_line().as_deref(), Some("data: partial"));
1187    }
1188
1189    #[cfg(any(feature = "openai", feature = "openai-codex"))]
1190    #[test]
1191    fn test_sse_line_buffer_handles_utf8_split_across_chunks() {
1192        // "café" — the 'é' is the two bytes 0xC3 0xA9. Split the chunk boundary
1193        // *inside* that character: the naive per-chunk from_utf8_lossy would emit
1194        // a U+FFFD replacement char; the line buffer must decode it losslessly
1195        // because it only decodes the complete line.
1196        let mut buf = SseLineBuffer::new();
1197        let line = "data: café\n";
1198        let bytes = line.as_bytes();
1199        let split = bytes.len() - 2; // between 0xC3 and 0xA9
1200        buf.extend(&bytes[..split]);
1201        assert_eq!(buf.next_line(), None);
1202        buf.extend(&bytes[split..]);
1203        assert_eq!(buf.next_line().as_deref(), Some("data: café"));
1204    }
1205}