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