Skip to main content

headless_lms_server/controllers/
mock_azure.rs

1use crate::controllers::mock_document_storage::{MOCK_DOCUMENTS, MockDocument};
2use crate::prelude::*;
3use headless_lms_chatbot::{
4    azure_chatbot::azure::{protocol::InputItem, tools::AzureLLMToolDefinition},
5    chatbot_tools::{
6        ChatbotToolDeclaration,
7        client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
8        custom_tools::course_structure::CourseStructureTool, get_chatbot_tool_definitions,
9        tool_is_answered_by_client,
10    },
11    cms_ai_suggestion::RESPONSE_FORMAT_NAME as CMS_SUGGESTION_FORMAT,
12    course_description_summary::RESPONSE_FORMAT_NAME as COURSE_DESCRIPTION_FORMAT,
13    llm_utils::AzureCompletionRequest,
14    message_suggestion::RESPONSE_FORMAT_NAME as MESSAGE_SUGGESTION_FORMAT,
15    prompt_creation::RESPONSE_FORMAT_NAME as PROMPT_CREATION_FORMAT,
16};
17use headless_lms_utils::azure_embedding::{
18    Embedding, EmbeddingRequest, EmbeddingResponse, EmbeddingResponseUsage,
19};
20use serde_json::{Value, json};
21
22/// Anywhere in a chat message, this makes the mock answer with a function call instead of a
23/// text answer, which is how a test drives the chatbot's tool loop. The chatbot then runs the
24/// tool and asks again with the tool output as the last input item, and the mock answers that
25/// with a text round, completing a two-round turn.
26const TOOL_CALL_TRIGGER: &str = "!MOCK_TOOL_CALL!";
27
28/// Like [TOOL_CALL_TRIGGER], but the call is one only the client can answer, so the chatbot
29/// suspends the turn instead of running anything. The turn is finished by the tool-response
30/// endpoint, whose request ends in the tool output and so gets the same text round.
31const CLIENT_TOOL_CALL_TRIGGER: &str = "!MOCK_CLIENT_TOOL_CALL!";
32
33/// Opens the trigger `!MOCK_TOOL_CALL:<tool_name>:<arguments>!`, which makes the mock call any
34/// registered tool, so exercising a new one end to end needs no scenario of its own here.
35const TOOL_CALL_BY_NAME_PREFIX: &str = "!MOCK_TOOL_CALL:";
36
37/// Drives an answer Azure cut short by `max_output_tokens`: the round ends on
38/// `response.incomplete` instead of `response.completed`.
39const INCOMPLETE_ANSWER_TRIGGER: &str = "!MOCK_INCOMPLETE_ANSWER!";
40
41/// Drives a stream a proxy closed cleanly before Azure ever sent a terminal event — neither
42/// `response.completed` nor `response.incomplete`.
43const TRUNCATED_STREAM_TRIGGER: &str = "!MOCK_TRUNCATED_STREAM!";
44
45/// Drives a search round whose completed output fails to parse as the chatbot's search-output
46/// schema: the same plain-text failure shape a real search backend can return, but on a
47/// `completed` item rather than the in-progress placeholder every other scenario here uses.
48const MALFORMED_SEARCH_OUTPUT_TRIGGER: &str = "!MOCK_MALFORMED_SEARCH_OUTPUT!";
49
50/// Drives a round that includes an output item kind the chatbot has no variant for.
51const UNKNOWN_ITEM_TYPE_TRIGGER: &str = "!MOCK_UNKNOWN_ITEM_TYPE!";
52
53/// The trigger text that makes the mock call `tool_name` with `arguments`.
54fn tool_call_by_name_trigger(tool_name: &str, arguments: &str) -> String {
55    format!("{TOOL_CALL_BY_NAME_PREFIX}{tool_name}:{arguments}!")
56}
57
58/// A tool call a message asked the mock to make.
59struct TriggeredToolCall {
60    tool_name: String,
61    arguments: String,
62}
63
64/// Reads a [TOOL_CALL_BY_NAME_PREFIX] trigger out of `message`.
65///
66/// A learner can type the trigger into the chat, so a name no registry claims has to read as an
67/// ordinary message rather than reach the chatbot as a hallucinated call. `!` closes the trigger
68/// and so cannot appear in `arguments`.
69fn parse_tool_call_by_name(message: &str) -> Option<TriggeredToolCall> {
70    let (_, rest) = message.split_once(TOOL_CALL_BY_NAME_PREFIX)?;
71    let (call, _) = rest.split_once('!')?;
72    let (tool_name, arguments) = call.split_once(':')?;
73    is_registered_tool(tool_name).then(|| TriggeredToolCall {
74        tool_name: tool_name.to_string(),
75        arguments: arguments.to_string(),
76    })
77}
78
79/// Whether either registry claims `tool_name`.
80fn is_registered_tool(tool_name: &str) -> bool {
81    tool_is_answered_by_client(tool_name)
82        || get_chatbot_tool_definitions()
83            .iter()
84            .any(|definition| match definition {
85                AzureLLMToolDefinition::Function(function) => function.name == tool_name,
86                AzureLLMToolDefinition::Search(_) => false,
87            })
88}
89
90/// The parts of a request the mock picks its answer from.
91struct MockRequest {
92    /// The text of the last input message, or `None` when the request ends in a tool output
93    /// instead. The other two endings never reach here.
94    message: Option<String>,
95    /// The structured output schema the answer has to parse as, `None` for a chat request.
96    format_name: Option<String>,
97    /// Whether the caller reads the answer as a Server-Sent Events stream or as one JSON object.
98    stream: bool,
99}
100
101impl MockRequest {
102    /// A streamed chat request carrying `message`.
103    fn chat(message: &str) -> Self {
104        MockRequest {
105            message: Some(message.to_string()),
106            format_name: None,
107            stream: true,
108        }
109    }
110
111    /// A streamed request resuming a tool loop, which ends in the tool's output rather than in a
112    /// message.
113    fn after_tool_run() -> Self {
114        MockRequest {
115            message: None,
116            format_name: None,
117            stream: true,
118        }
119    }
120
121    /// A request for structured output in `format_name`. Its message is one that would drive a
122    /// function call round if the message decided anything here, which it must not.
123    fn structured_output(format_name: &str) -> Self {
124        MockRequest {
125            message: Some(TOOL_CALL_TRIGGER.to_string()),
126            format_name: Some(format_name.to_string()),
127            stream: false,
128        }
129    }
130
131    /// Whether this asks for the structured output named `format_name`. False for a streamed
132    /// request even when it names the schema, since the answer to one is a whole JSON object that
133    /// a streaming caller cannot read.
134    fn wants_format(&self, format_name: &str) -> bool {
135        !self.stream && self.format_name.as_deref() == Some(format_name)
136    }
137
138    /// The tool call a streamed chat message asks the mock to make, if any.
139    fn triggered_tool_call(&self) -> Option<TriggeredToolCall> {
140        if !self.stream {
141            return None;
142        }
143        parse_tool_call_by_name(self.message.as_deref()?)
144    }
145
146    /// Whether this is a streamed chat message containing `trigger`.
147    fn message_contains(&self, trigger: &str) -> bool {
148        self.stream
149            && self
150                .message
151                .as_deref()
152                .is_some_and(|message| message.contains(trigger))
153    }
154}
155
156/// One shape of request the mock answers, and the answer it gives.
157///
158/// The tests drive every registered scenario through its own [`example`](Scenario::example), so
159/// registering a round here is what gets it verified at all.
160struct Scenario {
161    /// Names the scenario in the handler's log line and in test failures.
162    name: &'static str,
163    matches: fn(&MockRequest) -> bool,
164    /// Builds the answer to `request` against the base url its document urls have to point at.
165    respond: fn(&MockRequest, &str) -> String,
166    /// A request this scenario answers.
167    #[cfg_attr(not(test), allow(dead_code))]
168    example: fn() -> MockRequest,
169}
170
171/// The first scenario that matches answers, so the default chat answer, which takes any streamed
172/// message at all, comes last.
173///
174/// A blocking caller parses the whole body as one JSON object and a streaming one reads it event by
175/// event, so no answer suits both, and every scenario says which kind it is. Among the blocking
176/// ones the schema alone decides: a learner is free to type anything into the chat, so no part of a
177/// message may reach a feature's answer.
178const SCENARIOS: &[Scenario] = &[
179    Scenario {
180        name: "the next message suggestion",
181        matches: |request| request.wants_format(MESSAGE_SUGGESTION_FORMAT),
182        respond: |_, _| blocking_response(MESSAGE_SUGGESTION_PAYLOAD),
183        example: || MockRequest::structured_output(MESSAGE_SUGGESTION_FORMAT),
184    },
185    Scenario {
186        name: "the CMS paragraph suggestion",
187        matches: |request| request.wants_format(CMS_SUGGESTION_FORMAT),
188        respond: |_, _| blocking_response(CMS_SUGGESTION_PAYLOAD),
189        example: || MockRequest::structured_output(CMS_SUGGESTION_FORMAT),
190    },
191    Scenario {
192        name: "the course description summary",
193        matches: |request| request.wants_format(COURSE_DESCRIPTION_FORMAT),
194        respond: |_, _| blocking_response(COURSE_DESCRIPTION_PAYLOAD),
195        example: || MockRequest::structured_output(COURSE_DESCRIPTION_FORMAT),
196    },
197    Scenario {
198        name: "the client tool call round",
199        matches: |request| request.message_contains(CLIENT_TOOL_CALL_TRIGGER),
200        respond: |_, _| {
201            function_call_round(
202                <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME,
203                MOCK_MULTIPLE_CHOICE_ARGUMENTS,
204            )
205        },
206        example: || MockRequest::chat(CLIENT_TOOL_CALL_TRIGGER),
207    },
208    Scenario {
209        name: "the tool call round for a named tool",
210        matches: |request| request.triggered_tool_call().is_some(),
211        respond: |request, _| {
212            let call = request
213                .triggered_tool_call()
214                .expect("the scenario only answers a request carrying a tool call trigger");
215            function_call_round(&call.tool_name, &call.arguments)
216        },
217        example: || {
218            MockRequest::chat(&tool_call_by_name_trigger(
219                <CourseStructureTool as ChatbotToolDeclaration>::NAME,
220                "{}",
221            ))
222        },
223    },
224    Scenario {
225        name: "the function call round",
226        matches: |request| request.message_contains(TOOL_CALL_TRIGGER),
227        respond: |_, _| {
228            function_call_round(<CourseStructureTool as ChatbotToolDeclaration>::NAME, "{}")
229        },
230        example: || MockRequest::chat(TOOL_CALL_TRIGGER),
231    },
232    Scenario {
233        name: "the answer after a tool ran",
234        matches: |request| request.stream && request.message.is_none(),
235        respond: |_, _| tool_answer_round(),
236        example: MockRequest::after_tool_run,
237    },
238    Scenario {
239        name: "the prompt and first message generation",
240        matches: |request| request.wants_format(PROMPT_CREATION_FORMAT),
241        respond: |_, _| blocking_response(PROMPT_CREATION_PAYLOAD),
242        example: || MockRequest::structured_output(PROMPT_CREATION_FORMAT),
243    },
244    Scenario {
245        name: "an answer cut short by the token limit",
246        matches: |request| request.message_contains(INCOMPLETE_ANSWER_TRIGGER),
247        respond: |_, _| incomplete_answer_round(),
248        example: || MockRequest::chat(INCOMPLETE_ANSWER_TRIGGER),
249    },
250    Scenario {
251        name: "a stream that ends before response.completed",
252        matches: |request| request.message_contains(TRUNCATED_STREAM_TRIGGER),
253        respond: |_, _| truncated_stream_round(),
254        example: || MockRequest::chat(TRUNCATED_STREAM_TRIGGER),
255    },
256    Scenario {
257        name: "a completed search output that fails to parse",
258        matches: |request| request.message_contains(MALFORMED_SEARCH_OUTPUT_TRIGGER),
259        respond: |_, _| malformed_search_output_round(),
260        example: || MockRequest::chat(MALFORMED_SEARCH_OUTPUT_TRIGGER),
261    },
262    Scenario {
263        name: "an output item type the chatbot does not know",
264        matches: |request| request.message_contains(UNKNOWN_ITEM_TYPE_TRIGGER),
265        respond: |_, _| unknown_item_type_round(),
266        example: || MockRequest::chat(UNKNOWN_ITEM_TYPE_TRIGGER),
267    },
268    Scenario {
269        name: "the default chat answer",
270        matches: |request| request.stream && request.message.is_some(),
271        respond: |_, base_url| search_and_text_round(base_url),
272        example: || MockRequest::chat("Tell me more"),
273    },
274];
275
276/// The scenario that answers `request`, or `None` when the mock answers nothing like it.
277fn pick_scenario(request: &MockRequest) -> Option<&'static Scenario> {
278    SCENARIOS
279        .iter()
280        .find(|scenario| (scenario.matches)(request))
281}
282
283/// GET /api/v0/mock-azure/api/projects/test/openai/v1/responses
284/// POST /api/v0/mock-azure/api/projects/test/openai/v1/responses
285///
286/// Stands in for the Azure Responses API while the chatbot runs in test mode. Answers with the
287/// first scenario in [`SCENARIOS`] whose request shape matches, and 400s on a request no scenario
288/// answers.
289async fn mock_azure_chat_responses(
290    app_conf: web::Data<ApplicationConfiguration>,
291    payload: web::Json<AzureCompletionRequest>,
292) -> ControllerResult<String> {
293    assert!(app_conf.test_chatbot && app_conf.test_mode);
294
295    let last_input_item = &payload
296        .base
297        .input
298        .last()
299        .ok_or_else(|| {
300            controller_err!(
301                BadRequest,
302                "No messages in request, there should be at least one."
303            )
304        })?
305        .message_type;
306
307    let message = match last_input_item {
308        InputItem::Message { content, .. } => Some(content.clone().get_content_text()),
309        InputItem::FunctionCallOutput { .. } => None,
310        InputItem::FunctionCall { .. } | InputItem::Reasoning { .. } => {
311            return Err(controller_err!(
312                BadRequest,
313                "The mock has no response for a request that ends in a function call or a reasoning item."
314            ));
315        }
316    };
317
318    let request = MockRequest {
319        message,
320        format_name: payload
321            .base
322            .text
323            .as_ref()
324            .and_then(|text| text.format.as_ref())
325            .map(|format| format.name.clone()),
326        stream: payload.stream,
327    };
328    let scenario = pick_scenario(&request).ok_or_else(|| {
329        controller_err!(
330            BadRequest,
331            "The mock has no response for this shape of request."
332        )
333    })?;
334    debug!(scenario = scenario.name, "Answering as the mock Azure API");
335    let res = (scenario.respond)(&request, &app_conf.base_url);
336
337    let token = skip_authorize();
338    token.authorized_ok(res)
339}
340
341/// Renders `events` as a Server-Sent Events body in the order given, stamping every `data:` object
342/// with its own event name as `type`, the way Azure repeats it. The chatbot reads the `event:` line
343/// to decide what the `data:` line after it means, so the pairing and the order are what make a
344/// round parse as a tool call or as text.
345fn sse_body(events: Vec<(&str, Value)>) -> String {
346    events
347        .into_iter()
348        .map(|(event, mut data)| {
349            if let Some(object) = data.as_object_mut() {
350                object.insert("type".to_string(), json!(event));
351            }
352            format!("event: {event}\ndata: {data}\n\n")
353        })
354        .collect()
355}
356
357/// Wraps a round's own `events` in the lifecycle events every round shares: `response.created`,
358/// which is where the chatbot picks up the response id it needs before the round is classified,
359/// and the terminal `response.completed`.
360fn round(response_id: &str, events: Vec<(&'static str, Value)>, usage: Value) -> String {
361    let mut all = vec![(
362        "response.created",
363        json!({"response": response_object(response_id)}),
364    )];
365    all.extend(events);
366    all.push((
367        "response.completed",
368        json!({"response": completed_response_object(response_id, usage)}),
369    ));
370    sse_body(all)
371}
372
373/// What one round was billed for. `cached_tokens` is the part of the input Azure served from the
374/// prompt cache, and the rest of it is what the round wrote there.
375fn usage(
376    input_tokens: u32,
377    cached_tokens: u32,
378    output_tokens: u32,
379    reasoning_tokens: u32,
380) -> Value {
381    json!({
382        "input_tokens": input_tokens,
383        "input_tokens_details": {
384            "cached_tokens": cached_tokens,
385            "cache_write_tokens": input_tokens.saturating_sub(cached_tokens),
386        },
387        "output_tokens": output_tokens,
388        "output_tokens_details": {"reasoning_tokens": reasoning_tokens},
389        "total_tokens": input_tokens + output_tokens,
390    })
391}
392
393/// The response object of a lifecycle event before the last one. Only `id` and a possible `error`
394/// are read by the chatbot; Azure sends the full request parameters here as well. See
395/// [`completed_response_object`] for the terminal event.
396fn response_object(response_id: &str) -> Value {
397    json!({
398        "id": response_id,
399        "object": "response",
400        "status": "in_progress",
401        "usage": null,
402    })
403}
404
405/// The response object of the `response.completed` event, the only lifecycle event Azure reports
406/// token usage and the reasoning context on.
407fn completed_response_object(response_id: &str, usage: Value) -> Value {
408    json!({
409        "id": response_id,
410        "object": "response",
411        "status": "completed",
412        "reasoning": {"effort": "medium", "summary": null, "context": "current_turn"},
413        "usage": usage,
414    })
415}
416
417/// One assistant message as an output item, in the two shapes a round streams it in: `content` is
418/// empty while the item is in progress and carries the whole text once it is done.
419fn message_item(item_id: &str, response_id: &str, content: Value, status: &str) -> Value {
420    json!({
421        "type": "message",
422        "id": item_id,
423        "response_id": response_id,
424        "phase": "final_answer",
425        "role": "assistant",
426        "content": content,
427        "status": status,
428    })
429}
430
431/// The events that stream one assistant message: the item, its content part, one event per delta,
432/// and the finished item. `output_index` is the message's place among the round's output items, so
433/// it depends on how many items the round emitted before this one.
434fn message_item_events(
435    item_id: &str,
436    response_id: &str,
437    output_index: u32,
438    deltas: &[&str],
439) -> Vec<(&'static str, Value)> {
440    let text = deltas.concat();
441
442    let mut events = vec![
443        (
444            "response.output_item.added",
445            json!({
446                "output_index": output_index,
447                "item": message_item(item_id, response_id, json!([]), "in_progress"),
448            }),
449        ),
450        (
451            "response.content_part.added",
452            json!({
453                "content_index": 0,
454                "item_id": item_id,
455                "output_index": output_index,
456                "part": { "type": "output_text", "text": "" },
457            }),
458        ),
459    ];
460    events.extend(deltas.iter().map(|delta| {
461        (
462            "response.output_text.delta",
463            json!({
464                "content_index": 0,
465                "item_id": item_id,
466                "output_index": output_index,
467                "delta": delta,
468            }),
469        )
470    }));
471    events.extend([
472        (
473            "response.output_text.done",
474            json!({
475                "content_index": 0,
476                "item_id": item_id,
477                "output_index": output_index,
478                "text": text,
479            }),
480        ),
481        (
482            "response.content_part.done",
483            json!({
484                "content_index": 0,
485                "item_id": item_id,
486                "output_index": output_index,
487                "part": { "type": "output_text", "text": text },
488            }),
489        ),
490        (
491            "response.output_item.done",
492            json!({
493                "output_index": output_index,
494                "item": message_item(
495                    item_id,
496                    response_id,
497                    json!([{ "type": "output_text", "text": text }]),
498                    "completed",
499                ),
500            }),
501        ),
502    ]);
503    events
504}
505
506/// The events that stream the reasoning item a round emits before it calls anything or answers,
507/// which is what makes it the round's first output item.
508fn reasoning_item_events(response_id: &str) -> Vec<(&'static str, Value)> {
509    let item = reasoning_item(response_id);
510    vec![
511        (
512            "response.output_item.added",
513            json!({"output_index": 0, "item": item}),
514        ),
515        (
516            "response.output_item.done",
517            json!({"output_index": 0, "item": item}),
518        ),
519    ]
520}
521
522/// The item [`reasoning_item_events`] streams, unchanged in both of its events.
523fn reasoning_item(response_id: &str) -> Value {
524    json!({
525        "type": "reasoning",
526        "id": format!("rs_{}", Uuid::new_v4()),
527        "response_id": response_id,
528        "summary": [],
529        // Azure returns this whenever `store` is false, and the chatbot only replays a reasoning
530        // item that has it, so without it the mock never exercises the replay path.
531        "encrypted_content": "mock-encrypted-reasoning",
532    })
533}
534
535/// The question [CLIENT_TOOL_CALL_TRIGGER] makes the mock ask. Has to pass the tool's own
536/// argument validation, which the chatbot runs before it suspends the turn.
537const MOCK_MULTIPLE_CHOICE_ARGUMENTS: &str =
538    r#"{"question":"Which loop do you mean?","choices":["while","for"]}"#;
539
540/// A round in which the model calls `tool_name` with `arguments`.
541///
542/// Every tool it is used with works without a user, so the round works for an anonymous course
543/// material visitor.
544///
545/// The chatbot classifies the round from the function call item it announces and passes the tool
546/// parser only what follows that item, so the completed call has to arrive in a later
547/// `output_item.done`, and the round must contain no text delta at all.
548fn function_call_round(tool_name: &str, arguments: &str) -> String {
549    let response_id = format!("resp_{}", Uuid::new_v4());
550    let item_id = format!("fc_{}", Uuid::new_v4());
551    let call_id = format!("call_{}", Uuid::new_v4());
552
553    let function_call = |arguments: &str, status: &str| {
554        json!({
555            "type": "function_call",
556            "id": item_id,
557            "response_id": response_id,
558            "call_id": call_id,
559            "name": tool_name,
560            "arguments": arguments,
561            "status": status,
562        })
563    };
564    let mut events = reasoning_item_events(&response_id);
565    events.extend([
566        (
567            "response.output_item.added",
568            json!({"output_index": 1, "item": function_call("", "in_progress")}),
569        ),
570        (
571            "response.function_call_arguments.delta",
572            json!({"item_id": item_id, "output_index": 1, "delta": arguments}),
573        ),
574        (
575            "response.function_call_arguments.done",
576            json!({"item_id": item_id, "output_index": 1, "arguments": arguments}),
577        ),
578        (
579            "response.output_item.done",
580            json!({"output_index": 1, "item": function_call(arguments, "completed")}),
581        ),
582    ]);
583
584    round(&response_id, events, usage(42, 0, 88, 64))
585}
586
587/// The text answer the model gives once a tool has run.
588fn tool_answer_round() -> String {
589    let response_id = format!("resp_{}", Uuid::new_v4());
590    let item_id = format!("msg_{}", Uuid::new_v4());
591    let deltas = [
592        "Here", " is", " the", " mock", " answer", " after", " a", " tool", " ran.",
593    ];
594
595    round(
596        &response_id,
597        message_item_events(&item_id, &response_id, 0, &deltas),
598        // Second round of the same turn, so what the first round wrote to the cache comes back as
599        // a cache read here.
600        usage(96, 42, 24, 8),
601    )
602}
603
604/// An answer `max_output_tokens` cut short: it ends on `response.incomplete`, never on
605/// `response.completed`, with only a partial delta having streamed.
606fn incomplete_answer_round() -> String {
607    let response_id = format!("resp_{}", Uuid::new_v4());
608    let item_id = format!("msg_{}", Uuid::new_v4());
609
610    let events = vec![
611        (
612            "response.created",
613            json!({"response": response_object(&response_id)}),
614        ),
615        (
616            "response.output_item.added",
617            json!({
618                "output_index": 0,
619                "item": message_item(&item_id, &response_id, json!([]), "in_progress"),
620            }),
621        ),
622        (
623            "response.output_text.delta",
624            json!({
625                "content_index": 0,
626                "item_id": item_id,
627                "output_index": 0,
628                "delta": "This answer gets cut",
629            }),
630        ),
631        (
632            "response.incomplete",
633            json!({"response": {
634                "id": response_id,
635                "object": "response",
636                "status": "incomplete",
637                "incomplete_details": {"reason": "max_output_tokens"},
638                "usage": usage(30, 0, 8, 0),
639            }}),
640        ),
641    ];
642    sse_body(events)
643}
644
645/// A stream a proxy closed cleanly before Azure ever sent `response.completed` or
646/// `response.incomplete` — the shape a clean EOF produces, as opposed to the idle-timeout error a
647/// stalled connection produces.
648fn truncated_stream_round() -> String {
649    let response_id = format!("resp_{}", Uuid::new_v4());
650    let item_id = format!("fc_{}", Uuid::new_v4());
651    let call_id = format!("call_{}", Uuid::new_v4());
652
653    sse_body(vec![
654        (
655            "response.created",
656            json!({"response": response_object(&response_id)}),
657        ),
658        (
659            "response.output_item.done",
660            json!({
661                "output_index": 0,
662                "item": {
663                    "type": "function_call",
664                    "id": item_id,
665                    "response_id": response_id,
666                    "call_id": call_id,
667                    "name": <CourseStructureTool as ChatbotToolDeclaration>::NAME,
668                    "arguments": "{}",
669                    "status": "completed",
670                },
671            }),
672        ),
673    ])
674}
675
676/// A completed Azure AI Search output whose `output` is the same plain-text failure shape a real
677/// search backend can return, rather than the `AISearchOutput` JSON the round otherwise expects —
678/// on a `completed` item, unlike the in-progress placeholder [`search_and_text_round`] always
679/// resolves through.
680fn malformed_search_output_round() -> String {
681    let response_id = format!("resp_{}", Uuid::new_v4());
682    let call_id = format!("call_{}", Uuid::new_v4());
683    let search_item_id = format!("fc_{}", Uuid::new_v4());
684    let output_item_id = format!("fco_{}", Uuid::new_v4());
685    let message_item_id = format!("msg_{}", Uuid::new_v4());
686
687    let mut events = reasoning_item_events(&response_id);
688    events.extend([
689        (
690            "response.output_item.done",
691            json!({
692                "output_index": 1,
693                "item": {
694                    "type": "azure_ai_search_call",
695                    "id": search_item_id,
696                    "response_id": response_id,
697                    "call_id": call_id,
698                    "arguments": r#"{"query":"tell me more"}"#,
699                    "status": "completed",
700                },
701            }),
702        ),
703        (
704            "response.output_item.done",
705            json!({
706                "output_index": 2,
707                "item": {
708                    "type": "azure_ai_search_call_output",
709                    "id": output_item_id,
710                    "response_id": response_id,
711                    "call_id": call_id,
712                    "output": "remote tool call failed",
713                    "status": "completed",
714                },
715            }),
716        ),
717    ]);
718    events.extend(message_item_events(
719        &message_item_id,
720        &response_id,
721        3,
722        &["Sorry", ", search is unavailable."],
723    ));
724
725    round(&response_id, events, usage(38, 0, 40, 32))
726}
727
728/// A round that includes an output item kind the chatbot has no variant for, between the
729/// reasoning item and the answer, the way an Azure feature this code predates would arrive.
730fn unknown_item_type_round() -> String {
731    let response_id = format!("resp_{}", Uuid::new_v4());
732    let item_id = format!("ws_{}", Uuid::new_v4());
733    let message_item_id = format!("msg_{}", Uuid::new_v4());
734
735    let mut events = reasoning_item_events(&response_id);
736    events.push((
737        "response.output_item.done",
738        json!({
739            "output_index": 1,
740            "item": {
741                "type": "web_search_call",
742                "id": item_id,
743                "response_id": response_id,
744                "status": "completed",
745            },
746        }),
747    ));
748    events.extend(message_item_events(
749        &message_item_id,
750        &response_id,
751        2,
752        &["Handled", " gracefully."],
753    ));
754
755    round(&response_id, events, usage(20, 0, 20, 16))
756}
757
758/// The default chat answer: a search of the course material, the results it returns, and a text
759/// answer citing them.
760fn search_and_text_round(base_url: &str) -> String {
761    let response_id = format!("resp_{}", Uuid::new_v4());
762    let call_id = format!("call_{}", Uuid::new_v4());
763    let search_item_id = format!("fc_{}", Uuid::new_v4());
764    let output_item_id = format!("fco_{}", Uuid::new_v4());
765    let message_item_id = format!("msg_{}", Uuid::new_v4());
766
767    let search_call = |arguments: &str, status: &str| {
768        json!({
769            "type": "azure_ai_search_call",
770            "id": search_item_id,
771            "response_id": response_id,
772            "call_id": call_id,
773            "arguments": arguments,
774            "status": status,
775        })
776    };
777    let search_call_output = |output: &str, status: &str| {
778        json!({
779            "type": "azure_ai_search_call_output",
780            "id": output_item_id,
781            "response_id": response_id,
782            "call_id": call_id,
783            "output": output,
784            "status": status,
785        })
786    };
787
788    let mut events = reasoning_item_events(&response_id);
789    events.extend([
790        (
791            "response.output_item.added",
792            json!({"output_index": 1, "item": search_call("", "in_progress")}),
793        ),
794        (
795            "response.output_item.done",
796            json!({
797                "output_index": 1,
798                "item": search_call(r#"{"query":"tell me more"}"#, "completed"),
799            }),
800        ),
801        (
802            "response.output_item.added",
803            json!({"output_index": 2, "item": search_call_output("[]", "in_progress")}),
804        ),
805        (
806            "response.output_item.done",
807            json!({
808                "output_index": 2,
809                "item": search_call_output(&search_results(base_url), "completed"),
810            }),
811        ),
812    ]);
813    events.extend(message_item_events(
814        &message_item_id,
815        &response_id,
816        3,
817        &SEARCH_ANSWER_DELTAS,
818    ));
819
820    round(&response_id, events, usage(38, 0, 79, 64))
821}
822
823/// The default round's answer, one delta per element. Each `【x:y†source】` is a citation marker the
824/// frontend replaces with a link to the document it points at.
825const SEARCH_ANSWER_DELTAS: [&str; 12] = [
826    "Hello",
827    "!",
828    " How",
829    " can",
830    " I",
831    " assist",
832    " 【0:2†source】",
833    " you",
834    " 【0:1†source】",
835    " today",
836    "?",
837    "【0:2†source】",
838];
839
840/// What the search returns, as the JSON string Azure nests it in. Only `get_urls` is read: the
841/// chatbot fetches each of those to build the answer's citations, so both they and the hits come
842/// from [`MOCK_DOCUMENTS`], the documents the mock document storage serves.
843fn search_results(base_url: &str) -> String {
844    let [document1, document2, document3] = &MOCK_DOCUMENTS;
845    let hit = |id: &str, document: &MockDocument, content: &str| {
846        json!({
847            "id": id,
848            "content": content,
849            "filepath": document.id,
850            "title": document.title,
851            "url": "",
852            "score": 0.016666668,
853            "knowledgeSourceIndex": 0,
854        })
855    };
856    let get_urls: Vec<String> = MOCK_DOCUMENTS
857        .iter()
858        .map(|document| {
859            format!(
860                "{base_url}/api/v0/mock-document-storage/test/documents/{}",
861                document.id
862            )
863        })
864        .collect();
865
866    json!({
867        "documents": [
868            hit(
869                "doc1",
870                document1,
871                "This chunk is a snippet from page {} of the course {}. Mock test page content This is test content blah",
872            ),
873            hit(
874                "doc2",
875                document2,
876                "Mock test page content 2 This is another test page.",
877            ),
878            // A second hit on doc1's page, so it repeats that page's title and filepath and only
879            // the chunk is document3's.
880            hit("doc3", document1, document3.chunk),
881        ],
882        "get_urls": get_urls,
883    })
884    .to_string()
885}
886
887/// A response to a request that asked for structured output instead of a stream: one whole JSON
888/// object, whose text content is `payload`, the JSON the caller's own schema describes.
889fn blocking_response(payload: &str) -> String {
890    let response_id = format!("resp_{}", Uuid::new_v4());
891    let item_id = format!("msg_{}", Uuid::new_v4());
892
893    let mut response = completed_response_object(&response_id, usage(30, 0, 15, 0));
894    response["output"] = json!([message_item(
895        &item_id,
896        &response_id,
897        json!([{ "type": "output_text", "text": payload }]),
898        "completed",
899    )]);
900    response.to_string()
901}
902
903/// The suggestions the chatbot offers as the learner's next message.
904const MESSAGE_SUGGESTION_PAYLOAD: &str =
905    r#"{"suggestions":["Can you pls help me?","Nice weather we're having.","Hello?"]}"#;
906
907/// The rewrites the CMS offers for a paragraph.
908const CMS_SUGGESTION_PAYLOAD: &str = r#"{"suggestions":["Mock suggestion 1: The paragraph has been improved.","Mock suggestion 2: Here is an alternative version of the paragraph.","Mock suggestion 3: A third distinct rewrite of the paragraph."]}"#;
909
910/// The course description summary, in the shape Sisu expects it in.
911const COURSE_DESCRIPTION_PAYLOAD: &str = r#"{"modules":[{"description":"Introductory course to containers and containerization with Docker. Introduces containerization with Docker and relevant concepts such as image and volume. After completion, students are able to run containerized applications, containerize applications, utilize volumes to store data persistently outside containers, use port mapping to enable access via TCP to containerized applications, and share their own containers publicly. No hard prerequisites; Linux operating systems and web development experience are useful.","prerequisites":["No hard prerequisites","Linux operating systems and web development experience are useful"],"course_code":"TKT21036"}],"audience":["everyone"],"course_description":"Introductory course to containers and containerization with Docker. Introduces containerization with Docker and relevant concepts such as image and volume. After completion, students are able to run containerized applications, containerize applications, utilize volumes to store data persistently outside containers, use port mapping to enable access via TCP to containerized applications, and share their own containers publicly."}"#;
912
913const PROMPT_CREATION_PAYLOAD: &str = r#"{"prompt":"You are a helpful, clear, and concise chatbot for a course. Your purpose is to help learners understand and navigate the course, answer questions about its content when information is available, explain chatbot-related concepts at an appropriate level, and support learning with examples or step-by-step guidance. Do not invent course details, lessons, assignments, policies, or resources that have not been provided. If a question cannot be answered from the available information, say so plainly and ask the learner to provide more context or consult the course materials. Be friendly, professional, and focused. Keep responses relevant and avoid overwhelming the learner. When appropriate, suggest a practical next step or ask a clarifying question.","first_message":"Hi! I’m here to help you. Ask me about anything you’d like!","suggested_messages":["Can you pls help me?","Nice weather we're having.","Hello?"]}"#;
914
915// GET /api/v0/mock_azure/openai/v1/embeddings
916// POST /api/v0/mock_azure/openai/v1/embeddings
917async fn mock_azure_embeddings(
918    app_conf: web::Data<ApplicationConfiguration>,
919    payload: web::Json<EmbeddingRequest>,
920) -> ControllerResult<String> {
921    assert!(app_conf.test_chatbot && app_conf.test_mode);
922
923    if payload.input.iter().any(|s| s.trim().is_empty()) {
924        return Err(ControllerError::new(
925            ControllerErrorType::BadRequest,
926            "input must not be empty".to_string(),
927            None,
928        ));
929    }
930
931    let mock_response = EmbeddingResponse {
932        object: "list".to_string(),
933        model: "mock-embedder-3-small".to_string(),
934        usage: EmbeddingResponseUsage {
935            prompt_tokens: payload.input.len() as i32,
936            total_tokens: payload.input.len() as i32,
937        },
938        data: payload
939            .input
940            .iter()
941            .enumerate()
942            .map(|(index, _)| Embedding {
943                index: index as i32,
944                embedding: vec![0.0; 1536],
945                object: "embedding".to_string(),
946            })
947            .collect(),
948    };
949    let res = serde_json::to_string(&mock_response)?;
950    let token = skip_authorize();
951    token.authorized_ok(res)
952}
953
954pub fn _add_routes(cfg: &mut ServiceConfig) {
955    cfg.route(
956        "/api/projects/test/openai/v1/responses",
957        web::get().to(mock_azure_chat_responses),
958    )
959    .route(
960        "/api/projects/test/openai/v1/responses",
961        web::post().to(mock_azure_chat_responses),
962    )
963    .route("openai/v1/embeddings", web::get().to(mock_azure_embeddings))
964    .route(
965        "openai/v1/embeddings",
966        web::post().to(mock_azure_embeddings),
967    );
968}
969
970#[cfg(test)]
971mod tests {
972    use headless_lms_chatbot::{
973        azure_chatbot::azure::protocol::{
974            AISearchOutput, OutputItem, ReceivedOutputItem, ResponseOutput,
975        },
976        chatbot_tools::ClientChatbotTool,
977        llm_utils::{LLMResponse, parse_text_completion},
978    };
979    use regex::Regex;
980
981    use super::*;
982
983    const BASE_URL: &str = "http://project-331.local";
984
985    /// Pairs every `event:` line of a Server-Sent Events body with the `data:` line after it.
986    fn sse_events(body: &str) -> Vec<(&str, &str)> {
987        let mut events = Vec::new();
988        let mut pending = None;
989        for line in body.lines() {
990            if let Some(event) = line.strip_prefix("event: ") {
991                pending = Some(event);
992            } else if let Some(data) = line.strip_prefix("data: ")
993                && let Some(event) = pending.take()
994            {
995                events.push((event, data));
996            }
997        }
998        events
999    }
1000
1001    /// The body the mock answers `request` with, through the dispatch the handler runs.
1002    fn respond(request: &MockRequest) -> String {
1003        let scenario = pick_scenario(request).expect("the mock answers this request");
1004        (scenario.respond)(request, BASE_URL)
1005    }
1006
1007    /// Every registered scenario's example body of the given kind, named for failure messages.
1008    fn example_bodies(stream: bool) -> Vec<(&'static str, String)> {
1009        SCENARIOS
1010            .iter()
1011            .filter(|scenario| (scenario.example)().stream == stream)
1012            .map(|scenario| {
1013                let example = (scenario.example)();
1014                (scenario.name, (scenario.respond)(&example, BASE_URL))
1015            })
1016            .collect()
1017    }
1018
1019    /// The tools called by the function call items among `events`.
1020    fn called_tool_names(events: &[(&str, &str)]) -> Vec<String> {
1021        events
1022            .iter()
1023            .filter_map(|(_, data)| {
1024                match serde_json::from_str::<ResponseOutput>(data)
1025                    .ok()?
1026                    .item?
1027                    .known()?
1028                {
1029                    OutputItem::FunctionCall { tool_name, .. } => Some(tool_name),
1030                    _ => None,
1031                }
1032            })
1033            .collect()
1034    }
1035
1036    /// Where the round's first delta is, having checked the lifecycle events every round needs
1037    /// around it: the response id before the first delta, and the terminal event last.
1038    fn first_delta(events: &[(&str, &str)]) -> usize {
1039        let index = events
1040            .iter()
1041            .position(|(event, _)| event.ends_with(".delta"))
1042            .expect("The round streams a delta event");
1043        assert!(
1044            events[..index]
1045                .iter()
1046                .any(|(event, _)| *event == "response.created"),
1047            "The response id has to be known before the first delta"
1048        );
1049        assert_eq!(
1050            events.last().map(|(event, _)| *event),
1051            Some("response.completed")
1052        );
1053        index
1054    }
1055
1056    /// The names of the tools the chatbot runs itself.
1057    fn registered_tool_names() -> Vec<String> {
1058        get_chatbot_tool_definitions()
1059            .into_iter()
1060            .filter_map(|definition| match definition {
1061                AzureLLMToolDefinition::Function(function) => Some(function.name),
1062                AzureLLMToolDefinition::Search(_) => None,
1063            })
1064            .collect()
1065    }
1066
1067    /// The chatbot parses every streamed `data:` line into its own types and kills the whole
1068    /// conversation on one it cannot read, so a mistyped field here is otherwise only visible by
1069    /// running the whole stack.
1070    #[test]
1071    fn every_streamed_data_line_parses_into_chatbot_types() {
1072        let bodies = example_bodies(true);
1073        assert!(!bodies.is_empty(), "no streamed scenario is registered");
1074        for (name, body) in bodies {
1075            let events = sse_events(&body);
1076            assert!(!events.is_empty(), "{name} streams no events");
1077            for (event, data) in events {
1078                let parsed: ResponseOutput = serde_json::from_str(data).unwrap_or_else(|e| {
1079                    panic!("{name} streams a {event} the chatbot cannot parse: {e}\n{data}")
1080                });
1081                if event.starts_with("response.output_item.") {
1082                    assert!(
1083                        parsed.item.is_some(),
1084                        "{name}: {event} carries no item\n{data}"
1085                    );
1086                }
1087                if event == "response.created" {
1088                    assert!(
1089                        parsed.response.and_then(|response| response.id).is_some(),
1090                        "{name}: {event} carries no response id\n{data}"
1091                    );
1092                }
1093            }
1094        }
1095    }
1096
1097    /// A scenario answering an example its own `matches` rejects would leave the round it stands
1098    /// for untested while every test built from the registry passed.
1099    #[test]
1100    fn every_scenario_answers_its_own_example() {
1101        for scenario in SCENARIOS {
1102            let request = (scenario.example)();
1103            let picked = pick_scenario(&request).map(|picked| picked.name);
1104            assert_eq!(
1105                picked,
1106                Some(scenario.name),
1107                "{} is not the scenario its own example is answered by",
1108                scenario.name
1109            );
1110        }
1111    }
1112
1113    /// The structured output features parse the text content again as their own response shape, so
1114    /// both layers have to hold.
1115    #[test]
1116    fn structured_output_responses_parse_into_chatbot_types() {
1117        let bodies = example_bodies(false);
1118        assert!(!bodies.is_empty(), "no blocking scenario is registered");
1119        for (name, body) in bodies {
1120            let completion: LLMResponse = serde_json::from_str(&body)
1121                .unwrap_or_else(|e| panic!("{name} does not parse as an LLM response: {e}"));
1122            let content = parse_text_completion(completion)
1123                .unwrap_or_else(|e| panic!("{name} has no text content: {e}"));
1124            serde_json::from_str::<Value>(&content).unwrap_or_else(|e| {
1125                panic!("{name} content is not the JSON the feature parses: {e}\n{content}")
1126            });
1127        }
1128    }
1129
1130    /// Each feature parses the text content as its own response shape, so a payload nested inside
1131    /// another object would satisfy the test above and still break all three.
1132    #[test]
1133    fn a_blocking_response_carries_its_payload_as_the_whole_text_content() {
1134        for payload in [
1135            MESSAGE_SUGGESTION_PAYLOAD,
1136            CMS_SUGGESTION_PAYLOAD,
1137            COURSE_DESCRIPTION_PAYLOAD,
1138        ] {
1139            let completion: LLMResponse = serde_json::from_str(&blocking_response(payload))
1140                .expect("the blocking response parses as an LLM response");
1141            let content = parse_text_completion(completion).expect("the response has text content");
1142            assert_eq!(content, payload);
1143        }
1144    }
1145
1146    /// The chatbot classifies the round from the function call item it announces and hands the
1147    /// tool parser what follows. The tool parser needs a completed function call and a
1148    /// `response.completed`, and errors on a text delta.
1149    #[test]
1150    fn the_function_call_round_drives_the_tool_call_parser() {
1151        let body = respond(&MockRequest::chat(TOOL_CALL_TRIGGER));
1152        let events = sse_events(&body);
1153
1154        let first_delta = first_delta(&events);
1155        assert_eq!(
1156            events[first_delta].0,
1157            "response.function_call_arguments.delta"
1158        );
1159        assert!(
1160            !events
1161                .iter()
1162                .any(|(event, _)| *event == "response.output_text.delta"),
1163            "A text delta makes the tool parser error out"
1164        );
1165
1166        let called = called_tool_names(&events[first_delta + 1..]);
1167        assert!(
1168            !called.is_empty(),
1169            "The tool parser only sees function calls delivered after the first delta"
1170        );
1171
1172        let registered = registered_tool_names();
1173        for tool_name in called {
1174            assert!(
1175                registered.contains(&tool_name),
1176                "The mock calls {tool_name}, which no chatbot tool is registered under"
1177            );
1178        }
1179    }
1180
1181    /// The round that suspends a turn: the tool it calls has to be one the client answers, and
1182    /// must not be one the chatbot would run itself instead of suspending.
1183    #[test]
1184    fn the_client_tool_call_round_calls_a_tool_only_the_client_answers() {
1185        let body = respond(&MockRequest::chat(CLIENT_TOOL_CALL_TRIGGER));
1186
1187        let called = called_tool_names(&sse_events(&body));
1188        assert!(!called.is_empty(), "The round calls no tool");
1189        let registered = registered_tool_names();
1190        for tool_name in called {
1191            assert!(
1192                tool_is_answered_by_client(&tool_name),
1193                "The mock calls {tool_name} to suspend a turn, but the client does not answer it"
1194            );
1195            assert!(
1196                !registered.contains(&tool_name),
1197                "{tool_name} is registered as a chatbot tool, so the turn would never suspend"
1198            );
1199        }
1200    }
1201
1202    /// The chatbot validates a client tool's arguments before it suspends, so a round the tool
1203    /// would reject never reaches the client: it becomes a failure reported to the LLM instead.
1204    #[test]
1205    fn the_client_tool_call_round_asks_a_question_the_tool_accepts() {
1206        AskMultipleChoiceQuestionTool::parse_arguments(MOCK_MULTIPLE_CHOICE_ARGUMENTS)
1207            .expect("the mock's question passes the tool's own validation");
1208    }
1209
1210    #[test]
1211    fn the_answer_after_a_tool_ran_drives_the_text_parser() {
1212        let body = respond(&MockRequest::after_tool_run());
1213        let events = sse_events(&body);
1214
1215        assert_eq!(events[first_delta(&events)].0, "response.output_text.delta");
1216
1217        let streamed: String = events
1218            .iter()
1219            .filter(|(event, _)| *event == "response.output_text.delta")
1220            .filter_map(|(_, data)| serde_json::from_str::<ResponseOutput>(data).ok()?.delta)
1221            .collect();
1222        assert!(!streamed.is_empty(), "The round streams no text");
1223    }
1224
1225    /// The system tests wait for this answer with the citation markers stripped out by the
1226    /// frontend, and the committed screenshots show it with them rendered as citation pills, so
1227    /// rewording either form here, or shifting a space around a marker, fails the chatbot specs.
1228    #[test]
1229    fn the_search_round_answers_the_text_the_system_tests_wait_for() {
1230        let answer = SEARCH_ANSWER_DELTAS.concat();
1231        assert_eq!(
1232            answer,
1233            "Hello! How can I assist 【0:2†source】 you 【0:1†source】 today?【0:2†source】"
1234        );
1235
1236        // The frontend's REMOVE_CITATIONS_REGEX, which produces the text the specs wait for.
1237        let stripped = Regex::new(r"\s*?【\d+:\d+†source】")
1238            .expect("the citation regex compiles")
1239            .replace_all(&answer, "");
1240        assert_eq!(stripped, "Hello! How can I assist you today?");
1241    }
1242
1243    /// The urls are the only part of the search output the chatbot reads, and it reads them out of
1244    /// a JSON string nested in a JSON string, so the nesting only fails where it is parsed: when
1245    /// the answer's citations are saved.
1246    #[test]
1247    fn the_search_round_streams_document_urls() {
1248        let body = search_and_text_round(BASE_URL);
1249
1250        let search_outputs: Vec<String> = sse_events(&body)
1251            .into_iter()
1252            .filter_map(|(_, data)| {
1253                match serde_json::from_str::<ResponseOutput>(data)
1254                    .ok()?
1255                    .item?
1256                    .known()?
1257                {
1258                    OutputItem::AzureAiSearchCallOutput { output, .. }
1259                        if output.contains("get_urls") =>
1260                    {
1261                        Some(output)
1262                    }
1263                    _ => None,
1264                }
1265            })
1266            .collect();
1267        assert!(
1268            !search_outputs.is_empty(),
1269            "The search round streams no search output with urls"
1270        );
1271        for output in search_outputs {
1272            let parsed: AISearchOutput = serde_json::from_str(&output)
1273                .unwrap_or_else(|e| panic!("The search output does not parse: {e}\n{output}"));
1274            assert_eq!(parsed.get_urls.len(), 3);
1275            for url in parsed.get_urls {
1276                assert!(url.as_str().starts_with(BASE_URL), "{url}");
1277            }
1278        }
1279    }
1280
1281    /// An answer cut short by the token limit ends on `response.incomplete`, never on
1282    /// `response.completed`.
1283    #[test]
1284    fn the_incomplete_answer_round_ends_on_incomplete_not_completed() {
1285        let body = respond(&MockRequest::chat(INCOMPLETE_ANSWER_TRIGGER));
1286        let events = sse_events(&body);
1287        assert_eq!(
1288            events.last().map(|(event, _)| *event),
1289            Some("response.incomplete")
1290        );
1291        assert!(
1292            !events
1293                .iter()
1294                .any(|(event, _)| *event == "response.completed"),
1295            "an incomplete round must not also carry a response.completed"
1296        );
1297    }
1298
1299    /// A stream closed before Azure sends a terminal event must not carry one, or it stops
1300    /// exercising the shape a proxy's clean EOF produces.
1301    #[test]
1302    fn the_truncated_stream_round_carries_no_terminal_event() {
1303        let body = respond(&MockRequest::chat(TRUNCATED_STREAM_TRIGGER));
1304        let events = sse_events(&body);
1305        assert!(!events.is_empty(), "the round streams no events at all");
1306        assert!(
1307            !events
1308                .iter()
1309                .any(|(event, _)| *event == "response.completed" || *event == "response.incomplete"),
1310            "a truncated stream must carry neither response.completed nor response.incomplete"
1311        );
1312    }
1313
1314    /// The completed search output this scenario carries fails to parse as the chatbot's
1315    /// search-output schema, on a `completed` item rather than the in-progress placeholder every
1316    /// other search scenario here uses.
1317    #[test]
1318    fn the_malformed_search_output_round_carries_a_completed_output_that_fails_to_parse() {
1319        let body = respond(&MockRequest::chat(MALFORMED_SEARCH_OUTPUT_TRIGGER));
1320        let events = sse_events(&body);
1321        let output = events
1322            .iter()
1323            .find_map(|(_, data)| {
1324                match serde_json::from_str::<ResponseOutput>(data)
1325                    .ok()?
1326                    .item?
1327                    .known()?
1328                {
1329                    OutputItem::AzureAiSearchCallOutput { output, .. } => Some(output),
1330                    _ => None,
1331                }
1332            })
1333            .expect("the round carries a search output item");
1334        assert!(
1335            serde_json::from_str::<AISearchOutput>(&output).is_err(),
1336            "the output was expected to fail the chatbot's search-output schema: {output}"
1337        );
1338    }
1339
1340    /// An item type the chatbot has no variant for still deserializes as
1341    /// `ReceivedOutputItem::Unreadable` rather than failing the line it arrives on.
1342    #[test]
1343    fn the_unknown_item_type_round_deserializes_as_unreadable() {
1344        let body = respond(&MockRequest::chat(UNKNOWN_ITEM_TYPE_TRIGGER));
1345        let events = sse_events(&body);
1346        let saw_unreadable = events.iter().any(|(event, data)| {
1347            event.starts_with("response.output_item.")
1348                && matches!(
1349                    serde_json::from_str::<ResponseOutput>(data)
1350                        .ok()
1351                        .and_then(|r| r.item),
1352                    Some(ReceivedOutputItem::Unreadable(_))
1353                )
1354        });
1355        assert!(saw_unreadable, "the round carries no unreadable item");
1356    }
1357}