1use std::collections::HashMap;
7
8use async_trait::async_trait;
9
10use super::{BackendType, SpawnConfig};
11
12#[async_trait]
17pub trait BackendRouter: Send + Sync {
18 async fn route(
24 &self,
25 config: &SpawnConfig,
26 available: &[BackendType],
27 ) -> Option<BackendType>;
28}
29
30#[derive(Debug)]
57pub struct KeywordRouter {
58 rules: Vec<(String, BackendType)>,
59 default: BackendType,
60 use_word_boundary: bool,
62}
63
64impl KeywordRouter {
65 pub fn new(default: BackendType) -> Self {
67 Self {
68 rules: Vec::new(),
69 default,
70 use_word_boundary: false,
71 }
72 }
73
74 pub fn word_boundary(mut self, enable: bool) -> Self {
79 self.use_word_boundary = enable;
80 self
81 }
82
83 pub fn rule(mut self, keyword: impl Into<String>, backend: BackendType) -> Self {
86 self.rules.push((keyword.into().to_lowercase(), backend));
87 self
88 }
89
90 pub fn rules(mut self, rules: impl IntoIterator<Item = (String, BackendType)>) -> Self {
92 for (kw, bt) in rules {
93 self.rules.push((kw.to_lowercase(), bt));
94 }
95 self
96 }
97}
98
99fn contains_word(text: &str, word: &str) -> bool {
105 if word.is_empty() || text.len() < word.len() {
106 return false;
107 }
108 let text_bytes = text.as_bytes();
109 let mut start = 0;
110 while start + word.len() <= text.len() {
111 match text[start..].find(word) {
112 Some(pos) => {
113 let abs_pos = start + pos;
114 let before_ok =
115 abs_pos == 0 || !text_bytes[abs_pos - 1].is_ascii_alphanumeric();
116 let after_pos = abs_pos + word.len();
117 let after_ok =
118 after_pos >= text.len() || !text_bytes[after_pos].is_ascii_alphanumeric();
119 if before_ok && after_ok {
120 return true;
121 }
122 start = abs_pos + word.len();
127 }
128 None => break,
129 }
130 }
131 false
132}
133
134#[async_trait]
135impl BackendRouter for KeywordRouter {
136 async fn route(
137 &self,
138 config: &SpawnConfig,
139 available: &[BackendType],
140 ) -> Option<BackendType> {
141 if available.is_empty() {
142 return None;
143 }
144
145 let prompt_lower = config.prompt.to_lowercase();
146
147 for (keyword, backend) in &self.rules {
149 let matched = if self.use_word_boundary {
150 contains_word(&prompt_lower, keyword.as_str())
151 } else {
152 prompt_lower.contains(keyword.as_str())
153 };
154 if matched && available.contains(backend) {
155 return Some(*backend);
156 }
157 }
158
159 if available.contains(&self.default) {
161 Some(self.default)
162 } else {
163 available.first().copied()
164 }
165 }
166}
167
168#[derive(Debug, Clone)]
178pub struct BackendCapability {
179 pub multi_turn: bool,
181 pub streaming: bool,
183 pub cost_tier: u8,
185 pub latency_tier: u8,
187}
188
189impl Default for BackendCapability {
190 fn default() -> Self {
191 Self {
192 multi_turn: true,
193 streaming: true,
194 cost_tier: 2,
195 latency_tier: 2,
196 }
197 }
198}
199
200impl BackendCapability {
201 pub fn defaults() -> HashMap<BackendType, BackendCapability> {
203 let mut map = HashMap::new();
204 map.insert(BackendType::ClaudeCode, BackendCapability {
205 multi_turn: true,
206 streaming: true,
207 cost_tier: 3, latency_tier: 2,
209 });
210 map.insert(BackendType::Codex, BackendCapability {
211 multi_turn: true,
212 streaming: true,
213 cost_tier: 2,
214 latency_tier: 2,
215 });
216 map.insert(BackendType::GeminiCli, BackendCapability {
217 multi_turn: false, streaming: true,
219 cost_tier: 1, latency_tier: 3, });
222 map
223 }
224}
225
226#[derive(Debug)]
231pub struct CapabilityRouter {
232 capabilities: HashMap<BackendType, BackendCapability>,
233 require_multi_turn: bool,
235 cost_weight: f32,
237}
238
239impl Default for CapabilityRouter {
240 fn default() -> Self {
241 Self::new()
242 }
243}
244
245impl CapabilityRouter {
246 pub fn new() -> Self {
248 Self {
249 capabilities: BackendCapability::defaults(),
250 require_multi_turn: false,
251 cost_weight: 0.5,
252 }
253 }
254
255 pub fn require_multi_turn(mut self, require: bool) -> Self {
257 self.require_multi_turn = require;
258 self
259 }
260
261 pub fn cost_weight(mut self, weight: f32) -> Self {
263 self.cost_weight = weight.clamp(0.0, 1.0);
264 self
265 }
266
267 pub fn with_capability(mut self, backend: BackendType, cap: BackendCapability) -> Self {
269 self.capabilities.insert(backend, cap);
270 self
271 }
272}
273
274#[async_trait]
275impl BackendRouter for CapabilityRouter {
276 async fn route(
277 &self,
278 _config: &SpawnConfig,
279 available: &[BackendType],
280 ) -> Option<BackendType> {
281 let latency_weight = 1.0 - self.cost_weight;
282
283 available
285 .iter()
286 .filter(|bt| {
287 if let Some(cap) = self.capabilities.get(bt) {
288 !self.require_multi_turn || cap.multi_turn
289 } else {
290 !self.require_multi_turn
292 }
293 })
294 .min_by(|a, b| {
295 let score = |bt: &BackendType| -> f32 {
296 if let Some(cap) = self.capabilities.get(bt) {
297 self.cost_weight * cap.cost_tier as f32
298 + latency_weight * cap.latency_tier as f32
299 } else {
300 self.cost_weight * 2.0 + latency_weight * 2.0
302 }
303 };
304 score(a)
305 .partial_cmp(&score(b))
306 .unwrap_or(std::cmp::Ordering::Equal)
307 })
308 .copied()
309 }
310}
311
312pub struct ChainRouter {
335 routers: Vec<Box<dyn BackendRouter>>,
336}
337
338impl std::fmt::Debug for ChainRouter {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 f.debug_struct("ChainRouter")
341 .field("routers_count", &self.routers.len())
342 .finish()
343 }
344}
345
346impl ChainRouter {
347 pub fn new() -> Self {
349 Self {
350 routers: Vec::new(),
351 }
352 }
353
354 pub fn push(mut self, router: impl BackendRouter + 'static) -> Self {
356 self.routers.push(Box::new(router));
357 self
358 }
359}
360
361impl Default for ChainRouter {
362 fn default() -> Self {
363 Self::new()
364 }
365}
366
367#[async_trait]
368impl BackendRouter for ChainRouter {
369 async fn route(
370 &self,
371 config: &SpawnConfig,
372 available: &[BackendType],
373 ) -> Option<BackendType> {
374 for router in &self.routers {
375 if let Some(bt) = router.route(config, available).await {
376 return Some(bt);
377 }
378 }
379 None
380 }
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum PromptComplexity {
390 Simple,
392 Medium,
394 Complex,
396}
397
398#[derive(Debug)]
427pub struct SmartRouter {
428 priorities: Vec<(String, BackendType)>,
430 simple: Option<BackendType>,
432 medium: Option<BackendType>,
434 complex: Option<BackendType>,
436 simple_threshold: usize,
438 complex_threshold: usize,
440 capability: CapabilityRouter,
442 default: BackendType,
444}
445
446impl SmartRouter {
447 pub fn new(default: BackendType) -> Self {
449 Self {
450 priorities: Vec::new(),
451 simple: None,
452 medium: None,
453 complex: None,
454 simple_threshold: 200,
455 complex_threshold: 800,
456 capability: CapabilityRouter::new(),
457 default,
458 }
459 }
460
461 pub fn priority(mut self, keyword: impl Into<String>, backend: BackendType) -> Self {
466 self.priorities.push((keyword.into().to_lowercase(), backend));
467 self
468 }
469
470 pub fn simple_backend(mut self, backend: BackendType) -> Self {
472 self.simple = Some(backend);
473 self
474 }
475
476 pub fn medium_backend(mut self, backend: BackendType) -> Self {
478 self.medium = Some(backend);
479 self
480 }
481
482 pub fn complex_backend(mut self, backend: BackendType) -> Self {
484 self.complex = Some(backend);
485 self
486 }
487
488 pub fn complexity_threshold(mut self, simple: usize, complex: usize) -> Self {
500 debug_assert!(simple < complex, "simple threshold must be less than complex threshold");
501 self.simple_threshold = simple;
502 self.complex_threshold = complex;
503 self
504 }
505
506 pub fn cost_weight(mut self, weight: f32) -> Self {
508 self.capability = self.capability.cost_weight(weight);
509 self
510 }
511
512 pub fn require_multi_turn(mut self, require: bool) -> Self {
514 self.capability = self.capability.require_multi_turn(require);
515 self
516 }
517
518 pub fn with_capability(mut self, backend: BackendType, cap: BackendCapability) -> Self {
520 self.capability = self.capability.with_capability(backend, cap);
521 self
522 }
523
524 pub fn analyze_complexity(&self, prompt: &str) -> PromptComplexity {
526 let len = prompt.len();
527
528 let has_code = prompt.contains("```")
531 || prompt.contains("fn ")
532 || prompt.contains("def ")
533 || prompt.contains("class ")
534 || prompt.contains("impl ")
535 || prompt.contains("struct ")
536 || prompt.contains("import ")
537 || prompt.contains("#include")
538 || prompt.contains("function ")
539 || prompt.contains("async fn");
540
541 let paragraph_count = prompt.split("\n\n").count();
543
544 if len >= self.complex_threshold || (has_code && len >= self.simple_threshold) || paragraph_count >= 4 {
545 PromptComplexity::Complex
546 } else if len >= self.simple_threshold || has_code || paragraph_count >= 2 {
547 PromptComplexity::Medium
548 } else {
549 PromptComplexity::Simple
550 }
551 }
552}
553
554#[async_trait]
555impl BackendRouter for SmartRouter {
556 async fn route(
557 &self,
558 config: &SpawnConfig,
559 available: &[BackendType],
560 ) -> Option<BackendType> {
561 if available.is_empty() {
562 return None;
563 }
564
565 let prompt_lower = config.prompt.to_lowercase();
566
567 for (keyword, backend) in &self.priorities {
569 if contains_word(&prompt_lower, keyword.as_str()) && available.contains(backend) {
570 return Some(*backend);
571 }
572 }
573
574 let complexity = self.analyze_complexity(&config.prompt);
576 let preferred = match complexity {
577 PromptComplexity::Simple => self.simple,
578 PromptComplexity::Medium => self.medium,
579 PromptComplexity::Complex => self.complex,
580 };
581 if let Some(backend) = preferred {
582 if available.contains(&backend) {
583 return Some(backend);
584 }
585 }
586
587 if let Some(bt) = self.capability.route(config, available).await {
589 return Some(bt);
590 }
591
592 if available.contains(&self.default) {
594 Some(self.default)
595 } else {
596 available.first().copied()
597 }
598 }
599}
600
601#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[tokio::test]
610 async fn keyword_router_matches_first_rule() {
611 let router = KeywordRouter::new(BackendType::ClaudeCode)
612 .rule("review", BackendType::GeminiCli)
613 .rule("implement", BackendType::ClaudeCode)
614 .rule("test", BackendType::Codex);
615
616 let config = SpawnConfig::new("reviewer", "Please review this code carefully.");
617 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
618
619 let result = router.route(&config, &available).await;
620 assert_eq!(result, Some(BackendType::GeminiCli));
621 }
622
623 #[tokio::test]
624 async fn keyword_router_falls_back_to_default() {
625 let router = KeywordRouter::new(BackendType::Codex)
626 .rule("review", BackendType::GeminiCli);
627
628 let config = SpawnConfig::new("worker", "Do something unrelated.");
629 let available = vec![BackendType::ClaudeCode, BackendType::Codex];
630
631 let result = router.route(&config, &available).await;
632 assert_eq!(result, Some(BackendType::Codex));
633 }
634
635 #[tokio::test]
636 async fn keyword_router_skips_unavailable_backend() {
637 let router = KeywordRouter::new(BackendType::ClaudeCode)
638 .rule("review", BackendType::GeminiCli);
639
640 let config = SpawnConfig::new("reviewer", "Please review this code.");
641 let available = vec![BackendType::ClaudeCode, BackendType::Codex];
643
644 let result = router.route(&config, &available).await;
645 assert_eq!(result, Some(BackendType::ClaudeCode));
647 }
648
649 #[tokio::test]
650 async fn keyword_router_case_insensitive() {
651 let router = KeywordRouter::new(BackendType::ClaudeCode)
652 .rule("REVIEW", BackendType::GeminiCli);
653
654 let config = SpawnConfig::new("reviewer", "review this code");
655 let available = vec![BackendType::ClaudeCode, BackendType::GeminiCli];
656
657 let result = router.route(&config, &available).await;
658 assert_eq!(result, Some(BackendType::GeminiCli));
659 }
660
661 #[tokio::test]
662 async fn keyword_router_returns_none_for_empty_available() {
663 let router = KeywordRouter::new(BackendType::ClaudeCode);
664 let config = SpawnConfig::new("worker", "Do something.");
665 let result = router.route(&config, &[]).await;
666 assert_eq!(result, None);
667 }
668
669 #[tokio::test]
670 async fn capability_router_prefers_cheapest() {
671 let router = CapabilityRouter::new()
672 .cost_weight(1.0); let config = SpawnConfig::new("worker", "Do a simple task.");
675 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
676
677 let result = router.route(&config, &available).await;
678 assert_eq!(result, Some(BackendType::GeminiCli)); }
680
681 #[tokio::test]
682 async fn capability_router_multi_turn_requirement() {
683 let router = CapabilityRouter::new()
684 .require_multi_turn(true)
685 .cost_weight(1.0); let config = SpawnConfig::new("worker", "Multi-turn session.");
688 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
689
690 let result = router.route(&config, &available).await;
691 assert_eq!(result, Some(BackendType::Codex));
693 }
694
695 #[tokio::test]
696 async fn capability_router_prefers_fastest() {
697 let router = CapabilityRouter::new()
698 .cost_weight(0.0); let config = SpawnConfig::new("worker", "Quick task.");
701 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
702
703 let result = router.route(&config, &available).await;
704 assert_eq!(result, Some(BackendType::ClaudeCode));
706 }
707
708 #[tokio::test]
709 async fn capability_router_returns_none_for_empty_available() {
710 let router = CapabilityRouter::new();
711 let config = SpawnConfig::new("worker", "Do something.");
712 let result = router.route(&config, &[]).await;
713 assert_eq!(result, None);
714 }
715
716 #[test]
721 fn contains_word_exact_match() {
722 assert!(super::contains_word("review this code", "review"));
723 assert!(super::contains_word("please review", "review"));
724 assert!(super::contains_word("review", "review"));
725 }
726
727 #[test]
728 fn contains_word_rejects_substring() {
729 assert!(!super::contains_word("reviewing this code", "review"));
730 assert!(!super::contains_word("code reviewer", "review"));
731 assert!(!super::contains_word("prereview step", "review"));
732 }
733
734 #[test]
735 fn contains_word_with_punctuation() {
736 assert!(super::contains_word("please review, thanks", "review"));
737 assert!(super::contains_word("review.", "review"));
738 assert!(super::contains_word("(review)", "review"));
739 }
740
741 #[test]
742 fn contains_word_test_vs_testing() {
743 assert!(super::contains_word("test this function", "test"));
744 assert!(!super::contains_word("testing this function", "test"));
745 assert!(!super::contains_word("run the contest", "test"));
746 }
747
748 #[test]
749 fn contains_word_edge_cases() {
750 assert!(!super::contains_word("", "review"));
752 assert!(!super::contains_word("some text", ""));
753 assert!(!super::contains_word("", ""));
754
755 assert!(super::contains_word("a quick task", "a"));
757 assert!(!super::contains_word("analyze this", "a"));
758
759 assert!(super::contains_word("do a test", "test"));
761
762 assert!(super::contains_word("testing the test", "test"));
764
765 assert!(super::contains_word("run unit-test now", "test"));
767
768 assert!(super::contains_word("café test", "test"));
771 assert!(super::contains_word("café", "café"));
772 assert!(!super::contains_word("acafé", "café"));
774 assert!(super::contains_word("aéb test éb", "éb"));
776 }
777
778 #[tokio::test]
779 async fn keyword_router_word_boundary_mode() {
780 let router = KeywordRouter::new(BackendType::ClaudeCode)
781 .word_boundary(true)
782 .rule("test", BackendType::Codex);
783
784 let available = vec![BackendType::ClaudeCode, BackendType::Codex];
785
786 let config = SpawnConfig::new("w", "test this function");
788 assert_eq!(router.route(&config, &available).await, Some(BackendType::Codex));
789
790 let config = SpawnConfig::new("w", "testing this function");
792 assert_eq!(router.route(&config, &available).await, Some(BackendType::ClaudeCode));
793 }
794
795 #[tokio::test]
800 async fn chain_router_first_match_wins() {
801 let keyword = KeywordRouter::new(BackendType::ClaudeCode)
802 .rule("review", BackendType::GeminiCli);
803 let capability = CapabilityRouter::new().cost_weight(1.0);
804
805 let router = ChainRouter::new().push(keyword).push(capability);
806
807 let config = SpawnConfig::new("w", "review this code");
808 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
809
810 assert_eq!(router.route(&config, &available).await, Some(BackendType::GeminiCli));
812 }
813
814 #[tokio::test]
815 async fn chain_router_falls_through_to_second() {
816 let keyword = KeywordRouter::new(BackendType::ClaudeCode)
817 .word_boundary(true)
818 .rule("review", BackendType::GeminiCli);
819 let capability = CapabilityRouter::new().cost_weight(1.0);
820
821 let router = ChainRouter::new().push(keyword).push(capability);
822
823 let config = SpawnConfig::new("w", "do a simple task");
825 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
826
827 let result = router.route(&config, &available).await;
831 assert_eq!(result, Some(BackendType::ClaudeCode));
832 }
833
834 #[tokio::test]
835 async fn chain_router_empty_returns_none() {
836 let router = ChainRouter::new();
837 let config = SpawnConfig::new("w", "anything");
838 let result = router.route(&config, &[BackendType::ClaudeCode]).await;
839 assert_eq!(result, None);
840 }
841
842 #[test]
847 fn backend_capability_default() {
848 let cap = BackendCapability::default();
849 assert!(cap.multi_turn);
850 assert!(cap.streaming);
851 assert_eq!(cap.cost_tier, 2);
852 assert_eq!(cap.latency_tier, 2);
853 }
854
855 #[test]
856 fn capability_router_with_custom_capability() {
857 let cap = BackendCapability {
858 cost_tier: 1,
859 ..Default::default()
860 };
861 assert!(cap.multi_turn); assert_eq!(cap.cost_tier, 1); }
864
865 #[test]
866 fn routers_are_debug() {
867 let kw = KeywordRouter::new(BackendType::ClaudeCode).rule("test", BackendType::Codex);
868 assert!(format!("{kw:?}").contains("KeywordRouter"));
869
870 let cap = CapabilityRouter::new();
871 assert!(format!("{cap:?}").contains("CapabilityRouter"));
872
873 let chain = ChainRouter::new().push(KeywordRouter::new(BackendType::ClaudeCode));
874 let debug = format!("{chain:?}");
875 assert!(debug.contains("ChainRouter"));
876 assert!(debug.contains("routers_count"));
877
878 let smart = SmartRouter::new(BackendType::ClaudeCode);
879 assert!(format!("{smart:?}").contains("SmartRouter"));
880 }
881
882 #[test]
887 fn smart_router_complexity_simple() {
888 let router = SmartRouter::new(BackendType::ClaudeCode);
889 let complexity = router.analyze_complexity("Fix the bug.");
890 assert_eq!(complexity, PromptComplexity::Simple);
891 }
892
893 #[test]
894 fn smart_router_complexity_medium_by_length() {
895 let router = SmartRouter::new(BackendType::ClaudeCode);
896 let prompt = "a".repeat(250); let complexity = router.analyze_complexity(&prompt);
898 assert_eq!(complexity, PromptComplexity::Medium);
899 }
900
901 #[test]
902 fn smart_router_complexity_medium_by_code() {
903 let router = SmartRouter::new(BackendType::ClaudeCode);
904 let complexity = router.analyze_complexity("Please add fn main() here");
905 assert_eq!(complexity, PromptComplexity::Medium);
906 }
907
908 #[test]
909 fn smart_router_complexity_complex_by_length() {
910 let router = SmartRouter::new(BackendType::ClaudeCode);
911 let prompt = "a".repeat(900); let complexity = router.analyze_complexity(&prompt);
913 assert_eq!(complexity, PromptComplexity::Complex);
914 }
915
916 #[test]
917 fn smart_router_complexity_complex_by_code_and_length() {
918 let router = SmartRouter::new(BackendType::ClaudeCode);
919 let prompt = format!("Please implement this:\n```rust\nfn main() {{}}\n```\n{}", "x".repeat(200));
921 let complexity = router.analyze_complexity(&prompt);
922 assert_eq!(complexity, PromptComplexity::Complex);
923 }
924
925 #[test]
926 fn smart_router_complexity_complex_by_paragraphs() {
927 let router = SmartRouter::new(BackendType::ClaudeCode);
928 let prompt = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n\nFourth paragraph.";
929 let complexity = router.analyze_complexity(prompt);
930 assert_eq!(complexity, PromptComplexity::Complex);
931 }
932
933 #[tokio::test]
934 async fn smart_router_priority_wins() {
935 let router = SmartRouter::new(BackendType::Codex)
936 .priority("security audit", BackendType::ClaudeCode)
937 .simple_backend(BackendType::GeminiCli);
938
939 let config = SpawnConfig::new("w", "Run a security audit on this module");
940 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
941
942 assert_eq!(router.route(&config, &available).await, Some(BackendType::ClaudeCode));
944 }
945
946 #[tokio::test]
947 async fn smart_router_complexity_routing_simple() {
948 let router = SmartRouter::new(BackendType::Codex)
949 .simple_backend(BackendType::GeminiCli);
950
951 let config = SpawnConfig::new("w", "Fix the typo.");
952 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
953
954 assert_eq!(router.route(&config, &available).await, Some(BackendType::GeminiCli));
956 }
957
958 #[tokio::test]
959 async fn smart_router_complexity_routing_complex() {
960 let router = SmartRouter::new(BackendType::Codex)
961 .complex_backend(BackendType::ClaudeCode);
962
963 let long_prompt = "a".repeat(900);
964 let config = SpawnConfig::new("w", &long_prompt);
965 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
966
967 assert_eq!(router.route(&config, &available).await, Some(BackendType::ClaudeCode));
969 }
970
971 #[tokio::test]
972 async fn smart_router_falls_back_to_capability() {
973 let router = SmartRouter::new(BackendType::Codex)
975 .cost_weight(1.0); let config = SpawnConfig::new("w", "Do a task.");
978 let available = vec![BackendType::ClaudeCode, BackendType::Codex, BackendType::GeminiCli];
979
980 assert_eq!(router.route(&config, &available).await, Some(BackendType::GeminiCli));
982 }
983
984 #[tokio::test]
985 async fn smart_router_priority_skips_unavailable() {
986 let router = SmartRouter::new(BackendType::Codex)
987 .priority("audit", BackendType::ClaudeCode)
988 .simple_backend(BackendType::GeminiCli);
989
990 let config = SpawnConfig::new("w", "Run an audit");
991 let available = vec![BackendType::Codex, BackendType::GeminiCli];
993
994 assert_eq!(router.route(&config, &available).await, Some(BackendType::GeminiCli));
996 }
997
998 #[tokio::test]
999 async fn smart_router_returns_none_for_empty() {
1000 let router = SmartRouter::new(BackendType::ClaudeCode);
1001 let config = SpawnConfig::new("w", "anything");
1002 assert_eq!(router.route(&config, &[]).await, None);
1003 }
1004
1005 #[tokio::test]
1006 async fn smart_router_default_fallback() {
1007 let router = SmartRouter::new(BackendType::Codex);
1010
1011 let config = SpawnConfig::new("w", "Short task.");
1012 let available = vec![BackendType::Codex];
1013
1014 assert_eq!(router.route(&config, &available).await, Some(BackendType::Codex));
1016 }
1017
1018 #[test]
1019 fn smart_router_custom_thresholds() {
1020 let router = SmartRouter::new(BackendType::ClaudeCode)
1021 .complexity_threshold(50, 300);
1022
1023 assert_eq!(router.analyze_complexity(&"a".repeat(60)), PromptComplexity::Medium);
1025 assert_eq!(router.analyze_complexity(&"a".repeat(30)), PromptComplexity::Simple);
1027 assert_eq!(router.analyze_complexity(&"a".repeat(400)), PromptComplexity::Complex);
1029 }
1030}