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 crate::testing::{fake_spec, project};
285    use aether_core::agent_spec::AgentSpecExposure;
286    use llm::ModelSettings;
287
288    fn create_test_catalog(project_root: PathBuf) -> AgentCatalog {
289        let planner = fake_spec("planner", AgentSpecExposure::both());
290        AgentCatalog::new(project_root, vec![planner], None)
291    }
292
293    fn file_sources(spec: &AgentSpec) -> Vec<(PathBuf, bool)> {
294        spec.mcp_config_sources
295            .iter()
296            .filter_map(|source| match source {
297                McpConfigSource::File { path, defer_tools } => Some((path.clone(), *defer_tools)),
298                McpConfigSource::Json(_) | McpConfigSource::Inline(_) => None,
299            })
300            .collect()
301    }
302
303    fn has_prompt_file(spec: &AgentSpec, expected: &str) -> bool {
304        spec.prompts.iter().any(|prompt| match prompt {
305            Prompt::File { path, .. } => path.ends_with(expected),
306            Prompt::Text(_) | Prompt::McpInstructions(_) => false,
307        })
308    }
309
310    fn overrides(url: &str) -> ProviderConnectionOverrides {
311        ProviderConnectionOverrides::new(std::collections::BTreeMap::from([(
312            "anthropic".to_string(),
313            llm::ProviderConnectionOverride::url(url),
314        )]))
315    }
316
317    fn base_url(spec: &AgentSpec) -> Option<String> {
318        spec.provider_connections.config_for("anthropic").base_url
319    }
320
321    #[test]
322    fn provider_connections_reach_every_agent_not_just_the_selected_one() {
323        let dir = project();
324        let catalog = AgentCatalog::new(
325            dir.root().to_path_buf(),
326            vec![fake_spec("planner", AgentSpecExposure::both()), fake_spec("worker", AgentSpecExposure::agent_only())],
327            None,
328        )
329        .with_provider_connections(overrides("https://runtime.test"));
330
331        for name in ["planner", "worker"] {
332            assert_eq!(base_url(catalog.get(name).unwrap()).as_deref(), Some("https://runtime.test"), "agent {name}");
333        }
334    }
335
336    #[test]
337    fn runtime_provider_connections_win_over_settings_declared_ones() {
338        let dir = project();
339        let mut declared = fake_spec("planner", AgentSpecExposure::both());
340        declared.provider_connections = overrides("https://from-settings.test");
341
342        let catalog = AgentCatalog::new(dir.root().to_path_buf(), vec![declared], None)
343            .with_provider_connections(overrides("https://runtime.test"));
344
345        assert_eq!(base_url(catalog.get("planner").unwrap()).as_deref(), Some("https://runtime.test"));
346    }
347
348    #[test]
349    fn default_spec_inherits_runtime_provider_connections() {
350        let dir = project();
351        let catalog =
352            AgentCatalog::empty(dir.root().to_path_buf()).with_provider_connections(overrides("https://runtime.test"));
353
354        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
355
356        assert_eq!(spec.name, "__default__");
357        assert_eq!(base_url(&spec).as_deref(), Some("https://runtime.test"));
358    }
359
360    #[test]
361    fn settings_provider_connections_apply_to_explicit_model_specs() {
362        let dir = project().file("BASE.md", "Base instructions");
363        let settings = AetherSettings {
364            agents: vec![AgentConfig {
365                name: "planner".to_string(),
366                description: "Planner agent".to_string(),
367                model: "anthropic:claude-sonnet-4-5".to_string(),
368                user_invocable: true,
369                prompts: vec![crate::PromptSource::file("BASE.md")],
370                ..AgentConfig::default()
371            }],
372            providers: overrides("https://settings.test"),
373            ..AetherSettings::default()
374        };
375        let catalog = AgentCatalog::from_settings_or_empty(dir.root(), settings).unwrap();
376
377        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
378
379        assert_eq!(base_url(&spec).as_deref(), Some("https://settings.test"));
380    }
381
382    #[test]
383    fn settings_provider_connections_apply_to_fallback_specs_without_agents() {
384        let dir = project();
385        let settings = AetherSettings { providers: overrides("https://settings.test"), ..AetherSettings::default() };
386        let catalog = AgentCatalog::from_settings_or_empty(dir.root(), settings).unwrap();
387
388        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
389
390        assert_eq!(base_url(&spec).as_deref(), Some("https://settings.test"));
391    }
392
393    #[test]
394    fn runtime_provider_connections_override_settings_for_default_specs() {
395        let dir = project();
396        let settings = AetherSettings { providers: overrides("https://settings.test"), ..AetherSettings::default() };
397        let catalog = AgentCatalog::from_settings_or_empty(dir.root(), settings)
398            .unwrap()
399            .with_provider_connections(overrides("https://runtime.test"));
400
401        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
402
403        assert_eq!(base_url(&spec).as_deref(), Some("https://runtime.test"));
404    }
405
406    #[test]
407    fn default_spec_without_overrides_leaves_connections_unset() {
408        let dir = project();
409        let catalog = AgentCatalog::empty(dir.root().to_path_buf());
410
411        let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None);
412
413        assert_eq!(base_url(&spec), None);
414    }
415
416    #[test]
417    fn user_invocable_filters_correctly() {
418        let dir = project();
419        let root = dir.root().to_path_buf();
420        let catalog = AgentCatalog::new(
421            root,
422            vec![
423                fake_spec("planner", AgentSpecExposure::both()),
424                fake_spec("internal", AgentSpecExposure::agent_only()),
425            ],
426            None,
427        );
428
429        let user_invocable: Vec<_> = catalog.user_invocable().collect();
430        assert_eq!(user_invocable.len(), 1);
431        assert_eq!(user_invocable[0].name, "planner");
432    }
433
434    #[test]
435    fn agent_invocable_filters_correctly() {
436        let dir = project();
437        let root = dir.root().to_path_buf();
438        let catalog = AgentCatalog::new(
439            root,
440            vec![
441                fake_spec("planner", AgentSpecExposure::both()),
442                fake_spec("user-only", AgentSpecExposure::user_only()),
443            ],
444            None,
445        );
446
447        let agent_invocable: Vec<_> = catalog.agent_invocable().collect();
448        assert_eq!(agent_invocable.len(), 1);
449        assert_eq!(agent_invocable[0].name, "planner");
450    }
451
452    #[test]
453    fn default_agent_uses_selected_agent() {
454        let dir = project();
455        let catalog = AgentCatalog::new(
456            dir.root().to_path_buf(),
457            vec![fake_spec("first", AgentSpecExposure::both()), fake_spec("second", AgentSpecExposure::both())],
458            Some("second".to_string()),
459        );
460
461        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("second"));
462    }
463
464    #[test]
465    fn default_agent_falls_back_to_first_user_invocable() {
466        let dir = project();
467        let catalog = AgentCatalog::new(
468            dir.root().to_path_buf(),
469            vec![
470                fake_spec("internal", AgentSpecExposure::agent_only()),
471                fake_spec("visible", AgentSpecExposure::user_only()),
472            ],
473            None,
474        );
475
476        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("visible"));
477    }
478
479    #[test]
480    fn get_returns_error_for_missing_agent() {
481        let dir = project();
482        let catalog = create_test_catalog(dir.root().to_path_buf());
483        let result = catalog.get("nonexistent");
484        assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
485    }
486
487    #[test]
488    fn agent_rejects_reasoning_effort_unsupported_by_model() {
489        let dir = project().file("BASE.md", "Base instructions");
490        let config = AetherSettings {
491            agents: vec![AgentConfig {
492                name: "planner".to_string(),
493                description: "Planner agent".to_string(),
494                model: "anthropic:claude-opus-4-6".to_string(),
495                reasoning_effort: Some(llm::ReasoningEffort::Xhigh),
496                user_invocable: true,
497                prompts: vec![crate::PromptSource::file("BASE.md")],
498                ..AgentConfig::default()
499            }],
500            ..AetherSettings::default()
501        };
502
503        let error = AgentCatalog::from_settings(dir.root(), config).unwrap_err();
504
505        assert!(matches!(error, SettingsError::InvalidReasoningEffort { .. }));
506    }
507
508    #[test]
509    fn agent_context_window_is_resolved_into_spec() {
510        let dir = project().file("BASE.md", "Base instructions");
511
512        let config = AetherSettings {
513            agents: vec![AgentConfig {
514                name: "planner".to_string(),
515                description: "Planner agent".to_string(),
516                model: "anthropic:claude-sonnet-4-5".to_string(),
517                context_window: Some(200_000),
518                user_invocable: true,
519                prompts: vec![crate::PromptSource::file("BASE.md")],
520                ..AgentConfig::default()
521            }],
522            ..AetherSettings::default()
523        };
524
525        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
526        let spec = catalog.resolve("planner").unwrap();
527
528        assert_eq!(spec.context_window, Some(200_000));
529    }
530
531    #[test]
532    fn agent_model_settings_resolve_from_config_json() {
533        let dir = project().file("BASE.md", "Base instructions");
534
535        let json = r#"{
536            "agents": [{
537                "name": "judge",
538                "description": "Judge agent",
539                "model": "anthropic:claude-sonnet-4-5",
540                "userInvocable": true,
541                "prompts": ["BASE.md"],
542                "modelSettings": { "temperature": 0, "topP": 0.9, "maxTokens": 1024 }
543            }]
544        }"#;
545
546        let config: AetherSettings = serde_json::from_str(json).unwrap();
547        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
548        let spec = catalog.resolve("judge").unwrap();
549
550        assert_eq!(
551            spec.model_settings,
552            ModelSettings { temperature: Some(0.0), top_p: Some(0.9), max_tokens: Some(1024) }
553        );
554    }
555
556    #[test]
557    fn agent_context_window_rejects_zero() {
558        let config = AetherSettings {
559            agents: vec![AgentConfig {
560                name: "planner".to_string(),
561                description: "Planner agent".to_string(),
562                model: "anthropic:claude-sonnet-4-5".to_string(),
563                context_window: Some(0),
564                user_invocable: true,
565                ..AgentConfig::default()
566            }],
567            ..AetherSettings::default()
568        };
569
570        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
571
572        assert!(matches!(
573            err,
574            SettingsError::InvalidContextWindow { agent, context_window: 0 } if agent == "planner"
575        ));
576    }
577
578    #[test]
579    fn top_level_prompts_are_inherited_when_agent_prompts_are_empty() {
580        let dir = project().file("BASE.md", "Base instructions");
581
582        let config = AetherSettings {
583            prompts: vec![crate::PromptSource::file("BASE.md")],
584            agents: vec![AgentConfig {
585                name: "planner".to_string(),
586                description: "Planner agent".to_string(),
587                model: "anthropic:claude-sonnet-4-5".to_string(),
588                user_invocable: true,
589                ..AgentConfig::default()
590            }],
591            ..AetherSettings::default()
592        };
593
594        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
595        let spec = catalog.resolve("planner").unwrap();
596
597        assert!(has_prompt_file(&spec, "BASE.md"));
598    }
599
600    #[test]
601    fn agent_prompts_override_top_level_prompts() {
602        let dir = project().file("BASE.md", "Base instructions").file("AGENT.md", "Agent instructions");
603
604        let config = AetherSettings {
605            prompts: vec![crate::PromptSource::file("BASE.md")],
606            agents: vec![AgentConfig {
607                name: "planner".to_string(),
608                description: "Planner agent".to_string(),
609                model: "anthropic:claude-sonnet-4-5".to_string(),
610                user_invocable: true,
611                prompts: vec![crate::PromptSource::file("AGENT.md")],
612                ..AgentConfig::default()
613            }],
614            ..AetherSettings::default()
615        };
616
617        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
618        let spec = catalog.resolve("planner").unwrap();
619
620        assert!(has_prompt_file(&spec, "AGENT.md"));
621        assert!(!has_prompt_file(&spec, "BASE.md"));
622    }
623
624    #[test]
625    fn top_level_mcps_are_inherited_when_agent_mcps_are_empty() {
626        let dir = project().file("BASE.md", "Base instructions").file("base-mcp.json", "{}");
627
628        let config = AetherSettings {
629            prompts: vec![crate::PromptSource::file("BASE.md")],
630            mcps: vec![McpSourceSpec::file("base-mcp.json")],
631            agents: vec![AgentConfig {
632                name: "planner".to_string(),
633                description: "Planner agent".to_string(),
634                model: "anthropic:claude-sonnet-4-5".to_string(),
635                user_invocable: true,
636                ..AgentConfig::default()
637            }],
638            ..AetherSettings::default()
639        };
640
641        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
642        let spec = catalog.resolve("planner").unwrap();
643
644        assert_eq!(file_sources(&spec), vec![(dir.root().join("base-mcp.json"), false)]);
645    }
646
647    #[test]
648    fn agent_mcps_override_top_level_mcps() {
649        let dir =
650            project().file("BASE.md", "Base instructions").file("base-mcp.json", "{}").file("agent-mcp.json", "{}");
651
652        let config = AetherSettings {
653            prompts: vec![crate::PromptSource::file("BASE.md")],
654            mcps: vec![McpSourceSpec::file("base-mcp.json")],
655            agents: vec![AgentConfig {
656                name: "planner".to_string(),
657                description: "Planner agent".to_string(),
658                model: "anthropic:claude-sonnet-4-5".to_string(),
659                user_invocable: true,
660                mcps: vec![McpSourceSpec::file("agent-mcp.json")],
661                ..AgentConfig::default()
662            }],
663            ..AetherSettings::default()
664        };
665
666        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
667        let spec = catalog.resolve("planner").unwrap();
668
669        assert_eq!(file_sources(&spec), vec![(dir.root().join("agent-mcp.json"), false)]);
670    }
671
672    #[test]
673    fn missing_top_level_and_agent_prompts_still_errors() {
674        let config = AetherSettings {
675            agents: vec![AgentConfig {
676                name: "planner".to_string(),
677                description: "Planner agent".to_string(),
678                model: "anthropic:claude-sonnet-4-5".to_string(),
679                user_invocable: true,
680                ..AgentConfig::default()
681            }],
682            ..AetherSettings::default()
683        };
684
685        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
686
687        assert!(matches!(err, SettingsError::NoPromptsDeclared { agent } if agent == "planner"));
688    }
689
690    #[test]
691    fn resolve_missing_agent_returns_error() {
692        let dir = project();
693        let catalog = create_test_catalog(dir.root().to_path_buf());
694        let result = catalog.resolve("missing");
695        assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
696    }
697
698    #[test]
699    fn resolve_preserves_agent_mcp() {
700        let dir = project().file("agent-mcp.json", "{}");
701
702        let mut planner = fake_spec("planner", AgentSpecExposure::both());
703        planner.mcp_config_sources = vec![McpConfigSource::model_visible(dir.root().join("agent-mcp.json"))];
704
705        let catalog = AgentCatalog::new(dir.root().to_path_buf(), vec![planner], None);
706
707        let spec = catalog.resolve("planner").unwrap();
708        assert_eq!(file_sources(&spec), vec![(dir.root().join("agent-mcp.json"), false)]);
709    }
710
711    #[test]
712    fn resolve_no_mcp_config_is_valid() {
713        let dir = project();
714        let catalog =
715            AgentCatalog::new(dir.root().to_path_buf(), vec![fake_spec("planner", AgentSpecExposure::both())], None);
716
717        let spec = catalog.resolve("planner").unwrap();
718        assert!(spec.mcp_config_sources.is_empty());
719    }
720}