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, ServedSpeed, 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        served_speed: ServedSpeed::merge(carried.served_speed, usage.served_speed),
385        input_tokens: carried.input_tokens.saturating_add(usage.input_tokens),
386        output_tokens: carried.output_tokens.saturating_add(usage.output_tokens),
387        cached_input_tokens: carried
388            .cached_input_tokens
389            .saturating_add(usage.cached_input_tokens),
390        cache_creation_input_tokens: carried
391            .cache_creation_input_tokens
392            .saturating_add(usage.cache_creation_input_tokens),
393    }
394}
395
396/// Preserves usage across a stream-splicing wrapper's attempt boundary.
397///
398/// A wrapper that abandons one inner stream and splices in another (failover,
399/// credential refresh) has a hazard: [`StreamAccumulator`] keeps only the LAST
400/// `Usage` delta it sees, so the retried stream's usage would erase the
401/// abandoned one — silently un-billing tokens the provider already charged for.
402///
403/// The carry closes that: on every outgoing `Usage` delta, the wrapper calls
404/// [`running_total`](Self::running_total) to rewrite it to the sum of all
405/// abandoned attempts plus this stream's latest reading; when it abandons a
406/// stream to retry, it calls [`abandon`](Self::abandon) to fold that stream's
407/// usage into the carry. The final delta the consumer sees — the only one the
408/// accumulator keeps — is therefore the true total across every attempt.
409#[derive(Default)]
410pub(crate) struct UsageCarry {
411    /// Usage billed by abandoned attempts.
412    carried: Option<Usage>,
413    /// The current attempt's latest usage reading (last-wins within an attempt).
414    current: Option<Usage>,
415}
416
417impl UsageCarry {
418    pub(crate) const fn new() -> Self {
419        Self {
420            carried: None,
421            current: None,
422        }
423    }
424
425    /// Record `usage` as the current attempt's reading and return the running
426    /// total (abandoned attempts + this reading) to yield in its place.
427    pub(crate) fn running_total(&mut self, usage: Usage) -> Usage {
428        let total = add_usage(self.carried.as_ref(), &usage);
429        self.current = Some(usage);
430        total
431    }
432
433    /// Fold the current attempt's usage into the carry because its stream is
434    /// being abandoned for a retry.
435    pub(crate) fn abandon(&mut self) {
436        if let Some(current) = self.current.take() {
437            self.carried = Some(add_usage(self.carried.as_ref(), &current));
438        }
439    }
440}
441
442/// Helper to accumulate streamed content into a final response.
443///
444/// This struct collects [`StreamDelta`] events and can convert them
445/// into the final content blocks once the stream is complete.
446#[derive(Debug, Default)]
447pub struct StreamAccumulator {
448    /// Accumulated text for each block index
449    text_blocks: Vec<String>,
450    /// Accumulated thinking blocks for each block index
451    thinking_blocks: Vec<String>,
452    /// Accumulated signatures keyed by block index
453    thinking_signatures: HashMap<usize, String>,
454    /// Redacted thinking blocks: (`block_index`, data)
455    redacted_thinking_blocks: Vec<(usize, String)>,
456    /// Provider-owned opaque reasoning: (`block_index`, provider, data)
457    opaque_reasoning_blocks: Vec<(usize, String, serde_json::Value)>,
458    /// Accumulated tool use calls
459    tool_uses: Vec<ToolUseAccumulator>,
460    /// Usage information from the stream
461    usage: Option<Usage>,
462    /// Stop reason from the stream
463    stop_reason: Option<StopReason>,
464    /// Serving route reported by the stream's `Done` marker
465    served_route: Option<String>,
466}
467
468/// Accumulator for a single tool use during streaming.
469#[derive(Debug, Default)]
470pub struct ToolUseAccumulator {
471    /// Tool call ID
472    pub id: String,
473    /// Tool name
474    pub name: String,
475    /// Accumulated JSON input (may be incomplete during streaming)
476    pub input_json: String,
477    /// Block index for ordering
478    pub block_index: usize,
479    /// Optional thought signature (used by Gemini 3.x models)
480    pub thought_signature: Option<String>,
481}
482
483impl StreamAccumulator {
484    /// Create a new empty accumulator.
485    #[must_use]
486    pub fn new() -> Self {
487        Self::default()
488    }
489
490    /// Apply a stream delta to the accumulator.
491    pub fn apply(&mut self, delta: &StreamDelta) {
492        match delta {
493            StreamDelta::TextDelta { delta, block_index } => {
494                if *block_index > MAX_BLOCK_INDEX {
495                    log::warn!(
496                        "dropping TextDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
497                    );
498                    return;
499                }
500                while self.text_blocks.len() <= *block_index {
501                    self.text_blocks.push(String::new());
502                }
503                self.text_blocks[*block_index].push_str(delta);
504            }
505            StreamDelta::ThinkingDelta { delta, block_index } => {
506                if *block_index > MAX_BLOCK_INDEX {
507                    log::warn!(
508                        "dropping ThinkingDelta with out-of-range block_index {block_index} (max {MAX_BLOCK_INDEX})"
509                    );
510                    return;
511                }
512                while self.thinking_blocks.len() <= *block_index {
513                    self.thinking_blocks.push(String::new());
514                }
515                self.thinking_blocks[*block_index].push_str(delta);
516            }
517            StreamDelta::ToolUseStart {
518                id,
519                name,
520                block_index,
521                thought_signature,
522            } => {
523                self.tool_uses.push(ToolUseAccumulator {
524                    id: id.clone(),
525                    name: name.clone(),
526                    input_json: String::new(),
527                    block_index: *block_index,
528                    thought_signature: thought_signature.clone(),
529                });
530            }
531            StreamDelta::ToolInputDelta { id, delta, .. } => {
532                if let Some(tool) = self.tool_uses.iter_mut().find(|t| t.id == *id) {
533                    tool.input_json.push_str(delta);
534                }
535            }
536            StreamDelta::SignatureDelta { delta, block_index } => {
537                self.thinking_signatures
538                    .entry(*block_index)
539                    .or_default()
540                    .push_str(delta);
541            }
542            StreamDelta::RedactedThinking { data, block_index } => {
543                self.redacted_thinking_blocks
544                    .push((*block_index, data.clone()));
545            }
546            StreamDelta::OpaqueReasoning {
547                provider,
548                data,
549                block_index,
550            } => {
551                self.opaque_reasoning_blocks
552                    .push((*block_index, provider.clone(), data.clone()));
553            }
554            StreamDelta::Usage(u) => {
555                self.usage = Some(u.clone());
556            }
557            StreamDelta::Done {
558                stop_reason,
559                served_route,
560            } => {
561                self.stop_reason = *stop_reason;
562                self.served_route.clone_from(served_route);
563            }
564            StreamDelta::Error { .. } => {}
565        }
566    }
567
568    /// Get the accumulated usage information.
569    #[must_use]
570    pub const fn usage(&self) -> Option<&Usage> {
571        self.usage.as_ref()
572    }
573
574    /// Get the stop reason.
575    #[must_use]
576    pub const fn stop_reason(&self) -> Option<&StopReason> {
577        self.stop_reason.as_ref()
578    }
579
580    /// The serving route the stream's `Done` marker reported, if any.
581    #[must_use]
582    pub fn served_route(&self) -> Option<&str> {
583        self.served_route.as_deref()
584    }
585
586    /// Convert accumulated content to `ContentBlock`s.
587    ///
588    /// This consumes the accumulator and returns the final content blocks.
589    /// Tool use JSON is parsed at this point; invalid JSON results in a null input.
590    #[must_use]
591    pub fn into_content_blocks(self) -> Vec<ContentBlock> {
592        let mut blocks: Vec<(usize, ContentBlock)> = Vec::new();
593
594        // Add thinking blocks with their indices, attaching signatures
595        let mut signatures = self.thinking_signatures;
596        for (idx, thinking) in self.thinking_blocks.into_iter().enumerate() {
597            if !thinking.is_empty() {
598                let signature = signatures.remove(&idx).filter(|s| !s.is_empty());
599                blocks.push((
600                    idx,
601                    ContentBlock::Thinking {
602                        thinking,
603                        signature,
604                    },
605                ));
606            }
607        }
608
609        // Add redacted thinking blocks
610        for (idx, data) in self.redacted_thinking_blocks {
611            blocks.push((idx, ContentBlock::RedactedThinking { data }));
612        }
613
614        // Add provider-owned reasoning state without interpreting its payload.
615        for (idx, provider, data) in self.opaque_reasoning_blocks {
616            blocks.push((idx, ContentBlock::OpaqueReasoning { provider, data }));
617        }
618
619        // Add text blocks with their indices
620        for (idx, text) in self.text_blocks.into_iter().enumerate() {
621            if !text.is_empty() {
622                blocks.push((idx, ContentBlock::Text { text }));
623            }
624        }
625
626        // Add tool uses with their indices
627        for tool in self.tool_uses {
628            let input: serde_json::Value =
629                serde_json::from_str(&tool.input_json).unwrap_or_else(|e| {
630                    log::warn!(
631                        "Failed to parse streamed tool input JSON for tool '{}' (id={}): {} — \
632                         input_json ({} bytes): '{}'",
633                        tool.name,
634                        tool.id,
635                        e,
636                        tool.input_json.len(),
637                        tool.input_json.chars().take(500).collect::<String>(),
638                    );
639                    serde_json::json!({})
640                });
641            blocks.push((
642                tool.block_index,
643                ContentBlock::ToolUse {
644                    id: tool.id,
645                    name: tool.name,
646                    input,
647                    thought_signature: tool.thought_signature,
648                },
649            ));
650        }
651
652        // Sort by block index to maintain order
653        blocks.sort_by_key(|(idx, _)| *idx);
654
655        blocks.into_iter().map(|(_, block)| block).collect()
656    }
657
658    /// Take ownership of accumulated usage.
659    pub const fn take_usage(&mut self) -> Option<Usage> {
660        self.usage.take()
661    }
662
663    /// Take ownership of stop reason.
664    pub const fn take_stop_reason(&mut self) -> Option<StopReason> {
665        self.stop_reason.take()
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use agent_sdk_foundation::llm::SpeedTier;
673
674    #[test]
675    fn folding_usage_surfaces_a_downgrade_instead_of_picking_a_winner() {
676        let fast = Usage {
677            input_tokens: 10,
678            output_tokens: 5,
679            served_speed: Some(ServedSpeed::Uniform(SpeedTier::Fast)),
680            ..Usage::default()
681        };
682        let downgraded = Usage {
683            input_tokens: 3,
684            output_tokens: 2,
685            served_speed: Some(ServedSpeed::Uniform(SpeedTier::Standard)),
686            ..Usage::default()
687        };
688
689        let total = add_usage(Some(&fast), &downgraded);
690        assert_eq!(total.input_tokens, 13);
691        assert_eq!(total.output_tokens, 7);
692        assert_eq!(
693            total.served_speed,
694            Some(ServedSpeed::Mixed),
695            "a turn mixing an expedited call with a downgraded one must not \
696             report either tier as if it covered the whole turn",
697        );
698    }
699
700    #[test]
701    fn folding_usage_keeps_a_uniform_tier() {
702        let call = Usage {
703            input_tokens: 10,
704            output_tokens: 5,
705            served_speed: Some(ServedSpeed::Uniform(SpeedTier::Fast)),
706            ..Usage::default()
707        };
708        let total = add_usage(Some(&call), &call);
709        assert_eq!(
710            total.served_speed,
711            Some(ServedSpeed::Uniform(SpeedTier::Fast))
712        );
713    }
714
715    #[test]
716    fn test_accumulator_text_deltas() {
717        let mut acc = StreamAccumulator::new();
718
719        acc.apply(&StreamDelta::TextDelta {
720            delta: "Hello".to_string(),
721            block_index: 0,
722        });
723        acc.apply(&StreamDelta::TextDelta {
724            delta: " world".to_string(),
725            block_index: 0,
726        });
727
728        let blocks = acc.into_content_blocks();
729        assert_eq!(blocks.len(), 1);
730        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello world"));
731    }
732
733    #[test]
734    fn test_accumulator_multiple_text_blocks() {
735        let mut acc = StreamAccumulator::new();
736
737        acc.apply(&StreamDelta::TextDelta {
738            delta: "First".to_string(),
739            block_index: 0,
740        });
741        acc.apply(&StreamDelta::TextDelta {
742            delta: "Second".to_string(),
743            block_index: 1,
744        });
745
746        let blocks = acc.into_content_blocks();
747        assert_eq!(blocks.len(), 2);
748        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "First"));
749        assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Second"));
750    }
751
752    #[test]
753    fn test_accumulator_thinking_signature() {
754        let mut acc = StreamAccumulator::new();
755
756        acc.apply(&StreamDelta::ThinkingDelta {
757            delta: "Reasoning".to_string(),
758            block_index: 0,
759        });
760        acc.apply(&StreamDelta::SignatureDelta {
761            delta: "sig_123".to_string(),
762            block_index: 0,
763        });
764
765        let blocks = acc.into_content_blocks();
766        assert_eq!(blocks.len(), 1);
767        assert!(matches!(
768            &blocks[0],
769            ContentBlock::Thinking { thinking, signature }
770            if thinking == "Reasoning" && signature.as_deref() == Some("sig_123")
771        ));
772    }
773
774    #[test]
775    fn accumulator_preserves_opaque_reasoning_payload_and_order() {
776        let mut acc = StreamAccumulator::new();
777        acc.apply(&StreamDelta::TextDelta {
778            delta: "visible".to_owned(),
779            block_index: 2,
780        });
781        acc.apply(&StreamDelta::OpaqueReasoning {
782            provider: "test-provider".to_owned(),
783            data: serde_json::json!({
784                "id": "reasoning_1",
785                "encrypted_content": "do-not-inspect"
786            }),
787            block_index: 1,
788        });
789
790        let blocks = acc.into_content_blocks();
791        assert_eq!(blocks.len(), 2);
792        assert!(matches!(
793            &blocks[0],
794            ContentBlock::OpaqueReasoning { provider, data }
795                if provider == "test-provider"
796                    && data["id"] == "reasoning_1"
797                    && data["encrypted_content"] == "do-not-inspect"
798        ));
799        assert!(matches!(
800            &blocks[1],
801            ContentBlock::Text { text } if text == "visible"
802        ));
803    }
804
805    #[test]
806    fn test_accumulator_tool_use() {
807        let mut acc = StreamAccumulator::new();
808
809        acc.apply(&StreamDelta::ToolUseStart {
810            id: "call_123".to_string(),
811            name: "read_file".to_string(),
812            block_index: 0,
813            thought_signature: None,
814        });
815        acc.apply(&StreamDelta::ToolInputDelta {
816            id: "call_123".to_string(),
817            delta: r#"{"path":"#.to_string(),
818            block_index: 0,
819        });
820        acc.apply(&StreamDelta::ToolInputDelta {
821            id: "call_123".to_string(),
822            delta: r#""test.txt"}"#.to_string(),
823            block_index: 0,
824        });
825
826        let blocks = acc.into_content_blocks();
827        assert_eq!(blocks.len(), 1);
828        match &blocks[0] {
829            ContentBlock::ToolUse {
830                id, name, input, ..
831            } => {
832                assert_eq!(id, "call_123");
833                assert_eq!(name, "read_file");
834                assert_eq!(input["path"], "test.txt");
835            }
836            _ => panic!("Expected ToolUse block"),
837        }
838    }
839
840    #[test]
841    fn test_accumulator_mixed_content() {
842        let mut acc = StreamAccumulator::new();
843
844        acc.apply(&StreamDelta::TextDelta {
845            delta: "Let me read that file.".to_string(),
846            block_index: 0,
847        });
848        acc.apply(&StreamDelta::ToolUseStart {
849            id: "call_456".to_string(),
850            name: "read_file".to_string(),
851            block_index: 1,
852            thought_signature: None,
853        });
854        acc.apply(&StreamDelta::ToolInputDelta {
855            id: "call_456".to_string(),
856            delta: r#"{"path":"file.txt"}"#.to_string(),
857            block_index: 1,
858        });
859        acc.apply(&StreamDelta::Usage(Usage {
860            served_speed: None,
861            input_tokens: 100,
862            output_tokens: 50,
863            cached_input_tokens: 0,
864            cache_creation_input_tokens: 0,
865        }));
866        acc.apply(&StreamDelta::Done {
867            stop_reason: Some(StopReason::ToolUse),
868            served_route: None,
869        });
870
871        assert!(acc.usage().is_some());
872        assert_eq!(acc.usage().map(|u| u.input_tokens), Some(100));
873        assert!(matches!(acc.stop_reason(), Some(StopReason::ToolUse)));
874
875        let blocks = acc.into_content_blocks();
876        assert_eq!(blocks.len(), 2);
877        assert!(matches!(&blocks[0], ContentBlock::Text { .. }));
878        assert!(matches!(&blocks[1], ContentBlock::ToolUse { .. }));
879    }
880
881    #[test]
882    fn accumulator_captures_the_done_markers_served_route() {
883        let mut acc = StreamAccumulator::new();
884        assert_eq!(acc.served_route(), None);
885        acc.apply(&StreamDelta::Done {
886            stop_reason: Some(StopReason::EndTurn),
887            served_route: Some("openrouter".to_owned()),
888        });
889        assert_eq!(acc.served_route(), Some("openrouter"));
890
891        let mut without = StreamAccumulator::new();
892        without.apply(&StreamDelta::Done {
893            stop_reason: Some(StopReason::EndTurn),
894            served_route: None,
895        });
896        assert_eq!(without.served_route(), None);
897    }
898
899    #[test]
900    fn test_accumulator_invalid_tool_json() {
901        let mut acc = StreamAccumulator::new();
902
903        acc.apply(&StreamDelta::ToolUseStart {
904            id: "call_789".to_string(),
905            name: "test_tool".to_string(),
906            block_index: 0,
907            thought_signature: None,
908        });
909        acc.apply(&StreamDelta::ToolInputDelta {
910            id: "call_789".to_string(),
911            delta: "invalid json {".to_string(),
912            block_index: 0,
913        });
914
915        let blocks = acc.into_content_blocks();
916        assert_eq!(blocks.len(), 1);
917        match &blocks[0] {
918            ContentBlock::ToolUse { input, .. } => {
919                assert!(input.is_object());
920            }
921            _ => panic!("Expected ToolUse block"),
922        }
923    }
924
925    #[test]
926    fn test_accumulator_empty_tool_input_falls_back_to_empty_object() {
927        // If no ToolInputDelta is received (e.g., stream interrupted or
928        // deltas had mismatched IDs), the tool use block should still be
929        // produced with an empty object so that the error is attributable
930        // to the tool rather than silently lost.
931        let mut acc = StreamAccumulator::new();
932
933        acc.apply(&StreamDelta::ToolUseStart {
934            id: "call_empty".to_string(),
935            name: "read".to_string(),
936            block_index: 0,
937            thought_signature: None,
938        });
939        // No ToolInputDelta applied
940
941        let blocks = acc.into_content_blocks();
942        assert_eq!(blocks.len(), 1);
943        match &blocks[0] {
944            ContentBlock::ToolUse { input, name, .. } => {
945                assert_eq!(name, "read");
946                assert_eq!(input, &serde_json::json!({}));
947            }
948            _ => panic!("Expected ToolUse block"),
949        }
950    }
951
952    #[test]
953    fn test_accumulator_mismatched_delta_id_drops_input() {
954        // If ToolInputDelta has a different ID than any ToolUseStart,
955        // the input is silently dropped (the tool gets empty {}).
956        let mut acc = StreamAccumulator::new();
957
958        acc.apply(&StreamDelta::ToolUseStart {
959            id: "call_A".to_string(),
960            name: "bash".to_string(),
961            block_index: 0,
962            thought_signature: None,
963        });
964        // Delta with wrong ID
965        acc.apply(&StreamDelta::ToolInputDelta {
966            id: "call_B".to_string(),
967            delta: r#"{"command":"ls"}"#.to_string(),
968            block_index: 0,
969        });
970
971        let blocks = acc.into_content_blocks();
972        assert_eq!(blocks.len(), 1);
973        match &blocks[0] {
974            ContentBlock::ToolUse { input, .. } => {
975                // Input should be empty because the delta had a mismatched ID
976                assert_eq!(input, &serde_json::json!({}));
977            }
978            _ => panic!("Expected ToolUse block"),
979        }
980    }
981
982    #[test]
983    fn test_accumulator_empty() {
984        let acc = StreamAccumulator::new();
985        let blocks = acc.into_content_blocks();
986        assert!(blocks.is_empty());
987    }
988
989    #[test]
990    fn test_accumulator_skips_empty_text() {
991        let mut acc = StreamAccumulator::new();
992
993        acc.apply(&StreamDelta::TextDelta {
994            delta: String::new(),
995            block_index: 0,
996        });
997        acc.apply(&StreamDelta::TextDelta {
998            delta: "Hello".to_string(),
999            block_index: 1,
1000        });
1001
1002        let blocks = acc.into_content_blocks();
1003        assert_eq!(blocks.len(), 1);
1004        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello"));
1005    }
1006
1007    #[test]
1008    fn test_accumulator_ignores_out_of_range_block_index() {
1009        // A hostile/corrupted event with a huge block_index must not drive an
1010        // unbounded Vec allocation. The delta is dropped, leaving the accumulator
1011        // tiny rather than allocating billions of empty Strings.
1012        let mut acc = StreamAccumulator::new();
1013
1014        acc.apply(&StreamDelta::TextDelta {
1015            delta: "ok".to_string(),
1016            block_index: 0,
1017        });
1018        acc.apply(&StreamDelta::TextDelta {
1019            delta: "boom".to_string(),
1020            block_index: usize::MAX,
1021        });
1022        acc.apply(&StreamDelta::ThinkingDelta {
1023            delta: "boom".to_string(),
1024            block_index: usize::MAX,
1025        });
1026
1027        let blocks = acc.into_content_blocks();
1028        assert_eq!(blocks.len(), 1);
1029        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "ok"));
1030    }
1031
1032    #[tokio::test]
1033    async fn classifies_typed_connect_failure_as_connectivity() -> anyhow::Result<()> {
1034        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1035        let address = listener.local_addr()?;
1036        drop(listener);
1037
1038        let result = reqwest::Client::new()
1039            .get(format!("http://{address}"))
1040            .send()
1041            .await;
1042        let Err(error) = result else {
1043            anyhow::bail!("closed local port unexpectedly accepted a connection")
1044        };
1045        assert_eq!(
1046            classify_reqwest_error(&error),
1047            StreamErrorKind::Connectivity
1048        );
1049        Ok(())
1050    }
1051
1052    #[tokio::test]
1053    async fn proxy_tunnel_rejection_is_not_connectivity() -> anyhow::Result<()> {
1054        use tokio::io::AsyncWriteExt as _;
1055
1056        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1057        let address = listener.local_addr()?;
1058        let server = tokio::spawn(async move {
1059            let (mut socket, _) = listener.accept().await?;
1060            socket
1061                .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
1062                .await?;
1063            anyhow::Ok(())
1064        });
1065        let client = reqwest::Client::builder()
1066            .proxy(reqwest::Proxy::all(format!("http://{address}"))?)
1067            .build()?;
1068        let Err(error) = client.get("https://example.invalid").send().await else {
1069            anyhow::bail!("rejected proxy tunnel unexpectedly succeeded")
1070        };
1071        assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1072        server.await??;
1073        Ok(())
1074    }
1075
1076    #[tokio::test]
1077    async fn tls_handshake_transport_drop_is_connectivity() -> anyhow::Result<()> {
1078        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1079        let address = listener.local_addr()?;
1080        let server = tokio::spawn(async move {
1081            let (socket, _) = listener.accept().await?;
1082            drop(socket);
1083            anyhow::Ok(())
1084        });
1085        let client = reqwest::Client::builder().no_proxy().build()?;
1086        let Err(error) = client.get(format!("https://{address}")).send().await else {
1087            anyhow::bail!("dropped TLS handshake unexpectedly succeeded")
1088        };
1089        assert_eq!(
1090            classify_reqwest_error(&error),
1091            StreamErrorKind::Connectivity
1092        );
1093        server.await??;
1094        Ok(())
1095    }
1096
1097    /// A live TLS peer that fails certificate validation is a policy
1098    /// rejection, not an outage — waiting for connectivity cannot fix it, so
1099    /// it must stay on the bounded path. The server presents a self-signed
1100    /// certificate the client refuses; the assertion also pins the
1101    /// `native_tls::Error` downcast in `is_tls_rejection` against a version
1102    /// drift between reqwest's native-tls and the workspace's.
1103    #[tokio::test]
1104    async fn tls_certificate_rejection_is_bounded_server_error() -> anyhow::Result<()> {
1105        const SELF_SIGNED_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----
1106MIIDJzCCAg+gAwIBAgIUPiG3JI6c72crNdzYks8mo1pmHMEwDQYJKoZIhvcNAQEL
1107BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcxNDE4NDYzMloYDzIxMjYw
1108NjIwMTg0NjMyWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
1109AQUAA4IBDwAwggEKAoIBAQCtBOh4EAP48fjE59F+L9qNEp/yUlOJXYJbm6m4nzTg
111000RNc+dqsfrObIWJDuAaiKimunkGrSy77ELNAHlJmtOSkq8hu1C5/k6LW0GvPHuC
1111faPFEevCmxbERVZnt1f9IQ2e77oZz752cNzDlUIKyy5v3LpGaL8vT1bLAFuHT9z/
11123mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsvfAY4mhbV8uRjKj+IZnOV1WYqQ62o
1113xbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyjmUszRpfh7YX2wkk3UqZgN1+zsRDK
1114MBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/R8TJmQpXAgMBAAGjbzBtMB0GA1Ud
1115DgQWBBT8LxETkCZh4h6qjMlLJMooNHTgkTAfBgNVHSMEGDAWgBT8LxETkCZh4h6q
1116jMlLJMooNHTgkTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z
1117dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEACjZ8oqjFooFxjS3BnbhrNrF29/Jv
1118PbX32Tg3+3qUkS5+XnO64mLm+pQzUGs16+TyqdEkck//51KkyvzrnnGRYGc5eHEQ
1119zorkR1zlE+c8sjKcenvVkkLEKWaWNtEvpb+U0Ps6rP2Y1Jo4/AxTuxXrYxQ+XSTy
1120V4HyKriK6utlmhGpKUZhTZPTiTC/GaAwimCFgfw4wDuWGow92z3AnR9Q3KFpgrTP
1121B5z+i0oiNv6GpalGq3oe1ucKt+fduYWsC2Vea/PObZowciqbsA0mv3oHlyT9jPFT
1122hY9YjeYgUtEnf0BlrUrgbpd9DnVd5TNU0nDbPC7bv/yu8nF1nKUFWa2nsw==
1123-----END CERTIFICATE-----";
1124        const SELF_SIGNED_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
1125MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtBOh4EAP48fjE
112659F+L9qNEp/yUlOJXYJbm6m4nzTg00RNc+dqsfrObIWJDuAaiKimunkGrSy77ELN
1127AHlJmtOSkq8hu1C5/k6LW0GvPHuCfaPFEevCmxbERVZnt1f9IQ2e77oZz752cNzD
1128lUIKyy5v3LpGaL8vT1bLAFuHT9z/3mlqEwyK7mQlS3LZvwJQ6NfL2lgr5uVDFdsv
1129fAY4mhbV8uRjKj+IZnOV1WYqQ62oxbjC/NKXbvqKBigOhbo+idk1sjKbkjm2uvyj
1130mUszRpfh7YX2wkk3UqZgN1+zsRDKMBMyuZkkr7Vb/8ed07SN8Ma64fwCrrQba4l/
1131R8TJmQpXAgMBAAECggEAAk8G9RctnmRIMARx4K+tyGUfukGL+NDFHQjSNnL1Zyya
1132hDgQNfXDBX8gNwh6SBBbw8HIPKUR7D4GVCr181v8B8AqUxZnSNwSWzyv/zEc6sxX
1133Y5lOHo4oOx07vm2NYITQ5DaJsq95eKYf5AI5W+CDMZ3t5GOgbXavD01la0RPDCD7
1134d+H9WI7RiKlCaiD174FQfSSwcAHpesrUcopPxMfZzpjxYClGdmMp7/RTmSVg8jex
1135eGceJvZujmjTnYczIce0Ibtozbq91qbwro32U2wbkvNpbU8GTG+st6nRlNRGmHeF
1136AJnOw+CiY9x7KaG4ZhsEY4VRk8YRJo/cLrPcx87JwQKBgQDv76cDYUgFzFXKWH+1
1137hc+oTLuUcn+X6E3ljvMKk9P4nQDgTRDxx5bBm6lHVv3IZoszi60Hvzblqr2HIO5S
1138Gl9KVBkHCLaYc8ny4rYQKVjKLA2/TnDE8Y8FhFnZTpBeEWhb2axQE5zb8WA6Ku6L
1139gEl04OSHjMlqAWt2Va5PZnFQQQKBgQC4mlfFFkfu92RKkYfhXkXUd5psSL4/1C1S
1140wYnqyL7rmAMmKO+y2MdnS1SAwSFGtmexibEcpDu8OASPQoovy4O5De+p/wL7v7aJ
1141+X2J9zaM2ggQN3tYz/HWCdCSpZJy+ufHtLwW9ESu0wW2G0ESRUxtEKvmBB/b/nrO
1142pK7VWxW0lwKBgQCWPG1LRIKgfs3JIZj1xI++Ri2+SeNy7ta3wsaT/PRhW43M5PST
1143L/JJ0HoyXVoTPYI0CGWT0DtDm6GJFymi5zh7hiUVrnMHCpmNKD/v5rPeA6+n9inO
1144Z6KyRaks1HC5NhUuTiIDEgTKA13JjlBHsVBNivQNnC4R3km3kvbOaMrTAQKBgAoR
11456U3H/F6NwjvLGoVxtg90Asl7Yl1q/pnwEszq7Hc/kJRpUUIJTz9UPaTUZDNOSfPG
1146VhIA531J9P23nIAk8ueKWhOE5K3E9HksUevPv3sJfb0cua7LkR6i5GzLeWSqSTB8
1147rHH4GzMKMdqQPAl6HEQqz6W5fd9rT1msZBkhYdq7AoGBAJFTgwSK84D707FxGASw
1148SyuZBVIVd3iF341tsgX48Q1SVq70Uu6AQ0qPJHyxk6pe8aCiermlvVX26nqSQqr7
1149RrpkOaRQNnAfmLmSHvHWZmErDzlsl7pKdIByHK5nx1ccE8xspPEfHsg00E/SWWD+
1150CQR0IwmxMNda1bOi/AL4rcN3
1151-----END PRIVATE KEY-----";
1152
1153        use anyhow::Context as _;
1154
1155        let identity = native_tls::Identity::from_pkcs8(SELF_SIGNED_CERT_PEM, SELF_SIGNED_KEY_PEM)?;
1156        let acceptor = native_tls::TlsAcceptor::new(identity)?;
1157        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
1158        let address = listener.local_addr()?;
1159        let server = std::thread::spawn(move || {
1160            if let Ok((socket, _)) = listener.accept() {
1161                // The client aborts after rejecting the certificate, so the
1162                // server-side handshake result is an expected error.
1163                drop(acceptor.accept(socket));
1164            }
1165        });
1166
1167        let client = reqwest::Client::builder().no_proxy().build()?;
1168        let result = client
1169            .get(format!("https://localhost:{}", address.port()))
1170            .send()
1171            .await;
1172        let Err(error) = result else {
1173            anyhow::bail!("self-signed certificate unexpectedly accepted")
1174        };
1175        assert_eq!(classify_reqwest_error(&error), StreamErrorKind::ServerError);
1176        server
1177            .join()
1178            .ok()
1179            .context("TLS test server thread panicked")?;
1180        Ok(())
1181    }
1182
1183    #[tokio::test]
1184    async fn classifies_premature_http_eof_as_connection_lost() -> anyhow::Result<()> {
1185        use tokio::io::AsyncWriteExt as _;
1186
1187        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
1188        let address = listener.local_addr()?;
1189        let server = tokio::spawn(async move {
1190            let (mut socket, _) = listener.accept().await?;
1191            socket
1192                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nx")
1193                .await?;
1194            anyhow::Ok(())
1195        });
1196
1197        let response = reqwest::Client::new()
1198            .get(format!("http://{address}"))
1199            .send()
1200            .await?;
1201        let Err(error) = response.bytes().await else {
1202            anyhow::bail!("truncated HTTP body unexpectedly completed")
1203        };
1204        let StreamDelta::Error { kind, .. } = reqwest_body_error_delta("stream error", &error)
1205        else {
1206            anyhow::bail!("body error helper did not return an error delta")
1207        };
1208        assert_eq!(kind, StreamErrorKind::ConnectionLost);
1209        server.await??;
1210        Ok(())
1211    }
1212
1213    #[cfg(any(feature = "openai", feature = "openai-codex"))]
1214    #[test]
1215    fn test_sse_line_buffer_splits_multiple_lines() {
1216        let mut buf = SseLineBuffer::new();
1217        buf.extend(b"data: one\ndata: two\n");
1218        assert_eq!(buf.next_line().as_deref(), Some("data: one"));
1219        assert_eq!(buf.next_line().as_deref(), Some("data: two"));
1220        assert_eq!(buf.next_line(), None);
1221    }
1222
1223    #[cfg(any(feature = "openai", feature = "openai-codex"))]
1224    #[test]
1225    fn test_sse_line_buffer_buffers_partial_line_until_newline() {
1226        let mut buf = SseLineBuffer::new();
1227        buf.extend(b"data: par");
1228        assert_eq!(buf.next_line(), None);
1229        buf.extend(b"tial\n");
1230        assert_eq!(buf.next_line().as_deref(), Some("data: partial"));
1231    }
1232
1233    #[cfg(any(feature = "openai", feature = "openai-codex"))]
1234    #[test]
1235    fn test_sse_line_buffer_handles_utf8_split_across_chunks() {
1236        // "café" — the 'é' is the two bytes 0xC3 0xA9. Split the chunk boundary
1237        // *inside* that character: the naive per-chunk from_utf8_lossy would emit
1238        // a U+FFFD replacement char; the line buffer must decode it losslessly
1239        // because it only decodes the complete line.
1240        let mut buf = SseLineBuffer::new();
1241        let line = "data: café\n";
1242        let bytes = line.as_bytes();
1243        let split = bytes.len() - 2; // between 0xC3 and 0xA9
1244        buf.extend(&bytes[..split]);
1245        assert_eq!(buf.next_line(), None);
1246        buf.extend(&bytes[split..]);
1247        assert_eq!(buf.next_line().as_deref(), Some("data: café"));
1248    }
1249}