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