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#[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 pub fn empty(project_root: PathBuf) -> Self {
88 Self::new(project_root, Vec::new(), None)
89 }
90
91 pub fn project_root(&self) -> &Path {
93 &self.project_root
94 }
95
96 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 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 pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
116 self.registry.all().iter().filter(|s| s.exposure.user_invocable)
117 }
118
119 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 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}