Skip to main content

rig_core/
streaming.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//! The main traits defined in this module are:
6//! - [StreamingPrompt]: Defines a high-level streaming LLM one-shot prompt interface
7//! - [StreamingChat]: Defines a high-level streaming LLM chat interface with history
8//! - [StreamingCompletion]: Defines a low-level streaming LLM completion interface
9//!
10
11use crate::OneOrMany;
12use crate::agent::prompt_request::streaming::StreamingPromptRequest;
13use crate::completion::{
14    CompletionError, CompletionModel, CompletionRequestBuilder, CompletionResponse, GetTokenUsage,
15    Message, Usage,
16};
17use crate::message::{
18    AssistantContent, Reasoning, ReasoningContent, Text, ToolCall, ToolFunction, ToolResult,
19};
20use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
21use futures::stream::{AbortHandle, Abortable};
22use futures::{Stream, StreamExt};
23use serde::{Deserialize, Serialize};
24use std::future::Future;
25use std::pin::Pin;
26use std::sync::atomic::AtomicBool;
27use std::task::{Context, Poll};
28use tokio::sync::watch;
29
30/// Control for pausing and resuming a streaming response
31pub struct PauseControl {
32    pub(crate) paused_tx: watch::Sender<bool>,
33    pub(crate) paused_rx: watch::Receiver<bool>,
34}
35
36impl PauseControl {
37    /// Create a pause controller in the running state.
38    pub fn new() -> Self {
39        let (paused_tx, paused_rx) = watch::channel(false);
40        Self {
41            paused_tx,
42            paused_rx,
43        }
44    }
45
46    /// Pause polling of the public stream until [`PauseControl::resume`] is called.
47    pub fn pause(&self) {
48        let _ = self.paused_tx.send(true);
49    }
50
51    /// Resume polling after a pause.
52    pub fn resume(&self) {
53        let _ = self.paused_tx.send(false);
54    }
55
56    /// Returns whether the stream is currently paused.
57    pub fn is_paused(&self) -> bool {
58        *self.paused_rx.borrow()
59    }
60}
61
62impl Default for PauseControl {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68/// The content of a tool call delta - either the tool name or argument data
69#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
70pub enum ToolCallDeltaContent {
71    /// Tool/function name emitted by the provider.
72    Name(String),
73    /// Partial JSON argument data emitted by the provider.
74    Delta(String),
75}
76
77/// Enum representing a streaming chunk from the model
78#[derive(Debug, Clone)]
79pub enum RawStreamingChoice<R>
80where
81    R: Clone,
82{
83    /// A text chunk from a message response
84    Message(String),
85
86    /// Start a new text content block in the accumulated final choice.
87    ///
88    /// This is an internal provider-normalization event. It is not yielded to
89    /// public stream consumers, but lets providers preserve metadata boundaries
90    /// for final aggregated assistant text blocks.
91    TextStart {
92        /// Provider-specific metadata attached to this text block.
93        additional_params: Option<serde_json::Value>,
94    },
95
96    /// Provider-specific metadata for the current text content block.
97    ///
98    /// This is not yielded to public stream consumers. The metadata is merged
99    /// into the current aggregated [`Text`] block.
100    TextAdditionalParams(serde_json::Value),
101
102    /// A tool call response (in its entirety)
103    ToolCall(RawStreamingToolCall),
104    /// A tool call partial/delta
105    ToolCallDelta {
106        /// Provider-supplied tool call ID.
107        id: String,
108        /// Rig-generated unique identifier for this tool call.
109        internal_call_id: String,
110        content: ToolCallDeltaContent,
111    },
112    /// A reasoning (in its entirety)
113    Reasoning {
114        /// Provider-supplied reasoning block ID, when present.
115        id: Option<String>,
116        /// Complete reasoning content block.
117        content: ReasoningContent,
118    },
119    /// A reasoning partial/delta
120    ReasoningDelta {
121        /// Provider-supplied reasoning block ID, when present.
122        id: Option<String>,
123        /// Partial reasoning text.
124        reasoning: String,
125    },
126
127    /// The final response object, must be yielded if you want the
128    /// `response` field to be populated on the `StreamingCompletionResponse`
129    FinalResponse(R),
130
131    /// Provider-assigned message ID (e.g. OpenAI Responses API `msg_` ID).
132    /// Captured silently into `StreamingCompletionResponse::message_id`.
133    MessageId(String),
134
135    /// A provider-native output item this version does not model — e.g. an
136    /// OpenAI Responses hosted-tool result (`web_search_call`, `file_search_call`,
137    /// `computer_call`, `code_interpreter_call`). Carries the raw item object
138    /// verbatim. Forwarded to the stream consumer as
139    /// [`StreamedAssistantContent::Unknown`] but not folded into the accumulated
140    /// assistant message (there is no `AssistantContent::Unknown` history slot).
141    Unknown(serde_json::Value),
142}
143
144/// Describes a streaming tool call response (in its entirety)
145#[derive(Debug, Clone)]
146pub struct RawStreamingToolCall {
147    /// Provider-supplied tool call ID.
148    pub id: String,
149    /// Rig-generated unique identifier for this tool call.
150    pub internal_call_id: String,
151    /// Provider-specific call ID used by some APIs for tool result correlation.
152    pub call_id: Option<String>,
153    /// Tool/function name.
154    pub name: String,
155    /// Parsed tool arguments.
156    pub arguments: serde_json::Value,
157    /// Optional provider signature associated with the tool call.
158    pub signature: Option<String>,
159    /// Additional provider-specific tool call metadata.
160    pub additional_params: Option<serde_json::Value>,
161}
162
163impl RawStreamingToolCall {
164    /// Create an empty tool call accumulator for provider streaming parsers.
165    pub fn empty() -> Self {
166        Self {
167            id: String::new(),
168            internal_call_id: crate::id::generate(),
169            call_id: None,
170            name: String::new(),
171            arguments: serde_json::Value::Null,
172            signature: None,
173            additional_params: None,
174        }
175    }
176
177    /// Create a complete tool call with a generated internal call ID.
178    pub fn new(id: String, name: String, arguments: serde_json::Value) -> Self {
179        Self {
180            id,
181            internal_call_id: crate::id::generate(),
182            call_id: None,
183            name,
184            arguments,
185            signature: None,
186            additional_params: None,
187        }
188    }
189
190    /// Override the generated internal call ID.
191    pub fn with_internal_call_id(mut self, internal_call_id: String) -> Self {
192        self.internal_call_id = internal_call_id;
193        self
194    }
195
196    /// Attach a provider-specific call ID.
197    pub fn with_call_id(mut self, call_id: String) -> Self {
198        self.call_id = Some(call_id);
199        self
200    }
201
202    /// Attach or clear a provider signature.
203    pub fn with_signature(mut self, signature: Option<String>) -> Self {
204        self.signature = signature;
205        self
206    }
207
208    /// Attach provider-specific metadata.
209    pub fn with_additional_params(mut self, additional_params: Option<serde_json::Value>) -> Self {
210        self.additional_params = additional_params;
211        self
212    }
213}
214
215impl From<RawStreamingToolCall> for ToolCall {
216    fn from(tool_call: RawStreamingToolCall) -> Self {
217        ToolCall {
218            id: tool_call.id,
219            call_id: tool_call.call_id,
220            function: ToolFunction {
221                name: tool_call.name,
222                arguments: tool_call.arguments,
223            },
224            signature: tool_call.signature,
225            additional_params: tool_call.additional_params,
226        }
227    }
228}
229
230#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
231/// Provider stream of raw completion chunks on native targets.
232pub type StreamingResult<R> =
233    Pin<Box<dyn Stream<Item = Result<RawStreamingChoice<R>, CompletionError>> + Send>>;
234
235#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
236/// Provider stream of raw completion chunks on wasm targets.
237pub type StreamingResult<R> =
238    Pin<Box<dyn Stream<Item = Result<RawStreamingChoice<R>, CompletionError>>>>;
239
240/// The response from a streaming completion request;
241/// message and response are populated at the end of the
242/// `inner` stream.
243pub struct StreamingCompletionResponse<R>
244where
245    R: Clone + Unpin + GetTokenUsage,
246{
247    pub(crate) inner: Abortable<StreamingResult<R>>,
248    pub(crate) abort_handle: AbortHandle,
249    pub(crate) pause_control: PauseControl,
250    assistant_items: Vec<AssistantContent>,
251    text_item_index: Option<usize>,
252    reasoning_item_index: Option<usize>,
253    /// The final aggregated message from the stream
254    /// contains all text and tool calls generated
255    pub choice: OneOrMany<AssistantContent>,
256    /// The final response from the stream, may be `None`
257    /// if the provider didn't yield it during the stream
258    pub response: Option<R>,
259    pub final_response_yielded: AtomicBool,
260    /// Provider-assigned message ID (e.g. OpenAI Responses API `msg_` ID).
261    pub message_id: Option<String>,
262}
263
264impl<R> StreamingCompletionResponse<R>
265where
266    R: Clone + Unpin + GetTokenUsage,
267{
268    /// Wrap a provider stream and initialize aggregation state.
269    pub fn stream(inner: StreamingResult<R>) -> StreamingCompletionResponse<R> {
270        let (abort_handle, abort_registration) = AbortHandle::new_pair();
271        let abortable_stream = Abortable::new(inner, abort_registration);
272        let pause_control = PauseControl::new();
273        Self {
274            inner: abortable_stream,
275            abort_handle,
276            pause_control,
277            assistant_items: vec![],
278            text_item_index: None,
279            reasoning_item_index: None,
280            choice: OneOrMany::one(AssistantContent::text("")),
281            response: None,
282            final_response_yielded: AtomicBool::new(false),
283            message_id: None,
284        }
285    }
286
287    /// Cancel the stream. Cancellation is surfaced as normal stream termination.
288    pub fn cancel(&self) {
289        self.abort_handle.abort();
290    }
291
292    /// Pause stream polling.
293    pub fn pause(&self) {
294        self.pause_control.pause();
295    }
296
297    /// Resume stream polling after a pause.
298    pub fn resume(&self) {
299        self.pause_control.resume();
300    }
301
302    /// Returns whether the stream is currently paused.
303    pub fn is_paused(&self) -> bool {
304        self.pause_control.is_paused()
305    }
306
307    /// Token usage reported by the provider for this response.
308    ///
309    /// Returns the usage carried by the final response once the stream has
310    /// produced it. Until then — or when the provider does not report streamed
311    /// usage — this returns [`Usage::new`], the zero-valued sentinel for missing
312    /// usage metrics.
313    pub fn usage(&self) -> Usage {
314        self.response.token_usage()
315    }
316
317    fn append_text_chunk(&mut self, text: &str) {
318        if let Some(index) = self.text_item_index
319            && let Some(AssistantContent::Text(existing_text)) = self.assistant_items.get_mut(index)
320        {
321            existing_text.text.push_str(text);
322            return;
323        }
324
325        self.assistant_items
326            .push(AssistantContent::text(text.to_owned()));
327        self.text_item_index = Some(self.assistant_items.len() - 1);
328    }
329
330    fn append_text_additional_params(&mut self, additional_params: serde_json::Value) {
331        if additional_params.is_null() {
332            return;
333        }
334
335        let index = if let Some(index) = self.text_item_index
336            && matches!(
337                self.assistant_items.get(index),
338                Some(AssistantContent::Text(_))
339            ) {
340            index
341        } else {
342            self.assistant_items.push(AssistantContent::text(""));
343            let index = self.assistant_items.len() - 1;
344            self.text_item_index = Some(index);
345            index
346        };
347
348        let Some(AssistantContent::Text(text)) = self.assistant_items.get_mut(index) else {
349            return;
350        };
351
352        match text.additional_params.as_mut() {
353            Some(existing) => merge_text_additional_params(existing, additional_params),
354            None => text.additional_params = Some(additional_params),
355        }
356    }
357
358    /// Accumulate streaming reasoning delta text into assistant_items.
359    /// Providers that only emit ReasoningDelta (not full Reasoning blocks)
360    /// need this so the aggregated response includes reasoning content.
361    fn append_reasoning_chunk(&mut self, id: &Option<String>, text: &str) {
362        if let Some(index) = self.reasoning_item_index
363            && let Some(AssistantContent::Reasoning(existing)) = self.assistant_items.get_mut(index)
364            && let Some(ReasoningContent::Text {
365                text: existing_text,
366                ..
367            }) = existing.content.last_mut()
368        {
369            existing_text.push_str(text);
370            return;
371        }
372
373        self.assistant_items
374            .push(AssistantContent::Reasoning(Reasoning {
375                id: id.clone(),
376                content: vec![ReasoningContent::Text {
377                    text: text.to_string(),
378                    signature: None,
379                }],
380            }));
381        self.reasoning_item_index = Some(self.assistant_items.len() - 1);
382    }
383}
384
385fn merge_text_additional_params(existing: &mut serde_json::Value, incoming: serde_json::Value) {
386    match (existing, incoming) {
387        (serde_json::Value::Object(existing_map), serde_json::Value::Object(incoming_map)) => {
388            for (key, incoming_value) in incoming_map {
389                match existing_map.get_mut(&key) {
390                    Some(existing_value) => match (existing_value, incoming_value) {
391                        (
392                            serde_json::Value::Array(existing_array),
393                            serde_json::Value::Array(mut incoming_array),
394                        ) => existing_array.append(&mut incoming_array),
395                        (existing_value, incoming_value) => {
396                            merge_text_additional_params(existing_value, incoming_value);
397                        }
398                    },
399                    None => {
400                        existing_map.insert(key, incoming_value);
401                    }
402                }
403            }
404        }
405        (existing, incoming) => {
406            *existing = incoming;
407        }
408    }
409}
410
411impl<R> From<StreamingCompletionResponse<R>> for CompletionResponse<Option<R>>
412where
413    R: Clone + Unpin + GetTokenUsage,
414{
415    fn from(value: StreamingCompletionResponse<R>) -> CompletionResponse<Option<R>> {
416        CompletionResponse {
417            choice: value.choice,
418            // Derive usage from the final response. `Option<R>: GetTokenUsage`
419            // yields the provider's usage when present and the zero sentinel
420            // (`Usage::new`) when the stream produced no final response.
421            usage: value.response.token_usage(),
422            raw_response: value.response,
423            message_id: value.message_id,
424        }
425    }
426}
427
428impl<R> Stream for StreamingCompletionResponse<R>
429where
430    R: Clone + Unpin + GetTokenUsage,
431{
432    type Item = Result<StreamedAssistantContent<R>, CompletionError>;
433
434    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
435        let stream = self.get_mut();
436
437        if stream.is_paused() {
438            cx.waker().wake_by_ref();
439            return Poll::Pending;
440        }
441
442        match Pin::new(&mut stream.inner).poll_next(cx) {
443            Poll::Pending => Poll::Pending,
444            Poll::Ready(None) => {
445                // This is run at the end of the inner stream to collect all tokens into
446                // a single unified `Message`.
447                if stream.assistant_items.is_empty() {
448                    stream.assistant_items.push(AssistantContent::text(""));
449                }
450
451                if let Some(choice) =
452                    OneOrMany::from_iter_optional(std::mem::take(&mut stream.assistant_items))
453                {
454                    stream.choice = choice;
455                }
456
457                Poll::Ready(None)
458            }
459            Poll::Ready(Some(Err(err))) => {
460                if matches!(err, CompletionError::ProviderError(ref e) if e.to_string().contains("aborted"))
461                {
462                    return Poll::Ready(None); // Treat cancellation as stream termination
463                }
464                Poll::Ready(Some(Err(err)))
465            }
466            Poll::Ready(Some(Ok(choice))) => match choice {
467                RawStreamingChoice::Message(text) => {
468                    stream.reasoning_item_index = None;
469                    stream.append_text_chunk(&text);
470                    Poll::Ready(Some(Ok(StreamedAssistantContent::text(&text))))
471                }
472                RawStreamingChoice::TextStart { additional_params } => {
473                    stream.reasoning_item_index = None;
474                    stream.text_item_index = None;
475                    if let Some(additional_params) = additional_params {
476                        stream.append_text_additional_params(additional_params);
477                    }
478                    stream.poll_next_unpin(cx)
479                }
480                RawStreamingChoice::TextAdditionalParams(additional_params) => {
481                    stream.append_text_additional_params(additional_params);
482                    stream.poll_next_unpin(cx)
483                }
484                RawStreamingChoice::ToolCallDelta {
485                    id,
486                    internal_call_id,
487                    content,
488                } => Poll::Ready(Some(Ok(StreamedAssistantContent::ToolCallDelta {
489                    id,
490                    internal_call_id,
491                    content,
492                }))),
493                RawStreamingChoice::Reasoning { id, content } => {
494                    let reasoning = Reasoning {
495                        id,
496                        content: vec![content],
497                    };
498                    stream.text_item_index = None;
499                    // Full reasoning block supersedes any delta accumulation
500                    stream.reasoning_item_index = None;
501                    stream
502                        .assistant_items
503                        .push(AssistantContent::Reasoning(reasoning.clone()));
504                    Poll::Ready(Some(Ok(StreamedAssistantContent::Reasoning(reasoning))))
505                }
506                RawStreamingChoice::ReasoningDelta { id, reasoning } => {
507                    stream.text_item_index = None;
508                    stream.append_reasoning_chunk(&id, &reasoning);
509                    Poll::Ready(Some(Ok(StreamedAssistantContent::ReasoningDelta {
510                        id,
511                        reasoning,
512                    })))
513                }
514                RawStreamingChoice::ToolCall(raw_tool_call) => {
515                    let internal_call_id = raw_tool_call.internal_call_id.clone();
516                    let tool_call: ToolCall = raw_tool_call.into();
517                    stream.text_item_index = None;
518                    stream.reasoning_item_index = None;
519                    stream
520                        .assistant_items
521                        .push(AssistantContent::ToolCall(tool_call.clone()));
522                    Poll::Ready(Some(Ok(StreamedAssistantContent::ToolCall {
523                        tool_call,
524                        internal_call_id,
525                    })))
526                }
527                RawStreamingChoice::FinalResponse(response) => {
528                    if stream
529                        .final_response_yielded
530                        .load(std::sync::atomic::Ordering::SeqCst)
531                    {
532                        stream.poll_next_unpin(cx)
533                    } else {
534                        // Set the final response field and return the next item in the stream
535                        stream.response = Some(response.clone());
536                        stream
537                            .final_response_yielded
538                            .store(true, std::sync::atomic::Ordering::SeqCst);
539                        let final_response = StreamedAssistantContent::final_response(response);
540                        Poll::Ready(Some(Ok(final_response)))
541                    }
542                }
543                RawStreamingChoice::MessageId(id) => {
544                    stream.message_id = Some(id);
545                    stream.poll_next_unpin(cx)
546                }
547                RawStreamingChoice::Unknown(value) => {
548                    // Pass an unmodeled provider item straight through to the
549                    // consumer; it is intentionally not pushed into
550                    // `assistant_items` (no `AssistantContent::Unknown` exists).
551                    Poll::Ready(Some(Ok(StreamedAssistantContent::Unknown(value))))
552                }
553            },
554        }
555    }
556}
557
558/// Trait for high-level streaming prompt interface.
559///
560/// This trait provides a simple interface for streaming prompts to a completion model.
561/// Implementations can optionally support prompt hooks for observing and controlling
562/// the agent's execution lifecycle.
563pub trait StreamingPrompt<M, R>
564where
565    M: CompletionModel + 'static,
566    <M as CompletionModel>::StreamingResponse: WasmCompatSend,
567    R: Clone + Unpin + GetTokenUsage,
568{
569    /// Stream a simple prompt to the model.
570    ///
571    /// Attach hooks to observe or steer the run via
572    /// [`StreamingPromptRequest::add_hook`].
573    fn stream_prompt(
574        &self,
575        prompt: impl Into<Message> + WasmCompatSend,
576    ) -> StreamingPromptRequest<M>;
577}
578
579/// Trait for high-level streaming chat interface with conversation history.
580///
581/// This trait provides an interface for streaming chat completions with support
582/// for maintaining conversation history. Implementations can optionally support
583/// prompt hooks for observing and controlling the agent's execution lifecycle.
584pub trait StreamingChat<M, R>: WasmCompatSend + WasmCompatSync
585where
586    M: CompletionModel + 'static,
587    <M as CompletionModel>::StreamingResponse: WasmCompatSend,
588    R: Clone + Unpin + GetTokenUsage,
589{
590    /// Stream a chat with history to the model.
591    ///
592    /// The messages returned by the model can be accessed via `PromptResponse::messages()`
593    ///
594    /// You are responsible for managing history, a simple linear solution could look like:
595    /// ```ignore
596    ///  let mut history = vec![];
597    ///
598    ///  loop {
599    ///      let prompt = "Create GPT-67, make no mistakes";
600    ///      let mut stream = agent.stream_chat(prompt, &history).await;
601    ///
602    ///      while let Some(msg) = stream.next().await {
603    ///         match msg {
604    ///              Ok(MultiTurnStreamItem::FinalResponse(fin)) => {
605    ///                  history.extend_from_slice(fin.messages().unwrap_or_default());
606    ///                  break;
607    ///             }
608    ///             Ok(_other) => { /* Do something with this chunk */ }
609    ///             Err(e) => return Err(e.into()),
610    ///         }
611    ///     }
612    /// }
613    /// ```
614    fn stream_chat<I, T>(
615        &self,
616        prompt: impl Into<Message> + WasmCompatSend,
617        chat_history: I,
618    ) -> StreamingPromptRequest<M>
619    where
620        I: IntoIterator<Item = T> + WasmCompatSend,
621        T: Into<Message>;
622}
623
624/// Trait for low-level streaming completion interface
625pub trait StreamingCompletion<M: CompletionModel> {
626    /// Generate a streaming completion from a request
627    fn stream_completion<I, T>(
628        &self,
629        prompt: impl Into<Message> + WasmCompatSend,
630        chat_history: I,
631    ) -> impl Future<Output = Result<CompletionRequestBuilder<M>, CompletionError>>
632    where
633        I: IntoIterator<Item = T> + WasmCompatSend,
634        T: Into<Message>;
635}
636
637// Test module
638#[cfg(test)]
639mod tests {
640    use std::time::Duration;
641
642    use super::*;
643    use crate::test_utils::MockResponse;
644    use async_stream::stream;
645    use tokio::time::sleep;
646
647    #[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
648    fn to_stream_result(
649        stream: impl futures::Stream<Item = Result<RawStreamingChoice<MockResponse>, CompletionError>>
650        + Send
651        + 'static,
652    ) -> StreamingResult<MockResponse> {
653        Box::pin(stream)
654    }
655
656    #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
657    fn to_stream_result(
658        stream: impl futures::Stream<Item = Result<RawStreamingChoice<MockResponse>, CompletionError>>
659        + 'static,
660    ) -> StreamingResult<MockResponse> {
661        Box::pin(stream)
662    }
663
664    fn create_mock_stream() -> StreamingCompletionResponse<MockResponse> {
665        let stream = stream! {
666            yield Ok(RawStreamingChoice::Message("hello 1".to_string()));
667            sleep(Duration::from_millis(100)).await;
668            yield Ok(RawStreamingChoice::Message("hello 2".to_string()));
669            sleep(Duration::from_millis(100)).await;
670            yield Ok(RawStreamingChoice::Message("hello 3".to_string()));
671            sleep(Duration::from_millis(100)).await;
672            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(15)));
673        };
674
675        StreamingCompletionResponse::stream(to_stream_result(stream))
676    }
677
678    fn create_reasoning_stream() -> StreamingCompletionResponse<MockResponse> {
679        let stream = stream! {
680            yield Ok(RawStreamingChoice::Reasoning {
681                id: Some("rs_1".to_string()),
682                content: ReasoningContent::Text {
683                    text: "step one".to_string(),
684                    signature: Some("sig_1".to_string()),
685                },
686            });
687            yield Ok(RawStreamingChoice::Message("final answer".to_string()));
688            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(5)));
689        };
690
691        StreamingCompletionResponse::stream(to_stream_result(stream))
692    }
693
694    fn create_reasoning_only_stream() -> StreamingCompletionResponse<MockResponse> {
695        let stream = stream! {
696            yield Ok(RawStreamingChoice::Reasoning {
697                id: Some("rs_only".to_string()),
698                content: ReasoningContent::Summary("hidden summary".to_string()),
699            });
700            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(2)));
701        };
702
703        StreamingCompletionResponse::stream(to_stream_result(stream))
704    }
705
706    fn create_interleaved_stream() -> StreamingCompletionResponse<MockResponse> {
707        let stream = stream! {
708            yield Ok(RawStreamingChoice::Reasoning {
709                id: Some("rs_interleaved".to_string()),
710                content: ReasoningContent::Text {
711                    text: "chain-of-thought".to_string(),
712                    signature: None,
713                },
714            });
715            yield Ok(RawStreamingChoice::Message("final-text".to_string()));
716            yield Ok(RawStreamingChoice::ToolCall(
717                RawStreamingToolCall::new(
718                    "tool_1".to_string(),
719                    "mock_tool".to_string(),
720                    serde_json::json!({"arg": 1}),
721                ),
722            ));
723            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(3)));
724        };
725
726        StreamingCompletionResponse::stream(to_stream_result(stream))
727    }
728
729    fn create_text_tool_text_stream() -> StreamingCompletionResponse<MockResponse> {
730        let stream = stream! {
731            yield Ok(RawStreamingChoice::Message("first".to_string()));
732            yield Ok(RawStreamingChoice::ToolCall(
733                RawStreamingToolCall::new(
734                    "tool_split".to_string(),
735                    "mock_tool".to_string(),
736                    serde_json::json!({"arg": "x"}),
737                ),
738            ));
739            yield Ok(RawStreamingChoice::Message("second".to_string()));
740            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(3)));
741        };
742
743        StreamingCompletionResponse::stream(to_stream_result(stream))
744    }
745
746    fn create_text_metadata_stream() -> StreamingCompletionResponse<MockResponse> {
747        let stream = stream! {
748            yield Ok(RawStreamingChoice::TextStart {
749                additional_params: None,
750            });
751            yield Ok(RawStreamingChoice::Message("first".to_string()));
752            yield Ok(RawStreamingChoice::TextAdditionalParams(serde_json::json!({
753                "citations": [{
754                    "type": "char_location",
755                    "cited_text": "First citation.",
756                    "document_index": 0,
757                    "start_char_index": 0,
758                    "end_char_index": 15
759                }]
760            })));
761            yield Ok(RawStreamingChoice::TextAdditionalParams(serde_json::json!({
762                "citations": [{
763                    "type": "char_location",
764                    "cited_text": "Second citation.",
765                    "document_index": 0,
766                    "start_char_index": 16,
767                    "end_char_index": 32
768                }]
769            })));
770            yield Ok(RawStreamingChoice::TextStart {
771                additional_params: Some(serde_json::json!({
772                    "block": 2
773                })),
774            });
775            yield Ok(RawStreamingChoice::Message("second".to_string()));
776            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(3)));
777        };
778
779        StreamingCompletionResponse::stream(to_stream_result(stream))
780    }
781
782    #[tokio::test]
783    async fn into_completion_response_derives_usage_from_final_response() {
784        let mut stream = create_mock_stream();
785
786        // Drain the stream so the final response (and its usage) is captured.
787        while stream.next().await.is_some() {}
788
789        // usage() surfaces the final response's token usage...
790        assert_eq!(stream.usage().total_tokens, 15);
791
792        // ...and the From conversion carries it instead of a zero sentinel.
793        let response: CompletionResponse<Option<MockResponse>> = stream.into();
794        assert_eq!(response.usage.total_tokens, 15);
795    }
796
797    #[tokio::test]
798    async fn usage_is_zero_sentinel_before_final_response() {
799        // A stream that never yields a FinalResponse reports the zero sentinel.
800        let stream = StreamingCompletionResponse::stream(to_stream_result(stream! {
801            yield Ok(RawStreamingChoice::Message("no final response".to_string()));
802        }));
803        assert_eq!(stream.usage().total_tokens, 0);
804    }
805
806    #[tokio::test]
807    async fn test_stream_cancellation() {
808        let mut stream = create_mock_stream();
809
810        println!("Response: ");
811        let mut chunk_count = 0;
812        while let Some(chunk) = stream.next().await {
813            match chunk {
814                Ok(StreamedAssistantContent::Text(text)) => {
815                    print!("{}", text.text);
816                    std::io::Write::flush(&mut std::io::stdout()).unwrap();
817                    chunk_count += 1;
818                }
819                Ok(StreamedAssistantContent::ToolCall {
820                    tool_call,
821                    internal_call_id,
822                }) => {
823                    println!("\nTool Call: {tool_call:?}, internal_call_id={internal_call_id:?}");
824                    chunk_count += 1;
825                }
826                Ok(StreamedAssistantContent::ToolCallDelta {
827                    id,
828                    internal_call_id,
829                    content,
830                }) => {
831                    println!(
832                        "\nTool Call delta: id={id:?}, internal_call_id={internal_call_id:?}, content={content:?}"
833                    );
834                    chunk_count += 1;
835                }
836                Ok(StreamedAssistantContent::Final(res)) => {
837                    println!("\nFinal response: {res:?}");
838                }
839                Ok(StreamedAssistantContent::Reasoning(reasoning)) => {
840                    let reasoning = reasoning.display_text();
841                    print!("{reasoning}");
842                    std::io::Write::flush(&mut std::io::stdout()).unwrap();
843                }
844                Ok(StreamedAssistantContent::ReasoningDelta { reasoning, .. }) => {
845                    println!("Reasoning delta: {reasoning}");
846                    chunk_count += 1;
847                }
848                Ok(StreamedAssistantContent::Unknown(value)) => {
849                    println!("\nUnknown item: {value:?}");
850                    chunk_count += 1;
851                }
852                Err(e) => {
853                    eprintln!("Error: {e:?}");
854                    break;
855                }
856            }
857
858            if chunk_count >= 2 {
859                println!("\nCancelling stream...");
860                stream.cancel();
861                println!("Stream cancelled.");
862                break;
863            }
864        }
865
866        let next_chunk = stream.next().await;
867        assert!(
868            next_chunk.is_none(),
869            "Expected no further chunks after cancellation, got {next_chunk:?}"
870        );
871    }
872
873    #[tokio::test]
874    async fn test_stream_pause_resume() {
875        let stream = create_mock_stream();
876
877        // Test pause
878        stream.pause();
879        assert!(stream.is_paused());
880
881        // Test resume
882        stream.resume();
883        assert!(!stream.is_paused());
884    }
885
886    #[tokio::test]
887    async fn test_stream_aggregates_reasoning_content() {
888        let mut stream = create_reasoning_stream();
889        while stream.next().await.is_some() {}
890
891        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
892
893        assert!(choice_items.iter().any(|item| matches!(
894            item,
895            AssistantContent::Reasoning(Reasoning {
896                id: Some(id),
897                content
898            }) if id == "rs_1"
899                && matches!(
900                    content.first(),
901                    Some(ReasoningContent::Text {
902                        text,
903                        signature: Some(signature)
904                    }) if text == "step one" && signature == "sig_1"
905                )
906        )));
907    }
908
909    #[tokio::test]
910    async fn test_stream_reasoning_only_does_not_inject_empty_text() {
911        let mut stream = create_reasoning_only_stream();
912        while stream.next().await.is_some() {}
913
914        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
915        assert_eq!(choice_items.len(), 1);
916        assert!(matches!(
917            choice_items.first(),
918            Some(AssistantContent::Reasoning(Reasoning { id: Some(id), .. })) if id == "rs_only"
919        ));
920    }
921
922    #[tokio::test]
923    async fn test_stream_aggregates_assistant_items_in_arrival_order() {
924        let mut stream = create_interleaved_stream();
925        while stream.next().await.is_some() {}
926
927        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
928        assert_eq!(choice_items.len(), 3);
929        assert!(matches!(
930            choice_items.first(),
931            Some(AssistantContent::Reasoning(Reasoning { id: Some(id), .. })) if id == "rs_interleaved"
932        ));
933        assert!(matches!(
934            choice_items.get(1),
935            Some(AssistantContent::Text(Text { text, .. })) if text == "final-text"
936        ));
937        assert!(matches!(
938            choice_items.get(2),
939            Some(AssistantContent::ToolCall(ToolCall { id, .. })) if id == "tool_1"
940        ));
941    }
942
943    #[tokio::test]
944    async fn unknown_choice_reaches_consumer_but_not_aggregated_choice() {
945        let unknown = serde_json::json!({
946            "type": "web_search_call",
947            "id": "ws_1",
948            "status": "completed",
949        });
950        let yielded = unknown.clone();
951        let stream = stream! {
952            yield Ok(RawStreamingChoice::Unknown(yielded));
953            yield Ok(RawStreamingChoice::Message("done".to_string()));
954            yield Ok(RawStreamingChoice::FinalResponse(MockResponse::with_total_tokens(1)));
955        };
956        let mut stream = StreamingCompletionResponse::stream(to_stream_result(stream));
957
958        let mut consumer_unknown = None;
959        let mut consumer_text = String::new();
960        while let Some(item) = stream.next().await {
961            match item.expect("stream item should be Ok") {
962                StreamedAssistantContent::Unknown(value) => consumer_unknown = Some(value),
963                StreamedAssistantContent::Text(text) => consumer_text.push_str(&text.text),
964                _ => {}
965            }
966        }
967
968        // The consumer receives the unmodeled item verbatim ...
969        assert_eq!(consumer_unknown.as_ref(), Some(&unknown));
970        assert_eq!(consumer_text, "done");
971
972        // ... but it is structurally absent from the aggregated assistant choice
973        // (the sole source of persisted history): only the text item remains.
974        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
975        assert_eq!(choice_items.len(), 1);
976        assert!(matches!(
977            choice_items.first(),
978            Some(AssistantContent::Text(Text { text, .. })) if text == "done"
979        ));
980    }
981
982    #[tokio::test]
983    async fn test_stream_keeps_non_contiguous_text_chunks_split_by_tool_call() {
984        let mut stream = create_text_tool_text_stream();
985        while stream.next().await.is_some() {}
986
987        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
988        assert_eq!(choice_items.len(), 3);
989        assert!(matches!(
990            choice_items.first(),
991            Some(AssistantContent::Text(Text { text, .. })) if text == "first"
992        ));
993        assert!(matches!(
994            choice_items.get(1),
995            Some(AssistantContent::ToolCall(ToolCall { id, .. })) if id == "tool_split"
996        ));
997        assert!(matches!(
998            choice_items.get(2),
999            Some(AssistantContent::Text(Text { text, .. })) if text == "second"
1000        ));
1001    }
1002
1003    #[tokio::test]
1004    async fn test_stream_preserves_text_additional_params() {
1005        let mut stream = create_text_metadata_stream();
1006        while stream.next().await.is_some() {}
1007
1008        let choice_items: Vec<AssistantContent> = stream.choice.clone().into_iter().collect();
1009        assert_eq!(choice_items.len(), 2);
1010
1011        let Some(AssistantContent::Text(Text {
1012            text,
1013            additional_params: Some(additional_params),
1014        })) = choice_items.first()
1015        else {
1016            panic!("expected first text item with metadata");
1017        };
1018        assert_eq!(text, "first");
1019        assert_eq!(
1020            additional_params["citations"]
1021                .as_array()
1022                .expect("citations should be an array")
1023                .len(),
1024            2
1025        );
1026
1027        let Some(AssistantContent::Text(Text {
1028            text,
1029            additional_params: Some(additional_params),
1030        })) = choice_items.get(1)
1031        else {
1032            panic!("expected second text item with metadata");
1033        };
1034        assert_eq!(text, "second");
1035        assert_eq!(additional_params["block"], 2);
1036    }
1037}
1038
1039/// Describes responses from a streamed provider response which is either text, a tool call or a final usage response.
1040#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1041#[serde(untagged)]
1042pub enum StreamedAssistantContent<R> {
1043    /// Text delta emitted by the assistant.
1044    Text(Text),
1045    /// Complete tool call emitted by the assistant.
1046    ToolCall {
1047        tool_call: ToolCall,
1048        /// Rig-generated unique identifier for this tool call.
1049        /// Use this to correlate with ToolCallDelta events.
1050        internal_call_id: String,
1051    },
1052    /// Partial tool call data emitted by the assistant.
1053    ToolCallDelta {
1054        /// Provider-supplied tool call ID.
1055        id: String,
1056        /// Rig-generated unique identifier for this tool call.
1057        internal_call_id: String,
1058        content: ToolCallDeltaContent,
1059    },
1060    /// Complete reasoning block emitted by the assistant.
1061    Reasoning(Reasoning),
1062    /// Partial reasoning text emitted by the assistant.
1063    ReasoningDelta {
1064        /// Provider-supplied reasoning block ID, when present.
1065        id: Option<String>,
1066        /// Partial reasoning text.
1067        reasoning: String,
1068    },
1069    /// Final provider response object, if yielded by the provider stream.
1070    Final(R),
1071    /// A provider-native output item rig does not model, preserved verbatim —
1072    /// e.g. an OpenAI Responses hosted-tool result (`web_search_call`,
1073    /// `file_search_call`, `computer_call`, `code_interpreter_call`). It is
1074    /// yielded to the consumer for inspection/forwarding but is not added to the
1075    /// accumulated assistant message or persisted history. Kept last because the
1076    /// enum is `#[serde(untagged)]` and a raw [`Value`](serde_json::Value)
1077    /// matches anything, so earlier (typed) variants must be tried first.
1078    Unknown(serde_json::Value),
1079}
1080
1081impl<R> StreamedAssistantContent<R>
1082where
1083    R: Clone + Unpin,
1084{
1085    /// Create a text stream item.
1086    pub fn text(text: &str) -> Self {
1087        Self::Text(Text::new(text.to_string()))
1088    }
1089
1090    /// Create a final response stream item.
1091    pub fn final_response(res: R) -> Self {
1092        Self::Final(res)
1093    }
1094}
1095
1096/// Streamed user content. This content is primarily used to represent tool results from tool calls made during a multi-turn/step agent prompt.
1097#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1098#[serde(untagged)]
1099pub enum StreamedUserContent {
1100    /// Tool result emitted during a multi-turn streaming agent loop.
1101    ToolResult {
1102        tool_result: ToolResult,
1103        /// Rig-generated unique identifier for the tool call this result
1104        /// belongs to. Use this to correlate with the originating
1105        /// [`StreamedAssistantContent::ToolCall::internal_call_id`].
1106        internal_call_id: String,
1107    },
1108}
1109
1110impl StreamedUserContent {
1111    /// Create a streamed tool result correlated to an internal tool call ID.
1112    pub fn tool_result(tool_result: ToolResult, internal_call_id: String) -> Self {
1113        Self::ToolResult {
1114            tool_result,
1115            internal_call_id,
1116        }
1117    }
1118}