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