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