Skip to main content

wabot_testing/
mock_adapter.rs

1//! A scriptable stand-in for an LLM. Port of
2//! `wabot-ts/src/testing/MockChatAdapter.ts`.
3
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use parking_lot::Mutex;
9use wabot_feature_chat_bot::{
10    ChatAdapter, ChatAdapterError, ChatAdapterRequest, ChatAdapterResponse, ChatItem, ChatMessage,
11    FunctionCall, LanguageModelUsage,
12};
13
14/// One scripted turn: a fixed list of items, or a function of the
15/// request when a test needs to answer differently depending on what
16/// it was asked.
17pub enum ScriptedTurn {
18    Items(Vec<ChatItem>),
19    #[allow(clippy::type_complexity)]
20    Responder(Box<dyn Fn(&ChatAdapterRequest) -> Vec<ChatItem> + Send + Sync>),
21}
22
23/// What the adapter was asked, kept for assertions.
24#[derive(Debug, Clone)]
25pub struct RecordedRequest {
26    pub system_prompt: String,
27    pub tool_names: Vec<String>,
28    pub models: Vec<String>,
29    pub prev_items: Vec<ChatItem>,
30}
31
32impl RecordedRequest {
33    fn of(request: &ChatAdapterRequest) -> Self {
34        Self {
35            system_prompt: request.system_prompt.clone(),
36            tool_names: request.tools.iter().map(|t| t.name.clone()).collect(),
37            models: request.models.iter().map(|m| m.model.clone()).collect(),
38            prev_items: request.prev_items.clone(),
39        }
40    }
41}
42
43/// A deterministic [`ChatAdapter`]: script the model's turns, then
44/// assert on what it was asked.
45///
46/// ```ignore
47/// let adapter = MockChatAdapter::new();
48/// adapter.call_tool("read_order", serde_json::json!({ "id": 7 }));
49/// adapter.reply("Your order shipped.");
50/// ```
51///
52/// **One queued turn is consumed per round-trip.** A tool-call turn
53/// therefore needs another turn queued behind it, because the chat
54/// loop calls the adapter again after running the tool — the mistake
55/// is common enough that the exhaustion error says so.
56///
57/// Scripting takes `&self` so a test can queue turns after handing the
58/// adapter to a harness.
59pub struct MockChatAdapter {
60    queue: Mutex<std::collections::VecDeque<ScriptedTurn>>,
61    requests: Mutex<Vec<RecordedRequest>>,
62    call_ids: AtomicUsize,
63    fallback_reply: Mutex<Option<String>>,
64}
65
66impl Default for MockChatAdapter {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl MockChatAdapter {
73    pub fn new() -> Self {
74        Self {
75            queue: Mutex::new(Default::default()),
76            requests: Mutex::new(Vec::new()),
77            call_ids: AtomicUsize::new(0),
78            fallback_reply: Mutex::new(None),
79        }
80    }
81
82    pub fn arc() -> Arc<Self> {
83        Arc::new(Self::new())
84    }
85
86    /// Answer anything unscripted with `text` instead of failing —
87    /// for tests that care about one turn and not about the rest.
88    ///
89    /// Off by default: a silent stand-in reply turns "the test didn't
90    /// script enough" into a confusing assertion failure further down.
91    pub fn with_fallback_reply(self, text: impl Into<String>) -> Self {
92        *self.fallback_reply.lock() = Some(text.into());
93        self
94    }
95
96    /// Queue a turn where the model answers with plain text.
97    pub fn reply(&self, text: impl Into<String>) -> &Self {
98        self.enqueue(ScriptedTurn::Items(vec![ChatItem::bot(ChatMessage::text(
99            text,
100        ))]))
101    }
102
103    /// Queue a turn where the model calls a tool. The tool itself runs
104    /// for real — this only scripts the model's decision to call it.
105    pub fn call_tool(&self, name: impl Into<String>, arguments: impl ToArguments) -> &Self {
106        let id = self.call_ids.fetch_add(1, Ordering::SeqCst) + 1;
107        self.enqueue(ScriptedTurn::Items(vec![ChatItem::call(FunctionCall {
108            id: format!("mock-call-{id}"),
109            name: name.into(),
110            arguments: Some(arguments.to_arguments()),
111            result: None,
112            signature: None,
113        })]))
114    }
115
116    /// Queue a raw turn.
117    pub fn enqueue(&self, turn: ScriptedTurn) -> &Self {
118        self.queue.lock().push_back(turn);
119        self
120    }
121
122    /// Queue a turn computed from the request — for asserting on what
123    /// the model was given *and* branching on it in one place.
124    pub fn respond_with(
125        &self,
126        responder: impl Fn(&ChatAdapterRequest) -> Vec<ChatItem> + Send + Sync + 'static,
127    ) -> &Self {
128        self.enqueue(ScriptedTurn::Responder(Box::new(responder)))
129    }
130
131    /// Every request the adapter received, oldest first.
132    pub fn requests(&self) -> Vec<RecordedRequest> {
133        self.requests.lock().clone()
134    }
135
136    pub fn last_request(&self) -> Option<RecordedRequest> {
137        self.requests.lock().last().cloned()
138    }
139
140    /// How many round-trips happened — the cheapest check that a tool
141    /// loop ran as many times as expected, and no more.
142    pub fn call_count(&self) -> usize {
143        self.requests.lock().len()
144    }
145
146    /// Turns still queued. A test that ends with this above zero
147    /// scripted more than it exercised, which usually means the
148    /// conversation took a different path than it assumed.
149    pub fn pending(&self) -> usize {
150        self.queue.lock().len()
151    }
152}
153
154#[async_trait]
155impl ChatAdapter for MockChatAdapter {
156    async fn next_items(
157        &self,
158        request: ChatAdapterRequest,
159    ) -> Result<ChatAdapterResponse, ChatAdapterError> {
160        self.requests.lock().push(RecordedRequest::of(&request));
161
162        let turn = self.queue.lock().pop_front();
163        let next_items = match turn {
164            Some(ScriptedTurn::Items(items)) => items,
165            Some(ScriptedTurn::Responder(responder)) => responder(&request),
166            None => match self.fallback_reply.lock().clone() {
167                Some(text) => vec![ChatItem::bot(ChatMessage::text(text))],
168                None => {
169                    return Err(ChatAdapterError::Other(
170                        "MockChatAdapter: no scripted turn left. Queue one with reply() / \
171                         call_tool() / enqueue(), or set with_fallback_reply(). Remember that \
172                         after a call_tool() turn the chat loop calls the adapter again."
173                            .into(),
174                    ))
175                }
176            },
177        };
178
179        Ok(ChatAdapterResponse {
180            next_items,
181            usage: LanguageModelUsage {
182                input_tokens: 1,
183                output_tokens: 1,
184                provider: Some("mock".into()),
185                model: request.models.first().map(|m| m.model.clone()),
186                ..Default::default()
187            },
188        })
189    }
190}
191
192/// Tool arguments as either JSON or a raw string, so a test can write
193/// the natural thing — and can also script the malformed JSON a real
194/// model sometimes emits.
195pub trait ToArguments {
196    fn to_arguments(self) -> String;
197}
198
199impl ToArguments for serde_json::Value {
200    fn to_arguments(self) -> String {
201        self.to_string()
202    }
203}
204
205impl ToArguments for &str {
206    fn to_arguments(self) -> String {
207        self.to_string()
208    }
209}
210
211impl ToArguments for String {
212    fn to_arguments(self) -> String {
213        self
214    }
215}
216
217/// `call_tool("x", NoArgs)` for a tool that takes none.
218pub struct NoArgs;
219
220impl ToArguments for NoArgs {
221    fn to_arguments(self) -> String {
222        "{}".into()
223    }
224}