1use crate::error::SettingsError;
4use aether_core::agent_spec::{AgentSpec, AgentSpecExposure, McpJsonFileRef, ToolFilter};
5use aether_core::core::Prompt;
6use glob::glob;
7use llm::{LlmModel, ReasoningEffort};
8use std::collections::HashSet;
9use std::path::Path;
10
11#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
13#[serde(untagged)]
14pub enum McpServerEntry {
15 Path(String),
16 Config {
17 path: String,
18 #[serde(default)]
19 proxy: bool,
20 },
21}
22
23impl McpServerEntry {
24 pub fn path_str(&self) -> &str {
25 match self {
26 McpServerEntry::Path(p) => p,
27 McpServerEntry::Config { path, .. } => path,
28 }
29 }
30
31 pub fn proxy(&self) -> bool {
32 match self {
33 McpServerEntry::Path(_) => false,
34 McpServerEntry::Config { proxy, .. } => *proxy,
35 }
36 }
37}
38
39impl From<&str> for McpServerEntry {
40 fn from(s: &str) -> Self {
41 Self::Path(s.to_string())
42 }
43}
44
45#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
47#[serde(default, rename_all = "camelCase")]
48pub struct Settings {
49 #[serde(skip_serializing_if = "Vec::is_empty")]
51 pub prompts: Vec<String>,
52 #[serde(skip_serializing_if = "Vec::is_empty")]
54 pub mcp_servers: Vec<McpServerEntry>,
55 pub agents: Vec<AgentEntry>,
57}
58
59#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
61#[serde(default, rename_all = "camelCase")]
62pub struct AgentEntry {
63 pub name: String,
64 pub description: String,
65 pub model: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub reasoning_effort: Option<ReasoningEffort>,
68 #[serde(default)]
69 pub user_invocable: bool,
70 #[serde(default)]
71 pub agent_invocable: bool,
72 #[serde(default)]
73 pub prompts: Vec<String>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub mcp_servers: Vec<McpServerEntry>,
76 #[serde(default, skip_serializing_if = "ToolFilter::is_empty")]
77 pub tools: ToolFilter,
78}
79
80pub fn load_agent_catalog(project_root: &Path) -> Result<super::catalog::AgentCatalog, SettingsError> {
85 let settings_path = project_root.join(".aether/settings.json");
86
87 let settings = match std::fs::read_to_string(&settings_path) {
88 Ok(content) => {
89 if content.trim().is_empty() {
90 Settings::default()
91 } else {
92 serde_json::from_str(&content).map_err(|e| SettingsError::ParseError(e.to_string()))?
93 }
94 }
95 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
96 return Ok(super::catalog::AgentCatalog::empty(project_root.to_path_buf()));
97 }
98 Err(e) => {
99 return Err(SettingsError::IoError(format!("Failed to read {}: {}", settings_path.display(), e)));
100 }
101 };
102
103 resolve_settings(project_root, settings)
104}
105
106fn resolve_settings(project_root: &Path, settings: Settings) -> Result<super::catalog::AgentCatalog, SettingsError> {
108 let Settings { prompts: inherited_patterns, mcp_servers, agents } = settings;
109
110 validate_prompt_entries(project_root, &inherited_patterns, None)?;
111 let inherited_mcp_config_refs = resolve_mcp_config_refs(project_root, &mcp_servers)?;
112 let inherited_prompts = build_inherited_prompts(&inherited_patterns, project_root);
113
114 let mut seen_names = HashSet::new();
115 let mut specs = Vec::with_capacity(agents.len());
116
117 for (index, entry) in agents.into_iter().enumerate() {
118 specs.push(resolve_agent_entry(project_root, &inherited_prompts, entry, index, &mut seen_names)?);
119 }
120
121 Ok(super::catalog::AgentCatalog::new(
122 project_root.to_path_buf(),
123 inherited_prompts,
124 inherited_mcp_config_refs,
125 specs,
126 ))
127}
128
129fn resolve_agent_entry(
130 project_root: &Path,
131 inherited_prompts: &[Prompt],
132 entry: AgentEntry,
133 index: usize,
134 seen_names: &mut HashSet<String>,
135) -> Result<AgentSpec, SettingsError> {
136 let name = entry.name.trim().to_string();
137 if name.is_empty() {
138 return Err(SettingsError::EmptyAgentName { index });
139 }
140
141 if name == "__default__" {
142 return Err(SettingsError::ReservedAgentName { name });
143 }
144
145 if !seen_names.insert(name.clone()) {
146 return Err(SettingsError::DuplicateAgentName { name });
147 }
148
149 let description = entry.description.trim().to_string();
150 if description.is_empty() {
151 return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
152 }
153
154 let model = parse_model(&name, &entry.model)?;
155
156 if !entry.user_invocable && !entry.agent_invocable {
157 return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
158 }
159
160 validate_prompt_entries(project_root, &entry.prompts, Some(&name))?;
161
162 if inherited_prompts.is_empty() && entry.prompts.is_empty() {
163 return Err(SettingsError::NoPrompts { agent: name.clone() });
164 }
165
166 let mcp_config_refs = resolve_mcp_config_refs(project_root, &entry.mcp_servers)?;
167
168 let prompts = if entry.prompts.is_empty() {
169 inherited_prompts.to_vec()
170 } else {
171 entry.prompts.iter().map(|p| Prompt::from_globs(vec![p.clone()], project_root.to_path_buf())).collect()
172 };
173
174 Ok(AgentSpec {
175 name,
176 description,
177 model,
178 reasoning_effort: entry.reasoning_effort,
179 prompts,
180 mcp_config_refs,
181 exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
182 tools: entry.tools,
183 })
184}
185
186fn parse_model(agent: &str, model: &str) -> Result<String, SettingsError> {
187 canonicalize_model_spec(model).map_err(|error| SettingsError::InvalidModel {
188 agent: agent.to_string(),
189 model: model.to_string(),
190 error,
191 })
192}
193
194fn canonicalize_model_spec(model: &str) -> Result<String, String> {
195 let trimmed = model.trim();
196 if trimmed.is_empty() {
197 return Err("Model spec cannot be empty".to_string());
198 }
199
200 let mut canonical_parts = Vec::new();
201 for part in trimmed.split(',').map(str::trim) {
202 if part.is_empty() {
203 return Err("Model spec contains an empty entry".to_string());
204 }
205
206 part.parse::<LlmModel>().map_err(|error: String| error)?;
207 canonical_parts.push(part.to_string());
208 }
209
210 Ok(canonical_parts.join(","))
211}
212
213fn validate_prompt_entries(
214 project_root: &Path,
215 patterns: &[String],
216 agent_name: Option<&str>,
217) -> Result<(), SettingsError> {
218 for pattern in patterns {
219 validate_prompt_entry(project_root, pattern, agent_name)?;
220 }
221 Ok(())
222}
223
224fn resolve_mcp_config_refs(
225 project_root: &Path,
226 entries: &[McpServerEntry],
227) -> Result<Vec<McpJsonFileRef>, SettingsError> {
228 let mut resolved = Vec::with_capacity(entries.len());
229 for entry in entries {
230 let full_path = project_root.join(entry.path_str());
231 if full_path.is_file() {
232 resolved.push(McpJsonFileRef { path: full_path, proxy: entry.proxy() });
233 } else {
234 return Err(SettingsError::InvalidMcpConfigPath { path: entry.path_str().to_string() });
235 }
236 }
237 Ok(resolved)
238}
239
240fn validate_prompt_entry(project_root: &Path, pattern: &str, agent_name: Option<&str>) -> Result<(), SettingsError> {
242 let full_pattern = if Path::new(pattern).is_absolute() {
243 pattern.to_string()
244 } else {
245 project_root.join(pattern).to_string_lossy().to_string()
246 };
247
248 let has_file_match = glob(&full_pattern)
249 .map_err(|e| {
250 if let Some(agent) = agent_name {
251 SettingsError::InvalidGlobPattern {
252 agent: agent.to_string(),
253 pattern: pattern.to_string(),
254 error: e.to_string(),
255 }
256 } else {
257 SettingsError::InvalidInheritedGlobPattern { pattern: pattern.to_string(), error: e.to_string() }
258 }
259 })?
260 .filter_map(Result::ok)
261 .any(|path| path.is_file());
262
263 if has_file_match {
264 Ok(())
265 } else if let Some(agent) = agent_name {
266 Err(SettingsError::ZeroMatchPrompt { agent: agent.to_string(), pattern: pattern.to_string() })
267 } else {
268 Err(SettingsError::ZeroMatchInheritedPrompt { pattern: pattern.to_string() })
269 }
270}
271
272fn build_inherited_prompts(patterns: &[String], project_root: &Path) -> Vec<Prompt> {
276 patterns.iter().map(|pattern| Prompt::from_globs(vec![pattern.clone()], project_root.to_path_buf())).collect()
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use aether_core::agent_spec::McpJsonFileRef;
283 use std::fs;
284
285 fn create_temp_project() -> tempfile::TempDir {
286 tempfile::tempdir().unwrap()
287 }
288
289 fn write_settings(dir: &Path, content: &str) {
290 let aether_dir = dir.join(".aether");
291 fs::create_dir_all(&aether_dir).unwrap();
292 fs::write(aether_dir.join("settings.json"), content).unwrap();
293 }
294
295 fn write_file(dir: &Path, path: &str, content: &str) {
296 let full_path = dir.join(path);
297 if let Some(parent) = full_path.parent() {
298 fs::create_dir_all(parent).unwrap();
299 }
300 fs::write(full_path, content).unwrap();
301 }
302
303 fn agent_settings(extra: &str) -> String {
305 let comma = if extra.is_empty() { "" } else { "," };
306 format!(
307 r#"{{"agents": [{{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]{comma} {extra}}}]}}"#
308 )
309 }
310
311 fn setup_and_load(json: &str) -> (tempfile::TempDir, Result<super::super::catalog::AgentCatalog, SettingsError>) {
313 let dir = create_temp_project();
314 write_file(dir.path(), "AGENTS.md", "Be helpful");
315 write_settings(dir.path(), json);
316 let result = load_agent_catalog(dir.path());
317 (dir, result)
318 }
319
320 fn setup_and_load_ok(json: &str) -> (tempfile::TempDir, super::super::catalog::AgentCatalog) {
321 let (dir, result) = setup_and_load(json);
322 (dir, result.unwrap())
323 }
324
325 #[test]
326 fn missing_settings_yields_empty_catalog() {
327 let dir = create_temp_project();
328 let catalog = load_agent_catalog(dir.path()).unwrap();
329 assert!(catalog.all().is_empty());
330 }
331
332 #[test]
333 fn exposure_flags_parsed_correctly() {
334 for (user, agent) in [(true, true), (true, false), (false, true)] {
335 let json = format!(
336 r#"{{"agents": [{{
337 "name": "planner", "description": "Planner agent",
338 "model": "anthropic:claude-sonnet-4-5",
339 "userInvocable": {user}, "agentInvocable": {agent},
340 "prompts": ["AGENTS.md"]
341 }}]}}"#
342 );
343 let (_, catalog) = setup_and_load_ok(&json);
344 let spec = catalog.get("planner").unwrap();
345 assert_eq!(spec.exposure.user_invocable, user);
346 assert_eq!(spec.exposure.agent_invocable, agent);
347 }
348 }
349
350 #[test]
351 fn invalid_model_string_rejected() {
352 let (_, result) = setup_and_load(
353 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
354 );
355 assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
356 }
357
358 #[test]
359 fn alloy_model_string_is_accepted() {
360 let json = r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#;
361 let (_, catalog) = setup_and_load_ok(json);
362 assert_eq!(catalog.get("alloy").unwrap().model.clone(), "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat");
363 }
364
365 #[test]
366 fn alloy_model_string_with_unknown_member_is_rejected() {
367 let (_, result) = setup_and_load(
368 r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,mystery:nope", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
369 );
370 assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
371 }
372
373 #[test]
374 fn invalid_reasoning_effort_rejected() {
375 let (_, result) = setup_and_load(&agent_settings(r#""reasoningEffort": "invalid""#));
376 assert!(matches!(result, Err(SettingsError::ParseError(_))));
377 }
378
379 #[test]
380 fn duplicate_agent_names_rejected() {
381 let (_, result) = setup_and_load(
382 r#"{"agents": [
383 {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
384 {"name": "planner", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
385 ]}"#,
386 );
387 assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
388 }
389
390 #[test]
391 fn agent_prompts_override_inherited() {
392 let dir = create_temp_project();
393 write_file(dir.path(), "BASE.md", "Base instructions");
394 write_file(dir.path(), "AGENTS.md", "Agent instructions");
395 write_settings(
396 dir.path(),
397 r#"{"prompts": ["BASE.md"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "agentInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
398 );
399
400 let catalog = load_agent_catalog(dir.path()).unwrap();
401 assert_eq!(catalog.get("planner").unwrap().prompts.len(), 1);
403 }
404
405 #[test]
406 fn agent_without_prompts_inherits_top_level() {
407 let dir = create_temp_project();
408 write_file(dir.path(), "BASE.md", "Base instructions");
409 write_settings(
410 dir.path(),
411 r#"{"prompts": ["BASE.md"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true}]}"#,
412 );
413
414 let catalog = load_agent_catalog(dir.path()).unwrap();
415 assert_eq!(catalog.get("planner").unwrap().prompts.len(), 1);
416 }
417
418 #[test]
419 fn one_prompt_globs_per_entry() {
420 let dir = create_temp_project();
421 write_file(dir.path(), "a.md", "A");
422 write_file(dir.path(), "b.md", "B");
423 write_settings(
424 dir.path(),
425 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["a.md", "b.md"]}]}"#,
426 );
427
428 let catalog = load_agent_catalog(dir.path()).unwrap();
429 assert_eq!(catalog.get("planner").unwrap().prompts.len(), 2);
431 }
432
433 #[test]
434 fn zero_match_prompt_rejected() {
435 let dir = create_temp_project();
436 write_settings(
438 dir.path(),
439 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["nonexistent.md"]}]}"#,
440 );
441 assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
442 }
443
444 #[test]
445 fn prompt_matching_only_directories_is_rejected() {
446 let dir = create_temp_project();
447 std::fs::create_dir_all(dir.path().join("prompts")).unwrap();
448 write_settings(
449 dir.path(),
450 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["prompts/*"]}]}"#,
451 );
452 assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
453 }
454
455 #[test]
456 fn no_invocation_surface_rejected() {
457 let (_, result) = setup_and_load(
458 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": false, "agentInvocable": false, "prompts": ["AGENTS.md"]}]}"#,
459 );
460 assert!(matches!(result, Err(SettingsError::NoInvocationSurface { .. })));
461 }
462
463 #[test]
464 fn empty_and_whitespace_names_rejected() {
465 for name in ["", " "] {
466 let json = format!(
467 r#"{{"agents": [{{"name": "{name}", "description": "Agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
468 );
469 let (_, result) = setup_and_load(&json);
470 assert!(
471 matches!(result, Err(SettingsError::EmptyAgentName { .. })),
472 "expected EmptyAgentName for name={name:?}"
473 );
474 }
475 }
476
477 #[test]
478 fn empty_and_whitespace_descriptions_rejected() {
479 for desc in ["", " "] {
480 let json = format!(
481 r#"{{"agents": [{{"name": "planner", "description": "{desc}", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
482 );
483 let (_, result) = setup_and_load(&json);
484 assert!(
485 matches!(result, Err(SettingsError::MissingField { .. })),
486 "expected MissingField for desc={desc:?}"
487 );
488 }
489 }
490
491 #[test]
492 fn duplicate_agent_names_after_trim_rejected() {
493 let (_, result) = setup_and_load(
494 r#"{"agents": [
495 {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
496 {"name": " planner ", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
497 ]}"#,
498 );
499 assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
500 }
501
502 #[test]
503 fn agent_name_and_description_are_trimmed() {
504 let (_, catalog) = setup_and_load_ok(
505 r#"{"agents": [{"name": " planner ", "description": " Planner agent ", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
506 );
507 let spec = catalog.get("planner").unwrap();
508 assert_eq!(spec.name, "planner");
509 assert_eq!(spec.description, "Planner agent");
510 }
511
512 #[test]
513 fn no_prompts_rejected() {
514 let dir = create_temp_project();
515 write_settings(
516 dir.path(),
517 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true}]}"#,
518 );
519 assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::NoPrompts { .. })));
520 }
521
522 #[test]
523 fn malformed_json_rejected() {
524 let dir = create_temp_project();
525 write_settings(dir.path(), "not valid json");
526 assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ParseError(_))));
527 }
528
529 #[test]
530 fn invalid_mcp_servers_path_rejected() {
531 let (_, result) = setup_and_load(
532 r#"{"mcpServers": ["nonexistent.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
533 );
534 assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
535 }
536
537 #[test]
538 fn invalid_agent_mcp_servers_path_rejected() {
539 let (_, result) = setup_and_load(&agent_settings(r#""mcpServers": ["nonexistent.json"]"#));
540 assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
541 }
542
543 #[test]
544 fn valid_mcp_servers_path_accepted() {
545 let dir = create_temp_project();
546 write_file(dir.path(), "AGENTS.md", "Be helpful");
547 write_file(dir.path(), ".aether/mcp/default.json", "{}");
548 write_settings(
549 dir.path(),
550 r#"{"mcpServers": [".aether/mcp/default.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
551 );
552
553 let catalog = load_agent_catalog(dir.path()).unwrap();
554 let resolved = catalog.resolve("planner", dir.path()).unwrap();
555 assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join(".aether/mcp/default.json"))]);
556 }
557
558 #[test]
559 fn top_level_mcp_servers_array_parses_and_resolves_in_order() {
560 let dir = create_temp_project();
561 write_file(dir.path(), "AGENTS.md", "Be helpful");
562 write_file(dir.path(), ".aether/mcp/a.json", "{}");
563 write_file(dir.path(), ".aether/mcp/b.json", "{}");
564 write_settings(
565 dir.path(),
566 r#"{"mcpServers": [".aether/mcp/a.json", ".aether/mcp/b.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
567 );
568
569 let catalog = load_agent_catalog(dir.path()).unwrap();
570 let resolved = catalog.resolve("planner", dir.path()).unwrap();
571 assert_eq!(
572 resolved.mcp_config_refs,
573 vec![
574 McpJsonFileRef::direct(dir.path().join(".aether/mcp/a.json")),
575 McpJsonFileRef::direct(dir.path().join(".aether/mcp/b.json")),
576 ]
577 );
578 }
579
580 #[test]
581 fn top_level_mcp_servers_invalid_path_in_middle_of_array_rejected() {
582 let dir = create_temp_project();
583 write_file(dir.path(), "AGENTS.md", "Be helpful");
584 write_file(dir.path(), "good.json", "{}");
585 write_settings(
586 dir.path(),
587 r#"{"mcpServers": ["good.json", "bad.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
588 );
589
590 let result = load_agent_catalog(dir.path());
591 match result {
592 Err(SettingsError::InvalidMcpConfigPath { path }) => assert_eq!(path, "bad.json"),
593 other => panic!("expected InvalidMcpConfigPath for bad.json, got {other:?}"),
594 }
595 }
596
597 #[test]
598 fn agent_mcp_servers_array_parses() {
599 let dir = create_temp_project();
600 write_file(dir.path(), "AGENTS.md", "Be helpful");
601 write_file(dir.path(), "a.json", "{}");
602 write_file(dir.path(), "b.json", "{}");
603 write_settings(
604 dir.path(),
605 r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"], "mcpServers": ["a.json", "b.json"]}]}"#,
606 );
607
608 let catalog = load_agent_catalog(dir.path()).unwrap();
609 let resolved = catalog.resolve("planner", dir.path()).unwrap();
610 assert_eq!(
611 resolved.mcp_config_refs,
612 vec![McpJsonFileRef::direct(dir.path().join("a.json")), McpJsonFileRef::direct(dir.path().join("b.json"))]
613 );
614 }
615
616 #[test]
617 fn agent_mcp_servers_overrides_inherited_array() {
618 let dir = create_temp_project();
619 write_file(dir.path(), "AGENTS.md", "Be helpful");
620 write_file(dir.path(), "base.json", "{}");
621 write_file(dir.path(), "override.json", "{}");
622 write_settings(
623 dir.path(),
624 r#"{"mcpServers": ["base.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"], "mcpServers": ["override.json"]}]}"#,
625 );
626
627 let catalog = load_agent_catalog(dir.path()).unwrap();
628 let resolved = catalog.resolve("planner", dir.path()).unwrap();
629 assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("override.json"))]);
630 }
631
632 #[test]
633 fn empty_mcp_servers_array_falls_back_to_cwd_mcp() {
634 let dir = create_temp_project();
635 write_file(dir.path(), "AGENTS.md", "Be helpful");
636 write_file(dir.path(), "mcp.json", "{}");
637 write_settings(
638 dir.path(),
639 r#"{"mcpServers": [], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
640 );
641
642 let catalog = load_agent_catalog(dir.path()).unwrap();
643 let resolved = catalog.resolve("planner", dir.path()).unwrap();
644 assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("mcp.json"))]);
645 }
646
647 #[test]
648 fn any_invalid_agent_entry_fails_catalog_load() {
649 let (_, result) = setup_and_load(
650 r#"{"agents": [
651 {"name": "valid", "description": "Valid agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
652 {"name": "invalid", "description": "Invalid agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}
653 ]}"#,
654 );
655 assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
656 }
657
658 fn two_agent_json() -> &'static str {
659 r#"{"agents": [
660 {"name": "zebra", "description": "Z agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
661 {"name": "alpha", "description": "A agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
662 ]}"#
663 }
664
665 #[test]
666 fn preserves_authored_agent_order_and_lookup() {
667 let (_, catalog) = setup_and_load_ok(two_agent_json());
668 let names: Vec<_> = catalog.all().iter().map(|s| s.name.as_str()).collect();
669 assert_eq!(names, vec!["zebra", "alpha"]); assert_eq!(catalog.get("alpha").unwrap().name, "alpha");
671 assert_eq!(catalog.get("zebra").unwrap().name, "zebra");
672 }
673
674 #[test]
675 fn tools_filter_parsed_from_settings() {
676 let (_, catalog) = setup_and_load_ok(
677 r#"{"agents": [{"name": "researcher", "description": "Read-only agent", "model": "anthropic:claude-sonnet-4-5", "agentInvocable": true, "prompts": ["AGENTS.md"], "tools": {"allow": ["coding__grep", "coding__read_file"], "deny": ["coding__write*"]}}]}"#,
678 );
679 let spec = catalog.get("researcher").unwrap();
680 assert_eq!(spec.tools.allow, vec!["coding__grep", "coding__read_file"]);
681 assert_eq!(spec.tools.deny, vec!["coding__write*"]);
682 }
683
684 #[test]
685 fn absent_tools_field_yields_default_filter() {
686 let (_, catalog) = setup_and_load_ok(&agent_settings(""));
687 let spec = catalog.get("planner").unwrap();
688 assert!(spec.tools.allow.is_empty());
689 assert!(spec.tools.deny.is_empty());
690 }
691
692 #[test]
693 fn reserved_agent_name_rejected() {
694 let (_, result) = setup_and_load(
695 r#"{"agents": [{"name": "__default__", "description": "Sneaky agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
696 );
697 assert!(matches!(result, Err(SettingsError::ReservedAgentName { .. })));
698 }
699
700 #[test]
701 fn mcp_server_entry_object_form_with_proxy() {
702 let dir = create_temp_project();
703 write_file(dir.path(), "AGENTS.md", "Be helpful");
704 write_file(dir.path(), ".mcp.json", r#"{"servers": {}}"#);
705 write_settings(
706 dir.path(),
707 r#"{"mcpServers": [{"path": ".mcp.json", "proxy": true}], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
708 );
709
710 let catalog = load_agent_catalog(dir.path()).unwrap();
711 let resolved = catalog.resolve("planner", dir.path()).unwrap();
712 assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::proxied(dir.path().join(".mcp.json"))]);
713 }
714
715 #[test]
716 fn mcp_server_entry_object_form_proxy_defaults_false() {
717 let dir = create_temp_project();
718 write_file(dir.path(), "AGENTS.md", "Be helpful");
719 write_file(dir.path(), "a.json", r#"{"servers": {}}"#);
720 write_settings(
721 dir.path(),
722 r#"{"mcpServers": [{"path": "a.json"}], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
723 );
724
725 let catalog = load_agent_catalog(dir.path()).unwrap();
726 let resolved = catalog.resolve("planner", dir.path()).unwrap();
727 assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("a.json"))]);
728 }
729
730 #[test]
731 fn mcp_server_entry_mixed_string_and_object() {
732 let dir = create_temp_project();
733 write_file(dir.path(), "AGENTS.md", "Be helpful");
734 write_file(dir.path(), "direct.json", r#"{"servers": {}}"#);
735 write_file(dir.path(), "proxied.json", r#"{"servers": {}}"#);
736 write_settings(
737 dir.path(),
738 r#"{"mcpServers": ["direct.json", {"path": "proxied.json", "proxy": true}], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
739 );
740
741 let catalog = load_agent_catalog(dir.path()).unwrap();
742 let resolved = catalog.resolve("planner", dir.path()).unwrap();
743 assert_eq!(
744 resolved.mcp_config_refs,
745 vec![
746 McpJsonFileRef::direct(dir.path().join("direct.json")),
747 McpJsonFileRef::proxied(dir.path().join("proxied.json")),
748 ]
749 );
750 }
751
752 #[test]
753 fn mcp_server_entry_object_form_invalid_path_rejected() {
754 let (_, result) = setup_and_load(
755 r#"{"mcpServers": [{"path": "nonexistent.json", "proxy": true}], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
756 );
757 assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
758 }
759}