Skip to main content

aether_project/
settings.rs

1//! Settings file parsing and validation.
2
3use crate::error::SettingsError;
4use aether_core::agent_spec::{AgentSpec, AgentSpecExposure, ToolFilter};
5use aether_core::core::Prompt;
6use glob::glob;
7use llm::{LlmModel, ReasoningEffort};
8use std::collections::HashSet;
9use std::path::Path;
10
11/// Settings DTO for `.aether/settings.json`.
12#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
13#[serde(default, rename_all = "camelCase")]
14pub struct Settings {
15    /// Inherited prompts for all agents.
16    #[serde(skip_serializing_if = "Vec::is_empty")]
17    pub prompts: Vec<String>,
18    /// Paths to inherited MCP configs for all agents, applied in order (last wins on collisions).
19    #[serde(skip_serializing_if = "Vec::is_empty")]
20    pub mcp_servers: Vec<String>,
21    /// The canonical authored agent registry.
22    pub agents: Vec<AgentEntry>,
23}
24
25/// Agent entry DTO for `.aether/settings.json`.
26#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
27#[serde(default, rename_all = "camelCase")]
28pub struct AgentEntry {
29    pub name: String,
30    pub description: String,
31    pub model: String,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub reasoning_effort: Option<ReasoningEffort>,
34    #[serde(default)]
35    pub user_invocable: bool,
36    #[serde(default)]
37    pub agent_invocable: bool,
38    #[serde(default)]
39    pub prompts: Vec<String>,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub mcp_servers: Vec<String>,
42    #[serde(default, skip_serializing_if = "ToolFilter::is_empty")]
43    pub tools: ToolFilter,
44}
45
46/// Load and resolve the agent catalog from a project root.
47///
48/// If `.aether/settings.json` is absent, returns a valid empty catalog.
49/// If the settings file is malformed or contains invalid entries, returns an error.
50pub fn load_agent_catalog(project_root: &Path) -> Result<super::catalog::AgentCatalog, SettingsError> {
51    let settings_path = project_root.join(".aether/settings.json");
52
53    let settings = match std::fs::read_to_string(&settings_path) {
54        Ok(content) => {
55            if content.trim().is_empty() {
56                Settings::default()
57            } else {
58                serde_json::from_str(&content).map_err(|e| SettingsError::ParseError(e.to_string()))?
59            }
60        }
61        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
62            return Ok(super::catalog::AgentCatalog::empty(project_root.to_path_buf()));
63        }
64        Err(e) => {
65            return Err(SettingsError::IoError(format!("Failed to read {}: {}", settings_path.display(), e)));
66        }
67    };
68
69    resolve_settings(project_root, settings)
70}
71
72/// Resolve settings into a catalog of agent specs.
73fn resolve_settings(project_root: &Path, settings: Settings) -> Result<super::catalog::AgentCatalog, SettingsError> {
74    let Settings { prompts: inherited_patterns, mcp_servers, agents } = settings;
75
76    validate_prompt_entries(project_root, &inherited_patterns, None)?;
77    let inherited_mcp_config_paths = resolve_mcp_config_paths(project_root, &mcp_servers)?;
78    let inherited_prompts = build_inherited_prompts(&inherited_patterns, project_root);
79
80    let mut seen_names = HashSet::new();
81    let mut specs = Vec::with_capacity(agents.len());
82
83    for (index, entry) in agents.into_iter().enumerate() {
84        specs.push(resolve_agent_entry(project_root, &inherited_prompts, entry, index, &mut seen_names)?);
85    }
86
87    Ok(super::catalog::AgentCatalog::new(
88        project_root.to_path_buf(),
89        inherited_prompts,
90        inherited_mcp_config_paths,
91        specs,
92    ))
93}
94
95fn resolve_agent_entry(
96    project_root: &Path,
97    inherited_prompts: &[Prompt],
98    entry: AgentEntry,
99    index: usize,
100    seen_names: &mut HashSet<String>,
101) -> Result<AgentSpec, SettingsError> {
102    let name = entry.name.trim().to_string();
103    if name.is_empty() {
104        return Err(SettingsError::EmptyAgentName { index });
105    }
106
107    if name == "__default__" {
108        return Err(SettingsError::ReservedAgentName { name });
109    }
110
111    if !seen_names.insert(name.clone()) {
112        return Err(SettingsError::DuplicateAgentName { name });
113    }
114
115    let description = entry.description.trim().to_string();
116    if description.is_empty() {
117        return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
118    }
119
120    let model = parse_model(&name, &entry.model)?;
121
122    if !entry.user_invocable && !entry.agent_invocable {
123        return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
124    }
125
126    validate_prompt_entries(project_root, &entry.prompts, Some(&name))?;
127
128    if inherited_prompts.is_empty() && entry.prompts.is_empty() {
129        return Err(SettingsError::NoPrompts { agent: name.clone() });
130    }
131
132    let mcp_config_paths = resolve_mcp_config_paths(project_root, &entry.mcp_servers)?;
133
134    let prompts = if entry.prompts.is_empty() {
135        inherited_prompts.to_vec()
136    } else {
137        entry.prompts.iter().map(|p| Prompt::from_globs(vec![p.clone()], project_root.to_path_buf())).collect()
138    };
139
140    Ok(AgentSpec {
141        name,
142        description,
143        model,
144        reasoning_effort: entry.reasoning_effort,
145        prompts,
146        mcp_config_paths,
147        exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
148        tools: entry.tools,
149    })
150}
151
152fn parse_model(agent: &str, model: &str) -> Result<String, SettingsError> {
153    canonicalize_model_spec(model).map_err(|error| SettingsError::InvalidModel {
154        agent: agent.to_string(),
155        model: model.to_string(),
156        error,
157    })
158}
159
160fn canonicalize_model_spec(model: &str) -> Result<String, String> {
161    let trimmed = model.trim();
162    if trimmed.is_empty() {
163        return Err("Model spec cannot be empty".to_string());
164    }
165
166    let mut canonical_parts = Vec::new();
167    for part in trimmed.split(',').map(str::trim) {
168        if part.is_empty() {
169            return Err("Model spec contains an empty entry".to_string());
170        }
171
172        part.parse::<LlmModel>().map_err(|error: String| error)?;
173        canonical_parts.push(part.to_string());
174    }
175
176    Ok(canonical_parts.join(","))
177}
178
179fn validate_prompt_entries(
180    project_root: &Path,
181    patterns: &[String],
182    agent_name: Option<&str>,
183) -> Result<(), SettingsError> {
184    for pattern in patterns {
185        validate_prompt_entry(project_root, pattern, agent_name)?;
186    }
187    Ok(())
188}
189
190fn resolve_mcp_config_paths(
191    project_root: &Path,
192    mcp_paths: &[String],
193) -> Result<Vec<std::path::PathBuf>, SettingsError> {
194    let mut resolved = Vec::with_capacity(mcp_paths.len());
195    for path in mcp_paths {
196        let full_path = project_root.join(path);
197        if full_path.is_file() {
198            resolved.push(full_path);
199        } else {
200            return Err(SettingsError::InvalidMcpConfigPath { path: path.clone() });
201        }
202    }
203    Ok(resolved)
204}
205
206/// Validate that a prompt entry resolves to at least one file.
207fn validate_prompt_entry(project_root: &Path, pattern: &str, agent_name: Option<&str>) -> Result<(), SettingsError> {
208    let full_pattern = if Path::new(pattern).is_absolute() {
209        pattern.to_string()
210    } else {
211        project_root.join(pattern).to_string_lossy().to_string()
212    };
213
214    let has_file_match = glob(&full_pattern)
215        .map_err(|e| {
216            if let Some(agent) = agent_name {
217                SettingsError::InvalidGlobPattern {
218                    agent: agent.to_string(),
219                    pattern: pattern.to_string(),
220                    error: e.to_string(),
221                }
222            } else {
223                SettingsError::InvalidInheritedGlobPattern { pattern: pattern.to_string(), error: e.to_string() }
224            }
225        })?
226        .filter_map(Result::ok)
227        .any(|path| path.is_file());
228
229    if has_file_match {
230        Ok(())
231    } else if let Some(agent) = agent_name {
232        Err(SettingsError::ZeroMatchPrompt { agent: agent.to_string(), pattern: pattern.to_string() })
233    } else {
234        Err(SettingsError::ZeroMatchInheritedPrompt { pattern: pattern.to_string() })
235    }
236}
237
238/// Build the inherited prompts from patterns.
239///
240/// Each pattern becomes one `Prompt::PromptGlobs` value.
241fn build_inherited_prompts(patterns: &[String], project_root: &Path) -> Vec<Prompt> {
242    patterns.iter().map(|pattern| Prompt::from_globs(vec![pattern.clone()], project_root.to_path_buf())).collect()
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::fs;
249
250    fn create_temp_project() -> tempfile::TempDir {
251        tempfile::tempdir().unwrap()
252    }
253
254    fn write_settings(dir: &Path, content: &str) {
255        let aether_dir = dir.join(".aether");
256        fs::create_dir_all(&aether_dir).unwrap();
257        fs::write(aether_dir.join("settings.json"), content).unwrap();
258    }
259
260    fn write_file(dir: &Path, path: &str, content: &str) {
261        let full_path = dir.join(path);
262        if let Some(parent) = full_path.parent() {
263            fs::create_dir_all(parent).unwrap();
264        }
265        fs::write(full_path, content).unwrap();
266    }
267
268    /// Standard agent JSON with customizable fields. `extra` is injected into the agent object.
269    fn agent_settings(extra: &str) -> String {
270        let comma = if extra.is_empty() { "" } else { "," };
271        format!(
272            r#"{{"agents": [{{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]{comma} {extra}}}]}}"#
273        )
274    }
275
276    /// Setup a project with AGENTS.md, write settings JSON, and load the catalog.
277    fn setup_and_load(json: &str) -> (tempfile::TempDir, Result<super::super::catalog::AgentCatalog, SettingsError>) {
278        let dir = create_temp_project();
279        write_file(dir.path(), "AGENTS.md", "Be helpful");
280        write_settings(dir.path(), json);
281        let result = load_agent_catalog(dir.path());
282        (dir, result)
283    }
284
285    fn setup_and_load_ok(json: &str) -> (tempfile::TempDir, super::super::catalog::AgentCatalog) {
286        let (dir, result) = setup_and_load(json);
287        (dir, result.unwrap())
288    }
289
290    #[test]
291    fn missing_settings_yields_empty_catalog() {
292        let dir = create_temp_project();
293        let catalog = load_agent_catalog(dir.path()).unwrap();
294        assert!(catalog.all().is_empty());
295    }
296
297    #[test]
298    fn exposure_flags_parsed_correctly() {
299        for (user, agent) in [(true, true), (true, false), (false, true)] {
300            let json = format!(
301                r#"{{"agents": [{{
302                    "name": "planner", "description": "Planner agent",
303                    "model": "anthropic:claude-sonnet-4-5",
304                    "userInvocable": {user}, "agentInvocable": {agent},
305                    "prompts": ["AGENTS.md"]
306                }}]}}"#
307            );
308            let (_, catalog) = setup_and_load_ok(&json);
309            let spec = catalog.get("planner").unwrap();
310            assert_eq!(spec.exposure.user_invocable, user);
311            assert_eq!(spec.exposure.agent_invocable, agent);
312        }
313    }
314
315    #[test]
316    fn invalid_model_string_rejected() {
317        let (_, result) = setup_and_load(
318            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
319        );
320        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
321    }
322
323    #[test]
324    fn alloy_model_string_is_accepted() {
325        let json = r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#;
326        let (_, catalog) = setup_and_load_ok(json);
327        assert_eq!(catalog.get("alloy").unwrap().model.clone(), "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat");
328    }
329
330    #[test]
331    fn alloy_model_string_with_unknown_member_is_rejected() {
332        let (_, result) = setup_and_load(
333            r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,mystery:nope", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
334        );
335        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
336    }
337
338    #[test]
339    fn invalid_reasoning_effort_rejected() {
340        let (_, result) = setup_and_load(&agent_settings(r#""reasoningEffort": "invalid""#));
341        assert!(matches!(result, Err(SettingsError::ParseError(_))));
342    }
343
344    #[test]
345    fn duplicate_agent_names_rejected() {
346        let (_, result) = setup_and_load(
347            r#"{"agents": [
348                {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
349                {"name": "planner", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
350            ]}"#,
351        );
352        assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
353    }
354
355    #[test]
356    fn agent_prompts_override_inherited() {
357        let dir = create_temp_project();
358        write_file(dir.path(), "BASE.md", "Base instructions");
359        write_file(dir.path(), "AGENTS.md", "Agent instructions");
360        write_settings(
361            dir.path(),
362            r#"{"prompts": ["BASE.md"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "agentInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
363        );
364
365        let catalog = load_agent_catalog(dir.path()).unwrap();
366        // Agent-local prompts override inherited, not additive
367        assert_eq!(catalog.get("planner").unwrap().prompts.len(), 1);
368    }
369
370    #[test]
371    fn agent_without_prompts_inherits_top_level() {
372        let dir = create_temp_project();
373        write_file(dir.path(), "BASE.md", "Base instructions");
374        write_settings(
375            dir.path(),
376            r#"{"prompts": ["BASE.md"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true}]}"#,
377        );
378
379        let catalog = load_agent_catalog(dir.path()).unwrap();
380        assert_eq!(catalog.get("planner").unwrap().prompts.len(), 1);
381    }
382
383    #[test]
384    fn one_prompt_globs_per_entry() {
385        let dir = create_temp_project();
386        write_file(dir.path(), "a.md", "A");
387        write_file(dir.path(), "b.md", "B");
388        write_settings(
389            dir.path(),
390            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["a.md", "b.md"]}]}"#,
391        );
392
393        let catalog = load_agent_catalog(dir.path()).unwrap();
394        // Should have 2 PromptGlobs entries, not 1 combined
395        assert_eq!(catalog.get("planner").unwrap().prompts.len(), 2);
396    }
397
398    #[test]
399    fn zero_match_prompt_rejected() {
400        let dir = create_temp_project();
401        // No AGENTS.md created — prompt won't match
402        write_settings(
403            dir.path(),
404            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["nonexistent.md"]}]}"#,
405        );
406        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
407    }
408
409    #[test]
410    fn prompt_matching_only_directories_is_rejected() {
411        let dir = create_temp_project();
412        std::fs::create_dir_all(dir.path().join("prompts")).unwrap();
413        write_settings(
414            dir.path(),
415            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["prompts/*"]}]}"#,
416        );
417        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
418    }
419
420    #[test]
421    fn no_invocation_surface_rejected() {
422        let (_, result) = setup_and_load(
423            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": false, "agentInvocable": false, "prompts": ["AGENTS.md"]}]}"#,
424        );
425        assert!(matches!(result, Err(SettingsError::NoInvocationSurface { .. })));
426    }
427
428    #[test]
429    fn empty_and_whitespace_names_rejected() {
430        for name in ["", "   "] {
431            let json = format!(
432                r#"{{"agents": [{{"name": "{name}", "description": "Agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
433            );
434            let (_, result) = setup_and_load(&json);
435            assert!(
436                matches!(result, Err(SettingsError::EmptyAgentName { .. })),
437                "expected EmptyAgentName for name={name:?}"
438            );
439        }
440    }
441
442    #[test]
443    fn empty_and_whitespace_descriptions_rejected() {
444        for desc in ["", "   "] {
445            let json = format!(
446                r#"{{"agents": [{{"name": "planner", "description": "{desc}", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
447            );
448            let (_, result) = setup_and_load(&json);
449            assert!(
450                matches!(result, Err(SettingsError::MissingField { .. })),
451                "expected MissingField for desc={desc:?}"
452            );
453        }
454    }
455
456    #[test]
457    fn duplicate_agent_names_after_trim_rejected() {
458        let (_, result) = setup_and_load(
459            r#"{"agents": [
460                {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
461                {"name": " planner ", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
462            ]}"#,
463        );
464        assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
465    }
466
467    #[test]
468    fn agent_name_and_description_are_trimmed() {
469        let (_, catalog) = setup_and_load_ok(
470            r#"{"agents": [{"name": " planner ", "description": " Planner agent ", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
471        );
472        let spec = catalog.get("planner").unwrap();
473        assert_eq!(spec.name, "planner");
474        assert_eq!(spec.description, "Planner agent");
475    }
476
477    #[test]
478    fn no_prompts_rejected() {
479        let dir = create_temp_project();
480        write_settings(
481            dir.path(),
482            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true}]}"#,
483        );
484        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::NoPrompts { .. })));
485    }
486
487    #[test]
488    fn malformed_json_rejected() {
489        let dir = create_temp_project();
490        write_settings(dir.path(), "not valid json");
491        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ParseError(_))));
492    }
493
494    #[test]
495    fn invalid_mcp_servers_path_rejected() {
496        let (_, result) = setup_and_load(
497            r#"{"mcpServers": ["nonexistent.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
498        );
499        assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
500    }
501
502    #[test]
503    fn invalid_agent_mcp_servers_path_rejected() {
504        let (_, result) = setup_and_load(&agent_settings(r#""mcpServers": ["nonexistent.json"]"#));
505        assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
506    }
507
508    #[test]
509    fn valid_mcp_servers_path_accepted() {
510        let dir = create_temp_project();
511        write_file(dir.path(), "AGENTS.md", "Be helpful");
512        write_file(dir.path(), ".aether/mcp/default.json", "{}");
513        write_settings(
514            dir.path(),
515            r#"{"mcpServers": [".aether/mcp/default.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
516        );
517
518        let catalog = load_agent_catalog(dir.path()).unwrap();
519        let resolved = catalog.resolve("planner", dir.path()).unwrap();
520        assert_eq!(resolved.mcp_config_paths, vec![dir.path().join(".aether/mcp/default.json")]);
521    }
522
523    #[test]
524    fn top_level_mcp_servers_array_parses_and_resolves_in_order() {
525        let dir = create_temp_project();
526        write_file(dir.path(), "AGENTS.md", "Be helpful");
527        write_file(dir.path(), ".aether/mcp/a.json", "{}");
528        write_file(dir.path(), ".aether/mcp/b.json", "{}");
529        write_settings(
530            dir.path(),
531            r#"{"mcpServers": [".aether/mcp/a.json", ".aether/mcp/b.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
532        );
533
534        let catalog = load_agent_catalog(dir.path()).unwrap();
535        let resolved = catalog.resolve("planner", dir.path()).unwrap();
536        assert_eq!(
537            resolved.mcp_config_paths,
538            vec![dir.path().join(".aether/mcp/a.json"), dir.path().join(".aether/mcp/b.json")]
539        );
540    }
541
542    #[test]
543    fn top_level_mcp_servers_invalid_path_in_middle_of_array_rejected() {
544        let dir = create_temp_project();
545        write_file(dir.path(), "AGENTS.md", "Be helpful");
546        write_file(dir.path(), "good.json", "{}");
547        write_settings(
548            dir.path(),
549            r#"{"mcpServers": ["good.json", "bad.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
550        );
551
552        let result = load_agent_catalog(dir.path());
553        match result {
554            Err(SettingsError::InvalidMcpConfigPath { path }) => assert_eq!(path, "bad.json"),
555            other => panic!("expected InvalidMcpConfigPath for bad.json, got {other:?}"),
556        }
557    }
558
559    #[test]
560    fn agent_mcp_servers_array_parses() {
561        let dir = create_temp_project();
562        write_file(dir.path(), "AGENTS.md", "Be helpful");
563        write_file(dir.path(), "a.json", "{}");
564        write_file(dir.path(), "b.json", "{}");
565        write_settings(
566            dir.path(),
567            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"], "mcpServers": ["a.json", "b.json"]}]}"#,
568        );
569
570        let catalog = load_agent_catalog(dir.path()).unwrap();
571        let resolved = catalog.resolve("planner", dir.path()).unwrap();
572        assert_eq!(resolved.mcp_config_paths, vec![dir.path().join("a.json"), dir.path().join("b.json")]);
573    }
574
575    #[test]
576    fn agent_mcp_servers_overrides_inherited_array() {
577        let dir = create_temp_project();
578        write_file(dir.path(), "AGENTS.md", "Be helpful");
579        write_file(dir.path(), "base.json", "{}");
580        write_file(dir.path(), "override.json", "{}");
581        write_settings(
582            dir.path(),
583            r#"{"mcpServers": ["base.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"], "mcpServers": ["override.json"]}]}"#,
584        );
585
586        let catalog = load_agent_catalog(dir.path()).unwrap();
587        let resolved = catalog.resolve("planner", dir.path()).unwrap();
588        assert_eq!(resolved.mcp_config_paths, vec![dir.path().join("override.json")]);
589    }
590
591    #[test]
592    fn empty_mcp_servers_array_falls_back_to_cwd_mcp() {
593        let dir = create_temp_project();
594        write_file(dir.path(), "AGENTS.md", "Be helpful");
595        write_file(dir.path(), "mcp.json", "{}");
596        write_settings(
597            dir.path(),
598            r#"{"mcpServers": [], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
599        );
600
601        let catalog = load_agent_catalog(dir.path()).unwrap();
602        let resolved = catalog.resolve("planner", dir.path()).unwrap();
603        assert_eq!(resolved.mcp_config_paths, vec![dir.path().join("mcp.json")]);
604    }
605
606    #[test]
607    fn any_invalid_agent_entry_fails_catalog_load() {
608        let (_, result) = setup_and_load(
609            r#"{"agents": [
610                {"name": "valid", "description": "Valid agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
611                {"name": "invalid", "description": "Invalid agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}
612            ]}"#,
613        );
614        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
615    }
616
617    fn two_agent_json() -> &'static str {
618        r#"{"agents": [
619            {"name": "zebra", "description": "Z agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
620            {"name": "alpha", "description": "A agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
621        ]}"#
622    }
623
624    #[test]
625    fn preserves_authored_agent_order_and_lookup() {
626        let (_, catalog) = setup_and_load_ok(two_agent_json());
627        let names: Vec<_> = catalog.all().iter().map(|s| s.name.as_str()).collect();
628        assert_eq!(names, vec!["zebra", "alpha"]); // not alphabetized
629        assert_eq!(catalog.get("alpha").unwrap().name, "alpha");
630        assert_eq!(catalog.get("zebra").unwrap().name, "zebra");
631    }
632
633    #[test]
634    fn tools_filter_parsed_from_settings() {
635        let (_, catalog) = setup_and_load_ok(
636            r#"{"agents": [{"name": "researcher", "description": "Read-only agent", "model": "anthropic:claude-sonnet-4-5", "agentInvocable": true, "prompts": ["AGENTS.md"], "tools": {"allow": ["coding__grep", "coding__read_file"], "deny": ["coding__write*"]}}]}"#,
637        );
638        let spec = catalog.get("researcher").unwrap();
639        assert_eq!(spec.tools.allow, vec!["coding__grep", "coding__read_file"]);
640        assert_eq!(spec.tools.deny, vec!["coding__write*"]);
641    }
642
643    #[test]
644    fn absent_tools_field_yields_default_filter() {
645        let (_, catalog) = setup_and_load_ok(&agent_settings(""));
646        let spec = catalog.get("planner").unwrap();
647        assert!(spec.tools.allow.is_empty());
648        assert!(spec.tools.deny.is_empty());
649    }
650
651    #[test]
652    fn reserved_agent_name_rejected() {
653        let (_, result) = setup_and_load(
654            r#"{"agents": [{"name": "__default__", "description": "Sneaky agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
655        );
656        assert!(matches!(result, Err(SettingsError::ReservedAgentName { .. })));
657    }
658}