Skip to main content

ralph_workflow/agents/
config.rs

1//! Agent configuration types and TOML parsing.
2//!
3//! This module provides types for loading and managing agent configurations
4//! from TOML files, including support for global and per-project configs.
5
6use super::ccs_env::{load_ccs_env_vars, CcsEnvVarsError};
7use super::fallback::FallbackConfig;
8use super::parser::JsonParserType;
9use serde::Deserialize;
10use std::collections::HashMap;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15/// Default agents.toml template embedded at compile time.
16pub const DEFAULT_AGENTS_TOML: &str = include_str!("../../examples/agents.toml");
17
18/// Config source for tracking where config was loaded from.
19#[derive(Debug, Clone)]
20pub struct ConfigSource {
21    pub path: PathBuf,
22    pub agents_loaded: usize,
23}
24
25/// Agent capabilities.
26#[derive(Debug, Clone)]
27pub struct AgentConfig {
28    /// Base command to run the agent.
29    pub cmd: String,
30    /// Output-format flag (JSON streaming, text mode, etc.).
31    pub output_flag: String,
32    /// Flag for autonomous mode (no prompts).
33    pub yolo_flag: String,
34    /// Flag for verbose output.
35    pub verbose_flag: String,
36    /// Whether the agent can run git commit.
37    pub can_commit: bool,
38    /// Which JSON parser to use for this agent's output.
39    pub json_parser: JsonParserType,
40    /// Model/provider flag for agents that support model selection.
41    pub model_flag: Option<String>,
42    /// Print/non-interactive mode flag (e.g., "-p" for Claude/CCS).
43    pub print_flag: String,
44    /// Include partial messages flag for streaming with -p (e.g., "--include-partial-messages").
45    /// Required for Claude/CCS to stream JSON output when using -p mode.
46    pub streaming_flag: String,
47    /// Session continuation flag template (e.g., "--session {}" for OpenCode).
48    /// The `{}` placeholder is replaced with the session ID at runtime.
49    /// If empty, session continuation is not supported for this agent.
50    pub session_flag: String,
51    /// Environment variables to set when running this agent.
52    /// Used for providers that need env vars (e.g., loaded from CCS settings).
53    pub env_vars: std::collections::HashMap<String, String>,
54    /// Display name for UI/logging (e.g., "ccs-glm" instead of raw agent name).
55    /// If None, the agent name from the registry is used.
56    pub display_name: Option<String>,
57}
58
59impl Default for AgentConfig {
60    fn default() -> Self {
61        Self {
62            cmd: String::new(),
63            output_flag: String::new(),
64            yolo_flag: String::new(),
65            verbose_flag: String::new(),
66            can_commit: true,
67            json_parser: JsonParserType::Generic,
68            model_flag: None,
69            print_flag: String::new(),
70            streaming_flag: String::new(),
71            session_flag: String::new(),
72            env_vars: std::collections::HashMap::new(),
73            display_name: None,
74        }
75    }
76}
77
78impl AgentConfig {
79    /// Create a new AgentConfig builder.
80    pub fn builder() -> AgentConfigBuilder {
81        AgentConfigBuilder::default()
82    }
83
84    /// Build full command string with specified flags.
85    pub fn build_cmd(&self, output: bool, yolo: bool, verbose: bool) -> String {
86        self.build_cmd_with_model(output, yolo, verbose, None)
87    }
88
89    /// Build full command string with specified flags and optional model override.
90    pub fn build_cmd_with_model(
91        &self,
92        output: bool,
93        yolo: bool,
94        verbose: bool,
95        model_override: Option<&str>,
96    ) -> String {
97        let mut parts = vec![self.cmd.clone()];
98
99        // Add print flag first (for CCS that needs -p after the profile name)
100        if !self.print_flag.is_empty() {
101            parts.push(self.print_flag.clone());
102        }
103
104        if output && !self.output_flag.is_empty() {
105            parts.push(self.output_flag.clone());
106        }
107
108        // Add streaming flag when using stream-json output with -p
109        // Claude/CCS require --include-partial-messages to stream JSON in -p mode
110        if output
111            && !self.output_flag.is_empty()
112            && self.output_flag.contains("stream-json")
113            && !self.print_flag.is_empty()
114            && !self.streaming_flag.is_empty()
115        {
116            parts.push(self.streaming_flag.clone());
117        }
118        if yolo && !self.yolo_flag.is_empty() {
119            parts.push(self.yolo_flag.clone());
120        }
121
122        // Claude CLI requires --verbose when using --output-format=stream-json
123        let needs_verbose = verbose || self.requires_verbose_for_json(output);
124
125        if needs_verbose && !self.verbose_flag.is_empty() {
126            parts.push(self.verbose_flag.clone());
127        }
128
129        // Add model flag: runtime override takes precedence over config
130        let effective_model = model_override.or(self.model_flag.as_deref());
131        if let Some(model) = effective_model {
132            if !model.is_empty() {
133                parts.push(model.to_string());
134            }
135        }
136
137        parts.join(" ")
138    }
139
140    /// Build full command string with session continuation.
141    ///
142    /// This is used for XSD retries where we want to continue an existing session
143    /// so the AI retains memory of its previous reasoning.
144    ///
145    /// # Arguments
146    ///
147    /// * `output` - Enable JSON output format
148    /// * `yolo` - Enable autonomous mode
149    /// * `verbose` - Enable verbose output
150    /// * `model_override` - Optional model override
151    /// * `session_id` - Session ID to continue (if supported by this agent)
152    ///
153    /// # Returns
154    ///
155    /// The command string with session continuation flag if supported
156    pub fn build_cmd_with_session(
157        &self,
158        output: bool,
159        yolo: bool,
160        verbose: bool,
161        model_override: Option<&str>,
162        session_id: Option<&str>,
163    ) -> String {
164        let mut cmd = self.build_cmd_with_model(output, yolo, verbose, model_override);
165
166        // Add session continuation flag if we have a session ID and the agent supports it
167        if let Some(sid) = session_id {
168            if !self.session_flag.is_empty() {
169                let session_arg = self.session_flag.replace("{}", sid);
170                cmd.push(' ');
171                cmd.push_str(&session_arg);
172            }
173        }
174
175        cmd
176    }
177
178    /// Check if this agent supports session continuation.
179    pub fn supports_session_continuation(&self) -> bool {
180        !self.session_flag.is_empty()
181    }
182
183    /// Check if this agent requires --verbose when JSON output is enabled.
184    fn requires_verbose_for_json(&self, json_enabled: bool) -> bool {
185        if !json_enabled || !self.output_flag.contains("stream-json") {
186            return false;
187        }
188
189        // Both `claude` and CCS (`ccs ...`) require verbose mode when using stream-json output.
190        // CCS is a wrapper around the Claude CLI and inherits its stream-json quirks.
191        let base = self.cmd.split_whitespace().next().unwrap_or("");
192        // Extract just the file name from the path to handle cases like "/usr/local/bin/claude"
193        let exe_name = Path::new(base)
194            .file_name()
195            .and_then(|n| n.to_str())
196            .unwrap_or(base);
197        matches!(exe_name, "claude" | "ccs")
198    }
199}
200
201/// Builder for AgentConfig.
202///
203/// Provides a fluent API for constructing AgentConfig instances
204/// without needing to specify all 12 fields.
205///
206/// # Example
207///
208/// ```
209/// use ralph_workflow::agents::AgentConfig;
210///
211/// let config = AgentConfig::builder()
212///     .cmd("claude")
213///     .output_flag("--output-format=stream-json")
214///     .yolo_flag("--dangerously-skip-permissions")
215///     .build();
216/// ```
217#[derive(Default, Debug, Clone)]
218pub struct AgentConfigBuilder {
219    cmd: Option<String>,
220    output_flag: Option<String>,
221    yolo_flag: Option<String>,
222    verbose_flag: Option<String>,
223    can_commit: Option<bool>,
224    json_parser: Option<JsonParserType>,
225    model_flag: Option<String>,
226    print_flag: Option<String>,
227    streaming_flag: Option<String>,
228    session_flag: Option<String>,
229    env_vars: Option<std::collections::HashMap<String, String>>,
230    display_name: Option<String>,
231}
232
233impl AgentConfigBuilder {
234    /// Set the base command to run the agent.
235    pub fn cmd(mut self, cmd: impl Into<String>) -> Self {
236        self.cmd = Some(cmd.into());
237        self
238    }
239
240    /// Set the output-format flag.
241    pub fn output_flag(mut self, flag: impl Into<String>) -> Self {
242        self.output_flag = Some(flag.into());
243        self
244    }
245
246    /// Set the autonomous mode flag.
247    pub fn yolo_flag(mut self, flag: impl Into<String>) -> Self {
248        self.yolo_flag = Some(flag.into());
249        self
250    }
251
252    /// Set the verbose output flag.
253    pub fn verbose_flag(mut self, flag: impl Into<String>) -> Self {
254        self.verbose_flag = Some(flag.into());
255        self
256    }
257
258    /// Set whether the agent can run git commit.
259    pub fn can_commit(mut self, can_commit: bool) -> Self {
260        self.can_commit = Some(can_commit);
261        self
262    }
263
264    /// Set the JSON parser type.
265    pub fn json_parser(mut self, parser: JsonParserType) -> Self {
266        self.json_parser = Some(parser);
267        self
268    }
269
270    /// Set the model/provider flag.
271    pub fn model_flag(mut self, flag: impl Into<String>) -> Self {
272        self.model_flag = Some(flag.into());
273        self
274    }
275
276    /// Set the print/non-interactive mode flag.
277    pub fn print_flag(mut self, flag: impl Into<String>) -> Self {
278        self.print_flag = Some(flag.into());
279        self
280    }
281
282    /// Set the streaming flag.
283    pub fn streaming_flag(mut self, flag: impl Into<String>) -> Self {
284        self.streaming_flag = Some(flag.into());
285        self
286    }
287
288    /// Set the session continuation flag template.
289    pub fn session_flag(mut self, flag: impl Into<String>) -> Self {
290        self.session_flag = Some(flag.into());
291        self
292    }
293
294    /// Set environment variables.
295    pub fn env_vars(mut self, env_vars: std::collections::HashMap<String, String>) -> Self {
296        self.env_vars = Some(env_vars);
297        self
298    }
299
300    /// Set the display name.
301    pub fn display_name(mut self, name: impl Into<String>) -> Self {
302        self.display_name = Some(name.into());
303        self
304    }
305
306    /// Build the AgentConfig.
307    ///
308    /// Uses defaults for any unset fields.
309    pub fn build(self) -> AgentConfig {
310        AgentConfig {
311            cmd: self.cmd.unwrap_or_default(),
312            output_flag: self.output_flag.unwrap_or_default(),
313            yolo_flag: self.yolo_flag.unwrap_or_default(),
314            verbose_flag: self.verbose_flag.unwrap_or_default(),
315            can_commit: self.can_commit.unwrap_or(true),
316            json_parser: self.json_parser.unwrap_or(JsonParserType::Generic),
317            model_flag: self.model_flag,
318            print_flag: self.print_flag.unwrap_or_default(),
319            streaming_flag: self.streaming_flag.unwrap_or_default(),
320            session_flag: self.session_flag.unwrap_or_default(),
321            env_vars: self.env_vars.unwrap_or_default(),
322            display_name: self.display_name,
323        }
324    }
325}
326
327/// TOML configuration for an agent (for deserialization).
328#[derive(Debug, Clone, Deserialize)]
329pub struct AgentConfigToml {
330    /// Base command to run the agent.
331    pub cmd: String,
332    /// Output-format flag (optional, defaults to empty).
333    #[serde(default)]
334    pub output_flag: String,
335    /// Flag for autonomous mode (optional, defaults to empty).
336    #[serde(default)]
337    pub yolo_flag: String,
338    /// Flag for verbose output (optional, defaults to empty).
339    #[serde(default)]
340    pub verbose_flag: String,
341    /// Whether the agent can run git commit (optional, defaults to true).
342    #[serde(default = "default_can_commit")]
343    pub can_commit: bool,
344    /// Which JSON parser to use (optional, defaults to "generic").
345    #[serde(default)]
346    pub json_parser: String,
347    /// Model/provider flag for model selection.
348    #[serde(default)]
349    pub model_flag: Option<String>,
350    /// Print/non-interactive mode flag (optional, defaults to empty).
351    #[serde(default)]
352    pub print_flag: String,
353    /// Include partial messages flag for streaming with -p (optional, defaults to "--include-partial-messages").
354    #[serde(default = "default_streaming_flag")]
355    pub streaming_flag: String,
356    /// Session continuation flag template (optional, e.g., "--session {}" for OpenCode).
357    /// The `{}` placeholder is replaced with the session ID at runtime.
358    /// If empty, session continuation is not supported for this agent.
359    #[serde(default)]
360    pub session_flag: String,
361    /// CCS profile to load env vars from (e.g., "glm").
362    ///
363    /// Ralph resolves the CCS profile to a settings file using CCS config mappings
364    /// (`~/.ccs/config.json` and/or `~/.ccs/config.yaml`) and common settings file
365    /// naming (`~/.ccs/{profile}.settings.json` / `~/.ccs/{profile}.setting.json`).
366    ///
367    /// The resulting values are injected into the agent process only (they are not
368    /// persisted).
369    #[serde(default)]
370    pub ccs_profile: Option<String>,
371    /// Environment variables to set when running this agent (optional).
372    /// If `ccs_profile` is set, these are merged with CCS env vars (CCS takes precedence).
373    #[serde(default)]
374    pub env_vars: std::collections::HashMap<String, String>,
375    /// Display name for UI/logging (optional, e.g., "My Custom Agent" instead of registry name).
376    #[serde(default)]
377    pub display_name: Option<String>,
378}
379
380const fn default_can_commit() -> bool {
381    true
382}
383
384fn default_streaming_flag() -> String {
385    "--include-partial-messages".to_string()
386}
387
388impl From<AgentConfigToml> for AgentConfig {
389    fn from(toml: AgentConfigToml) -> Self {
390        // Loading CCS env vars is best-effort: registry initialization should not fail
391        // just because a CCS profile is missing or misconfigured.
392        let ccs_env_vars = toml
393            .ccs_profile
394            .as_deref()
395            .map_or_else(HashMap::new, |profile| match load_ccs_env_vars(profile) {
396                Ok(vars) => vars,
397                Err(err) => {
398                    eprintln!(
399                        "Warning: failed to load CCS env vars for profile '{profile}': {err}"
400                    );
401                    HashMap::new()
402                }
403            });
404
405        // Merge manually specified env vars with CCS env vars
406        // CCS env vars take precedence (as documented in ccs_profile field)
407        let mut merged_env_vars = toml.env_vars;
408        for (key, value) in ccs_env_vars {
409            merged_env_vars.insert(key, value);
410        }
411
412        Self {
413            cmd: toml.cmd,
414            output_flag: toml.output_flag,
415            yolo_flag: toml.yolo_flag,
416            verbose_flag: toml.verbose_flag,
417            can_commit: toml.can_commit,
418            json_parser: JsonParserType::parse(&toml.json_parser),
419            model_flag: toml.model_flag,
420            print_flag: toml.print_flag,
421            streaming_flag: toml.streaming_flag,
422            session_flag: toml.session_flag,
423            env_vars: merged_env_vars,
424            display_name: toml.display_name,
425        }
426    }
427}
428
429/// Get the global config directory for Ralph.
430///
431/// Returns `~/.config/ralph` on Unix and `%APPDATA%\ralph` on Windows.
432/// Returns None if the home directory cannot be determined.
433pub fn global_config_dir() -> Option<PathBuf> {
434    dirs::config_dir().map(|d| d.join("ralph"))
435}
436
437/// Get the global agents.toml path.
438///
439/// Returns `~/.config/ralph/agents.toml` on Unix.
440pub fn global_agents_config_path() -> Option<PathBuf> {
441    global_config_dir().map(|d| d.join("agents.toml"))
442}
443
444/// Root TOML configuration structure.
445#[derive(Debug, Clone, Deserialize)]
446pub struct AgentsConfigFile {
447    /// Map of agent name to configuration.
448    #[serde(default)]
449    pub agents: HashMap<String, AgentConfigToml>,
450    /// Agent chain configuration (preferred agents + fallbacks).
451    #[serde(default, rename = "agent_chain")]
452    pub fallback: FallbackConfig,
453}
454
455/// Error type for agent configuration loading.
456#[derive(Debug, thiserror::Error)]
457pub enum AgentConfigError {
458    #[error("Failed to read config file: {0}")]
459    Io(#[from] io::Error),
460    #[error("Failed to parse TOML: {0}")]
461    Toml(#[from] toml::de::Error),
462    #[error("Built-in agents.toml template is invalid TOML: {0}")]
463    DefaultTemplateToml(toml::de::Error),
464    #[error("{0}")]
465    CcsEnvVars(#[from] CcsEnvVarsError),
466}
467
468/// Result of checking/initializing the agents config file.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum ConfigInitResult {
471    /// Config file already exists, no action taken.
472    AlreadyExists,
473    /// Config file was just created from template.
474    Created,
475}
476
477impl AgentsConfigFile {
478    /// Load agents config from a file, returning None if file doesn't exist.
479    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
480        let path = path.as_ref();
481        if !path.exists() {
482            return Ok(None);
483        }
484
485        let contents = fs::read_to_string(path)?;
486        let config: Self = toml::from_str(&contents)?;
487        Ok(Some(config))
488    }
489
490    /// Load agents config from a file using workspace abstraction.
491    ///
492    /// This is the architecture-conformant version that uses the Workspace trait
493    /// instead of direct filesystem access, allowing for proper testing with
494    /// MemoryWorkspace.
495    ///
496    /// # Arguments
497    ///
498    /// * `path` - Path to the config file (relative to workspace root)
499    /// * `workspace` - The workspace to use for filesystem operations
500    ///
501    /// # Returns
502    ///
503    /// Returns `Ok(Some(config))` if file exists and parses successfully,
504    /// `Ok(None)` if file doesn't exist, or an error if parsing fails.
505    pub fn load_from_file_with_workspace(
506        path: &Path,
507        workspace: &dyn crate::workspace::Workspace,
508    ) -> Result<Option<Self>, AgentConfigError> {
509        if !workspace.exists(path) {
510            return Ok(None);
511        }
512
513        let contents = workspace
514            .read(path)
515            .map_err(|e| AgentConfigError::Io(io::Error::other(e)))?;
516        let config: Self = toml::from_str(&contents)?;
517        Ok(Some(config))
518    }
519
520    /// Ensure agents config file exists, creating it from template if needed.
521    pub fn ensure_config_exists<P: AsRef<Path>>(path: P) -> io::Result<ConfigInitResult> {
522        let path = path.as_ref();
523
524        if path.exists() {
525            return Ok(ConfigInitResult::AlreadyExists);
526        }
527
528        // Create parent directories if they don't exist
529        if let Some(parent) = path.parent() {
530            fs::create_dir_all(parent)?;
531        }
532
533        // Write the default template
534        fs::write(path, DEFAULT_AGENTS_TOML)?;
535
536        Ok(ConfigInitResult::Created)
537    }
538
539    /// Ensure agents config file exists using workspace abstraction.
540    ///
541    /// This is the architecture-conformant version that uses the Workspace trait
542    /// instead of direct filesystem access, allowing for proper testing with
543    /// MemoryWorkspace.
544    ///
545    /// # Arguments
546    ///
547    /// * `path` - Path to the config file (relative to workspace root)
548    /// * `workspace` - The workspace to use for filesystem operations
549    ///
550    /// # Returns
551    ///
552    /// Returns `Ok(ConfigInitResult::AlreadyExists)` if file exists,
553    /// `Ok(ConfigInitResult::Created)` if file was created from template,
554    /// or an error if creation fails.
555    pub fn ensure_config_exists_with_workspace(
556        path: &Path,
557        workspace: &dyn crate::workspace::Workspace,
558    ) -> io::Result<ConfigInitResult> {
559        if workspace.exists(path) {
560            return Ok(ConfigInitResult::AlreadyExists);
561        }
562
563        // Create parent directories if they don't exist
564        if let Some(parent) = path.parent() {
565            workspace.create_dir_all(parent)?;
566        }
567
568        // Write the default template
569        workspace.write(path, DEFAULT_AGENTS_TOML)?;
570
571        Ok(ConfigInitResult::Created)
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn test_agent_build_cmd() {
581        let agent = AgentConfig {
582            cmd: "testbot run".to_string(),
583            output_flag: "--json".to_string(),
584            yolo_flag: "--yes".to_string(),
585            verbose_flag: "--verbose".to_string(),
586            can_commit: true,
587            json_parser: JsonParserType::Generic,
588            model_flag: None,
589            print_flag: String::new(),
590            streaming_flag: String::new(),
591            session_flag: String::new(),
592            env_vars: std::collections::HashMap::new(),
593            display_name: None,
594        };
595
596        let cmd = agent.build_cmd(true, true, true);
597        assert!(cmd.contains("testbot run"));
598        assert!(cmd.contains("--json"));
599        assert!(cmd.contains("--yes"));
600        assert!(cmd.contains("--verbose"));
601    }
602
603    #[test]
604    fn test_agent_config_from_toml() {
605        let toml = AgentConfigToml {
606            cmd: "myagent run".to_string(),
607            output_flag: "--json".to_string(),
608            yolo_flag: "--auto".to_string(),
609            verbose_flag: "--verbose".to_string(),
610            can_commit: false,
611            json_parser: "claude".to_string(),
612            model_flag: Some("-m provider/model".to_string()),
613            print_flag: String::new(),
614            streaming_flag: String::new(),
615            session_flag: "--session {}".to_string(),
616            ccs_profile: None,
617            env_vars: std::collections::HashMap::new(),
618            display_name: Some("My Custom Agent".to_string()),
619        };
620
621        let config: AgentConfig = AgentConfig::from(toml);
622        assert_eq!(config.cmd, "myagent run");
623        assert!(!config.can_commit);
624        assert_eq!(config.json_parser, JsonParserType::Claude);
625        assert_eq!(config.model_flag, Some("-m provider/model".to_string()));
626        assert_eq!(config.display_name, Some("My Custom Agent".to_string()));
627        assert_eq!(config.session_flag, "--session {}");
628    }
629
630    #[test]
631    fn test_agent_config_toml_defaults() {
632        let toml_str = r#"cmd = "myagent""#;
633        let config: AgentConfigToml = toml::from_str(toml_str).unwrap();
634
635        assert_eq!(config.cmd, "myagent");
636        assert_eq!(config.output_flag, "");
637        assert!(config.can_commit); // default is true
638    }
639
640    #[test]
641    fn test_agent_config_with_print_flag() {
642        let agent = AgentConfig {
643            cmd: "ccs glm".to_string(),
644            output_flag: "--output-format=stream-json".to_string(),
645            yolo_flag: "--dangerously-skip-permissions".to_string(),
646            verbose_flag: "--verbose".to_string(),
647            can_commit: true,
648            json_parser: JsonParserType::Claude,
649            model_flag: None,
650            print_flag: "-p".to_string(),
651            streaming_flag: "--include-partial-messages".to_string(),
652            session_flag: String::new(),
653            env_vars: std::collections::HashMap::new(),
654            display_name: None,
655        };
656
657        let cmd = agent.build_cmd(true, true, true);
658        assert!(cmd.contains("ccs glm -p"));
659        assert!(cmd.contains("--output-format=stream-json"));
660        assert!(cmd.contains("--include-partial-messages"));
661    }
662
663    #[test]
664    fn test_default_agents_toml_is_valid() {
665        let config: AgentsConfigFile = toml::from_str(DEFAULT_AGENTS_TOML).unwrap();
666        assert!(config.agents.contains_key("claude"));
667        assert!(config.agents.contains_key("codex"));
668    }
669
670    #[test]
671    fn test_global_config_path() {
672        if let Some(path) = global_agents_config_path() {
673            assert!(path.ends_with("agents.toml"));
674        }
675    }
676
677    #[test]
678    fn test_build_cmd_with_session() {
679        // Test with OpenCode agent (uses -s flag per `opencode run --help`)
680        let agent = AgentConfig {
681            cmd: "opencode run".to_string(),
682            output_flag: "--json".to_string(),
683            yolo_flag: "--yes".to_string(),
684            verbose_flag: "--verbose".to_string(),
685            can_commit: true,
686            json_parser: JsonParserType::OpenCode,
687            model_flag: None,
688            print_flag: String::new(),
689            streaming_flag: String::new(),
690            session_flag: "-s {}".to_string(), // From `opencode run --help`
691            env_vars: std::collections::HashMap::new(),
692            display_name: None,
693        };
694
695        // Without session ID
696        let cmd = agent.build_cmd_with_session(true, true, true, None, None);
697        assert!(!cmd.contains("-s "));
698
699        // With session ID
700        let cmd = agent.build_cmd_with_session(true, true, true, None, Some("ses_abc123"));
701        assert!(cmd.contains("-s ses_abc123"));
702    }
703
704    #[test]
705    fn test_build_cmd_with_session_claude() {
706        // Test with Claude agent (uses --resume flag per `claude --help`)
707        let agent = AgentConfig {
708            cmd: "claude -p".to_string(),
709            output_flag: "--output-format=stream-json".to_string(),
710            yolo_flag: "--dangerously-skip-permissions".to_string(),
711            verbose_flag: "--verbose".to_string(),
712            can_commit: true,
713            json_parser: JsonParserType::Claude,
714            model_flag: None,
715            print_flag: String::new(),
716            streaming_flag: String::new(),
717            session_flag: "--resume {}".to_string(), // From `claude --help`
718            env_vars: std::collections::HashMap::new(),
719            display_name: None,
720        };
721
722        // With session ID
723        let cmd = agent.build_cmd_with_session(true, true, true, None, Some("abc123"));
724        assert!(cmd.contains("--resume abc123"));
725    }
726
727    #[test]
728    fn test_build_cmd_with_session_no_support() {
729        let agent = AgentConfig {
730            cmd: "generic-agent".to_string(),
731            output_flag: String::new(),
732            yolo_flag: String::new(),
733            verbose_flag: String::new(),
734            can_commit: true,
735            json_parser: JsonParserType::Generic,
736            model_flag: None,
737            print_flag: String::new(),
738            streaming_flag: String::new(),
739            session_flag: String::new(), // No session support
740            env_vars: std::collections::HashMap::new(),
741            display_name: None,
742        };
743
744        // Session ID should be ignored when agent doesn't support it
745        let cmd = agent.build_cmd_with_session(false, false, false, None, Some("ses_abc123"));
746        assert!(!cmd.contains("ses_abc123"));
747        assert!(!agent.supports_session_continuation());
748    }
749
750    #[test]
751    fn test_supports_session_continuation() {
752        let with_support = AgentConfig {
753            cmd: "opencode run".to_string(),
754            output_flag: String::new(),
755            yolo_flag: String::new(),
756            verbose_flag: String::new(),
757            can_commit: true,
758            json_parser: JsonParserType::OpenCode,
759            model_flag: None,
760            print_flag: String::new(),
761            streaming_flag: String::new(),
762            session_flag: "--session {}".to_string(),
763            env_vars: std::collections::HashMap::new(),
764            display_name: None,
765        };
766        assert!(with_support.supports_session_continuation());
767
768        let without_support = AgentConfig {
769            cmd: "generic-agent".to_string(),
770            output_flag: String::new(),
771            yolo_flag: String::new(),
772            verbose_flag: String::new(),
773            can_commit: true,
774            json_parser: JsonParserType::Generic,
775            model_flag: None,
776            print_flag: String::new(),
777            streaming_flag: String::new(),
778            session_flag: String::new(),
779            env_vars: std::collections::HashMap::new(),
780            display_name: None,
781        };
782        assert!(!without_support.supports_session_continuation());
783    }
784
785    // =========================================================================
786    // Workspace-aware function tests (architecture-conformant)
787    // =========================================================================
788
789    #[test]
790    fn test_load_from_file_with_workspace_nonexistent() {
791        use crate::workspace::MemoryWorkspace;
792        let workspace = MemoryWorkspace::new_test();
793        let path = Path::new(".agent/agents.toml");
794
795        let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
796        assert!(result.is_none());
797    }
798
799    #[test]
800    fn test_load_from_file_with_workspace_valid_config() {
801        use crate::workspace::MemoryWorkspace;
802        let workspace =
803            MemoryWorkspace::new_test().with_file(".agent/agents.toml", DEFAULT_AGENTS_TOML);
804        let path = Path::new(".agent/agents.toml");
805
806        let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
807        assert!(result.is_some());
808        let config = result.unwrap();
809        assert!(config.agents.contains_key("claude"));
810    }
811
812    #[test]
813    fn test_ensure_config_exists_with_workspace_creates_file() {
814        use crate::workspace::{MemoryWorkspace, Workspace};
815        let workspace = MemoryWorkspace::new_test();
816        let path = Path::new(".agent/agents.toml");
817
818        let result =
819            AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
820        assert!(matches!(result, ConfigInitResult::Created));
821        assert!(workspace.exists(path));
822
823        // Verify the content is the default template
824        let content = workspace.read(path).unwrap();
825        assert_eq!(content, DEFAULT_AGENTS_TOML);
826    }
827
828    #[test]
829    fn test_ensure_config_exists_with_workspace_already_exists() {
830        use crate::workspace::{MemoryWorkspace, Workspace};
831        let workspace =
832            MemoryWorkspace::new_test().with_file(".agent/agents.toml", "# custom config");
833        let path = Path::new(".agent/agents.toml");
834
835        let result =
836            AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
837        assert!(matches!(result, ConfigInitResult::AlreadyExists));
838
839        // Verify the content was not overwritten
840        let content = workspace.read(path).unwrap();
841        assert_eq!(content, "# custom config");
842    }
843}