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