Skip to main content

rig_core/test_utils/
streaming.rs

1//! Streaming helpers for [`MockCompletionModel`](super::MockCompletionModel):
2//! the script a mock reply is written from, and the decoder that writes it
3//! through the completion writer, as a wire's decoder does.
4
5use std::collections::HashMap;
6
7use crate::completion::{CompletionResponse, Usage};
8use crate::error::ProviderError;
9use crate::operation::{Block, CallFragment, Completion, Finish};
10use crate::wire::{Decoder, Flow, Out, WireEvent};
11
12/// Provider descriptor name reported by the test doubles.
13pub const MOCK_PROVIDER: &str = "mock";
14
15/// The end the mock model's reply finishes with, carrying `usage`.
16pub fn mock_final(usage: Usage) -> Finish {
17    Finish {
18        usage,
19        ..Finish::default()
20    }
21}
22
23/// A fixture's provider item: a non-empty JSON object, or `None` for
24/// `null` and `{}`. Any other value is a scripting mistake surfaced as a
25/// stream error.
26fn fixture_item(value: serde_json::Value) -> Result<Option<serde_json::Value>, ProviderError> {
27    match value {
28        serde_json::Value::Null => Ok(None),
29        serde_json::Value::Object(map) if map.is_empty() => Ok(None),
30        serde_json::Value::Object(map) => Ok(Some(serde_json::Value::Object(map))),
31        other => Err(ProviderError::Provider(format!(
32            "mock stream fixture provider item must be a JSON object, got: {other}"
33        ))),
34    }
35}
36
37/// The end of a reply whose usage has only `total_tokens` set.
38pub fn mock_final_with_total_tokens(total_tokens: u64) -> Finish {
39    mock_final(Usage {
40        total_tokens: Some(total_tokens),
41        ..Default::default()
42    })
43}
44
45/// Scripted streaming event yielded by [`MockCompletionModel`](super::MockCompletionModel).
46#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
47pub enum MockStreamEvent {
48    /// Text chunk.
49    Text(String),
50    /// Start a new text part, with the provider item it is decoded from.
51    TextStart {
52        id: String,
53        additional_params: Option<serde_json::Value>,
54    },
55    /// Fields merged into the current text part's provider item.
56    TextAdditionalParams(serde_json::Value),
57    /// Complete tool call event.
58    ToolCall {
59        id: String,
60        name: String,
61        arguments: serde_json::Value,
62        call_id: Option<String>,
63    },
64    /// A tool call's name, as a wire streams it.
65    ToolCallNameDelta { id: String, name: String },
66    /// A fragment of a tool call's arguments.
67    ToolCallArgumentsDelta { id: String, arguments: String },
68    /// The end of a tool call streamed as fragments: the call closes with
69    /// what they carried, as a wire's step-stop does.
70    ToolCallEnd { id: String },
71    /// Complete reasoning event.
72    Reasoning { id: String, text: String },
73    /// Reasoning delta event.
74    ReasoningDelta { id: String, reasoning: String },
75    /// Provider-native output item that Rig does not model.
76    Unknown(serde_json::Value),
77    /// The transport request id of this turn. The mock transport reports
78    /// it, as a real transport reports a response header; the decoder never
79    /// sees it. Its position in the turn does not matter; when a turn
80    /// scripts more than one, the first wins.
81    RequestId(String),
82    /// The provider's end of the reply.
83    FinalResponse(Finish),
84    /// A failure, which ends the reply.
85    Error(MockError),
86}
87
88use super::completion::MockError;
89
90/// The provider id a fixture spells, when it spells one. Corpus fixtures
91/// are plain data: the renderings `reasoning-0`, `block-3`, `output-1`,
92/// `tool-2` and `text-0` name a part the wire gave no id, and anything else
93/// is the wire's own id.
94fn fixture_provider_id(id: &str) -> Option<&str> {
95    let unnamed = ["reasoning-", "block-", "output-", "tool-", "text-"]
96        .iter()
97        .any(|namespace| {
98            id.strip_prefix(namespace)
99                .is_some_and(|rest| rest.parse::<u64>().is_ok())
100        });
101    (!id.is_empty() && !unnamed).then_some(id)
102}
103
104impl MockStreamEvent {
105    /// Create a text chunk.
106    pub fn text(text: impl Into<String>) -> Self {
107        Self::Text(text.into())
108    }
109
110    /// Start a new text content block identified by `id`.
111    pub fn text_start(id: impl Into<String>, additional_params: Option<serde_json::Value>) -> Self {
112        Self::TextStart {
113            id: id.into(),
114            additional_params,
115        }
116    }
117
118    /// Create a complete tool call event.
119    pub fn tool_call(
120        id: impl Into<String>,
121        name: impl Into<String>,
122        arguments: serde_json::Value,
123    ) -> Self {
124        Self::ToolCall {
125            id: id.into(),
126            name: name.into(),
127            arguments,
128            call_id: None,
129        }
130    }
131
132    /// Attach a provider-specific call ID to a complete tool call event.
133    pub fn with_call_id(mut self, call_id: impl Into<String>) -> Self {
134        if let Self::ToolCall { call_id: id, .. } = &mut self {
135            *id = Some(call_id.into());
136        }
137        self
138    }
139
140    /// Create a tool call name delta.
141    pub fn tool_call_name_delta(id: impl Into<String>, name: impl Into<String>) -> Self {
142        Self::ToolCallNameDelta {
143            id: id.into(),
144            name: name.into(),
145        }
146    }
147
148    /// Create a tool call arguments delta.
149    pub fn tool_call_arguments_delta(id: impl Into<String>, arguments: impl Into<String>) -> Self {
150        Self::ToolCallArgumentsDelta {
151            id: id.into(),
152            arguments: arguments.into(),
153        }
154    }
155
156    /// Create the end of a tool call streamed as deltas.
157    pub fn tool_call_end(id: impl Into<String>) -> Self {
158        Self::ToolCallEnd { id: id.into() }
159    }
160
161    /// Create a complete reasoning event with the default mock id
162    /// (`"reasoning-0"`). Use [`Self::with_reasoning_id`] for tests that
163    /// need distinct reasoning items.
164    pub fn reasoning(reasoning: impl Into<String>) -> Self {
165        Self::Reasoning {
166            id: "reasoning-0".to_string(),
167            text: reasoning.into(),
168        }
169    }
170
171    /// Attach a provider-specific reasoning ID to a complete reasoning event.
172    pub fn with_reasoning_id(mut self, reasoning_id: impl Into<String>) -> Self {
173        if let Self::Reasoning { id, .. } = &mut self {
174            *id = reasoning_id.into();
175        }
176        self
177    }
178
179    /// Create a reasoning delta event with the default mock id
180    /// (`"reasoning-0"`). Use [`Self::reasoning_delta_with_id`] for tests
181    /// that need distinct reasoning items.
182    pub fn reasoning_delta(reasoning: impl Into<String>) -> Self {
183        Self::reasoning_delta_with_id("reasoning-0", reasoning)
184    }
185
186    /// Create a reasoning delta event with an explicit reasoning item id.
187    pub fn reasoning_delta_with_id(id: impl Into<String>, reasoning: impl Into<String>) -> Self {
188        Self::ReasoningDelta {
189            id: id.into(),
190            reasoning: reasoning.into(),
191        }
192    }
193
194    /// Create an unmodeled provider output item.
195    pub fn unknown(value: serde_json::Value) -> Self {
196        Self::Unknown(value)
197    }
198
199    /// Create the provider's end of the reply, with usage.
200    pub fn final_response(usage: Usage) -> Self {
201        Self::FinalResponse(mock_final(usage))
202    }
203
204    /// Create a final response event whose usage reports no counter.
205    pub fn final_response_with_default_usage() -> Self {
206        Self::FinalResponse(mock_final(Usage::default()))
207    }
208
209    /// Create a final response event whose usage has only `total_tokens` set.
210    pub fn final_response_with_total_tokens(total_tokens: u64) -> Self {
211        Self::FinalResponse(mock_final_with_total_tokens(total_tokens))
212    }
213
214    /// Create a stream error event.
215    pub fn error(message: impl Into<String>) -> Self {
216        Self::Error(MockError::provider(message))
217    }
218}
219
220/// One step of a mock reply: a scripted event, or a whole response.
221#[derive(Clone, Debug)]
222pub enum MockFrame {
223    /// A scripted event.
224    Event(MockStreamEvent),
225    /// A whole response, as a unary turn answers.
226    Response(Box<CompletionResponse>),
227}
228
229/// The reply document of [`MockScript`](super::MockScript): a whole
230/// response's `raw`, or a stream's scripted end, serialized. A stream that
231/// failed before its end has none.
232#[derive(Debug, Default)]
233pub struct MockDocument {
234    document: Option<serde_json::Value>,
235    failed: bool,
236}
237
238impl crate::wire::document::Serves<crate::operation::Completion> for MockDocument {}
239
240impl crate::wire::document::Reassemble<MockFrame> for MockDocument {
241    fn absorb(&mut self, frame: &MockFrame) {
242        if self.document.is_some() || self.failed {
243            return;
244        }
245        match frame {
246            MockFrame::Response(response) => self.document = Some(response.raw.clone()),
247            MockFrame::Event(MockStreamEvent::FinalResponse(finish)) => {
248                self.document = serde_json::to_value(finish).ok();
249            }
250            MockFrame::Event(MockStreamEvent::Error(_)) => self.failed = true,
251            MockFrame::Event(_) => {}
252        }
253    }
254
255    fn finish(self) -> serde_json::Value {
256        self.document.unwrap_or(serde_json::Value::Null)
257    }
258}
259
260/// The decoder of [`MockScript`](super::MockScript): each scripted step is
261/// written through the completion writer.
262#[derive(Default)]
263pub struct MockDecoder<'id> {
264    /// The reasoning item each scripted id streams into, in start order.
265    reasoning: Vec<(String, usize)>,
266    /// The item index each scripted call id streams under.
267    calls: HashMap<String, usize>,
268    brand: std::marker::PhantomData<fn(&'id ()) -> &'id ()>,
269}
270
271/// The provider item a scripted id names, when it names one.
272fn id_item(id: &str) -> serde_json::Value {
273    fixture_provider_id(id).map_or(
274        serde_json::Value::Null,
275        |id| serde_json::json!({ "id": id }),
276    )
277}
278
279impl<'id> MockDecoder<'id> {
280    fn call_index(&mut self, out: &mut Out<'id, Completion>, id: &str) -> usize {
281        if let Some(index) = self.calls.get(id) {
282            return *index;
283        }
284        let index = out.fresh_index();
285        if !id.is_empty() {
286            self.calls.insert(id.to_owned(), index);
287        }
288        index
289    }
290
291    fn event(
292        &mut self,
293        event: MockStreamEvent,
294        mut out: Out<'id, Completion>,
295    ) -> Result<Flow, ProviderError> {
296        match event {
297            MockStreamEvent::Text(text) => {
298                out.run(Block::Text, &text)?;
299            }
300            MockStreamEvent::TextStart {
301                id: _,
302                additional_params,
303            } => {
304                out.end_run()?;
305                let index = out.run(Block::Text, "")?;
306                if let Some(item) = additional_params.map(fixture_item).transpose()?.flatten() {
307                    out.edit(index, |slot| *slot = item)?;
308                }
309            }
310            MockStreamEvent::TextAdditionalParams(additional_params) => {
311                // An empty fixture object is a scripting mistake, not a no-op.
312                let Some(serde_json::Value::Object(fields)) = fixture_item(additional_params)?
313                else {
314                    return Err(ProviderError::Provider(
315                        "mock stream fixture `TextAdditionalParams` carries no data — \
316                         drop the event instead"
317                            .to_string(),
318                    ));
319                };
320                let index = out.run(Block::Text, "")?;
321                out.edit(index, |item| {
322                    if !item.is_object() {
323                        *item = serde_json::Value::Object(serde_json::Map::new());
324                    }
325                    if let Some(item) = item.as_object_mut() {
326                        item.extend(fields);
327                    }
328                })?;
329            }
330            MockStreamEvent::ToolCall {
331                id,
332                name,
333                arguments,
334                call_id,
335            } => {
336                out.end_run()?;
337                // An id-less call is a wire that sends none: rig issues the
338                // id. A call scripted as fragments under this id is the one
339                // this restatement closes.
340                let index = match self.calls.remove(&id) {
341                    Some(index) => index,
342                    None => out.fresh_index(),
343                };
344                out.fragment(
345                    Some(index),
346                    CallFragment {
347                        id: call_id.as_deref().or(fixture_provider_id(&id)),
348                        name: Some(name.as_str()),
349                        ..CallFragment::default()
350                    },
351                )?;
352                out.announce(index, arguments)?;
353                out.finish(index)?;
354            }
355            MockStreamEvent::ToolCallNameDelta { id, name } => {
356                out.end_run()?;
357                let index = self.call_index(&mut out, &id);
358                out.fragment(
359                    Some(index),
360                    CallFragment {
361                        id: fixture_provider_id(&id),
362                        name: Some(name.as_str()),
363                        ..CallFragment::default()
364                    },
365                )?;
366            }
367            MockStreamEvent::ToolCallArgumentsDelta { id, arguments } => {
368                out.end_run()?;
369                let index = self.call_index(&mut out, &id);
370                out.fragment(
371                    Some(index),
372                    CallFragment {
373                        id: fixture_provider_id(&id),
374                        arguments: Some(arguments.as_str()),
375                        ..CallFragment::default()
376                    },
377                )?;
378            }
379            MockStreamEvent::ToolCallEnd { id } => {
380                let index = self.call_index(&mut out, &id);
381                self.calls.remove(&id);
382                out.finish(index)?;
383            }
384            MockStreamEvent::Reasoning { id, text } => {
385                out.end_run()?;
386                // A whole reasoning closes the part streamed under its id.
387                match self.reasoning.iter().position(|(open, _)| *open == id) {
388                    Some(at) => {
389                        let (_, index) = self.reasoning.remove(at);
390                        out.finish(index)?;
391                    }
392                    None => {
393                        let index = out.fresh_index();
394                        out.whole(
395                            index,
396                            Block::Reasoning { redacted: false },
397                            id_item(&id),
398                            &text,
399                        )?;
400                    }
401                }
402            }
403            MockStreamEvent::ReasoningDelta { id, reasoning } => {
404                out.end_run()?;
405                let index = match self.reasoning.iter().find(|(open, _)| *open == id) {
406                    Some((_, index)) => *index,
407                    None => {
408                        let index = out.fresh_index();
409                        out.open(index, Block::Reasoning { redacted: false }, id_item(&id))?;
410                        self.reasoning.push((id, index));
411                        index
412                    }
413                };
414                out.push(index, &reasoning)?;
415            }
416            MockStreamEvent::Unknown(value) => out.unknown(value.into()),
417            MockStreamEvent::RequestId(_) => {}
418            MockStreamEvent::FinalResponse(finish) => {
419                out.end_run()?;
420                for (_, index) in std::mem::take(&mut self.reasoning) {
421                    out.finish(index)?;
422                }
423                return Ok(out.end(finish));
424            }
425            MockStreamEvent::Error(error) => return Err(error.into_completion_error()),
426        }
427        Ok(Flow::More)
428    }
429
430    fn response(
431        &mut self,
432        response: CompletionResponse,
433        mut out: Out<'id, Completion>,
434    ) -> Result<Flow, ProviderError> {
435        for content in response.choice.iter().cloned() {
436            out.content(content)?;
437        }
438        Ok(out.end(Finish {
439            usage: response.usage,
440            reason: response.finish_reason(),
441            response_id: response.response_id().map(str::to_owned),
442            model: response.model().map(str::to_owned),
443            error: response.error.clone(),
444        }))
445    }
446}
447
448impl<'id> Decoder<'id, Completion, MockFrame> for MockDecoder<'id> {
449    type Event = MockFrame;
450
451    fn classify(&self, frame: MockFrame) -> WireEvent<MockFrame> {
452        WireEvent::Known(frame)
453    }
454
455    fn decode(
456        &mut self,
457        frame: MockFrame,
458        out: Out<'id, Completion>,
459    ) -> Result<Flow, ProviderError> {
460        match frame {
461            MockFrame::Event(event) => self.event(event, out),
462            MockFrame::Response(response) => self.response(*response, out),
463        }
464    }
465}