ralph_workflow/agents/
registry.rs

1//! Agent registry for agent lookup and management.
2//!
3//! The registry holds all configured agents and provides methods for
4//! looking up agents by name, validating agent chains, and checking
5//! agent availability.
6//!
7//! # CCS (Claude Code Switch) Support
8//!
9//! The registry supports CCS aliases using `ccs/alias` syntax.
10//! CCS aliases are resolved on-the-fly to generate `AgentConfig` instances.
11//!
12//! ```ignore
13//! // Using CCS aliases in agent chains
14//! [ccs_aliases]
15//! work = "ccs work"
16//! personal = "ccs personal"
17//!
18//! [agent_chain]
19//! developer = ["ccs/work", "claude"]
20//! ```
21use super::ccs::CcsAliasResolver;
22use super::config::{AgentConfig, AgentConfigError, AgentsConfigFile, DEFAULT_AGENTS_TOML};
23use super::fallback::{AgentRole, FallbackConfig};
24use super::parser::JsonParserType;
25use crate::config::{CcsAliasConfig, CcsConfig};
26use std::collections::HashMap;
27use std::path::Path;
28
29/// Agent registry with CCS alias support.
30///
31/// CCS aliases are eagerly resolved and registered as regular agents
32/// when set via `set_ccs_aliases()`. This allows `get()` to work
33/// uniformly for both regular agents and CCS aliases.
34pub struct AgentRegistry {
35    agents: HashMap<String, AgentConfig>,
36    fallback: FallbackConfig,
37    /// CCS alias resolver for `ccs/alias` syntax.
38    ccs_resolver: CcsAliasResolver,
39}
40
41impl AgentRegistry {
42    /// Create a new registry with default agents.
43    pub fn new() -> Result<Self, AgentConfigError> {
44        let AgentsConfigFile { agents, fallback } =
45            toml::from_str(DEFAULT_AGENTS_TOML).map_err(AgentConfigError::DefaultTemplateToml)?;
46
47        let mut registry = Self {
48            agents: HashMap::new(),
49            fallback,
50            ccs_resolver: CcsAliasResolver::empty(),
51        };
52
53        for (name, agent_toml) in agents {
54            registry.register(&name, AgentConfig::from(agent_toml));
55        }
56
57        Ok(registry)
58    }
59
60    /// Set CCS aliases for the registry.
61    ///
62    /// This eagerly registers CCS aliases as agents so they can be
63    /// resolved with `resolve_config()`.
64    pub fn set_ccs_aliases(
65        &mut self,
66        aliases: &HashMap<String, CcsAliasConfig>,
67        defaults: CcsConfig,
68    ) {
69        self.ccs_resolver = CcsAliasResolver::new(aliases.clone(), defaults);
70        // Eagerly register CCS aliases as agents
71        for alias_name in aliases.keys() {
72            let agent_name = format!("ccs/{alias_name}");
73            if let Some(config) = self.ccs_resolver.try_resolve(&agent_name) {
74                self.agents.insert(agent_name, config);
75            }
76        }
77    }
78
79    /// Register a new agent.
80    pub fn register(&mut self, name: &str, config: AgentConfig) {
81        self.agents.insert(name.to_string(), config);
82    }
83
84    /// Resolve an agent's configuration, including on-the-fly CCS references.
85    ///
86    /// CCS supports direct execution via `ccs/<alias>` even when the alias isn't
87    /// pre-registered in config; those are resolved lazily here.
88    pub fn resolve_config(&self, name: &str) -> Option<AgentConfig> {
89        self.agents
90            .get(name)
91            .cloned()
92            .or_else(|| self.ccs_resolver.try_resolve(name))
93    }
94
95    /// Get display name for an agent.
96    ///
97    /// Returns the agent's custom display name if set (e.g., "ccs-glm" for CCS aliases),
98    /// otherwise returns the agent's registry name.
99    ///
100    /// # Arguments
101    ///
102    /// * `name` - The agent's registry name (e.g., "ccs/glm", "claude")
103    ///
104    /// # Examples
105    ///
106    /// ```ignore
107    /// assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
108    /// assert_eq!(registry.display_name("claude"), "claude");
109    /// ```
110    pub fn display_name(&self, name: &str) -> String {
111        self.resolve_config(name)
112            .and_then(|config| config.display_name)
113            .unwrap_or_else(|| name.to_string())
114    }
115
116    /// Resolve a fuzzy agent name to a canonical agent name.
117    ///
118    /// This handles common typos and alternative forms:
119    /// - `ccs/<unregistered>`: Returns the name as-is for direct CCS execution
120    /// - Other fuzzy matches: Returns the canonical name if a match is found
121    /// - Exact matches: Returns the name as-is
122    ///
123    /// Returns `None` if the name cannot be resolved to any known agent.
124    pub fn resolve_fuzzy(&self, name: &str) -> Option<String> {
125        // First check if it's an exact match
126        if self.agents.contains_key(name) {
127            return Some(name.to_string());
128        }
129
130        // Handle ccs/<unregistered> pattern - return as-is for direct CCS execution
131        if name.starts_with("ccs/") {
132            return Some(name.to_string());
133        }
134
135        // Handle common typos/alternatives
136        let normalized = name.to_lowercase();
137        let alternatives = Self::get_fuzzy_alternatives(&normalized);
138
139        for alt in alternatives {
140            // If it's a ccs/ pattern, return it for direct CCS execution
141            if alt.starts_with("ccs/") {
142                return Some(alt);
143            }
144            // Otherwise check if it exists in the registry
145            if self.agents.contains_key(&alt) {
146                return Some(alt);
147            }
148        }
149
150        None
151    }
152
153    /// Get fuzzy alternatives for a given agent name.
154    ///
155    /// Returns a list of potential canonical names to try, in order of preference.
156    pub(crate) fn get_fuzzy_alternatives(name: &str) -> Vec<String> {
157        let mut alternatives = Vec::new();
158
159        // Add exact match first
160        alternatives.push(name.to_string());
161
162        // Handle common typos and variations
163        match name {
164            // ccs variations
165            n if n.starts_with("ccs-") => {
166                alternatives.push(name.replace("ccs-", "ccs/"));
167            }
168            n if n.contains('_') => {
169                alternatives.push(name.replace('_', "-"));
170                alternatives.push(name.replace('_', "/"));
171            }
172
173            // claude variations
174            "claud" | "cloud" => alternatives.push("claude".to_string()),
175
176            // codex variations
177            "codeex" | "code-x" => alternatives.push("codex".to_string()),
178
179            // cursor variations
180            "crusor" => alternatives.push("cursor".to_string()),
181
182            // opencode variations
183            "opencode" | "open-code" => alternatives.push("opencode".to_string()),
184
185            // gemini variations
186            "gemeni" | "gemni" => alternatives.push("gemini".to_string()),
187
188            // qwen variations
189            "quen" | "quwen" => alternatives.push("qwen".to_string()),
190
191            // aider variations
192            "ader" => alternatives.push("aider".to_string()),
193
194            // vibe variations
195            "vib" => alternatives.push("vibe".to_string()),
196
197            // cline variations
198            "kline" => alternatives.push("cline".to_string()),
199
200            _ => {}
201        }
202
203        alternatives
204    }
205
206    /// List all registered agents.
207    pub fn list(&self) -> Vec<(&str, &AgentConfig)> {
208        self.agents.iter().map(|(k, v)| (k.as_str(), v)).collect()
209    }
210
211    /// Get command for developer role.
212    pub fn developer_cmd(&self, agent_name: &str) -> Option<String> {
213        self.resolve_config(agent_name)
214            .map(|c| c.build_cmd(true, true, true))
215    }
216
217    /// Get command for reviewer role.
218    pub fn reviewer_cmd(&self, agent_name: &str) -> Option<String> {
219        self.resolve_config(agent_name)
220            .map(|c| c.build_cmd(true, true, false))
221    }
222
223    /// Load custom agents from a TOML configuration file.
224    pub fn load_from_file<P: AsRef<Path>>(&mut self, path: P) -> Result<usize, AgentConfigError> {
225        match AgentsConfigFile::load_from_file(path)? {
226            Some(config) => {
227                let count = config.agents.len();
228                for (name, agent_toml) in config.agents {
229                    self.register(&name, AgentConfig::from(agent_toml));
230                }
231                // Load fallback configuration
232                self.fallback = config.fallback;
233                Ok(count)
234            }
235            None => Ok(0),
236        }
237    }
238
239    /// Apply settings from the unified config (`~/.config/ralph-workflow.toml`).
240    ///
241    /// This merges (in increasing priority):
242    /// 1. Built-in defaults (embedded `examples/agents.toml`)
243    /// 2. Unified config: `[agents]`, `[ccs_aliases]`, and `[agent_chain]` (if present)
244    ///
245    /// Returns the number of agents loaded from unified config, including CCS aliases.
246    pub fn apply_unified_config(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
247        let mut loaded = self.apply_ccs_aliases(unified);
248        loaded += self.apply_agent_overrides(unified);
249
250        if let Some(chain) = &unified.agent_chain {
251            self.fallback = chain.clone();
252        }
253
254        loaded
255    }
256
257    /// Apply CCS aliases from the unified config.
258    fn apply_ccs_aliases(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
259        if unified.ccs_aliases.is_empty() {
260            return 0;
261        }
262
263        let loaded = unified.ccs_aliases.len();
264        let aliases = unified
265            .ccs_aliases
266            .iter()
267            .map(|(name, v)| (name.clone(), v.as_config()))
268            .collect::<HashMap<_, _>>();
269        self.set_ccs_aliases(&aliases, unified.ccs.clone());
270        loaded
271    }
272
273    /// Apply agent overrides from the unified config.
274    fn apply_agent_overrides(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
275        if unified.agents.is_empty() {
276            return 0;
277        }
278
279        let mut loaded = 0usize;
280        for (name, overrides) in &unified.agents {
281            if let Some(existing) = self.agents.get(name).cloned() {
282                // Merge with existing agent
283                let merged = Self::merge_agent_config(existing, overrides);
284                self.register(name, merged);
285                loaded += 1;
286            } else {
287                // New agent definition: require a non-empty command.
288                if let Some(config) = Self::create_new_agent_config(overrides) {
289                    self.register(name, config);
290                    loaded += 1;
291                }
292            }
293        }
294        loaded
295    }
296
297    /// Create a new agent config from unified config overrides.
298    fn create_new_agent_config(
299        overrides: &crate::config::unified::AgentConfigToml,
300    ) -> Option<AgentConfig> {
301        let cmd = overrides
302            .cmd
303            .as_deref()
304            .map(str::trim)
305            .filter(|s| !s.is_empty())?;
306
307        let json_parser = overrides
308            .json_parser
309            .as_deref()
310            .map(str::trim)
311            .filter(|s| !s.is_empty())
312            .unwrap_or("generic");
313
314        Some(AgentConfig {
315            cmd: cmd.to_string(),
316            output_flag: overrides.output_flag.clone().unwrap_or_default(),
317            yolo_flag: overrides.yolo_flag.clone().unwrap_or_default(),
318            verbose_flag: overrides.verbose_flag.clone().unwrap_or_default(),
319            can_commit: overrides.can_commit.unwrap_or(true),
320            json_parser: JsonParserType::parse(json_parser),
321            model_flag: overrides.model_flag.clone(),
322            print_flag: overrides.print_flag.clone().unwrap_or_default(),
323            streaming_flag: overrides.streaming_flag.clone().unwrap_or_else(|| {
324                // Default to "--include-partial-messages" for Claude/CCS agents
325                if cmd.starts_with("claude") || cmd.starts_with("ccs") {
326                    "--include-partial-messages".to_string()
327                } else {
328                    String::new()
329                }
330            }),
331            env_vars: std::collections::HashMap::new(),
332            display_name: overrides
333                .display_name
334                .as_ref()
335                .filter(|s| !s.is_empty())
336                .cloned(),
337        })
338    }
339
340    /// Merge overrides with existing agent config.
341    fn merge_agent_config(
342        existing: AgentConfig,
343        overrides: &crate::config::unified::AgentConfigToml,
344    ) -> AgentConfig {
345        AgentConfig {
346            cmd: overrides
347                .cmd
348                .as_deref()
349                .map(str::trim)
350                .filter(|s| !s.is_empty())
351                .map(str::to_string)
352                .unwrap_or(existing.cmd),
353            output_flag: overrides
354                .output_flag
355                .clone()
356                .unwrap_or(existing.output_flag),
357            yolo_flag: overrides.yolo_flag.clone().unwrap_or(existing.yolo_flag),
358            verbose_flag: overrides
359                .verbose_flag
360                .clone()
361                .unwrap_or(existing.verbose_flag),
362            can_commit: overrides.can_commit.unwrap_or(existing.can_commit),
363            json_parser: overrides
364                .json_parser
365                .as_deref()
366                .map(str::trim)
367                .filter(|s| !s.is_empty())
368                .map_or(existing.json_parser, JsonParserType::parse),
369            model_flag: overrides.model_flag.clone().or(existing.model_flag),
370            print_flag: overrides.print_flag.clone().unwrap_or(existing.print_flag),
371            streaming_flag: overrides
372                .streaming_flag
373                .clone()
374                .unwrap_or(existing.streaming_flag),
375            // Do NOT inherit env_vars from the existing agent to prevent
376            // CCS env vars from one agent from leaking into another.
377            // The unified config (unified::AgentConfigToml) doesn't support
378            // ccs_profile or env_vars fields, so we always start fresh.
379            env_vars: std::collections::HashMap::new(),
380            // Preserve existing display name unless explicitly overridden
381            // Empty string explicitly clears the display name
382            display_name: match &overrides.display_name {
383                Some(s) if s.is_empty() => None,
384                Some(s) => Some(s.clone()),
385                None => existing.display_name,
386            },
387        }
388    }
389
390    /// Get the fallback configuration.
391    pub const fn fallback_config(&self) -> &FallbackConfig {
392        &self.fallback
393    }
394
395    /// Get all fallback agents for a role that are registered in this registry.
396    pub fn available_fallbacks(&self, role: AgentRole) -> Vec<&str> {
397        self.fallback
398            .get_fallbacks(role)
399            .iter()
400            .filter(|name| self.is_agent_available(name))
401            // Agents with can_commit=false are chat-only / non-tool agents and will stall Ralph.
402            .filter(|name| {
403                self.resolve_config(name.as_str())
404                    .is_some_and(|cfg| cfg.can_commit)
405            })
406            .map(std::string::String::as_str)
407            .collect()
408    }
409
410    /// Validate that agent chains are configured for both roles.
411    pub fn validate_agent_chains(&self) -> Result<(), String> {
412        let has_developer = self.fallback.has_fallbacks(AgentRole::Developer);
413        let has_reviewer = self.fallback.has_fallbacks(AgentRole::Reviewer);
414
415        if !has_developer && !has_reviewer {
416            return Err("No agent chain configured.\n\
417                Please add an [agent_chain] section to ~/.config/ralph-workflow.toml.\n\
418                Run 'ralph --init-global' to create a default configuration."
419                .to_string());
420        }
421
422        if !has_developer {
423            return Err("No developer agent chain configured.\n\
424                Add 'developer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
425                Use --list-agents to see available agents."
426                .to_string());
427        }
428
429        if !has_reviewer {
430            return Err("No reviewer agent chain configured.\n\
431                Add 'reviewer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
432                Use --list-agents to see available agents."
433                .to_string());
434        }
435
436        // Sanity check: ensure there is at least one workflow-capable agent per role.
437        for role in [AgentRole::Developer, AgentRole::Reviewer] {
438            let chain = self.fallback.get_fallbacks(role);
439            let has_capable = chain
440                .iter()
441                .any(|name| self.resolve_config(name).is_some_and(|cfg| cfg.can_commit));
442            if !has_capable {
443                return Err(format!(
444                    "No workflow-capable agents found for {role}.\n\
445                    All agents in the {role} chain have can_commit=false.\n\
446                    Fix: set can_commit=true for at least one agent or update [agent_chain]."
447                ));
448            }
449        }
450
451        Ok(())
452    }
453
454    /// Check if an agent is available (command exists and is executable).
455    pub fn is_agent_available(&self, name: &str) -> bool {
456        if let Some(config) = self.resolve_config(name) {
457            let Ok(parts) = crate::common::split_command(&config.cmd) else {
458                return false;
459            };
460            let Some(base_cmd) = parts.first() else {
461                return false;
462            };
463
464            // Check if the command exists in PATH
465            which::which(base_cmd).is_ok()
466        } else {
467            false
468        }
469    }
470
471    /// List all available (installed) agents.
472    pub fn list_available(&self) -> Vec<&str> {
473        self.agents
474            .keys()
475            .filter(|name| self.is_agent_available(name))
476            .map(std::string::String::as_str)
477            .collect()
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::agents::JsonParserType;
485    use std::sync::Mutex;
486
487    static ENV_MUTEX: Mutex<()> = Mutex::new(());
488
489    fn default_ccs() -> CcsConfig {
490        CcsConfig::default()
491    }
492
493    fn write_stub_executable(dir: &std::path::Path, name: &str) {
494        #[cfg(windows)]
495        {
496            let path = dir.join(format!("{}.cmd", name));
497            std::fs::write(&path, "@echo off\r\nexit /b 0\r\n").unwrap();
498        }
499        #[cfg(unix)]
500        {
501            use std::os::unix::fs::PermissionsExt;
502            let path = dir.join(name);
503            std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
504            let mut perms = std::fs::metadata(&path).unwrap().permissions();
505            perms.set_mode(0o755);
506            std::fs::set_permissions(&path, perms).unwrap();
507        }
508    }
509
510    #[test]
511    fn test_registry_new() {
512        let registry = AgentRegistry::new().unwrap();
513        // Behavioral test: agents are registered if they resolve
514        assert!(registry.resolve_config("claude").is_some());
515        assert!(registry.resolve_config("codex").is_some());
516    }
517
518    #[test]
519    fn test_registry_register() {
520        let mut registry = AgentRegistry::new().unwrap();
521        registry.register(
522            "testbot",
523            AgentConfig {
524                cmd: "testbot run".to_string(),
525                output_flag: "--json".to_string(),
526                yolo_flag: "--yes".to_string(),
527                verbose_flag: String::new(),
528                can_commit: true,
529                json_parser: JsonParserType::Generic,
530                model_flag: None,
531                print_flag: String::new(),
532                streaming_flag: String::new(),
533                env_vars: std::collections::HashMap::new(),
534                display_name: None,
535            },
536        );
537        // Behavioral test: registered agent should resolve
538        assert!(registry.resolve_config("testbot").is_some());
539    }
540
541    #[test]
542    fn test_registry_display_name() {
543        let mut registry = AgentRegistry::new().unwrap();
544
545        // Agent without custom display name uses registry key
546        registry.register(
547            "claude",
548            AgentConfig {
549                cmd: "claude -p".to_string(),
550                output_flag: "--output-format=stream-json".to_string(),
551                yolo_flag: "--dangerously-skip-permissions".to_string(),
552                verbose_flag: "--verbose".to_string(),
553                can_commit: true,
554                json_parser: JsonParserType::Claude,
555                model_flag: None,
556                print_flag: String::new(),
557                streaming_flag: "--include-partial-messages".to_string(),
558                env_vars: std::collections::HashMap::new(),
559                display_name: None,
560            },
561        );
562
563        // Agent with custom display name uses that
564        registry.register(
565            "ccs/glm",
566            AgentConfig {
567                cmd: "ccs glm".to_string(),
568                output_flag: "--output-format=stream-json".to_string(),
569                yolo_flag: "--dangerously-skip-permissions".to_string(),
570                verbose_flag: "--verbose".to_string(),
571                can_commit: true,
572                json_parser: JsonParserType::Claude,
573                model_flag: None,
574                print_flag: "-p".to_string(),
575                streaming_flag: "--include-partial-messages".to_string(),
576                env_vars: std::collections::HashMap::new(),
577                display_name: Some("ccs-glm".to_string()),
578            },
579        );
580
581        // Test display names
582        assert_eq!(registry.display_name("claude"), "claude");
583        assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
584
585        // Unknown agent returns the key as-is
586        assert_eq!(registry.display_name("unknown"), "unknown");
587    }
588
589    #[test]
590    fn test_registry_available_fallbacks() {
591        let _lock = ENV_MUTEX.lock().unwrap();
592        let original_path = std::env::var_os("PATH");
593        let dir = tempfile::tempdir().unwrap();
594
595        write_stub_executable(dir.path(), "claude");
596        write_stub_executable(dir.path(), "codex");
597
598        let mut new_paths = vec![dir.path().to_path_buf()];
599        if let Some(p) = &original_path {
600            new_paths.extend(std::env::split_paths(p));
601        }
602        let joined = std::env::join_paths(new_paths).unwrap();
603        std::env::set_var("PATH", &joined);
604
605        let mut registry = AgentRegistry::new().unwrap();
606        // Use apply_unified_config to set fallback chain (public API)
607        let toml_str = r#"
608            [agent_chain]
609            developer = ["claude", "nonexistent", "codex"]
610        "#;
611        let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
612        registry.apply_unified_config(&unified);
613
614        let fallbacks = registry.available_fallbacks(AgentRole::Developer);
615        assert!(fallbacks.contains(&"claude"));
616        assert!(fallbacks.contains(&"codex"));
617        assert!(!fallbacks.contains(&"nonexistent"));
618
619        if let Some(p) = original_path {
620            std::env::set_var("PATH", p);
621        } else {
622            std::env::remove_var("PATH");
623        }
624    }
625
626    #[test]
627    fn test_validate_agent_chains() {
628        let mut registry = AgentRegistry::new().unwrap();
629
630        // Both chains configured should pass - use apply_unified_config (public API)
631        let toml_str = r#"
632            [agent_chain]
633            developer = ["claude"]
634            reviewer = ["codex"]
635        "#;
636        let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
637        registry.apply_unified_config(&unified);
638        assert!(registry.validate_agent_chains().is_ok());
639    }
640
641    #[test]
642    fn test_ccs_aliases_registration() {
643        // Test that CCS aliases are registered correctly
644        let mut registry = AgentRegistry::new().unwrap();
645
646        let mut aliases = HashMap::new();
647        aliases.insert(
648            "work".to_string(),
649            CcsAliasConfig {
650                cmd: "ccs work".to_string(),
651                ..CcsAliasConfig::default()
652            },
653        );
654        aliases.insert(
655            "personal".to_string(),
656            CcsAliasConfig {
657                cmd: "ccs personal".to_string(),
658                ..CcsAliasConfig::default()
659            },
660        );
661        aliases.insert(
662            "gemini".to_string(),
663            CcsAliasConfig {
664                cmd: "ccs gemini".to_string(),
665                ..CcsAliasConfig::default()
666            },
667        );
668
669        registry.set_ccs_aliases(&aliases, default_ccs());
670
671        // CCS aliases should be registered as agents - behavioral test: they resolve
672        assert!(registry.resolve_config("ccs/work").is_some());
673        assert!(registry.resolve_config("ccs/personal").is_some());
674        assert!(registry.resolve_config("ccs/gemini").is_some());
675
676        // Get should return valid config
677        let config = registry.resolve_config("ccs/work").unwrap();
678        // When claude binary is found, it replaces "ccs work" with the path to claude
679        assert!(
680            config.cmd.ends_with("claude") || config.cmd == "ccs work",
681            "cmd should be 'ccs work' or a path ending with 'claude', got: {}",
682            config.cmd
683        );
684        assert!(config.can_commit);
685        assert_eq!(config.json_parser, JsonParserType::Claude);
686    }
687
688    #[test]
689    fn test_ccs_in_fallback_chain() {
690        let _lock = ENV_MUTEX.lock().unwrap();
691        let original_path = std::env::var_os("PATH");
692        let dir = tempfile::tempdir().unwrap();
693
694        // Create stub for ccs command
695        write_stub_executable(dir.path(), "ccs");
696        write_stub_executable(dir.path(), "claude");
697
698        let mut new_paths = vec![dir.path().to_path_buf()];
699        if let Some(p) = &original_path {
700            new_paths.extend(std::env::split_paths(p));
701        }
702        let joined = std::env::join_paths(new_paths).unwrap();
703        std::env::set_var("PATH", &joined);
704
705        let mut registry = AgentRegistry::new().unwrap();
706
707        // Register CCS aliases
708        let mut aliases = HashMap::new();
709        aliases.insert(
710            "work".to_string(),
711            CcsAliasConfig {
712                cmd: "ccs work".to_string(),
713                ..CcsAliasConfig::default()
714            },
715        );
716        registry.set_ccs_aliases(&aliases, default_ccs());
717
718        // Set fallback chain with CCS alias using apply_unified_config (public API)
719        let toml_str = r#"
720            [agent_chain]
721            developer = ["ccs/work", "claude"]
722            reviewer = ["claude"]
723        "#;
724        let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
725        registry.apply_unified_config(&unified);
726
727        // ccs/work should be in available fallbacks (since ccs is in PATH)
728        let fallbacks = registry.available_fallbacks(AgentRole::Developer);
729        assert!(fallbacks.contains(&"ccs/work"));
730        assert!(fallbacks.contains(&"claude"));
731
732        // Validate chains should pass
733        assert!(registry.validate_agent_chains().is_ok());
734
735        if let Some(p) = original_path {
736            std::env::set_var("PATH", p);
737        } else {
738            std::env::remove_var("PATH");
739        }
740    }
741
742    #[test]
743    fn test_ccs_aliases_with_registry_constructor() {
744        let mut registry = AgentRegistry::new().unwrap();
745        registry.set_ccs_aliases(&HashMap::new(), default_ccs());
746
747        // Should have built-in agents - behavioral test: they resolve
748        assert!(registry.resolve_config("claude").is_some());
749        assert!(registry.resolve_config("codex").is_some());
750
751        // Now test with actual aliases
752        let mut registry2 = AgentRegistry::new().unwrap();
753        let mut aliases = HashMap::new();
754        aliases.insert(
755            "work".to_string(),
756            CcsAliasConfig {
757                cmd: "ccs work".to_string(),
758                ..CcsAliasConfig::default()
759            },
760        );
761
762        registry2.set_ccs_aliases(&aliases, default_ccs());
763        // Behavioral test: CCS alias should resolve
764        assert!(registry2.resolve_config("ccs/work").is_some());
765    }
766
767    #[test]
768    fn test_list_includes_ccs_aliases() {
769        let mut registry = AgentRegistry::new().unwrap();
770
771        let mut aliases = HashMap::new();
772        aliases.insert(
773            "work".to_string(),
774            CcsAliasConfig {
775                cmd: "ccs work".to_string(),
776                ..CcsAliasConfig::default()
777            },
778        );
779        aliases.insert(
780            "personal".to_string(),
781            CcsAliasConfig {
782                cmd: "ccs personal".to_string(),
783                ..CcsAliasConfig::default()
784            },
785        );
786        registry.set_ccs_aliases(&aliases, default_ccs());
787
788        let all_agents = registry.list();
789
790        assert_eq!(
791            all_agents
792                .iter()
793                .filter(|(name, _)| name.starts_with("ccs/"))
794                .count(),
795            2
796        );
797    }
798
799    #[test]
800    fn test_resolve_fuzzy_exact_match() {
801        let registry = AgentRegistry::new().unwrap();
802        assert_eq!(registry.resolve_fuzzy("claude"), Some("claude".to_string()));
803        assert_eq!(registry.resolve_fuzzy("codex"), Some("codex".to_string()));
804    }
805
806    #[test]
807    fn test_resolve_fuzzy_ccs_unregistered() {
808        let registry = AgentRegistry::new().unwrap();
809        // ccs/<unregistered> should return as-is for direct execution
810        assert_eq!(
811            registry.resolve_fuzzy("ccs/random"),
812            Some("ccs/random".to_string())
813        );
814        assert_eq!(
815            registry.resolve_fuzzy("ccs/unregistered"),
816            Some("ccs/unregistered".to_string())
817        );
818    }
819
820    #[test]
821    fn test_resolve_fuzzy_typos() {
822        let registry = AgentRegistry::new().unwrap();
823        // Test common typos
824        assert_eq!(registry.resolve_fuzzy("claud"), Some("claude".to_string()));
825        assert_eq!(registry.resolve_fuzzy("CLAUD"), Some("claude".to_string()));
826    }
827
828    #[test]
829    fn test_resolve_fuzzy_codex_variations() {
830        let registry = AgentRegistry::new().unwrap();
831        // Test codex variations
832        assert_eq!(registry.resolve_fuzzy("codeex"), Some("codex".to_string()));
833        assert_eq!(registry.resolve_fuzzy("code-x"), Some("codex".to_string()));
834        assert_eq!(registry.resolve_fuzzy("CODEEX"), Some("codex".to_string()));
835    }
836
837    #[test]
838    fn test_resolve_fuzzy_cursor_variations() {
839        let registry = AgentRegistry::new().unwrap();
840        // Test cursor variations
841        assert_eq!(registry.resolve_fuzzy("crusor"), Some("cursor".to_string()));
842        assert_eq!(registry.resolve_fuzzy("CRUSOR"), Some("cursor".to_string()));
843    }
844
845    #[test]
846    fn test_resolve_fuzzy_gemini_variations() {
847        let registry = AgentRegistry::new().unwrap();
848        // Test gemini variations
849        assert_eq!(registry.resolve_fuzzy("gemeni"), Some("gemini".to_string()));
850        assert_eq!(registry.resolve_fuzzy("gemni"), Some("gemini".to_string()));
851        assert_eq!(registry.resolve_fuzzy("GEMENI"), Some("gemini".to_string()));
852    }
853
854    #[test]
855    fn test_resolve_fuzzy_qwen_variations() {
856        let registry = AgentRegistry::new().unwrap();
857        // Test qwen variations
858        assert_eq!(registry.resolve_fuzzy("quen"), Some("qwen".to_string()));
859        assert_eq!(registry.resolve_fuzzy("quwen"), Some("qwen".to_string()));
860        assert_eq!(registry.resolve_fuzzy("QUEN"), Some("qwen".to_string()));
861    }
862
863    #[test]
864    fn test_resolve_fuzzy_aider_variations() {
865        let registry = AgentRegistry::new().unwrap();
866        // Test aider variations
867        assert_eq!(registry.resolve_fuzzy("ader"), Some("aider".to_string()));
868        assert_eq!(registry.resolve_fuzzy("ADER"), Some("aider".to_string()));
869    }
870
871    #[test]
872    fn test_resolve_fuzzy_vibe_variations() {
873        let registry = AgentRegistry::new().unwrap();
874        // Test vibe variations
875        assert_eq!(registry.resolve_fuzzy("vib"), Some("vibe".to_string()));
876        assert_eq!(registry.resolve_fuzzy("VIB"), Some("vibe".to_string()));
877    }
878
879    #[test]
880    fn test_resolve_fuzzy_cline_variations() {
881        let registry = AgentRegistry::new().unwrap();
882        // Test cline variations
883        assert_eq!(registry.resolve_fuzzy("kline"), Some("cline".to_string()));
884        assert_eq!(registry.resolve_fuzzy("KLINE"), Some("cline".to_string()));
885    }
886
887    #[test]
888    fn test_resolve_fuzzy_ccs_dash_to_slash() {
889        let registry = AgentRegistry::new().unwrap();
890        // Test ccs- to ccs/ conversion (even for unregistered aliases)
891        assert_eq!(
892            registry.resolve_fuzzy("ccs-random"),
893            Some("ccs/random".to_string())
894        );
895        assert_eq!(
896            registry.resolve_fuzzy("ccs-test"),
897            Some("ccs/test".to_string())
898        );
899    }
900
901    #[test]
902    fn test_resolve_fuzzy_underscore_replacement() {
903        // Test underscore to dash/slash replacement
904        // Note: These test the pattern, actual agents may not exist
905        let result = AgentRegistry::get_fuzzy_alternatives("my_agent");
906        assert!(result.contains(&"my_agent".to_string()));
907        assert!(result.contains(&"my-agent".to_string()));
908        assert!(result.contains(&"my/agent".to_string()));
909    }
910
911    #[test]
912    fn test_resolve_fuzzy_unknown() {
913        let registry = AgentRegistry::new().unwrap();
914        // Unknown agent should return None
915        assert_eq!(registry.resolve_fuzzy("totally-unknown"), None);
916    }
917
918    #[test]
919    fn test_apply_unified_config_does_not_inherit_env_vars() {
920        // Regression test for CCS env vars leaking between agents.
921        // Ensures that when apply_unified_config merges agent configurations,
922        // env_vars from the existing agent are NOT inherited into the merged agent.
923        let mut registry = AgentRegistry::new().unwrap();
924
925        // First, manually register a "claude" agent with some env vars (simulating
926        // a previously-loaded agent with CCS env vars or manually-specified vars)
927        registry.register(
928            "claude",
929            AgentConfig {
930                cmd: "claude -p".to_string(),
931                output_flag: "--output-format=stream-json".to_string(),
932                yolo_flag: "--dangerously-skip-permissions".to_string(),
933                verbose_flag: "--verbose".to_string(),
934                can_commit: true,
935                json_parser: JsonParserType::Claude,
936                model_flag: None,
937                print_flag: String::new(),
938                streaming_flag: "--include-partial-messages".to_string(),
939                // Simulate CCS env vars from a previous load
940                env_vars: {
941                    let mut vars = std::collections::HashMap::new();
942                    vars.insert(
943                        "ANTHROPIC_BASE_URL".to_string(),
944                        "https://api.z.ai/api/anthropic".to_string(),
945                    );
946                    vars.insert(
947                        "ANTHROPIC_AUTH_TOKEN".to_string(),
948                        "test-token-glm".to_string(),
949                    );
950                    vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
951                    vars
952                },
953                display_name: None,
954            },
955        );
956
957        // Verify the "claude" agent has the GLM env vars
958        let claude_config = registry.resolve_config("claude").unwrap();
959        assert_eq!(claude_config.env_vars.len(), 3);
960        assert_eq!(
961            claude_config.env_vars.get("ANTHROPIC_BASE_URL"),
962            Some(&"https://api.z.ai/api/anthropic".to_string())
963        );
964
965        // Now apply a unified config that overrides the "claude" agent
966        // (simulating user's ~/.config/ralph-workflow.toml with [agents.claude])
967        // Create a minimal GeneralConfig via Default for UnifiedConfig
968        // Note: We can't directly construct UnifiedConfig with Default because agents is not Default
969        // So we'll create it by deserializing from a TOML string
970        let toml_str = r#"
971            [general]
972            verbosity = 2
973            interactive = true
974            isolation_mode = true
975
976            [agents.claude]
977            cmd = "claude -p"
978            display_name = "My Custom Claude"
979        "#;
980        let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
981
982        // Apply the unified config
983        registry.apply_unified_config(&unified);
984
985        // Verify that the "claude" agent's env_vars are now empty (NOT inherited)
986        let claude_config_after = registry.resolve_config("claude").unwrap();
987        assert_eq!(
988            claude_config_after.env_vars.len(),
989            0,
990            "env_vars should NOT be inherited from the existing agent when unified config is applied"
991        );
992        assert_eq!(
993            claude_config_after.display_name,
994            Some("My Custom Claude".to_string()),
995            "display_name should be updated from the unified config"
996        );
997    }
998
999    #[test]
1000    fn test_resolve_config_does_not_share_env_vars_between_agents() {
1001        // Regression test for the exact bug scenario:
1002        // 1. User runs Ralph with ccs/glm agent (with GLM env vars)
1003        // 2. User then runs Ralph with claude agent
1004        // 3. Claude should NOT have GLM env vars
1005        //
1006        // This test verifies that resolve_config() returns independent AgentConfig
1007        // instances with separate env_vars HashMaps - i.e., modifications to one
1008        // agent's env_vars don't affect another agent's config.
1009        let mut registry = AgentRegistry::new().unwrap();
1010
1011        // Register ccs/glm with GLM environment variables
1012        registry.register(
1013            "ccs/glm",
1014            AgentConfig {
1015                cmd: "ccs glm".to_string(),
1016                output_flag: "--output-format=stream-json".to_string(),
1017                yolo_flag: "--dangerously-skip-permissions".to_string(),
1018                verbose_flag: "--verbose".to_string(),
1019                can_commit: true,
1020                json_parser: JsonParserType::Claude,
1021                model_flag: None,
1022                print_flag: "-p".to_string(),
1023                streaming_flag: "--include-partial-messages".to_string(),
1024                env_vars: {
1025                    let mut vars = std::collections::HashMap::new();
1026                    vars.insert(
1027                        "ANTHROPIC_BASE_URL".to_string(),
1028                        "https://api.z.ai/api/anthropic".to_string(),
1029                    );
1030                    vars.insert(
1031                        "ANTHROPIC_AUTH_TOKEN".to_string(),
1032                        "test-token-glm".to_string(),
1033                    );
1034                    vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1035                    vars
1036                },
1037                display_name: Some("ccs-glm".to_string()),
1038            },
1039        );
1040
1041        // Register claude with empty env_vars (typical configuration)
1042        registry.register(
1043            "claude",
1044            AgentConfig {
1045                cmd: "claude -p".to_string(),
1046                output_flag: "--output-format=stream-json".to_string(),
1047                yolo_flag: "--dangerously-skip-permissions".to_string(),
1048                verbose_flag: "--verbose".to_string(),
1049                can_commit: true,
1050                json_parser: JsonParserType::Claude,
1051                model_flag: None,
1052                print_flag: String::new(),
1053                streaming_flag: "--include-partial-messages".to_string(),
1054                env_vars: std::collections::HashMap::new(),
1055                display_name: None,
1056            },
1057        );
1058
1059        // Resolve ccs/glm config first
1060        let glm_config = registry.resolve_config("ccs/glm").unwrap();
1061        assert_eq!(glm_config.env_vars.len(), 3);
1062        assert_eq!(
1063            glm_config.env_vars.get("ANTHROPIC_BASE_URL"),
1064            Some(&"https://api.z.ai/api/anthropic".to_string())
1065        );
1066
1067        // Resolve claude config
1068        let claude_config = registry.resolve_config("claude").unwrap();
1069        assert_eq!(
1070            claude_config.env_vars.len(),
1071            0,
1072            "claude agent should have empty env_vars"
1073        );
1074
1075        // Resolve ccs/glm again to ensure we get a fresh clone
1076        let glm_config2 = registry.resolve_config("ccs/glm").unwrap();
1077        assert_eq!(glm_config2.env_vars.len(), 3);
1078
1079        // Modify the first GLM config's env_vars
1080        // This should NOT affect the second GLM config if cloning is deep
1081        drop(glm_config);
1082
1083        // Verify claude still has empty env_vars after another resolve
1084        let claude_config2 = registry.resolve_config("claude").unwrap();
1085        assert_eq!(
1086            claude_config2.env_vars.len(),
1087            0,
1088            "claude agent env_vars should remain independent"
1089        );
1090    }
1091}