Skip to main content

wabot_testing/
chat_bot.rs

1//! Drive a real `ChatBot` in a test. Port of
2//! `wabot-ts/src/testing/chatBotHarness.ts`.
3
4use std::sync::Arc;
5
6use parking_lot::Mutex;
7use wabot_core::injection::Container;
8use wabot_feature_chat_bot::{
9    ChatAdapter, ChatBot, ChatBotError, ChatItem, ChatMemory, ChatMessage, FunctionCall,
10};
11use wabot_feature_mindset::{Mindset, MindsetOperator, MindsetTool, ToolDefinition, ToolError};
12
13use crate::memory::TestChatMemory;
14use crate::mock_adapter::MockChatAdapter;
15
16/// Everything the bot did in response to one message.
17#[derive(Debug, Clone, Default)]
18pub struct ChatTurn {
19    /// Messages delivered through the reply callback — what the user
20    /// actually saw.
21    pub replies: Vec<ChatMessage>,
22    /// Tool calls executed during the turn, with their results.
23    pub tool_calls: Vec<FunctionCall>,
24    /// Every item recorded during the turn, in order.
25    pub items: Vec<ChatItem>,
26}
27
28impl ChatTurn {
29    /// The reply texts, which is what most assertions want.
30    pub fn texts(&self) -> Vec<String> {
31        self.replies.iter().filter_map(|m| m.text.clone()).collect()
32    }
33
34    /// The single reply text, when the turn produced exactly one.
35    ///
36    /// # Panics
37    ///
38    /// If there wasn't exactly one — a turn that replied twice, or not
39    /// at all, is a different outcome and silently taking the first
40    /// would hide it.
41    pub fn text(&self) -> String {
42        let texts = self.texts();
43        assert_eq!(texts.len(), 1, "expected exactly one reply, got {texts:?}");
44        texts.into_iter().next().unwrap()
45    }
46
47    pub fn called(&self, name: &str) -> bool {
48        self.tool_calls.iter().any(|c| c.name == name)
49    }
50}
51
52/// Runs the **real** chat stack — real [`MindsetOperator`], real system
53/// prompt, real tool loop with argument validation — against an in-RAM
54/// memory and a scriptable adapter.
55///
56/// The point is that a test exercises production code paths. Anything
57/// the harness reimplemented would be a second implementation free to
58/// drift from the one that ships.
59///
60/// ```ignore
61/// let harness = ChatBotHarness::builder(Arc::new(MyMindset))
62///     .tools(OrderTools::register_tools(&container))
63///     .container(container)
64///     .build();
65///
66/// harness.adapter().call_tool("read_order", json!({ "id": 7 }));
67/// harness.adapter().reply("It shipped yesterday.");
68///
69/// let turn = harness.send("where is order 7?").await.unwrap();
70/// assert_eq!(turn.text(), "It shipped yesterday.");
71/// assert!(turn.called("read_order"));
72/// ```
73pub struct ChatBotHarness {
74    adapter: Arc<MockChatAdapter>,
75    memory: Arc<TestChatMemory>,
76    operator: Arc<MindsetOperator>,
77    bot: ChatBot,
78}
79
80impl ChatBotHarness {
81    /// A harness with a mock adapter, an in-RAM memory and no tools.
82    pub fn new(mindset: Arc<dyn Mindset>) -> Self {
83        Self::builder(mindset).build()
84    }
85
86    pub fn builder(mindset: Arc<dyn Mindset>) -> ChatBotHarnessBuilder {
87        ChatBotHarnessBuilder {
88            mindset,
89            tools: Vec::new(),
90            container: None,
91            adapter: None,
92        }
93    }
94
95    /// The scripted adapter — queue turns and assert on requests.
96    pub fn adapter(&self) -> &Arc<MockChatAdapter> {
97        &self.adapter
98    }
99
100    /// The operator the bot uses, for asserting on the real prompt and
101    /// tool schema.
102    pub fn operator(&self) -> &Arc<MindsetOperator> {
103        &self.operator
104    }
105
106    /// Send a human message and collect what the bot did.
107    pub async fn send(&self, message: impl IntoChatMessage) -> Result<ChatTurn, ChatBotError> {
108        let before = self.memory.len();
109        let replies: Arc<Mutex<Vec<ChatMessage>>> = Arc::new(Mutex::new(Vec::new()));
110
111        let sink = replies.clone();
112        let reply: wabot_feature_chat_bot::BotReplyFn = Arc::new(move |message| {
113            let sink = sink.clone();
114            Box::pin(async move {
115                sink.lock().push(message);
116            })
117        });
118
119        self.bot
120            .send_message(message.into_chat_message(), reply)
121            .await?;
122
123        let replies = std::mem::take(&mut *replies.lock());
124        let items = self.memory.items_from(before);
125        let tool_calls = items
126            .iter()
127            .filter_map(|item| match item {
128                ChatItem::FunctionCall { function_call } => Some(function_call.clone()),
129                _ => None,
130            })
131            .collect();
132
133        Ok(ChatTurn {
134            replies,
135            tool_calls,
136            items,
137        })
138    }
139
140    /// Run one tool directly — real validation, real dispatch — without
141    /// scripting a conversation around it. Returns the string the model
142    /// would have received.
143    pub async fn call_tool(
144        &self,
145        name: &str,
146        arguments: impl crate::mock_adapter::ToArguments,
147    ) -> Result<String, ToolError> {
148        self.operator
149            .call_function(name, &arguments.to_arguments())
150            .await
151    }
152
153    /// The real system prompt this mindset produces.
154    pub async fn system_prompt(&self) -> String {
155        self.operator.system_prompt().await
156    }
157
158    /// The real tool schema the model would be given.
159    pub fn tools(&self) -> Result<Vec<MindsetTool>, ToolError> {
160        self.operator.tools()
161    }
162
163    /// Everything recorded across every turn.
164    pub fn history(&self) -> Vec<ChatItem> {
165        self.memory.all()
166    }
167
168    pub fn memory(&self) -> &Arc<TestChatMemory> {
169        &self.memory
170    }
171}
172
173pub struct ChatBotHarnessBuilder {
174    mindset: Arc<dyn Mindset>,
175    tools: Vec<ToolDefinition>,
176    container: Option<Container>,
177    adapter: Option<Arc<MockChatAdapter>>,
178}
179
180impl ChatBotHarnessBuilder {
181    /// Tools the mindset can call, usually
182    /// `MyTools::register_tools(&container)`.
183    pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
184        self.tools.extend(tools);
185        self
186    }
187
188    /// The container the tools resolve from. Register a tool set's
189    /// dependencies (a fake database, say) there before building.
190    pub fn container(mut self, container: Container) -> Self {
191        self.container = Some(container);
192        self
193    }
194
195    /// Share an adapter — to script it before building, or to reuse
196    /// one across harnesses.
197    pub fn adapter(mut self, adapter: Arc<MockChatAdapter>) -> Self {
198        self.adapter = Some(adapter);
199        self
200    }
201
202    pub fn build(self) -> ChatBotHarness {
203        let container = self.container.unwrap_or_default();
204        let adapter = self.adapter.unwrap_or_else(MockChatAdapter::arc);
205        let memory = Arc::new(TestChatMemory::new());
206
207        let operator =
208            Arc::new(MindsetOperator::new(container, self.mindset).with_module_tools(self.tools));
209        let bot = ChatBot::new(
210            memory.clone() as Arc<dyn ChatMemory>,
211            adapter.clone() as Arc<dyn ChatAdapter>,
212            operator.clone(),
213        );
214
215        ChatBotHarness {
216            adapter,
217            memory,
218            operator,
219            bot,
220        }
221    }
222}
223
224/// So `send("hola")` works as well as `send(ChatMessage { … })`.
225pub trait IntoChatMessage {
226    fn into_chat_message(self) -> ChatMessage;
227}
228
229impl IntoChatMessage for ChatMessage {
230    fn into_chat_message(self) -> ChatMessage {
231        self
232    }
233}
234
235impl IntoChatMessage for &str {
236    fn into_chat_message(self) -> ChatMessage {
237        ChatMessage::text(self)
238    }
239}
240
241impl IntoChatMessage for String {
242    fn into_chat_message(self) -> ChatMessage {
243        ChatMessage::text(self)
244    }
245}