1use std::future::Future;
44use std::pin::Pin;
45use std::sync::Arc;
46
47use wabot_feature_chat_bot::{
48 ChatAdapter, ChatAdapterRequest, ChatItem, ChatMessage, ChatMessageFile, FunctionCall,
49 ModelRef, ToolDefinition, ToolParameter,
50};
51
52pub struct ConformanceCase {
54 pub name: &'static str,
55 pub asserts: &'static str,
58 run: Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send>,
59}
60
61impl ConformanceCase {
62 pub async fn run(self) -> Result<(), String> {
63 (self.run)().await
64 }
65}
66
67fn case<F, Fut>(name: &'static str, asserts: &'static str, body: F) -> ConformanceCase
68where
69 F: FnOnce() -> Fut + Send + 'static,
70 Fut: Future<Output = Result<(), String>> + Send + 'static,
71{
72 ConformanceCase {
73 name,
74 asserts,
75 run: Box::new(move || Box::pin(body())),
76 }
77}
78
79fn ensure(condition: bool, message: impl Into<String>) -> Result<(), String> {
80 if condition {
81 Ok(())
82 } else {
83 Err(message.into())
84 }
85}
86
87fn human(text: &str) -> ChatItem {
88 ChatItem::HumanMessage {
89 human_message: ChatMessage::text(text),
90 }
91}
92
93fn tool(name: &str, description: &str, parameters: Vec<ToolParameter>) -> ToolDefinition {
94 ToolDefinition {
95 name: name.into(),
96 description: description.into(),
97 language: "english".into(),
98 parameters,
99 }
100}
101
102fn parameter(name: &str, kind: &str, description: &str, required: bool) -> ToolParameter {
103 ToolParameter {
104 name: name.into(),
105 r#type: kind.into(),
106 description: description.into(),
107 required,
108 }
109}
110
111fn country_tools() -> Vec<ToolDefinition> {
112 vec![
113 tool(
114 "getCountryTime",
115 "return the current time of a country",
116 vec![parameter("country", "string", "the country iso code", true)],
117 ),
118 tool(
119 "getCountryMainLanguage",
120 "return the main language of a country",
121 vec![parameter("country", "string", "the country iso code", true)],
122 ),
123 ]
124}
125
126fn request(models: Vec<ModelRef>, prompt: &str) -> ChatAdapterRequest {
127 ChatAdapterRequest {
128 models,
129 system_prompt: "You are a helpful assistant.".into(),
130 tools: Vec::new(),
131 prev_items: vec![human(prompt)],
132 }
133}
134
135fn first_text(items: &[ChatItem]) -> Option<String> {
136 items.iter().find_map(|item| match item {
137 ChatItem::BotMessage { bot_message } => bot_message.text.clone(),
138 _ => None,
139 })
140}
141
142fn calls(items: &[ChatItem]) -> Vec<&FunctionCall> {
143 items
144 .iter()
145 .filter_map(|item| match item {
146 ChatItem::FunctionCall { function_call } => Some(function_call),
147 _ => None,
148 })
149 .collect()
150}
151
152pub fn chat_adapter_conformance(
154 adapter: Arc<dyn ChatAdapter>,
155 model: &str,
156) -> Vec<ConformanceCase> {
157 let models = vec![ModelRef::model(model)];
158
159 vec![
160 {
161 let adapter = adapter.clone();
162 let models = models.clone();
163 case(
164 "answers a human message",
165 "a plain question comes back as one bot message with text",
166 move || async move {
167 let response = adapter
168 .next_items(request(models, "Say the single word: pong"))
169 .await
170 .map_err(|error| error.to_string())?;
171 let text =
172 first_text(&response.next_items).ok_or("no bot message in the response")?;
173 ensure(!text.trim().is_empty(), "the bot message had no text")
174 },
175 )
176 },
177 {
178 let adapter = adapter.clone();
179 let models = models.clone();
180 case(
181 "reports usage",
182 "input and output token counts come back non-zero",
183 move || async move {
184 let response = adapter
185 .next_items(request(models, "Say the single word: pong"))
186 .await
187 .map_err(|error| error.to_string())?;
188 ensure(
189 response.usage.input_tokens > 0,
190 format!("input_tokens was {}", response.usage.input_tokens),
191 )?;
192 ensure(
193 response.usage.output_tokens > 0,
194 format!("output_tokens was {}", response.usage.output_tokens),
195 )
196 },
197 )
198 },
199 {
200 let adapter = adapter.clone();
201 case(
202 "fails on an unknown model",
203 "a bad request is an error, not an empty success",
204 move || async move {
205 let outcome = adapter
206 .next_items(request(
207 vec![ModelRef::model("definitely-not-a-real-model-xyz")],
208 "hello",
209 ))
210 .await;
211 ensure(
212 outcome.is_err(),
213 "an unknown model was accepted — a caller cannot tell that from a real answer",
214 )
215 },
216 )
217 },
218 {
219 let adapter = adapter.clone();
220 let models = models.clone();
221 case(
222 "calls the right tool",
223 "given two similar tools, the model picks the one the question needs, with its argument",
224 move || async move {
225 let response = adapter
226 .next_items(ChatAdapterRequest {
227 models,
228 system_prompt: "Use the tools to answer.".into(),
229 tools: country_tools(),
230 prev_items: vec![human("What time is it in Japan?")],
231 })
232 .await
233 .map_err(|error| error.to_string())?;
234
235 let calls = calls(&response.next_items);
236 let call = calls.first().ok_or("the model called no tool")?;
237 ensure(
238 call.name == "getCountryTime",
239 format!("it called {} instead", call.name),
240 )?;
241
242 let arguments: serde_json::Value =
243 serde_json::from_str(call.arguments.as_deref().unwrap_or("{}"))
244 .map_err(|error| format!("arguments were not JSON: {error}"))?;
245 let country = arguments
246 .get("country")
247 .and_then(|value| value.as_str())
248 .ok_or("the call carried no country argument")?;
249 ensure(
250 country.to_lowercase().contains("jp")
251 || country.to_lowercase().contains("japan"),
252 format!("the country argument was {country:?}"),
253 )
254 },
255 )
256 },
257 {
258 let adapter = adapter.clone();
259 let models = models.clone();
260 case(
261 "consumes a tool result",
262 "a function call with its result fed back produces an answer that uses it",
263 move || async move {
264 let response = adapter
265 .next_items(ChatAdapterRequest {
266 models,
267 system_prompt: "Use the tools to answer.".into(),
268 tools: country_tools(),
269 prev_items: vec![
270 human(
271 "What time is it in Japan? Include the station code \
272 verbatim in your answer.",
273 ),
274 ChatItem::FunctionCall {
275 function_call: FunctionCall {
276 id: "call_1".into(),
277 name: "getCountryTime".into(),
278 arguments: Some(r#"{"country":"JP"}"#.into()),
279 result: Some(
286 r#"{"time":"23:45","stationCode":"ZQX7"}"#.into(),
287 ),
288 signature: None,
289 },
290 },
291 ],
292 })
293 .await
294 .map_err(|error| error.to_string())?;
295
296 let text = first_text(&response.next_items)
297 .ok_or("the model did not answer after the tool result")?;
298 ensure(
299 text.contains("ZQX7"),
300 format!("the answer ignored the tool's result: {text}"),
301 )
302 },
303 )
304 },
305 {
306 let adapter = adapter.clone();
307 let models = models.clone();
308 case(
309 "an optional argument may be nulled",
310 "a parameter marked optional accepts null rather than forcing the model to invent a value",
311 move || async move {
312 let response = adapter
313 .next_items(ChatAdapterRequest {
314 models,
315 system_prompt: "Use the tool. If you have no value for an optional \
316 argument, pass null."
317 .into(),
318 tools: vec![tool(
319 "createNote",
320 "store a note",
321 vec![
322 parameter("text", "string", "the note body", true),
323 parameter(
324 "folder",
325 "string",
326 "optional folder to file it under",
327 false,
328 ),
329 ],
330 )],
331 prev_items: vec![human("Save a note that says 'buy milk'.")],
332 })
333 .await
334 .map_err(|error| error.to_string())?;
335
336 let calls = calls(&response.next_items);
337 let call = calls.first().ok_or("the model called no tool")?;
338 let arguments: serde_json::Value =
339 serde_json::from_str(call.arguments.as_deref().unwrap_or("{}"))
340 .map_err(|error| format!("arguments were not JSON: {error}"))?;
341
342 ensure(
343 arguments.get("text").and_then(|v| v.as_str()).is_some(),
344 "the required argument is missing",
345 )?;
346 match arguments.get("folder") {
351 None | Some(serde_json::Value::Null) => Ok(()),
352 Some(other) => ensure(
353 other.is_string(),
354 format!("the optional argument came back as {other}"),
355 ),
356 }
357 },
358 )
359 },
360 {
361 let adapter = adapter.clone();
362 let models = models.clone();
363 case(
364 "keeps a multi-turn conversation",
365 "earlier turns are sent, so the model can refer back to them",
366 move || async move {
367 let response = adapter
368 .next_items(ChatAdapterRequest {
369 models,
370 system_prompt: "Answer briefly.".into(),
371 tools: Vec::new(),
372 prev_items: vec![
373 human("My favourite colour is chartreuse. Remember it."),
374 ChatItem::BotMessage {
375 bot_message: ChatMessage::text("Noted."),
376 },
377 human("What is my favourite colour? Answer with one word."),
378 ],
379 })
380 .await
381 .map_err(|error| error.to_string())?;
382
383 let text = first_text(&response.next_items).ok_or("no answer")?;
384 ensure(
385 text.to_lowercase().contains("chartreuse"),
386 format!("the earlier turn did not reach the model: {text}"),
387 )
388 },
389 )
390 },
391 {
392 let adapter = adapter.clone();
393 let models = models.clone();
394 case(
395 "reads an attached image",
396 "the image bytes reach the model, not just its filename",
397 move || async move {
398 let response = adapter
399 .next_items(ChatAdapterRequest {
400 models,
401 system_prompt: "Answer in one word.".into(),
402 tools: Vec::new(),
403 prev_items: vec![ChatItem::HumanMessage {
404 human_message: ChatMessage {
405 text: Some(
406 "What colour fills this image? Answer with one word."
407 .into(),
408 ),
409 images: Some(vec![public_image()]),
410 ..ChatMessage::default()
411 },
412 }],
413 })
414 .await
415 .map_err(|error| error.to_string())?;
416
417 let text = first_text(&response.next_items).ok_or("no answer")?;
418 ensure(
419 text.to_lowercase().contains("red"),
420 format!("the model did not see the image: {text}"),
421 )
422 },
423 )
424 },
425 {
426 let adapter = adapter.clone();
427 let models = models.clone();
428 case(
429 "describes an attachment it cannot read",
430 "an unsupported file is reported to the model rather than dropped",
431 move || async move {
432 let response = adapter
433 .next_items(ChatAdapterRequest {
434 models,
435 system_prompt: "Answer briefly and truthfully.".into(),
436 tools: Vec::new(),
437 prev_items: vec![ChatItem::HumanMessage {
438 human_message: ChatMessage {
439 text: Some(
440 "Did I attach a file? Answer yes or no, then say its \
441 format."
442 .into(),
443 ),
444 images: Some(vec![ChatMessageFile {
445 id: "weird-1".into(),
446 mime_type: "image/vnd.adobe.photoshop".into(),
447 name: Some("mockup.psd".into()),
448 public_url: Some(
449 "https://example.invalid/mockup.psd".into(),
450 ),
451 base64_url: None,
452 }]),
453 ..ChatMessage::default()
454 },
455 }],
456 })
457 .await
458 .map_err(|error| error.to_string())?;
459
460 let text = first_text(&response.next_items)
461 .ok_or("no answer")?
462 .to_lowercase();
463 ensure(
467 text.contains("yes") || text.contains("psd") || text.contains("photoshop"),
468 format!("the model was not told a file was attached: {text}"),
469 )
470 },
471 )
472 },
473 ]
474}
475
476fn public_image() -> ChatMessageFile {
486 ChatMessageFile {
487 id: "red-1".into(),
488 mime_type: "image/png".into(),
489 name: Some("red.png".into()),
490 public_url: None,
491 base64_url: Some(format!("data:image/png;base64,{RED_PNG}")),
492 }
493}
494
495const RED_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAS0lEQVR42u3PQQkAAAgAsetfWiP4FgYrsKZeS0BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEDgsqnc8OJg6Ln3AAAAAElFTkSuQmCC";
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
504 fn the_suite_lists_its_cases() {
505 struct Never;
506 #[async_trait::async_trait]
507 impl ChatAdapter for Never {
508 async fn next_items(
509 &self,
510 _request: ChatAdapterRequest,
511 ) -> Result<
512 wabot_feature_chat_bot::ChatAdapterResponse,
513 wabot_feature_chat_bot::ChatAdapterError,
514 > {
515 unreachable!()
516 }
517 }
518
519 let cases = chat_adapter_conformance(Arc::new(Never), "any");
520 assert!(cases.len() >= 9);
521 assert!(cases.iter().all(|case| !case.asserts.is_empty()));
522 assert!(cases.iter().any(|case| case.name == "calls the right tool"));
523 }
524}