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