Skip to main content

tower_llm/validation/
mod.rs

1//! Conversation validation utilities (pure, test-focused).
2//!
3//! What this module provides
4//! - `validate_conversation` to detect ordering and tool-call consistency violations
5//! - `ValidationPolicy` to configure strictness per test or example
6//! - `ViolationCode` and `Violation` to describe issues precisely for assertions
7//!
8//! Invariants validated (when enabled by policy)
9//! - Role sequencing: first non-system is user; no assistant before first user; optional repeated-role rejection
10//! - Tool-calls: assistant tool_calls must be followed by tool messages with matching ids, before the next assistant
11//! - Tool outputs: unknown, duplicate, and out-of-order tool responses detected
12//! - Structure: system-not-first; unsupported message kinds (developer/function) when disallowed
13//! - Extras: duplicate/empty tool_call ids; empty tool message ids; tool-before-assistant; contiguity of tool responses; require at least one user
14//!
15//! Policies
16//! - `allow_system_anywhere`, `require_user_first`, `allow_repeated_roles`
17//! - `enforce_tool_response_order`, `allow_unknown_tool_response`, `allow_duplicate_tool_response`
18//! - `allow_developer_and_function`, `enforce_contiguous_tool_responses`, `require_user_present`, `allow_dangling_tool_calls`
19//!
20//! Quick start (tests/examples)
21//! ```rust
22//! use tower_llm::validation::{validate_conversation, ValidationPolicy};
23//! use async_openai::types::*;
24//!
25//! let sys = ChatCompletionRequestSystemMessageArgs::default().content("sys").build().unwrap();
26//! let usr = ChatCompletionRequestUserMessageArgs::default().content("hi").build().unwrap();
27//! let asst = ChatCompletionRequestAssistantMessageArgs::default().content("ok").build().unwrap();
28//! let msgs = vec![sys.into(), usr.into(), asst.into()];
29//! assert!(validate_conversation(&msgs, &ValidationPolicy::default()).is_none());
30//! ```
31//!
32//! This module is self-contained and has no side effects. It is intended for
33//! tests and examples, but can be used in layers to assert correctness post-transform.
34
35use async_openai::types::ChatCompletionRequestMessage as ReqMsg;
36
37pub mod gen;
38pub mod mutate;
39
40/// Configuration controlling which rules are enforced.
41#[derive(Debug, Clone)]
42pub struct ValidationPolicy {
43    /// Allow `system` messages to appear anywhere (not only before first non-system).
44    pub allow_system_anywhere: bool,
45    /// Require the first non-system message to be `user`.
46    pub require_user_first: bool,
47    /// Allow repeated adjacent roles (no error on runs like user→user).
48    pub allow_repeated_roles: bool,
49    /// Enforce that tool responses follow the exact declared order of tool_calls.
50    pub enforce_tool_response_order: bool,
51    /// Allow tool responses with unknown tool_call_id (no matching assistant tool_call).
52    pub allow_unknown_tool_response: bool,
53    /// Allow multiple tool responses for the same tool_call_id.
54    pub allow_duplicate_tool_response: bool,
55    /// Allow Developer/Function message kinds.
56    pub allow_developer_and_function: bool,
57    /// Enforce that tool responses are immediately contiguous after the assistant.
58    pub enforce_contiguous_tool_responses: bool,
59    /// Require at least one user message to be present in the conversation.
60    pub require_user_present: bool,
61    /// Allow assistant tool_calls to be dangling (missing corresponding tool responses) at boundaries/end.
62    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/// Well-defined codes for violations.
83#[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/// A single violation with human-readable message and structured data.
139#[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
256/// Validate a conversation and return violations, if any.
257/// Returns None when no violations are found.
258pub fn validate_conversation(
259    messages: &[ReqMsg],
260    policy: &ValidationPolicy,
261) -> Option<Vec<Violation>> {
262    let mut violations: Vec<Violation> = Vec::new();
263
264    // 1) Baseline role checks
265    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        // System not first
279        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        // Detect first_non_system and user gating
288        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        // Repeated roles
309        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                    // Count the run length starting at i-1
321                    let mut count = 2usize; // includes (i-1) and i
322                    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    // Special cases after baseline scan
359    if policy.require_user_present && !first_user_seen {
360        violations.push(Violation::new(ViolationCode::NoUserMessage));
361    }
362
363    // 2) Tool-call invariants, scan with state
364    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>>, // id -> indices where seen
371        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                // On assistant boundary, finalize previous expectations and check contiguity if broken by assistant
380                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                    // Missing ids
394                    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                    // Order check
408                    if policy.enforce_tool_response_order && !exp.seen_order.is_empty() {
409                        // Compare sequence of first occurrences to the prefix of expected_ids of same length
410                        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                // Validate assistant tool_calls for duplicates and empty ids
429                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                // Start new expectations (if any tool_calls)
463                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                    // No active expectation window
507                    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                        // pending responses remain?
516                        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    // Finalize at end-of-list
578    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        // Assistant with one tool call, then a user before tool response
962        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}