rig_core/streaming/mod.rs
1//! This module provides functionality for working with streaming completion models.
2//! It provides traits and types for generating streaming completion requests and
3//! handling streaming completion responses.
4//!
5//! Provider implementations use these types to expose raw streamed completion
6//! events without depending on a runtime.
7
8mod identity;
9mod parts;
10
11use crate::completion::{CompletionError, CompletionResponse, Usage};
12use crate::message::{
13 AssistantContent, Reasoning, ReasoningContent, Text, ToolCall, ToolFunction, ToolResult,
14};
15use crate::wasm_compat::WasmCompatSend;
16use futures::stream::{AbortHandle, Abortable};
17use futures::{Stream, StreamExt};
18pub use identity::{MintKind, StreamPartId, SyntheticIds, WireId};
19use parts::PartsAccumulator;
20use serde::{Deserialize, Serialize};
21use std::future::Future;
22use std::pin::Pin;
23use std::sync::atomic::AtomicBool;
24use std::task::{Context, Poll};
25use tokio::sync::watch;
26
27/// Control for pausing and resuming a streaming response
28pub struct PauseControl {
29 pub(crate) paused_tx: watch::Sender<bool>,
30 pub(crate) paused_rx: watch::Receiver<bool>,
31}
32
33impl PauseControl {
34 /// Create a pause controller in the running state.
35 pub fn new() -> Self {
36 let (paused_tx, paused_rx) = watch::channel(false);
37 Self {
38 paused_tx,
39 paused_rx,
40 }
41 }
42
43 /// Pause polling of the public stream until [`PauseControl::resume`] is called.
44 pub fn pause(&self) {
45 let _ = self.paused_tx.send(true);
46 }
47
48 /// Resume polling after a pause.
49 pub fn resume(&self) {
50 let _ = self.paused_tx.send(false);
51 }
52
53 /// Returns whether the stream is currently paused.
54 pub fn is_paused(&self) -> bool {
55 *self.paused_rx.borrow()
56 }
57}
58
59impl Default for PauseControl {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65/// The content of a tool call delta - either the tool name or argument data
66#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
67pub enum ToolCallDeltaContent {
68 /// Tool/function name emitted by the provider.
69 Name(String),
70 /// Partial JSON argument data emitted by the provider.
71 Delta(String),
72}
73
74/// How the shared assembler treats an argument payload that does not parse as
75/// JSON when a streamed tool call's input ends.
76///
77/// This is genuine wire-family policy, declared by the adapter on the end
78/// event rather than hand-rolled per provider.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum UnparseableToolInput {
81 /// Drop the call silently: the input never fully arrived (the
82 /// OpenAI-compatible end-of-stream flush of pending calls).
83 Drop,
84 /// Deliver the call with `{}` arguments: the wire superseded the call
85 /// mid-assembly (the OpenAI-compatible same-slot eviction path).
86 EmptyObject,
87 /// Surface an in-band error item: the wire promised a complete block
88 /// (Anthropic `content_block_stop`, Bedrock `contentBlockStop`).
89 Error,
90 /// Leave the call open and emit nothing: the end was a completion
91 /// *probe* (the OpenAI-compatible single-chunk immediate-emission path),
92 /// and input that does not yet finalize may still be extended by later
93 /// fragments and closed by a genuine flush.
94 Keep,
95}
96
97/// End of a streamed tool call's input: the signal for the shared assembler
98/// ([`RawStreamingChoice::ToolInputEnd`]) to finalize the call.
99///
100/// Optional fields are authoritative wire values that supersede the assembled
101/// state — a wire whose completed item restates the call (OpenAI Responses
102/// `output_item.done`) carries them; delta-only wires leave them `None` and
103/// the assembled fragments are parsed instead.
104#[derive(Debug, Clone)]
105pub struct ToolInputEnd {
106 /// Assembly identity: the id the call's fragments were emitted under.
107 pub id: StreamPartId,
108 /// Authoritative provider-issued tool id, when one exists (e.g. an id
109 /// that arrived after the call opened id-less). The durable handle;
110 /// absence is `None`, never an empty string.
111 pub tool_id: Option<WireId>,
112 /// Authoritative tool name from the wire's completed item.
113 pub name: Option<String>,
114 /// Authoritative parsed arguments from the wire's completed item.
115 pub arguments: Option<serde_json::Value>,
116 /// Provider call-correlation id (e.g. OpenAI Responses `call_id`).
117 pub call_id: Option<String>,
118 /// Provider signature attached to the completed call.
119 pub signature: Option<String>,
120 /// Provider-specific metadata attached to the completed call.
121 pub additional_params: Option<serde_json::Value>,
122 /// Wire-family policy for assembled arguments that fail to parse.
123 pub on_unparseable: UnparseableToolInput,
124}
125
126/// Decoration a provider attaches to a streamed tool call that is still
127/// assembling, matched by its established provider id (e.g. OpenRouter
128/// encrypted reasoning details). Carried onto the completed call by the
129/// adapter's end event.
130#[derive(Debug, Clone)]
131pub struct ToolCallDecoration {
132 /// Established provider id of the call to decorate.
133 pub tool_id: String,
134 /// Provider signature to attach to the completed call.
135 pub signature: Option<String>,
136 /// Provider-specific metadata to attach to the completed call.
137 pub additional_params: Option<serde_json::Value>,
138}
139
140impl ToolInputEnd {
141 /// End the call identified by `id`, finalizing from assembled fragments
142 /// with the given unparseable-input policy.
143 pub fn new(id: impl Into<StreamPartId>, on_unparseable: UnparseableToolInput) -> Self {
144 Self {
145 id: id.into(),
146 tool_id: None,
147 name: None,
148 arguments: None,
149 call_id: None,
150 signature: None,
151 additional_params: None,
152 on_unparseable,
153 }
154 }
155}
156
157/// Discriminant for [`StreamFinal`].
158///
159/// [`StreamedAssistantContent`] is `#[serde(untagged)]` and its
160/// [`StreamedAssistantContent::Unknown`] variant matches any JSON value, so the
161/// terminal record needs a field that identifies it structurally.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum StreamFinalKind {
165 /// The provider's terminal stream event.
166 Final,
167}
168
169/// The provider's terminal stream record, normalized.
170///
171/// This replaces the provider-typed final payload that streams used to carry:
172/// usage is a plain field rather than a trait method, and the finish reason is
173/// normalized exactly as on the unary [`CompletionResponse`].
174///
175/// Providers that want their own terminal type keep it behind
176/// [`RawStreamingResult`] and map it once with [`normalize_stream`].
177///
178/// # Emission contract
179///
180/// A terminal record is emitted only when the provider signaled genuine
181/// completion — its own end-of-response event (an Anthropic `message_delta`
182/// with a stop reason, an OpenAI `[DONE]` / `response.completed`, a Gemini
183/// chunk carrying `finishReason`, and so on). Three failure shapes reach a
184/// consumer, and they are distinct:
185///
186/// | Shape | `Err` item | Stream continues | Terminal record |
187/// |---|---|---|---|
188/// | Transport error (connection lost, HTTP failure) | yes | no | never |
189/// | Malformed frame (recoverable parse error) | yes | yes | if a genuine terminal later arrives |
190/// | Truncation (EOF without the provider's end event) | no | — | never |
191///
192/// On a terminal error (a transport failure or the provider's own failure
193/// event), tool calls that were fully delivered before the failure are yielded
194/// *before* the terminal `Err`; nothing follows the error — the stream then
195/// ends without a terminal record.
196///
197/// Consequently an `Err` item is **not** by itself terminal: a malformed frame
198/// is surfaced and the stream keeps consuming, so a later genuine terminal
199/// still completes it. Consumers must drain the stream to `None` rather than
200/// stop at the first `Err`, and must treat the absence of a terminal record as
201/// truncation, never as a successful zero-usage completion.
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(from = "StreamFinalRepr")]
204pub struct StreamFinal {
205 /// Discriminating field; always [`StreamFinalKind::Final`].
206 pub kind: StreamFinalKind,
207 /// Token usage reported by the provider for this streamed completion.
208 /// Zero-valued usage is the documented sentinel for missing metrics.
209 pub usage: Usage,
210 /// Why the model stopped generating, when the provider reported it.
211 ///
212 /// [`normalize_stream`] applies
213 /// [`FinishReason::reconcile_with_output`](crate::completion::FinishReason::reconcile_with_output)
214 /// to this value using the tool calls actually seen on the stream, so a
215 /// provider mapper does not need to (and cannot — it has no view of the
216 /// preceding events).
217 #[serde(default)]
218 pub finish_reason: Option<crate::completion::FinishReason>,
219 /// Provider-assigned *assistant message* ID, when available — only IDs the
220 /// provider would recognize on a replayed assistant message. Response-scoped
221 /// identifiers belong in [`StreamFinal::response_id`].
222 #[serde(default)]
223 pub message_id: Option<String>,
224 /// Provider-assigned response-scoped ID, when available — e.g. an OpenAI
225 /// chat `chatcmpl-` ID. Never replayed to a provider as a message ID.
226 #[serde(default)]
227 pub response_id: Option<String>,
228 /// The provider's transport-level request identifier, taken from the SSE
229 /// connection's HTTP response headers (Anthropic `request-id`, OpenAI/xAI
230 /// `x-request-id`). When the source reconnected, this is the connection
231 /// that delivered this terminal record. Never the body's message/response
232 /// id. `None` means the provider did not report one — a documented
233 /// outcome, never an error.
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub provider_request_id: Option<String>,
236 /// Stable descriptor name of the provider that produced this stream.
237 pub provider: String,
238 /// Provider-reported model identifier, when available.
239 #[serde(default)]
240 pub model: Option<String>,
241 /// The provider's own terminal record for this stream: the value the
242 /// model's inherent `raw_stream` would have yielded as its `FinalResponse`,
243 /// serialized. It is the terminal record as rig's wire type parsed it —
244 /// fields that type does not model are not here — and it is the terminal
245 /// record only, not the stream's frames; see the module docs for why
246 /// frames are a separate mechanism. [`normalize_stream`] populates it
247 /// unconditionally — the same parity the pre-normalization `Final(R)` had.
248 ///
249 /// An escape hatch for provider-specific data rig does not normalize — it
250 /// never replaces a normalized field, and every normalized field means the
251 /// same thing whatever this holds. `Value::Null` means the record was
252 /// built without a provider behind it — [`StreamFinal::new`] without
253 /// `with_raw` (a provider's mapper before [`normalize_stream`] attaches
254 /// the terminal, test doubles, hand-built records), or a record persisted
255 /// before the field existed — never that the provider sent nothing: no
256 /// stream that reached its terminal yields `Null` here.
257 ///
258 /// Typed access is recoverable: provider terminal types are
259 /// `Deserialize`, so `provider::StreamingCompletionResponse::deserialize(&raw)`
260 /// returns the provider's own type.
261 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
262 pub raw: serde_json::Value,
263}
264
265impl StreamFinal {
266 /// Create a terminal record for `provider` with `usage`; optional metadata
267 /// starts unset and is filled in with the `with_*` helpers.
268 pub fn new(provider: impl Into<String>, usage: Usage) -> Self {
269 Self {
270 kind: StreamFinalKind::Final,
271 usage,
272 finish_reason: None,
273 message_id: None,
274 response_id: None,
275 provider_request_id: None,
276 provider: provider.into(),
277 model: None,
278 raw: serde_json::Value::Null,
279 }
280 }
281
282 /// Attach the normalized finish reason.
283 pub fn with_finish_reason(self, finish_reason: crate::completion::FinishReason) -> Self {
284 self.with_optional_finish_reason(Some(finish_reason))
285 }
286
287 /// Attach the normalized finish reason when the provider reported one.
288 pub fn with_optional_finish_reason(
289 mut self,
290 finish_reason: Option<crate::completion::FinishReason>,
291 ) -> Self {
292 self.finish_reason = finish_reason;
293 self
294 }
295
296 /// This terminal record's identity metadata as one
297 /// [`crate::completion::ResponseIdentity`] carrier.
298 pub fn identity(&self) -> crate::completion::ResponseIdentity {
299 crate::completion::ResponseIdentity {
300 message_id: self.message_id.clone(),
301 response_id: self.response_id.clone(),
302 provider_request_id: self.provider_request_id.clone(),
303 }
304 }
305}
306
307crate::provider_response::response_metadata_setters!(StreamFinal);
308
309/// Wire-shape mirror of [`StreamFinal`], used only for deserialization.
310///
311/// Serde must never construct an invariant-bearing value structurally: a plain
312/// derive would let `"message_id":""` skip the empty-string filtering the
313/// `with_*` setters apply. This mirror deserializes the exact wire shape —
314/// including the discriminating `kind` field — and [`From`] funnels it through
315/// [`StreamFinal::new`] and the setters, so every deserialized value satisfies
316/// the same invariants as a constructed one. Serialization stays derived on
317/// [`StreamFinal`] itself, so the wire format is unchanged.
318#[derive(Deserialize)]
319struct StreamFinalRepr {
320 kind: StreamFinalKind,
321 usage: Usage,
322 #[serde(default)]
323 finish_reason: Option<crate::completion::FinishReason>,
324 #[serde(default)]
325 message_id: Option<String>,
326 #[serde(default)]
327 response_id: Option<String>,
328 #[serde(default)]
329 provider_request_id: Option<String>,
330 provider: String,
331 #[serde(default)]
332 model: Option<String>,
333 // `default` because persisted terminal records predate the field; a
334 // missing key loads as `Null`, which is exactly what "no provider record
335 // behind this value" means.
336 #[serde(default)]
337 raw: serde_json::Value,
338}
339
340impl From<StreamFinalRepr> for StreamFinal {
341 fn from(repr: StreamFinalRepr) -> Self {
342 let StreamFinalRepr {
343 kind,
344 usage,
345 finish_reason,
346 message_id,
347 response_id,
348 provider_request_id,
349 provider,
350 model,
351 raw,
352 } = repr;
353 // `StreamFinal::new` sets the only possible discriminant; the
354 // irrefutable pattern consumes the mirrored field.
355 let StreamFinalKind::Final = kind;
356 Self::new(provider, usage)
357 .with_optional_finish_reason(finish_reason)
358 .with_optional_message_id(message_id)
359 .with_optional_response_id(response_id)
360 .with_optional_provider_request_id(provider_request_id)
361 .with_optional_model(model)
362 .with_raw(raw)
363 }
364}
365
366/// An unmodeled wire payload on the raw passthrough channel.
367///
368/// Wraps the raw JSON with a **redacted** `Debug` (structural metadata only):
369/// unmodeled frames can carry model output or other sensitive provider data,
370/// and `warn!(?value)`-style Debug captures in streaming modules were a
371/// recurring leak class a text scanner existed to police. With the payload
372/// unable to Debug-print its content, that class is structurally closed for
373/// the JSON channel — the redaction is a property of the type, not a
374/// convention. Consumers who want the content opt in explicitly via
375/// [`UnknownPayload::value`]; serialization
376/// is `#[serde(transparent)]`, so wire round-trips are unchanged.
377#[derive(Clone, PartialEq, Serialize, Deserialize)]
378#[serde(transparent)]
379pub struct UnknownPayload(serde_json::Value);
380
381impl UnknownPayload {
382 /// Wrap a raw unmodeled payload.
383 pub fn new(value: serde_json::Value) -> Self {
384 Self(value)
385 }
386
387 /// The raw payload, for consumers who opt in to the content.
388 pub fn value(&self) -> &serde_json::Value {
389 &self.0
390 }
391}
392
393impl std::fmt::Debug for UnknownPayload {
394 /// Structural metadata only — never the payload.
395 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396 let bytes = serde_json::to_vec(&self.0)
397 .map(|json| json.len())
398 .unwrap_or(0);
399 write!(f, "UnknownPayload({bytes} bytes redacted)")
400 }
401}
402
403impl From<serde_json::Value> for UnknownPayload {
404 fn from(value: serde_json::Value) -> Self {
405 Self(value)
406 }
407}
408
409#[cfg(test)]
410mod unknown_payload_tests {
411 use super::UnknownPayload;
412
413 /// The redaction is a property of the type: no Debug rendering — direct,
414 /// via a containing derive, or through a `warn!(?value)` capture — can
415 /// reproduce payload content.
416 #[test]
417 fn debug_output_never_contains_payload_content() {
418 let payload = UnknownPayload::new(serde_json::json!({
419 "secret_field": "SENSITIVE-CONTENT",
420 }));
421 let rendered = format!("{payload:?}");
422 assert!(!rendered.contains("SENSITIVE-CONTENT"));
423 assert!(!rendered.contains("secret_field"));
424 assert!(rendered.contains("redacted"));
425 }
426
427 /// Serialization stays transparent, so wire round-trips are unchanged.
428 #[test]
429 fn serde_round_trip_is_transparent() {
430 let value = serde_json::json!({"type": "future_event", "n": 1});
431 let payload = UnknownPayload::new(value.clone());
432 let encoded = serde_json::to_string(&payload).expect("serializes");
433 assert_eq!(encoded, serde_json::to_string(&value).expect("serializes"));
434 let decoded: UnknownPayload = serde_json::from_str(&encoded).expect("deserializes");
435 assert_eq!(decoded, payload);
436 }
437}
438
439/// Enum representing a streaming chunk from the model.
440///
441/// `R` is the terminal record type. Ordinary streams use the normalized
442/// [`StreamFinal`] default; a provider's inherent `raw_stream` method
443/// substitutes its own native terminal type over the same event vocabulary,
444/// which is what keeps [`crate::completion::CompletionModel`] free of response
445/// associated types.
446#[derive(Debug, Clone)]
447pub enum RawStreamingChoice<R = StreamFinal> {
448 /// A text chunk from a message response
449 Message(String),
450
451 /// Start a new text content block in the accumulated final choice.
452 ///
453 /// This is an internal provider-normalization event. It is not yielded to
454 /// public stream consumers, but lets providers preserve block boundaries
455 /// and metadata for final aggregated assistant text blocks.
456 TextStart {
457 /// Identity of the text block being opened.
458 ///
459 /// The same mandatory-identity contract as
460 /// [`RawStreamingChoice::Reasoning::id`]: distinct wire output items
461 /// must aggregate as distinct text parts (two OpenAI Responses
462 /// `message` items must not concatenate), so the accumulator keys
463 /// text blocks by identity. Providers propagate the wire's item
464 /// identity (`StreamPartId::Wire`: the Responses `item_id`, Anthropic's
465 /// block index) when it exists, or mint one at the boundary
466 /// (`StreamPartId::Minted`, via [`SyntheticIds`]). A wire that never
467 /// announces text boundaries may skip `TextStart` entirely: a bare
468 /// [`RawStreamingChoice::Message`] with no open block opens a
469 /// boundary-minted block.
470 id: StreamPartId,
471 /// Provider-specific metadata attached to this text block.
472 additional_params: Option<crate::message::AdditionalParams>,
473 },
474
475 /// Provider-specific metadata for the current text content block.
476 ///
477 /// This is not yielded to public stream consumers. The metadata is merged
478 /// into the current aggregated [`Text`] block.
479 /// [`crate::message::AdditionalParams`] is non-empty by construction, so
480 /// a provider with nothing to attach skips the variant instead of
481 /// emitting an empty carrier.
482 TextAdditionalParams(crate::message::AdditionalParams),
483
484 /// A tool call response (in its entirety) — wires that never fragment
485 /// tool input emit this directly; fragmenting wires emit
486 /// [`RawStreamingChoice::ToolCallDelta`] fragments closed by
487 /// [`RawStreamingChoice::ToolInputEnd`], and the shared accumulator
488 /// assembles the completed call.
489 ToolCall(RawStreamingToolCall),
490 /// A tool call partial/delta.
491 ///
492 /// All fragments of one call carry one `id`; the shared accumulator keys
493 /// assembly by it and mints the internal correlation id when the call
494 /// opens, so adapters never track per-call state.
495 ToolCallDelta {
496 /// Identity of the tool call this fragment extends, stable across the
497 /// call's fragments.
498 ///
499 /// The same mandatory-identity contract as
500 /// [`RawStreamingChoice::Reasoning::id`]: parallel calls interleave
501 /// their fragments on real wires, so the accumulator must key
502 /// assembly by identity. Providers propagate the wire's tool-call id
503 /// (`StreamPartId::Wire`), or mint one at the boundary from the wire's
504 /// own index (`StreamPartId::Minted`, via [`SyntheticIds`]) when the wire
505 /// omits it — a shared identity would collapse parallel calls into
506 /// one corrupted assembly. A minted identity keys assembly only; it
507 /// never becomes the completed call's durable
508 /// [`ToolCall::id`](crate::message::ToolCall::id).
509 id: StreamPartId,
510 content: ToolCallDeltaContent,
511 },
512 /// End of a streamed tool call's input: the shared accumulator finalizes
513 /// the assembled fragments (or the event's authoritative payload) into a
514 /// completed tool call.
515 ToolInputEnd(ToolInputEnd),
516 /// A reasoning (in its entirety)
517 Reasoning {
518 /// Identity of the reasoning item this block belongs to.
519 ///
520 /// Required: reasoning interleaves with other output on real wires
521 /// (OpenAI Responses emits the completed item after tool calls), so
522 /// the accumulator must key by identity rather than guess by
523 /// adjacency. Providers propagate the wire's item id
524 /// (`StreamPartId::Wire`: `item_id` on Responses events) or mint a
525 /// stream-scoped id at the boundary (`StreamPartId::Minted`, via
526 /// [`SyntheticIds`]) when the wire has none. Deltas and the full
527 /// block for the same item MUST carry the same key.
528 id: StreamPartId,
529 /// The provider-issued reasoning item id, when one exists — the
530 /// durable handle that becomes
531 /// [`Reasoning::id`](crate::message::Reasoning::id) and round-trips
532 /// upstream. Carried separately from the accumulation key: the key
533 /// is opaque and can never leak; the handle is data.
534 provider_id: Option<WireId>,
535 /// Complete reasoning content block.
536 content: ReasoningContent,
537 },
538 /// Open the reasoning part identified by `id`.
539 ///
540 /// Optional — a bare [`RawStreamingChoice::ReasoningDelta`] opens its
541 /// part leniently — but a wire that announces block starts should emit
542 /// it so arrival order is fixed at the wire's own boundary. A start for
543 /// an already-open key is a no-op; a start for a finished key opens a
544 /// new part (key reuse). Not yielded to public stream consumers.
545 ReasoningStart {
546 /// Accumulation key of the reasoning part being opened.
547 id: StreamPartId,
548 /// The provider-issued reasoning item id, when one exists.
549 provider_id: Option<WireId>,
550 },
551
552 /// Close the reasoning part identified by `id` — the lifecycle
553 /// primitive every wire has (or has synthesized by its adapter at the
554 /// boundaries it already detects), so "is this part still open?" is
555 /// never re-derived per wire.
556 ///
557 /// `reasoning` is the wire's authoritative whole-block restatement; it
558 /// supersedes the delta accumulation. `signature` is a provider
559 /// signature closing the block; it attaches to the part's text — and
560 /// because an end for an already-finished key with only a signature
561 /// attaches to THAT part, a trailing signature signs the block that
562 /// holds the chain-of-thought instead of fabricating an empty sibling.
563 /// A repeated end with no payload is a no-op: idempotence belongs to
564 /// the entity, not to a guard each route must remember.
565 ///
566 /// The completed part is yielded to consumers as
567 /// [`StreamedAssistantContent::Reasoning`] — the uniform
568 /// block-completed signal across every wire — when the wire itself
569 /// said something at the boundary: an end carrying a restatement or
570 /// signature, or a bare end frame the wire actually sent
571 /// (`wire_sent`). A bare end an adapter *synthesized* at an
572 /// interleaving boundary stays silent: the consumer already received
573 /// every delta, and fabricating a completion event the wire never
574 /// sent would change what downstream history builders observe.
575 ReasoningEnd {
576 /// Accumulation key of the reasoning part being closed.
577 id: StreamPartId,
578 /// The wire's authoritative completed block, when it restates one.
579 reasoning: Option<Reasoning>,
580 /// A provider signature closing the block.
581 signature: Option<String>,
582 /// Whether the wire itself sent this end frame (anthropic's
583 /// `content_block_stop`), as opposed to the adapter synthesizing
584 /// it at a boundary the wire never announces. Wire-sent ends
585 /// yield the completed block even when bare.
586 wire_sent: bool,
587 },
588
589 /// Close the text block identified by `id`: later bare text deltas open
590 /// a fresh block instead of extending it. (A later
591 /// [`RawStreamingChoice::TextStart`] with the same key still
592 /// reactivates the block — the keyed collapse is explicit.) Not yielded
593 /// to public stream consumers.
594 TextEnd {
595 /// Accumulation key of the text block being closed.
596 id: StreamPartId,
597 },
598
599 /// A reasoning partial/delta
600 ReasoningDelta {
601 /// Accumulation key of the reasoning item this delta extends. Same
602 /// contract as [`RawStreamingChoice::Reasoning::id`]; all deltas of
603 /// one block share one key.
604 id: StreamPartId,
605 /// The provider-issued reasoning item id, when one exists (see
606 /// [`RawStreamingChoice::Reasoning::provider_id`]) — what a
607 /// delta-built part records as its durable id.
608 provider_id: Option<WireId>,
609 /// Partial reasoning text.
610 reasoning: String,
611 },
612
613 /// The final response object, must be yielded if you want the
614 /// `response` field to be populated on the `StreamingCompletionResponse`
615 FinalResponse(R),
616
617 /// Provider-assigned message ID (e.g. OpenAI Responses API `msg_` ID).
618 /// Captured silently into `StreamingCompletionResponse::message_id`.
619 MessageId(String),
620
621 /// A provider-native output item this version does not model — e.g. an
622 /// OpenAI Responses hosted-tool result (`web_search_call`, `file_search_call`,
623 /// `computer_call`, `code_interpreter_call`). Carries the raw item object
624 /// verbatim. Forwarded to the stream consumer as
625 /// [`StreamedAssistantContent::Unknown`] but not folded into the accumulated
626 /// assistant message (there is no `AssistantContent::Unknown` history slot).
627 Unknown(UnknownPayload),
628}
629
630impl<R> RawStreamingChoice<R> {
631 /// Convert only the terminal record, preserving every incremental content
632 /// event unchanged.
633 pub fn try_map_final<S>(
634 self,
635 map: impl FnOnce(R) -> Result<S, CompletionError>,
636 ) -> Result<RawStreamingChoice<S>, CompletionError> {
637 Ok(match self {
638 Self::Message(text) => RawStreamingChoice::Message(text),
639 Self::TextStart {
640 id,
641 additional_params,
642 } => RawStreamingChoice::TextStart {
643 id,
644 additional_params,
645 },
646 Self::TextAdditionalParams(params) => RawStreamingChoice::TextAdditionalParams(params),
647 Self::ToolCall(call) => RawStreamingChoice::ToolCall(call),
648 Self::ToolCallDelta { id, content } => {
649 RawStreamingChoice::ToolCallDelta { id, content }
650 }
651 Self::ToolInputEnd(end) => RawStreamingChoice::ToolInputEnd(end),
652 Self::Reasoning {
653 id,
654 provider_id,
655 content,
656 } => RawStreamingChoice::Reasoning {
657 id,
658 provider_id,
659 content,
660 },
661 Self::ReasoningDelta {
662 id,
663 provider_id,
664 reasoning,
665 } => RawStreamingChoice::ReasoningDelta {
666 id,
667 provider_id,
668 reasoning,
669 },
670 Self::ReasoningStart { id, provider_id } => {
671 RawStreamingChoice::ReasoningStart { id, provider_id }
672 }
673 Self::ReasoningEnd {
674 id,
675 reasoning,
676 signature,
677 wire_sent,
678 } => RawStreamingChoice::ReasoningEnd {
679 id,
680 reasoning,
681 signature,
682 wire_sent,
683 },
684 Self::TextEnd { id } => RawStreamingChoice::TextEnd { id },
685 Self::FinalResponse(response) => RawStreamingChoice::FinalResponse(map(response)?),
686 Self::MessageId(id) => RawStreamingChoice::MessageId(id),
687 Self::Unknown(value) => RawStreamingChoice::Unknown(value),
688 })
689 }
690}
691
692/// Describes a streaming tool call response (in its entirety)
693#[derive(Debug, Clone)]
694pub struct RawStreamingToolCall {
695 /// Accumulation/reconciliation key of the tool call —
696 /// `StreamPartId::Wire`-derived when the provider supplied an id,
697 /// minted when the wire omitted one. A key only; the durable id is
698 /// [`RawStreamingToolCall::tool_id`].
699 pub id: StreamPartId,
700 /// The provider-issued tool id, when one exists — the durable handle
701 /// that becomes [`ToolCall::id`](crate::message::ToolCall::id). Absent
702 /// means absent: serializers omit the field, and nothing fabricated can
703 /// take its place.
704 pub tool_id: Option<WireId>,
705 /// Rig-generated unique identifier for this tool call.
706 pub internal_call_id: String,
707 /// Provider-specific call ID used by some APIs for tool result correlation.
708 pub call_id: Option<String>,
709 /// Tool/function name.
710 pub name: String,
711 /// Parsed tool arguments.
712 pub arguments: serde_json::Value,
713 /// Optional provider signature associated with the tool call.
714 pub signature: Option<String>,
715 /// Additional provider-specific tool call metadata.
716 pub additional_params: Option<serde_json::Value>,
717}
718
719impl RawStreamingToolCall {
720 /// Create an empty tool call accumulator for provider streaming parsers.
721 pub fn empty() -> Self {
722 Self {
723 // A parser-accumulator placeholder key; providers overwrite it
724 // with the wire's key before emitting. Deliberately minted: an
725 // unset key must never read as wire-derived.
726 id: StreamPartId::minted(MintKind::Tool, u64::MAX),
727 tool_id: None,
728 internal_call_id: crate::id::generate(),
729 call_id: None,
730 name: String::new(),
731 arguments: serde_json::Value::Null,
732 signature: None,
733 additional_params: None,
734 }
735 }
736
737 /// Create a complete tool call with a generated internal call ID.
738 pub fn new(id: impl Into<StreamPartId>, name: String, arguments: serde_json::Value) -> Self {
739 let id = id.into();
740 // A wire-derived key doubles as the durable id (the common case:
741 // providers key by the id the wire issued); minted keys carry none.
742 let tool_id = id.wire_str().and_then(WireId::new);
743 Self {
744 id,
745 tool_id,
746 internal_call_id: crate::id::generate(),
747 call_id: None,
748 name,
749 arguments,
750 signature: None,
751 additional_params: None,
752 }
753 }
754
755 /// Attach a provider-specific call ID.
756 pub fn with_call_id(mut self, call_id: String) -> Self {
757 self.call_id = Some(call_id);
758 self
759 }
760
761 /// Attach or clear a provider signature.
762 pub fn with_signature(mut self, signature: Option<String>) -> Self {
763 self.signature = signature;
764 self
765 }
766
767 /// Attach provider-specific metadata.
768 pub fn with_additional_params(mut self, additional_params: Option<serde_json::Value>) -> Self {
769 self.additional_params = additional_params;
770 self
771 }
772}
773
774impl From<RawStreamingToolCall> for ToolCall {
775 fn from(tool_call: RawStreamingToolCall) -> Self {
776 // Only provider-issued handles populate `provider`: a dual wire
777 // carries (call_id, item id), a single wire carries its id in
778 // `call_id`. With none, the correlation handle is minted and
779 // `provider` records the absence — never an empty sentinel.
780 let provider = crate::message::ProviderCallId::from_optional_wire(
781 tool_call.call_id,
782 tool_call.tool_id.map(WireId::into_string),
783 );
784 let id = crate::message::ToolCallId::for_provider(provider.as_ref());
785 ToolCall {
786 id,
787 provider,
788 function: ToolFunction {
789 name: tool_call.name,
790 arguments: tool_call.arguments,
791 },
792 signature: tool_call.signature,
793 additional_params: tool_call.additional_params,
794 }
795 }
796}
797
798#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
799/// Provider stream whose terminal record is the provider-native `R`, on native
800/// targets.
801///
802/// This is the raw channel of rig's two-channel contract: every provider's
803/// inherent `raw_stream`/`raw_completion` returns provider-native types
804/// directly from the wire decode, never routed through the normalized
805/// accumulation ([`normalize_stream`] / the parts accumulator) — the
806/// semantic channel maps this stream's terminal record exactly once. There
807/// is deliberately no provider-*typed* payload on the normalized types; the
808/// typed channels are the contract. What the normalized types also carry is
809/// that same terminal record *serialized* ([`StreamFinal::raw`]) — for
810/// callers who no longer hold the concrete model, an agent having erased it,
811/// and so cannot reach the typed channel at all. The frames of the stream are
812/// a different axis: they were never exposed on any rig surface, and exposing
813/// them is a per-frame mechanism (a raw stream part), not a field on the
814/// terminal record — so [`StreamFinal::raw`] captures the terminal only.
815///
816/// Precedent, read carefully: openai-agents also splits raw from semantic,
817/// but the load-bearing part of its design is elsewhere — its semantic
818/// layer never reads a delta at all (it acts only on whole done items and
819/// the completed response), and delta aggregation is confined to the
820/// per-provider adapter, which *synthesizes* a canonical terminal event so
821/// the shared layer sees one grammar. rig cannot fully adopt that shape
822/// (openai-agents' canonical grammar is one vendor's schema; rig normalizes
823/// 14 wire families through one accumulator), and centralizing the
824/// accumulator is what forces cross-provider identity — hence the
825/// provenance-typed [`StreamPartId`]. What rig does copy from that precedent is
826/// the raw channel itself and provenance-as-data rather than naming
827/// convention.
828pub type RawStreamingResult<R> =
829 Pin<Box<dyn Stream<Item = Result<RawStreamingChoice<R>, CompletionError>> + Send>>;
830
831#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
832/// Provider stream whose terminal record is the provider-native `R`, on wasm
833/// targets.
834pub type RawStreamingResult<R> =
835 Pin<Box<dyn Stream<Item = Result<RawStreamingChoice<R>, CompletionError>>>>;
836
837/// Normalized provider stream, as consumed by [`StreamingCompletionResponse`].
838pub type StreamingResult = RawStreamingResult<StreamFinal>;
839
840/// Normalize the terminal record of a provider-native stream.
841///
842/// Every incremental event passes through untouched; only
843/// [`RawStreamingChoice::FinalResponse`] is converted, by `map`. On the way
844/// through, the stream remembers whether it emitted any tool call and applies
845/// [`FinishReason::reconcile_with_output`](crate::completion::FinishReason::reconcile_with_output)
846/// to the mapped record — the streaming counterpart of what
847/// [`CompletionResponse::with_finish_reason`] does on the unary path, so both
848/// paths agree about a `stop` that was really a tool call.
849///
850/// The provider-native terminal `R` is also serialized onto
851/// [`StreamFinal::raw`] *before* `map` consumes it — this is the one
852/// streaming seam every provider routes through, so it is the streaming
853/// counterpart of the capture each provider's unary `completion` performs
854/// before `normalize`. That is why `R` is bounded `Serialize`: every in-tree
855/// terminal type already is, and a terminal that could not be serialized
856/// could not be surfaced to callers who no longer hold the typed model.
857pub fn normalize_stream<R, F>(stream: RawStreamingResult<R>, mut map: F) -> StreamingResult
858where
859 R: Serialize + 'static,
860 F: FnMut(R) -> Result<StreamFinal, CompletionError> + WasmCompatSend + 'static,
861{
862 let mut emitted_tool_call = false;
863 Box::pin(stream.map(move |item| {
864 item.and_then(|choice| {
865 // Only a completed `ToolCall` counts, because only that becomes an
866 // `AssistantContent::ToolCall` in the aggregated choice — which is
867 // exactly what the unary path reconciles against. Counting deltas
868 // here would make a stream whose tool call never assembled report
869 // `ToolCalls` while the same data converted to a unary response
870 // reported `Stop`.
871 if matches!(&choice, RawStreamingChoice::ToolCall(_)) {
872 emitted_tool_call = true;
873 }
874 choice.try_map_final(|response| {
875 // Capture before `map` consumes the terminal. A serialization
876 // failure propagates: a silent `None` would contradict the
877 // field's meaning (a provider record stands behind every
878 // normalized terminal). In practice `to_value` on a value
879 // that just deserialized cannot fail.
880 let raw = serde_json::to_value(&response)?;
881 let mut response = map(response)?.with_raw(raw);
882 response.finish_reason = response
883 .finish_reason
884 .map(|reason| reason.reconcile_with_output(emitted_tool_call));
885 Ok(response)
886 })
887 })
888 }))
889}
890
891#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
892/// Future a paused [`StreamingCompletionResponse`] parks on until resumed, on
893/// native targets.
894type ResumeWait = Pin<Box<dyn Future<Output = ()> + Send>>;
895
896#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
897/// Future a paused [`StreamingCompletionResponse`] parks on until resumed, on
898/// wasm targets.
899type ResumeWait = Pin<Box<dyn Future<Output = ()>>>;
900
901/// The response from a streaming completion request;
902/// message and response are populated at the end of the
903/// `inner` stream.
904pub struct StreamingCompletionResponse {
905 pub(crate) inner: Abortable<StreamingResult>,
906 pub(crate) abort_handle: AbortHandle,
907 pub(crate) pause_control: PauseControl,
908 /// Accumulates the streamed parts of the final aggregated choice.
909 parts: PartsAccumulator,
910 /// Stable descriptor name of the provider producing this stream.
911 ///
912 /// Known when the stream is opened rather than when it terminates, so a
913 /// stream that errors or is cancelled before its terminal record still
914 /// names its provider.
915 provider: String,
916 /// The final aggregated message from the stream
917 /// contains all text and tool calls generated
918 pub choice: Vec<AssistantContent>,
919 /// Whether the stream already reached its end and aggregated `choice`.
920 ///
921 /// [`PartsAccumulator::finish`] is destructive (it takes the accumulated
922 /// parts and falls back to one empty text part), so re-polling a drained
923 /// stream — which `Stream` permits and combinators do — would otherwise
924 /// replace a fully aggregated `choice` with empty text (#2258 H6).
925 finished: bool,
926 /// Parked wait on the pause channel while [`PauseControl`] holds the
927 /// stream paused; `None` whenever the stream is running (#2258 H7).
928 resume_wait: Option<ResumeWait>,
929 /// Rig-generated public correlators for reasoning parts, one per
930 /// accumulation key: stable across a part's deltas, unique per run, and
931 /// carrying nothing an accumulation key could leak.
932 reasoning_correlators: std::collections::HashMap<StreamPartId, String>,
933 /// Correlators of finished reasoning parts, kept for the stream's
934 /// lifetime (mirroring the accumulator's `finished_reasoning`): a
935 /// trailing signature-only end — Gemini's `thoughtSignature` after a
936 /// synthesized silent boundary — must restate the identity its part's
937 /// deltas carried, not mint a fresh one the assembler cannot match.
938 finished_reasoning_correlators: std::collections::HashMap<StreamPartId, String>,
939 /// The provider's normalized terminal record, may be `None`
940 /// if the provider didn't yield it during the stream
941 pub response: Option<StreamFinal>,
942 pub final_response_yielded: AtomicBool,
943 /// Provider-assigned message ID (e.g. OpenAI Responses API `msg_` ID).
944 pub message_id: Option<String>,
945}
946
947impl StreamingCompletionResponse {
948 /// Wrap a provider stream and initialize aggregation state.
949 ///
950 /// `provider` is the stable descriptor name of the provider producing the
951 /// stream; it is recorded up front so it is available even when the stream
952 /// never reaches its terminal record.
953 pub fn stream(provider: impl Into<String>, inner: StreamingResult) -> Self {
954 let (abort_handle, abort_registration) = AbortHandle::new_pair();
955 let abortable_stream = Abortable::new(inner, abort_registration);
956 let pause_control = PauseControl::new();
957 Self {
958 inner: abortable_stream,
959 abort_handle,
960 pause_control,
961 parts: PartsAccumulator::new(),
962 provider: provider.into(),
963 // A stream that has not produced anything yet has produced nothing.
964 // This used to hold a fabricated empty-text part because the field
965 // could not be empty; that part was indistinguishable from a real
966 // empty text block the model had emitted.
967 choice: Vec::new(),
968 finished: false,
969 resume_wait: None,
970 reasoning_correlators: std::collections::HashMap::new(),
971 finished_reasoning_correlators: std::collections::HashMap::new(),
972 response: None,
973 final_response_yielded: AtomicBool::new(false),
974 message_id: None,
975 }
976 }
977
978 /// Stable descriptor name of the provider producing this stream.
979 pub fn provider(&self) -> &str {
980 &self.provider
981 }
982
983 /// Resolve the public correlator for a reasoning part that just ended,
984 /// keeping the identity available for the part's afterlife.
985 ///
986 /// An end always clears the live delta map — a reused accumulation key
987 /// opens a NEW part whose deltas must mint fresh — but the taken
988 /// correlator moves to the finished map rather than dying, so trailing
989 /// metadata (a late signature after a synthesized silent end) restates
990 /// the identity the part's deltas carried. A restatement under a spent
991 /// key is a new sibling part: it mints fresh and overwrites the entry,
992 /// exactly as the accumulator overwrites its finished index. Entries
993 /// live until the stream is dropped, matching `finished_reasoning`.
994 fn reasoning_end_correlator(&mut self, id: StreamPartId, restated: bool) -> String {
995 match self.reasoning_correlators.remove(&id) {
996 Some(taken) => {
997 self.finished_reasoning_correlators
998 .insert(id, taken.clone());
999 taken
1000 }
1001 None if restated => {
1002 let minted = crate::id::generate();
1003 self.finished_reasoning_correlators
1004 .insert(id, minted.clone());
1005 minted
1006 }
1007 None => self
1008 .finished_reasoning_correlators
1009 .entry(id)
1010 .or_insert_with(crate::id::generate)
1011 .clone(),
1012 }
1013 }
1014
1015 /// Cancel the stream and immediately drop the provider's inner stream.
1016 /// Cancellation is surfaced as normal stream termination.
1017 ///
1018 /// Cancelling also resumes a paused stream: a consumer parked on the
1019 /// pause channel must observe the termination instead of waiting forever
1020 /// for a resume that will never affect a stream that no longer exists.
1021 pub fn cancel(&mut self) {
1022 self.abort_handle.abort();
1023 let (abort_handle, abort_registration) = AbortHandle::new_pair();
1024 let empty: StreamingResult = Box::pin(futures::stream::poll_fn(|_| Poll::Ready(None)));
1025 self.inner = Abortable::new(empty, abort_registration);
1026 self.abort_handle = abort_handle;
1027 self.pause_control.resume();
1028 }
1029
1030 /// Pause stream polling.
1031 pub fn pause(&self) {
1032 self.pause_control.pause();
1033 }
1034
1035 /// Resume stream polling after a pause.
1036 pub fn resume(&self) {
1037 self.pause_control.resume();
1038 }
1039
1040 /// Returns whether the stream is currently paused.
1041 pub fn is_paused(&self) -> bool {
1042 self.pause_control.is_paused()
1043 }
1044
1045 /// Token usage reported by the provider for this response.
1046 ///
1047 /// Returns the usage carried by the final response once the stream has
1048 /// produced it. Until then — or when the provider does not report streamed
1049 /// usage — this returns [`Usage::new`], the zero-valued sentinel for missing
1050 /// usage metrics.
1051 pub fn usage(&self) -> Usage {
1052 self.response
1053 .as_ref()
1054 .map(|response| response.usage)
1055 .unwrap_or_default()
1056 }
1057
1058 /// This stream's identity metadata as one
1059 /// [`crate::completion::ResponseIdentity`] carrier.
1060 ///
1061 /// The message id is read from the stream rather than the terminal record:
1062 /// an explicit `MessageId` event outranks the terminal's id, and the
1063 /// terminal record backfills the field when the stream never saw one. The
1064 /// response-scoped and transport ids exist only on the terminal record, so
1065 /// they stay `None` for a stream that ended without one.
1066 pub fn identity(&self) -> crate::completion::ResponseIdentity {
1067 crate::completion::ResponseIdentity {
1068 message_id: self.message_id.clone(),
1069 ..self
1070 .response
1071 .as_ref()
1072 .map(StreamFinal::identity)
1073 .unwrap_or_default()
1074 }
1075 }
1076}
1077
1078impl From<StreamingCompletionResponse> for CompletionResponse {
1079 fn from(value: StreamingCompletionResponse) -> CompletionResponse {
1080 // Usage is the zero sentinel (`Usage::new`) when the stream produced no
1081 // terminal record. `provider` comes from the stream itself rather than
1082 // the terminal record, so it is populated even then.
1083 let terminal = value.response.as_ref();
1084 CompletionResponse::new(
1085 value.choice,
1086 terminal.map(|response| response.usage).unwrap_or_default(),
1087 value.provider,
1088 )
1089 // An explicit `MessageId` event outranks the terminal record's ID.
1090 .with_optional_message_id(
1091 value
1092 .message_id
1093 .or_else(|| terminal.and_then(|response| response.message_id.clone())),
1094 )
1095 .with_optional_response_id(terminal.and_then(|response| response.response_id.clone()))
1096 .with_optional_provider_request_id(
1097 terminal.and_then(|response| response.provider_request_id.clone()),
1098 )
1099 .with_optional_finish_reason(terminal.and_then(|response| response.finish_reason.clone()))
1100 .with_optional_model(terminal.and_then(|response| response.model.clone()))
1101 }
1102}
1103
1104impl Stream for StreamingCompletionResponse {
1105 type Item = Result<StreamedAssistantContent, CompletionError>;
1106
1107 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1108 let stream = self.get_mut();
1109
1110 // A drained stream stays drained: `finish()` consumes the accumulated
1111 // parts, so re-polling must not run it again and clobber `choice`
1112 // with the empty-text fallback (#2258 H6).
1113 if stream.finished {
1114 return Poll::Ready(None);
1115 }
1116
1117 if stream.is_paused() {
1118 // Park on the pause channel rather than re-waking immediately: a
1119 // self-wake turns a pause into a busy poll loop that burns the
1120 // executor for as long as the consumer stays paused (#2258 H7).
1121 // `wait_for` evaluates the *current* value when it is first
1122 // polled, so a resume racing this branch resolves it at once
1123 // instead of parking forever on a notification already sent.
1124 let wait = match stream.resume_wait.as_mut() {
1125 Some(wait) => wait,
1126 None => {
1127 let mut paused_rx = stream.pause_control.paused_rx.clone();
1128 stream.resume_wait.insert(Box::pin(async move {
1129 let _ = paused_rx.wait_for(|paused| !*paused).await;
1130 }))
1131 }
1132 };
1133 if wait.as_mut().poll(cx).is_pending() {
1134 return Poll::Pending;
1135 }
1136 stream.resume_wait = None;
1137 }
1138
1139 // Non-yielding events (`continue` arms: block bookkeeping, dropped
1140 // ends, duplicate terminals) loop rather than recurse — a long run of
1141 // them must not grow the stack (#2258 review P3).
1142 loop {
1143 return match Pin::new(&mut stream.inner).poll_next(cx) {
1144 Poll::Pending => Poll::Pending,
1145 Poll::Ready(None) => {
1146 // Run at the end of the inner stream to collect all tokens
1147 // into a single unified `Message`. `finish` can now be
1148 // empty — a turn that streamed nothing is no longer padded
1149 // with a fabricated empty-text part — and an empty result
1150 // leaves the already-empty `choice` alone.
1151 let finished = stream.parts.finish();
1152 if !finished.is_empty() {
1153 stream.choice = finished;
1154 }
1155 stream.finished = true;
1156
1157 Poll::Ready(None)
1158 }
1159 // Every error reaches the consumer. Cancellation is *not* an
1160 // error here: `cancel()` aborts through `Abortable`, which
1161 // terminates the inner stream with `Ready(None)` above, so
1162 // the aggregated choice is finished normally. (Until #2258 H8
1163 // this arm swallowed any `ProviderError` whose text merely
1164 // contained "aborted", reporting clean EOF while silently
1165 // discarding both the error and the streamed content.)
1166 Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
1167 Poll::Ready(Some(Ok(choice))) => match choice {
1168 RawStreamingChoice::Message(text) => {
1169 stream.parts.text_delta(&text);
1170 Poll::Ready(Some(Ok(StreamedAssistantContent::text(&text))))
1171 }
1172 RawStreamingChoice::TextStart {
1173 id,
1174 additional_params,
1175 } => {
1176 stream.parts.text_start(&id, additional_params);
1177 continue;
1178 }
1179 RawStreamingChoice::TextAdditionalParams(additional_params) => {
1180 stream.parts.text_additional_params(additional_params);
1181 continue;
1182 }
1183 RawStreamingChoice::ToolCallDelta { id, content } => {
1184 // The accumulator owns assembly; it mints the internal
1185 // correlation id when the call opens and returns it for
1186 // every fragment, so the public delta stays correlated
1187 // with the eventual completed call.
1188 let internal_call_id = match &content {
1189 ToolCallDeltaContent::Name(name) => {
1190 stream.parts.tool_name_delta(&id, name)
1191 }
1192 ToolCallDeltaContent::Delta(fragment) => {
1193 stream.parts.tool_args_delta(&id, fragment)
1194 }
1195 };
1196 Poll::Ready(Some(Ok(StreamedAssistantContent::ToolCallDelta {
1197 internal_call_id,
1198 content,
1199 })))
1200 }
1201 RawStreamingChoice::ToolInputEnd(end) => match stream.parts.tool_input_end(end)
1202 {
1203 Ok(Some((tool_call, internal_call_id))) => {
1204 Poll::Ready(Some(Ok(StreamedAssistantContent::ToolCall {
1205 tool_call,
1206 internal_call_id,
1207 })))
1208 }
1209 // Dropped (nameless or partial input): not content.
1210 Ok(None) => continue,
1211 // Malformed complete input surfaces in-band; the stream
1212 // keeps consuming, matching the malformed-frame contract.
1213 Err(err) => Poll::Ready(Some(Err(err))),
1214 },
1215 RawStreamingChoice::Reasoning {
1216 id,
1217 provider_id,
1218 content,
1219 } => {
1220 // A whole block is open + authoritative restatement
1221 // + close in one event. The durable `Reasoning::id`
1222 // comes only from the provider-issued handle; the
1223 // accumulation key is opaque and cannot reach the
1224 // replayable message.
1225 let restatement = Reasoning {
1226 id: provider_id.map(WireId::into_string),
1227 content: vec![content],
1228 };
1229 let completed = stream.parts.reasoning_end(&id, Some(restatement), None);
1230 // The part is finished: its delta correlator (fresh-
1231 // minted for a block with no prior deltas) is restated
1232 // on the completed event and retained for trailing
1233 // metadata under the same key.
1234 let correlator = stream.reasoning_end_correlator(id, true);
1235 match completed {
1236 Some(completed) => {
1237 Poll::Ready(Some(Ok(StreamedAssistantContent::Reasoning {
1238 reasoning: completed,
1239 id: correlator,
1240 })))
1241 }
1242 None => continue,
1243 }
1244 }
1245 RawStreamingChoice::ReasoningStart { id, provider_id } => {
1246 // A start that genuinely opened a part installs a
1247 // fresh live correlator: without it, a part that
1248 // closes with no deltas (a signature-only end under
1249 // a reused key) would fall back to the finished map
1250 // and inherit the PREVIOUS part's public identity.
1251 if stream.parts.reasoning_start(&id, provider_id.as_ref()) {
1252 stream
1253 .reasoning_correlators
1254 .insert(id, crate::id::generate());
1255 }
1256 continue;
1257 }
1258 RawStreamingChoice::ReasoningEnd {
1259 id,
1260 reasoning,
1261 signature,
1262 wire_sent,
1263 } => {
1264 // The completed block is yielded when the wire said
1265 // something at the boundary: an end payload (a
1266 // restatement or a signature) or a bare end frame
1267 // the wire actually sent (anthropic's
1268 // `content_block_stop` on an unsigned block). Only a
1269 // bare end an adapter *synthesized* stays silent —
1270 // the consumer already received every delta, and
1271 // fabricating a "completed block" event the wire
1272 // never sent would change what downstream history
1273 // builders observe.
1274 let authoritative = reasoning.is_some() || signature.is_some() || wire_sent;
1275 let restated = reasoning.is_some();
1276 let completed = stream.parts.reasoning_end(&id, reasoning, signature);
1277 // The part is finished: the live delta map is cleared
1278 // unconditionally — a suppressed synthesized end must
1279 // still make a reused key mint fresh — but the
1280 // correlator survives in the finished map, so a
1281 // trailing signature-bearing end for this key restates
1282 // the identity its deltas carried instead of minting
1283 // one the assembler cannot match.
1284 let correlator = stream.reasoning_end_correlator(id, restated);
1285 match completed {
1286 Some(completed) if authoritative => {
1287 Poll::Ready(Some(Ok(StreamedAssistantContent::Reasoning {
1288 reasoning: completed,
1289 id: correlator,
1290 })))
1291 }
1292 _ => continue,
1293 }
1294 }
1295 RawStreamingChoice::TextEnd { id } => {
1296 stream.parts.text_end(&id);
1297 continue;
1298 }
1299 RawStreamingChoice::ReasoningDelta {
1300 id,
1301 provider_id,
1302 reasoning,
1303 } => {
1304 stream
1305 .parts
1306 .reasoning_delta(&id, provider_id.as_ref(), &reasoning);
1307 // The public delta carries a rig-generated correlator
1308 // (stable per part, unique per run) plus the durable
1309 // provider id when one exists. The opaque
1310 // accumulation key is never observable.
1311 let correlator = stream
1312 .reasoning_correlators
1313 .entry(id)
1314 .or_insert_with(crate::id::generate)
1315 .clone();
1316 Poll::Ready(Some(Ok(StreamedAssistantContent::ReasoningDelta {
1317 id: correlator,
1318 provider_id: provider_id.map(WireId::into_string),
1319 reasoning,
1320 })))
1321 }
1322 RawStreamingChoice::ToolCall(raw_tool_call) => {
1323 let minted_internal_call_id = raw_tool_call.internal_call_id.clone();
1324 let part_id = raw_tool_call.id.clone();
1325 let tool_call: ToolCall = raw_tool_call.into();
1326 // A wire that fragmented this call's input already
1327 // published an internal id on its deltas; the
1328 // accumulator adopts it so the completed call stays
1329 // correlated with them (the contract on
1330 // `StreamedAssistantContent::ToolCall`). With no open
1331 // assembly the emitter's minted id is kept.
1332 let internal_call_id = stream.parts.tool_call(
1333 &part_id,
1334 tool_call.clone(),
1335 minted_internal_call_id,
1336 );
1337 Poll::Ready(Some(Ok(StreamedAssistantContent::ToolCall {
1338 tool_call,
1339 internal_call_id,
1340 })))
1341 }
1342 RawStreamingChoice::FinalResponse(mut response) => {
1343 // Assembled tool calls never pass `normalize_stream` as
1344 // `RawStreamingChoice::ToolCall`, so the finish-reason
1345 // reconciliation runs here too, against the accumulator's
1346 // authoritative view of completed calls. Idempotent over
1347 // the reconciliation `normalize_stream` already applied.
1348 response.finish_reason = response.finish_reason.map(|reason| {
1349 reason.reconcile_with_output(stream.parts.saw_tool_call())
1350 });
1351 if stream
1352 .final_response_yielded
1353 .load(std::sync::atomic::Ordering::SeqCst)
1354 {
1355 continue;
1356 } else {
1357 // Set the final response field and return the next item in the stream.
1358 // An explicit `MessageId` event keeps precedence; the
1359 // terminal record only fills a gap.
1360 if stream.message_id.is_none() {
1361 stream.message_id = response.message_id.clone();
1362 }
1363 stream.response = Some(response.clone());
1364 stream
1365 .final_response_yielded
1366 .store(true, std::sync::atomic::Ordering::SeqCst);
1367 let final_response = StreamedAssistantContent::final_response(response);
1368 Poll::Ready(Some(Ok(final_response)))
1369 }
1370 }
1371 RawStreamingChoice::MessageId(id) => {
1372 stream.message_id = Some(id);
1373 continue;
1374 }
1375 RawStreamingChoice::Unknown(value) => {
1376 // Pass an unmodeled provider item straight through to the
1377 // consumer; it is intentionally not pushed into
1378 // `assistant_items` (no `AssistantContent::Unknown` exists).
1379 // No exclusion warning here: everything reaching this arm
1380 // is a live wire frame a provider adapter chose not to
1381 // model (adapters warn on those themselves) — a persisted
1382 // item that failed the strict `Text` decode is created by
1383 // consumer-side serde and never re-enters this stream.
1384 // The agent assembler, which does ingest such items,
1385 // carries that warning.
1386 Poll::Ready(Some(Ok(StreamedAssistantContent::Unknown(value))))
1387 }
1388 },
1389 };
1390 }
1391 }
1392}
1393
1394// Test module
1395#[cfg(test)]
1396mod tests {
1397 use std::time::Duration;
1398
1399 use super::*;
1400 use crate::completion::FinishReason;
1401 use async_stream::stream;
1402 use tokio::time::sleep;
1403
1404 /// Provider descriptor used by the mock streams in this module.
1405 const TEST_PROVIDER: &str = "test-provider";
1406
1407 /// Fixture params: the JSON literal is always a non-empty object.
1408 fn fixture_params(value: serde_json::Value) -> crate::message::AdditionalParams {
1409 crate::message::AdditionalParams::try_from_value(value)
1410 .expect("fixture params must be a JSON object")
1411 .expect("fixture params must carry data")
1412 }
1413
1414 /// Terminal record with a known total-token count.
1415 fn mock_final_with_total_tokens(total_tokens: u64) -> StreamFinal {
1416 let mut usage = Usage::new();
1417 usage.total_tokens = total_tokens;
1418 StreamFinal::new(TEST_PROVIDER, usage)
1419 }
1420
1421 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1422 fn to_stream_result(
1423 stream: impl futures::Stream<Item = Result<RawStreamingChoice, CompletionError>>
1424 + Send
1425 + 'static,
1426 ) -> StreamingResult {
1427 Box::pin(stream)
1428 }
1429
1430 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1431 fn to_stream_result(
1432 stream: impl futures::Stream<Item = Result<RawStreamingChoice, CompletionError>> + 'static,
1433 ) -> StreamingResult {
1434 Box::pin(stream)
1435 }
1436
1437 fn create_mock_stream() -> StreamingCompletionResponse {
1438 let stream = stream! {
1439 yield Ok(RawStreamingChoice::Message("hello 1".to_string()));
1440 sleep(Duration::from_millis(100)).await;
1441 yield Ok(RawStreamingChoice::Message("hello 2".to_string()));
1442 sleep(Duration::from_millis(100)).await;
1443 yield Ok(RawStreamingChoice::Message("hello 3".to_string()));
1444 sleep(Duration::from_millis(100)).await;
1445 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(15)));
1446 };
1447
1448 StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(stream))
1449 }
1450
1451 /// #2258 review P3: non-yielding events (`MessageId` here) drive the
1452 /// `poll_next` loop instead of synchronous self-recursion, so a long run
1453 /// of them cannot grow the stack. Pre-fix, each of these frames was one
1454 /// recursive `poll_next` stack frame and a run this long overflowed in
1455 /// debug builds.
1456 #[tokio::test]
1457 async fn a_long_run_of_non_yielding_events_does_not_grow_the_stack() {
1458 let raw = stream! {
1459 for n in 0..50_000u32 {
1460 yield Ok(RawStreamingChoice::MessageId(format!("msg_{n}")));
1461 }
1462 yield Ok(RawStreamingChoice::Message("done".to_string()));
1463 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(1)));
1464 };
1465 let mut stream = StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(raw));
1466
1467 let mut texts = Vec::new();
1468 while let Some(item) = stream.next().await {
1469 if let Ok(StreamedAssistantContent::Text(text)) = item {
1470 texts.push(text.text);
1471 }
1472 }
1473 assert_eq!(texts, vec!["done".to_string()]);
1474 // The last id recorded wins.
1475 assert_eq!(stream.message_id.as_deref(), Some("msg_49999"));
1476 }
1477
1478 /// A stream that never saw a `MessageId` event takes all three identity
1479 /// axes from the terminal record.
1480 #[tokio::test]
1481 async fn stream_identity_falls_back_to_the_terminal_records_ids() {
1482 let raw = stream! {
1483 yield Ok(RawStreamingChoice::Message("done".to_string()));
1484 yield Ok(RawStreamingChoice::FinalResponse(
1485 mock_final_with_total_tokens(1)
1486 .with_message_id("msg_terminal")
1487 .with_response_id("resp_1")
1488 .with_provider_request_id("req_1"),
1489 ));
1490 };
1491 let mut stream = StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(raw));
1492 while stream.next().await.is_some() {}
1493
1494 assert_eq!(
1495 stream.identity(),
1496 crate::completion::ResponseIdentity {
1497 message_id: Some("msg_terminal".to_string()),
1498 response_id: Some("resp_1".to_string()),
1499 provider_request_id: Some("req_1".to_string()),
1500 }
1501 );
1502 }
1503
1504 /// An explicit `MessageId` event outranks the terminal record's message id;
1505 /// the response-scoped and transport ids still come from the terminal.
1506 #[tokio::test]
1507 async fn stream_identity_prefers_an_explicit_message_id_event() {
1508 let raw = stream! {
1509 yield Ok(RawStreamingChoice::MessageId("msg_event".to_string()));
1510 yield Ok(RawStreamingChoice::Message("done".to_string()));
1511 yield Ok(RawStreamingChoice::FinalResponse(
1512 mock_final_with_total_tokens(1)
1513 .with_message_id("msg_terminal")
1514 .with_response_id("resp_1"),
1515 ));
1516 };
1517 let mut stream = StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(raw));
1518 while stream.next().await.is_some() {}
1519
1520 assert_eq!(
1521 stream.identity(),
1522 crate::completion::ResponseIdentity {
1523 message_id: Some("msg_event".to_string()),
1524 response_id: Some("resp_1".to_string()),
1525 provider_request_id: None,
1526 }
1527 );
1528 }
1529
1530 fn create_reasoning_stream() -> StreamingCompletionResponse {
1531 let stream = stream! {
1532 yield Ok(RawStreamingChoice::Reasoning { id: StreamPartId::wire("rs_1"),
1533 provider_id: WireId::new("rs_1"),
1534 content: ReasoningContent::Text {
1535 text: "step one".to_string(),
1536 signature: Some("sig_1".to_string()),
1537 },
1538 });
1539 yield Ok(RawStreamingChoice::Message("final answer".to_string()));
1540 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(5)));
1541 };
1542
1543 StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(stream))
1544 }
1545
1546 fn create_reasoning_only_stream() -> StreamingCompletionResponse {
1547 let stream = stream! {
1548 yield Ok(RawStreamingChoice::Reasoning { id: StreamPartId::wire("rs_only"),
1549 provider_id: WireId::new("rs_only"),
1550 content: ReasoningContent::Summary("hidden summary".to_string()),
1551 });
1552 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
1553 };
1554
1555 StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(stream))
1556 }
1557
1558 fn create_interleaved_stream() -> StreamingCompletionResponse {
1559 let stream = stream! {
1560 yield Ok(RawStreamingChoice::Reasoning { id: StreamPartId::wire("rs_interleaved"),
1561 provider_id: WireId::new("rs_interleaved"),
1562 content: ReasoningContent::Text {
1563 text: "chain-of-thought".to_string(),
1564 signature: None,
1565 },
1566 });
1567 yield Ok(RawStreamingChoice::Message("final-text".to_string()));
1568 yield Ok(RawStreamingChoice::ToolCall(
1569 RawStreamingToolCall::new(
1570 "tool_1".to_string(),
1571 "mock_tool".to_string(),
1572 serde_json::json!({"arg": 1}),
1573 ),
1574 ));
1575 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(3)));
1576 };
1577
1578 StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(stream))
1579 }
1580
1581 fn create_text_tool_text_stream() -> StreamingCompletionResponse {
1582 let stream = stream! {
1583 yield Ok(RawStreamingChoice::Message("first".to_string()));
1584 yield Ok(RawStreamingChoice::ToolCall(
1585 RawStreamingToolCall::new(
1586 "tool_split".to_string(),
1587 "mock_tool".to_string(),
1588 serde_json::json!({"arg": "x"}),
1589 ),
1590 ));
1591 yield Ok(RawStreamingChoice::Message("second".to_string()));
1592 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(3)));
1593 };
1594
1595 StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(stream))
1596 }
1597
1598 fn create_text_metadata_stream() -> StreamingCompletionResponse {
1599 let stream = stream! {
1600 yield Ok(RawStreamingChoice::TextStart {
1601 id: StreamPartId::wire("block-0"),
1602 additional_params: None,
1603 });
1604 yield Ok(RawStreamingChoice::Message("first".to_string()));
1605 yield Ok(RawStreamingChoice::TextAdditionalParams(fixture_params(serde_json::json!({
1606 "citations": [{
1607 "type": "char_location",
1608 "cited_text": "First citation.",
1609 "document_index": 0,
1610 "start_char_index": 0,
1611 "end_char_index": 15
1612 }]
1613 }))));
1614 yield Ok(RawStreamingChoice::TextAdditionalParams(fixture_params(serde_json::json!({
1615 "citations": [{
1616 "type": "char_location",
1617 "cited_text": "Second citation.",
1618 "document_index": 0,
1619 "start_char_index": 16,
1620 "end_char_index": 32
1621 }]
1622 }))));
1623 yield Ok(RawStreamingChoice::TextStart {
1624 id: StreamPartId::wire("block-1"),
1625 additional_params: crate::message::AdditionalParams::try_from_value(serde_json::json!({
1626 "block": 2
1627 })).expect("object params"),
1628 });
1629 yield Ok(RawStreamingChoice::Message("second".to_string()));
1630 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(3)));
1631 };
1632
1633 StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(stream))
1634 }
1635
1636 #[tokio::test]
1637 async fn into_completion_response_derives_usage_from_final_response() {
1638 let mut stream = create_mock_stream();
1639
1640 // Drain the stream so the final response (and its usage) is captured.
1641 while stream.next().await.is_some() {}
1642
1643 // usage() surfaces the final response's token usage...
1644 assert_eq!(stream.usage().total_tokens, 15);
1645
1646 // ...and the From conversion carries it instead of a zero sentinel.
1647 let response: CompletionResponse = stream.into();
1648 assert_eq!(response.usage.total_tokens, 15);
1649 assert_eq!(response.provider, TEST_PROVIDER);
1650 }
1651
1652 /// Regression (rig#2265): the transport request id captured on the
1653 /// terminal record must survive stream→`CompletionResponse` conversion,
1654 /// exactly like the response id, usage, finish reason, and model do.
1655 #[tokio::test]
1656 async fn into_completion_response_carries_the_terminal_request_id() {
1657 let mut stream = StreamingCompletionResponse::stream(
1658 TEST_PROVIDER,
1659 to_stream_result(stream! {
1660 yield Ok(RawStreamingChoice::Message("hi".to_string()));
1661 yield Ok(RawStreamingChoice::FinalResponse(
1662 StreamFinal::new(TEST_PROVIDER, Usage::new())
1663 .with_response_id("resp_1")
1664 .with_provider_request_id("req_transport_1"),
1665 ));
1666 }),
1667 );
1668 while stream.next().await.is_some() {}
1669
1670 let response: CompletionResponse = stream.into();
1671 assert_eq!(response.response_id.as_deref(), Some("resp_1"));
1672 assert_eq!(
1673 response.provider_request_id.as_deref(),
1674 Some("req_transport_1")
1675 );
1676 }
1677
1678 #[tokio::test]
1679 async fn a_stream_without_a_terminal_record_still_names_its_provider() {
1680 // The provider is known when the stream is opened, so a stream that
1681 // errors or is truncated before its terminal record must not degrade
1682 // `provider` to an empty string — every other missing value has a
1683 // documented sentinel (`Usage::new`, `None`) and this one should too.
1684 let mut stream = StreamingCompletionResponse::stream(
1685 TEST_PROVIDER,
1686 to_stream_result(stream! {
1687 yield Ok(RawStreamingChoice::Message("truncated".to_string()));
1688 }),
1689 );
1690 while stream.next().await.is_some() {}
1691
1692 // No terminal record was ever yielded, so none may be synthesized.
1693 assert!(stream.response.is_none());
1694
1695 let response: CompletionResponse = stream.into();
1696 assert_eq!(response.provider, TEST_PROVIDER);
1697 assert_eq!(response.usage, Usage::new());
1698 assert_eq!(response.finish_reason(), None);
1699 assert_eq!(response.model, None);
1700 }
1701
1702 #[tokio::test]
1703 async fn a_stream_that_errors_mid_stream_keeps_content_and_omits_the_terminal() {
1704 // A transport error after some content must forward the error, keep
1705 // the content already aggregated, and never fabricate a terminal
1706 // record the provider did not send.
1707 let mut stream = StreamingCompletionResponse::stream(
1708 TEST_PROVIDER,
1709 to_stream_result(stream! {
1710 yield Ok(RawStreamingChoice::Message("partial".to_string()));
1711 yield Err(CompletionError::ProviderError(
1712 "connection reset".to_string(),
1713 ));
1714 }),
1715 );
1716
1717 let mut saw_error = false;
1718 while let Some(item) = stream.next().await {
1719 if item.is_err() {
1720 saw_error = true;
1721 }
1722 }
1723 assert!(saw_error, "the mid-stream error must be forwarded");
1724
1725 // No StreamFinal may be synthesized for the aborted stream...
1726 assert!(stream.response.is_none());
1727
1728 // ...but the content delivered before the error is preserved.
1729 assert_eq!(
1730 stream.choice.first(),
1731 Some(&AssistantContent::text("partial".to_string())),
1732 );
1733 }
1734
1735 #[tokio::test]
1736 async fn normalize_stream_upgrades_a_stop_that_carried_a_tool_call() {
1737 // Several gateways report a plain `stop` on a tool-calling turn. The
1738 // streaming path must reconcile it exactly as the unary path does.
1739 let raw: RawStreamingResult<Usage> = Box::pin(stream! {
1740 yield Ok(RawStreamingChoice::ToolCall(RawStreamingToolCall {
1741 tool_id: WireId::new("call_1"),
1742 id: StreamPartId::wire("call_1"),
1743 call_id: None,
1744 internal_call_id: "internal_1".to_string(),
1745 name: "lookup".to_string(),
1746 arguments: serde_json::json!({}),
1747 signature: None,
1748 additional_params: None,
1749 }));
1750 yield Ok(RawStreamingChoice::FinalResponse(Usage::new()));
1751 });
1752
1753 let normalized = normalize_stream(raw, |usage| {
1754 Ok(StreamFinal::new(TEST_PROVIDER, usage).with_finish_reason(FinishReason::Stop))
1755 });
1756
1757 let mut stream = StreamingCompletionResponse::stream(TEST_PROVIDER, normalized);
1758 while stream.next().await.is_some() {}
1759
1760 assert_eq!(
1761 stream
1762 .response
1763 .as_ref()
1764 .and_then(|final_record| final_record.finish_reason.clone()),
1765 Some(FinishReason::ToolCalls),
1766 );
1767 }
1768
1769 #[tokio::test]
1770 async fn normalize_stream_leaves_a_stop_without_tool_calls_alone() {
1771 let raw: RawStreamingResult<Usage> = Box::pin(stream! {
1772 yield Ok(RawStreamingChoice::Message("done".to_string()));
1773 yield Ok(RawStreamingChoice::FinalResponse(Usage::new()));
1774 });
1775
1776 let normalized = normalize_stream(raw, |usage| {
1777 Ok(StreamFinal::new(TEST_PROVIDER, usage).with_finish_reason(FinishReason::Stop))
1778 });
1779
1780 let mut stream = StreamingCompletionResponse::stream(TEST_PROVIDER, normalized);
1781 while stream.next().await.is_some() {}
1782
1783 assert_eq!(
1784 stream
1785 .response
1786 .as_ref()
1787 .and_then(|final_record| final_record.finish_reason.clone()),
1788 Some(FinishReason::Stop),
1789 );
1790 }
1791
1792 #[test]
1793 fn stream_final_round_trips_and_is_distinguishable_from_unknown_content() {
1794 let final_record = StreamFinal::new(
1795 "example",
1796 Usage {
1797 input_tokens: 4,
1798 output_tokens: 6,
1799 total_tokens: 10,
1800 cached_input_tokens: 1,
1801 cache_creation_input_tokens: 2,
1802 tool_use_prompt_tokens: 3,
1803 reasoning_tokens: 4,
1804 },
1805 )
1806 .with_finish_reason(FinishReason::Other("future_reason".to_owned()))
1807 .with_message_id("msg_123")
1808 .with_model("provider-model-v2");
1809
1810 let encoded = serde_json::to_value(StreamedAssistantContent::Final(final_record.clone()))
1811 .expect("serialize final item");
1812 assert_eq!(encoded["kind"], serde_json::json!("final"));
1813
1814 let decoded = serde_json::from_value::<StreamedAssistantContent>(encoded)
1815 .expect("deserialize final item");
1816 assert_eq!(decoded, StreamedAssistantContent::Final(final_record));
1817
1818 // An unmodeled provider item must still land in `Unknown` rather than
1819 // being mistaken for a terminal record.
1820 let provider_item = serde_json::json!({
1821 "provider_native_event": "future_terminal",
1822 "usage": {"total_tokens": 10}
1823 });
1824 let decoded = serde_json::from_value::<StreamedAssistantContent>(provider_item.clone())
1825 .expect("deserialize unknown item");
1826 assert_eq!(
1827 decoded,
1828 StreamedAssistantContent::Unknown(provider_item.into())
1829 );
1830 }
1831
1832 /// Deserialization funnels through `new` + the setters, so the invariants
1833 /// hold on persisted values too: a `""` identifier comes back as `None`.
1834 #[test]
1835 fn deserializing_stream_final_filters_empty_identifiers() {
1836 let decoded = serde_json::from_value::<StreamFinal>(serde_json::json!({
1837 "kind": "final",
1838 "usage": Usage::new(),
1839 "message_id": "",
1840 "response_id": "",
1841 "model": "",
1842 "provider": "example",
1843 }))
1844 .expect("deserialize terminal record");
1845
1846 assert_eq!(decoded.message_id, None);
1847 assert_eq!(decoded.response_id, None);
1848 assert_eq!(decoded.model, None);
1849 }
1850
1851 /// A provider-native terminal type standing in for the real ones: it
1852 /// carries a field the normalized record does not model, so the test can
1853 /// tell "the raw payload is the terminal record" from "some value was
1854 /// attached".
1855 #[derive(Debug, Serialize, Deserialize, PartialEq)]
1856 struct ProviderTerminal {
1857 usage: Usage,
1858 provider_only: String,
1859 }
1860
1861 fn provider_terminal_stream() -> RawStreamingResult<ProviderTerminal> {
1862 Box::pin(stream! {
1863 yield Ok(RawStreamingChoice::Message("done".to_string()));
1864 yield Ok(RawStreamingChoice::FinalResponse(ProviderTerminal {
1865 usage: Usage {
1866 input_tokens: 3,
1867 output_tokens: 5,
1868 total_tokens: 8,
1869 ..Usage::new()
1870 },
1871 provider_only: "kept".to_string(),
1872 }));
1873 })
1874 }
1875
1876 async fn drain(normalized: StreamingResult) -> StreamFinal {
1877 let mut stream = StreamingCompletionResponse::stream(TEST_PROVIDER, normalized);
1878 while stream.next().await.is_some() {}
1879 stream
1880 .response
1881 .expect("stream should end with a terminal record")
1882 }
1883
1884 /// The load-bearing streaming test: `raw` is the provider's terminal
1885 /// record serialized — it deserializes back into the provider's own type
1886 /// and re-serializes equal — and the normalized fields are what the
1887 /// mapper produced.
1888 #[tokio::test]
1889 async fn normalize_stream_captures_the_terminal_record() {
1890 let normalized = normalize_stream(provider_terminal_stream(), |terminal| {
1891 Ok(StreamFinal::new(TEST_PROVIDER, terminal.usage))
1892 });
1893 let final_record = drain(normalized).await;
1894 let raw = &final_record.raw;
1895
1896 let typed = ProviderTerminal::deserialize(raw).expect("raw is the provider's terminal");
1897 assert_eq!(typed.provider_only, "kept");
1898 assert_eq!(&serde_json::to_value(&typed).expect("re-serialize"), raw);
1899
1900 assert_eq!(final_record.usage.total_tokens, 8);
1901 assert_eq!(final_record.provider, TEST_PROVIDER);
1902 assert_eq!(final_record.finish_reason, None);
1903 }
1904
1905 /// Finish-reason reconciliation is unchanged by capture: a `stop` that
1906 /// carried a tool call is still upgraded, with `raw` attached.
1907 #[tokio::test]
1908 async fn normalize_stream_reconciles_finish_reason_with_raw_attached() {
1909 let raw: RawStreamingResult<Usage> = Box::pin(stream! {
1910 yield Ok(RawStreamingChoice::ToolCall(RawStreamingToolCall {
1911 tool_id: WireId::new("call_1"),
1912 id: StreamPartId::wire("call_1"),
1913 call_id: None,
1914 internal_call_id: "internal_1".to_string(),
1915 name: "lookup".to_string(),
1916 arguments: serde_json::json!({}),
1917 signature: None,
1918 additional_params: None,
1919 }));
1920 yield Ok(RawStreamingChoice::FinalResponse(Usage::new()));
1921 });
1922 let normalized = normalize_stream(raw, |usage| {
1923 Ok(StreamFinal::new(TEST_PROVIDER, usage).with_finish_reason(FinishReason::Stop))
1924 });
1925 let final_record = drain(normalized).await;
1926 assert_eq!(final_record.finish_reason, Some(FinishReason::ToolCalls));
1927 assert!(!final_record.raw.is_null());
1928 }
1929
1930 /// The deserialization mirror carries `raw`: a terminal record with a
1931 /// captured payload survives serialize → deserialize with the payload
1932 /// intact, both bare and wrapped in `StreamedAssistantContent::Final`
1933 /// (the shape the agent forwards). A record serialized before the field
1934 /// existed still loads, with `raw` unset.
1935 #[test]
1936 fn stream_final_raw_round_trips_through_serde_mirror() {
1937 let payload = serde_json::json!({
1938 "usage": {"total_tokens": 8},
1939 "provider_only": "kept"
1940 });
1941 let final_record = StreamFinal::new("example", Usage::new())
1942 .with_message_id("msg_123")
1943 .with_raw(payload.clone());
1944
1945 let encoded = serde_json::to_value(&final_record).expect("serialize");
1946 assert_eq!(encoded["raw"], payload);
1947 let decoded = serde_json::from_value::<StreamFinal>(encoded.clone()).expect("deserialize");
1948 assert_eq!(decoded.raw, payload);
1949 assert_eq!(decoded, final_record);
1950 assert_eq!(
1951 serde_json::to_value(&decoded).expect("re-serialize"),
1952 encoded
1953 );
1954
1955 let wrapped = StreamedAssistantContent::Final(final_record.clone());
1956 let encoded = serde_json::to_value(&wrapped).expect("serialize wrapped");
1957 let decoded = serde_json::from_value::<StreamedAssistantContent>(encoded)
1958 .expect("deserialize wrapped");
1959 assert_eq!(decoded, wrapped);
1960
1961 // Pre-field JSON: no `raw` key.
1962 let legacy = serde_json::json!({
1963 "kind": "final",
1964 "usage": serde_json::to_value(Usage::new()).unwrap(),
1965 "provider": "example"
1966 });
1967 let decoded = serde_json::from_value::<StreamFinal>(legacy).expect("legacy loads");
1968 assert!(decoded.raw.is_null());
1969
1970 // Unset `raw` is not written, so a record without capture serializes
1971 // exactly as it did before the field existed.
1972 let bare = serde_json::to_value(StreamFinal::new("example", Usage::new())).unwrap();
1973 assert!(bare.get("raw").is_none());
1974 }
1975
1976 /// The deserialization mirror must not change the wire format: a fully
1977 /// populated terminal record round-trips to byte-identical JSON.
1978 #[test]
1979 fn stream_final_serde_round_trip_is_identity() {
1980 let final_record = StreamFinal::new(
1981 "example",
1982 Usage {
1983 input_tokens: 4,
1984 output_tokens: 6,
1985 total_tokens: 10,
1986 cached_input_tokens: 1,
1987 cache_creation_input_tokens: 2,
1988 tool_use_prompt_tokens: 3,
1989 reasoning_tokens: 4,
1990 },
1991 )
1992 .with_finish_reason(FinishReason::Stop)
1993 .with_message_id("msg_123")
1994 .with_response_id("resp_456")
1995 .with_model("provider-model-v2");
1996
1997 let encoded = serde_json::to_value(&final_record).expect("serialize terminal record");
1998 assert_eq!(encoded["kind"], serde_json::json!("final"));
1999
2000 let decoded = serde_json::from_value::<StreamFinal>(encoded.clone()).expect("deserialize");
2001 assert_eq!(decoded, final_record);
2002 assert_eq!(
2003 serde_json::to_value(&decoded).expect("re-serialize"),
2004 encoded
2005 );
2006 }
2007
2008 #[tokio::test]
2009 async fn usage_is_zero_sentinel_before_final_response() {
2010 // A stream that never yields a FinalResponse reports the zero sentinel.
2011 let stream = StreamingCompletionResponse::stream(
2012 TEST_PROVIDER,
2013 to_stream_result(stream! {
2014 yield Ok(RawStreamingChoice::Message("no final response".to_string()));
2015 }),
2016 );
2017 assert_eq!(stream.usage().total_tokens, 0);
2018 }
2019
2020 #[tokio::test]
2021 async fn test_stream_cancellation() {
2022 let mut stream = create_mock_stream();
2023
2024 println!("Response: ");
2025 let mut chunk_count = 0;
2026 while let Some(chunk) = stream.next().await {
2027 match chunk {
2028 Ok(StreamedAssistantContent::Text(text)) => {
2029 print!("{}", text.text);
2030 std::io::Write::flush(&mut std::io::stdout()).unwrap();
2031 chunk_count += 1;
2032 }
2033 Ok(StreamedAssistantContent::ToolCall {
2034 tool_call,
2035 internal_call_id,
2036 }) => {
2037 println!("\nTool Call: {tool_call:?}, internal_call_id={internal_call_id:?}");
2038 chunk_count += 1;
2039 }
2040 Ok(StreamedAssistantContent::ToolCallDelta {
2041 internal_call_id,
2042 content,
2043 }) => {
2044 println!(
2045 "\nTool Call delta: internal_call_id={internal_call_id:?}, content={content:?}"
2046 );
2047 chunk_count += 1;
2048 }
2049 Ok(StreamedAssistantContent::Final(res)) => {
2050 println!("\nFinal response: {res:?}");
2051 }
2052 Ok(StreamedAssistantContent::Reasoning { reasoning, .. }) => {
2053 let reasoning = reasoning.display_text();
2054 print!("{reasoning}");
2055 std::io::Write::flush(&mut std::io::stdout()).unwrap();
2056 }
2057 Ok(StreamedAssistantContent::ReasoningDelta { reasoning, .. }) => {
2058 println!("Reasoning delta: {reasoning}");
2059 chunk_count += 1;
2060 }
2061 Ok(StreamedAssistantContent::Unknown(value)) => {
2062 println!("\nUnknown item: {value:?}");
2063 chunk_count += 1;
2064 }
2065 Err(e) => {
2066 eprintln!("Error: {e:?}");
2067 break;
2068 }
2069 }
2070
2071 if chunk_count >= 2 {
2072 println!("\nCancelling stream...");
2073 stream.cancel();
2074 println!("Stream cancelled.");
2075 break;
2076 }
2077 }
2078
2079 let next_chunk = stream.next().await;
2080 assert!(
2081 next_chunk.is_none(),
2082 "Expected no further chunks after cancellation, got {next_chunk:?}"
2083 );
2084 }
2085
2086 #[tokio::test]
2087 async fn test_stream_pause_resume() {
2088 let stream = create_mock_stream();
2089
2090 // Test pause
2091 stream.pause();
2092 assert!(stream.is_paused());
2093
2094 // Test resume
2095 stream.resume();
2096 assert!(!stream.is_paused());
2097 }
2098
2099 /// #2258 H7: a paused stream parks on the pause channel instead of
2100 /// re-waking itself, which turned a pause into a busy poll loop. The
2101 /// `is_woken` assertion is the pin: pre-fix the paused poll woke the task
2102 /// immediately, so it failed.
2103 ///
2104 /// Not inducible from a recorded provider turn — pause/resume is
2105 /// consumer-side control flow with no wire representation.
2106 #[tokio::test]
2107 async fn a_paused_stream_parks_until_resume_instead_of_busy_waking() {
2108 let stream = StreamingCompletionResponse::stream(
2109 TEST_PROVIDER,
2110 to_stream_result(stream! {
2111 yield Ok(RawStreamingChoice::Message("hello".to_string()));
2112 }),
2113 );
2114 let resume = stream.pause_control.paused_tx.clone();
2115 stream.pause();
2116
2117 let mut task = tokio_test::task::spawn(stream);
2118 assert!(
2119 task.poll_next().is_pending(),
2120 "a paused stream yields nothing"
2121 );
2122 assert!(
2123 !task.is_woken(),
2124 "a paused stream must idle, not re-wake itself"
2125 );
2126
2127 resume.send(false).expect("resume");
2128 assert!(task.is_woken(), "resuming must wake the parked stream");
2129 assert!(matches!(
2130 task.poll_next(),
2131 Poll::Ready(Some(Ok(StreamedAssistantContent::Text(text)))) if text.text == "hello"
2132 ));
2133 }
2134
2135 /// #2258 B7: cancelling a paused stream must not deadlock — the consumer
2136 /// parked on the pause channel observes the termination because
2137 /// `cancel()` also resumes.
2138 #[tokio::test]
2139 async fn cancelling_a_paused_stream_terminates_instead_of_deadlocking() {
2140 let mut stream = create_mock_stream();
2141 stream.pause();
2142 stream.cancel();
2143 assert!(
2144 !stream.is_paused(),
2145 "cancel must lift the pause so the termination is observable"
2146 );
2147 assert!(
2148 stream.next().await.is_none(),
2149 "a cancelled stream terminates"
2150 );
2151 }
2152
2153 /// #2258 H6: `finish()` is destructive, so a second poll of a drained
2154 /// stream must not run it again — pre-fix the re-poll replaced a fully
2155 /// aggregated `choice` with the empty-text fallback.
2156 ///
2157 /// Not inducible from a recorded provider turn: re-polling a terminated
2158 /// stream is consumer behavior (`Stream` permits it, and combinators do
2159 /// it), independent of any wire.
2160 #[tokio::test]
2161 async fn re_polling_a_drained_stream_preserves_the_aggregated_choice() {
2162 let mut stream = create_mock_stream();
2163 while stream.next().await.is_some() {}
2164
2165 let drained: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2166 assert_eq!(
2167 drained,
2168 vec![AssistantContent::text("hello 1hello 2hello 3")]
2169 );
2170
2171 for _ in 0..3 {
2172 assert!(
2173 stream.next().await.is_none(),
2174 "a drained stream stays drained"
2175 );
2176 }
2177 assert_eq!(
2178 stream.choice.clone().into_iter().collect::<Vec<_>>(),
2179 drained,
2180 "re-polling must not re-run the destructive finish()"
2181 );
2182
2183 // The conversion into a unary response still carries the content.
2184 let response: CompletionResponse = stream.into();
2185 assert_eq!(response.choice.into_iter().collect::<Vec<_>>(), drained);
2186 }
2187
2188 /// #2258 H8: a `ProviderError` whose text happens to contain "aborted"
2189 /// is an error like any other. It used to be swallowed as clean EOF,
2190 /// discarding both the failure and the content streamed before it.
2191 ///
2192 /// Not inducible from a recorded provider turn: no in-tree provider emits
2193 /// this sentinel, and real cancellation arrives as `Ready(None)` through
2194 /// `Abortable` rather than as an error item.
2195 #[tokio::test]
2196 async fn a_provider_error_mentioning_aborted_reaches_the_consumer() {
2197 let mut stream = StreamingCompletionResponse::stream(
2198 TEST_PROVIDER,
2199 to_stream_result(stream! {
2200 yield Ok(RawStreamingChoice::Message("partial".to_string()));
2201 yield Err(CompletionError::ProviderError(
2202 "upstream aborted the request".to_string(),
2203 ));
2204 }),
2205 );
2206
2207 let mut errors = Vec::new();
2208 while let Some(item) = stream.next().await {
2209 if let Err(err) = item {
2210 errors.push(err.to_string());
2211 }
2212 }
2213 assert_eq!(errors.len(), 1, "the error must not be swallowed");
2214 assert!(errors[0].contains("upstream aborted the request"));
2215
2216 // The content streamed before the failure is still aggregated.
2217 assert_eq!(
2218 stream.choice.first(),
2219 Some(&AssistantContent::text("partial".to_string()))
2220 );
2221 assert!(stream.response.is_none());
2222 }
2223
2224 /// #2258 F1, at the stream boundary: a wire that fragments a call's input
2225 /// and then restates it as one complete block must publish the completed
2226 /// call under the id its deltas already published — the correlation
2227 /// contract on [`StreamedAssistantContent::ToolCall`]. Pre-fix the
2228 /// completed call carried a fresh id no delta ever mentioned.
2229 ///
2230 /// Not inducible from a recorded provider turn: no in-tree wire mixes the
2231 /// two shapes for one call, though out-of-tree adapters can.
2232 #[tokio::test]
2233 async fn a_full_tool_call_correlates_with_the_deltas_of_the_same_id() {
2234 let mut stream = StreamingCompletionResponse::stream(
2235 TEST_PROVIDER,
2236 to_stream_result(stream! {
2237 yield Ok(RawStreamingChoice::ToolCallDelta {
2238 id: StreamPartId::wire("tc1"),
2239 content: ToolCallDeltaContent::Name("add".to_string()),
2240 });
2241 yield Ok(RawStreamingChoice::ToolCallDelta {
2242 id: StreamPartId::wire("tc1"),
2243 content: ToolCallDeltaContent::Delta("{\"x\":1}".to_string()),
2244 });
2245 yield Ok(RawStreamingChoice::ToolCall(RawStreamingToolCall::new(
2246 "tc1".to_string(),
2247 "add".to_string(),
2248 serde_json::json!({"x": 1}),
2249 )));
2250 yield Ok(RawStreamingChoice::ToolInputEnd(ToolInputEnd::new(
2251 "tc1",
2252 UnparseableToolInput::Drop,
2253 )));
2254 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(1)));
2255 }),
2256 );
2257
2258 let mut delta_ids = Vec::new();
2259 let mut completed_ids = Vec::new();
2260 while let Some(item) = stream.next().await {
2261 match item.expect("stream item should be Ok") {
2262 StreamedAssistantContent::ToolCallDelta {
2263 internal_call_id, ..
2264 } => delta_ids.push(internal_call_id),
2265 StreamedAssistantContent::ToolCall {
2266 internal_call_id, ..
2267 } => completed_ids.push(internal_call_id),
2268 _ => {}
2269 }
2270 }
2271
2272 assert_eq!(delta_ids.len(), 2);
2273 assert_eq!(delta_ids[0], delta_ids[1], "one call, one internal id");
2274 assert_eq!(
2275 completed_ids,
2276 vec![delta_ids[0].clone()],
2277 "the completed call must carry the id its deltas published"
2278 );
2279
2280 // The trailing end event for a call a full block already delivered
2281 // finalizes nothing: exactly one tool call reaches the choice.
2282 let tool_calls: Vec<&ToolCall> = stream
2283 .choice
2284 .iter()
2285 .filter_map(|item| match item {
2286 AssistantContent::ToolCall(tool_call) => Some(tool_call),
2287 _ => None,
2288 })
2289 .collect();
2290 assert_eq!(tool_calls.len(), 1, "got {:?}", stream.choice);
2291 }
2292
2293 #[tokio::test]
2294 async fn test_stream_aggregates_reasoning_content() {
2295 let mut stream = create_reasoning_stream();
2296 while stream.next().await.is_some() {}
2297
2298 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2299
2300 assert!(choice_items.iter().any(|item| matches!(
2301 item,
2302 AssistantContent::Reasoning(Reasoning {
2303 id: Some(id),
2304 content
2305 }) if id == "rs_1"
2306 && matches!(
2307 content.first(),
2308 Some(ReasoningContent::Text {
2309 text,
2310 signature: Some(signature)
2311 }) if text == "step one" && signature == "sig_1"
2312 )
2313 )));
2314 }
2315
2316 /// A full reasoning block replaces its own delta accumulation, so the
2317 /// aggregated choice matches unary normalization of the same turn: one
2318 /// reasoning item carrying the completed block, not delta-plus-duplicate.
2319 #[tokio::test]
2320 async fn full_reasoning_block_supersedes_its_accumulated_deltas() {
2321 let mut stream = StreamingCompletionResponse::stream(
2322 TEST_PROVIDER,
2323 to_stream_result(stream! {
2324 yield Ok(RawStreamingChoice::ReasoningDelta {
2325 id: StreamPartId::wire("rs_1"),
2326 provider_id: WireId::new("rs_1"),
2327 reasoning: "partial ".to_string(),
2328 });
2329 yield Ok(RawStreamingChoice::Reasoning { id: StreamPartId::wire("rs_1"),
2330 provider_id: WireId::new("rs_1"),
2331 content: ReasoningContent::Text {
2332 text: "the complete chain".to_string(),
2333 signature: Some("sig_1".to_string()),
2334 },
2335 });
2336 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2337 }),
2338 );
2339 while stream.next().await.is_some() {}
2340
2341 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2342 let reasoning_items: Vec<&Reasoning> = choice_items
2343 .iter()
2344 .filter_map(|item| match item {
2345 AssistantContent::Reasoning(reasoning) => Some(reasoning),
2346 _ => None,
2347 })
2348 .collect();
2349
2350 assert_eq!(reasoning_items.len(), 1, "got {choice_items:?}");
2351 let reasoning = reasoning_items.first().expect("one reasoning item");
2352 assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
2353 assert!(matches!(
2354 reasoning.content.first(),
2355 Some(ReasoningContent::Text { text, signature: Some(signature) })
2356 if text == "the complete chain" && signature == "sig_1"
2357 ));
2358 }
2359
2360 /// A full block whose ID differs from the accumulating item's ID is a
2361 /// distinct reasoning item and is appended, not a replacement.
2362 #[tokio::test]
2363 async fn full_reasoning_block_with_a_different_id_appends() {
2364 let mut stream = StreamingCompletionResponse::stream(
2365 TEST_PROVIDER,
2366 to_stream_result(stream! {
2367 yield Ok(RawStreamingChoice::ReasoningDelta {
2368 id: StreamPartId::wire("rs_1"),
2369 provider_id: WireId::new("rs_1"),
2370 reasoning: "first item deltas".to_string(),
2371 });
2372 yield Ok(RawStreamingChoice::Reasoning { id: StreamPartId::wire("rs_2"),
2373 provider_id: WireId::new("rs_2"),
2374 content: ReasoningContent::Text {
2375 text: "a different item".to_string(),
2376 signature: None,
2377 },
2378 });
2379 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2380 }),
2381 );
2382 while stream.next().await.is_some() {}
2383
2384 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2385 let reasoning_ids: Vec<Option<&str>> = choice_items
2386 .iter()
2387 .filter_map(|item| match item {
2388 AssistantContent::Reasoning(reasoning) => Some(reasoning.id.as_deref()),
2389 _ => None,
2390 })
2391 .collect();
2392
2393 assert_eq!(reasoning_ids, vec![Some("rs_1"), Some("rs_2")]);
2394 }
2395
2396 /// A bare end the wire actually sent yields the completed block (the
2397 /// wire announced the boundary and the consumer must see it — e.g.
2398 /// anthropic's `content_block_stop` on an unsigned thinking block); a
2399 /// bare end an adapter synthesized stays silent.
2400 #[tokio::test]
2401 async fn wire_sent_bare_end_yields_the_completed_block_synthesized_stays_silent() {
2402 let run = |wire_sent: bool| async move {
2403 let mut stream = StreamingCompletionResponse::stream(
2404 TEST_PROVIDER,
2405 to_stream_result(stream! {
2406 yield Ok(RawStreamingChoice::ReasoningDelta {
2407 id: StreamPartId::minted(MintKind::Block, 0),
2408 provider_id: None,
2409 reasoning: "unsigned thoughts".to_string(),
2410 });
2411 yield Ok(RawStreamingChoice::ReasoningEnd {
2412 id: StreamPartId::minted(MintKind::Block, 0),
2413 reasoning: None,
2414 signature: None,
2415 wire_sent,
2416 });
2417 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2418 }),
2419 );
2420 let mut completed = Vec::new();
2421 while let Some(item) = stream.next().await {
2422 if let Ok(StreamedAssistantContent::Reasoning { reasoning, .. }) = item {
2423 completed.push(reasoning);
2424 }
2425 }
2426 completed
2427 };
2428
2429 let wire = run(true).await;
2430 assert_eq!(wire.len(), 1, "a wire-sent end announces the boundary");
2431 assert!(matches!(
2432 wire[0].content.first(),
2433 Some(ReasoningContent::Text { text, signature: None }) if text == "unsigned thoughts"
2434 ));
2435
2436 let synthesized = run(false).await;
2437 assert!(
2438 synthesized.is_empty(),
2439 "a synthesized bare end fabricates nothing: {synthesized:?}"
2440 );
2441 }
2442
2443 /// The public delta correlator is unique per *part*, not per key: when a
2444 /// constant minted key (boundary-less wires) is reused for a new block
2445 /// after the previous one ended, the new block's deltas carry a fresh
2446 /// correlator.
2447 #[tokio::test]
2448 async fn reused_key_after_end_mints_a_fresh_delta_correlator() {
2449 let key = || StreamPartId::minted(MintKind::Reasoning, 0);
2450 let mut stream = StreamingCompletionResponse::stream(
2451 TEST_PROVIDER,
2452 to_stream_result(stream! {
2453 yield Ok(RawStreamingChoice::ReasoningDelta {
2454 id: key(),
2455 provider_id: None,
2456 reasoning: "block A".to_string(),
2457 });
2458 yield Ok(RawStreamingChoice::ReasoningEnd {
2459 id: key(),
2460 reasoning: None,
2461 signature: None,
2462 wire_sent: false,
2463 });
2464 yield Ok(RawStreamingChoice::Message("interleaved".to_string()));
2465 yield Ok(RawStreamingChoice::ReasoningDelta {
2466 id: key(),
2467 provider_id: None,
2468 reasoning: "block B".to_string(),
2469 });
2470 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2471 }),
2472 );
2473
2474 let mut delta_ids = Vec::new();
2475 while let Some(item) = stream.next().await {
2476 if let Ok(StreamedAssistantContent::ReasoningDelta { id, .. }) = item {
2477 delta_ids.push(id);
2478 }
2479 }
2480
2481 assert_eq!(delta_ids.len(), 2, "one delta per block");
2482 assert_ne!(
2483 delta_ids[0], delta_ids[1],
2484 "distinct parts must not share a correlator"
2485 );
2486 }
2487
2488 /// The completed reasoning event restates the correlator its deltas
2489 /// carried (the anthropic shape: id-less deltas, wire-sent bare stop),
2490 /// keeping it distinct from the durable provider handle, which stays
2491 /// absent.
2492 #[tokio::test]
2493 async fn completed_reasoning_restates_the_delta_correlator() {
2494 let key = || StreamPartId::minted(MintKind::Block, 0);
2495 let mut stream = StreamingCompletionResponse::stream(
2496 TEST_PROVIDER,
2497 to_stream_result(stream! {
2498 yield Ok(RawStreamingChoice::ReasoningDelta {
2499 id: key(),
2500 provider_id: None,
2501 reasoning: "unsigned thoughts".to_string(),
2502 });
2503 yield Ok(RawStreamingChoice::ReasoningEnd {
2504 id: key(),
2505 reasoning: None,
2506 signature: None,
2507 wire_sent: true,
2508 });
2509 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2510 }),
2511 );
2512
2513 let mut delta_ids = Vec::new();
2514 let mut completed = Vec::new();
2515 while let Some(item) = stream.next().await {
2516 match item {
2517 Ok(StreamedAssistantContent::ReasoningDelta { id, .. }) => delta_ids.push(id),
2518 Ok(StreamedAssistantContent::Reasoning { reasoning, id }) => {
2519 completed.push((reasoning, id));
2520 }
2521 _ => {}
2522 }
2523 }
2524
2525 let (reasoning, correlator) = completed.first().expect("one completed block");
2526 assert_eq!(
2527 Some(correlator),
2528 delta_ids.first(),
2529 "the completed block restates its deltas' correlator"
2530 );
2531 assert_eq!(
2532 reasoning.id, None,
2533 "no provider handle exists on this wire; the correlator must not leak into it"
2534 );
2535 }
2536
2537 /// On a signed end (the gemini shape) the completed event carries BOTH
2538 /// identities as distinct values: the rig correlator matching the
2539 /// deltas, and the durable provider handle in `reasoning.id`.
2540 #[tokio::test]
2541 async fn completed_reasoning_keeps_correlator_and_provider_handle_distinct() {
2542 let mut stream = StreamingCompletionResponse::stream(
2543 TEST_PROVIDER,
2544 to_stream_result(stream! {
2545 yield Ok(RawStreamingChoice::ReasoningDelta {
2546 id: StreamPartId::wire("rs_1"),
2547 provider_id: WireId::new("rs_1"),
2548 reasoning: "signed thoughts".to_string(),
2549 });
2550 yield Ok(RawStreamingChoice::ReasoningEnd {
2551 id: StreamPartId::wire("rs_1"),
2552 reasoning: None,
2553 signature: Some("sig_1".to_string()),
2554 wire_sent: true,
2555 });
2556 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2557 }),
2558 );
2559
2560 let mut delta_ids = Vec::new();
2561 let mut completed = Vec::new();
2562 while let Some(item) = stream.next().await {
2563 match item {
2564 Ok(StreamedAssistantContent::ReasoningDelta { id, .. }) => delta_ids.push(id),
2565 Ok(StreamedAssistantContent::Reasoning { reasoning, id }) => {
2566 completed.push((reasoning, id));
2567 }
2568 _ => {}
2569 }
2570 }
2571
2572 let (reasoning, correlator) = completed.first().expect("one completed block");
2573 assert_eq!(Some(correlator), delta_ids.first());
2574 assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
2575 assert_ne!(
2576 correlator.as_str(),
2577 "rs_1",
2578 "the rig correlator and the provider handle are separate values"
2579 );
2580 }
2581
2582 /// A trailing signature after a synthesized silent end restates the
2583 /// deltas' correlator (the gemini shape: thought deltas, visible text
2584 /// forcing a synthesized boundary, then a bare `thoughtSignature`
2585 /// frame). The suppressed end must not discard the part's identity —
2586 /// a fresh mint here strands the signed completion where the
2587 /// streamed-turn assembler cannot match it, duplicating the part.
2588 #[tokio::test]
2589 async fn late_signature_after_synthesized_end_restates_the_delta_correlator() {
2590 let key = || StreamPartId::minted(MintKind::Reasoning, 0);
2591 let mut stream = StreamingCompletionResponse::stream(
2592 TEST_PROVIDER,
2593 to_stream_result(stream! {
2594 yield Ok(RawStreamingChoice::ReasoningDelta {
2595 id: key(),
2596 provider_id: None,
2597 reasoning: "hidden thoughts".to_string(),
2598 });
2599 // The adapter saw visible text begin and synthesized a
2600 // silent boundary the wire never sent.
2601 yield Ok(RawStreamingChoice::ReasoningEnd {
2602 id: key(),
2603 reasoning: None,
2604 signature: None,
2605 wire_sent: false,
2606 });
2607 yield Ok(RawStreamingChoice::Message("visible".to_string()));
2608 // The trailing signature frame closes the same part.
2609 yield Ok(RawStreamingChoice::ReasoningEnd {
2610 id: key(),
2611 reasoning: None,
2612 signature: Some("sig_late".to_string()),
2613 wire_sent: true,
2614 });
2615 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2616 }),
2617 );
2618
2619 let mut delta_ids = Vec::new();
2620 let mut completed = Vec::new();
2621 while let Some(item) = stream.next().await {
2622 match item {
2623 Ok(StreamedAssistantContent::ReasoningDelta { id, .. }) => delta_ids.push(id),
2624 Ok(StreamedAssistantContent::Reasoning { reasoning, id }) => {
2625 completed.push((reasoning, id));
2626 }
2627 _ => {}
2628 }
2629 }
2630
2631 assert_eq!(completed.len(), 1, "one signed completion, no duplicate");
2632 let (reasoning, correlator) = completed.first().expect("one completed block");
2633 assert_eq!(
2634 Some(correlator),
2635 delta_ids.first(),
2636 "the signed completion restates the correlator its deltas carried"
2637 );
2638 assert!(
2639 reasoning.content.iter().any(|content| matches!(
2640 content,
2641 ReasoningContent::Text { signature: Some(sig), .. } if sig == "sig_late"
2642 )),
2643 "the trailing signature landed on the completed part"
2644 );
2645 }
2646
2647 /// A delta-less `ReasoningStart` under a reused key opens a NEW part
2648 /// with a fresh public correlator — even when that part closes with a
2649 /// signature-only end and no delta ever minted one (sequence O9: the
2650 /// finished map must never leak the previous part's identity onto a
2651 /// distinct part).
2652 #[tokio::test]
2653 async fn a_delta_less_start_under_a_reused_key_mints_a_fresh_correlator() {
2654 let key = || StreamPartId::minted(MintKind::Reasoning, 0);
2655 let mut stream = StreamingCompletionResponse::stream(
2656 TEST_PROVIDER,
2657 to_stream_result(stream! {
2658 yield Ok(RawStreamingChoice::ReasoningDelta {
2659 id: key(),
2660 provider_id: None,
2661 reasoning: "part one".to_string(),
2662 });
2663 yield Ok(RawStreamingChoice::ReasoningEnd {
2664 id: key(),
2665 reasoning: None,
2666 signature: None,
2667 wire_sent: true,
2668 });
2669 yield Ok(RawStreamingChoice::ReasoningStart {
2670 id: key(),
2671 provider_id: None,
2672 });
2673 yield Ok(RawStreamingChoice::ReasoningEnd {
2674 id: key(),
2675 reasoning: None,
2676 signature: Some("sig2".to_string()),
2677 wire_sent: true,
2678 });
2679 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2680 }),
2681 );
2682
2683 let mut completed_ids = Vec::new();
2684 while let Some(item) = stream.next().await {
2685 if let Ok(StreamedAssistantContent::Reasoning { id, .. }) = item {
2686 completed_ids.push(id);
2687 }
2688 }
2689
2690 assert_eq!(completed_ids.len(), 2, "two distinct parts complete");
2691 assert_ne!(
2692 completed_ids.first(),
2693 completed_ids.get(1),
2694 "distinct parts must not share a public correlator"
2695 );
2696 }
2697
2698 /// Ending a part and streaming new deltas under the same accumulation
2699 /// key opens a NEW part: the second part's correlator is fresh, never
2700 /// the finished part's retained identity.
2701 #[tokio::test]
2702 async fn reused_accumulation_key_mints_a_fresh_correlator_after_an_end() {
2703 let key = || StreamPartId::minted(MintKind::Reasoning, 0);
2704 let mut stream = StreamingCompletionResponse::stream(
2705 TEST_PROVIDER,
2706 to_stream_result(stream! {
2707 yield Ok(RawStreamingChoice::ReasoningDelta {
2708 id: key(),
2709 provider_id: None,
2710 reasoning: "first part".to_string(),
2711 });
2712 yield Ok(RawStreamingChoice::ReasoningEnd {
2713 id: key(),
2714 reasoning: None,
2715 signature: None,
2716 wire_sent: true,
2717 });
2718 yield Ok(RawStreamingChoice::ReasoningDelta {
2719 id: key(),
2720 provider_id: None,
2721 reasoning: "second part".to_string(),
2722 });
2723 yield Ok(RawStreamingChoice::ReasoningEnd {
2724 id: key(),
2725 reasoning: None,
2726 signature: None,
2727 wire_sent: true,
2728 });
2729 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2730 }),
2731 );
2732
2733 let mut completed_ids = Vec::new();
2734 while let Some(item) = stream.next().await {
2735 if let Ok(StreamedAssistantContent::Reasoning { id, .. }) = item {
2736 completed_ids.push(id);
2737 }
2738 }
2739
2740 assert_eq!(completed_ids.len(), 2, "two parts under the reused key");
2741 assert_ne!(
2742 completed_ids.first(),
2743 completed_ids.get(1),
2744 "a reused key opens a new part with a fresh correlator"
2745 );
2746 }
2747
2748 /// A whole-block reasoning event with no prior deltas still carries a
2749 /// non-empty correlator, and two such parts never share one.
2750 #[tokio::test]
2751 async fn whole_block_reasoning_mints_a_unique_correlator() {
2752 let mut stream = StreamingCompletionResponse::stream(
2753 TEST_PROVIDER,
2754 to_stream_result(stream! {
2755 yield Ok(RawStreamingChoice::Reasoning {
2756 id: StreamPartId::wire("rs_1"),
2757 provider_id: WireId::new("rs_1"),
2758 content: ReasoningContent::Text {
2759 text: "first".to_string(),
2760 signature: None,
2761 },
2762 });
2763 yield Ok(RawStreamingChoice::Reasoning {
2764 id: StreamPartId::wire("rs_2"),
2765 provider_id: WireId::new("rs_2"),
2766 content: ReasoningContent::Text {
2767 text: "second".to_string(),
2768 signature: None,
2769 },
2770 });
2771 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2772 }),
2773 );
2774
2775 let mut correlators = Vec::new();
2776 while let Some(item) = stream.next().await {
2777 if let Ok(StreamedAssistantContent::Reasoning { id, .. }) = item {
2778 correlators.push(id);
2779 }
2780 }
2781
2782 assert_eq!(correlators.len(), 2);
2783 assert!(correlators.iter().all(|id| !id.is_empty()));
2784 assert_ne!(
2785 correlators[0], correlators[1],
2786 "distinct parts must not share a correlator"
2787 );
2788 }
2789
2790 #[tokio::test]
2791 async fn full_reasoning_block_supersedes_deltas_across_interleaved_output() {
2792 // Providers may emit the completed reasoning item after other output
2793 // (reasoning -> tool call -> completed block). The tool call clears
2794 // the active reasoning index, so replacement must fall back to the
2795 // by-ID scan rather than appending a duplicate.
2796 let mut stream = StreamingCompletionResponse::stream(
2797 TEST_PROVIDER,
2798 to_stream_result(stream! {
2799 yield Ok(RawStreamingChoice::ReasoningDelta {
2800 id: StreamPartId::wire("rs_1"),
2801 provider_id: WireId::new("rs_1"),
2802 reasoning: "partial ".to_string(),
2803 });
2804 yield Ok(RawStreamingChoice::ToolCall(RawStreamingToolCall::new(
2805 "call_1".to_string(),
2806 "probe".to_string(),
2807 serde_json::json!({}),
2808 )));
2809 yield Ok(RawStreamingChoice::Reasoning { id: StreamPartId::wire("rs_1"),
2810 provider_id: WireId::new("rs_1"),
2811 content: ReasoningContent::Text {
2812 text: "the full block".to_string(),
2813 signature: None,
2814 },
2815 });
2816 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2817 }),
2818 );
2819 while stream.next().await.is_some() {}
2820
2821 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2822 let reasoning_items: Vec<&Reasoning> = choice_items
2823 .iter()
2824 .filter_map(|item| match item {
2825 AssistantContent::Reasoning(reasoning) => Some(reasoning),
2826 _ => None,
2827 })
2828 .collect();
2829
2830 assert_eq!(
2831 reasoning_items.len(),
2832 1,
2833 "the full block must replace the delta-built item, not join it"
2834 );
2835 let only = reasoning_items.first().expect("one reasoning item");
2836 assert_eq!(only.id.as_deref(), Some("rs_1"));
2837 assert!(
2838 only.content.iter().any(|content| matches!(
2839 content,
2840 ReasoningContent::Text { text, .. } if text == "the full block"
2841 )),
2842 "the surviving item must carry the full block's content"
2843 );
2844 }
2845
2846 #[tokio::test]
2847 async fn minted_id_full_reasoning_block_does_not_clobber_a_wire_id_item() {
2848 // Ids are mandatory on the grammar; a provider-minted id (the
2849 // "reasoning-0"-style boundary fallback) is a distinct identity from
2850 // a wire-supplied one, so the block appends rather than overwriting
2851 // an unrelated item's deltas.
2852 let mut stream = StreamingCompletionResponse::stream(
2853 TEST_PROVIDER,
2854 to_stream_result(stream! {
2855 yield Ok(RawStreamingChoice::ReasoningDelta {
2856 id: StreamPartId::wire("rs_1"),
2857 provider_id: WireId::new("rs_1"),
2858 reasoning: "identified deltas".to_string(),
2859 });
2860 yield Ok(RawStreamingChoice::Reasoning {
2861 id: StreamPartId::wire("reasoning-0"),
2862 provider_id: WireId::new("reasoning-0"),
2863 content: ReasoningContent::Text {
2864 text: "anonymous block".to_string(),
2865 signature: None,
2866 },
2867 });
2868 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(2)));
2869 }),
2870 );
2871 while stream.next().await.is_some() {}
2872
2873 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2874 let reasoning_ids: Vec<Option<&str>> = choice_items
2875 .iter()
2876 .filter_map(|item| match item {
2877 AssistantContent::Reasoning(reasoning) => Some(reasoning.id.as_deref()),
2878 _ => None,
2879 })
2880 .collect();
2881
2882 assert_eq!(reasoning_ids, vec![Some("rs_1"), Some("reasoning-0")]);
2883 }
2884
2885 #[tokio::test]
2886 async fn test_stream_reasoning_only_does_not_inject_empty_text() {
2887 let mut stream = create_reasoning_only_stream();
2888 while stream.next().await.is_some() {}
2889
2890 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2891 assert_eq!(choice_items.len(), 1);
2892 assert!(matches!(
2893 choice_items.first(),
2894 Some(AssistantContent::Reasoning(Reasoning { id: Some(id), .. })) if id == "rs_only"
2895 ));
2896 }
2897
2898 #[tokio::test]
2899 async fn test_stream_aggregates_assistant_items_in_arrival_order() {
2900 let mut stream = create_interleaved_stream();
2901 while stream.next().await.is_some() {}
2902
2903 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2904 assert_eq!(choice_items.len(), 3);
2905 assert!(matches!(
2906 choice_items.first(),
2907 Some(AssistantContent::Reasoning(Reasoning { id: Some(id), .. })) if id == "rs_interleaved"
2908 ));
2909 assert!(matches!(
2910 choice_items.get(1),
2911 Some(AssistantContent::Text(Text { text, .. })) if text == "final-text"
2912 ));
2913 assert!(matches!(
2914 choice_items.get(2),
2915 Some(AssistantContent::ToolCall(ToolCall { id, .. })) if id == "tool_1"
2916 ));
2917 }
2918
2919 #[tokio::test]
2920 async fn unknown_choice_reaches_consumer_but_not_aggregated_choice() {
2921 let unknown = serde_json::json!({
2922 "type": "web_search_call",
2923 "id": "ws_1",
2924 "status": "completed",
2925 });
2926 let yielded = unknown.clone();
2927 let stream = stream! {
2928 yield Ok(RawStreamingChoice::Unknown(yielded.into()));
2929 yield Ok(RawStreamingChoice::Message("done".to_string()));
2930 yield Ok(RawStreamingChoice::FinalResponse(mock_final_with_total_tokens(1)));
2931 };
2932 let mut stream =
2933 StreamingCompletionResponse::stream(TEST_PROVIDER, to_stream_result(stream));
2934
2935 let mut consumer_unknown = None;
2936 let mut consumer_text = String::new();
2937 while let Some(item) = stream.next().await {
2938 match item.expect("stream item should be Ok") {
2939 StreamedAssistantContent::Unknown(value) => consumer_unknown = Some(value),
2940 StreamedAssistantContent::Text(text) => consumer_text.push_str(&text.text),
2941 _ => {}
2942 }
2943 }
2944
2945 // The consumer receives the unmodeled item verbatim ...
2946 assert_eq!(consumer_unknown.as_ref(), Some(&unknown.into()));
2947 assert_eq!(consumer_text, "done");
2948
2949 // ... but it is structurally absent from the aggregated assistant choice
2950 // (the sole source of persisted history): only the text item remains.
2951 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2952 assert_eq!(choice_items.len(), 1);
2953 assert!(matches!(
2954 choice_items.first(),
2955 Some(AssistantContent::Text(Text { text, .. })) if text == "done"
2956 ));
2957 }
2958
2959 #[tokio::test]
2960 async fn test_stream_keeps_non_contiguous_text_chunks_split_by_tool_call() {
2961 let mut stream = create_text_tool_text_stream();
2962 while stream.next().await.is_some() {}
2963
2964 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2965 assert_eq!(choice_items.len(), 3);
2966 assert!(matches!(
2967 choice_items.first(),
2968 Some(AssistantContent::Text(Text { text, .. })) if text == "first"
2969 ));
2970 assert!(matches!(
2971 choice_items.get(1),
2972 Some(AssistantContent::ToolCall(ToolCall { id, .. })) if id == "tool_split"
2973 ));
2974 assert!(matches!(
2975 choice_items.get(2),
2976 Some(AssistantContent::Text(Text { text, .. })) if text == "second"
2977 ));
2978 }
2979
2980 #[tokio::test]
2981 async fn test_stream_preserves_text_additional_params() {
2982 let mut stream = create_text_metadata_stream();
2983 while stream.next().await.is_some() {}
2984
2985 let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
2986 assert_eq!(choice_items.len(), 2);
2987
2988 let Some(AssistantContent::Text(Text {
2989 text,
2990 additional_params: Some(additional_params),
2991 })) = choice_items.first()
2992 else {
2993 panic!("expected first text item with metadata");
2994 };
2995 assert_eq!(text, "first");
2996 assert_eq!(
2997 additional_params["citations"]
2998 .as_array()
2999 .expect("citations should be an array")
3000 .len(),
3001 2
3002 );
3003
3004 let Some(AssistantContent::Text(Text {
3005 text,
3006 additional_params: Some(additional_params),
3007 })) = choice_items.get(1)
3008 else {
3009 panic!("expected second text item with metadata");
3010 };
3011 assert_eq!(text, "second");
3012 assert_eq!(additional_params["block"], 2);
3013 }
3014}
3015
3016/// Describes responses from a streamed provider response which is either text, a tool call or a final usage response.
3017#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
3018#[serde(untagged)]
3019pub enum StreamedAssistantContent {
3020 /// Text delta emitted by the assistant.
3021 Text(Text),
3022 /// Complete tool call emitted by the assistant.
3023 ToolCall {
3024 tool_call: ToolCall,
3025 /// Rig-generated unique identifier for this tool call.
3026 /// Use this to correlate with ToolCallDelta events.
3027 internal_call_id: String,
3028 },
3029 /// Partial tool call data emitted by the assistant.
3030 ToolCallDelta {
3031 /// Rig-generated correlator for this call: stable across the call's
3032 /// fragments, matches the eventual
3033 /// [`StreamedAssistantContent::ToolCall`], and unique per run.
3034 /// Provider-issued ids arrive on the completed [`ToolCall`]; no
3035 /// stream-internal key is ever rendered here.
3036 internal_call_id: String,
3037 content: ToolCallDeltaContent,
3038 },
3039 /// Complete reasoning block emitted by the assistant.
3040 ///
3041 /// Supersedes any prior [`StreamedAssistantContent::ReasoningDelta`]s
3042 /// carrying the same correlator `id`: render it as a *replacement* for
3043 /// the accumulated delta text, not an addition. The match key is this
3044 /// variant's `id`, not [`Reasoning::id`](crate::message::Reasoning::id).
3045 /// The aggregated [`StreamingCompletionResponse::choice`] already
3046 /// applies this replacement.
3047 Reasoning {
3048 reasoning: Reasoning,
3049 /// Rig-generated correlator: matches the `id` on this part's prior
3050 /// [`StreamedAssistantContent::ReasoningDelta`]s and is unique per
3051 /// run. The durable provider handle is `reasoning.id`; this value
3052 /// never enters replayable history.
3053 id: String,
3054 },
3055 /// Partial reasoning text emitted by the assistant.
3056 ReasoningDelta {
3057 /// Rig-generated correlator for the reasoning part this delta
3058 /// extends: stable across the part's deltas and unique per run.
3059 /// Never a stream-internal key and never a fabricated provider
3060 /// value.
3061 id: String,
3062 /// The provider-issued reasoning item id, when one exists — the
3063 /// durable handle the aggregated
3064 /// [`Reasoning::id`](crate::message::Reasoning::id) will carry
3065 /// (`None` on wires that issue no reasoning ids).
3066 #[serde(default, skip_serializing_if = "Option::is_none")]
3067 provider_id: Option<String>,
3068 /// Partial reasoning text.
3069 reasoning: String,
3070 },
3071 /// The provider's normalized terminal record, if yielded by the stream.
3072 Final(StreamFinal),
3073 /// A provider-native output item rig does not model, preserved verbatim —
3074 /// e.g. an OpenAI Responses hosted-tool result (`web_search_call`,
3075 /// `file_search_call`, `computer_call`, `code_interpreter_call`). It is
3076 /// yielded to the consumer for inspection/forwarding but is not added to the
3077 /// accumulated assistant message or persisted history. Kept last because the
3078 /// enum is `#[serde(untagged)]` and the transparent payload wrapper
3079 /// matches anything, so earlier (typed) variants must be tried first.
3080 Unknown(UnknownPayload),
3081}
3082
3083impl StreamedAssistantContent {
3084 /// Create a text stream item.
3085 pub fn text(text: &str) -> Self {
3086 Self::Text(Text::new(text.to_string()))
3087 }
3088
3089 /// Create a final response stream item.
3090 pub fn final_response(res: StreamFinal) -> Self {
3091 Self::Final(res)
3092 }
3093}
3094
3095/// Streamed user content. This content is primarily used to represent tool results from tool calls made during a multi-turn/step agent prompt.
3096#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
3097#[serde(untagged)]
3098pub enum StreamedUserContent {
3099 /// Tool result emitted during a multi-turn streaming agent loop.
3100 ToolResult {
3101 tool_result: ToolResult,
3102 /// Rig-generated unique identifier for the tool call this result
3103 /// belongs to. Use this to correlate with the originating
3104 /// [`StreamedAssistantContent::ToolCall::internal_call_id`].
3105 internal_call_id: String,
3106 },
3107}
3108
3109impl StreamedUserContent {
3110 /// Create a streamed tool result correlated to an internal tool call ID.
3111 pub fn tool_result(tool_result: ToolResult, internal_call_id: String) -> Self {
3112 Self::ToolResult {
3113 tool_result,
3114 internal_call_id,
3115 }
3116 }
3117}