Skip to main content

ai_agents_state/
evaluator.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use serde_json::Value;
6
7use ai_agents_core::{ChatMessage, LLMProvider, Result};
8
9use super::config::{CompareOp, ContextMatcher, GuardConditions, Transition, TransitionGuard};
10
11pub struct TransitionContext {
12    pub user_message: String,
13    pub assistant_response: String,
14    pub current_state: String,
15    pub context: HashMap<String, Value>,
16}
17
18impl TransitionContext {
19    pub fn new(user_message: &str, assistant_response: &str, current_state: &str) -> Self {
20        Self {
21            user_message: user_message.to_string(),
22            assistant_response: assistant_response.to_string(),
23            current_state: current_state.to_string(),
24            context: HashMap::new(),
25        }
26    }
27
28    pub fn with_context(mut self, context: HashMap<String, Value>) -> Self {
29        self.context = context;
30        self
31    }
32}
33
34#[async_trait]
35pub trait TransitionEvaluator: Send + Sync {
36    async fn select_transition(
37        &self,
38        transitions: &[Transition],
39        context: &TransitionContext,
40    ) -> Result<Option<usize>>;
41}
42
43pub struct LLMTransitionEvaluator {
44    llm: Arc<dyn LLMProvider>,
45}
46
47impl LLMTransitionEvaluator {
48    pub fn new(llm: Arc<dyn LLMProvider>) -> Self {
49        Self { llm }
50    }
51}
52
53// ── Standalone guard evaluation functions ──
54
55pub fn evaluate_guard(guard: &TransitionGuard, ctx: &TransitionContext) -> bool {
56    match guard {
57        TransitionGuard::Expression(expr) => evaluate_expression(expr, ctx),
58        TransitionGuard::Conditions(conditions) => evaluate_conditions(conditions, ctx),
59    }
60}
61
62pub fn evaluate_expression(expr: &str, ctx: &TransitionContext) -> bool {
63    let expr = expr.trim();
64
65    if !expr.contains("{{") {
66        return !expr.is_empty();
67    }
68
69    let inner = expr.trim_start_matches("{{").trim_end_matches("}}").trim();
70
71    evaluate_simple_expression(inner, ctx)
72}
73
74fn evaluate_simple_expression(expr: &str, ctx: &TransitionContext) -> bool {
75    if let Some(path) = expr.strip_prefix("context.") {
76        return get_context_value(path, &ctx.context).is_some();
77    }
78
79    if let Some(field) = expr.strip_prefix("state.") {
80        return evaluate_state_expression(field, ctx);
81    }
82
83    if let Some(idx) = expr.find('>') {
84        let (left, right) = expr.split_at(idx);
85        let op = if right.starts_with(">=") { ">=" } else { ">" };
86        let right = right.trim_start_matches(op).trim();
87        let left = left.trim();
88
89        if let (Some(left_val), Ok(right_val)) = (resolve_value(left, ctx), right.parse::<f64>())
90            && let Some(left_num) = left_val.as_f64()
91        {
92            return if op == ">=" {
93                left_num >= right_val
94            } else {
95                left_num > right_val
96            };
97        }
98    }
99
100    if let Some(idx) = expr.find('<') {
101        let (left, right) = expr.split_at(idx);
102        let op = if right.starts_with("<=") { "<=" } else { "<" };
103        let right = right.trim_start_matches(op).trim();
104        let left = left.trim();
105
106        if let (Some(left_val), Ok(right_val)) = (resolve_value(left, ctx), right.parse::<f64>())
107            && let Some(left_num) = left_val.as_f64()
108        {
109            return if op == "<=" {
110                left_num <= right_val
111            } else {
112                left_num < right_val
113            };
114        }
115    }
116
117    if let Some(idx) = expr.find("==") {
118        let (left, right) = expr.split_at(idx);
119        let right = &right[2..].trim();
120        let left = left.trim();
121
122        if let Some(left_val) = resolve_value(left, ctx) {
123            let right_val: Value = if right.starts_with('"') && right.ends_with('"') {
124                Value::String(right[1..right.len() - 1].to_string())
125            } else if *right == "true" {
126                Value::Bool(true)
127            } else if *right == "false" {
128                Value::Bool(false)
129            } else if let Ok(n) = right.parse::<f64>() {
130                serde_json::json!(n)
131            } else {
132                Value::String(right.to_string())
133            };
134
135            return left_val == right_val;
136        }
137    }
138
139    if let Some(idx) = expr.find("!=") {
140        let (left, right) = expr.split_at(idx);
141        let right = &right[2..].trim();
142        let left = left.trim();
143
144        if let Some(left_val) = resolve_value(left, ctx) {
145            let right_val: Value = if right.starts_with('"') && right.ends_with('"') {
146                Value::String(right[1..right.len() - 1].to_string())
147            } else if *right == "true" {
148                Value::Bool(true)
149            } else if *right == "false" {
150                Value::Bool(false)
151            } else if let Ok(n) = right.parse::<f64>() {
152                serde_json::json!(n)
153            } else {
154                Value::String(right.to_string())
155            };
156
157            return left_val != right_val;
158        }
159    }
160
161    false
162}
163
164fn resolve_value(expr: &str, ctx: &TransitionContext) -> Option<Value> {
165    let expr = expr.trim();
166    if let Some(path) = expr.strip_prefix("context.") {
167        return get_context_value(path, &ctx.context);
168    }
169    if let Some(field) = expr.strip_prefix("state.") {
170        return get_state_value(field, ctx);
171    }
172    None
173}
174
175fn evaluate_state_expression(field: &str, _ctx: &TransitionContext) -> bool {
176    matches!(field, "turn_count")
177}
178
179fn get_state_value(field: &str, ctx: &TransitionContext) -> Option<Value> {
180    match field {
181        "current" => Some(Value::String(ctx.current_state.clone())),
182        _ => None,
183    }
184}
185
186pub fn evaluate_conditions(conditions: &GuardConditions, ctx: &TransitionContext) -> bool {
187    match conditions {
188        GuardConditions::All(exprs) => exprs.iter().all(|e| evaluate_expression(e, ctx)),
189        GuardConditions::Any(exprs) => exprs.iter().any(|e| evaluate_expression(e, ctx)),
190        GuardConditions::Context(matchers) => evaluate_context_matchers(matchers, &ctx.context),
191    }
192}
193
194pub fn evaluate_context_matchers(
195    matchers: &HashMap<String, ContextMatcher>,
196    context: &HashMap<String, Value>,
197) -> bool {
198    for (path, matcher) in matchers {
199        let value = get_context_value(path, context);
200        if !match_value(value.as_ref(), matcher) {
201            return false;
202        }
203    }
204    true
205}
206
207pub fn get_context_value(path: &str, context: &HashMap<String, Value>) -> Option<Value> {
208    ai_agents_core::get_dot_path_from_map(context, path)
209}
210
211pub fn match_value(value: Option<&Value>, matcher: &ContextMatcher) -> bool {
212    match matcher {
213        ContextMatcher::Exact(expected) => value.map(|v| v == expected).unwrap_or(false),
214        ContextMatcher::Exists { exists } => {
215            let has_value = value.is_some() && value != Some(&Value::Null);
216            *exists == has_value
217        }
218        ContextMatcher::Compare(op) => {
219            let Some(val) = value else {
220                return false;
221            };
222            compare_value(val, op)
223        }
224    }
225}
226
227/// Compare with type coercion: context extractors store all values as strings, but YAML guards may specify booleans (`eq: true`) or numbers (`eq: 42`).
228/// Coerce the string to the expected type before comparing.
229fn values_equal_coerced(value: &Value, expected: &Value) -> bool {
230    if value == expected {
231        return true;
232    }
233    // String value vs non-string expected: coerce the string
234    if let Some(s) = value.as_str() {
235        match expected {
236            Value::Bool(b) => match s {
237                "true" => return *b,
238                "false" => return !*b,
239                _ => {}
240            },
241            Value::Number(n) => {
242                if let Ok(parsed) = s.parse::<f64>()
243                    && let Some(expected_f) = n.as_f64()
244                {
245                    return (parsed - expected_f).abs() < f64::EPSILON;
246                }
247            }
248            _ => {}
249        }
250    }
251    // Non-string value vs string expected: coerce the other way
252    if let Some(s) = expected.as_str() {
253        match value {
254            Value::Bool(b) => match s {
255                "true" => return *b,
256                "false" => return !*b,
257                _ => {}
258            },
259            Value::Number(n) => {
260                if let Ok(parsed) = s.parse::<f64>()
261                    && let Some(val_f) = n.as_f64()
262                {
263                    return (parsed - val_f).abs() < f64::EPSILON;
264                }
265            }
266            _ => {}
267        }
268    }
269    false
270}
271
272pub fn compare_value(value: &Value, op: &CompareOp) -> bool {
273    match op {
274        CompareOp::Eq(expected) => values_equal_coerced(value, expected),
275        CompareOp::Neq(expected) => !values_equal_coerced(value, expected),
276        CompareOp::Gt(n) => value.as_f64().map(|v| v > *n).unwrap_or(false),
277        CompareOp::Gte(n) => value.as_f64().map(|v| v >= *n).unwrap_or(false),
278        CompareOp::Lt(n) => value.as_f64().map(|v| v < *n).unwrap_or(false),
279        CompareOp::Lte(n) => value.as_f64().map(|v| v <= *n).unwrap_or(false),
280        CompareOp::In(values) => values.contains(value),
281        CompareOp::Contains(s) => value
282            .as_str()
283            .map(|v| v.contains(s))
284            .or_else(|| {
285                value
286                    .as_array()
287                    .map(|arr| arr.iter().any(|v| v.as_str() == Some(s)))
288            })
289            .unwrap_or(false),
290    }
291}
292
293#[async_trait]
294impl TransitionEvaluator for LLMTransitionEvaluator {
295    async fn select_transition(
296        &self,
297        transitions: &[Transition],
298        context: &TransitionContext,
299    ) -> Result<Option<usize>> {
300        if transitions.is_empty() {
301            return Ok(None);
302        }
303
304        // Guard-based transitions (existing, no LLM)
305        for (i, transition) in transitions.iter().enumerate() {
306            if let Some(ref guard) = transition.guard
307                && evaluate_guard(guard, context)
308            {
309                return Ok(Some(i));
310            }
311        }
312
313        // !!NOTE: Resolved-intent short-circuit
314        //
315        // If disambiguation has resolved an intent, try to match it against transitions that declare an `intent` field.
316        // This is DETERMINISTIC - no LLM call.
317        if let Some(resolved) = context.context.get("resolved_intent")
318            && let Some(resolved_str) = resolved.as_str()
319            // Skip null values (used to clear stale context)
320            && !resolved_str.is_empty()
321        {
322            for (i, transition) in transitions.iter().enumerate() {
323                if let Some(ref intent) = transition.intent
324                    && intent == resolved_str
325                {
326                    tracing::debug!(
327                        resolved_intent = resolved_str,
328                        target = %transition.to,
329                        "Deterministic routing via resolved_intent"
330                    );
331                    return Ok(Some(i));
332                }
333            }
334        }
335
336        // ── Phase 1: LLM-based evaluation (existing) ──
337        let llm_transitions: Vec<(usize, &Transition)> = transitions
338            .iter()
339            .enumerate()
340            .filter(|(_, t)| !t.when.is_empty() && t.guard.is_none())
341            .collect();
342
343        if llm_transitions.is_empty() {
344            return Ok(None);
345        }
346
347        let conditions: Vec<String> = llm_transitions
348            .iter()
349            .enumerate()
350            .map(|(display_idx, (_, t))| format!("{}. {}", display_idx + 1, t.when))
351            .collect();
352
353        let prompt = format!(
354            r#"Based on the conversation, which condition is met?
355
356Current state: {}
357User message: {}
358Assistant response: {}
359
360Conditions:
361{}
3620. None of the above
363
364Reply with ONLY the number (0-{})."#,
365            context.current_state,
366            context.user_message,
367            context.assistant_response,
368            conditions.join("\n"),
369            llm_transitions.len()
370        );
371
372        let messages = vec![ChatMessage::user(&prompt)];
373        let response = self.llm.complete(&messages, None).await?;
374
375        let choice: usize = response.content.trim().parse().unwrap_or(0);
376
377        if choice == 0 || choice > llm_transitions.len() {
378            Ok(None)
379        } else {
380            Ok(Some(llm_transitions[choice - 1].0))
381        }
382    }
383}
384
385pub struct GuardOnlyEvaluator;
386
387impl GuardOnlyEvaluator {
388    pub fn new() -> Self {
389        Self
390    }
391
392    pub fn evaluate_guard(&self, guard: &TransitionGuard, ctx: &TransitionContext) -> bool {
393        evaluate_guard(guard, ctx)
394    }
395
396    pub fn evaluate_guards(
397        &self,
398        transitions: &[Transition],
399        ctx: &TransitionContext,
400    ) -> Option<usize> {
401        for (i, transition) in transitions.iter().enumerate() {
402            if let Some(ref guard) = transition.guard
403                && evaluate_guard(guard, ctx)
404            {
405                return Some(i);
406            }
407        }
408        None
409    }
410}
411
412impl Default for GuardOnlyEvaluator {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::super::config::TransitionTiming;
421    use super::*;
422    use ai_agents_core::{FinishReason, LLMResponse};
423    use ai_agents_llm::mock::MockLLMProvider;
424
425    #[tokio::test]
426    async fn test_select_transition_none() {
427        let mut mock = MockLLMProvider::new("evaluator_test");
428        mock.add_response(LLMResponse::new("0", FinishReason::Stop));
429        let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
430
431        let transitions = vec![Transition {
432            to: "next".into(),
433            when: "user says goodbye".into(),
434            guard: None,
435            intent: None,
436            auto: true,
437            priority: 0,
438            cooldown_turns: None,
439            timing: TransitionTiming::PostResponse,
440            requires_response: false,
441            run_extractors: false,
442        }];
443
444        let context = TransitionContext::new("hello", "hi there", "greeting");
445
446        let result = evaluator.select_transition(&transitions, &context).await;
447        assert!(result.is_ok());
448        assert!(result.unwrap().is_none());
449    }
450
451    #[tokio::test]
452    async fn test_select_transition_match() {
453        let mut mock = MockLLMProvider::new("evaluator_test");
454        mock.add_response(LLMResponse::new("1", FinishReason::Stop));
455        let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
456
457        let transitions = vec![
458            Transition {
459                to: "support".into(),
460                when: "user needs help".into(),
461                guard: None,
462                intent: None,
463                auto: true,
464                priority: 10,
465                cooldown_turns: None,
466                timing: TransitionTiming::PostResponse,
467                requires_response: false,
468                run_extractors: false,
469            },
470            Transition {
471                to: "sales".into(),
472                when: "user wants to buy".into(),
473                guard: None,
474                intent: None,
475                auto: true,
476                priority: 5,
477                cooldown_turns: None,
478                timing: TransitionTiming::PostResponse,
479                requires_response: false,
480                run_extractors: false,
481            },
482        ];
483
484        let context = TransitionContext::new("I need help", "Sure!", "greeting");
485
486        let result = evaluator.select_transition(&transitions, &context).await;
487        assert!(result.is_ok());
488        assert_eq!(result.unwrap(), Some(0));
489    }
490
491    #[tokio::test]
492    async fn test_empty_transitions() {
493        let mock = MockLLMProvider::new("evaluator_test");
494        let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
495
496        let context = TransitionContext::new("hi", "hello", "start");
497
498        let result = evaluator.select_transition(&[], &context).await;
499        assert!(result.is_ok());
500        assert!(result.unwrap().is_none());
501    }
502
503    #[test]
504    fn test_guard_expression_simple() {
505        let mut context_map = HashMap::new();
506        context_map.insert("has_data".to_string(), Value::Bool(true));
507
508        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
509
510        let guard = TransitionGuard::Expression("{{ context.has_data }}".into());
511        assert!(evaluate_guard(&guard, &ctx));
512    }
513
514    #[test]
515    fn test_guard_expression_missing() {
516        let ctx = TransitionContext::new("hi", "hello", "start").with_context(HashMap::new());
517
518        let guard = TransitionGuard::Expression("{{ context.has_data }}".into());
519        assert!(!evaluate_guard(&guard, &ctx));
520    }
521
522    #[test]
523    fn test_guard_with_nested_context() {
524        let mut context_map = HashMap::new();
525        context_map.insert(
526            "user".to_string(),
527            serde_json::json!({
528                "name": "Alice",
529                "verified": true
530            }),
531        );
532
533        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
534
535        let guard = TransitionGuard::Expression("{{ context.user.verified }}".into());
536        assert!(evaluate_guard(&guard, &ctx));
537    }
538
539    #[test]
540    fn test_guard_conditions_all() {
541        let mut context_map = HashMap::new();
542        context_map.insert("has_name".to_string(), Value::Bool(true));
543        context_map.insert("has_email".to_string(), Value::Bool(true));
544
545        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
546
547        let guard = TransitionGuard::Conditions(GuardConditions::All(vec![
548            "{{ context.has_name }}".into(),
549            "{{ context.has_email }}".into(),
550        ]));
551        assert!(evaluate_guard(&guard, &ctx));
552    }
553
554    #[test]
555    fn test_guard_conditions_any() {
556        let mut context_map = HashMap::new();
557        context_map.insert("is_vip".to_string(), Value::Bool(true));
558
559        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
560
561        let guard = TransitionGuard::Conditions(GuardConditions::Any(vec![
562            "{{ context.is_admin }}".into(),
563            "{{ context.is_vip }}".into(),
564        ]));
565        assert!(evaluate_guard(&guard, &ctx));
566    }
567
568    #[test]
569    fn test_guard_context_matchers() {
570        let mut context_map = HashMap::new();
571        context_map.insert(
572            "user".to_string(),
573            serde_json::json!({
574                "tier": "premium",
575                "balance": 100.0
576            }),
577        );
578
579        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
580
581        let mut matchers = HashMap::new();
582        matchers.insert(
583            "user.tier".to_string(),
584            ContextMatcher::Exact(Value::String("premium".into())),
585        );
586        matchers.insert(
587            "user.balance".to_string(),
588            ContextMatcher::Compare(CompareOp::Gte(50.0)),
589        );
590
591        let guard = TransitionGuard::Conditions(GuardConditions::Context(matchers));
592        assert!(evaluate_guard(&guard, &ctx));
593    }
594
595    #[tokio::test]
596    async fn test_guard_priority_over_llm() {
597        let mock = MockLLMProvider::new("guard_test");
598        let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
599
600        let mut context_map = HashMap::new();
601        context_map.insert("ready".to_string(), Value::Bool(true));
602
603        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
604
605        let transitions = vec![
606            Transition {
607                to: "llm_based".into(),
608                when: "user wants to proceed".into(),
609                guard: None,
610                intent: None,
611                auto: true,
612                priority: 10,
613                cooldown_turns: None,
614                timing: TransitionTiming::PostResponse,
615                requires_response: false,
616                run_extractors: false,
617            },
618            Transition {
619                to: "guard_based".into(),
620                when: String::new(),
621                guard: Some(TransitionGuard::Expression("{{ context.ready }}".into())),
622                intent: None,
623                auto: true,
624                priority: 5,
625                cooldown_turns: None,
626                timing: TransitionTiming::PostResponse,
627                requires_response: false,
628                run_extractors: false,
629            },
630        ];
631
632        let result = evaluator.select_transition(&transitions, &ctx).await;
633        assert_eq!(result.unwrap(), Some(1));
634    }
635
636    #[test]
637    fn test_guard_only_evaluator() {
638        let evaluator = GuardOnlyEvaluator::new();
639
640        let mut context_map = HashMap::new();
641        context_map.insert("ready".to_string(), Value::Bool(true));
642
643        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
644
645        let transitions = vec![
646            Transition {
647                to: "no_guard".into(),
648                when: "some condition".into(),
649                guard: None,
650                intent: None,
651                auto: true,
652                priority: 10,
653                cooldown_turns: None,
654                timing: TransitionTiming::PostResponse,
655                requires_response: false,
656                run_extractors: false,
657            },
658            Transition {
659                to: "with_guard".into(),
660                when: String::new(),
661                guard: Some(TransitionGuard::Expression("{{ context.ready }}".into())),
662                intent: None,
663                auto: true,
664                priority: 5,
665                cooldown_turns: None,
666                timing: TransitionTiming::PostResponse,
667                requires_response: false,
668                run_extractors: false,
669            },
670        ];
671
672        let result = evaluator.evaluate_guards(&transitions, &ctx);
673        assert_eq!(result, Some(1));
674    }
675
676    #[test]
677    fn test_context_matcher_exists() {
678        let mut context_map = HashMap::new();
679        context_map.insert("name".to_string(), Value::String("Alice".into()));
680
681        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
682
683        let mut matchers = HashMap::new();
684        matchers.insert("name".to_string(), ContextMatcher::Exists { exists: true });
685        matchers.insert(
686            "email".to_string(),
687            ContextMatcher::Exists { exists: false },
688        );
689
690        let guard = TransitionGuard::Conditions(GuardConditions::Context(matchers));
691        assert!(evaluate_guard(&guard, &ctx));
692    }
693
694    #[test]
695    fn test_compare_contains() {
696        let mut context_map = HashMap::new();
697        context_map.insert("message".to_string(), Value::String("hello world".into()));
698        context_map.insert("tags".to_string(), serde_json::json!(["urgent", "support"]));
699
700        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
701
702        let mut matchers1 = HashMap::new();
703        matchers1.insert(
704            "message".to_string(),
705            ContextMatcher::Compare(CompareOp::Contains("world".into())),
706        );
707        let guard1 = TransitionGuard::Conditions(GuardConditions::Context(matchers1));
708        assert!(evaluate_guard(&guard1, &ctx));
709
710        let mut matchers2 = HashMap::new();
711        matchers2.insert(
712            "tags".to_string(),
713            ContextMatcher::Compare(CompareOp::Contains("urgent".into())),
714        );
715        let guard2 = TransitionGuard::Conditions(GuardConditions::Context(matchers2));
716        assert!(evaluate_guard(&guard2, &ctx));
717    }
718
719    #[test]
720    fn test_compare_in() {
721        let mut context_map = HashMap::new();
722        context_map.insert("tier".to_string(), Value::String("premium".into()));
723
724        let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
725
726        let mut matchers = HashMap::new();
727        matchers.insert(
728            "tier".to_string(),
729            ContextMatcher::Compare(CompareOp::In(vec![
730                Value::String("premium".into()),
731                Value::String("enterprise".into()),
732            ])),
733        );
734
735        let guard = TransitionGuard::Conditions(GuardConditions::Context(matchers));
736        assert!(evaluate_guard(&guard, &ctx));
737    }
738
739    /// a transition's `intent` field, routing is deterministic - no LLM call.
740    #[tokio::test]
741    async fn test_intent_based_routing_deterministic() {
742        // The mock has NO responses queued — if the LLM were called it would panic/fail.
743        let mock = MockLLMProvider::new("intent_test");
744        let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
745
746        let transitions = vec![
747            Transition {
748                to: "cancel_order".into(),
749                when: "User wants to cancel an order".into(),
750                guard: None,
751                intent: Some("cancel_order".into()),
752                auto: true,
753                priority: 10,
754                cooldown_turns: None,
755                timing: TransitionTiming::PostResponse,
756                requires_response: false,
757                run_extractors: false,
758            },
759            Transition {
760                to: "cancel_reservation".into(),
761                when: "User wants to cancel a reservation".into(),
762                guard: None,
763                intent: Some("cancel_reservation".into()),
764                auto: true,
765                priority: 10,
766                cooldown_turns: None,
767                timing: TransitionTiming::PostResponse,
768                requires_response: false,
769                run_extractors: false,
770            },
771            Transition {
772                to: "cancel_subscription".into(),
773                when: "User wants to cancel a subscription".into(),
774                guard: None,
775                intent: Some("cancel_subscription".into()),
776                auto: true,
777                priority: 10,
778                cooldown_turns: None,
779                timing: TransitionTiming::PostResponse,
780                requires_response: false,
781                run_extractors: false,
782            },
783        ];
784
785        // Simulate disambiguation having resolved intent to "cancel_reservation"
786        let mut context_map = HashMap::new();
787        context_map.insert(
788            "resolved_intent".to_string(),
789            Value::String("cancel_reservation".into()),
790        );
791
792        let ctx =
793            TransitionContext::new("あれキャンセルして", "", "greeting").with_context(context_map);
794
795        let result = evaluator
796            .select_transition(&transitions, &ctx)
797            .await
798            .unwrap();
799        // Should pick index 1 (cancel_reservation) deterministically
800        assert_eq!(result, Some(1));
801    }
802
803    /// the evaluator falls back to LLM-based `when` evaluation.
804    #[tokio::test]
805    async fn test_intent_routing_falls_back_to_llm_when_no_resolved_intent() {
806        let mut mock = MockLLMProvider::new("intent_fallback_test");
807        // LLM returns "1" → first LLM transition (cancel_order)
808        mock.add_response(LLMResponse::new("1", FinishReason::Stop));
809        let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
810
811        let transitions = vec![
812            Transition {
813                to: "cancel_order".into(),
814                when: "User wants to cancel an order".into(),
815                guard: None,
816                intent: Some("cancel_order".into()),
817                auto: true,
818                priority: 10,
819                cooldown_turns: None,
820                timing: TransitionTiming::PostResponse,
821                requires_response: false,
822                run_extractors: false,
823            },
824            Transition {
825                to: "cancel_reservation".into(),
826                when: "User wants to cancel a reservation".into(),
827                guard: None,
828                intent: Some("cancel_reservation".into()),
829                auto: true,
830                priority: 10,
831                cooldown_turns: None,
832                timing: TransitionTiming::PostResponse,
833                requires_response: false,
834                run_extractors: false,
835            },
836        ];
837
838        // No resolved_intent in context -> LLM evaluation fires
839        let ctx = TransitionContext::new("Cancel order ORD-1042", "", "greeting")
840            .with_context(HashMap::new());
841
842        let result = evaluator
843            .select_transition(&transitions, &ctx)
844            .await
845            .unwrap();
846        // LLM said "1" → index 0 (first LLM transition)
847        assert_eq!(result, Some(0));
848    }
849
850    /// transition's `intent` field, it falls through to LLM evaluation.
851    #[tokio::test]
852    async fn test_no_routing_when_resolved_intent_doesnt_match() {
853        let mut mock = MockLLMProvider::new("intent_nomatch_test");
854        // LLM returns "0" (none of the above)
855        mock.add_response(LLMResponse::new("0", FinishReason::Stop));
856        let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
857
858        let transitions = vec![
859            Transition {
860                to: "cancel_order".into(),
861                when: "User wants to cancel an order".into(),
862                guard: None,
863                intent: Some("cancel_order".into()),
864                auto: true,
865                priority: 10,
866                cooldown_turns: None,
867                timing: TransitionTiming::PostResponse,
868                requires_response: false,
869                run_extractors: false,
870            },
871            Transition {
872                to: "cancel_reservation".into(),
873                when: "User wants to cancel a reservation".into(),
874                guard: None,
875                intent: Some("cancel_reservation".into()),
876                auto: true,
877                priority: 10,
878                cooldown_turns: None,
879                timing: TransitionTiming::PostResponse,
880                requires_response: false,
881                run_extractors: false,
882            },
883        ];
884
885        // resolved_intent is "something_else" which matches no transition
886        let mut context_map = HashMap::new();
887        context_map.insert(
888            "resolved_intent".to_string(),
889            Value::String("something_else".into()),
890        );
891
892        let ctx = TransitionContext::new("do something", "", "greeting").with_context(context_map);
893
894        let result = evaluator
895            .select_transition(&transitions, &ctx)
896            .await
897            .unwrap();
898        // LLM said "0" → None
899        assert_eq!(result, None);
900    }
901
902    /// should be ignored - not treated as a valid intent.
903    #[tokio::test]
904    async fn test_null_resolved_intent_is_ignored() {
905        let mut mock = MockLLMProvider::new("intent_null_test");
906        mock.add_response(LLMResponse::new("1", FinishReason::Stop));
907        let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
908
909        let transitions = vec![Transition {
910            to: "cancel_order".into(),
911            when: "User wants to cancel an order".into(),
912            guard: None,
913            intent: Some("cancel_order".into()),
914            auto: true,
915            priority: 10,
916            cooldown_turns: None,
917            timing: TransitionTiming::PostResponse,
918            requires_response: false,
919            run_extractors: false,
920        }];
921
922        // resolved_intent is Null (simulating clear_disambiguation_context)
923        let mut context_map = HashMap::new();
924        context_map.insert("resolved_intent".to_string(), Value::Null);
925
926        let ctx =
927            TransitionContext::new("Cancel my order", "", "greeting").with_context(context_map);
928
929        let result = evaluator
930            .select_transition(&transitions, &ctx)
931            .await
932            .unwrap();
933        // Null resolved_intent should be ignored; LLM said "1" -> index 0
934        assert_eq!(result, Some(0));
935    }
936}