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::driver::{Exchange, Model, Opened, Opening, Transport};
9use crate::error::{EncodeError, ProviderError};
10use crate::operation::Completion;
11use crate::wire::{Capabilities, Descriptor, Mode, Wire};
12use crate::{
13    completion::{AssistantContent, CompletionRequest, CompletionResponse, Usage},
14    message::{ToolCall, ToolFunction},
15};
16
17use super::streaming::{MOCK_PROVIDER, MockDecoder, MockDocument, MockFrame, MockStreamEvent};
18
19/// Scripted error returned by [`MockCompletionModel`].
20#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
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    /// A streamed reply that sent nothing for this long.
29    Stalled(std::time::Duration),
30}
31
32impl MockError {
33    /// Create a provider error.
34    pub fn provider(message: impl Into<String>) -> Self {
35        Self::Provider(message.into())
36    }
37
38    /// Create a request error.
39    pub fn request(message: impl Into<String>) -> Self {
40        Self::Request(message.into())
41    }
42
43    pub(crate) fn into_completion_error(self) -> ProviderError {
44        match self {
45            Self::Provider(message) => ProviderError::Provider(message),
46            Self::Request(message) => ProviderError::request(message),
47            Self::ProviderResponse(response) => ProviderError::ProviderResponse(response),
48            Self::Stalled(idle) => ProviderError::Stalled(idle),
49        }
50    }
51}
52
53/// A scripted non-streaming mock completion turn.
54///
55/// A turn is data: a script serializes, so a scripted model can be written
56/// to a fixture and read back (see `MockCompletionModel::script`).
57#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
58pub struct MockTurn {
59    response: Result<MockTurnResponse, MockError>,
60}
61
62#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
63struct MockTurnResponse {
64    choice: Vec<AssistantContent>,
65    usage: Usage,
66    response_id: Option<String>,
67    provider_request_id: Option<String>,
68    finish_reason: Option<crate::completion::FinishReason>,
69    /// A scripted provider document, when the test supplies one; otherwise
70    /// the turn itself, serialized, is the mock's document. Absent from the
71    /// serialized turn when unscripted, so that document never nests
72    /// itself; a scripted one survives a serde round trip of the script.
73    #[serde(
74        default,
75        skip_serializing_if = "Option::is_none",
76        deserialize_with = "deserialize_scripted_raw"
77    )]
78    raw: Option<serde_json::Value>,
79}
80
81fn deserialize_scripted_raw<'de, D: serde::Deserializer<'de>>(
82    deserializer: D,
83) -> Result<Option<serde_json::Value>, D::Error> {
84    // Only a missing field means unscripted; explicit JSON null is a document.
85    serde::Deserialize::deserialize(deserializer).map(Some)
86}
87
88impl MockTurn {
89    /// Create a text response turn.
90    pub fn text(text: impl Into<String>) -> Self {
91        Self::from_content(AssistantContent::text(text.into()))
92    }
93
94    /// Create a tool-call response turn. An empty `name` scripts a provider
95    /// error instead: no call can be built without a name.
96    pub fn tool_call(
97        id: impl Into<String>,
98        name: impl Into<String>,
99        arguments: serde_json::Value,
100    ) -> Self {
101        match crate::message::ToolName::new(name) {
102            Ok(name) => Self::from_content(AssistantContent::ToolCall(ToolCall::from_wire(
103                id,
104                ToolFunction::new(name, arguments),
105            ))),
106            Err(error) => Self::error(error.to_string()),
107        }
108    }
109
110    /// Create a provider-error response turn.
111    pub fn error(message: impl Into<String>) -> Self {
112        Self {
113            response: Err(MockError::provider(message)),
114        }
115    }
116
117    /// Create a provider-response error turn carrying a transport request id
118    /// (rig#2314): the scripted failure a test uses to assert error-identity
119    /// attribution.
120    pub fn provider_response_error(
121        status: http::StatusCode,
122        body: impl Into<String>,
123        request_id: impl Into<String>,
124    ) -> Self {
125        Self {
126            response: Err(MockError::ProviderResponse(
127                crate::provider_response::ProviderResponseError::new(status, body)
128                    .with_provider_request_id(Some(request_id.into())),
129            )),
130        }
131    }
132
133    /// Create a request-error response turn.
134    pub fn request_error(message: impl Into<String>) -> Self {
135        Self {
136            response: Err(MockError::request(message)),
137        }
138    }
139
140    /// Create a response turn from one assistant content item.
141    pub fn from_content(content: AssistantContent) -> Self {
142        Self {
143            response: Ok(MockTurnResponse {
144                choice: vec![content],
145                usage: Usage::default(),
146                response_id: None,
147                provider_request_id: None,
148                finish_reason: None,
149                raw: None,
150            }),
151        }
152    }
153
154    /// Create a response turn from assistant content items.
155    ///
156    /// Infallible now that content is a `Vec`: an empty turn is a shape a
157    /// provider can genuinely return, so it is a value to build, not an error.
158    pub fn from_contents(content: impl IntoIterator<Item = AssistantContent>) -> Self {
159        Self {
160            response: Ok(MockTurnResponse {
161                choice: content.into_iter().collect(),
162                usage: Usage::default(),
163                response_id: None,
164                provider_request_id: None,
165                finish_reason: None,
166                raw: None,
167            }),
168        }
169    }
170
171    /// Attach a provider-specific call ID to a tool-call response turn.
172    pub fn with_call_id(mut self, call_id: impl Into<String>) -> Self {
173        let call_id = call_id.into();
174        if let Ok(response) = &mut self.response {
175            for content in response.choice.iter_mut() {
176                if let AssistantContent::ToolCall(tool_call) = content {
177                    tool_call.id = crate::message::CallId::from_wire(call_id);
178                    break;
179                }
180            }
181        }
182        self
183    }
184
185    /// Override usage for this turn.
186    pub fn with_usage(mut self, usage: Usage) -> Self {
187        if let Ok(response) = &mut self.response {
188            response.usage = usage;
189        }
190        self
191    }
192
193    /// Set a provider-assigned response-scoped ID for this turn.
194    pub fn with_response_id(mut self, response_id: impl Into<String>) -> Self {
195        if let Ok(response) = &mut self.response {
196            response.response_id = Some(response_id.into());
197        }
198        self
199    }
200
201    /// Set a provider transport request id for this turn.
202    pub fn with_provider_request_id(mut self, request_id: impl Into<String>) -> Self {
203        if let Ok(response) = &mut self.response {
204            response.provider_request_id = Some(request_id.into());
205        }
206        self
207    }
208
209    /// Set the terminal finish reason for this turn.
210    ///
211    /// Without this, a mocked blocking turn always reports `None`, which
212    /// leaves the whole blocking half of the truncation contract (rig#2322)
213    /// unexercisable — the streamed mock could script a reason and the
214    /// blocking one could not.
215    pub fn with_finish_reason(mut self, finish_reason: crate::completion::FinishReason) -> Self {
216        if let Ok(response) = &mut self.response {
217            response.finish_reason = Some(finish_reason);
218        }
219        self
220    }
221
222    /// The provider document the mock attaches to this turn's response: the
223    /// scripted payload when one was supplied, otherwise the turn itself,
224    /// serialized. Public so a test can state the expected `raw` of a
225    /// recorded call without repeating the mock's serialization. An error
226    /// turn has no document.
227    pub fn raw(&self) -> Result<serde_json::Value, ProviderError> {
228        let response = self
229            .response
230            .as_ref()
231            .map_err(|error| error.clone().into_completion_error())?;
232        match &response.raw {
233            Some(raw) => Ok(raw.clone()),
234            None => Ok(serde_json::to_value(response)?),
235        }
236    }
237
238    fn into_completion_response(self) -> Result<CompletionResponse, ProviderError> {
239        let raw = self.raw()?;
240        let response = self.response.map_err(MockError::into_completion_error)?;
241        let mut origin = crate::message::Origin::new(MOCK_API, MOCK_PROVIDER, "");
242        origin.response_id = response.response_id;
243        let mut completion = CompletionResponse::new(response.choice, response.usage, origin, raw)
244            .with_optional_finish_reason(response.finish_reason);
245        completion.provider_request_id = response.provider_request_id;
246        Ok(completion)
247    }
248}
249
250type MockInvocation = (CompletionRequest, Option<crate::observe::AdapterContext>);
251
252#[derive(Default)]
253struct MockScriptState {
254    turns: Mutex<VecDeque<MockTurn>>,
255    stream_turns: Mutex<VecDeque<Vec<MockStreamEvent>>>,
256    requests: Mutex<Vec<MockInvocation>>,
257}
258
259/// The scripted completion wire: its payload is the request, and each frame
260/// is a scripted step its decoder writes through the completion writer's
261/// part handles, as any wire's decoder does. A runtime that answers from a
262/// script ([`MockRuntime`], or a test's own) is its transport.
263#[derive(Clone, Debug, PartialEq, Eq)]
264pub struct MockScript {
265    name: String,
266    id: Option<String>,
267    capabilities: Capabilities,
268    /// The facts its replies are priced by, as a real wire's are.
269    facts: crate::catalog::ModelFacts,
270}
271
272impl MockScript {
273    /// A scripted wire named `name`, as records and telemetry name it, with
274    /// default capabilities.
275    pub fn new(name: impl Into<String>) -> Self {
276        Self {
277            name: name.into(),
278            id: None,
279            capabilities: Capabilities::default(),
280            facts: crate::catalog::ModelFacts::default(),
281        }
282    }
283
284    /// The same wire, addressing the model `id`.
285    pub fn with_id(mut self, id: impl Into<String>) -> Self {
286        self.id = Some(id.into());
287        self
288    }
289
290    /// The same wire, reporting `capabilities`.
291    pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
292        self.capabilities = capabilities;
293        self
294    }
295
296    /// The same wire answering as `spec`'s model: named as its provider and
297    /// id, and pricing each reply's usage at the spec's prices, as the
298    /// provider's own wire would. A reply whose usage states its cost keeps
299    /// it.
300    pub fn priced_as(self, spec: impl Into<std::sync::Arc<crate::catalog::ModelSpec>>) -> Self {
301        let spec = spec.into();
302        Self {
303            name: spec.provider.vendor().to_owned(),
304            id: Some(spec.id.clone()),
305            facts: crate::catalog::ModelFacts::new(spec),
306            ..self
307        }
308    }
309}
310
311/// The wire format the scripted wire names.
312pub const MOCK_API: crate::message::Api = crate::message::Api::from_static("mock.script");
313
314/// The model a scripted wire addresses when it names none.
315pub const MOCK_MODEL: &str = "mock-model";
316
317impl crate::completion::ReplayTarget for MockScript {
318    fn api(&self) -> crate::message::Api {
319        MOCK_API
320    }
321
322    /// A script takes every option and sends nothing for it, so a test
323    /// reads the options it set on the request the script records.
324    fn map_options(
325        &self,
326        _request: &crate::completion::CompletionRequest,
327        fields: crate::completion::options::OptionFields<'_>,
328    ) -> crate::completion::options::OptionMap {
329        each_set(fields, || {
330            crate::completion::options::Mapping::Omit("a scripted reply ignores options")
331        })
332    }
333
334    // Scripts state a finish reason only when a test is about one.
335    fn states_finish_reason(&self) -> bool {
336        false
337    }
338
339    fn provider(&self) -> &str {
340        &self.name
341    }
342
343    /// The model the script addresses: its id, or [`MOCK_MODEL`].
344    fn model(&self) -> &str {
345        self.id.as_deref().unwrap_or(MOCK_MODEL)
346    }
347
348    fn accepts(&self, _model: &str) -> crate::completion::Accepts {
349        crate::completion::Accepts::ALL
350    }
351}
352
353impl Default for MockScript {
354    fn default() -> Self {
355        Self::new(MOCK_PROVIDER)
356    }
357}
358
359impl Wire for MockScript {
360    type Op = Completion;
361    type Payload = CompletionRequest;
362    type Frame = MockFrame;
363    type Event = MockFrame;
364    type Decoder<'id> = MockDecoder<'id>;
365    type Reassembler = MockDocument;
366
367    fn describe(&self) -> Descriptor<'_> {
368        Descriptor::new(&self.name)
369            .model(self.id.as_deref())
370            .capabilities(self.capabilities)
371            .facts(&self.facts)
372            .replay(self)
373    }
374
375    /// The request as the runtime received it, its history already shaped
376    /// for this wire.
377    fn encode(
378        &self,
379        request: CompletionRequest,
380        _mode: Mode,
381    ) -> Result<CompletionRequest, EncodeError> {
382        Ok(request)
383    }
384
385    fn decoder<'id>(&self) -> MockDecoder<'id> {
386        MockDecoder::default()
387    }
388}
389
390/// The scripted runtime behind [`MockCompletionModel`]: the transport of a
391/// [`MockScript`] wire named [`MOCK_PROVIDER`].
392///
393/// Each call consumes exactly one scripted turn. If no turn is available,
394/// the call fails with [`ProviderError::Provider`] and a clear message
395/// instead of repeating previous responses.
396#[derive(Clone, Default)]
397pub struct MockRuntime {
398    state: Arc<MockScriptState>,
399}
400
401/// A cloneable scripted completion model for tests: the scripted wire over
402/// its runtime. Clones share the script and the recorded requests.
403pub type MockCompletionModel = Model<MockScript, MockRuntime>;
404
405impl MockCompletionModel {
406    /// Create a mock model that returns one text completion.
407    pub fn text(text: impl Into<String>) -> Self {
408        Self::from_turns([MockTurn::text(text)])
409    }
410
411    /// Create a mock model from scripted non-streaming turns.
412    pub fn from_turns(turns: impl IntoIterator<Item = MockTurn>) -> Self {
413        Self::scripted(turns.into_iter().collect(), VecDeque::new())
414    }
415
416    /// Create a mock model from scripted streaming turns.
417    pub fn from_stream_turns(
418        stream_turns: impl IntoIterator<Item = impl IntoIterator<Item = MockStreamEvent>>,
419    ) -> Self {
420        Self::scripted(
421            VecDeque::new(),
422            stream_turns
423                .into_iter()
424                .map(|turn| turn.into_iter().collect())
425                .collect(),
426        )
427    }
428
429    fn scripted(turns: VecDeque<MockTurn>, stream_turns: VecDeque<Vec<MockStreamEvent>>) -> Self {
430        Model::new(
431            MockScript::default(),
432            MockRuntime {
433                state: Arc::new(MockScriptState {
434                    turns: Mutex::new(turns),
435                    stream_turns: Mutex::new(stream_turns),
436                    requests: Mutex::new(Vec::new()),
437                }),
438            },
439        )
440    }
441
442    /// The same scripted model answering as `spec`'s model and pricing its
443    /// replies at the spec's prices ([`MockScript::priced_as`]), for a test
444    /// of what spending is counted.
445    pub fn priced_as(mut self, spec: impl Into<std::sync::Arc<crate::catalog::ModelSpec>>) -> Self {
446        self.wire = self.wire.priced_as(spec);
447        self
448    }
449
450    /// Return cloned requests received by this model.
451    pub fn requests(&self) -> Vec<CompletionRequest> {
452        self.transport
453            .requests_guard()
454            .iter()
455            .map(|(request, _)| request.clone())
456            .collect()
457    }
458
459    /// Return invocation contexts in the same order as the captured requests.
460    pub fn contexts(&self) -> Vec<Option<crate::observe::AdapterContext>> {
461        self.transport
462            .requests_guard()
463            .iter()
464            .map(|(_, context)| context.clone())
465            .collect()
466    }
467
468    /// Return the number of requests received by this model.
469    pub fn request_count(&self) -> usize {
470        self.transport.requests_guard().len()
471    }
472
473    /// The non-streaming turns not yet consumed, in order — the read-back
474    /// half of the script, so a script is serde in and serde out.
475    pub fn script(&self) -> Vec<MockTurn> {
476        lock(&self.transport.state.turns).iter().cloned().collect()
477    }
478}
479
480impl MockRuntime {
481    fn requests_guard(&self) -> MutexGuard<'_, Vec<MockInvocation>> {
482        lock(&self.state.requests)
483    }
484}
485
486fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
487    match mutex.lock() {
488        Ok(guard) => guard,
489        Err(poisoned) => poisoned.into_inner(),
490    }
491}
492
493impl Transport<MockScript> for MockRuntime {
494    fn send(&self, request: CompletionRequest, exchange: Exchange) -> Opening<MockFrame> {
495        let mode = exchange.mode;
496        self.requests_guard().push((request, exchange.observation));
497        match mode {
498            // A whole turn is one response, its document the response's
499            // `raw`; a scripted failure fails the reply.
500            Mode::Unary => {
501                let Some(turn) = lock(&self.state.turns).pop_front() else {
502                    return Opening::failed(ProviderError::Provider(
503                        "mock completion model has no scripted completion turn".to_string(),
504                    ));
505                };
506                match turn.into_completion_response() {
507                    Ok(response) => {
508                        let document = response.raw.clone();
509                        let request_id = response.provider_request_id.clone();
510                        Opening::ready(
511                            Opened::new(futures::stream::iter([Ok(MockFrame::Response(
512                                Box::new(response),
513                            ))]))
514                            .with_document(document)
515                            .with_request_id(request_id),
516                        )
517                    }
518                    Err(error) => Opening::ready(Opened::failed(error)),
519                }
520            }
521            Mode::Streaming => {
522                let Some(turn) = lock(&self.state.stream_turns).pop_front() else {
523                    return Opening::failed(ProviderError::Provider(
524                        "mock completion model has no scripted streaming turn".to_string(),
525                    ));
526                };
527                let request_id = turn.iter().find_map(|event| match event {
528                    MockStreamEvent::RequestId(id) => Some(id.clone()),
529                    _ => None,
530                });
531                Opening::ready(
532                    Opened::new(futures::stream::iter(
533                        turn.into_iter()
534                            .filter(|event| !matches!(event, MockStreamEvent::RequestId(_)))
535                            .map(|event| Ok(MockFrame::Event(event))),
536                    ))
537                    .with_request_id(request_id),
538                )
539            }
540        }
541    }
542}
543
544#[cfg(test)]
545mod tests;
546
547/// Every set option refused, for a test target that takes none.
548pub fn refuse_options(
549    fields: crate::completion::options::OptionFields<'_>,
550) -> crate::completion::options::OptionMap {
551    each_set(fields, || {
552        crate::completion::options::Mapping::unsupported("the test target takes no options")
553    })
554}
555
556/// `answer` for every set option but the cache key, which is unrouted.
557fn each_set(
558    fields: crate::completion::options::OptionFields<'_>,
559    answer: impl Fn() -> crate::completion::options::Mapping,
560) -> crate::completion::options::OptionMap {
561    use crate::completion::options::{Mapping, OptionMap};
562    let [
563        reasoning,
564        cache,
565        service_tier,
566        verbosity,
567        parallel_tool_calls,
568        top_p,
569        seed,
570        stop,
571        _,
572    ] = fields
573        .set()
574        .map(|set| if set { answer() } else { Mapping::Nothing });
575    OptionMap {
576        reasoning,
577        cache,
578        service_tier,
579        verbosity,
580        parallel_tool_calls,
581        top_p,
582        seed,
583        stop,
584        cache_key: Mapping::unrouted(fields.cache_key),
585    }
586}