Skip to main content

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    /// Create a registry with only built-in agents (no config file loading).
117    ///
118    /// This is useful for integration tests that need a minimal registry
119    /// without loading from config files or environment variables.
120    ///
121    /// # Test-Utils Only
122    ///
123    /// This function is only available when the `test-utils` feature is enabled.
124    #[cfg(feature = "test-utils")]
125    #[must_use]
126    pub fn with_builtins_only() -> Self {
127        Self::new().expect("Built-in agents should always be valid")
128    }
129
130    /// Resolve an agent's configuration, including on-the-fly CCS and OpenCode references.
131    ///
132    /// CCS supports direct execution via `ccs/<alias>` even when the alias isn't
133    /// pre-registered in config; those are resolved lazily here.
134    ///
135    /// OpenCode supports dynamic provider/model via `opencode/provider/model` syntax;
136    /// those are validated against the API catalog and resolved lazily here.
137    pub fn resolve_config(&self, name: &str) -> Option<AgentConfig> {
138        self.agents
139            .get(name)
140            .cloned()
141            .or_else(|| self.ccs_resolver.try_resolve(name))
142            .or_else(|| {
143                self.opencode_resolver
144                    .as_ref()
145                    .and_then(|r| r.try_resolve(name))
146            })
147    }
148
149    /// Get display name for an agent.
150    ///
151    /// Returns the agent's custom display name if set (e.g., "ccs-glm" for CCS aliases),
152    /// otherwise returns the agent's registry name.
153    ///
154    /// # Arguments
155    ///
156    /// * `name` - The agent's registry name (e.g., "ccs/glm", "claude")
157    ///
158    /// # Examples
159    ///
160    /// ```ignore
161    /// assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
162    /// assert_eq!(registry.display_name("claude"), "claude");
163    /// ```
164    pub fn display_name(&self, name: &str) -> String {
165        self.resolve_config(name)
166            .and_then(|config| config.display_name)
167            .unwrap_or_else(|| name.to_string())
168    }
169
170    /// Find the registry name for an agent given its log file name.
171    ///
172    /// Log file names use a sanitized form of the registry name where `/` is
173    /// replaced with `-` to avoid creating subdirectories. This function
174    /// reverses that sanitization to find the original registry name.
175    ///
176    /// This is used for session continuation, where the agent name is extracted
177    /// from log file names (e.g., "ccs-glm", "opencode-anthropic-claude-sonnet-4")
178    /// but we need to look up the agent in the registry (which uses names like
179    /// "ccs/glm", "opencode/anthropic/claude-sonnet-4").
180    ///
181    /// # Strategy
182    ///
183    /// 1. Check if the name is already a valid registry key (no sanitization needed)
184    /// 2. Search registered agents for one whose sanitized name matches
185    /// 3. Try common patterns like "ccs-X" → "ccs/X", "opencode-X-Y" → "opencode/X/Y"
186    ///
187    /// # Arguments
188    ///
189    /// * `logfile_name` - The agent name extracted from a log file (e.g., "ccs-glm")
190    ///
191    /// # Returns
192    ///
193    /// The registry name if found (e.g., "ccs/glm"), or `None` if no match.
194    ///
195    /// # Examples
196    ///
197    /// ```ignore
198    /// assert_eq!(registry.resolve_from_logfile_name("ccs-glm"), Some("ccs/glm".to_string()));
199    /// assert_eq!(registry.resolve_from_logfile_name("claude"), Some("claude".to_string()));
200    /// assert_eq!(registry.resolve_from_logfile_name("opencode-anthropic-claude-sonnet-4"),
201    ///            Some("opencode/anthropic/claude-sonnet-4".to_string()));
202    /// ```
203    pub fn resolve_from_logfile_name(&self, logfile_name: &str) -> Option<String> {
204        // First check if the name is exactly a registry name (no sanitization was needed)
205        if self.agents.contains_key(logfile_name) {
206            return Some(logfile_name.to_string());
207        }
208
209        // Search registered agents for one whose sanitized name matches
210        for name in self.agents.keys() {
211            let sanitized = name.replace('/', "-");
212            if sanitized == logfile_name {
213                return Some(name.clone());
214            }
215        }
216
217        // Try to resolve dynamically for unregistered agents
218        // CCS pattern: "ccs-alias" → "ccs/alias"
219        if let Some(alias) = logfile_name.strip_prefix("ccs-") {
220            let registry_name = format!("ccs/{}", alias);
221            // CCS agents can be resolved dynamically even if not pre-registered
222            return Some(registry_name);
223        }
224
225        // OpenCode pattern: "opencode-provider-model" → "opencode/provider/model"
226        // Note: This is a best-effort heuristic for backwards compatibility.
227        // Provider names may contain hyphens (e.g., "zai-coding-plan"), making it
228        // impossible to reliably split "opencode-zai-coding-plan-glm-4.7".
229        // The preferred approach is to pass the original agent name through
230        // SessionInfo rather than relying on log file name parsing.
231        if let Some(rest) = logfile_name.strip_prefix("opencode-") {
232            if let Some(first_hyphen) = rest.find('-') {
233                let provider = &rest[..first_hyphen];
234                let model = &rest[first_hyphen + 1..];
235                let registry_name = format!("opencode/{}/{}", provider, model);
236                return Some(registry_name);
237            }
238        }
239
240        // No match found
241        None
242    }
243
244    /// Resolve a fuzzy agent name to a canonical agent name.
245    ///
246    /// This handles common typos and alternative forms:
247    /// - `ccs/<unregistered>`: Returns the name as-is for direct CCS execution
248    /// - `opencode/provider/model`: Returns the name as-is for dynamic resolution
249    /// - Other fuzzy matches: Returns the canonical name if a match is found
250    /// - Exact matches: Returns the name as-is
251    ///
252    /// Returns `None` if the name cannot be resolved to any known agent.
253    pub fn resolve_fuzzy(&self, name: &str) -> Option<String> {
254        // First check if it's an exact match
255        if self.agents.contains_key(name) {
256            return Some(name.to_string());
257        }
258
259        // Handle ccs/<unregistered> pattern - return as-is for direct CCS execution
260        if name.starts_with("ccs/") {
261            return Some(name.to_string());
262        }
263
264        // Handle opencode/provider/model pattern - return as-is for dynamic resolution
265        if name.starts_with("opencode/") {
266            // Validate that it has the right format (opencode/provider/model)
267            let parts: Vec<&str> = name.split('/').collect();
268            if parts.len() == 3 && parts[0] == "opencode" {
269                return Some(name.to_string());
270            }
271        }
272
273        // Handle common typos/alternatives
274        let normalized = name.to_lowercase();
275        let alternatives = Self::get_fuzzy_alternatives(&normalized);
276
277        for alt in alternatives {
278            // If it's a ccs/ pattern, return it for direct CCS execution
279            if alt.starts_with("ccs/") {
280                return Some(alt);
281            }
282            // If it's an opencode/ pattern, validate the format
283            if alt.starts_with("opencode/") {
284                let parts: Vec<&str> = alt.split('/').collect();
285                if parts.len() == 3 && parts[0] == "opencode" {
286                    return Some(alt);
287                }
288            }
289            // Otherwise check if it exists in the registry
290            if self.agents.contains_key(&alt) {
291                return Some(alt);
292            }
293        }
294
295        None
296    }
297
298    /// Get fuzzy alternatives for a given agent name.
299    ///
300    /// Returns a list of potential canonical names to try, in order of preference.
301    pub(crate) fn get_fuzzy_alternatives(name: &str) -> Vec<String> {
302        let mut alternatives = Vec::new();
303
304        // Add exact match first
305        alternatives.push(name.to_string());
306
307        // Handle common typos and variations
308        match name {
309            // ccs variations
310            n if n.starts_with("ccs-") => {
311                alternatives.push(name.replace("ccs-", "ccs/"));
312            }
313            n if n.contains('_') => {
314                alternatives.push(name.replace('_', "-"));
315                alternatives.push(name.replace('_', "/"));
316            }
317
318            // claude variations
319            "claud" | "cloud" => alternatives.push("claude".to_string()),
320
321            // codex variations
322            "codeex" | "code-x" => alternatives.push("codex".to_string()),
323
324            // cursor variations
325            "crusor" => alternatives.push("cursor".to_string()),
326
327            // opencode variations
328            "opencode" | "open-code" => alternatives.push("opencode".to_string()),
329
330            // gemini variations
331            "gemeni" | "gemni" => alternatives.push("gemini".to_string()),
332
333            // qwen variations
334            "quen" | "quwen" => alternatives.push("qwen".to_string()),
335
336            // aider variations
337            "ader" => alternatives.push("aider".to_string()),
338
339            // vibe variations
340            "vib" => alternatives.push("vibe".to_string()),
341
342            // cline variations
343            "kline" => alternatives.push("cline".to_string()),
344
345            _ => {}
346        }
347
348        alternatives
349    }
350
351    /// List all registered agents.
352    pub fn list(&self) -> Vec<(&str, &AgentConfig)> {
353        self.agents.iter().map(|(k, v)| (k.as_str(), v)).collect()
354    }
355
356    /// Get command for developer role.
357    pub fn developer_cmd(&self, agent_name: &str) -> Option<String> {
358        self.resolve_config(agent_name)
359            .map(|c| c.build_cmd(true, true, true))
360    }
361
362    /// Get command for reviewer role.
363    pub fn reviewer_cmd(&self, agent_name: &str) -> Option<String> {
364        self.resolve_config(agent_name)
365            .map(|c| c.build_cmd(true, true, false))
366    }
367
368    /// Load custom agents from a TOML configuration file.
369    pub fn load_from_file<P: AsRef<Path>>(&mut self, path: P) -> Result<usize, AgentConfigError> {
370        match AgentsConfigFile::load_from_file(path)? {
371            Some(config) => {
372                let count = config.agents.len();
373                for (name, agent_toml) in config.agents {
374                    self.register(&name, AgentConfig::from(agent_toml));
375                }
376                // Load fallback configuration
377                self.fallback = config.fallback;
378                Ok(count)
379            }
380            None => Ok(0),
381        }
382    }
383
384    /// Apply settings from the unified config (`~/.config/ralph-workflow.toml`).
385    ///
386    /// This merges (in increasing priority):
387    /// 1. Built-in defaults (embedded `examples/agents.toml`)
388    /// 2. Unified config: `[agents]`, `[ccs_aliases]`, and `[agent_chain]` (if present)
389    ///
390    /// Returns the number of agents loaded from unified config, including CCS aliases.
391    pub fn apply_unified_config(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
392        let mut loaded = self.apply_ccs_aliases(unified);
393        loaded += self.apply_agent_overrides(unified);
394
395        if let Some(chain) = &unified.agent_chain {
396            self.fallback = chain.clone();
397        }
398
399        loaded
400    }
401
402    /// Apply CCS aliases from the unified config.
403    fn apply_ccs_aliases(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
404        if unified.ccs_aliases.is_empty() {
405            return 0;
406        }
407
408        let loaded = unified.ccs_aliases.len();
409        let aliases = unified
410            .ccs_aliases
411            .iter()
412            .map(|(name, v)| (name.clone(), v.as_config()))
413            .collect::<HashMap<_, _>>();
414        self.set_ccs_aliases(&aliases, unified.ccs.clone());
415        loaded
416    }
417
418    /// Apply agent overrides from the unified config.
419    fn apply_agent_overrides(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
420        if unified.agents.is_empty() {
421            return 0;
422        }
423
424        let mut loaded = 0usize;
425        for (name, overrides) in &unified.agents {
426            if let Some(existing) = self.agents.get(name).cloned() {
427                // Merge with existing agent
428                let merged = Self::merge_agent_config(existing, overrides);
429                self.register(name, merged);
430                loaded += 1;
431            } else {
432                // New agent definition: require a non-empty command.
433                if let Some(config) = Self::create_new_agent_config(overrides) {
434                    self.register(name, config);
435                    loaded += 1;
436                }
437            }
438        }
439        loaded
440    }
441
442    /// Create a new agent config from unified config overrides.
443    fn create_new_agent_config(
444        overrides: &crate::config::unified::AgentConfigToml,
445    ) -> Option<AgentConfig> {
446        let cmd = overrides
447            .cmd
448            .as_deref()
449            .map(str::trim)
450            .filter(|s| !s.is_empty())?;
451
452        let json_parser = overrides
453            .json_parser
454            .as_deref()
455            .map(str::trim)
456            .filter(|s| !s.is_empty())
457            .unwrap_or("generic");
458
459        Some(AgentConfig {
460            cmd: cmd.to_string(),
461            output_flag: overrides.output_flag.clone().unwrap_or_default(),
462            yolo_flag: overrides.yolo_flag.clone().unwrap_or_default(),
463            verbose_flag: overrides.verbose_flag.clone().unwrap_or_default(),
464            can_commit: overrides.can_commit.unwrap_or(true),
465            json_parser: JsonParserType::parse(json_parser),
466            model_flag: overrides.model_flag.clone(),
467            print_flag: overrides.print_flag.clone().unwrap_or_default(),
468            streaming_flag: overrides.streaming_flag.clone().unwrap_or_else(|| {
469                // Default to "--include-partial-messages" for Claude/CCS agents
470                if cmd.starts_with("claude") || cmd.starts_with("ccs") {
471                    "--include-partial-messages".to_string()
472                } else {
473                    String::new()
474                }
475            }),
476            session_flag: overrides.session_flag.clone().unwrap_or_else(|| {
477                // Default session continuation flags for known agents
478                // These flags are verified from CLI --help output:
479                // - Claude: --resume <session_id> (from `claude --help`)
480                // - OpenCode: -s <session_id> (from `opencode run --help`)
481                // - Codex: Uses `codex exec resume <id>` subcommand, not a flag - not supported
482                if cmd.starts_with("claude") || cmd.starts_with("ccs") {
483                    "--resume {}".to_string()
484                } else if cmd.starts_with("opencode") {
485                    "-s {}".to_string()
486                } else {
487                    String::new()
488                }
489            }),
490            env_vars: std::collections::HashMap::new(),
491            display_name: overrides
492                .display_name
493                .as_ref()
494                .filter(|s| !s.is_empty())
495                .cloned(),
496        })
497    }
498
499    /// Merge overrides with existing agent config.
500    fn merge_agent_config(
501        existing: AgentConfig,
502        overrides: &crate::config::unified::AgentConfigToml,
503    ) -> AgentConfig {
504        AgentConfig {
505            cmd: overrides
506                .cmd
507                .as_deref()
508                .map(str::trim)
509                .filter(|s| !s.is_empty())
510                .map(str::to_string)
511                .unwrap_or(existing.cmd),
512            output_flag: overrides
513                .output_flag
514                .clone()
515                .unwrap_or(existing.output_flag),
516            yolo_flag: overrides.yolo_flag.clone().unwrap_or(existing.yolo_flag),
517            verbose_flag: overrides
518                .verbose_flag
519                .clone()
520                .unwrap_or(existing.verbose_flag),
521            can_commit: overrides.can_commit.unwrap_or(existing.can_commit),
522            json_parser: overrides
523                .json_parser
524                .as_deref()
525                .map(str::trim)
526                .filter(|s| !s.is_empty())
527                .map_or(existing.json_parser, JsonParserType::parse),
528            model_flag: overrides.model_flag.clone().or(existing.model_flag),
529            print_flag: overrides.print_flag.clone().unwrap_or(existing.print_flag),
530            streaming_flag: overrides
531                .streaming_flag
532                .clone()
533                .unwrap_or(existing.streaming_flag),
534            session_flag: overrides
535                .session_flag
536                .clone()
537                .unwrap_or(existing.session_flag),
538            // Do NOT inherit env_vars from the existing agent to prevent
539            // CCS env vars from one agent from leaking into another.
540            // The unified config (unified::AgentConfigToml) doesn't support
541            // ccs_profile or env_vars fields, so we always start fresh.
542            env_vars: std::collections::HashMap::new(),
543            // Preserve existing display name unless explicitly overridden
544            // Empty string explicitly clears the display name
545            display_name: match &overrides.display_name {
546                Some(s) if s.is_empty() => None,
547                Some(s) => Some(s.clone()),
548                None => existing.display_name,
549            },
550        }
551    }
552
553    /// Get the fallback configuration.
554    pub const fn fallback_config(&self) -> &FallbackConfig {
555        &self.fallback
556    }
557
558    /// Get the retry timer provider.
559    pub fn retry_timer(&self) -> Arc<dyn RetryTimerProvider> {
560        Arc::clone(&self.retry_timer)
561    }
562
563    /// Set the retry timer provider (for testing purposes).
564    ///
565    /// This is used to inject a test timer that doesn't actually sleep,
566    /// enabling fast test execution without waiting for retry delays.
567    #[cfg(any(test, feature = "test-utils"))]
568    pub fn set_retry_timer(&mut self, timer: Arc<dyn RetryTimerProvider>) {
569        self.retry_timer = timer;
570    }
571
572    /// Get all fallback agents for a role that are registered in this registry.
573    pub fn available_fallbacks(&self, role: AgentRole) -> Vec<&str> {
574        self.fallback
575            .get_fallbacks(role)
576            .iter()
577            .filter(|name| self.is_agent_available(name))
578            // Agents with can_commit=false are chat-only / non-tool agents and will stall Ralph.
579            .filter(|name| {
580                self.resolve_config(name.as_str())
581                    .is_some_and(|cfg| cfg.can_commit)
582            })
583            .map(std::string::String::as_str)
584            .collect()
585    }
586
587    /// Validate that agent chains are configured for both roles.
588    pub fn validate_agent_chains(&self) -> Result<(), String> {
589        let has_developer = self.fallback.has_fallbacks(AgentRole::Developer);
590        let has_reviewer = self.fallback.has_fallbacks(AgentRole::Reviewer);
591
592        if !has_developer && !has_reviewer {
593            return Err("No agent chain configured.\n\
594                Please add an [agent_chain] section to ~/.config/ralph-workflow.toml.\n\
595                Run 'ralph --init-global' to create a default configuration."
596                .to_string());
597        }
598
599        if !has_developer {
600            return Err("No developer agent chain configured.\n\
601                Add 'developer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
602                Use --list-agents to see available agents."
603                .to_string());
604        }
605
606        if !has_reviewer {
607            return Err("No reviewer agent chain configured.\n\
608                Add 'reviewer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
609                Use --list-agents to see available agents."
610                .to_string());
611        }
612
613        // Sanity check: ensure there is at least one workflow-capable agent per role.
614        for role in [AgentRole::Developer, AgentRole::Reviewer] {
615            let chain = self.fallback.get_fallbacks(role);
616            let has_capable = chain
617                .iter()
618                .any(|name| self.resolve_config(name).is_some_and(|cfg| cfg.can_commit));
619            if !has_capable {
620                return Err(format!(
621                    "No workflow-capable agents found for {role}.\n\
622                    All agents in the {role} chain have can_commit=false.\n\
623                    Fix: set can_commit=true for at least one agent or update [agent_chain]."
624                ));
625            }
626        }
627
628        Ok(())
629    }
630
631    /// Check if an agent is available (command exists and is executable).
632    pub fn is_agent_available(&self, name: &str) -> bool {
633        if let Some(config) = self.resolve_config(name) {
634            let Ok(parts) = crate::common::split_command(&config.cmd) else {
635                return false;
636            };
637            let Some(base_cmd) = parts.first() else {
638                return false;
639            };
640
641            // Check if the command exists in PATH
642            which::which(base_cmd).is_ok()
643        } else {
644            false
645        }
646    }
647
648    /// List all available (installed) agents.
649    pub fn list_available(&self) -> Vec<&str> {
650        self.agents
651            .keys()
652            .filter(|name| self.is_agent_available(name))
653            .map(std::string::String::as_str)
654            .collect()
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::agents::JsonParserType;
662
663    fn default_ccs() -> CcsConfig {
664        CcsConfig::default()
665    }
666
667    #[test]
668    fn test_registry_new() {
669        let registry = AgentRegistry::new().unwrap();
670        // Behavioral test: agents are registered if they resolve
671        assert!(registry.resolve_config("claude").is_some());
672        assert!(registry.resolve_config("codex").is_some());
673    }
674
675    #[test]
676    fn test_registry_register() {
677        let mut registry = AgentRegistry::new().unwrap();
678        registry.register(
679            "testbot",
680            AgentConfig {
681                cmd: "testbot run".to_string(),
682                output_flag: "--json".to_string(),
683                yolo_flag: "--yes".to_string(),
684                verbose_flag: String::new(),
685                can_commit: true,
686                json_parser: JsonParserType::Generic,
687                model_flag: None,
688                print_flag: String::new(),
689                streaming_flag: String::new(),
690                session_flag: String::new(),
691                env_vars: std::collections::HashMap::new(),
692                display_name: None,
693            },
694        );
695        // Behavioral test: registered agent should resolve
696        assert!(registry.resolve_config("testbot").is_some());
697    }
698
699    #[test]
700    fn test_registry_display_name() {
701        let mut registry = AgentRegistry::new().unwrap();
702
703        // Agent without custom display name uses registry key
704        registry.register(
705            "claude",
706            AgentConfig {
707                cmd: "claude -p".to_string(),
708                output_flag: "--output-format=stream-json".to_string(),
709                yolo_flag: "--dangerously-skip-permissions".to_string(),
710                verbose_flag: "--verbose".to_string(),
711                can_commit: true,
712                json_parser: JsonParserType::Claude,
713                model_flag: None,
714                print_flag: String::new(),
715                streaming_flag: "--include-partial-messages".to_string(),
716                session_flag: "--resume {}".to_string(),
717                env_vars: std::collections::HashMap::new(),
718                display_name: None,
719            },
720        );
721
722        // Agent with custom display name uses that
723        registry.register(
724            "claude",
725            AgentConfig {
726                cmd: "claude -p".to_string(),
727                output_flag: "--output-format=stream-json".to_string(),
728                yolo_flag: "--dangerously-skip-permissions".to_string(),
729                verbose_flag: "--verbose".to_string(),
730                can_commit: true,
731                json_parser: JsonParserType::Claude,
732                model_flag: None,
733                print_flag: String::new(),
734                streaming_flag: "--include-partial-messages".to_string(),
735                session_flag: "--resume {}".to_string(),
736                env_vars: std::collections::HashMap::new(),
737                display_name: None,
738            },
739        );
740
741        // Test display names
742        assert_eq!(registry.display_name("claude"), "claude");
743        assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
744
745        // Unknown agent returns the key as-is
746        assert_eq!(registry.display_name("unknown"), "unknown");
747    }
748
749    #[test]
750    fn test_resolve_from_logfile_name() {
751        let mut registry = AgentRegistry::new().unwrap();
752
753        // Register a CCS agent with slash in name
754        registry.register(
755            "ccs/glm",
756            AgentConfig {
757                cmd: "ccs glm".to_string(),
758                output_flag: "--output-format=stream-json".to_string(),
759                yolo_flag: "--dangerously-skip-permissions".to_string(),
760                verbose_flag: "--verbose".to_string(),
761                can_commit: true,
762                json_parser: JsonParserType::Claude,
763                model_flag: None,
764                print_flag: "-p".to_string(),
765                streaming_flag: "--include-partial-messages".to_string(),
766                session_flag: "--resume {}".to_string(),
767                env_vars: std::collections::HashMap::new(),
768                display_name: Some("ccs-glm".to_string()),
769            },
770        );
771
772        // Register a plain agent without slash
773        registry.register(
774            "claude",
775            AgentConfig {
776                cmd: "claude -p".to_string(),
777                output_flag: "--output-format=stream-json".to_string(),
778                yolo_flag: "--dangerously-skip-permissions".to_string(),
779                verbose_flag: "--verbose".to_string(),
780                can_commit: true,
781                json_parser: JsonParserType::Claude,
782                model_flag: None,
783                print_flag: String::new(),
784                streaming_flag: "--include-partial-messages".to_string(),
785                session_flag: "--resume {}".to_string(),
786                env_vars: std::collections::HashMap::new(),
787                display_name: None,
788            },
789        );
790
791        // Register an OpenCode agent with multiple slashes
792        registry.register(
793            "opencode/anthropic/claude-sonnet-4",
794            AgentConfig {
795                cmd: "opencode run".to_string(),
796                output_flag: "--format json".to_string(),
797                yolo_flag: String::new(),
798                verbose_flag: "--log-level DEBUG".to_string(),
799                can_commit: true,
800                json_parser: JsonParserType::OpenCode,
801                model_flag: Some("-p anthropic -m claude-sonnet-4".to_string()),
802                print_flag: String::new(),
803                streaming_flag: String::new(),
804                session_flag: "-s {}".to_string(),
805                env_vars: std::collections::HashMap::new(),
806                display_name: Some("OpenCode (anthropic)".to_string()),
807            },
808        );
809
810        // Test: Agent names that don't need sanitization
811        assert_eq!(
812            registry.resolve_from_logfile_name("claude"),
813            Some("claude".to_string())
814        );
815
816        // Test: CCS agent - sanitized name resolved to registry name
817        assert_eq!(
818            registry.resolve_from_logfile_name("ccs-glm"),
819            Some("ccs/glm".to_string())
820        );
821
822        // Test: OpenCode agent - sanitized name resolved to registry name
823        assert_eq!(
824            registry.resolve_from_logfile_name("opencode-anthropic-claude-sonnet-4"),
825            Some("opencode/anthropic/claude-sonnet-4".to_string())
826        );
827
828        // Test: Unregistered CCS agent - should still resolve via pattern matching
829        assert_eq!(
830            registry.resolve_from_logfile_name("ccs-zai"),
831            Some("ccs/zai".to_string())
832        );
833
834        // Test: Unregistered OpenCode agent - should still resolve via pattern matching
835        assert_eq!(
836            registry.resolve_from_logfile_name("opencode-google-gemini-pro"),
837            Some("opencode/google/gemini-pro".to_string())
838        );
839
840        // Test: Unknown agent returns None
841        assert_eq!(registry.resolve_from_logfile_name("unknown-agent"), None);
842    }
843
844    #[test]
845    fn test_registry_available_fallbacks() {
846        // Test that available_fallbacks filters to only agents with commands in PATH.
847        // Uses system commands (echo, cat) that exist on all systems to avoid
848        // creating real executables or modifying PATH.
849        let mut registry = AgentRegistry::new().unwrap();
850
851        // Register agents using commands that exist on all systems
852        registry.register(
853            "echo-agent",
854            AgentConfig {
855                cmd: "echo test".to_string(),
856                output_flag: String::new(),
857                yolo_flag: String::new(),
858                verbose_flag: String::new(),
859                can_commit: true,
860                json_parser: JsonParserType::Generic,
861                model_flag: None,
862                print_flag: String::new(),
863                streaming_flag: String::new(),
864                session_flag: String::new(),
865                env_vars: std::collections::HashMap::new(),
866                display_name: None,
867            },
868        );
869        registry.register(
870            "cat-agent",
871            AgentConfig {
872                cmd: "cat --version".to_string(),
873                output_flag: String::new(),
874                yolo_flag: String::new(),
875                verbose_flag: String::new(),
876                can_commit: true,
877                json_parser: JsonParserType::Generic,
878                model_flag: None,
879                print_flag: String::new(),
880                streaming_flag: String::new(),
881                session_flag: String::new(),
882                env_vars: std::collections::HashMap::new(),
883                display_name: None,
884            },
885        );
886        registry.register(
887            "nonexistent-agent",
888            AgentConfig {
889                cmd: "this-command-definitely-does-not-exist-xyz123".to_string(),
890                output_flag: String::new(),
891                yolo_flag: String::new(),
892                verbose_flag: String::new(),
893                can_commit: true,
894                json_parser: JsonParserType::Generic,
895                model_flag: None,
896                print_flag: String::new(),
897                streaming_flag: String::new(),
898                session_flag: String::new(),
899                env_vars: std::collections::HashMap::new(),
900                display_name: None,
901            },
902        );
903
904        // Set fallback chain using registered agents
905        let toml_str = r#"
906            [agent_chain]
907            developer = ["echo-agent", "nonexistent-agent", "cat-agent"]
908        "#;
909        let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
910        registry.apply_unified_config(&unified);
911
912        let fallbacks = registry.available_fallbacks(AgentRole::Developer);
913        assert!(
914            fallbacks.contains(&"echo-agent"),
915            "echo-agent should be available"
916        );
917        assert!(
918            fallbacks.contains(&"cat-agent"),
919            "cat-agent should be available"
920        );
921        assert!(
922            !fallbacks.contains(&"nonexistent-agent"),
923            "nonexistent-agent should not be available"
924        );
925    }
926
927    #[test]
928    fn test_validate_agent_chains() {
929        let mut registry = AgentRegistry::new().unwrap();
930
931        // Both chains configured should pass - use apply_unified_config (public API)
932        let toml_str = r#"
933            [agent_chain]
934            developer = ["claude"]
935            reviewer = ["codex"]
936        "#;
937        let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
938        registry.apply_unified_config(&unified);
939        assert!(registry.validate_agent_chains().is_ok());
940    }
941
942    #[test]
943    fn test_ccs_aliases_registration() {
944        // Test that CCS aliases are registered correctly
945        let mut registry = AgentRegistry::new().unwrap();
946
947        let mut aliases = HashMap::new();
948        aliases.insert(
949            "work".to_string(),
950            CcsAliasConfig {
951                cmd: "ccs work".to_string(),
952                ..CcsAliasConfig::default()
953            },
954        );
955        aliases.insert(
956            "personal".to_string(),
957            CcsAliasConfig {
958                cmd: "ccs personal".to_string(),
959                ..CcsAliasConfig::default()
960            },
961        );
962        aliases.insert(
963            "gemini".to_string(),
964            CcsAliasConfig {
965                cmd: "ccs gemini".to_string(),
966                ..CcsAliasConfig::default()
967            },
968        );
969
970        registry.set_ccs_aliases(&aliases, default_ccs());
971
972        // CCS aliases should be registered as agents - behavioral test: they resolve
973        assert!(registry.resolve_config("ccs/work").is_some());
974        assert!(registry.resolve_config("ccs/personal").is_some());
975        assert!(registry.resolve_config("ccs/gemini").is_some());
976
977        // Get should return valid config
978        let config = registry.resolve_config("ccs/work").unwrap();
979        // When claude binary is found, it replaces "ccs work" with the path to claude
980        assert!(
981            config.cmd.ends_with("claude") || config.cmd == "ccs work",
982            "cmd should be 'ccs work' or a path ending with 'claude', got: {}",
983            config.cmd
984        );
985        assert!(config.can_commit);
986        assert_eq!(config.json_parser, JsonParserType::Claude);
987    }
988
989    #[test]
990    fn test_ccs_in_fallback_chain() {
991        // Test that CCS aliases can be used in fallback chains.
992        // Uses `echo` command which exists on all systems to avoid creating
993        // real executables or modifying PATH.
994        let mut registry = AgentRegistry::new().unwrap();
995
996        // Register CCS aliases using echo command (exists on all systems)
997        let mut aliases = HashMap::new();
998        aliases.insert(
999            "work".to_string(),
1000            CcsAliasConfig {
1001                cmd: "echo work".to_string(),
1002                ..CcsAliasConfig::default()
1003            },
1004        );
1005        registry.set_ccs_aliases(&aliases, default_ccs());
1006
1007        // Register a system command agent for comparison
1008        registry.register(
1009            "echo-agent",
1010            AgentConfig {
1011                cmd: "echo test".to_string(),
1012                output_flag: String::new(),
1013                yolo_flag: String::new(),
1014                verbose_flag: String::new(),
1015                can_commit: true,
1016                json_parser: JsonParserType::Generic,
1017                model_flag: None,
1018                print_flag: String::new(),
1019                streaming_flag: String::new(),
1020                session_flag: String::new(),
1021                env_vars: std::collections::HashMap::new(),
1022                display_name: None,
1023            },
1024        );
1025
1026        // Set fallback chain with CCS alias using apply_unified_config (public API)
1027        let toml_str = r#"
1028            [agent_chain]
1029            developer = ["ccs/work", "echo-agent"]
1030            reviewer = ["echo-agent"]
1031        "#;
1032        let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
1033        registry.apply_unified_config(&unified);
1034
1035        // ccs/work should be in available fallbacks (since echo is in PATH)
1036        let fallbacks = registry.available_fallbacks(AgentRole::Developer);
1037        assert!(
1038            fallbacks.contains(&"ccs/work"),
1039            "ccs/work should be available"
1040        );
1041        assert!(
1042            fallbacks.contains(&"echo-agent"),
1043            "echo-agent should be available"
1044        );
1045
1046        // Validate chains should pass
1047        assert!(registry.validate_agent_chains().is_ok());
1048    }
1049
1050    #[test]
1051    fn test_ccs_aliases_with_registry_constructor() {
1052        let mut registry = AgentRegistry::new().unwrap();
1053        registry.set_ccs_aliases(&HashMap::new(), default_ccs());
1054
1055        // Should have built-in agents - behavioral test: they resolve
1056        assert!(registry.resolve_config("claude").is_some());
1057        assert!(registry.resolve_config("codex").is_some());
1058
1059        // Now test with actual aliases
1060        let mut registry2 = AgentRegistry::new().unwrap();
1061        let mut aliases = HashMap::new();
1062        aliases.insert(
1063            "work".to_string(),
1064            CcsAliasConfig {
1065                cmd: "ccs work".to_string(),
1066                ..CcsAliasConfig::default()
1067            },
1068        );
1069
1070        registry2.set_ccs_aliases(&aliases, default_ccs());
1071        // Behavioral test: CCS alias should resolve
1072        assert!(registry2.resolve_config("ccs/work").is_some());
1073    }
1074
1075    #[test]
1076    fn test_list_includes_ccs_aliases() {
1077        let mut registry = AgentRegistry::new().unwrap();
1078
1079        let mut aliases = HashMap::new();
1080        aliases.insert(
1081            "work".to_string(),
1082            CcsAliasConfig {
1083                cmd: "ccs work".to_string(),
1084                ..CcsAliasConfig::default()
1085            },
1086        );
1087        aliases.insert(
1088            "personal".to_string(),
1089            CcsAliasConfig {
1090                cmd: "ccs personal".to_string(),
1091                ..CcsAliasConfig::default()
1092            },
1093        );
1094        registry.set_ccs_aliases(&aliases, default_ccs());
1095
1096        let all_agents = registry.list();
1097
1098        assert_eq!(
1099            all_agents
1100                .iter()
1101                .filter(|(name, _)| name.starts_with("ccs/"))
1102                .count(),
1103            2
1104        );
1105    }
1106
1107    #[test]
1108    fn test_resolve_fuzzy_exact_match() {
1109        let registry = AgentRegistry::new().unwrap();
1110        assert_eq!(registry.resolve_fuzzy("claude"), Some("claude".to_string()));
1111        assert_eq!(registry.resolve_fuzzy("codex"), Some("codex".to_string()));
1112    }
1113
1114    #[test]
1115    fn test_resolve_fuzzy_ccs_unregistered() {
1116        let registry = AgentRegistry::new().unwrap();
1117        // ccs/<unregistered> should return as-is for direct execution
1118        assert_eq!(
1119            registry.resolve_fuzzy("ccs/random"),
1120            Some("ccs/random".to_string())
1121        );
1122        assert_eq!(
1123            registry.resolve_fuzzy("ccs/unregistered"),
1124            Some("ccs/unregistered".to_string())
1125        );
1126    }
1127
1128    #[test]
1129    fn test_resolve_fuzzy_typos() {
1130        let registry = AgentRegistry::new().unwrap();
1131        // Test common typos
1132        assert_eq!(registry.resolve_fuzzy("claud"), Some("claude".to_string()));
1133        assert_eq!(registry.resolve_fuzzy("CLAUD"), Some("claude".to_string()));
1134    }
1135
1136    #[test]
1137    fn test_resolve_fuzzy_codex_variations() {
1138        let registry = AgentRegistry::new().unwrap();
1139        // Test codex variations
1140        assert_eq!(registry.resolve_fuzzy("codeex"), Some("codex".to_string()));
1141        assert_eq!(registry.resolve_fuzzy("code-x"), Some("codex".to_string()));
1142        assert_eq!(registry.resolve_fuzzy("CODEEX"), Some("codex".to_string()));
1143    }
1144
1145    #[test]
1146    fn test_resolve_fuzzy_cursor_variations() {
1147        let registry = AgentRegistry::new().unwrap();
1148        // Test cursor variations
1149        assert_eq!(registry.resolve_fuzzy("crusor"), Some("cursor".to_string()));
1150        assert_eq!(registry.resolve_fuzzy("CRUSOR"), Some("cursor".to_string()));
1151    }
1152
1153    #[test]
1154    fn test_resolve_fuzzy_gemini_variations() {
1155        let registry = AgentRegistry::new().unwrap();
1156        // Test gemini variations
1157        assert_eq!(registry.resolve_fuzzy("gemeni"), Some("gemini".to_string()));
1158        assert_eq!(registry.resolve_fuzzy("gemni"), Some("gemini".to_string()));
1159        assert_eq!(registry.resolve_fuzzy("GEMENI"), Some("gemini".to_string()));
1160    }
1161
1162    #[test]
1163    fn test_resolve_fuzzy_qwen_variations() {
1164        let registry = AgentRegistry::new().unwrap();
1165        // Test qwen variations
1166        assert_eq!(registry.resolve_fuzzy("quen"), Some("qwen".to_string()));
1167        assert_eq!(registry.resolve_fuzzy("quwen"), Some("qwen".to_string()));
1168        assert_eq!(registry.resolve_fuzzy("QUEN"), Some("qwen".to_string()));
1169    }
1170
1171    #[test]
1172    fn test_resolve_fuzzy_aider_variations() {
1173        let registry = AgentRegistry::new().unwrap();
1174        // Test aider variations
1175        assert_eq!(registry.resolve_fuzzy("ader"), Some("aider".to_string()));
1176        assert_eq!(registry.resolve_fuzzy("ADER"), Some("aider".to_string()));
1177    }
1178
1179    #[test]
1180    fn test_resolve_fuzzy_vibe_variations() {
1181        let registry = AgentRegistry::new().unwrap();
1182        // Test vibe variations
1183        assert_eq!(registry.resolve_fuzzy("vib"), Some("vibe".to_string()));
1184        assert_eq!(registry.resolve_fuzzy("VIB"), Some("vibe".to_string()));
1185    }
1186
1187    #[test]
1188    fn test_resolve_fuzzy_cline_variations() {
1189        let registry = AgentRegistry::new().unwrap();
1190        // Test cline variations
1191        assert_eq!(registry.resolve_fuzzy("kline"), Some("cline".to_string()));
1192        assert_eq!(registry.resolve_fuzzy("KLINE"), Some("cline".to_string()));
1193    }
1194
1195    #[test]
1196    fn test_resolve_fuzzy_ccs_dash_to_slash() {
1197        let registry = AgentRegistry::new().unwrap();
1198        // Test ccs- to ccs/ conversion (even for unregistered aliases)
1199        assert_eq!(
1200            registry.resolve_fuzzy("ccs-random"),
1201            Some("ccs/random".to_string())
1202        );
1203        assert_eq!(
1204            registry.resolve_fuzzy("ccs-test"),
1205            Some("ccs/test".to_string())
1206        );
1207    }
1208
1209    #[test]
1210    fn test_resolve_fuzzy_underscore_replacement() {
1211        // Test underscore to dash/slash replacement
1212        // Note: These test the pattern, actual agents may not exist
1213        let result = AgentRegistry::get_fuzzy_alternatives("my_agent");
1214        assert!(result.contains(&"my_agent".to_string()));
1215        assert!(result.contains(&"my-agent".to_string()));
1216        assert!(result.contains(&"my/agent".to_string()));
1217    }
1218
1219    #[test]
1220    fn test_resolve_fuzzy_unknown() {
1221        let registry = AgentRegistry::new().unwrap();
1222        // Unknown agent should return None
1223        assert_eq!(registry.resolve_fuzzy("totally-unknown"), None);
1224    }
1225
1226    #[test]
1227    fn test_apply_unified_config_does_not_inherit_env_vars() {
1228        // Regression test for CCS env vars leaking between agents.
1229        // Ensures that when apply_unified_config merges agent configurations,
1230        // env_vars from the existing agent are NOT inherited into the merged agent.
1231        let mut registry = AgentRegistry::new().unwrap();
1232
1233        // First, manually register a "claude" agent with some env vars (simulating
1234        // a previously-loaded agent with CCS env vars or manually-specified vars)
1235        registry.register(
1236            "claude",
1237            AgentConfig {
1238                cmd: "claude -p".to_string(),
1239                output_flag: "--output-format=stream-json".to_string(),
1240                yolo_flag: "--dangerously-skip-permissions".to_string(),
1241                verbose_flag: "--verbose".to_string(),
1242                can_commit: true,
1243                json_parser: JsonParserType::Claude,
1244                model_flag: None,
1245                print_flag: String::new(),
1246                streaming_flag: "--include-partial-messages".to_string(),
1247                session_flag: "--resume {}".to_string(),
1248                // Simulate CCS env vars from a previous load
1249                env_vars: {
1250                    let mut vars = std::collections::HashMap::new();
1251                    vars.insert(
1252                        "ANTHROPIC_BASE_URL".to_string(),
1253                        "https://api.z.ai/api/anthropic".to_string(),
1254                    );
1255                    vars.insert(
1256                        "ANTHROPIC_AUTH_TOKEN".to_string(),
1257                        "test-token-glm".to_string(),
1258                    );
1259                    vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1260                    vars
1261                },
1262                display_name: None,
1263            },
1264        );
1265
1266        // Verify the "claude" agent has the GLM env vars
1267        let claude_config = registry.resolve_config("claude").unwrap();
1268        assert_eq!(claude_config.env_vars.len(), 3);
1269        assert_eq!(
1270            claude_config.env_vars.get("ANTHROPIC_BASE_URL"),
1271            Some(&"https://api.z.ai/api/anthropic".to_string())
1272        );
1273
1274        // Now apply a unified config that overrides the "claude" agent
1275        // (simulating user's ~/.config/ralph-workflow.toml with [agents.claude])
1276        // Create a minimal GeneralConfig via Default for UnifiedConfig
1277        // Note: We can't directly construct UnifiedConfig with Default because agents is not Default
1278        // So we'll create it by deserializing from a TOML string
1279        let toml_str = r#"
1280            [general]
1281            verbosity = 2
1282            interactive = true
1283            isolation_mode = true
1284
1285            [agents.claude]
1286            cmd = "claude -p"
1287            display_name = "My Custom Claude"
1288        "#;
1289        let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
1290
1291        // Apply the unified config
1292        registry.apply_unified_config(&unified);
1293
1294        // Verify that the "claude" agent's env_vars are now empty (NOT inherited)
1295        let claude_config_after = registry.resolve_config("claude").unwrap();
1296        assert_eq!(
1297            claude_config_after.env_vars.len(),
1298            0,
1299            "env_vars should NOT be inherited from the existing agent when unified config is applied"
1300        );
1301        assert_eq!(
1302            claude_config_after.display_name,
1303            Some("My Custom Claude".to_string()),
1304            "display_name should be updated from the unified config"
1305        );
1306    }
1307
1308    #[test]
1309    fn test_resolve_config_does_not_share_env_vars_between_agents() {
1310        // Regression test for the exact bug scenario:
1311        // 1. User runs Ralph with ccs/glm agent (with GLM env vars)
1312        // 2. User then runs Ralph with claude agent
1313        // 3. Claude should NOT have GLM env vars
1314        //
1315        // This test verifies that resolve_config() returns independent AgentConfig
1316        // instances with separate env_vars HashMaps - i.e., modifications to one
1317        // agent's env_vars don't affect another agent's config.
1318        let mut registry = AgentRegistry::new().unwrap();
1319
1320        // Register ccs/glm with GLM environment variables
1321        registry.register(
1322            "ccs/glm",
1323            AgentConfig {
1324                cmd: "ccs glm".to_string(),
1325                output_flag: "--output-format=stream-json".to_string(),
1326                yolo_flag: "--dangerously-skip-permissions".to_string(),
1327                verbose_flag: "--verbose".to_string(),
1328                can_commit: true,
1329                json_parser: JsonParserType::Claude,
1330                model_flag: None,
1331                print_flag: "-p".to_string(),
1332                streaming_flag: "--include-partial-messages".to_string(),
1333                session_flag: "--resume {}".to_string(),
1334                env_vars: {
1335                    let mut vars = std::collections::HashMap::new();
1336                    vars.insert(
1337                        "ANTHROPIC_BASE_URL".to_string(),
1338                        "https://api.z.ai/api/anthropic".to_string(),
1339                    );
1340                    vars.insert(
1341                        "ANTHROPIC_AUTH_TOKEN".to_string(),
1342                        "test-token-glm".to_string(),
1343                    );
1344                    vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1345                    vars
1346                },
1347                display_name: Some("ccs-glm".to_string()),
1348            },
1349        );
1350
1351        // Register claude with empty env_vars (typical configuration)
1352        registry.register(
1353            "claude",
1354            AgentConfig {
1355                cmd: "claude -p".to_string(),
1356                output_flag: "--output-format=stream-json".to_string(),
1357                yolo_flag: "--dangerously-skip-permissions".to_string(),
1358                verbose_flag: "--verbose".to_string(),
1359                can_commit: true,
1360                json_parser: JsonParserType::Claude,
1361                model_flag: None,
1362                print_flag: String::new(),
1363                streaming_flag: "--include-partial-messages".to_string(),
1364                session_flag: "--resume {}".to_string(),
1365                env_vars: std::collections::HashMap::new(),
1366                display_name: None,
1367            },
1368        );
1369
1370        // Resolve ccs/glm config first
1371        let glm_config = registry.resolve_config("ccs/glm").unwrap();
1372        assert_eq!(glm_config.env_vars.len(), 3);
1373        assert_eq!(
1374            glm_config.env_vars.get("ANTHROPIC_BASE_URL"),
1375            Some(&"https://api.z.ai/api/anthropic".to_string())
1376        );
1377
1378        // Resolve claude config
1379        let claude_config = registry.resolve_config("claude").unwrap();
1380        assert_eq!(
1381            claude_config.env_vars.len(),
1382            0,
1383            "claude agent should have empty env_vars"
1384        );
1385
1386        // Resolve ccs/glm again to ensure we get a fresh clone
1387        let glm_config2 = registry.resolve_config("ccs/glm").unwrap();
1388        assert_eq!(glm_config2.env_vars.len(), 3);
1389
1390        // Modify the first GLM config's env_vars
1391        // This should NOT affect the second GLM config if cloning is deep
1392        drop(glm_config);
1393
1394        // Verify claude still has empty env_vars after another resolve
1395        let claude_config2 = registry.resolve_config("claude").unwrap();
1396        assert_eq!(
1397            claude_config2.env_vars.len(),
1398            0,
1399            "claude agent env_vars should remain independent"
1400        );
1401    }
1402}