Skip to main content

rig_core/test_utils/
completion.rs

1//! Completion helpers for deterministic agent-loop tests.
2
3use std::{
4    collections::VecDeque,
5    sync::{Arc, Mutex, MutexGuard},
6};
7
8use crate::{
9    completion::{
10        AssistantContent, CompletionError, CompletionModel, CompletionRequest, CompletionResponse,
11        Usage,
12    },
13    message::{ToolCall, ToolFunction},
14    streaming::StreamingCompletionResponse,
15};
16
17use super::streaming::{MOCK_PROVIDER, MockStreamEvent};
18
19/// Scripted error returned by [`MockCompletionModel`].
20#[derive(Clone, Debug)]
21pub enum MockError {
22    /// Provider error.
23    Provider(String),
24    /// Request construction error.
25    Request(String),
26    /// A preserved provider error response (rig#2314), id included.
27    ProviderResponse(crate::provider_response::ProviderResponseError),
28}
29
30impl MockError {
31    /// Create a provider error.
32    pub fn provider(message: impl Into<String>) -> Self {
33        Self::Provider(message.into())
34    }
35
36    /// Create a request error.
37    pub fn request(message: impl Into<String>) -> Self {
38        Self::Request(message.into())
39    }
40
41    pub(crate) fn into_completion_error(self) -> CompletionError {
42        match self {
43            Self::Provider(message) => CompletionError::ProviderError(message),
44            Self::Request(message) => CompletionError::RequestError(message.into()),
45            Self::ProviderResponse(response) => CompletionError::ProviderResponse(response),
46        }
47    }
48}
49
50/// A scripted non-streaming mock completion turn.
51#[derive(Clone, Debug)]
52pub struct MockTurn {
53    response: Result<MockTurnResponse, MockError>,
54}
55
56#[derive(Clone, Debug)]
57struct MockTurnResponse {
58    choice: Vec<AssistantContent>,
59    usage: Usage,
60    message_id: Option<String>,
61    response_id: Option<String>,
62    provider_request_id: Option<String>,
63    finish_reason: Option<crate::completion::FinishReason>,
64    raw: serde_json::Value,
65}
66
67impl MockTurn {
68    /// Create a text response turn.
69    pub fn text(text: impl Into<String>) -> Self {
70        Self::from_content(AssistantContent::text(text.into()))
71    }
72
73    /// Create a tool-call response turn.
74    pub fn tool_call(
75        id: impl Into<String>,
76        name: impl Into<String>,
77        arguments: serde_json::Value,
78    ) -> Self {
79        Self::from_content(AssistantContent::ToolCall(ToolCall::from_wire(
80            id,
81            ToolFunction::new(name.into(), arguments),
82        )))
83    }
84
85    /// Create a provider-error response turn.
86    pub fn error(message: impl Into<String>) -> Self {
87        Self {
88            response: Err(MockError::provider(message)),
89        }
90    }
91
92    /// Create a provider-response error turn carrying a transport request id
93    /// (rig#2314): the scripted failure a test uses to assert error-identity
94    /// attribution.
95    pub fn provider_response_error(
96        status: http::StatusCode,
97        body: impl Into<String>,
98        request_id: impl Into<String>,
99    ) -> Self {
100        Self {
101            response: Err(MockError::ProviderResponse(
102                crate::provider_response::ProviderResponseError::new(status, body)
103                    .with_provider_request_id(Some(request_id.into())),
104            )),
105        }
106    }
107
108    /// Create a request-error response turn.
109    pub fn request_error(message: impl Into<String>) -> Self {
110        Self {
111            response: Err(MockError::request(message)),
112        }
113    }
114
115    /// Create a response turn from one assistant content item.
116    pub fn from_content(content: AssistantContent) -> Self {
117        Self {
118            response: Ok(MockTurnResponse {
119                choice: vec![content],
120                usage: Usage::new(),
121                message_id: None,
122                response_id: None,
123                provider_request_id: None,
124                finish_reason: None,
125                raw: serde_json::Value::Null,
126            }),
127        }
128    }
129
130    /// Create a response turn from assistant content items.
131    ///
132    /// Infallible now that content is a `Vec`: an empty turn is a shape a
133    /// provider can genuinely return, so it is a value to build, not an error.
134    pub fn from_contents(content: impl IntoIterator<Item = AssistantContent>) -> Self {
135        Self {
136            response: Ok(MockTurnResponse {
137                choice: content.into_iter().collect(),
138                usage: Usage::new(),
139                message_id: None,
140                response_id: None,
141                provider_request_id: None,
142                finish_reason: None,
143                raw: serde_json::Value::Null,
144            }),
145        }
146    }
147
148    /// Attach a provider-specific call ID to a tool-call response turn.
149    pub fn with_call_id(mut self, call_id: impl Into<String>) -> Self {
150        let call_id = call_id.into();
151        if let Ok(response) = &mut self.response {
152            for content in response.choice.iter_mut() {
153                if let AssistantContent::ToolCall(tool_call) = content {
154                    tool_call.provider = crate::message::ProviderCallId::new(call_id);
155                    break;
156                }
157            }
158        }
159        self
160    }
161
162    /// Override usage for this turn.
163    pub fn with_usage(mut self, usage: Usage) -> Self {
164        if let Ok(response) = &mut self.response {
165            response.usage = usage;
166        }
167        self
168    }
169
170    /// Set a provider-assigned assistant message ID for this turn.
171    pub fn with_message_id(mut self, message_id: impl Into<String>) -> Self {
172        if let Ok(response) = &mut self.response {
173            response.message_id = Some(message_id.into());
174        }
175        self
176    }
177
178    /// Set a provider-assigned response-scoped ID for this turn.
179    pub fn with_response_id(mut self, response_id: impl Into<String>) -> Self {
180        if let Ok(response) = &mut self.response {
181            response.response_id = Some(response_id.into());
182        }
183        self
184    }
185
186    /// Set a provider transport request id for this turn.
187    pub fn with_provider_request_id(mut self, request_id: impl Into<String>) -> Self {
188        if let Ok(response) = &mut self.response {
189            response.provider_request_id = Some(request_id.into());
190        }
191        self
192    }
193
194    /// Set the terminal finish reason for this turn.
195    ///
196    /// Without this, a mocked blocking turn always reports `None`, which
197    /// leaves the whole blocking half of the truncation contract (rig#2322)
198    /// unexercisable — the streamed mock could script a reason and the
199    /// blocking one could not.
200    pub fn with_finish_reason(mut self, finish_reason: crate::completion::FinishReason) -> Self {
201        if let Ok(response) = &mut self.response {
202            response.finish_reason = Some(finish_reason);
203        }
204        self
205    }
206
207    /// Script the provider's own response for this turn — what a real seam
208    /// would serialize from its raw type. Attached to the response as-is, so
209    /// agent tests can prove the payload reaches every observer of the turn
210    /// without a live provider. A turn without a scripted payload reports
211    /// `raw: Value::Null`, so a non-null `raw` in a test means the scripted
212    /// value arrived, never that the mock invented one.
213    pub fn with_raw(mut self, raw: serde_json::Value) -> Self {
214        if let Ok(response) = &mut self.response {
215            response.raw = raw;
216        }
217        self
218    }
219
220    fn into_completion_response(self) -> Result<CompletionResponse, CompletionError> {
221        let response = self.response.map_err(MockError::into_completion_error)?;
222        Ok(
223            CompletionResponse::new(response.choice, response.usage, MOCK_PROVIDER)
224                .with_optional_message_id(response.message_id)
225                .with_optional_response_id(response.response_id)
226                .with_optional_provider_request_id(response.provider_request_id)
227                .with_optional_finish_reason(response.finish_reason)
228                .with_raw(response.raw),
229        )
230    }
231}
232
233#[derive(Default)]
234struct MockCompletionModelState {
235    turns: Mutex<VecDeque<MockTurn>>,
236    stream_turns: Mutex<VecDeque<Vec<MockStreamEvent>>>,
237    requests: Mutex<Vec<CompletionRequest>>,
238}
239
240/// A cloneable scripted [`CompletionModel`] for tests.
241///
242/// Each completion or stream call consumes exactly one scripted turn. If no turn
243/// is available, the model returns [`CompletionError::ProviderError`] with a
244/// clear message instead of repeating previous responses.
245#[derive(Clone, Default)]
246pub struct MockCompletionModel {
247    state: Arc<MockCompletionModelState>,
248}
249
250impl MockCompletionModel {
251    /// Create a mock model from scripted non-streaming turns.
252    pub fn new(turns: impl IntoIterator<Item = MockTurn>) -> Self {
253        Self::from_turns(turns)
254    }
255
256    /// Create a mock model that returns one text completion.
257    pub fn text(text: impl Into<String>) -> Self {
258        Self::from_turns([MockTurn::text(text)])
259    }
260
261    /// Create a mock model from scripted non-streaming turns.
262    pub fn from_turns(turns: impl IntoIterator<Item = MockTurn>) -> Self {
263        Self {
264            state: Arc::new(MockCompletionModelState {
265                turns: Mutex::new(turns.into_iter().collect()),
266                stream_turns: Mutex::new(VecDeque::new()),
267                requests: Mutex::new(Vec::new()),
268            }),
269        }
270    }
271
272    /// Create a mock model from scripted streaming turns.
273    pub fn from_stream_turns(
274        stream_turns: impl IntoIterator<Item = impl IntoIterator<Item = MockStreamEvent>>,
275    ) -> Self {
276        Self {
277            state: Arc::new(MockCompletionModelState {
278                turns: Mutex::new(VecDeque::new()),
279                stream_turns: Mutex::new(
280                    stream_turns
281                        .into_iter()
282                        .map(|turn| turn.into_iter().collect())
283                        .collect(),
284                ),
285                requests: Mutex::new(Vec::new()),
286            }),
287        }
288    }
289
290    /// Return cloned requests received by this model.
291    pub fn requests(&self) -> Vec<CompletionRequest> {
292        self.requests_guard().clone()
293    }
294
295    /// Return the number of requests received by this model.
296    pub fn request_count(&self) -> usize {
297        self.requests_guard().len()
298    }
299
300    fn record_request(&self, request: CompletionRequest) {
301        self.requests_guard().push(request);
302    }
303
304    fn next_turn(&self) -> Option<MockTurn> {
305        self.turns_guard().pop_front()
306    }
307
308    fn next_stream_turn(&self) -> Option<Vec<MockStreamEvent>> {
309        self.stream_turns_guard().pop_front()
310    }
311
312    fn turns_guard(&self) -> MutexGuard<'_, VecDeque<MockTurn>> {
313        match self.state.turns.lock() {
314            Ok(guard) => guard,
315            Err(poisoned) => poisoned.into_inner(),
316        }
317    }
318
319    fn stream_turns_guard(&self) -> MutexGuard<'_, VecDeque<Vec<MockStreamEvent>>> {
320        match self.state.stream_turns.lock() {
321            Ok(guard) => guard,
322            Err(poisoned) => poisoned.into_inner(),
323        }
324    }
325
326    fn requests_guard(&self) -> MutexGuard<'_, Vec<CompletionRequest>> {
327        match self.state.requests.lock() {
328            Ok(guard) => guard,
329            Err(poisoned) => poisoned.into_inner(),
330        }
331    }
332}
333
334impl CompletionModel for MockCompletionModel {
335    async fn completion(
336        &self,
337        request: CompletionRequest,
338    ) -> Result<CompletionResponse, CompletionError> {
339        self.record_request(request);
340        let Some(turn) = self.next_turn() else {
341            return Err(CompletionError::ProviderError(
342                "mock completion model has no scripted completion turn".to_string(),
343            ));
344        };
345
346        turn.into_completion_response()
347    }
348
349    async fn stream(
350        &self,
351        request: CompletionRequest,
352    ) -> Result<StreamingCompletionResponse, CompletionError> {
353        self.record_request(request);
354        let Some(events) = self.next_stream_turn() else {
355            return Err(CompletionError::ProviderError(
356                "mock completion model has no scripted streaming turn".to_string(),
357            ));
358        };
359
360        let stream = async_stream::stream! {
361            for event in events {
362                yield event.into_raw_choice();
363            }
364        };
365        // Scripted terminals go through `normalize_stream` like every real
366        // provider's, so the mock observes the same `Stop` -> `ToolCalls`
367        // reconciliation callers see in production — and the same raw
368        // capture: the mock's terminal type is `StreamFinal` itself, so `raw`
369        // is the scripted terminal serialized.
370        let stream = crate::streaming::normalize_stream(Box::pin(stream), Ok);
371        Ok(StreamingCompletionResponse::stream(MOCK_PROVIDER, stream))
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::{
379        message::Message,
380        streaming::{StreamFinal, StreamedAssistantContent, ToolCallDeltaContent},
381    };
382    use futures::StreamExt;
383
384    fn request(prompt: &str) -> CompletionRequest {
385        CompletionRequest {
386            model: None,
387            preamble: None,
388            chat_history: vec![Message::user(prompt)],
389            documents: Vec::new(),
390            tools: Vec::new(),
391            temperature: None,
392            max_tokens: None,
393            tool_choice: None,
394            additional_params: None,
395            output_schema: None,
396            record_telemetry_content: false,
397        }
398    }
399
400    #[tokio::test]
401    async fn completion_consumes_scripted_turns_and_records_requests() {
402        let model = MockCompletionModel::new([
403            MockTurn::text("first").with_message_id("msg_1"),
404            MockTurn::tool_call("tool_1", "calculator", serde_json::json!({"x": 1}))
405                .with_call_id("call_1"),
406        ]);
407
408        let first = model
409            .completion(request("hello"))
410            .await
411            .expect("first scripted turn should succeed");
412        assert_eq!(first.message_id.as_deref(), Some("msg_1"));
413        assert!(matches!(
414            first.choice.first(),
415            Some(AssistantContent::Text(text)) if text.text == "first"
416        ));
417
418        let second = model
419            .completion(request("use a tool"))
420            .await
421            .expect("second scripted turn should succeed");
422        assert!(matches!(
423            second.choice.first(),
424            Some(AssistantContent::ToolCall(tool_call))
425                if tool_call.id == "tool_1"
426                    && tool_call
427                        .provider
428                        .as_ref()
429                        .is_some_and(|provider| provider.call_id == "call_1")
430        ));
431
432        assert_eq!(model.request_count(), 2);
433        assert_eq!(model.requests().len(), 2);
434    }
435
436    /// The mock behaves like a real seam: a scripted raw payload rides on the
437    /// normalized response unconditionally, and a turn that scripted none
438    /// reports `raw: Value::Null` — the mock never invents a payload, so `Value::Null`
439    /// here means "no provider record was scripted behind this turn".
440    #[tokio::test]
441    async fn completion_attaches_scripted_raw_and_reports_null_when_unscripted() {
442        let payload = serde_json::json!({"provider_only": "kept", "id": "resp_1"});
443        let model = MockCompletionModel::new([
444            MockTurn::text("first").with_raw(payload.clone()),
445            MockTurn::text("second"),
446        ]);
447
448        let scripted = model
449            .completion(request("hello"))
450            .await
451            .expect("first scripted turn should succeed");
452        assert_eq!(scripted.raw, payload);
453
454        let unscripted = model
455            .completion(request("hello"))
456            .await
457            .expect("second scripted turn should succeed");
458        assert!(unscripted.raw.is_null());
459
460        assert_eq!(model.requests().len(), 2);
461    }
462
463    /// The streaming half of the same contract: the scripted terminal goes
464    /// through `normalize_stream`, so the terminal's `raw` is the scripted
465    /// terminal record serialized (the mock's own terminal type is
466    /// `StreamFinal`).
467    #[tokio::test]
468    async fn stream_terminal_raw_is_the_scripted_terminal_serialized() {
469        let model = MockCompletionModel::from_stream_turns([vec![
470            MockStreamEvent::text("hello"),
471            MockStreamEvent::final_response(Usage {
472                input_tokens: 1,
473                output_tokens: 2,
474                total_tokens: 3,
475                ..Usage::new()
476            }),
477        ]]);
478
479        let mut stream = model
480            .stream(request("hello"))
481            .await
482            .expect("stream should open");
483        while stream.next().await.is_some() {}
484        let terminal = stream.response.expect("terminal record");
485        let raw = &terminal.raw;
486        let typed: StreamFinal = serde_json::from_value(raw.clone()).expect("terminal type");
487        assert_eq!(typed.usage.total_tokens, 3);
488        assert!(
489            typed.raw.is_null(),
490            "the scripted terminal itself carried no raw (Value::Null)"
491        );
492        assert_eq!(
493            serde_json::to_value(&typed).expect("re-serialize"),
494            *raw,
495            "the capture must be exactly what the scripted terminal serializes to"
496        );
497        assert_eq!(terminal.usage.total_tokens, 3);
498    }
499
500    #[tokio::test]
501    async fn missing_completion_turn_returns_provider_error() {
502        let model = MockCompletionModel::default();
503
504        let err = model
505            .completion(request("hello"))
506            .await
507            .expect_err("missing turn should error");
508
509        assert!(matches!(
510            err,
511            CompletionError::ProviderError(message)
512                if message.contains("no scripted completion turn")
513        ));
514    }
515
516    #[tokio::test]
517    async fn stream_yields_scripted_events_and_records_requests() {
518        let model = MockCompletionModel::from_stream_turns([[
519            MockStreamEvent::message_id("msg_stream"),
520            MockStreamEvent::text("hel"),
521            MockStreamEvent::text("lo"),
522            MockStreamEvent::tool_call_name_delta("tool_1", "calculator"),
523            MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
524            MockStreamEvent::tool_call("tool_1", "calculator", serde_json::json!({"x": 1}))
525                .with_call_id("call_1"),
526            MockStreamEvent::final_response_with_total_tokens(7),
527        ]]);
528
529        let mut stream = model
530            .stream(request("stream"))
531            .await
532            .expect("stream should be created");
533
534        let mut text = String::new();
535        let mut saw_name_delta = false;
536        let mut saw_arguments_delta = false;
537        let mut saw_tool_call = false;
538        let mut saw_final = false;
539
540        while let Some(item) = stream.next().await {
541            match item.expect("stream event should succeed") {
542                StreamedAssistantContent::Text(chunk) => text.push_str(&chunk.text),
543                StreamedAssistantContent::ToolCallDelta { content, .. } => match content {
544                    ToolCallDeltaContent::Name(name) => {
545                        saw_name_delta = name == "calculator";
546                    }
547                    ToolCallDeltaContent::Delta(arguments) => {
548                        saw_arguments_delta = arguments == "{\"x\":1}";
549                    }
550                },
551                StreamedAssistantContent::ToolCall { tool_call, .. } => {
552                    saw_tool_call = tool_call
553                        .provider
554                        .as_ref()
555                        .is_some_and(|provider| provider.call_id == "call_1");
556                }
557                StreamedAssistantContent::Final(response) => {
558                    saw_final = matches!(
559                        response.usage,
560                        Usage {
561                            total_tokens: 7,
562                            ..
563                        }
564                    );
565                }
566                _ => {}
567            }
568        }
569
570        assert_eq!(text, "hello");
571        assert!(saw_name_delta);
572        assert!(saw_arguments_delta);
573        assert!(saw_tool_call);
574        assert!(saw_final);
575        assert_eq!(stream.message_id.as_deref(), Some("msg_stream"));
576        assert_eq!(model.request_count(), 1);
577    }
578
579    #[tokio::test]
580    async fn stream_error_event_is_returned() {
581        let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::error("boom")]]);
582        let mut stream = model
583            .stream(request("stream"))
584            .await
585            .expect("stream should be created");
586
587        let err = stream
588            .next()
589            .await
590            .expect("stream should yield one event")
591            .expect_err("scripted event should error");
592
593        assert!(matches!(
594            err,
595            CompletionError::ProviderError(message) if message == "boom"
596        ));
597    }
598}