1use async_openai::types::ChatCompletionRequestMessage as ReqMsg;
36
37pub mod gen;
38pub mod mutate;
39
40#[derive(Debug, Clone)]
42pub struct ValidationPolicy {
43 pub allow_system_anywhere: bool,
45 pub require_user_first: bool,
47 pub allow_repeated_roles: bool,
49 pub enforce_tool_response_order: bool,
51 pub allow_unknown_tool_response: bool,
53 pub allow_duplicate_tool_response: bool,
55 pub allow_developer_and_function: bool,
57 pub enforce_contiguous_tool_responses: bool,
59 pub require_user_present: bool,
61 pub allow_dangling_tool_calls: bool,
63}
64
65impl Default for ValidationPolicy {
66 fn default() -> Self {
67 Self {
68 allow_system_anywhere: false,
69 require_user_first: true,
70 allow_repeated_roles: false,
71 enforce_tool_response_order: true,
72 allow_unknown_tool_response: false,
73 allow_duplicate_tool_response: false,
74 allow_developer_and_function: false,
75 enforce_contiguous_tool_responses: false,
76 require_user_present: false,
77 allow_dangling_tool_calls: false,
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ViolationCode {
85 AssistantBeforeUser {
86 assistant_index: usize,
87 },
88 RepeatedRole {
89 role: String,
90 start_index: usize,
91 count: usize,
92 },
93 MissingToolResponses {
94 assistant_index: usize,
95 missing_ids: Vec<String>,
96 },
97 UnknownToolResponse {
98 tool_index: usize,
99 tool_call_id: String,
100 },
101 DuplicateToolResponse {
102 tool_call_id: String,
103 indices: Vec<usize>,
104 },
105 ToolResponsesOutOfOrder {
106 assistant_index: usize,
107 expected: Vec<String>,
108 observed: Vec<String>,
109 },
110 SystemNotFirst {
111 system_index: usize,
112 },
113 UnsupportedMessageType {
114 index: usize,
115 kind: String,
116 },
117 DuplicateToolCallIdsInAssistant {
118 assistant_index: usize,
119 duplicate_ids: Vec<String>,
120 },
121 EmptyToolCallIdInAssistant {
122 assistant_index: usize,
123 positions: Vec<usize>,
124 },
125 EmptyToolMessageId {
126 tool_index: usize,
127 },
128 ToolBeforeAssistant {
129 tool_index: usize,
130 },
131 ToolResponsesNotContiguous {
132 assistant_index: usize,
133 interrupt_index: usize,
134 },
135 NoUserMessage,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Violation {
141 pub code: ViolationCode,
142 pub message: String,
143}
144
145impl Violation {
146 pub fn new(code: ViolationCode) -> Self {
147 let message = match &code {
148 ViolationCode::AssistantBeforeUser { assistant_index } => {
149 format!(
150 "assistant appears before first user at index {}",
151 assistant_index
152 )
153 }
154 ViolationCode::RepeatedRole {
155 role,
156 start_index,
157 count,
158 } => {
159 format!(
160 "role '{}' repeats starting at index {} (count = {})",
161 role, start_index, count
162 )
163 }
164 ViolationCode::MissingToolResponses {
165 assistant_index,
166 missing_ids,
167 } => {
168 format!(
169 "assistant with tool_calls at index {} missing responses for ids: {}",
170 assistant_index,
171 missing_ids.join(", ")
172 )
173 }
174 ViolationCode::UnknownToolResponse {
175 tool_index,
176 tool_call_id,
177 } => {
178 format!(
179 "tool response at index {} references unknown id '{}'",
180 tool_index, tool_call_id
181 )
182 }
183 ViolationCode::DuplicateToolResponse {
184 tool_call_id,
185 indices,
186 } => {
187 format!(
188 "duplicate tool responses for id '{}' at indices {:?}",
189 tool_call_id, indices
190 )
191 }
192 ViolationCode::ToolResponsesOutOfOrder {
193 assistant_index,
194 expected,
195 observed,
196 } => {
197 format!(
198 "tool responses out of order after assistant index {} (expected {:?}, observed {:?})",
199 assistant_index, expected, observed
200 )
201 }
202 ViolationCode::SystemNotFirst { system_index } => {
203 format!(
204 "system message appears after convo start at index {}",
205 system_index
206 )
207 }
208 ViolationCode::UnsupportedMessageType { index, kind } => {
209 format!("unsupported message kind '{}' at index {}", kind, index)
210 }
211 ViolationCode::DuplicateToolCallIdsInAssistant {
212 assistant_index,
213 duplicate_ids,
214 } => {
215 format!(
216 "assistant at index {} has duplicate tool_call ids: {:?}",
217 assistant_index, duplicate_ids
218 )
219 }
220 ViolationCode::EmptyToolCallIdInAssistant {
221 assistant_index,
222 positions,
223 } => {
224 format!(
225 "assistant at index {} has empty tool_call id(s) at positions {:?}",
226 assistant_index, positions
227 )
228 }
229 ViolationCode::EmptyToolMessageId { tool_index } => {
230 format!(
231 "tool message at index {} has empty tool_call_id",
232 tool_index
233 )
234 }
235 ViolationCode::ToolBeforeAssistant { tool_index } => {
236 format!(
237 "tool message at index {} occurs before any assistant tool_calls",
238 tool_index
239 )
240 }
241 ViolationCode::ToolResponsesNotContiguous {
242 assistant_index,
243 interrupt_index,
244 } => {
245 format!(
246 "non-tool message at index {} interrupted contiguous tool responses after assistant index {}",
247 interrupt_index, assistant_index
248 )
249 }
250 ViolationCode::NoUserMessage => "conversation contains no user message".to_string(),
251 };
252 Self { code, message }
253 }
254}
255
256pub fn validate_conversation(
259 messages: &[ReqMsg],
260 policy: &ValidationPolicy,
261) -> Option<Vec<Violation>> {
262 let mut violations: Vec<Violation> = Vec::new();
263
264 let mut first_non_system_seen = false;
266 let mut first_user_seen = false;
267 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
268 enum R {
269 System,
270 User,
271 Assistant,
272 Tool,
273 Developer,
274 Function,
275 }
276 let mut last_role: Option<R> = None;
277 for (i, m) in messages.iter().enumerate() {
278 if !policy.allow_system_anywhere && first_non_system_seen {
280 if let ReqMsg::System(_) = m {
281 violations.push(Violation::new(ViolationCode::SystemNotFirst {
282 system_index: i,
283 }));
284 }
285 }
286
287 match m {
289 ReqMsg::System(_) => {}
290 _ => {
291 if !first_non_system_seen {
292 first_non_system_seen = true;
293 if policy.require_user_first
294 && !matches!(m, ReqMsg::User(_))
295 && matches!(m, ReqMsg::Assistant(_))
296 {
297 violations.push(Violation::new(ViolationCode::AssistantBeforeUser {
298 assistant_index: i,
299 }));
300 }
301 }
302 }
303 }
304 if matches!(m, ReqMsg::User(_)) {
305 first_user_seen = true;
306 }
307
308 if !policy.allow_repeated_roles {
310 let current_role = match m {
311 ReqMsg::System(_) => R::System,
312 ReqMsg::User(_) => R::User,
313 ReqMsg::Assistant(_) => R::Assistant,
314 ReqMsg::Tool(_) => R::Tool,
315 ReqMsg::Developer(_) => R::Developer,
316 ReqMsg::Function(_) => R::Function,
317 };
318 if let Some(prev) = last_role {
319 if prev == current_role {
320 let mut count = 2usize; let mut j = i + 1;
323 while j < messages.len() {
324 let rj = match &messages[j] {
325 ReqMsg::System(_) => R::System,
326 ReqMsg::User(_) => R::User,
327 ReqMsg::Assistant(_) => R::Assistant,
328 ReqMsg::Tool(_) => R::Tool,
329 ReqMsg::Developer(_) => R::Developer,
330 ReqMsg::Function(_) => R::Function,
331 };
332 if rj == current_role {
333 count += 1;
334 j += 1;
335 } else {
336 break;
337 }
338 }
339 let role_str = match current_role {
340 R::System => "system",
341 R::User => "user",
342 R::Assistant => "assistant",
343 R::Tool => "tool",
344 R::Developer => "developer",
345 R::Function => "function",
346 };
347 violations.push(Violation::new(ViolationCode::RepeatedRole {
348 role: role_str.to_string(),
349 start_index: i - 1,
350 count,
351 }));
352 }
353 }
354 last_role = Some(current_role);
355 }
356 }
357
358 if policy.require_user_present && !first_user_seen {
360 violations.push(Violation::new(ViolationCode::NoUserMessage));
361 }
362
363 use std::collections::{HashMap, HashSet};
365 #[derive(Default)]
366 struct Expected {
367 assistant_index: usize,
368 expected_ids: Vec<String>,
369 seen_order: Vec<String>,
370 seen_counts: HashMap<String, Vec<usize>>, contiguity_broken: bool,
372 }
373 let mut current: Option<Expected> = None;
374
375 let mut i = 0usize;
376 while i < messages.len() {
377 match &messages[i] {
378 ReqMsg::Assistant(a) => {
379 if let Some(mut exp) = current.take() {
381 if policy.enforce_contiguous_tool_responses && !exp.contiguity_broken {
382 let pending = exp.expected_ids.len() - exp.seen_counts.keys().count();
383 if pending > 0 {
384 violations.push(Violation::new(
385 ViolationCode::ToolResponsesNotContiguous {
386 assistant_index: exp.assistant_index,
387 interrupt_index: i,
388 },
389 ));
390 exp.contiguity_broken = true;
391 }
392 }
393 let expected_set: HashSet<&String> = exp.expected_ids.iter().collect();
395 let observed_set: HashSet<&String> = exp.seen_order.iter().collect();
396 let missing: Vec<String> = expected_set
397 .difference(&observed_set)
398 .cloned()
399 .cloned()
400 .collect();
401 if !missing.is_empty() && !policy.allow_dangling_tool_calls {
402 violations.push(Violation::new(ViolationCode::MissingToolResponses {
403 assistant_index: exp.assistant_index,
404 missing_ids: missing,
405 }));
406 }
407 if policy.enforce_tool_response_order && !exp.seen_order.is_empty() {
409 let expected_prefix: Vec<String> = exp
411 .expected_ids
412 .iter()
413 .take(exp.seen_order.len())
414 .cloned()
415 .collect();
416 if exp.seen_order != expected_prefix {
417 violations.push(Violation::new(
418 ViolationCode::ToolResponsesOutOfOrder {
419 assistant_index: exp.assistant_index,
420 expected: expected_prefix,
421 observed: exp.seen_order,
422 },
423 ));
424 }
425 }
426 }
427
428 if let Some(tool_calls) = &a.tool_calls {
430 if !tool_calls.is_empty() {
431 use std::collections::HashSet;
432 let mut seen: HashSet<&str> = HashSet::new();
433 let mut dups: Vec<String> = Vec::new();
434 let mut empties: Vec<usize> = Vec::new();
435 for (k, tc) in tool_calls.iter().enumerate() {
436 if tc.id.is_empty() {
437 empties.push(k);
438 }
439 if !tc.id.is_empty() && !seen.insert(tc.id.as_str()) {
440 dups.push(tc.id.clone());
441 }
442 }
443 if !dups.is_empty() {
444 violations.push(Violation::new(
445 ViolationCode::DuplicateToolCallIdsInAssistant {
446 assistant_index: i,
447 duplicate_ids: dups,
448 },
449 ));
450 }
451 if !empties.is_empty() {
452 violations.push(Violation::new(
453 ViolationCode::EmptyToolCallIdInAssistant {
454 assistant_index: i,
455 positions: empties,
456 },
457 ));
458 }
459 }
460 }
461
462 if let Some(tool_calls) = &a.tool_calls {
464 if !tool_calls.is_empty() {
465 let expected_ids: Vec<String> =
466 tool_calls.iter().map(|tc| tc.id.clone()).collect();
467 current = Some(Expected {
468 assistant_index: i,
469 expected_ids,
470 seen_order: Vec::new(),
471 seen_counts: HashMap::new(),
472 contiguity_broken: false,
473 });
474 }
475 }
476 }
477 ReqMsg::Tool(t) => {
478 let id = t.tool_call_id.clone();
479 if id.is_empty() {
480 violations.push(Violation::new(ViolationCode::EmptyToolMessageId {
481 tool_index: i,
482 }));
483 }
484 if let Some(exp) = current.as_mut() {
485 let known = exp.expected_ids.iter().any(|x| x == &id);
486 if !known {
487 if !policy.allow_unknown_tool_response {
488 violations.push(Violation::new(ViolationCode::UnknownToolResponse {
489 tool_index: i,
490 tool_call_id: id.clone(),
491 }));
492 }
493 } else {
494 let entry = exp.seen_counts.entry(id.clone()).or_default();
495 entry.push(i);
496 if entry.len() == 1 {
497 exp.seen_order.push(id.clone());
498 } else if !policy.allow_duplicate_tool_response {
499 violations.push(Violation::new(ViolationCode::DuplicateToolResponse {
500 tool_call_id: id.clone(),
501 indices: entry.clone(),
502 }));
503 }
504 }
505 } else {
506 violations.push(Violation::new(ViolationCode::ToolBeforeAssistant {
508 tool_index: i,
509 }));
510 }
511 }
512 ReqMsg::System(_) | ReqMsg::User(_) => {
513 if let Some(exp) = current.as_mut() {
514 if policy.enforce_contiguous_tool_responses && !exp.contiguity_broken {
515 let pending = exp.expected_ids.len() - exp.seen_counts.keys().count();
517 if pending > 0 {
518 violations.push(Violation::new(
519 ViolationCode::ToolResponsesNotContiguous {
520 assistant_index: exp.assistant_index,
521 interrupt_index: i,
522 },
523 ));
524 exp.contiguity_broken = true;
525 }
526 }
527 }
528 }
529 ReqMsg::Developer(_) => {
530 if !policy.allow_developer_and_function {
531 violations.push(Violation::new(ViolationCode::UnsupportedMessageType {
532 index: i,
533 kind: "developer".into(),
534 }));
535 }
536 if let Some(exp) = current.as_mut() {
537 if policy.enforce_contiguous_tool_responses && !exp.contiguity_broken {
538 let pending = exp.expected_ids.len() - exp.seen_counts.keys().count();
539 if pending > 0 {
540 violations.push(Violation::new(
541 ViolationCode::ToolResponsesNotContiguous {
542 assistant_index: exp.assistant_index,
543 interrupt_index: i,
544 },
545 ));
546 exp.contiguity_broken = true;
547 }
548 }
549 }
550 }
551 ReqMsg::Function(_) => {
552 if !policy.allow_developer_and_function {
553 violations.push(Violation::new(ViolationCode::UnsupportedMessageType {
554 index: i,
555 kind: "function".into(),
556 }));
557 }
558 if let Some(exp) = current.as_mut() {
559 if policy.enforce_contiguous_tool_responses && !exp.contiguity_broken {
560 let pending = exp.expected_ids.len() - exp.seen_counts.keys().count();
561 if pending > 0 {
562 violations.push(Violation::new(
563 ViolationCode::ToolResponsesNotContiguous {
564 assistant_index: exp.assistant_index,
565 interrupt_index: i,
566 },
567 ));
568 exp.contiguity_broken = true;
569 }
570 }
571 }
572 }
573 }
574 i += 1;
575 }
576
577 if let Some(exp) = current.take() {
579 use std::collections::HashSet;
580 let expected_set: HashSet<&String> = exp.expected_ids.iter().collect();
581 let observed_set: HashSet<&String> = exp.seen_order.iter().collect();
582 let missing: Vec<String> = expected_set
583 .difference(&observed_set)
584 .cloned()
585 .cloned()
586 .collect();
587 if !missing.is_empty() && !policy.allow_dangling_tool_calls {
588 violations.push(Violation::new(ViolationCode::MissingToolResponses {
589 assistant_index: exp.assistant_index,
590 missing_ids: missing,
591 }));
592 }
593 if policy.enforce_tool_response_order && !exp.seen_order.is_empty() {
594 let expected_prefix: Vec<String> = exp
595 .expected_ids
596 .iter()
597 .take(exp.seen_order.len())
598 .cloned()
599 .collect();
600 if exp.seen_order != expected_prefix {
601 violations.push(Violation::new(ViolationCode::ToolResponsesOutOfOrder {
602 assistant_index: exp.assistant_index,
603 expected: expected_prefix,
604 observed: exp.seen_order,
605 }));
606 }
607 }
608 }
609
610 if violations.is_empty() {
611 None
612 } else {
613 Some(violations)
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use async_openai::types::*;
621
622 fn req(messages: Vec<ChatCompletionRequestMessage>) -> Vec<ChatCompletionRequestMessage> {
623 messages
624 }
625
626 #[test]
627 fn valid_simple_conversation() {
628 let sys = ChatCompletionRequestSystemMessageArgs::default()
629 .content("sys")
630 .build()
631 .unwrap();
632 let usr = ChatCompletionRequestUserMessageArgs::default()
633 .content("hi")
634 .build()
635 .unwrap();
636 let asst = ChatCompletionRequestAssistantMessageArgs::default()
637 .content("ok")
638 .build()
639 .unwrap();
640 let msgs = req(vec![sys.into(), usr.into(), asst.into()]);
641 let out = validate_conversation(&msgs, &ValidationPolicy::default());
642 assert!(out.is_none());
643 }
644
645 #[test]
646 fn assistant_before_user_detected() {
647 let asst = ChatCompletionRequestAssistantMessageArgs::default()
648 .content("hi")
649 .build()
650 .unwrap();
651 let usr = ChatCompletionRequestUserMessageArgs::default()
652 .content("later")
653 .build()
654 .unwrap();
655 let msgs = req(vec![asst.into(), usr.into()]);
656 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
657 assert!(out
658 .iter()
659 .any(|v| matches!(v.code, ViolationCode::AssistantBeforeUser { .. })));
660 }
661
662 #[test]
663 fn repeated_role_detected() {
664 let usr1 = ChatCompletionRequestUserMessageArgs::default()
665 .content("a")
666 .build()
667 .unwrap();
668 let usr2 = ChatCompletionRequestUserMessageArgs::default()
669 .content("b")
670 .build()
671 .unwrap();
672 let msgs = req(vec![usr1.into(), usr2.into()]);
673 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
674 assert!(out
675 .iter()
676 .any(|v| matches!(v.code, ViolationCode::RepeatedRole { .. })));
677 }
678
679 #[test]
680 fn tool_calls_valid_sequence() {
681 let tc = ChatCompletionMessageToolCall {
682 id: "c1".to_string(),
683 r#type: ChatCompletionToolType::Function,
684 function: FunctionCall {
685 name: "calc".into(),
686 arguments: "{}".into(),
687 },
688 };
689 let asst = ChatCompletionRequestAssistantMessageArgs::default()
690 .content("")
691 .tool_calls(vec![tc])
692 .build()
693 .unwrap();
694 let tool = ChatCompletionRequestToolMessageArgs::default()
695 .tool_call_id("c1")
696 .content("{}")
697 .build()
698 .unwrap();
699 let msgs = req(vec![
700 ChatCompletionRequestMessage::Assistant(asst),
701 ChatCompletionRequestMessage::Tool(tool),
702 ]);
703 let policy = ValidationPolicy {
704 require_user_first: false,
705 ..Default::default()
706 };
707 let out = validate_conversation(&msgs, &policy);
708 assert!(out.is_none());
709 }
710
711 #[test]
712 fn missing_tool_response_detected() {
713 let tc = ChatCompletionMessageToolCall {
714 id: "c1".to_string(),
715 r#type: ChatCompletionToolType::Function,
716 function: FunctionCall {
717 name: "calc".into(),
718 arguments: "{}".into(),
719 },
720 };
721 let asst = ChatCompletionRequestAssistantMessageArgs::default()
722 .content("")
723 .tool_calls(vec![tc])
724 .build()
725 .unwrap();
726 let msgs = req(vec![ChatCompletionRequestMessage::Assistant(asst)]);
727 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
728 assert!(out
729 .iter()
730 .any(|v| matches!(v.code, ViolationCode::MissingToolResponses { .. })));
731 }
732
733 #[test]
734 fn missing_tool_response_allowed_by_policy() {
735 let tc = ChatCompletionMessageToolCall {
736 id: "c1".to_string(),
737 r#type: ChatCompletionToolType::Function,
738 function: FunctionCall {
739 name: "calc".into(),
740 arguments: "{}".into(),
741 },
742 };
743 let asst = ChatCompletionRequestAssistantMessageArgs::default()
744 .content("")
745 .tool_calls(vec![tc])
746 .build()
747 .unwrap();
748 let msgs = req(vec![ChatCompletionRequestMessage::Assistant(asst)]);
749 let policy = ValidationPolicy {
750 require_user_first: false,
751 allow_dangling_tool_calls: true,
752 ..Default::default()
753 };
754 let out = validate_conversation(&msgs, &policy);
755 assert!(out.is_none());
756 }
757
758 #[test]
759 fn unknown_tool_response_detected() {
760 let tool = ChatCompletionRequestToolMessageArgs::default()
761 .tool_call_id("unknown")
762 .content("{}")
763 .build()
764 .unwrap();
765 let msgs = req(vec![ChatCompletionRequestMessage::Tool(tool)]);
766 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
767 assert!(out.iter().any(|v| matches!(
768 v.code,
769 ViolationCode::UnknownToolResponse { .. } | ViolationCode::ToolBeforeAssistant { .. }
770 )));
771 }
772
773 #[test]
774 fn duplicate_tool_response_detected() {
775 let tc = ChatCompletionMessageToolCall {
776 id: "c1".to_string(),
777 r#type: ChatCompletionToolType::Function,
778 function: FunctionCall {
779 name: "calc".into(),
780 arguments: "{}".into(),
781 },
782 };
783 let asst = ChatCompletionRequestAssistantMessageArgs::default()
784 .content("")
785 .tool_calls(vec![tc])
786 .build()
787 .unwrap();
788 let tool1 = ChatCompletionRequestToolMessageArgs::default()
789 .tool_call_id("c1")
790 .content("{}")
791 .build()
792 .unwrap();
793 let tool2 = ChatCompletionRequestToolMessageArgs::default()
794 .tool_call_id("c1")
795 .content("{}")
796 .build()
797 .unwrap();
798 let msgs = req(vec![
799 ChatCompletionRequestMessage::Assistant(asst),
800 ChatCompletionRequestMessage::Tool(tool1),
801 ChatCompletionRequestMessage::Tool(tool2),
802 ]);
803 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
804 assert!(out
805 .iter()
806 .any(|v| matches!(v.code, ViolationCode::DuplicateToolResponse { .. })));
807 }
808
809 #[test]
810 fn out_of_order_tool_responses_detected() {
811 let tc1 = ChatCompletionMessageToolCall {
812 id: "c1".to_string(),
813 r#type: ChatCompletionToolType::Function,
814 function: FunctionCall {
815 name: "calc".into(),
816 arguments: "{}".into(),
817 },
818 };
819 let tc2 = ChatCompletionMessageToolCall {
820 id: "c2".to_string(),
821 r#type: ChatCompletionToolType::Function,
822 function: FunctionCall {
823 name: "calc".into(),
824 arguments: "{}".into(),
825 },
826 };
827 let asst = ChatCompletionRequestAssistantMessageArgs::default()
828 .content("")
829 .tool_calls(vec![tc1, tc2])
830 .build()
831 .unwrap();
832 let tool_b = ChatCompletionRequestToolMessageArgs::default()
833 .tool_call_id("c2")
834 .content("{}")
835 .build()
836 .unwrap();
837 let tool_a = ChatCompletionRequestToolMessageArgs::default()
838 .tool_call_id("c1")
839 .content("{}")
840 .build()
841 .unwrap();
842 let msgs = req(vec![
843 ChatCompletionRequestMessage::Assistant(asst),
844 ChatCompletionRequestMessage::Tool(tool_b),
845 ChatCompletionRequestMessage::Tool(tool_a),
846 ]);
847 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
848 assert!(out
849 .iter()
850 .any(|v| matches!(v.code, ViolationCode::ToolResponsesOutOfOrder { .. })));
851 }
852
853 #[test]
854 fn system_not_first_detected() {
855 let usr = ChatCompletionRequestUserMessageArgs::default()
856 .content("u")
857 .build()
858 .unwrap();
859 let sys = ChatCompletionRequestSystemMessageArgs::default()
860 .content("s")
861 .build()
862 .unwrap();
863 let msgs = req(vec![usr.into(), sys.into()]);
864 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
865 assert!(out
866 .iter()
867 .any(|v| matches!(v.code, ViolationCode::SystemNotFirst { .. })));
868 }
869
870 #[test]
871 fn duplicate_tool_call_ids_in_assistant_detected() {
872 let tc1 = ChatCompletionMessageToolCall {
873 id: "dup".to_string(),
874 r#type: ChatCompletionToolType::Function,
875 function: FunctionCall {
876 name: "a".into(),
877 arguments: "{}".into(),
878 },
879 };
880 let tc2 = ChatCompletionMessageToolCall {
881 id: "dup".to_string(),
882 r#type: ChatCompletionToolType::Function,
883 function: FunctionCall {
884 name: "b".into(),
885 arguments: "{}".into(),
886 },
887 };
888 let asst = ChatCompletionRequestAssistantMessageArgs::default()
889 .content("")
890 .tool_calls(vec![tc1, tc2])
891 .build()
892 .unwrap();
893 let msgs = req(vec![ChatCompletionRequestMessage::Assistant(asst)]);
894 let policy = ValidationPolicy {
895 require_user_first: false,
896 ..Default::default()
897 };
898 let out = validate_conversation(&msgs, &policy).unwrap();
899 assert!(out.iter().any(|v| matches!(
900 v.code,
901 ViolationCode::DuplicateToolCallIdsInAssistant { .. }
902 )));
903 }
904
905 #[test]
906 fn empty_tool_call_id_in_assistant_detected() {
907 let tc1 = ChatCompletionMessageToolCall {
908 id: "".to_string(),
909 r#type: ChatCompletionToolType::Function,
910 function: FunctionCall {
911 name: "a".into(),
912 arguments: "{}".into(),
913 },
914 };
915 let asst = ChatCompletionRequestAssistantMessageArgs::default()
916 .content("")
917 .tool_calls(vec![tc1])
918 .build()
919 .unwrap();
920 let msgs = req(vec![ChatCompletionRequestMessage::Assistant(asst)]);
921 let policy = ValidationPolicy {
922 require_user_first: false,
923 ..Default::default()
924 };
925 let out = validate_conversation(&msgs, &policy).unwrap();
926 assert!(out
927 .iter()
928 .any(|v| matches!(v.code, ViolationCode::EmptyToolCallIdInAssistant { .. })));
929 }
930
931 #[test]
932 fn empty_tool_message_id_detected() {
933 let tool = ChatCompletionRequestToolMessageArgs::default()
934 .tool_call_id("")
935 .content("{}")
936 .build()
937 .unwrap();
938 let msgs = req(vec![ChatCompletionRequestMessage::Tool(tool)]);
939 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
940 assert!(out
941 .iter()
942 .any(|v| matches!(v.code, ViolationCode::EmptyToolMessageId { .. })));
943 }
944
945 #[test]
946 fn tool_before_assistant_detected() {
947 let tool = ChatCompletionRequestToolMessageArgs::default()
948 .tool_call_id("id1")
949 .content("{}")
950 .build()
951 .unwrap();
952 let msgs = req(vec![ChatCompletionRequestMessage::Tool(tool)]);
953 let out = validate_conversation(&msgs, &ValidationPolicy::default()).unwrap();
954 assert!(out
955 .iter()
956 .any(|v| matches!(v.code, ViolationCode::ToolBeforeAssistant { .. })));
957 }
958
959 #[test]
960 fn contiguity_enforced_detects_interruptions() {
961 let tc = ChatCompletionMessageToolCall {
963 id: "c1".into(),
964 r#type: ChatCompletionToolType::Function,
965 function: FunctionCall {
966 name: "x".into(),
967 arguments: "{}".into(),
968 },
969 };
970 let asst = ChatCompletionRequestAssistantMessageArgs::default()
971 .content("")
972 .tool_calls(vec![tc])
973 .build()
974 .unwrap();
975 let user = ChatCompletionRequestUserMessageArgs::default()
976 .content("oops")
977 .build()
978 .unwrap();
979 let tool = ChatCompletionRequestToolMessageArgs::default()
980 .tool_call_id("c1")
981 .content("{}")
982 .build()
983 .unwrap();
984 let msgs = req(vec![
985 ChatCompletionRequestMessage::Assistant(asst),
986 ChatCompletionRequestMessage::User(user),
987 ChatCompletionRequestMessage::Tool(tool),
988 ]);
989 let policy = ValidationPolicy {
990 require_user_first: false,
991 enforce_contiguous_tool_responses: true,
992 ..Default::default()
993 };
994 let out = validate_conversation(&msgs, &policy).unwrap();
995 assert!(out
996 .iter()
997 .any(|v| matches!(v.code, ViolationCode::ToolResponsesNotContiguous { .. })));
998 }
999
1000 #[test]
1001 fn require_user_present_detects_absence() {
1002 let asst = ChatCompletionRequestAssistantMessageArgs::default()
1003 .content("hi")
1004 .build()
1005 .unwrap();
1006 let msgs = req(vec![ChatCompletionRequestMessage::Assistant(asst)]);
1007 let policy = ValidationPolicy {
1008 require_user_present: true,
1009 require_user_first: false,
1010 ..Default::default()
1011 };
1012 let out = validate_conversation(&msgs, &policy).unwrap();
1013 assert!(out
1014 .iter()
1015 .any(|v| matches!(v.code, ViolationCode::NoUserMessage)));
1016 }
1017}