Skip to main content

wabot_testing/
conformance.rs

1//! A suite every chat adapter must pass. Port of
2//! `wabot-ts/src/testing/conformance/chatAdapterConformanceCases.ts`.
3//!
4//! ```ignore
5//! for case in chat_adapter_conformance(adapter, "gpt-4o-mini") {
6//!     println!("{}: {:?}", case.name, case.run().await);
7//! }
8//! ```
9//!
10//! ## What a conformance suite is for
11//!
12//! Six adapters implement one trait, and each was tested against its
13//! own mock server — which proves each speaks its own provider's
14//! dialect, and nothing at all about whether they behave *the same*.
15//! The differences that bite are not in the happy path: they are
16//! whether a tool call comes back with its arguments intact, whether
17//! usage is reported, whether a nulled optional argument survives the
18//! round trip. This suite is the same questions asked of every
19//! provider.
20//!
21//! ## Runner-agnostic on purpose
22//!
23//! Each case is a name plus a boxed future, not a `#[test]`. An
24//! adapter crate wires them into its own test binary, and an
25//! application can run them against a provider it added itself —
26//! which is the point of publishing them rather than keeping them
27//! internal.
28//!
29//! ## What is deliberately not here yet
30//!
31//! The TypeScript suite has cases this port cannot express:
32//!
33//! * **Nested, enum and typed-array tool parameters.** `ToolSchema`
34//!   here is flat (`name` / `type` / `description` / `required`), a
35//!   deliberate Phase-4e decision. An array's item type reaches the
36//!   model as a bare `array`, so a case asserting the model filled a
37//!   `string[]` correctly would be testing a schema the port doesn't
38//!   emit.
39//!
40//! Listed rather than quietly dropped: this is the checklist for when
41//! it lands.
42
43use 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
52/// One case: a name and something to run.
53pub struct ConformanceCase {
54    pub name: &'static str,
55    /// What it checks, in one line — printed by a runner so a failure
56    /// reads as a property, not a number.
57    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
152/// Every case an adapter must pass, for `model`.
153pub 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                                        // An opaque token, not a time:
280                                        // a model that answers "11:45
281                                        // PM" for "23:45" has used the
282                                        // result correctly, and an
283                                        // assertion on the formatting
284                                        // would call that a failure.
285                                        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                    // The point is that the provider accepted the
347                    // nullable spelling at all: absent or null both
348                    // mean "no value", and both survive
349                    // `normalize_optional_arguments`.
350                    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                    // The property: the model *knows* something was
464                    // attached. Dropping it silently would have it
465                    // answer "no".
466                    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
476/// A 64×64 solid red PNG, inline.
477///
478/// Inline rather than a URL, and a colour rather than a photograph,
479/// for the same reason: the case must fail only when *this port* is
480/// wrong. A hosted image makes a third party's bot policy part of the
481/// test (a first attempt used Wikipedia, and the provider's fetcher
482/// was refused), and "is this a cat" makes the model's eyesight part
483/// of it. What is being checked is that the bytes reached the model at
484/// all.
485fn 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    /// The suite has to be buildable without a provider — an adapter
502    /// crate wires it up before it ever has a key.
503    #[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}