1use crate::controllers::mock_document_storage::{MOCK_DOCUMENTS, MockDocument};
2use crate::prelude::*;
3use headless_lms_chatbot::{
4 azure_chatbot::azure::{
5 protocol::{InputItem, LLMRequestResponseFormatParam},
6 tools::AzureLLMToolDefinition,
7 },
8 chart_spec_generation::{
9 DATA_SAMPLE_HEADING, EXISTING_SPEC_HEADING, USER_PROMPT_PREFIX as CHART_SPEC_REQUEST,
10 },
11 chatbot_tools::{
12 ChatbotToolDeclaration,
13 client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
14 custom_tools::course_structure::CourseStructureTool, get_chatbot_tool_definitions,
15 tool_is_answered_by_client,
16 },
17 cms_ai_suggestion::RESPONSE_FORMAT_NAME as CMS_SUGGESTION_FORMAT,
18 course_description_summary::RESPONSE_FORMAT_NAME as COURSE_DESCRIPTION_FORMAT,
19 feedback_categorization::RESPONSE_FORMAT_NAME as FEEDBACK_CATEGORIZATION_FORMAT,
20 llm_utils::AzureCompletionRequest,
21 message_suggestion::RESPONSE_FORMAT_NAME as MESSAGE_SUGGESTION_FORMAT,
22 prompt_creation::RESPONSE_FORMAT_NAME as PROMPT_CREATION_FORMAT,
23};
24use headless_lms_utils::azure_embedding::{
25 Embedding, EmbeddingRequest, EmbeddingResponse, EmbeddingResponseUsage,
26};
27use serde_json::{Value, json};
28
29const TOOL_CALL_TRIGGER: &str = "!MOCK_TOOL_CALL!";
34
35const CLIENT_TOOL_CALL_TRIGGER: &str = "!MOCK_CLIENT_TOOL_CALL!";
39
40const TOOL_CALL_BY_NAME_PREFIX: &str = "!MOCK_TOOL_CALL:";
43
44const INCOMPLETE_ANSWER_TRIGGER: &str = "!MOCK_INCOMPLETE_ANSWER!";
47
48const TRUNCATED_STREAM_TRIGGER: &str = "!MOCK_TRUNCATED_STREAM!";
51
52const MALFORMED_SEARCH_OUTPUT_TRIGGER: &str = "!MOCK_MALFORMED_SEARCH_OUTPUT!";
56
57const UNKNOWN_ITEM_TYPE_TRIGGER: &str = "!MOCK_UNKNOWN_ITEM_TYPE!";
59
60fn tool_call_by_name_trigger(tool_name: &str, arguments: &str) -> String {
62 format!("{TOOL_CALL_BY_NAME_PREFIX}{tool_name}:{arguments}!")
63}
64
65struct TriggeredToolCall {
67 tool_name: String,
68 arguments: String,
69}
70
71fn parse_tool_call_by_name(message: &str) -> Option<TriggeredToolCall> {
77 let (_, rest) = message.split_once(TOOL_CALL_BY_NAME_PREFIX)?;
78 let (call, _) = rest.split_once('!')?;
79 let (tool_name, arguments) = call.split_once(':')?;
80 is_registered_tool(tool_name).then(|| TriggeredToolCall {
81 tool_name: tool_name.to_string(),
82 arguments: arguments.to_string(),
83 })
84}
85
86fn is_registered_tool(tool_name: &str) -> bool {
88 tool_is_answered_by_client(tool_name)
89 || get_chatbot_tool_definitions()
90 .iter()
91 .any(|definition| match definition {
92 AzureLLMToolDefinition::Function(function) => function.name == tool_name,
93 AzureLLMToolDefinition::Search(_) => false,
94 })
95}
96
97struct MockRequest {
99 message: Option<String>,
102 conversation: Vec<String>,
105 format_name: Option<String>,
107 wants_json_object: bool,
109 stream: bool,
111}
112
113impl MockRequest {
114 fn chat(message: &str) -> Self {
116 MockRequest {
117 message: Some(message.to_string()),
118 conversation: vec![message.to_string()],
119 format_name: None,
120 wants_json_object: false,
121 stream: true,
122 }
123 }
124
125 fn after_tool_run() -> Self {
128 MockRequest {
129 message: None,
130 conversation: Vec::new(),
131 format_name: None,
132 wants_json_object: false,
133 stream: true,
134 }
135 }
136
137 fn structured_output(format_name: &str) -> Self {
140 MockRequest {
141 message: Some(TOOL_CALL_TRIGGER.to_string()),
142 conversation: vec![TOOL_CALL_TRIGGER.to_string()],
143 format_name: Some(format_name.to_string()),
144 wants_json_object: false,
145 stream: false,
146 }
147 }
148
149 fn json_object(request_text: &str) -> Self {
151 MockRequest {
152 message: Some(request_text.to_string()),
153 conversation: vec![request_text.to_string()],
154 format_name: None,
155 wants_json_object: true,
156 stream: false,
157 }
158 }
159
160 fn wants_format(&self, format_name: &str) -> bool {
164 !self.stream && self.format_name.as_deref() == Some(format_name)
165 }
166
167 fn wants_json_object_for(&self, request_text: &str) -> bool {
172 !self.stream
173 && self.wants_json_object
174 && self
175 .conversation
176 .iter()
177 .any(|message| message.contains(request_text))
178 }
179
180 fn prompt_section(&self, heading: &str, next_heading: &str) -> Option<&str> {
186 let (_, after) = self
187 .conversation
188 .iter()
189 .find_map(|message| message.split_once(heading))?;
190 Some(after.split(next_heading).next().unwrap_or(after))
191 }
192
193 fn triggered_tool_call(&self) -> Option<TriggeredToolCall> {
195 if !self.stream {
196 return None;
197 }
198 parse_tool_call_by_name(self.message.as_deref()?)
199 }
200
201 fn message_contains(&self, trigger: &str) -> bool {
203 self.stream
204 && self
205 .message
206 .as_deref()
207 .is_some_and(|message| message.contains(trigger))
208 }
209}
210
211struct Scenario {
216 name: &'static str,
218 matches: fn(&MockRequest) -> bool,
219 respond: fn(&MockRequest, &str) -> String,
221 #[cfg_attr(not(test), allow(dead_code))]
223 example: fn() -> MockRequest,
224}
225
226const SCENARIOS: &[Scenario] = &[
234 Scenario {
235 name: "the next message suggestion",
236 matches: |request| request.wants_format(MESSAGE_SUGGESTION_FORMAT),
237 respond: |_, _| blocking_response(MESSAGE_SUGGESTION_PAYLOAD),
238 example: || MockRequest::structured_output(MESSAGE_SUGGESTION_FORMAT),
239 },
240 Scenario {
241 name: "the CMS paragraph suggestion",
242 matches: |request| request.wants_format(CMS_SUGGESTION_FORMAT),
243 respond: |_, _| blocking_response(CMS_SUGGESTION_PAYLOAD),
244 example: || MockRequest::structured_output(CMS_SUGGESTION_FORMAT),
245 },
246 Scenario {
247 name: "the course description summary",
248 matches: |request| request.wants_format(COURSE_DESCRIPTION_FORMAT),
249 respond: |_, _| blocking_response(COURSE_DESCRIPTION_PAYLOAD),
250 example: || MockRequest::structured_output(COURSE_DESCRIPTION_FORMAT),
251 },
252 Scenario {
253 name: "the feedback categorization",
254 matches: |request| request.wants_format(FEEDBACK_CATEGORIZATION_FORMAT),
255 respond: |_, _| blocking_response(FEEDBACK_CATEGORIZATION_PAYLOAD),
256 example: || MockRequest::structured_output(FEEDBACK_CATEGORIZATION_FORMAT),
257 },
258 Scenario {
259 name: "the chart block specification",
260 matches: |request| request.wants_json_object_for(CHART_SPEC_REQUEST),
261 respond: |request, _| blocking_response(&chart_spec_payload(request)),
262 example: || MockRequest::json_object(CHART_SPEC_REQUEST),
263 },
264 Scenario {
265 name: "the client tool call round",
266 matches: |request| request.message_contains(CLIENT_TOOL_CALL_TRIGGER),
267 respond: |_, _| {
268 function_call_round(
269 <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME,
270 MOCK_MULTIPLE_CHOICE_ARGUMENTS,
271 )
272 },
273 example: || MockRequest::chat(CLIENT_TOOL_CALL_TRIGGER),
274 },
275 Scenario {
276 name: "the tool call round for a named tool",
277 matches: |request| request.triggered_tool_call().is_some(),
278 respond: |request, _| {
279 let call = request
280 .triggered_tool_call()
281 .expect("the scenario only answers a request carrying a tool call trigger");
282 function_call_round(&call.tool_name, &call.arguments)
283 },
284 example: || {
285 MockRequest::chat(&tool_call_by_name_trigger(
286 <CourseStructureTool as ChatbotToolDeclaration>::NAME,
287 "{}",
288 ))
289 },
290 },
291 Scenario {
292 name: "the function call round",
293 matches: |request| request.message_contains(TOOL_CALL_TRIGGER),
294 respond: |_, _| {
295 function_call_round(<CourseStructureTool as ChatbotToolDeclaration>::NAME, "{}")
296 },
297 example: || MockRequest::chat(TOOL_CALL_TRIGGER),
298 },
299 Scenario {
300 name: "the answer after a tool ran",
301 matches: |request| request.stream && request.message.is_none(),
302 respond: |_, _| tool_answer_round(),
303 example: MockRequest::after_tool_run,
304 },
305 Scenario {
306 name: "the prompt and first message generation",
307 matches: |request| request.wants_format(PROMPT_CREATION_FORMAT),
308 respond: |_, _| blocking_response(PROMPT_CREATION_PAYLOAD),
309 example: || MockRequest::structured_output(PROMPT_CREATION_FORMAT),
310 },
311 Scenario {
312 name: "an answer cut short by the token limit",
313 matches: |request| request.message_contains(INCOMPLETE_ANSWER_TRIGGER),
314 respond: |_, _| incomplete_answer_round(),
315 example: || MockRequest::chat(INCOMPLETE_ANSWER_TRIGGER),
316 },
317 Scenario {
318 name: "a stream that ends before response.completed",
319 matches: |request| request.message_contains(TRUNCATED_STREAM_TRIGGER),
320 respond: |_, _| truncated_stream_round(),
321 example: || MockRequest::chat(TRUNCATED_STREAM_TRIGGER),
322 },
323 Scenario {
324 name: "a completed search output that fails to parse",
325 matches: |request| request.message_contains(MALFORMED_SEARCH_OUTPUT_TRIGGER),
326 respond: |_, _| malformed_search_output_round(),
327 example: || MockRequest::chat(MALFORMED_SEARCH_OUTPUT_TRIGGER),
328 },
329 Scenario {
330 name: "an output item type the chatbot does not know",
331 matches: |request| request.message_contains(UNKNOWN_ITEM_TYPE_TRIGGER),
332 respond: |_, _| unknown_item_type_round(),
333 example: || MockRequest::chat(UNKNOWN_ITEM_TYPE_TRIGGER),
334 },
335 Scenario {
336 name: "the default chat answer",
337 matches: |request| request.stream && request.message.is_some(),
338 respond: |_, base_url| search_and_text_round(base_url),
339 example: || MockRequest::chat("Tell me more"),
340 },
341];
342
343fn pick_scenario(request: &MockRequest) -> Option<&'static Scenario> {
345 SCENARIOS
346 .iter()
347 .find(|scenario| (scenario.matches)(request))
348}
349
350async fn mock_azure_chat_responses(
357 app_conf: web::Data<ApplicationConfiguration>,
358 payload: web::Json<AzureCompletionRequest>,
359) -> ControllerResult<String> {
360 assert!(app_conf.test_chatbot && app_conf.test_mode);
361
362 let last_input_item = &payload
363 .base
364 .input
365 .last()
366 .ok_or_else(|| {
367 controller_err!(
368 BadRequest,
369 "No messages in request, there should be at least one."
370 )
371 })?
372 .message_type;
373
374 let message = match last_input_item {
375 InputItem::Message { content, .. } => Some(content.clone().get_content_text()),
376 InputItem::FunctionCallOutput { .. } => None,
377 InputItem::FunctionCall { .. } | InputItem::Reasoning { .. } => {
378 return Err(controller_err!(
379 BadRequest,
380 "The mock has no response for a request that ends in a function call or a reasoning item."
381 ));
382 }
383 };
384
385 let requested_format = payload
386 .base
387 .text
388 .as_ref()
389 .and_then(|text| text.format.as_ref());
390 let request = MockRequest {
391 message,
392 conversation: payload
393 .base
394 .input
395 .iter()
396 .filter_map(|item| match &item.message_type {
397 InputItem::Message { content, .. } => Some(content.clone().get_content_text()),
398 _ => None,
399 })
400 .collect(),
401 format_name: match requested_format {
402 Some(LLMRequestResponseFormatParam::JsonSchema { name, .. }) => Some(name.clone()),
403 _ => None,
404 },
405 wants_json_object: matches!(
406 requested_format,
407 Some(LLMRequestResponseFormatParam::JsonObject)
408 ),
409 stream: payload.stream,
410 };
411 let scenario = pick_scenario(&request).ok_or_else(|| {
412 controller_err!(
413 BadRequest,
414 "The mock has no response for this shape of request."
415 )
416 })?;
417 debug!(scenario = scenario.name, "Answering as the mock Azure API");
418 let res = (scenario.respond)(&request, &app_conf.base_url);
419
420 let token = skip_authorize();
421 token.authorized_ok(res)
422}
423
424fn sse_body(events: Vec<(&str, Value)>) -> String {
429 events
430 .into_iter()
431 .map(|(event, mut data)| {
432 if let Some(object) = data.as_object_mut() {
433 object.insert("type".to_string(), json!(event));
434 }
435 format!("event: {event}\ndata: {data}\n\n")
436 })
437 .collect()
438}
439
440fn round(response_id: &str, events: Vec<(&'static str, Value)>, usage: Value) -> String {
444 let mut all = vec![(
445 "response.created",
446 json!({"response": response_object(response_id)}),
447 )];
448 all.extend(events);
449 all.push((
450 "response.completed",
451 json!({"response": completed_response_object(response_id, usage)}),
452 ));
453 sse_body(all)
454}
455
456fn usage(
459 input_tokens: u32,
460 cached_tokens: u32,
461 output_tokens: u32,
462 reasoning_tokens: u32,
463) -> Value {
464 json!({
465 "input_tokens": input_tokens,
466 "input_tokens_details": {
467 "cached_tokens": cached_tokens,
468 "cache_write_tokens": input_tokens.saturating_sub(cached_tokens),
469 },
470 "output_tokens": output_tokens,
471 "output_tokens_details": {"reasoning_tokens": reasoning_tokens},
472 "total_tokens": input_tokens + output_tokens,
473 })
474}
475
476fn response_object(response_id: &str) -> Value {
480 json!({
481 "id": response_id,
482 "object": "response",
483 "status": "in_progress",
484 "usage": null,
485 })
486}
487
488fn completed_response_object(response_id: &str, usage: Value) -> Value {
491 json!({
492 "id": response_id,
493 "object": "response",
494 "status": "completed",
495 "reasoning": {"effort": "medium", "summary": null, "context": "current_turn"},
496 "usage": usage,
497 })
498}
499
500fn message_item(item_id: &str, response_id: &str, content: Value, status: &str) -> Value {
503 json!({
504 "type": "message",
505 "id": item_id,
506 "response_id": response_id,
507 "phase": "final_answer",
508 "role": "assistant",
509 "content": content,
510 "status": status,
511 })
512}
513
514fn message_item_events(
518 item_id: &str,
519 response_id: &str,
520 output_index: u32,
521 deltas: &[&str],
522) -> Vec<(&'static str, Value)> {
523 let text = deltas.concat();
524
525 let mut events = vec![
526 (
527 "response.output_item.added",
528 json!({
529 "output_index": output_index,
530 "item": message_item(item_id, response_id, json!([]), "in_progress"),
531 }),
532 ),
533 (
534 "response.content_part.added",
535 json!({
536 "content_index": 0,
537 "item_id": item_id,
538 "output_index": output_index,
539 "part": { "type": "output_text", "text": "" },
540 }),
541 ),
542 ];
543 events.extend(deltas.iter().map(|delta| {
544 (
545 "response.output_text.delta",
546 json!({
547 "content_index": 0,
548 "item_id": item_id,
549 "output_index": output_index,
550 "delta": delta,
551 }),
552 )
553 }));
554 events.extend([
555 (
556 "response.output_text.done",
557 json!({
558 "content_index": 0,
559 "item_id": item_id,
560 "output_index": output_index,
561 "text": text,
562 }),
563 ),
564 (
565 "response.content_part.done",
566 json!({
567 "content_index": 0,
568 "item_id": item_id,
569 "output_index": output_index,
570 "part": { "type": "output_text", "text": text },
571 }),
572 ),
573 (
574 "response.output_item.done",
575 json!({
576 "output_index": output_index,
577 "item": message_item(
578 item_id,
579 response_id,
580 json!([{ "type": "output_text", "text": text }]),
581 "completed",
582 ),
583 }),
584 ),
585 ]);
586 events
587}
588
589fn reasoning_item_events(response_id: &str) -> Vec<(&'static str, Value)> {
592 let item = reasoning_item(response_id);
593 vec![
594 (
595 "response.output_item.added",
596 json!({"output_index": 0, "item": item}),
597 ),
598 (
599 "response.output_item.done",
600 json!({"output_index": 0, "item": item}),
601 ),
602 ]
603}
604
605fn reasoning_item(response_id: &str) -> Value {
607 json!({
608 "type": "reasoning",
609 "id": format!("rs_{}", Uuid::new_v4()),
610 "response_id": response_id,
611 "summary": [],
612 "encrypted_content": "mock-encrypted-reasoning",
615 })
616}
617
618const MOCK_MULTIPLE_CHOICE_ARGUMENTS: &str =
621 r#"{"question":"Which loop do you mean?","choices":["while","for"]}"#;
622
623fn function_call_round(tool_name: &str, arguments: &str) -> String {
632 let response_id = format!("resp_{}", Uuid::new_v4());
633 let item_id = format!("fc_{}", Uuid::new_v4());
634 let call_id = format!("call_{}", Uuid::new_v4());
635
636 let function_call = |arguments: &str, status: &str| {
637 json!({
638 "type": "function_call",
639 "id": item_id,
640 "response_id": response_id,
641 "call_id": call_id,
642 "name": tool_name,
643 "arguments": arguments,
644 "status": status,
645 })
646 };
647 let mut events = reasoning_item_events(&response_id);
648 events.extend([
649 (
650 "response.output_item.added",
651 json!({"output_index": 1, "item": function_call("", "in_progress")}),
652 ),
653 (
654 "response.function_call_arguments.delta",
655 json!({"item_id": item_id, "output_index": 1, "delta": arguments}),
656 ),
657 (
658 "response.function_call_arguments.done",
659 json!({"item_id": item_id, "output_index": 1, "arguments": arguments}),
660 ),
661 (
662 "response.output_item.done",
663 json!({"output_index": 1, "item": function_call(arguments, "completed")}),
664 ),
665 ]);
666
667 round(&response_id, events, usage(42, 0, 88, 64))
668}
669
670fn tool_answer_round() -> String {
672 let response_id = format!("resp_{}", Uuid::new_v4());
673 let item_id = format!("msg_{}", Uuid::new_v4());
674 let deltas = [
675 "Here", " is", " the", " mock", " answer", " after", " a", " tool", " ran.",
676 ];
677
678 round(
679 &response_id,
680 message_item_events(&item_id, &response_id, 0, &deltas),
681 usage(96, 42, 24, 8),
684 )
685}
686
687fn incomplete_answer_round() -> String {
690 let response_id = format!("resp_{}", Uuid::new_v4());
691 let item_id = format!("msg_{}", Uuid::new_v4());
692
693 let events = vec![
694 (
695 "response.created",
696 json!({"response": response_object(&response_id)}),
697 ),
698 (
699 "response.output_item.added",
700 json!({
701 "output_index": 0,
702 "item": message_item(&item_id, &response_id, json!([]), "in_progress"),
703 }),
704 ),
705 (
706 "response.output_text.delta",
707 json!({
708 "content_index": 0,
709 "item_id": item_id,
710 "output_index": 0,
711 "delta": "This answer gets cut",
712 }),
713 ),
714 (
715 "response.incomplete",
716 json!({"response": {
717 "id": response_id,
718 "object": "response",
719 "status": "incomplete",
720 "incomplete_details": {"reason": "max_output_tokens"},
721 "usage": usage(30, 0, 8, 0),
722 }}),
723 ),
724 ];
725 sse_body(events)
726}
727
728fn truncated_stream_round() -> String {
732 let response_id = format!("resp_{}", Uuid::new_v4());
733 let item_id = format!("fc_{}", Uuid::new_v4());
734 let call_id = format!("call_{}", Uuid::new_v4());
735
736 sse_body(vec![
737 (
738 "response.created",
739 json!({"response": response_object(&response_id)}),
740 ),
741 (
742 "response.output_item.done",
743 json!({
744 "output_index": 0,
745 "item": {
746 "type": "function_call",
747 "id": item_id,
748 "response_id": response_id,
749 "call_id": call_id,
750 "name": <CourseStructureTool as ChatbotToolDeclaration>::NAME,
751 "arguments": "{}",
752 "status": "completed",
753 },
754 }),
755 ),
756 ])
757}
758
759fn malformed_search_output_round() -> String {
764 let response_id = format!("resp_{}", Uuid::new_v4());
765 let call_id = format!("call_{}", Uuid::new_v4());
766 let search_item_id = format!("fc_{}", Uuid::new_v4());
767 let output_item_id = format!("fco_{}", Uuid::new_v4());
768 let message_item_id = format!("msg_{}", Uuid::new_v4());
769
770 let mut events = reasoning_item_events(&response_id);
771 events.extend([
772 (
773 "response.output_item.done",
774 json!({
775 "output_index": 1,
776 "item": {
777 "type": "azure_ai_search_call",
778 "id": search_item_id,
779 "response_id": response_id,
780 "call_id": call_id,
781 "arguments": r#"{"query":"tell me more"}"#,
782 "status": "completed",
783 },
784 }),
785 ),
786 (
787 "response.output_item.done",
788 json!({
789 "output_index": 2,
790 "item": {
791 "type": "azure_ai_search_call_output",
792 "id": output_item_id,
793 "response_id": response_id,
794 "call_id": call_id,
795 "output": "remote tool call failed",
796 "status": "completed",
797 },
798 }),
799 ),
800 ]);
801 events.extend(message_item_events(
802 &message_item_id,
803 &response_id,
804 3,
805 &["Sorry", ", search is unavailable."],
806 ));
807
808 round(&response_id, events, usage(38, 0, 40, 32))
809}
810
811fn unknown_item_type_round() -> String {
814 let response_id = format!("resp_{}", Uuid::new_v4());
815 let item_id = format!("ws_{}", Uuid::new_v4());
816 let message_item_id = format!("msg_{}", Uuid::new_v4());
817
818 let mut events = reasoning_item_events(&response_id);
819 events.push((
820 "response.output_item.done",
821 json!({
822 "output_index": 1,
823 "item": {
824 "type": "web_search_call",
825 "id": item_id,
826 "response_id": response_id,
827 "status": "completed",
828 },
829 }),
830 ));
831 events.extend(message_item_events(
832 &message_item_id,
833 &response_id,
834 2,
835 &["Handled", " gracefully."],
836 ));
837
838 round(&response_id, events, usage(20, 0, 20, 16))
839}
840
841fn search_and_text_round(base_url: &str) -> String {
844 let response_id = format!("resp_{}", Uuid::new_v4());
845 let call_id = format!("call_{}", Uuid::new_v4());
846 let search_item_id = format!("fc_{}", Uuid::new_v4());
847 let output_item_id = format!("fco_{}", Uuid::new_v4());
848 let message_item_id = format!("msg_{}", Uuid::new_v4());
849
850 let search_call = |arguments: &str, status: &str| {
851 json!({
852 "type": "azure_ai_search_call",
853 "id": search_item_id,
854 "response_id": response_id,
855 "call_id": call_id,
856 "arguments": arguments,
857 "status": status,
858 })
859 };
860 let search_call_output = |output: &str, status: &str| {
861 json!({
862 "type": "azure_ai_search_call_output",
863 "id": output_item_id,
864 "response_id": response_id,
865 "call_id": call_id,
866 "output": output,
867 "status": status,
868 })
869 };
870
871 let mut events = reasoning_item_events(&response_id);
872 events.extend([
873 (
874 "response.output_item.added",
875 json!({"output_index": 1, "item": search_call("", "in_progress")}),
876 ),
877 (
878 "response.output_item.done",
879 json!({
880 "output_index": 1,
881 "item": search_call(r#"{"query":"tell me more"}"#, "completed"),
882 }),
883 ),
884 (
885 "response.output_item.added",
886 json!({"output_index": 2, "item": search_call_output("[]", "in_progress")}),
887 ),
888 (
889 "response.output_item.done",
890 json!({
891 "output_index": 2,
892 "item": search_call_output(&search_results(base_url), "completed"),
893 }),
894 ),
895 ]);
896 events.extend(message_item_events(
897 &message_item_id,
898 &response_id,
899 3,
900 &SEARCH_ANSWER_DELTAS,
901 ));
902
903 round(&response_id, events, usage(38, 0, 79, 64))
904}
905
906const SEARCH_ANSWER_DELTAS: [&str; 12] = [
909 "Hello",
910 "!",
911 " How",
912 " can",
913 " I",
914 " assist",
915 " 【0:2†source】",
916 " you",
917 " 【0:1†source】",
918 " today",
919 "?",
920 "【0:2†source】",
921];
922
923fn search_results(base_url: &str) -> String {
927 let [document1, document2, document3] = &MOCK_DOCUMENTS;
928 let hit = |id: &str, document: &MockDocument, content: &str| {
929 json!({
930 "id": id,
931 "content": content,
932 "filepath": document.id,
933 "title": document.title,
934 "url": "",
935 "score": 0.016666668,
936 "knowledgeSourceIndex": 0,
937 })
938 };
939 let get_urls: Vec<String> = MOCK_DOCUMENTS
940 .iter()
941 .map(|document| {
942 format!(
943 "{base_url}/api/v0/mock-document-storage/test/documents/{}",
944 document.id
945 )
946 })
947 .collect();
948
949 json!({
950 "documents": [
951 hit(
952 "doc1",
953 document1,
954 "This chunk is a snippet from page {} of the course {}. Mock test page content This is test content blah",
955 ),
956 hit(
957 "doc2",
958 document2,
959 "Mock test page content 2 This is another test page.",
960 ),
961 hit("doc3", document1, document3.chunk),
964 ],
965 "get_urls": get_urls,
966 })
967 .to_string()
968}
969
970fn blocking_response(payload: &str) -> String {
973 let response_id = format!("resp_{}", Uuid::new_v4());
974 let item_id = format!("msg_{}", Uuid::new_v4());
975
976 let mut response = completed_response_object(&response_id, usage(30, 0, 15, 0));
977 response["output"] = json!([message_item(
978 &item_id,
979 &response_id,
980 json!([{ "type": "output_text", "text": payload }]),
981 "completed",
982 )]);
983 response.to_string()
984}
985
986const MESSAGE_SUGGESTION_PAYLOAD: &str =
988 r#"{"suggestions":["Can you pls help me?","Nice weather we're having.","Hello?"]}"#;
989
990const 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."]}"#;
992
993struct SampledField {
995 name: String,
996 field_type: &'static str,
998}
999
1000fn placeholder_fields() -> (SampledField, SampledField) {
1002 (
1003 SampledField {
1004 name: "category".to_string(),
1005 field_type: "nominal",
1006 },
1007 SampledField {
1008 name: "value".to_string(),
1009 field_type: "quantitative",
1010 },
1011 )
1012}
1013
1014fn split_unquoted_commas(text: &str) -> Vec<&str> {
1017 let mut cells = Vec::new();
1018 let mut start = 0;
1019 let mut in_string = false;
1020 let mut escaped = false;
1021 for (i, character) in text.char_indices() {
1022 match character {
1023 _ if escaped => escaped = false,
1024 '\\' if in_string => escaped = true,
1025 '"' => in_string = !in_string,
1026 ',' if !in_string => {
1027 cells.push(&text[start..i]);
1028 start = i + character.len_utf8();
1029 }
1030 _ => {}
1031 }
1032 }
1033 cells.push(&text[start..]);
1034 cells
1035}
1036
1037fn unquote(cell: &str) -> &str {
1038 cell.trim().trim_matches('"').trim()
1039}
1040
1041fn json_sample_fields(sample: &str) -> Vec<SampledField> {
1047 let Some(open) = sample.find('{') else {
1048 return Vec::new();
1049 };
1050 let Some(close) = sample[open..].find('}') else {
1051 return Vec::new();
1052 };
1053 split_unquoted_commas(&sample[open + 1..open + close])
1054 .into_iter()
1055 .filter_map(|member| {
1056 let (key, value) = member.split_once(':')?;
1057 let name = unquote(key).to_string();
1058 let numeric = !value.trim().starts_with('"') && value.trim().parse::<f64>().is_ok();
1059 (!name.is_empty()).then_some(SampledField {
1060 name,
1061 field_type: if numeric { "quantitative" } else { "nominal" },
1062 })
1063 })
1064 .collect()
1065}
1066
1067fn csv_sample_fields(sample: &str) -> Vec<SampledField> {
1069 let mut rows = sample.lines().filter(|line| !line.trim().is_empty());
1070 let header = rows.next().unwrap_or_default();
1071 let first_row = split_unquoted_commas(rows.next().unwrap_or_default());
1072 split_unquoted_commas(header)
1073 .into_iter()
1074 .enumerate()
1075 .filter_map(|(column, name)| {
1076 let name = unquote(name).to_string();
1077 let numeric = first_row
1078 .get(column)
1079 .is_some_and(|value| unquote(value).parse::<f64>().is_ok());
1080 (!name.is_empty()).then_some(SampledField {
1081 name,
1082 field_type: if numeric { "quantitative" } else { "nominal" },
1083 })
1084 })
1085 .collect()
1086}
1087
1088fn charted_fields(request: &MockRequest) -> (SampledField, SampledField) {
1094 let Some(sample) = request.prompt_section(DATA_SAMPLE_HEADING, EXISTING_SPEC_HEADING) else {
1095 return placeholder_fields();
1096 };
1097 let fields = if sample.trim_start().starts_with(['[', '{']) {
1098 json_sample_fields(sample)
1099 } else {
1100 csv_sample_fields(sample)
1101 };
1102 let mut fields = fields.into_iter();
1103 match (fields.next(), fields.next()) {
1104 (Some(x), Some(y)) => (x, y),
1105 _ => placeholder_fields(),
1106 }
1107}
1108
1109fn chart_spec_payload(request: &MockRequest) -> String {
1113 let (x, y) = charted_fields(request);
1114 json!({
1115 "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
1116 "description": "Mock AI generated bar chart",
1117 "mark": "bar",
1118 "encoding": {
1119 "x": {"field": x.name, "type": x.field_type},
1120 "y": {"field": y.name, "type": y.field_type}
1121 }
1122 })
1123 .to_string()
1124}
1125
1126const 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."}"#;
1128
1129const 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?"]}"#;
1130
1131const FEEDBACK_CATEGORIZATION_PAYLOAD: &str = r#"{"category_name":"Useful feedback"}"#;
1132
1133async fn mock_azure_embeddings(
1136 app_conf: web::Data<ApplicationConfiguration>,
1137 payload: web::Json<EmbeddingRequest>,
1138) -> ControllerResult<String> {
1139 assert!(app_conf.test_chatbot && app_conf.test_mode);
1140
1141 if payload.input.iter().any(|s| s.trim().is_empty()) {
1142 return Err(ControllerError::new(
1143 ControllerErrorType::BadRequest,
1144 "input must not be empty".to_string(),
1145 None,
1146 ));
1147 }
1148
1149 let mock_response = EmbeddingResponse {
1150 object: "list".to_string(),
1151 model: "mock-embedder-3-small".to_string(),
1152 usage: EmbeddingResponseUsage {
1153 prompt_tokens: payload.input.len() as i32,
1154 total_tokens: payload.input.len() as i32,
1155 },
1156 data: payload
1157 .input
1158 .iter()
1159 .enumerate()
1160 .map(|(index, _)| Embedding {
1161 index: index as i32,
1162 embedding: vec![0.0; 1536],
1163 object: "embedding".to_string(),
1164 })
1165 .collect(),
1166 };
1167 let res = serde_json::to_string(&mock_response)?;
1168 let token = skip_authorize();
1169 token.authorized_ok(res)
1170}
1171
1172pub fn _add_routes(cfg: &mut ServiceConfig) {
1173 cfg.route(
1174 "/api/projects/test/openai/v1/responses",
1175 web::get().to(mock_azure_chat_responses),
1176 )
1177 .route(
1178 "/api/projects/test/openai/v1/responses",
1179 web::post().to(mock_azure_chat_responses),
1180 )
1181 .route("openai/v1/embeddings", web::get().to(mock_azure_embeddings))
1182 .route(
1183 "openai/v1/embeddings",
1184 web::post().to(mock_azure_embeddings),
1185 );
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use headless_lms_chatbot::{
1191 azure_chatbot::azure::protocol::{
1192 AISearchOutput, OutputItem, ReceivedOutputItem, ResponseOutput,
1193 },
1194 chatbot_tools::ClientChatbotTool,
1195 llm_utils::{LLMResponse, parse_text_completion},
1196 };
1197 use regex::Regex;
1198
1199 use super::*;
1200
1201 const BASE_URL: &str = "http://project-331.local";
1202
1203 fn sse_events(body: &str) -> Vec<(&str, &str)> {
1205 let mut events = Vec::new();
1206 let mut pending = None;
1207 for line in body.lines() {
1208 if let Some(event) = line.strip_prefix("event: ") {
1209 pending = Some(event);
1210 } else if let Some(data) = line.strip_prefix("data: ")
1211 && let Some(event) = pending.take()
1212 {
1213 events.push((event, data));
1214 }
1215 }
1216 events
1217 }
1218
1219 fn respond(request: &MockRequest) -> String {
1221 let scenario = pick_scenario(request).expect("the mock answers this request");
1222 (scenario.respond)(request, BASE_URL)
1223 }
1224
1225 fn example_bodies(stream: bool) -> Vec<(&'static str, String)> {
1227 SCENARIOS
1228 .iter()
1229 .filter(|scenario| (scenario.example)().stream == stream)
1230 .map(|scenario| {
1231 let example = (scenario.example)();
1232 (scenario.name, (scenario.respond)(&example, BASE_URL))
1233 })
1234 .collect()
1235 }
1236
1237 fn called_tool_names(events: &[(&str, &str)]) -> Vec<String> {
1239 events
1240 .iter()
1241 .filter_map(|(_, data)| {
1242 match serde_json::from_str::<ResponseOutput>(data)
1243 .ok()?
1244 .item?
1245 .known()?
1246 {
1247 OutputItem::FunctionCall { tool_name, .. } => Some(tool_name),
1248 _ => None,
1249 }
1250 })
1251 .collect()
1252 }
1253
1254 fn first_delta(events: &[(&str, &str)]) -> usize {
1257 let index = events
1258 .iter()
1259 .position(|(event, _)| event.ends_with(".delta"))
1260 .expect("The round streams a delta event");
1261 assert!(
1262 events[..index]
1263 .iter()
1264 .any(|(event, _)| *event == "response.created"),
1265 "The response id has to be known before the first delta"
1266 );
1267 assert_eq!(
1268 events.last().map(|(event, _)| *event),
1269 Some("response.completed")
1270 );
1271 index
1272 }
1273
1274 fn registered_tool_names() -> Vec<String> {
1276 get_chatbot_tool_definitions()
1277 .into_iter()
1278 .filter_map(|definition| match definition {
1279 AzureLLMToolDefinition::Function(function) => Some(function.name),
1280 AzureLLMToolDefinition::Search(_) => None,
1281 })
1282 .collect()
1283 }
1284
1285 #[test]
1289 fn every_streamed_data_line_parses_into_chatbot_types() {
1290 let bodies = example_bodies(true);
1291 assert!(!bodies.is_empty(), "no streamed scenario is registered");
1292 for (name, body) in bodies {
1293 let events = sse_events(&body);
1294 assert!(!events.is_empty(), "{name} streams no events");
1295 for (event, data) in events {
1296 let parsed: ResponseOutput = serde_json::from_str(data).unwrap_or_else(|e| {
1297 panic!("{name} streams a {event} the chatbot cannot parse: {e}\n{data}")
1298 });
1299 if event.starts_with("response.output_item.") {
1300 assert!(
1301 parsed.item.is_some(),
1302 "{name}: {event} carries no item\n{data}"
1303 );
1304 }
1305 if event == "response.created" {
1306 assert!(
1307 parsed.response.and_then(|response| response.id).is_some(),
1308 "{name}: {event} carries no response id\n{data}"
1309 );
1310 }
1311 }
1312 }
1313 }
1314
1315 #[test]
1318 fn every_scenario_answers_its_own_example() {
1319 for scenario in SCENARIOS {
1320 let request = (scenario.example)();
1321 let picked = pick_scenario(&request).map(|picked| picked.name);
1322 assert_eq!(
1323 picked,
1324 Some(scenario.name),
1325 "{} is not the scenario its own example is answered by",
1326 scenario.name
1327 );
1328 }
1329 }
1330
1331 #[test]
1334 fn structured_output_responses_parse_into_chatbot_types() {
1335 let bodies = example_bodies(false);
1336 assert!(!bodies.is_empty(), "no blocking scenario is registered");
1337 for (name, body) in bodies {
1338 let completion: LLMResponse = serde_json::from_str(&body)
1339 .unwrap_or_else(|e| panic!("{name} does not parse as an LLM response: {e}"));
1340 let content = parse_text_completion(completion)
1341 .unwrap_or_else(|e| panic!("{name} has no text content: {e}"));
1342 serde_json::from_str::<Value>(&content).unwrap_or_else(|e| {
1343 panic!("{name} content is not the JSON the feature parses: {e}\n{content}")
1344 });
1345 }
1346 }
1347
1348 #[test]
1351 fn a_blocking_response_carries_its_payload_as_the_whole_text_content() {
1352 for payload in [
1353 MESSAGE_SUGGESTION_PAYLOAD,
1354 CMS_SUGGESTION_PAYLOAD,
1355 COURSE_DESCRIPTION_PAYLOAD,
1356 ] {
1357 let completion: LLMResponse = serde_json::from_str(&blocking_response(payload))
1358 .expect("the blocking response parses as an LLM response");
1359 let content = parse_text_completion(completion).expect("the response has text content");
1360 assert_eq!(content, payload);
1361 }
1362 }
1363
1364 fn chart_request_sampling(sample: &str) -> MockRequest {
1366 MockRequest::json_object(&format!(
1367 "{CHART_SPEC_REQUEST}\n\nRequest:\nA bar chart{DATA_SAMPLE_HEADING}{sample}"
1368 ))
1369 }
1370
1371 fn charted_encoding(request: &MockRequest) -> ((String, String), (String, String)) {
1373 let spec: Value = serde_json::from_str(&chart_spec_payload(request))
1374 .expect("the chart specification is the whole answer, so it must be JSON");
1375 let channel = |name: &str| {
1376 let channel = &spec["encoding"][name];
1377 (
1378 channel["field"]
1379 .as_str()
1380 .expect("the channel names a field")
1381 .to_string(),
1382 channel["type"]
1383 .as_str()
1384 .expect("the channel gives a type")
1385 .to_string(),
1386 )
1387 };
1388 (channel("x"), channel("y"))
1389 }
1390
1391 #[test]
1394 fn the_mock_chart_specification_carries_no_data() {
1395 let spec: Value = serde_json::from_str(&chart_spec_payload(&MockRequest::json_object(
1396 CHART_SPEC_REQUEST,
1397 )))
1398 .expect("the chart specification is the whole answer, so it must be JSON");
1399
1400 assert!(spec.get("data").is_none(), "{spec}");
1401 assert!(spec.get("datasets").is_none(), "{spec}");
1402 }
1403
1404 #[test]
1405 fn the_mock_chart_falls_back_to_placeholder_fields_without_a_sample() {
1406 let (x, y) = charted_encoding(&MockRequest::json_object(CHART_SPEC_REQUEST));
1407
1408 assert_eq!(x, ("category".to_string(), "nominal".to_string()));
1409 assert_eq!(y, ("value".to_string(), "quantitative".to_string()));
1410 }
1411
1412 #[test]
1413 fn the_mock_chart_encodes_the_fields_of_a_json_sample() {
1414 let request = chart_request_sampling(
1415 "[\n { \"maara\": 0, \"hinta\": 8 },\n { \"maara\": 50, \"hinta\": 7 }\n]",
1416 );
1417
1418 let (x, y) = charted_encoding(&request);
1419
1420 assert_eq!(x, ("maara".to_string(), "quantitative".to_string()));
1421 assert_eq!(y, ("hinta".to_string(), "quantitative".to_string()));
1422 }
1423
1424 #[test]
1426 fn the_mock_chart_keeps_the_field_order_of_the_sample() {
1427 let request = chart_request_sampling("[{ \"zebra\": \"A\", \"apple\": 1 }]");
1428
1429 let (x, y) = charted_encoding(&request);
1430
1431 assert_eq!(x.0, "zebra");
1432 assert_eq!(y.0, "apple");
1433 }
1434
1435 #[test]
1436 fn the_mock_chart_encodes_the_columns_of_a_csv_sample() {
1437 let request = chart_request_sampling("category,value\nA,28\nB,55\n");
1438
1439 let (x, y) = charted_encoding(&request);
1440
1441 assert_eq!(x, ("category".to_string(), "nominal".to_string()));
1442 assert_eq!(y, ("value".to_string(), "quantitative".to_string()));
1443 }
1444
1445 #[test]
1447 fn the_mock_chart_reads_a_sample_cut_off_mid_row() {
1448 let request =
1449 chart_request_sampling("[{ \"year\": \"2024\", \"sold\": 12 }, { \"year\": \"20");
1450
1451 let (x, y) = charted_encoding(&request);
1452
1453 assert_eq!(x, ("year".to_string(), "nominal".to_string()));
1455 assert_eq!(y, ("sold".to_string(), "quantitative".to_string()));
1456 }
1457
1458 #[test]
1459 fn the_mock_chart_ignores_a_sample_it_cannot_find_two_fields_in() {
1460 let (x, y) = charted_encoding(&chart_request_sampling("nothing chartable here"));
1461
1462 assert_eq!(x.0, "category");
1463 assert_eq!(y.0, "value");
1464 }
1465
1466 #[test]
1469 fn the_mock_chart_reads_the_sample_on_a_repair_round() {
1470 let mut request = chart_request_sampling("category,value\nA,28\n");
1471 request
1472 .conversation
1473 .push("Fix this specification".to_string());
1474 request.message = Some("Fix this specification".to_string());
1475
1476 let (x, _) = charted_encoding(&request);
1477
1478 assert_eq!(x.0, "category");
1479 }
1480
1481 #[test]
1485 fn the_function_call_round_drives_the_tool_call_parser() {
1486 let body = respond(&MockRequest::chat(TOOL_CALL_TRIGGER));
1487 let events = sse_events(&body);
1488
1489 let first_delta = first_delta(&events);
1490 assert_eq!(
1491 events[first_delta].0,
1492 "response.function_call_arguments.delta"
1493 );
1494 assert!(
1495 !events
1496 .iter()
1497 .any(|(event, _)| *event == "response.output_text.delta"),
1498 "A text delta makes the tool parser error out"
1499 );
1500
1501 let called = called_tool_names(&events[first_delta + 1..]);
1502 assert!(
1503 !called.is_empty(),
1504 "The tool parser only sees function calls delivered after the first delta"
1505 );
1506
1507 let registered = registered_tool_names();
1508 for tool_name in called {
1509 assert!(
1510 registered.contains(&tool_name),
1511 "The mock calls {tool_name}, which no chatbot tool is registered under"
1512 );
1513 }
1514 }
1515
1516 #[test]
1519 fn the_client_tool_call_round_calls_a_tool_only_the_client_answers() {
1520 let body = respond(&MockRequest::chat(CLIENT_TOOL_CALL_TRIGGER));
1521
1522 let called = called_tool_names(&sse_events(&body));
1523 assert!(!called.is_empty(), "The round calls no tool");
1524 let registered = registered_tool_names();
1525 for tool_name in called {
1526 assert!(
1527 tool_is_answered_by_client(&tool_name),
1528 "The mock calls {tool_name} to suspend a turn, but the client does not answer it"
1529 );
1530 assert!(
1531 !registered.contains(&tool_name),
1532 "{tool_name} is registered as a chatbot tool, so the turn would never suspend"
1533 );
1534 }
1535 }
1536
1537 #[test]
1540 fn the_client_tool_call_round_asks_a_question_the_tool_accepts() {
1541 AskMultipleChoiceQuestionTool::parse_arguments(MOCK_MULTIPLE_CHOICE_ARGUMENTS)
1542 .expect("the mock's question passes the tool's own validation");
1543 }
1544
1545 #[test]
1546 fn the_answer_after_a_tool_ran_drives_the_text_parser() {
1547 let body = respond(&MockRequest::after_tool_run());
1548 let events = sse_events(&body);
1549
1550 assert_eq!(events[first_delta(&events)].0, "response.output_text.delta");
1551
1552 let streamed: String = events
1553 .iter()
1554 .filter(|(event, _)| *event == "response.output_text.delta")
1555 .filter_map(|(_, data)| serde_json::from_str::<ResponseOutput>(data).ok()?.delta)
1556 .collect();
1557 assert!(!streamed.is_empty(), "The round streams no text");
1558 }
1559
1560 #[test]
1564 fn the_search_round_answers_the_text_the_system_tests_wait_for() {
1565 let answer = SEARCH_ANSWER_DELTAS.concat();
1566 assert_eq!(
1567 answer,
1568 "Hello! How can I assist 【0:2†source】 you 【0:1†source】 today?【0:2†source】"
1569 );
1570
1571 let stripped = Regex::new(r"\s*?【\d+:\d+†source】")
1573 .expect("the citation regex compiles")
1574 .replace_all(&answer, "");
1575 assert_eq!(stripped, "Hello! How can I assist you today?");
1576 }
1577
1578 #[test]
1582 fn the_search_round_streams_document_urls() {
1583 let body = search_and_text_round(BASE_URL);
1584
1585 let search_outputs: Vec<String> = sse_events(&body)
1586 .into_iter()
1587 .filter_map(|(_, data)| {
1588 match serde_json::from_str::<ResponseOutput>(data)
1589 .ok()?
1590 .item?
1591 .known()?
1592 {
1593 OutputItem::AzureAiSearchCallOutput { output, .. }
1594 if output.contains("get_urls") =>
1595 {
1596 Some(output)
1597 }
1598 _ => None,
1599 }
1600 })
1601 .collect();
1602 assert!(
1603 !search_outputs.is_empty(),
1604 "The search round streams no search output with urls"
1605 );
1606 for output in search_outputs {
1607 let parsed: AISearchOutput = serde_json::from_str(&output)
1608 .unwrap_or_else(|e| panic!("The search output does not parse: {e}\n{output}"));
1609 assert_eq!(parsed.get_urls.len(), 3);
1610 for url in parsed.get_urls {
1611 assert!(url.as_str().starts_with(BASE_URL), "{url}");
1612 }
1613 }
1614 }
1615
1616 #[test]
1619 fn the_incomplete_answer_round_ends_on_incomplete_not_completed() {
1620 let body = respond(&MockRequest::chat(INCOMPLETE_ANSWER_TRIGGER));
1621 let events = sse_events(&body);
1622 assert_eq!(
1623 events.last().map(|(event, _)| *event),
1624 Some("response.incomplete")
1625 );
1626 assert!(
1627 !events
1628 .iter()
1629 .any(|(event, _)| *event == "response.completed"),
1630 "an incomplete round must not also carry a response.completed"
1631 );
1632 }
1633
1634 #[test]
1637 fn the_truncated_stream_round_carries_no_terminal_event() {
1638 let body = respond(&MockRequest::chat(TRUNCATED_STREAM_TRIGGER));
1639 let events = sse_events(&body);
1640 assert!(!events.is_empty(), "the round streams no events at all");
1641 assert!(
1642 !events
1643 .iter()
1644 .any(|(event, _)| *event == "response.completed" || *event == "response.incomplete"),
1645 "a truncated stream must carry neither response.completed nor response.incomplete"
1646 );
1647 }
1648
1649 #[test]
1653 fn the_malformed_search_output_round_carries_a_completed_output_that_fails_to_parse() {
1654 let body = respond(&MockRequest::chat(MALFORMED_SEARCH_OUTPUT_TRIGGER));
1655 let events = sse_events(&body);
1656 let output = events
1657 .iter()
1658 .find_map(|(_, data)| {
1659 match serde_json::from_str::<ResponseOutput>(data)
1660 .ok()?
1661 .item?
1662 .known()?
1663 {
1664 OutputItem::AzureAiSearchCallOutput { output, .. } => Some(output),
1665 _ => None,
1666 }
1667 })
1668 .expect("the round carries a search output item");
1669 assert!(
1670 serde_json::from_str::<AISearchOutput>(&output).is_err(),
1671 "the output was expected to fail the chatbot's search-output schema: {output}"
1672 );
1673 }
1674
1675 #[test]
1678 fn the_unknown_item_type_round_deserializes_as_unreadable() {
1679 let body = respond(&MockRequest::chat(UNKNOWN_ITEM_TYPE_TRIGGER));
1680 let events = sse_events(&body);
1681 let saw_unreadable = events.iter().any(|(event, data)| {
1682 event.starts_with("response.output_item.")
1683 && matches!(
1684 serde_json::from_str::<ResponseOutput>(data)
1685 .ok()
1686 .and_then(|r| r.item),
1687 Some(ReceivedOutputItem::Unreadable(_))
1688 )
1689 });
1690 assert!(saw_unreadable, "the round carries no unreadable item");
1691 }
1692}