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