Skip to main content

molo_core/provider/
fake.rs

1//! Test helper: a programmable fake Provider.
2//!
3//! A script is a sequence of per-turn replies ([`FakeReply`]); each `chat` /
4//! `stream_chat` call consumes one turn, and the received requests are
5//! recorded so tests can assert the Agent loop's process behavior (whether
6//! tool results are fed back, whether history is appended in order, etc.).
7//!
8//! It is positioned as a test helper, not a production component: unit tests
9//! of the Agent loop use it to inject deterministic replies without depending
10//! on a real API; users testing their own Agent loops can do the same.
11
12use crate::message::{Message, ToolCall};
13use crate::provider::{
14    ChatRequest, ChatResponse, FinishReason, Provider, ProviderCapabilities, ProviderError,
15    ProviderRequestContext, StreamEvent, TimeoutStage, Usage,
16};
17use futures::stream::BoxStream;
18use std::collections::VecDeque;
19use std::sync::{Arc, Mutex};
20
21/// The error message returned once the script is exhausted.
22const EXHAUSTED_MSG: &str = "fake provider: script exhausted";
23
24/// One turn of reply from the script.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum FakeReply {
27    /// Plain text reply (`FinishReason::Stop`).
28    Text(String),
29    /// Text + reasoning (thinking-model scenario; reasoning is carried back
30    /// verbatim in the history).
31    TextWithReasoning {
32        /// Reply text.
33        content: String,
34        /// Reasoning.
35        reasoning: String,
36    },
37    /// Text + requests to call several tools (multiple requests from the same
38    /// turn stay in one message); the text may be empty.
39    ToolCalls {
40        /// Reply text (may be empty).
41        content: String,
42        /// Tool calls requested this turn.
43        calls: Vec<ToolCall>,
44    },
45    /// Specifies this turn's usage (default zero); wraps a base reply
46    /// variant. Use it when a test needs to assert concrete token counts
47    /// (e.g. the Agent's summation); existing script styles keep working
48    /// unchanged.
49    WithUsage {
50        /// The wrapped base reply.
51        reply: Box<FakeReply>,
52        /// This turn's usage.
53        usage: Usage,
54    },
55    /// This turn fails outright; the error is returned to the caller as-is.
56    Error(ProviderError),
57}
58
59impl FakeReply {
60    /// Convenience constructor: text reply + specified usage.
61    pub fn text_with_usage(content: impl Into<String>, usage: Usage) -> Self {
62        Self::WithUsage {
63            reply: Box::new(Self::Text(content.into())),
64            usage,
65        }
66    }
67}
68
69/// A programmable fake [`Provider`] — a script is a sequence of per-turn
70/// replies, consumed in order by `chat` / `stream_chat`.
71///
72/// - Each call consumes one script turn and records the request; **once the
73///   script is exhausted, calls return [`ProviderError::Protocol`] and do
74///   not replay** — an Agent running an extra turn fails explicitly right
75///   away in tests;
76/// - `chat` and `stream_chat` consume the same script with the same
77///   semantics, differing only in how the reply is delivered (an event stream
78///   = several increments + one `Done`);
79/// - [`requests`](FakeProvider::requests) returns a snapshot of the received
80///   request history for tests to assert what the Agent sent to the model —
81///   since a fake is lenient (e.g. if the Agent forgets to feed back tool
82///   results, a real API would error but the fake would not), this is the
83///   only way to catch such process bugs.
84///
85/// # Examples
86///
87/// ```rust
88/// # extern crate molo_core as molo;
89/// # #[tokio::main]
90/// # async fn main() -> Result<(), molo::ProviderError> {
91/// use molo::message::Message;
92/// use molo::provider::{ChatRequest, FakeProvider, FakeReply, Provider};
93///
94/// let fake = FakeProvider::new([
95///     FakeReply::Text("hi".into()),
96///     FakeReply::Text("bye".into()),
97/// ]);
98///
99/// let r = fake.chat(ChatRequest::default()).await?;
100/// assert_eq!(r.message, Message::assistant("hi"));
101/// # Ok(())
102/// # }
103/// ```
104#[derive(Debug, Default)]
105pub struct FakeProvider {
106    /// The script to consume; each call pops from the front of the queue.
107    replies: Mutex<VecDeque<FakeReply>>,
108    /// Received request history (recorded whether or not the script is
109    /// exhausted).
110    requests: Mutex<Vec<ChatRequest>>,
111}
112
113impl Clone for FakeProvider {
114    /// Clones are independent copies: each holds and consumes its own script
115    /// queue and request history without affecting the other (script calls
116    /// made before the clone still consume from the start in the clone).
117    fn clone(&self) -> Self {
118        // std Mutex has no Clone: copy the contents under the lock and
119        // rebuild (a std Mutex cannot clone two locks at once).
120        let replies = self
121            .replies
122            .lock()
123            .expect("FakeProvider internal lock poisoned")
124            .clone();
125        let requests = self
126            .requests
127            .lock()
128            .expect("FakeProvider internal lock poisoned")
129            .clone();
130        Self {
131            replies: Mutex::new(replies),
132            requests: Mutex::new(requests),
133        }
134    }
135}
136
137impl FakeProvider {
138    /// Constructs from a script sequence; `chat` / `stream_chat` consume it
139    /// in order and error once exhausted.
140    pub fn new(replies: impl IntoIterator<Item = FakeReply>) -> Self {
141        Self {
142            replies: Mutex::new(replies.into_iter().collect()),
143            requests: Mutex::new(Vec::new()),
144        }
145    }
146
147    /// Appends one reply to the end of the script (for staged injection in
148    /// tests).
149    pub fn push(&self, reply: FakeReply) {
150        self.replies
151            .lock()
152            .expect("FakeProvider internal lock poisoned")
153            .push_back(reply);
154    }
155
156    /// A snapshot of the received request history, for tests to assert what
157    /// the Agent sent to the model.
158    ///
159    /// # Examples
160    ///
161    /// ```rust
162    /// # extern crate molo_core as molo;
163    /// # #[tokio::main]
164    /// # async fn main() -> Result<(), molo::ProviderError> {
165    /// use molo::message::Message;
166    /// use molo::provider::{ChatRequest, FakeProvider, FakeReply, Provider};
167    ///
168    /// let fake = FakeProvider::new([FakeReply::Text("hi".into())]);
169    /// fake.chat(ChatRequest {
170    ///     messages: vec![Message::user("hi")],
171    ///     ..Default::default()
172    /// })
173    /// .await?;
174    ///
175    /// assert_eq!(fake.requests()[0].messages, vec![Message::user("hi")]);
176    /// # Ok(())
177    /// # }
178    /// ```
179    pub fn requests(&self) -> Vec<ChatRequest> {
180        self.requests
181            .lock()
182            .expect("FakeProvider internal lock poisoned")
183            .clone()
184    }
185}
186
187#[async_trait::async_trait]
188impl Provider for FakeProvider {
189    fn capabilities(&self) -> ProviderCapabilities {
190        ProviderCapabilities {
191            streaming: true,
192            reasoning: true,
193            tool_calls: true,
194            parallel_tool_calls: true,
195            structured_output: true,
196            usage: true,
197            context_cancellation: true,
198            context_deadline: true,
199        }
200    }
201
202    async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
203        self.requests
204            .lock()
205            .expect("FakeProvider internal lock poisoned")
206            .push(request);
207        match self
208            .replies
209            .lock()
210            .expect("FakeProvider internal lock poisoned")
211            .pop_front()
212        {
213            None => Err(ProviderError::Protocol {
214                message: EXHAUSTED_MSG.to_string(),
215            }),
216            Some(reply) => chat_response(reply),
217        }
218    }
219
220    async fn chat_with_context(
221        &self,
222        request: ChatRequest,
223        context: &ProviderRequestContext,
224    ) -> Result<ChatResponse, ProviderError> {
225        check_context(context)?;
226        self.chat(request).await
227    }
228
229    async fn stream_chat(
230        &self,
231        request: ChatRequest,
232    ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
233        self.requests
234            .lock()
235            .expect("FakeProvider internal lock poisoned")
236            .push(request);
237        // Unwrap the usage override recursively first (same as chat:
238        // WithUsage only changes this turn's usage, not the reply body;
239        // nested WithUsage also unwraps correctly).
240        let (reply, usage_override) = match self
241            .replies
242            .lock()
243            .expect("FakeProvider internal lock poisoned")
244            .pop_front()
245        {
246            None => {
247                return Err(ProviderError::Protocol {
248                    message: EXHAUSTED_MSG.to_string(),
249                });
250            }
251            Some(reply) => unwrap_usage(reply),
252        };
253        let mut events = stream_events(reply)?;
254        if let Some(usage) = usage_override {
255            // The last event in the sequence is always Done; replace its
256            // usage.
257            if let Some(Ok(StreamEvent::Done {
258                usage: done_usage, ..
259            })) = events.last_mut()
260            {
261                *done_usage = Some(usage);
262            }
263        }
264        Ok(Box::pin(futures::stream::iter(events)))
265    }
266
267    async fn stream_chat_with_context(
268        &self,
269        request: ChatRequest,
270        context: &ProviderRequestContext,
271    ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
272        check_context(context)?;
273        self.stream_chat(request).await
274    }
275}
276
277fn check_context(context: &ProviderRequestContext) -> Result<(), ProviderError> {
278    if context.is_cancelled() {
279        Err(ProviderError::Cancelled)
280    } else if context.is_expired() {
281        Err(ProviderError::Timeout(TimeoutStage::Request))
282    } else {
283        Ok(())
284    }
285}
286
287/// Converts one script reply into a non-streaming response.
288///
289/// Usage is `None` unless injected via `WithUsage` (an endpoint that does not
290/// report usage); `WithUsage` wrappers are unwrapped recursively and
291/// overridden with the injected value (matching the streaming path).
292fn chat_response(reply: FakeReply) -> Result<ChatResponse, ProviderError> {
293    match reply {
294        // Boundary behavior consistent with chat: this turn's failure is
295        // returned as Err directly.
296        FakeReply::Error(e) => Err(e),
297        FakeReply::WithUsage { reply, usage } => {
298            let mut response = chat_response(*reply)?;
299            response.usage = Some(usage);
300            Ok(response)
301        }
302        FakeReply::Text(content) => Ok(ChatResponse {
303            message: Message::assistant(content),
304            finish_reason: FinishReason::Stop,
305            usage: None,
306        }),
307        FakeReply::TextWithReasoning { content, reasoning } => Ok(ChatResponse {
308            message: Message::assistant_with_reasoning(content, reasoning),
309            finish_reason: FinishReason::Stop,
310            usage: None,
311        }),
312        FakeReply::ToolCalls { content, calls } => Ok(ChatResponse {
313            message: Message::Assistant {
314                content,
315                reasoning: None,
316                tool_calls: calls,
317            },
318            finish_reason: FinishReason::Stop,
319            usage: None,
320        }),
321    }
322}
323
324/// Recursively unwraps `WithUsage` (matching the chat path): the outer usage
325/// wins, the inner one is the fallback — nested `WithUsage(WithUsage(...))`
326/// also unwraps correctly without panicking.
327fn unwrap_usage(reply: FakeReply) -> (FakeReply, Option<Usage>) {
328    match reply {
329        FakeReply::WithUsage { reply, usage } => {
330            let (inner, inner_usage) = unwrap_usage(*reply);
331            (inner, Some(usage).or(inner_usage))
332        }
333        other => (other, None),
334    }
335}
336
337/// Converts one script reply into a stream of events.
338///
339/// Unwrapped replies always carry zero usage (`Some`) — the fake mimics an
340/// endpoint with `include_usage` on, so streaming-summary tests can assert
341/// stably; `WithUsage` must be unwrapped by the caller first (this function
342/// does not handle it, and its fallback arm returns an error rather than
343/// panicking on an unwrapped call).
344fn stream_events(
345    reply: FakeReply,
346) -> Result<Vec<Result<StreamEvent, ProviderError>>, ProviderError> {
347    match reply {
348        // Boundary behavior consistent with chat: this turn's failure makes
349        // the method return Err directly.
350        FakeReply::Error(e) => Err(e),
351        FakeReply::Text(content) => Ok(vec![
352            Ok(StreamEvent::Delta(content)),
353            Ok(StreamEvent::Done {
354                reason: FinishReason::Stop,
355                usage: None,
356            }),
357        ]),
358        // Scripted order: the content Delta first, then the Reasoning
359        // fragment, then Done.
360        FakeReply::TextWithReasoning { content, reasoning } => Ok(vec![
361            Ok(StreamEvent::Delta(content)),
362            Ok(StreamEvent::Reasoning(reasoning)),
363            Ok(StreamEvent::Done {
364                reason: FinishReason::Stop,
365                usage: None,
366            }),
367        ]),
368        FakeReply::ToolCalls { content, calls } => {
369            let mut events = Vec::new();
370            if !content.is_empty() {
371                events.push(Ok(StreamEvent::Delta(content)));
372            }
373            events.extend(calls.into_iter().map(|c| {
374                Ok(StreamEvent::ToolCall {
375                    id: c.id,
376                    name: c.name,
377                    arguments: c.arguments,
378                })
379            }));
380            events.push(Ok(StreamEvent::Done {
381                reason: FinishReason::Stop,
382                usage: None,
383            }));
384            Ok(events)
385        }
386        // Defensive fallback: on the normal path the caller (stream_chat) has
387        // already unwrapped recursively, so this arm should not be reached;
388        // return an error rather than panicking on an unwrapped call.
389        FakeReply::WithUsage { .. } => Err(ProviderError::Protocol {
390            message: "internal error: WithUsage must be unwrapped before stream_events".into(),
391        }),
392    }
393}
394
395/// `Arc<FakeProvider>` is also a Provider: tests share one instance behind an
396/// Arc (used when wrapper tests assert internal request counts, such as
397/// RetryProvider's attempt count).
398#[async_trait::async_trait]
399impl Provider for Arc<FakeProvider> {
400    fn capabilities(&self) -> ProviderCapabilities {
401        self.as_ref().capabilities()
402    }
403
404    async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
405        self.as_ref().chat(request).await
406    }
407
408    async fn chat_with_context(
409        &self,
410        request: ChatRequest,
411        context: &ProviderRequestContext,
412    ) -> Result<ChatResponse, ProviderError> {
413        self.as_ref().chat_with_context(request, context).await
414    }
415
416    async fn stream_chat(
417        &self,
418        request: ChatRequest,
419    ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
420        self.as_ref().stream_chat(request).await
421    }
422
423    async fn stream_chat_with_context(
424        &self,
425        request: ChatRequest,
426        context: &ProviderRequestContext,
427    ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
428        self.as_ref()
429            .stream_chat_with_context(request, context)
430            .await
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::message::Message;
438    use futures::StreamExt;
439
440    #[tokio::test]
441    async fn chat_consumes_script_in_order() {
442        let fake = FakeProvider::new([FakeReply::Text("a".into()), FakeReply::Text("b".into())]);
443
444        let r1 = fake.chat(ChatRequest::default()).await.unwrap();
445        assert_eq!(r1.message, Message::assistant("a"));
446        assert_eq!(r1.finish_reason, FinishReason::Stop);
447
448        let r2 = fake.chat(ChatRequest::default()).await.unwrap();
449        assert_eq!(r2.message, Message::assistant("b"));
450    }
451
452    #[tokio::test]
453    async fn chat_returns_error_when_script_exhausted() {
454        let fake = FakeProvider::new([FakeReply::Text("only".into())]);
455        fake.chat(ChatRequest::default()).await.unwrap();
456
457        let err = fake.chat(ChatRequest::default()).await.unwrap_err();
458        assert!(matches!(err, ProviderError::Protocol { message: m } if m.contains("exhausted")));
459
460        // The exhausted call still records its request, so assertions can
461        // still see what the Agent last sent.
462        assert_eq!(fake.requests().len(), 2);
463    }
464
465    #[tokio::test]
466    async fn chat_with_text_and_reasoning() {
467        let fake = FakeProvider::new([FakeReply::TextWithReasoning {
468            content: "answer".into(),
469            reasoning: "think".into(),
470        }]);
471
472        let r = fake.chat(ChatRequest::default()).await.unwrap();
473        assert_eq!(
474            r.message,
475            Message::assistant_with_reasoning("answer", "think")
476        );
477    }
478
479    #[tokio::test]
480    async fn chat_with_tool_calls() {
481        let calls = vec![ToolCall {
482            id: "c1".into(),
483            name: "add".into(),
484            arguments: r#"{"a":1,"b":2}"#.into(),
485        }];
486        let fake = FakeProvider::new([FakeReply::ToolCalls {
487            content: String::new(),
488            calls: calls.clone(),
489        }]);
490
491        let r = fake.chat(ChatRequest::default()).await.unwrap();
492        match r.message {
493            Message::Assistant {
494                content,
495                reasoning,
496                tool_calls,
497            } => {
498                assert_eq!(content, "");
499                assert_eq!(reasoning, None);
500                assert_eq!(tool_calls, calls);
501            }
502            other => panic!("expected assistant, got {other:?}"),
503        }
504    }
505
506    #[tokio::test]
507    async fn chat_with_usage_override() {
508        let usage = Usage::new(7, 3);
509        let fake = FakeProvider::new([FakeReply::text_with_usage("hi", usage)]);
510
511        let r = fake.chat(ChatRequest::default()).await.unwrap();
512        assert_eq!(r.message, Message::assistant("hi"));
513        assert_eq!(r.usage, Some(usage));
514    }
515
516    #[tokio::test]
517    async fn stream_with_usage_override() {
518        let usage = Usage::new(7, 3);
519        let fake = FakeProvider::new([FakeReply::text_with_usage("hi", usage)]);
520
521        let mut stream = fake.stream_chat(ChatRequest::default()).await.unwrap();
522        let events: Vec<StreamEvent> = stream.by_ref().map(|e| e.unwrap()).collect().await;
523        // The last event is always Done, with the usage per the injected
524        // value.
525        assert_eq!(
526            events.last(),
527            Some(&StreamEvent::Done {
528                reason: FinishReason::Stop,
529                usage: Some(usage),
530            })
531        );
532    }
533
534    #[tokio::test]
535    async fn stream_nested_usage_override_no_panic() {
536        // Nested WithUsage(WithUsage(Text)): the streaming path unwraps
537        // recursively, the outer usage wins, no panic.
538        let usage = Usage::new(7, 3);
539        let fake = FakeProvider::new([FakeReply::WithUsage {
540            reply: Box::new(FakeReply::WithUsage {
541                reply: Box::new(FakeReply::Text("hi".into())),
542                usage: Usage::new(1, 1),
543            }),
544            usage,
545        }]);
546
547        let mut stream = fake.stream_chat(ChatRequest::default()).await.unwrap();
548        let events: Vec<StreamEvent> = stream.by_ref().map(|e| e.unwrap()).collect().await;
549        assert_eq!(events[0], StreamEvent::Delta("hi".into()));
550        assert_eq!(
551            events.last(),
552            Some(&StreamEvent::Done {
553                reason: FinishReason::Stop,
554                usage: Some(usage),
555            })
556        );
557    }
558
559    #[tokio::test]
560    async fn error_reply_passthrough_and_script_continues() {
561        let fake = FakeProvider::new([
562            FakeReply::Error(ProviderError::RateLimited { retry_after: None }),
563            FakeReply::Text("ok".into()),
564        ]);
565
566        // This turn fails: the error is returned as-is.
567        let err = fake.chat(ChatRequest::default()).await.unwrap_err();
568        assert!(matches!(err, ProviderError::RateLimited { .. }));
569
570        // The failed turn has been consumed; the next turn continues the
571        // script.
572        let r = fake.chat(ChatRequest::default()).await.unwrap();
573        assert_eq!(r.message, Message::assistant("ok"));
574    }
575
576    #[tokio::test]
577    async fn stream_chat_text_reply() {
578        let fake = FakeProvider::new([FakeReply::TextWithReasoning {
579            content: "hi".into(),
580            reasoning: "think".into(),
581        }]);
582
583        let mut stream = fake.stream_chat(ChatRequest::default()).await.unwrap();
584        // Scripted order: the content Delta first, then the Reasoning
585        // fragment, then Done.
586        assert_eq!(
587            stream.next().await.unwrap().unwrap(),
588            StreamEvent::Delta("hi".into())
589        );
590        assert_eq!(
591            stream.next().await.unwrap().unwrap(),
592            StreamEvent::Reasoning("think".into())
593        );
594        assert_eq!(
595            stream.next().await.unwrap().unwrap(),
596            StreamEvent::Done {
597                reason: FinishReason::Stop,
598                usage: None,
599            }
600        );
601        assert!(stream.next().await.is_none());
602    }
603
604    #[tokio::test]
605    async fn stream_chat_tool_call_reply() {
606        let fake = FakeProvider::new([FakeReply::ToolCalls {
607            content: "thinking aloud".into(),
608            calls: vec![ToolCall {
609                id: "c1".into(),
610                name: "add".into(),
611                arguments: r#"{"a":1}"#.into(),
612            }],
613        }]);
614
615        let mut stream = fake.stream_chat(ChatRequest::default()).await.unwrap();
616        // Non-empty text comes first as a Delta, then the tool-call event and
617        // the closing Done.
618        assert_eq!(
619            stream.next().await.unwrap().unwrap(),
620            StreamEvent::Delta("thinking aloud".into())
621        );
622        assert_eq!(
623            stream.next().await.unwrap().unwrap(),
624            StreamEvent::ToolCall {
625                id: "c1".into(),
626                name: "add".into(),
627                arguments: r#"{"a":1}"#.into(),
628            }
629        );
630        assert_eq!(
631            stream.next().await.unwrap().unwrap(),
632            StreamEvent::Done {
633                reason: FinishReason::Stop,
634                usage: None,
635            }
636        );
637        assert!(stream.next().await.is_none());
638    }
639
640    #[tokio::test]
641    async fn stream_chat_error_reply_returns_err() {
642        let fake = FakeProvider::new([FakeReply::Error(ProviderError::Api {
643            status: 400,
644            code: None,
645            message: "boom".into(),
646        })]);
647        // Boundary behavior consistent with chat: this turn's failure makes
648        // the method return Err directly.
649        match fake.stream_chat(ChatRequest::default()).await {
650            Err(ProviderError::Api {
651                status: 400,
652                code: None,
653                message: m,
654            }) => assert_eq!(m, "boom"),
655            Ok(_) => panic!("expected error"),
656            Err(other) => panic!("expected Api error, got {other:?}"),
657        }
658    }
659
660    #[tokio::test]
661    async fn stream_chat_exhausted_returns_err() {
662        let fake = FakeProvider::new([FakeReply::Text("only".into())]);
663        // Consume the only script turn first; a stream that is never polled
664        // would be pointless, so drop it explicitly here.
665        drop(fake.stream_chat(ChatRequest::default()).await.unwrap());
666
667        match fake.stream_chat(ChatRequest::default()).await {
668            Err(ProviderError::Protocol { message: m }) => assert!(m.contains("exhausted")),
669            Ok(_) => panic!("expected error"),
670            Err(other) => panic!("expected Protocol error, got {other:?}"),
671        }
672    }
673
674    #[tokio::test]
675    async fn requests_records_messages_passed() {
676        let fake = FakeProvider::new([FakeReply::Text("hi".into())]);
677        let messages = vec![Message::system("sys"), Message::user("hello")];
678
679        fake.chat(ChatRequest {
680            messages: messages.clone(),
681            ..Default::default()
682        })
683        .await
684        .unwrap();
685
686        let requests = fake.requests();
687        assert_eq!(requests.len(), 1);
688        assert_eq!(requests[0].messages, messages);
689    }
690
691    #[tokio::test]
692    async fn push_appends_to_script() {
693        let fake = FakeProvider::new([FakeReply::Text("first".into())]);
694        fake.push(FakeReply::Text("second".into()));
695
696        let r1 = fake.chat(ChatRequest::default()).await.unwrap();
697        assert_eq!(r1.message, Message::assistant("first"));
698        // No WithUsage in the script: usage is None for both turns ("not
699        // reported" stays distinguishable from a reported zero).
700        assert_eq!(r1.usage, None);
701        let r2 = fake.chat(ChatRequest::default()).await.unwrap();
702        assert_eq!(r2.message, Message::assistant("second"));
703        assert_eq!(r2.usage, None);
704    }
705
706    #[tokio::test]
707    async fn works_as_boxed_dyn_provider() {
708        // The Agent holds providers as Box<dyn Provider>; the fake must
709        // satisfy that shape.
710        let fake: Box<dyn Provider> = Box::new(FakeProvider::new([FakeReply::Text("hi".into())]));
711        let r = fake.chat(ChatRequest::default()).await.unwrap();
712        assert_eq!(r.message, Message::assistant("hi"));
713    }
714}