Skip to main content

aether_project/
agent_catalog.rs

1use crate::aether_settings::{project_settings_exist, user_settings_exist};
2use crate::error::SettingsError;
3use crate::{AetherSettings, AgentConfig, McpFileSpec, McpSourceSpec};
4use aether_core::agent_spec::{AgentSpec, AgentSpecExposure, McpConfigSource};
5use aether_core::core::{AgentRegistry, Prompt};
6use llm::catalog::{LlmModel, ModelSpec, ModelSpecError};
7use llm::{ProviderConnectionOverrides, ReasoningEffort};
8use mcp_utils::client::McpConfig;
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use utils::variables::VarError;
12
13/// A resolved catalog of agents from a project.
14#[derive(Debug, Clone)]
15pub struct AgentCatalog {
16    project_root: PathBuf,
17    registry: AgentRegistry,
18    selected_agent: Option<String>,
19    provider_connections: ProviderConnectionOverrides,
20}
21
22impl AgentCatalog {
23    pub fn load_default(project_root: &Path) -> Result<Option<Self>, SettingsError> {
24        let has_default_settings = user_settings_exist() || project_settings_exist(project_root);
25        let settings = AetherSettings::load_default(project_root)?;
26        if settings.agents.is_empty() && !has_default_settings {
27            return Ok(None);
28        }
29
30        let catalog = Self::from_settings(project_root, settings)?;
31        if catalog.user_invocable().next().is_none() {
32            return Err(SettingsError::NoUserInvocableAgents);
33        }
34
35        Ok(Some(catalog))
36    }
37
38    pub fn from_settings_or_empty(project_root: &Path, settings: AetherSettings) -> Result<Self, SettingsError> {
39        if settings.agents.is_empty() {
40            return Ok(Self::with_defaults(project_root.to_path_buf(), Vec::new(), None, settings.providers));
41        }
42        Self::from_settings(project_root, settings)
43    }
44
45    pub fn from_settings(project_root: &Path, settings: AetherSettings) -> Result<Self, SettingsError> {
46        validate_selected_agent(&settings)?;
47        let selected_agent =
48            settings.agent.as_deref().map(str::trim).filter(|name| !name.is_empty()).map(str::to_string);
49        let provider_connections = settings.providers.clone();
50        let defaults = AgentDefaults { prompts: settings.prompts, mcps: settings.mcps, providers: settings.providers };
51        let mut seen_names = HashSet::new();
52        let mut specs = Vec::with_capacity(settings.agents.len());
53        for (index, entry) in settings.agents.into_iter().enumerate() {
54            specs.push(resolve_agent_entry(project_root, entry, &defaults, index, &mut seen_names)?);
55        }
56
57        Ok(Self::with_defaults(project_root.to_path_buf(), specs, selected_agent, provider_connections))
58    }
59
60    pub fn new(project_root: PathBuf, specs: Vec<AgentSpec>, selected_agent: Option<String>) -> Self {
61        Self::with_defaults(project_root, specs, selected_agent, ProviderConnectionOverrides::default())
62    }
63
64    #[must_use]
65    pub fn with_provider_connections(mut self, overrides: ProviderConnectionOverrides) -> Self {
66        if overrides.is_empty() {
67            return self;
68        }
69
70        let specs = self
71            .registry
72            .all()
73            .iter()
74            .cloned()
75            .map(|mut spec| {
76                spec.provider_connections.merge(overrides.clone());
77                spec
78            })
79            .collect();
80
81        self.registry = AgentRegistry::new(specs);
82        self.provider_connections.merge(overrides);
83        self
84    }
85
86    /// Create an empty catalog for a project with no settings.
87    pub fn empty(project_root: PathBuf) -> Self {
88        Self::new(project_root, Vec::new(), None)
89    }
90
91    /// The project root directory.
92    pub fn project_root(&self) -> &Path {
93        &self.project_root
94    }
95
96    /// Get all agent specs in the catalog.
97    pub fn all(&self) -> &[AgentSpec] {
98        self.registry.all()
99    }
100
101    pub fn selected_agent(&self) -> Option<&str> {
102        self.selected_agent.as_deref()
103    }
104
105    pub fn default_agent(&self) -> Option<&AgentSpec> {
106        self.selected_agent.as_deref().and_then(|name| self.registry.get(name)).or_else(|| self.user_invocable().next())
107    }
108
109    /// Get a specific agent by name.
110    pub fn get(&self, name: &str) -> Result<&AgentSpec, SettingsError> {
111        self.registry.get(name).ok_or_else(|| SettingsError::AgentNotFound { name: name.to_string() })
112    }
113
114    /// Iterate over user-invocable agents.
115    pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
116        self.registry.all().iter().filter(|s| s.exposure.user_invocable)
117    }
118
119    /// Iterate over agent-invocable agents.
120    pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
121        self.registry.agent_invocable()
122    }
123
124    pub fn registry(&self) -> &AgentRegistry {
125        &self.registry
126    }
127
128    pub fn default_spec(&self, model: &LlmModel, reasoning_effort: Option<ReasoningEffort>) -> AgentSpec {
129        let mut spec = AgentSpec::bare(model, reasoning_effort, Vec::new());
130        spec.provider_connections.merge(self.provider_connections.clone());
131        spec
132    }
133
134    /// Resolve and return a named agent spec ready for runtime use.
135    pub fn resolve(&self, name: &str) -> Result<AgentSpec, SettingsError> {
136        self.get(name).cloned()
137    }
138}
139
140impl AgentCatalog {
141    fn with_defaults(
142        project_root: PathBuf,
143        specs: Vec<AgentSpec>,
144        selected_agent: Option<String>,
145        provider_connections: ProviderConnectionOverrides,
146    ) -> Self {
147        Self { project_root, registry: AgentRegistry::new(specs), selected_agent, provider_connections }
148    }
149}
150
151struct AgentDefaults {
152    prompts: Vec<crate::PromptSource>,
153    mcps: Vec<McpSourceSpec>,
154    providers: ProviderConnectionOverrides,
155}
156
157fn validate_selected_agent(settings: &AetherSettings) -> Result<(), SettingsError> {
158    if settings.agents.is_empty() {
159        return Err(SettingsError::EmptyAgents);
160    }
161
162    if let Some(agent) = settings.agent.as_deref() {
163        let selector = agent.trim();
164        let Some(entry) = settings.agents.iter().find(|entry| entry.name.trim() == selector) else {
165            return Err(SettingsError::InvalidAgentSelector { name: selector.to_string() });
166        };
167
168        if !entry.user_invocable {
169            return Err(SettingsError::NonUserInvocableAgentSelector { name: selector.to_string() });
170        }
171    }
172
173    Ok(())
174}
175
176fn resolve_agent_entry(
177    project_root: &Path,
178    entry: AgentConfig,
179    defaults: &AgentDefaults,
180    index: usize,
181    seen_names: &mut HashSet<String>,
182) -> Result<AgentSpec, SettingsError> {
183    let name = entry.name.trim().to_string();
184    if name.is_empty() {
185        return Err(SettingsError::EmptyAgentName { index });
186    }
187    if name == "__default__" {
188        return Err(SettingsError::ReservedAgentName { name });
189    }
190    if !seen_names.insert(name.clone()) {
191        return Err(SettingsError::DuplicateAgentName { name });
192    }
193
194    let description = entry.description.trim().to_string();
195    if description.is_empty() {
196        return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
197    }
198
199    let model = parse_model(&name, &entry.model)?;
200    model
201        .validate_reasoning_effort(entry.reasoning_effort)
202        .map_err(|source| SettingsError::InvalidReasoningEffort { agent: name.clone(), source })?;
203    if entry.context_window == Some(0) {
204        return Err(SettingsError::InvalidContextWindow { agent: name.clone(), context_window: 0 });
205    }
206    if !entry.user_invocable && !entry.agent_invocable {
207        return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
208    }
209    let prompt_sources = if entry.prompts.is_empty() { &defaults.prompts } else { &entry.prompts };
210    let prompts = Prompt::from_sources(project_root, prompt_sources)
211        .map_err(|source| SettingsError::AgentPromptSource { agent: name.clone(), source })?;
212    if prompts.is_empty() {
213        return Err(if prompt_sources.is_empty() {
214            SettingsError::NoPromptsDeclared { agent: name.clone() }
215        } else {
216            SettingsError::AllOptionalPromptsMissing { agent: name.clone() }
217        });
218    }
219    let mcp_sources = if entry.mcps.is_empty() { &defaults.mcps } else { &entry.mcps };
220    let mcp_config_sources = resolve_mcp_config_sources(project_root, mcp_sources)?;
221    let mut provider_connections = defaults.providers.clone();
222    provider_connections.merge(entry.providers);
223
224    Ok(AgentSpec {
225        name,
226        description,
227        model: model.to_string(),
228        reasoning_effort: entry.reasoning_effort,
229        model_settings: entry.model_settings,
230        context_window: entry.context_window,
231        prompts,
232        provider_connections,
233        mcp_config_sources,
234        exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
235        tools: entry.tools,
236    })
237}
238
239fn resolve_mcp_config_sources(
240    workspace_root: &Path,
241    entries: &[McpSourceSpec],
242) -> Result<Vec<McpConfigSource>, SettingsError> {
243    entries
244        .iter()
245        .filter_map(|entry| match entry {
246            McpSourceSpec::File(McpFileSpec { path, defer_tools, optional }) => match path.resolve(workspace_root) {
247                Ok(full_path) => {
248                    if full_path.is_file() {
249                        Some(Ok(McpConfigSource::file(full_path, *defer_tools)))
250                    } else if *optional {
251                        None
252                    } else {
253                        Some(Err(SettingsError::InvalidMcpConfigPath { path: path.as_authored().to_string() }))
254                    }
255                }
256                Err(VarError::NotFound(variable)) if *optional => {
257                    tracing::warn!(
258                        "Skipping optional MCP config '{}': variable '{variable}' is not defined",
259                        path.as_authored()
260                    );
261                    None
262                }
263                Err(VarError::NotFound(variable)) => Some(Err(SettingsError::UnresolvedMcpConfigVariable {
264                    path: path.as_authored().to_string(),
265                    variable,
266                })),
267            },
268            McpSourceSpec::Inline { servers } => Some(Ok(McpConfigSource::Inline(McpConfig::new(servers.clone())))),
269        })
270        .collect()
271}
272
273fn parse_model(agent: &str, model: &str) -> Result<ModelSpec, SettingsError> {
274    model.parse().map_err(|error: ModelSpecError| SettingsError::InvalidModel {
275        agent: agent.to_string(),
276        model: model.to_string(),
277        error: error.to_string(),
278    })
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use aether_core::agent_spec::AgentSpecExposure;
285    use llm::ModelSettings;
286    use mcp_utils::client::ToolFilter;
287    use std::fs;
288
289    fn create_temp_project() -> tempfile::TempDir {
290        tempfile::tempdir().unwrap()
291    }
292
293    fn write_file(dir: &Path, path: &str, content: &str) {
294        let full_path = dir.join(path);
295        if let Some(parent) = full_path.parent() {
296            fs::create_dir_all(parent).unwrap();
297        }
298        fs::write(full_path, content).unwrap();
299    }
300
301    fn make_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
302        AgentSpec {
303            name: name.to_string(),
304            description: format!("{name} agent"),
305            model: "anthropic:claude-sonnet-4-5".to_string(),
306            reasoning_effort: None,
307            model_settings: ModelSettings::default(),
308            context_window: None,
309            prompts: vec![],
310            provider_connections: ProviderConnectionOverrides::default(),
311            mcp_config_sources: Vec::new(),
312            exposure,
313            tools: ToolFilter::default(),
314        }
315    }
316
317    fn create_test_catalog(project_root: PathBuf) -> AgentCatalog {
318        let planner = make_spec("planner", AgentSpecExposure::both());
319        AgentCatalog::new(project_root, vec![planner], None)
320    }
321
322    fn file_sources(spec: &AgentSpec) -> Vec<(PathBuf, bool)> {
323        spec.mcp_config_sources
324            .iter()
325            .filter_map(|source| match source {
326                McpConfigSource::File { path, defer_tools } => Some((path.clone(), *defer_tools)),
327                McpConfigSource::Json(_) | McpConfigSource::Inline(_) => None,
328            })
329            .collect()
330    }
331
332    fn has_prompt_file(spec: &AgentSpec, expected: &str) -> bool {
333        spec.prompts.iter().any(|prompt| match prompt {
334            Prompt::File { path, .. } => path.ends_with(expected),
335            Prompt::Text(_) | Prompt::McpInstructions(_) => false,
336        })
337    }
338
339    fn overrides(url: &str) -> ProviderConnectionOverrides {
340        ProviderConnectionOverrides::new(std::collections::BTreeMap::from([(
341            "anthropic".to_string(),
342            llm::ProviderConnectionOverride::url(url),
343        )]))
344    }
345
346    fn base_url(spec: &AgentSpec) -> Option<String> {
347        spec.provider_connections.config_for("anthropic").base_url
348    }
349
350    #[test]
351    fn provider_connections_reach_every_agent_not_just_the_selected_one() {
352        let dir = create_temp_project();
353        let catalog = AgentCatalog::new(
354            dir.path().to_path_buf(),
355            vec![make_spec("planner", AgentSpecExposure::both()), make_spec("worker", AgentSpecExposure::agent_only())],
356            None,
357        )
358        .with_provider_connections(overrides("https://runtime.test"));
359
360        for name in ["planner", "worker"] {
361            assert_eq!(base_url(catalog.get(name).unwrap()).as_deref(), Some("https://runtime.test"), "agent {name}");
362        }
363    }
364
365    #[test]
366    fn runtime_provider_connections_win_over_settings_declared_ones() {
367        let dir = create_temp_project();
368        let mut declared = make_spec("planner", AgentSpecExposure::both());
369        declared.provider_connections = overrides("https://from-settings.test");
370
371        let catalog = AgentCatalog::new(dir.path().to_path_buf(), vec![declared], None)
372            .with_provider_connections(overrides("https://runtime.test"));
373
374        assert_eq!(base_url(catalog.get("planner").unwrap()).as_deref(), Some("https://runtime.test"));
375    }
376
377    #[test]
378    fn default_spec_inherits_runtime_provider_connections() {
379        let dir = create_temp_project();
380        let catalog =
381            AgentCatalog::empty(dir.path().to_path_buf()).with_provider_connections(overrides("https://runtime.test"));
382
383        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
384
385        assert_eq!(spec.name, "__default__");
386        assert_eq!(base_url(&spec).as_deref(), Some("https://runtime.test"));
387    }
388
389    #[test]
390    fn settings_provider_connections_apply_to_explicit_model_specs() {
391        let dir = create_temp_project();
392        write_file(dir.path(), "BASE.md", "Base instructions");
393        let settings = AetherSettings {
394            agents: vec![AgentConfig {
395                name: "planner".to_string(),
396                description: "Planner agent".to_string(),
397                model: "anthropic:claude-sonnet-4-5".to_string(),
398                user_invocable: true,
399                prompts: vec![crate::PromptSource::file("BASE.md")],
400                ..AgentConfig::default()
401            }],
402            providers: overrides("https://settings.test"),
403            ..AetherSettings::default()
404        };
405        let catalog = AgentCatalog::from_settings_or_empty(dir.path(), settings).unwrap();
406
407        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
408
409        assert_eq!(base_url(&spec).as_deref(), Some("https://settings.test"));
410    }
411
412    #[test]
413    fn settings_provider_connections_apply_to_fallback_specs_without_agents() {
414        let dir = create_temp_project();
415        let settings = AetherSettings { providers: overrides("https://settings.test"), ..AetherSettings::default() };
416        let catalog = AgentCatalog::from_settings_or_empty(dir.path(), settings).unwrap();
417
418        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
419
420        assert_eq!(base_url(&spec).as_deref(), Some("https://settings.test"));
421    }
422
423    #[test]
424    fn runtime_provider_connections_override_settings_for_default_specs() {
425        let dir = create_temp_project();
426        let settings = AetherSettings { providers: overrides("https://settings.test"), ..AetherSettings::default() };
427        let catalog = AgentCatalog::from_settings_or_empty(dir.path(), settings)
428            .unwrap()
429            .with_provider_connections(overrides("https://runtime.test"));
430
431        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
432
433        assert_eq!(base_url(&spec).as_deref(), Some("https://runtime.test"));
434    }
435
436    #[test]
437    fn default_spec_without_overrides_leaves_connections_unset() {
438        let dir = create_temp_project();
439        let catalog = AgentCatalog::empty(dir.path().to_path_buf());
440
441        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
442
443        assert_eq!(base_url(&spec), None);
444    }
445
446    #[test]
447    fn user_invocable_filters_correctly() {
448        let dir = create_temp_project();
449        let root = dir.path().to_path_buf();
450        let catalog = AgentCatalog::new(
451            root,
452            vec![
453                make_spec("planner", AgentSpecExposure::both()),
454                make_spec("internal", AgentSpecExposure::agent_only()),
455            ],
456            None,
457        );
458
459        let user_invocable: Vec<_> = catalog.user_invocable().collect();
460        assert_eq!(user_invocable.len(), 1);
461        assert_eq!(user_invocable[0].name, "planner");
462    }
463
464    #[test]
465    fn agent_invocable_filters_correctly() {
466        let dir = create_temp_project();
467        let root = dir.path().to_path_buf();
468        let catalog = AgentCatalog::new(
469            root,
470            vec![
471                make_spec("planner", AgentSpecExposure::both()),
472                make_spec("user-only", AgentSpecExposure::user_only()),
473            ],
474            None,
475        );
476
477        let agent_invocable: Vec<_> = catalog.agent_invocable().collect();
478        assert_eq!(agent_invocable.len(), 1);
479        assert_eq!(agent_invocable[0].name, "planner");
480    }
481
482    #[test]
483    fn default_agent_uses_selected_agent() {
484        let dir = create_temp_project();
485        let catalog = AgentCatalog::new(
486            dir.path().to_path_buf(),
487            vec![make_spec("first", AgentSpecExposure::both()), make_spec("second", AgentSpecExposure::both())],
488            Some("second".to_string()),
489        );
490
491        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("second"));
492    }
493
494    #[test]
495    fn default_agent_falls_back_to_first_user_invocable() {
496        let dir = create_temp_project();
497        let catalog = AgentCatalog::new(
498            dir.path().to_path_buf(),
499            vec![
500                make_spec("internal", AgentSpecExposure::agent_only()),
501                make_spec("visible", AgentSpecExposure::user_only()),
502            ],
503            None,
504        );
505
506        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("visible"));
507    }
508
509    #[test]
510    fn get_returns_error_for_missing_agent() {
511        let dir = create_temp_project();
512        let catalog = create_test_catalog(dir.path().to_path_buf());
513        let result = catalog.get("nonexistent");
514        assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
515    }
516
517    #[test]
518    fn agent_rejects_reasoning_effort_unsupported_by_model() {
519        let dir = create_temp_project();
520        write_file(dir.path(), "BASE.md", "Base instructions");
521        let config = AetherSettings {
522            agents: vec![AgentConfig {
523                name: "planner".to_string(),
524                description: "Planner agent".to_string(),
525                model: "anthropic:claude-opus-4-6".to_string(),
526                reasoning_effort: Some(llm::ReasoningEffort::Xhigh),
527                user_invocable: true,
528                prompts: vec![crate::PromptSource::file("BASE.md")],
529                ..AgentConfig::default()
530            }],
531            ..AetherSettings::default()
532        };
533
534        let error = AgentCatalog::from_settings(dir.path(), config).unwrap_err();
535
536        assert!(matches!(error, SettingsError::InvalidReasoningEffort { .. }));
537    }
538
539    #[test]
540    fn agent_context_window_is_resolved_into_spec() {
541        let dir = create_temp_project();
542        write_file(dir.path(), "BASE.md", "Base instructions");
543
544        let config = AetherSettings {
545            agents: vec![AgentConfig {
546                name: "planner".to_string(),
547                description: "Planner agent".to_string(),
548                model: "anthropic:claude-sonnet-4-5".to_string(),
549                context_window: Some(200_000),
550                user_invocable: true,
551                prompts: vec![crate::PromptSource::file("BASE.md")],
552                ..AgentConfig::default()
553            }],
554            ..AetherSettings::default()
555        };
556
557        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
558        let spec = catalog.resolve("planner").unwrap();
559
560        assert_eq!(spec.context_window, Some(200_000));
561    }
562
563    #[test]
564    fn agent_model_settings_resolve_from_config_json() {
565        let dir = create_temp_project();
566        write_file(dir.path(), "BASE.md", "Base instructions");
567
568        let json = r#"{
569            "agents": [{
570                "name": "judge",
571                "description": "Judge agent",
572                "model": "anthropic:claude-sonnet-4-5",
573                "userInvocable": true,
574                "prompts": ["BASE.md"],
575                "modelSettings": { "temperature": 0, "topP": 0.9, "maxTokens": 1024 }
576            }]
577        }"#;
578
579        let config: AetherSettings = serde_json::from_str(json).unwrap();
580        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
581        let spec = catalog.resolve("judge").unwrap();
582
583        assert_eq!(
584            spec.model_settings,
585            ModelSettings { temperature: Some(0.0), top_p: Some(0.9), max_tokens: Some(1024) }
586        );
587    }
588
589    #[test]
590    fn agent_context_window_rejects_zero() {
591        let config = AetherSettings {
592            agents: vec![AgentConfig {
593                name: "planner".to_string(),
594                description: "Planner agent".to_string(),
595                model: "anthropic:claude-sonnet-4-5".to_string(),
596                context_window: Some(0),
597                user_invocable: true,
598                ..AgentConfig::default()
599            }],
600            ..AetherSettings::default()
601        };
602
603        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
604
605        assert!(matches!(
606            err,
607            SettingsError::InvalidContextWindow { agent, context_window: 0 } if agent == "planner"
608        ));
609    }
610
611    #[test]
612    fn top_level_prompts_are_inherited_when_agent_prompts_are_empty() {
613        let dir = create_temp_project();
614        write_file(dir.path(), "BASE.md", "Base instructions");
615
616        let config = AetherSettings {
617            prompts: vec![crate::PromptSource::file("BASE.md")],
618            agents: vec![AgentConfig {
619                name: "planner".to_string(),
620                description: "Planner agent".to_string(),
621                model: "anthropic:claude-sonnet-4-5".to_string(),
622                user_invocable: true,
623                ..AgentConfig::default()
624            }],
625            ..AetherSettings::default()
626        };
627
628        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
629        let spec = catalog.resolve("planner").unwrap();
630
631        assert!(has_prompt_file(&spec, "BASE.md"));
632    }
633
634    #[test]
635    fn agent_prompts_override_top_level_prompts() {
636        let dir = create_temp_project();
637        write_file(dir.path(), "BASE.md", "Base instructions");
638        write_file(dir.path(), "AGENT.md", "Agent instructions");
639
640        let config = AetherSettings {
641            prompts: vec![crate::PromptSource::file("BASE.md")],
642            agents: vec![AgentConfig {
643                name: "planner".to_string(),
644                description: "Planner agent".to_string(),
645                model: "anthropic:claude-sonnet-4-5".to_string(),
646                user_invocable: true,
647                prompts: vec![crate::PromptSource::file("AGENT.md")],
648                ..AgentConfig::default()
649            }],
650            ..AetherSettings::default()
651        };
652
653        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
654        let spec = catalog.resolve("planner").unwrap();
655
656        assert!(has_prompt_file(&spec, "AGENT.md"));
657        assert!(!has_prompt_file(&spec, "BASE.md"));
658    }
659
660    #[test]
661    fn top_level_mcps_are_inherited_when_agent_mcps_are_empty() {
662        let dir = create_temp_project();
663        write_file(dir.path(), "BASE.md", "Base instructions");
664        write_file(dir.path(), "base-mcp.json", "{}");
665
666        let config = AetherSettings {
667            prompts: vec![crate::PromptSource::file("BASE.md")],
668            mcps: vec![McpSourceSpec::file("base-mcp.json")],
669            agents: vec![AgentConfig {
670                name: "planner".to_string(),
671                description: "Planner agent".to_string(),
672                model: "anthropic:claude-sonnet-4-5".to_string(),
673                user_invocable: true,
674                ..AgentConfig::default()
675            }],
676            ..AetherSettings::default()
677        };
678
679        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
680        let spec = catalog.resolve("planner").unwrap();
681
682        assert_eq!(file_sources(&spec), vec![(dir.path().join("base-mcp.json"), false)]);
683    }
684
685    #[test]
686    fn agent_mcps_override_top_level_mcps() {
687        let dir = create_temp_project();
688        write_file(dir.path(), "BASE.md", "Base instructions");
689        write_file(dir.path(), "base-mcp.json", "{}");
690        write_file(dir.path(), "agent-mcp.json", "{}");
691
692        let config = AetherSettings {
693            prompts: vec![crate::PromptSource::file("BASE.md")],
694            mcps: vec![McpSourceSpec::file("base-mcp.json")],
695            agents: vec![AgentConfig {
696                name: "planner".to_string(),
697                description: "Planner agent".to_string(),
698                model: "anthropic:claude-sonnet-4-5".to_string(),
699                user_invocable: true,
700                mcps: vec![McpSourceSpec::file("agent-mcp.json")],
701                ..AgentConfig::default()
702            }],
703            ..AetherSettings::default()
704        };
705
706        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
707        let spec = catalog.resolve("planner").unwrap();
708
709        assert_eq!(file_sources(&spec), vec![(dir.path().join("agent-mcp.json"), false)]);
710    }
711
712    #[test]
713    fn missing_top_level_and_agent_prompts_still_errors() {
714        let config = AetherSettings {
715            agents: vec![AgentConfig {
716                name: "planner".to_string(),
717                description: "Planner agent".to_string(),
718                model: "anthropic:claude-sonnet-4-5".to_string(),
719                user_invocable: true,
720                ..AgentConfig::default()
721            }],
722            ..AetherSettings::default()
723        };
724
725        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
726
727        assert!(matches!(err, SettingsError::NoPromptsDeclared { agent } if agent == "planner"));
728    }
729
730    #[test]
731    fn resolve_missing_agent_returns_error() {
732        let dir = create_temp_project();
733        let catalog = create_test_catalog(dir.path().to_path_buf());
734        let result = catalog.resolve("missing");
735        assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
736    }
737
738    #[test]
739    fn resolve_preserves_agent_mcp() {
740        let dir = create_temp_project();
741        write_file(dir.path(), "agent-mcp.json", "{}");
742
743        let mut planner = make_spec("planner", AgentSpecExposure::both());
744        planner.mcp_config_sources = vec![McpConfigSource::model_visible(dir.path().join("agent-mcp.json"))];
745
746        let catalog = AgentCatalog::new(dir.path().to_path_buf(), vec![planner], None);
747
748        let spec = catalog.resolve("planner").unwrap();
749        assert_eq!(file_sources(&spec), vec![(dir.path().join("agent-mcp.json"), false)]);
750    }
751
752    #[test]
753    fn resolve_no_mcp_config_is_valid() {
754        let dir = create_temp_project();
755        let catalog =
756            AgentCatalog::new(dir.path().to_path_buf(), vec![make_spec("planner", AgentSpecExposure::both())], None);
757
758        let spec = catalog.resolve("planner").unwrap();
759        assert!(spec.mcp_config_sources.is_empty());
760    }
761}