1use super::ccs::CcsAliasResolver;
34use super::config::{AgentConfig, AgentConfigError, AgentsConfigFile, DEFAULT_AGENTS_TOML};
35use super::fallback::{AgentRole, FallbackConfig};
36use super::opencode_resolver::OpenCodeResolver;
37use super::parser::JsonParserType;
38use super::retry_timer::{production_timer, RetryTimerProvider};
39use crate::agents::opencode_api::ApiCatalog;
40use crate::config::{CcsAliasConfig, CcsConfig};
41use std::collections::HashMap;
42use std::path::Path;
43use std::sync::Arc;
44
45pub struct AgentRegistry {
54 agents: HashMap<String, AgentConfig>,
55 fallback: FallbackConfig,
56 ccs_resolver: CcsAliasResolver,
58 opencode_resolver: Option<OpenCodeResolver>,
60 retry_timer: Arc<dyn RetryTimerProvider>,
62}
63
64impl AgentRegistry {
65 pub fn new() -> Result<Self, AgentConfigError> {
67 let AgentsConfigFile { agents, fallback } =
68 toml::from_str(DEFAULT_AGENTS_TOML).map_err(AgentConfigError::DefaultTemplateToml)?;
69
70 let mut registry = Self {
71 agents: HashMap::new(),
72 fallback,
73 ccs_resolver: CcsAliasResolver::empty(),
74 opencode_resolver: None,
75 retry_timer: production_timer(),
76 };
77
78 for (name, agent_toml) in agents {
79 registry.register(&name, AgentConfig::from(agent_toml));
80 }
81
82 Ok(registry)
83 }
84
85 pub fn set_opencode_catalog(&mut self, catalog: ApiCatalog) {
89 self.opencode_resolver = Some(OpenCodeResolver::new(catalog));
90 }
91
92 pub fn set_ccs_aliases(
97 &mut self,
98 aliases: &HashMap<String, CcsAliasConfig>,
99 defaults: CcsConfig,
100 ) {
101 self.ccs_resolver = CcsAliasResolver::new(aliases.clone(), defaults);
102 for alias_name in aliases.keys() {
104 let agent_name = format!("ccs/{alias_name}");
105 if let Some(config) = self.ccs_resolver.try_resolve(&agent_name) {
106 self.agents.insert(agent_name, config);
107 }
108 }
109 }
110
111 pub fn register(&mut self, name: &str, config: AgentConfig) {
113 self.agents.insert(name.to_string(), config);
114 }
115
116 #[cfg(feature = "test-utils")]
125 #[must_use]
126 pub fn with_builtins_only() -> Self {
127 Self::new().expect("Built-in agents should always be valid")
128 }
129
130 pub fn resolve_config(&self, name: &str) -> Option<AgentConfig> {
138 self.agents
139 .get(name)
140 .cloned()
141 .or_else(|| self.ccs_resolver.try_resolve(name))
142 .or_else(|| {
143 self.opencode_resolver
144 .as_ref()
145 .and_then(|r| r.try_resolve(name))
146 })
147 }
148
149 pub fn display_name(&self, name: &str) -> String {
165 self.resolve_config(name)
166 .and_then(|config| config.display_name)
167 .unwrap_or_else(|| name.to_string())
168 }
169
170 pub fn resolve_from_logfile_name(&self, logfile_name: &str) -> Option<String> {
204 if self.agents.contains_key(logfile_name) {
206 return Some(logfile_name.to_string());
207 }
208
209 for name in self.agents.keys() {
211 let sanitized = name.replace('/', "-");
212 if sanitized == logfile_name {
213 return Some(name.clone());
214 }
215 }
216
217 if let Some(alias) = logfile_name.strip_prefix("ccs-") {
220 let registry_name = format!("ccs/{}", alias);
221 return Some(registry_name);
223 }
224
225 if let Some(rest) = logfile_name.strip_prefix("opencode-") {
232 if let Some(first_hyphen) = rest.find('-') {
233 let provider = &rest[..first_hyphen];
234 let model = &rest[first_hyphen + 1..];
235 let registry_name = format!("opencode/{}/{}", provider, model);
236 return Some(registry_name);
237 }
238 }
239
240 None
242 }
243
244 pub fn resolve_fuzzy(&self, name: &str) -> Option<String> {
254 if self.agents.contains_key(name) {
256 return Some(name.to_string());
257 }
258
259 if name.starts_with("ccs/") {
261 return Some(name.to_string());
262 }
263
264 if name.starts_with("opencode/") {
266 let parts: Vec<&str> = name.split('/').collect();
268 if parts.len() == 3 && parts[0] == "opencode" {
269 return Some(name.to_string());
270 }
271 }
272
273 let normalized = name.to_lowercase();
275 let alternatives = Self::get_fuzzy_alternatives(&normalized);
276
277 for alt in alternatives {
278 if alt.starts_with("ccs/") {
280 return Some(alt);
281 }
282 if alt.starts_with("opencode/") {
284 let parts: Vec<&str> = alt.split('/').collect();
285 if parts.len() == 3 && parts[0] == "opencode" {
286 return Some(alt);
287 }
288 }
289 if self.agents.contains_key(&alt) {
291 return Some(alt);
292 }
293 }
294
295 None
296 }
297
298 pub(crate) fn get_fuzzy_alternatives(name: &str) -> Vec<String> {
302 let mut alternatives = Vec::new();
303
304 alternatives.push(name.to_string());
306
307 match name {
309 n if n.starts_with("ccs-") => {
311 alternatives.push(name.replace("ccs-", "ccs/"));
312 }
313 n if n.contains('_') => {
314 alternatives.push(name.replace('_', "-"));
315 alternatives.push(name.replace('_', "/"));
316 }
317
318 "claud" | "cloud" => alternatives.push("claude".to_string()),
320
321 "codeex" | "code-x" => alternatives.push("codex".to_string()),
323
324 "crusor" => alternatives.push("cursor".to_string()),
326
327 "opencode" | "open-code" => alternatives.push("opencode".to_string()),
329
330 "gemeni" | "gemni" => alternatives.push("gemini".to_string()),
332
333 "quen" | "quwen" => alternatives.push("qwen".to_string()),
335
336 "ader" => alternatives.push("aider".to_string()),
338
339 "vib" => alternatives.push("vibe".to_string()),
341
342 "kline" => alternatives.push("cline".to_string()),
344
345 _ => {}
346 }
347
348 alternatives
349 }
350
351 pub fn list(&self) -> Vec<(&str, &AgentConfig)> {
353 self.agents.iter().map(|(k, v)| (k.as_str(), v)).collect()
354 }
355
356 pub fn developer_cmd(&self, agent_name: &str) -> Option<String> {
358 self.resolve_config(agent_name)
359 .map(|c| c.build_cmd(true, true, true))
360 }
361
362 pub fn reviewer_cmd(&self, agent_name: &str) -> Option<String> {
364 self.resolve_config(agent_name)
365 .map(|c| c.build_cmd(true, true, false))
366 }
367
368 pub fn load_from_file<P: AsRef<Path>>(&mut self, path: P) -> Result<usize, AgentConfigError> {
370 match AgentsConfigFile::load_from_file(path)? {
371 Some(config) => {
372 let count = config.agents.len();
373 for (name, agent_toml) in config.agents {
374 self.register(&name, AgentConfig::from(agent_toml));
375 }
376 self.fallback = config.fallback;
378 Ok(count)
379 }
380 None => Ok(0),
381 }
382 }
383
384 pub fn apply_unified_config(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
392 let mut loaded = self.apply_ccs_aliases(unified);
393 loaded += self.apply_agent_overrides(unified);
394
395 if let Some(chain) = &unified.agent_chain {
396 self.fallback = chain.clone();
397 }
398
399 loaded
400 }
401
402 fn apply_ccs_aliases(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
404 if unified.ccs_aliases.is_empty() {
405 return 0;
406 }
407
408 let loaded = unified.ccs_aliases.len();
409 let aliases = unified
410 .ccs_aliases
411 .iter()
412 .map(|(name, v)| (name.clone(), v.as_config()))
413 .collect::<HashMap<_, _>>();
414 self.set_ccs_aliases(&aliases, unified.ccs.clone());
415 loaded
416 }
417
418 fn apply_agent_overrides(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
420 if unified.agents.is_empty() {
421 return 0;
422 }
423
424 let mut loaded = 0usize;
425 for (name, overrides) in &unified.agents {
426 if let Some(existing) = self.agents.get(name).cloned() {
427 let merged = Self::merge_agent_config(existing, overrides);
429 self.register(name, merged);
430 loaded += 1;
431 } else {
432 if let Some(config) = Self::create_new_agent_config(overrides) {
434 self.register(name, config);
435 loaded += 1;
436 }
437 }
438 }
439 loaded
440 }
441
442 fn create_new_agent_config(
444 overrides: &crate::config::unified::AgentConfigToml,
445 ) -> Option<AgentConfig> {
446 let cmd = overrides
447 .cmd
448 .as_deref()
449 .map(str::trim)
450 .filter(|s| !s.is_empty())?;
451
452 let json_parser = overrides
453 .json_parser
454 .as_deref()
455 .map(str::trim)
456 .filter(|s| !s.is_empty())
457 .unwrap_or("generic");
458
459 Some(AgentConfig {
460 cmd: cmd.to_string(),
461 output_flag: overrides.output_flag.clone().unwrap_or_default(),
462 yolo_flag: overrides.yolo_flag.clone().unwrap_or_default(),
463 verbose_flag: overrides.verbose_flag.clone().unwrap_or_default(),
464 can_commit: overrides.can_commit.unwrap_or(true),
465 json_parser: JsonParserType::parse(json_parser),
466 model_flag: overrides.model_flag.clone(),
467 print_flag: overrides.print_flag.clone().unwrap_or_default(),
468 streaming_flag: overrides.streaming_flag.clone().unwrap_or_else(|| {
469 if cmd.starts_with("claude") || cmd.starts_with("ccs") {
471 "--include-partial-messages".to_string()
472 } else {
473 String::new()
474 }
475 }),
476 session_flag: overrides.session_flag.clone().unwrap_or_else(|| {
477 if cmd.starts_with("claude") || cmd.starts_with("ccs") {
483 "--resume {}".to_string()
484 } else if cmd.starts_with("opencode") {
485 "-s {}".to_string()
486 } else {
487 String::new()
488 }
489 }),
490 env_vars: std::collections::HashMap::new(),
491 display_name: overrides
492 .display_name
493 .as_ref()
494 .filter(|s| !s.is_empty())
495 .cloned(),
496 })
497 }
498
499 fn merge_agent_config(
501 existing: AgentConfig,
502 overrides: &crate::config::unified::AgentConfigToml,
503 ) -> AgentConfig {
504 AgentConfig {
505 cmd: overrides
506 .cmd
507 .as_deref()
508 .map(str::trim)
509 .filter(|s| !s.is_empty())
510 .map(str::to_string)
511 .unwrap_or(existing.cmd),
512 output_flag: overrides
513 .output_flag
514 .clone()
515 .unwrap_or(existing.output_flag),
516 yolo_flag: overrides.yolo_flag.clone().unwrap_or(existing.yolo_flag),
517 verbose_flag: overrides
518 .verbose_flag
519 .clone()
520 .unwrap_or(existing.verbose_flag),
521 can_commit: overrides.can_commit.unwrap_or(existing.can_commit),
522 json_parser: overrides
523 .json_parser
524 .as_deref()
525 .map(str::trim)
526 .filter(|s| !s.is_empty())
527 .map_or(existing.json_parser, JsonParserType::parse),
528 model_flag: overrides.model_flag.clone().or(existing.model_flag),
529 print_flag: overrides.print_flag.clone().unwrap_or(existing.print_flag),
530 streaming_flag: overrides
531 .streaming_flag
532 .clone()
533 .unwrap_or(existing.streaming_flag),
534 session_flag: overrides
535 .session_flag
536 .clone()
537 .unwrap_or(existing.session_flag),
538 env_vars: std::collections::HashMap::new(),
543 display_name: match &overrides.display_name {
546 Some(s) if s.is_empty() => None,
547 Some(s) => Some(s.clone()),
548 None => existing.display_name,
549 },
550 }
551 }
552
553 pub const fn fallback_config(&self) -> &FallbackConfig {
555 &self.fallback
556 }
557
558 pub fn retry_timer(&self) -> Arc<dyn RetryTimerProvider> {
560 Arc::clone(&self.retry_timer)
561 }
562
563 #[cfg(any(test, feature = "test-utils"))]
568 pub fn set_retry_timer(&mut self, timer: Arc<dyn RetryTimerProvider>) {
569 self.retry_timer = timer;
570 }
571
572 pub fn available_fallbacks(&self, role: AgentRole) -> Vec<&str> {
574 self.fallback
575 .get_fallbacks(role)
576 .iter()
577 .filter(|name| self.is_agent_available(name))
578 .filter(|name| {
580 self.resolve_config(name.as_str())
581 .is_some_and(|cfg| cfg.can_commit)
582 })
583 .map(std::string::String::as_str)
584 .collect()
585 }
586
587 pub fn validate_agent_chains(&self) -> Result<(), String> {
589 let has_developer = self.fallback.has_fallbacks(AgentRole::Developer);
590 let has_reviewer = self.fallback.has_fallbacks(AgentRole::Reviewer);
591
592 if !has_developer && !has_reviewer {
593 return Err("No agent chain configured.\n\
594 Please add an [agent_chain] section to ~/.config/ralph-workflow.toml.\n\
595 Run 'ralph --init-global' to create a default configuration."
596 .to_string());
597 }
598
599 if !has_developer {
600 return Err("No developer agent chain configured.\n\
601 Add 'developer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
602 Use --list-agents to see available agents."
603 .to_string());
604 }
605
606 if !has_reviewer {
607 return Err("No reviewer agent chain configured.\n\
608 Add 'reviewer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
609 Use --list-agents to see available agents."
610 .to_string());
611 }
612
613 for role in [AgentRole::Developer, AgentRole::Reviewer] {
615 let chain = self.fallback.get_fallbacks(role);
616 let has_capable = chain
617 .iter()
618 .any(|name| self.resolve_config(name).is_some_and(|cfg| cfg.can_commit));
619 if !has_capable {
620 return Err(format!(
621 "No workflow-capable agents found for {role}.\n\
622 All agents in the {role} chain have can_commit=false.\n\
623 Fix: set can_commit=true for at least one agent or update [agent_chain]."
624 ));
625 }
626 }
627
628 Ok(())
629 }
630
631 pub fn is_agent_available(&self, name: &str) -> bool {
633 if let Some(config) = self.resolve_config(name) {
634 let Ok(parts) = crate::common::split_command(&config.cmd) else {
635 return false;
636 };
637 let Some(base_cmd) = parts.first() else {
638 return false;
639 };
640
641 which::which(base_cmd).is_ok()
643 } else {
644 false
645 }
646 }
647
648 pub fn list_available(&self) -> Vec<&str> {
650 self.agents
651 .keys()
652 .filter(|name| self.is_agent_available(name))
653 .map(std::string::String::as_str)
654 .collect()
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::agents::JsonParserType;
662
663 fn default_ccs() -> CcsConfig {
664 CcsConfig::default()
665 }
666
667 #[test]
668 fn test_registry_new() {
669 let registry = AgentRegistry::new().unwrap();
670 assert!(registry.resolve_config("claude").is_some());
672 assert!(registry.resolve_config("codex").is_some());
673 }
674
675 #[test]
676 fn test_registry_register() {
677 let mut registry = AgentRegistry::new().unwrap();
678 registry.register(
679 "testbot",
680 AgentConfig {
681 cmd: "testbot run".to_string(),
682 output_flag: "--json".to_string(),
683 yolo_flag: "--yes".to_string(),
684 verbose_flag: String::new(),
685 can_commit: true,
686 json_parser: JsonParserType::Generic,
687 model_flag: None,
688 print_flag: String::new(),
689 streaming_flag: String::new(),
690 session_flag: String::new(),
691 env_vars: std::collections::HashMap::new(),
692 display_name: None,
693 },
694 );
695 assert!(registry.resolve_config("testbot").is_some());
697 }
698
699 #[test]
700 fn test_registry_display_name() {
701 let mut registry = AgentRegistry::new().unwrap();
702
703 registry.register(
705 "claude",
706 AgentConfig {
707 cmd: "claude -p".to_string(),
708 output_flag: "--output-format=stream-json".to_string(),
709 yolo_flag: "--dangerously-skip-permissions".to_string(),
710 verbose_flag: "--verbose".to_string(),
711 can_commit: true,
712 json_parser: JsonParserType::Claude,
713 model_flag: None,
714 print_flag: String::new(),
715 streaming_flag: "--include-partial-messages".to_string(),
716 session_flag: "--resume {}".to_string(),
717 env_vars: std::collections::HashMap::new(),
718 display_name: None,
719 },
720 );
721
722 registry.register(
724 "claude",
725 AgentConfig {
726 cmd: "claude -p".to_string(),
727 output_flag: "--output-format=stream-json".to_string(),
728 yolo_flag: "--dangerously-skip-permissions".to_string(),
729 verbose_flag: "--verbose".to_string(),
730 can_commit: true,
731 json_parser: JsonParserType::Claude,
732 model_flag: None,
733 print_flag: String::new(),
734 streaming_flag: "--include-partial-messages".to_string(),
735 session_flag: "--resume {}".to_string(),
736 env_vars: std::collections::HashMap::new(),
737 display_name: None,
738 },
739 );
740
741 assert_eq!(registry.display_name("claude"), "claude");
743 assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
744
745 assert_eq!(registry.display_name("unknown"), "unknown");
747 }
748
749 #[test]
750 fn test_resolve_from_logfile_name() {
751 let mut registry = AgentRegistry::new().unwrap();
752
753 registry.register(
755 "ccs/glm",
756 AgentConfig {
757 cmd: "ccs glm".to_string(),
758 output_flag: "--output-format=stream-json".to_string(),
759 yolo_flag: "--dangerously-skip-permissions".to_string(),
760 verbose_flag: "--verbose".to_string(),
761 can_commit: true,
762 json_parser: JsonParserType::Claude,
763 model_flag: None,
764 print_flag: "-p".to_string(),
765 streaming_flag: "--include-partial-messages".to_string(),
766 session_flag: "--resume {}".to_string(),
767 env_vars: std::collections::HashMap::new(),
768 display_name: Some("ccs-glm".to_string()),
769 },
770 );
771
772 registry.register(
774 "claude",
775 AgentConfig {
776 cmd: "claude -p".to_string(),
777 output_flag: "--output-format=stream-json".to_string(),
778 yolo_flag: "--dangerously-skip-permissions".to_string(),
779 verbose_flag: "--verbose".to_string(),
780 can_commit: true,
781 json_parser: JsonParserType::Claude,
782 model_flag: None,
783 print_flag: String::new(),
784 streaming_flag: "--include-partial-messages".to_string(),
785 session_flag: "--resume {}".to_string(),
786 env_vars: std::collections::HashMap::new(),
787 display_name: None,
788 },
789 );
790
791 registry.register(
793 "opencode/anthropic/claude-sonnet-4",
794 AgentConfig {
795 cmd: "opencode run".to_string(),
796 output_flag: "--format json".to_string(),
797 yolo_flag: String::new(),
798 verbose_flag: "--log-level DEBUG".to_string(),
799 can_commit: true,
800 json_parser: JsonParserType::OpenCode,
801 model_flag: Some("-p anthropic -m claude-sonnet-4".to_string()),
802 print_flag: String::new(),
803 streaming_flag: String::new(),
804 session_flag: "-s {}".to_string(),
805 env_vars: std::collections::HashMap::new(),
806 display_name: Some("OpenCode (anthropic)".to_string()),
807 },
808 );
809
810 assert_eq!(
812 registry.resolve_from_logfile_name("claude"),
813 Some("claude".to_string())
814 );
815
816 assert_eq!(
818 registry.resolve_from_logfile_name("ccs-glm"),
819 Some("ccs/glm".to_string())
820 );
821
822 assert_eq!(
824 registry.resolve_from_logfile_name("opencode-anthropic-claude-sonnet-4"),
825 Some("opencode/anthropic/claude-sonnet-4".to_string())
826 );
827
828 assert_eq!(
830 registry.resolve_from_logfile_name("ccs-zai"),
831 Some("ccs/zai".to_string())
832 );
833
834 assert_eq!(
836 registry.resolve_from_logfile_name("opencode-google-gemini-pro"),
837 Some("opencode/google/gemini-pro".to_string())
838 );
839
840 assert_eq!(registry.resolve_from_logfile_name("unknown-agent"), None);
842 }
843
844 #[test]
845 fn test_registry_available_fallbacks() {
846 let mut registry = AgentRegistry::new().unwrap();
850
851 registry.register(
853 "echo-agent",
854 AgentConfig {
855 cmd: "echo test".to_string(),
856 output_flag: String::new(),
857 yolo_flag: String::new(),
858 verbose_flag: String::new(),
859 can_commit: true,
860 json_parser: JsonParserType::Generic,
861 model_flag: None,
862 print_flag: String::new(),
863 streaming_flag: String::new(),
864 session_flag: String::new(),
865 env_vars: std::collections::HashMap::new(),
866 display_name: None,
867 },
868 );
869 registry.register(
870 "cat-agent",
871 AgentConfig {
872 cmd: "cat --version".to_string(),
873 output_flag: String::new(),
874 yolo_flag: String::new(),
875 verbose_flag: String::new(),
876 can_commit: true,
877 json_parser: JsonParserType::Generic,
878 model_flag: None,
879 print_flag: String::new(),
880 streaming_flag: String::new(),
881 session_flag: String::new(),
882 env_vars: std::collections::HashMap::new(),
883 display_name: None,
884 },
885 );
886 registry.register(
887 "nonexistent-agent",
888 AgentConfig {
889 cmd: "this-command-definitely-does-not-exist-xyz123".to_string(),
890 output_flag: String::new(),
891 yolo_flag: String::new(),
892 verbose_flag: String::new(),
893 can_commit: true,
894 json_parser: JsonParserType::Generic,
895 model_flag: None,
896 print_flag: String::new(),
897 streaming_flag: String::new(),
898 session_flag: String::new(),
899 env_vars: std::collections::HashMap::new(),
900 display_name: None,
901 },
902 );
903
904 let toml_str = r#"
906 [agent_chain]
907 developer = ["echo-agent", "nonexistent-agent", "cat-agent"]
908 "#;
909 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
910 registry.apply_unified_config(&unified);
911
912 let fallbacks = registry.available_fallbacks(AgentRole::Developer);
913 assert!(
914 fallbacks.contains(&"echo-agent"),
915 "echo-agent should be available"
916 );
917 assert!(
918 fallbacks.contains(&"cat-agent"),
919 "cat-agent should be available"
920 );
921 assert!(
922 !fallbacks.contains(&"nonexistent-agent"),
923 "nonexistent-agent should not be available"
924 );
925 }
926
927 #[test]
928 fn test_validate_agent_chains() {
929 let mut registry = AgentRegistry::new().unwrap();
930
931 let toml_str = r#"
933 [agent_chain]
934 developer = ["claude"]
935 reviewer = ["codex"]
936 "#;
937 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
938 registry.apply_unified_config(&unified);
939 assert!(registry.validate_agent_chains().is_ok());
940 }
941
942 #[test]
943 fn test_ccs_aliases_registration() {
944 let mut registry = AgentRegistry::new().unwrap();
946
947 let mut aliases = HashMap::new();
948 aliases.insert(
949 "work".to_string(),
950 CcsAliasConfig {
951 cmd: "ccs work".to_string(),
952 ..CcsAliasConfig::default()
953 },
954 );
955 aliases.insert(
956 "personal".to_string(),
957 CcsAliasConfig {
958 cmd: "ccs personal".to_string(),
959 ..CcsAliasConfig::default()
960 },
961 );
962 aliases.insert(
963 "gemini".to_string(),
964 CcsAliasConfig {
965 cmd: "ccs gemini".to_string(),
966 ..CcsAliasConfig::default()
967 },
968 );
969
970 registry.set_ccs_aliases(&aliases, default_ccs());
971
972 assert!(registry.resolve_config("ccs/work").is_some());
974 assert!(registry.resolve_config("ccs/personal").is_some());
975 assert!(registry.resolve_config("ccs/gemini").is_some());
976
977 let config = registry.resolve_config("ccs/work").unwrap();
979 assert!(
981 config.cmd.ends_with("claude") || config.cmd == "ccs work",
982 "cmd should be 'ccs work' or a path ending with 'claude', got: {}",
983 config.cmd
984 );
985 assert!(config.can_commit);
986 assert_eq!(config.json_parser, JsonParserType::Claude);
987 }
988
989 #[test]
990 fn test_ccs_in_fallback_chain() {
991 let mut registry = AgentRegistry::new().unwrap();
995
996 let mut aliases = HashMap::new();
998 aliases.insert(
999 "work".to_string(),
1000 CcsAliasConfig {
1001 cmd: "echo work".to_string(),
1002 ..CcsAliasConfig::default()
1003 },
1004 );
1005 registry.set_ccs_aliases(&aliases, default_ccs());
1006
1007 registry.register(
1009 "echo-agent",
1010 AgentConfig {
1011 cmd: "echo test".to_string(),
1012 output_flag: String::new(),
1013 yolo_flag: String::new(),
1014 verbose_flag: String::new(),
1015 can_commit: true,
1016 json_parser: JsonParserType::Generic,
1017 model_flag: None,
1018 print_flag: String::new(),
1019 streaming_flag: String::new(),
1020 session_flag: String::new(),
1021 env_vars: std::collections::HashMap::new(),
1022 display_name: None,
1023 },
1024 );
1025
1026 let toml_str = r#"
1028 [agent_chain]
1029 developer = ["ccs/work", "echo-agent"]
1030 reviewer = ["echo-agent"]
1031 "#;
1032 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
1033 registry.apply_unified_config(&unified);
1034
1035 let fallbacks = registry.available_fallbacks(AgentRole::Developer);
1037 assert!(
1038 fallbacks.contains(&"ccs/work"),
1039 "ccs/work should be available"
1040 );
1041 assert!(
1042 fallbacks.contains(&"echo-agent"),
1043 "echo-agent should be available"
1044 );
1045
1046 assert!(registry.validate_agent_chains().is_ok());
1048 }
1049
1050 #[test]
1051 fn test_ccs_aliases_with_registry_constructor() {
1052 let mut registry = AgentRegistry::new().unwrap();
1053 registry.set_ccs_aliases(&HashMap::new(), default_ccs());
1054
1055 assert!(registry.resolve_config("claude").is_some());
1057 assert!(registry.resolve_config("codex").is_some());
1058
1059 let mut registry2 = AgentRegistry::new().unwrap();
1061 let mut aliases = HashMap::new();
1062 aliases.insert(
1063 "work".to_string(),
1064 CcsAliasConfig {
1065 cmd: "ccs work".to_string(),
1066 ..CcsAliasConfig::default()
1067 },
1068 );
1069
1070 registry2.set_ccs_aliases(&aliases, default_ccs());
1071 assert!(registry2.resolve_config("ccs/work").is_some());
1073 }
1074
1075 #[test]
1076 fn test_list_includes_ccs_aliases() {
1077 let mut registry = AgentRegistry::new().unwrap();
1078
1079 let mut aliases = HashMap::new();
1080 aliases.insert(
1081 "work".to_string(),
1082 CcsAliasConfig {
1083 cmd: "ccs work".to_string(),
1084 ..CcsAliasConfig::default()
1085 },
1086 );
1087 aliases.insert(
1088 "personal".to_string(),
1089 CcsAliasConfig {
1090 cmd: "ccs personal".to_string(),
1091 ..CcsAliasConfig::default()
1092 },
1093 );
1094 registry.set_ccs_aliases(&aliases, default_ccs());
1095
1096 let all_agents = registry.list();
1097
1098 assert_eq!(
1099 all_agents
1100 .iter()
1101 .filter(|(name, _)| name.starts_with("ccs/"))
1102 .count(),
1103 2
1104 );
1105 }
1106
1107 #[test]
1108 fn test_resolve_fuzzy_exact_match() {
1109 let registry = AgentRegistry::new().unwrap();
1110 assert_eq!(registry.resolve_fuzzy("claude"), Some("claude".to_string()));
1111 assert_eq!(registry.resolve_fuzzy("codex"), Some("codex".to_string()));
1112 }
1113
1114 #[test]
1115 fn test_resolve_fuzzy_ccs_unregistered() {
1116 let registry = AgentRegistry::new().unwrap();
1117 assert_eq!(
1119 registry.resolve_fuzzy("ccs/random"),
1120 Some("ccs/random".to_string())
1121 );
1122 assert_eq!(
1123 registry.resolve_fuzzy("ccs/unregistered"),
1124 Some("ccs/unregistered".to_string())
1125 );
1126 }
1127
1128 #[test]
1129 fn test_resolve_fuzzy_typos() {
1130 let registry = AgentRegistry::new().unwrap();
1131 assert_eq!(registry.resolve_fuzzy("claud"), Some("claude".to_string()));
1133 assert_eq!(registry.resolve_fuzzy("CLAUD"), Some("claude".to_string()));
1134 }
1135
1136 #[test]
1137 fn test_resolve_fuzzy_codex_variations() {
1138 let registry = AgentRegistry::new().unwrap();
1139 assert_eq!(registry.resolve_fuzzy("codeex"), Some("codex".to_string()));
1141 assert_eq!(registry.resolve_fuzzy("code-x"), Some("codex".to_string()));
1142 assert_eq!(registry.resolve_fuzzy("CODEEX"), Some("codex".to_string()));
1143 }
1144
1145 #[test]
1146 fn test_resolve_fuzzy_cursor_variations() {
1147 let registry = AgentRegistry::new().unwrap();
1148 assert_eq!(registry.resolve_fuzzy("crusor"), Some("cursor".to_string()));
1150 assert_eq!(registry.resolve_fuzzy("CRUSOR"), Some("cursor".to_string()));
1151 }
1152
1153 #[test]
1154 fn test_resolve_fuzzy_gemini_variations() {
1155 let registry = AgentRegistry::new().unwrap();
1156 assert_eq!(registry.resolve_fuzzy("gemeni"), Some("gemini".to_string()));
1158 assert_eq!(registry.resolve_fuzzy("gemni"), Some("gemini".to_string()));
1159 assert_eq!(registry.resolve_fuzzy("GEMENI"), Some("gemini".to_string()));
1160 }
1161
1162 #[test]
1163 fn test_resolve_fuzzy_qwen_variations() {
1164 let registry = AgentRegistry::new().unwrap();
1165 assert_eq!(registry.resolve_fuzzy("quen"), Some("qwen".to_string()));
1167 assert_eq!(registry.resolve_fuzzy("quwen"), Some("qwen".to_string()));
1168 assert_eq!(registry.resolve_fuzzy("QUEN"), Some("qwen".to_string()));
1169 }
1170
1171 #[test]
1172 fn test_resolve_fuzzy_aider_variations() {
1173 let registry = AgentRegistry::new().unwrap();
1174 assert_eq!(registry.resolve_fuzzy("ader"), Some("aider".to_string()));
1176 assert_eq!(registry.resolve_fuzzy("ADER"), Some("aider".to_string()));
1177 }
1178
1179 #[test]
1180 fn test_resolve_fuzzy_vibe_variations() {
1181 let registry = AgentRegistry::new().unwrap();
1182 assert_eq!(registry.resolve_fuzzy("vib"), Some("vibe".to_string()));
1184 assert_eq!(registry.resolve_fuzzy("VIB"), Some("vibe".to_string()));
1185 }
1186
1187 #[test]
1188 fn test_resolve_fuzzy_cline_variations() {
1189 let registry = AgentRegistry::new().unwrap();
1190 assert_eq!(registry.resolve_fuzzy("kline"), Some("cline".to_string()));
1192 assert_eq!(registry.resolve_fuzzy("KLINE"), Some("cline".to_string()));
1193 }
1194
1195 #[test]
1196 fn test_resolve_fuzzy_ccs_dash_to_slash() {
1197 let registry = AgentRegistry::new().unwrap();
1198 assert_eq!(
1200 registry.resolve_fuzzy("ccs-random"),
1201 Some("ccs/random".to_string())
1202 );
1203 assert_eq!(
1204 registry.resolve_fuzzy("ccs-test"),
1205 Some("ccs/test".to_string())
1206 );
1207 }
1208
1209 #[test]
1210 fn test_resolve_fuzzy_underscore_replacement() {
1211 let result = AgentRegistry::get_fuzzy_alternatives("my_agent");
1214 assert!(result.contains(&"my_agent".to_string()));
1215 assert!(result.contains(&"my-agent".to_string()));
1216 assert!(result.contains(&"my/agent".to_string()));
1217 }
1218
1219 #[test]
1220 fn test_resolve_fuzzy_unknown() {
1221 let registry = AgentRegistry::new().unwrap();
1222 assert_eq!(registry.resolve_fuzzy("totally-unknown"), None);
1224 }
1225
1226 #[test]
1227 fn test_apply_unified_config_does_not_inherit_env_vars() {
1228 let mut registry = AgentRegistry::new().unwrap();
1232
1233 registry.register(
1236 "claude",
1237 AgentConfig {
1238 cmd: "claude -p".to_string(),
1239 output_flag: "--output-format=stream-json".to_string(),
1240 yolo_flag: "--dangerously-skip-permissions".to_string(),
1241 verbose_flag: "--verbose".to_string(),
1242 can_commit: true,
1243 json_parser: JsonParserType::Claude,
1244 model_flag: None,
1245 print_flag: String::new(),
1246 streaming_flag: "--include-partial-messages".to_string(),
1247 session_flag: "--resume {}".to_string(),
1248 env_vars: {
1250 let mut vars = std::collections::HashMap::new();
1251 vars.insert(
1252 "ANTHROPIC_BASE_URL".to_string(),
1253 "https://api.z.ai/api/anthropic".to_string(),
1254 );
1255 vars.insert(
1256 "ANTHROPIC_AUTH_TOKEN".to_string(),
1257 "test-token-glm".to_string(),
1258 );
1259 vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1260 vars
1261 },
1262 display_name: None,
1263 },
1264 );
1265
1266 let claude_config = registry.resolve_config("claude").unwrap();
1268 assert_eq!(claude_config.env_vars.len(), 3);
1269 assert_eq!(
1270 claude_config.env_vars.get("ANTHROPIC_BASE_URL"),
1271 Some(&"https://api.z.ai/api/anthropic".to_string())
1272 );
1273
1274 let toml_str = r#"
1280 [general]
1281 verbosity = 2
1282 interactive = true
1283 isolation_mode = true
1284
1285 [agents.claude]
1286 cmd = "claude -p"
1287 display_name = "My Custom Claude"
1288 "#;
1289 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
1290
1291 registry.apply_unified_config(&unified);
1293
1294 let claude_config_after = registry.resolve_config("claude").unwrap();
1296 assert_eq!(
1297 claude_config_after.env_vars.len(),
1298 0,
1299 "env_vars should NOT be inherited from the existing agent when unified config is applied"
1300 );
1301 assert_eq!(
1302 claude_config_after.display_name,
1303 Some("My Custom Claude".to_string()),
1304 "display_name should be updated from the unified config"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_resolve_config_does_not_share_env_vars_between_agents() {
1310 let mut registry = AgentRegistry::new().unwrap();
1319
1320 registry.register(
1322 "ccs/glm",
1323 AgentConfig {
1324 cmd: "ccs glm".to_string(),
1325 output_flag: "--output-format=stream-json".to_string(),
1326 yolo_flag: "--dangerously-skip-permissions".to_string(),
1327 verbose_flag: "--verbose".to_string(),
1328 can_commit: true,
1329 json_parser: JsonParserType::Claude,
1330 model_flag: None,
1331 print_flag: "-p".to_string(),
1332 streaming_flag: "--include-partial-messages".to_string(),
1333 session_flag: "--resume {}".to_string(),
1334 env_vars: {
1335 let mut vars = std::collections::HashMap::new();
1336 vars.insert(
1337 "ANTHROPIC_BASE_URL".to_string(),
1338 "https://api.z.ai/api/anthropic".to_string(),
1339 );
1340 vars.insert(
1341 "ANTHROPIC_AUTH_TOKEN".to_string(),
1342 "test-token-glm".to_string(),
1343 );
1344 vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1345 vars
1346 },
1347 display_name: Some("ccs-glm".to_string()),
1348 },
1349 );
1350
1351 registry.register(
1353 "claude",
1354 AgentConfig {
1355 cmd: "claude -p".to_string(),
1356 output_flag: "--output-format=stream-json".to_string(),
1357 yolo_flag: "--dangerously-skip-permissions".to_string(),
1358 verbose_flag: "--verbose".to_string(),
1359 can_commit: true,
1360 json_parser: JsonParserType::Claude,
1361 model_flag: None,
1362 print_flag: String::new(),
1363 streaming_flag: "--include-partial-messages".to_string(),
1364 session_flag: "--resume {}".to_string(),
1365 env_vars: std::collections::HashMap::new(),
1366 display_name: None,
1367 },
1368 );
1369
1370 let glm_config = registry.resolve_config("ccs/glm").unwrap();
1372 assert_eq!(glm_config.env_vars.len(), 3);
1373 assert_eq!(
1374 glm_config.env_vars.get("ANTHROPIC_BASE_URL"),
1375 Some(&"https://api.z.ai/api/anthropic".to_string())
1376 );
1377
1378 let claude_config = registry.resolve_config("claude").unwrap();
1380 assert_eq!(
1381 claude_config.env_vars.len(),
1382 0,
1383 "claude agent should have empty env_vars"
1384 );
1385
1386 let glm_config2 = registry.resolve_config("ccs/glm").unwrap();
1388 assert_eq!(glm_config2.env_vars.len(), 3);
1389
1390 drop(glm_config);
1393
1394 let claude_config2 = registry.resolve_config("claude").unwrap();
1396 assert_eq!(
1397 claude_config2.env_vars.len(),
1398 0,
1399 "claude agent env_vars should remain independent"
1400 );
1401 }
1402}