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