1use std::collections::HashSet;
5use std::path::{Path, PathBuf};
6use std::sync::LazyLock;
7
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use tempfile::NamedTempFile;
11
12use super::error::SubAgentError;
13use super::hooks::SubagentHooks;
14
15pub(super) static AGENT_NAME_RE: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$").unwrap());
19
20pub fn is_valid_agent_name(name: &str) -> bool {
22 AGENT_NAME_RE.is_match(name)
23}
24
25const MAX_DEF_SIZE: usize = 256 * 1024;
29
30const MAX_ENTRIES_PER_DIR: usize = 100;
35
36#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum PermissionMode {
47 #[default]
49 Default,
50 AcceptEdits,
52 DontAsk,
54 BypassPermissions,
56 Plan,
58}
59
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum MemoryScope {
66 User,
71 Project,
75 Local,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SubAgentDef {
84 pub name: String,
85 pub description: String,
86 pub model: Option<String>,
87 pub tools: ToolPolicy,
88 pub disallowed_tools: Vec<String>,
102 pub permissions: SubAgentPermissions,
103 pub skills: SkillFilter,
104 pub system_prompt: String,
105 pub hooks: SubagentHooks,
110 pub memory: Option<MemoryScope>,
113 #[serde(skip)]
119 pub source: Option<String>,
120 #[serde(skip)]
124 pub file_path: Option<PathBuf>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum ToolPolicy {
130 AllowList(Vec<String>),
131 DenyList(Vec<String>),
132 InheritAll,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SubAgentPermissions {
137 pub secrets: Vec<String>,
138 pub max_turns: u32,
139 pub background: bool,
140 pub timeout_secs: u64,
141 pub ttl_secs: u64,
142 pub permission_mode: PermissionMode,
143}
144
145impl Default for SubAgentPermissions {
146 fn default() -> Self {
147 Self {
148 secrets: Vec::new(),
149 max_turns: 20,
150 background: false,
151 timeout_secs: 600,
152 ttl_secs: 300,
153 permission_mode: PermissionMode::Default,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159pub struct SkillFilter {
160 pub include: Vec<String>,
161 pub exclude: Vec<String>,
162}
163
164#[derive(Deserialize)]
169#[serde(deny_unknown_fields)]
170struct RawSubAgentDef {
171 name: String,
172 description: String,
173 model: Option<String>,
174 #[serde(default)]
175 tools: RawToolPolicy,
176 #[serde(default)]
177 permissions: RawPermissions,
178 #[serde(default)]
179 skills: RawSkillFilter,
180 #[serde(default)]
181 hooks: SubagentHooks,
182 #[serde(default)]
183 memory: Option<MemoryScope>,
184}
185
186#[derive(Default, Deserialize)]
193struct RawToolPolicy {
194 allow: Option<Vec<String>>,
195 deny: Option<Vec<String>>,
196 #[serde(default)]
199 except: Vec<String>,
200}
201
202#[derive(Deserialize)]
203struct RawPermissions {
204 #[serde(default)]
205 secrets: Vec<String>,
206 #[serde(default = "default_max_turns")]
207 max_turns: u32,
208 #[serde(default)]
209 background: bool,
210 #[serde(default = "default_timeout")]
211 timeout_secs: u64,
212 #[serde(default = "default_ttl")]
213 ttl_secs: u64,
214 #[serde(default)]
215 permission_mode: PermissionMode,
216}
217
218impl Default for RawPermissions {
219 fn default() -> Self {
220 Self {
221 secrets: Vec::new(),
222 max_turns: default_max_turns(),
223 background: false,
224 timeout_secs: default_timeout(),
225 ttl_secs: default_ttl(),
226 permission_mode: PermissionMode::Default,
227 }
228 }
229}
230
231#[derive(Default, Deserialize)]
232struct RawSkillFilter {
233 #[serde(default)]
234 include: Vec<String>,
235 #[serde(default)]
236 exclude: Vec<String>,
237}
238
239fn default_max_turns() -> u32 {
240 20
241}
242fn default_timeout() -> u64 {
243 600
244}
245fn default_ttl() -> u64 {
246 300
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252enum FrontmatterFormat {
253 Yaml,
254 Toml,
255}
256
257fn split_frontmatter<'a>(
277 content: &'a str,
278 path: &str,
279) -> Result<(&'a str, &'a str, FrontmatterFormat), SubAgentError> {
280 let make_err = |reason: &str| SubAgentError::Parse {
281 path: path.to_owned(),
282 reason: reason.to_owned(),
283 };
284
285 if let Some(rest) = content
286 .strip_prefix("---")
287 .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
288 {
289 let (yaml_str, after) = rest
294 .split_once("\n---")
295 .ok_or_else(|| make_err("missing closing `---` delimiter for YAML frontmatter"))?;
296 let body = after
297 .strip_prefix('\n')
298 .or_else(|| after.strip_prefix("\r\n"))
299 .unwrap_or(after);
300 return Ok((yaml_str, body, FrontmatterFormat::Yaml));
301 }
302
303 if let Some(rest) = content
304 .strip_prefix("+++")
305 .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
306 {
307 let (toml_str, after) = rest
309 .split_once("\n+++")
310 .ok_or_else(|| make_err("missing closing `+++` delimiter for TOML frontmatter"))?;
311 let body = after
312 .strip_prefix('\n')
313 .or_else(|| after.strip_prefix("\r\n"))
314 .unwrap_or(after);
315 return Ok((toml_str, body, FrontmatterFormat::Toml));
316 }
317
318 Err(make_err(
319 "missing frontmatter delimiters: expected `---` (YAML) or `+++` (TOML, deprecated)",
320 ))
321}
322
323impl SubAgentDef {
324 pub fn parse(content: &str) -> Result<Self, SubAgentError> {
355 Self::parse_with_path(content, "<unknown>")
356 }
357
358 fn parse_with_path(content: &str, path: &str) -> Result<Self, SubAgentError> {
359 let (frontmatter_str, body, format) = split_frontmatter(content, path)?;
360
361 let raw: RawSubAgentDef = match format {
362 FrontmatterFormat::Yaml => {
363 let yaml_normalized;
365 let yaml_str = if frontmatter_str.contains('\r') {
366 yaml_normalized = frontmatter_str.replace("\r\n", "\n").replace('\r', "\n");
367 &yaml_normalized
368 } else {
369 frontmatter_str
370 };
371 serde_norway::from_str(yaml_str).map_err(|e| SubAgentError::Parse {
372 path: path.to_owned(),
373 reason: e.to_string(),
374 })?
375 }
376 FrontmatterFormat::Toml => {
377 tracing::warn!(
378 path,
379 "sub-agent definition uses deprecated +++ TOML frontmatter, migrate to --- YAML"
380 );
381 let toml_normalized;
383 let toml_str = if frontmatter_str.contains('\r') {
384 toml_normalized = frontmatter_str.replace("\r\n", "\n").replace('\r', "\n");
385 &toml_normalized
386 } else {
387 frontmatter_str
388 };
389 toml::from_str(toml_str).map_err(|e| SubAgentError::Parse {
390 path: path.to_owned(),
391 reason: e.to_string(),
392 })?
393 }
394 };
395
396 if raw.name.trim().is_empty() {
397 return Err(SubAgentError::Invalid("name must not be empty".into()));
398 }
399 if raw.description.trim().is_empty() {
400 return Err(SubAgentError::Invalid(
401 "description must not be empty".into(),
402 ));
403 }
404 if !AGENT_NAME_RE.is_match(&raw.name) {
407 return Err(SubAgentError::Invalid(format!(
408 "name '{}' is invalid: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$ \
409 (ASCII only, no spaces or special characters)",
410 raw.name
411 )));
412 }
413 if raw
414 .description
415 .chars()
416 .any(|c| (c < '\x20' && c != '\t') || c == '\x7F')
417 {
418 return Err(SubAgentError::Invalid(
419 "description must not contain control characters".into(),
420 ));
421 }
422
423 let tools = match (raw.tools.allow, raw.tools.deny) {
424 (None, None) => ToolPolicy::InheritAll,
425 (Some(list), None) => ToolPolicy::AllowList(list),
426 (None, Some(list)) => ToolPolicy::DenyList(list),
427 (Some(_), Some(_)) => {
428 return Err(SubAgentError::Invalid(
429 "tools.allow and tools.deny are mutually exclusive".into(),
430 ));
431 }
432 };
433
434 let disallowed_tools = raw.tools.except;
435
436 let p = raw.permissions;
437 if p.permission_mode == PermissionMode::BypassPermissions {
438 tracing::warn!(
439 name = %raw.name,
440 "sub-agent definition uses bypass_permissions mode — grants unrestricted tool access"
441 );
442 }
443 Ok(Self {
444 name: raw.name,
445 description: raw.description,
446 model: raw.model,
447 tools,
448 disallowed_tools,
449 permissions: SubAgentPermissions {
450 secrets: p.secrets,
451 max_turns: p.max_turns,
452 background: p.background,
453 timeout_secs: p.timeout_secs,
454 ttl_secs: p.ttl_secs,
455 permission_mode: p.permission_mode,
456 },
457 skills: SkillFilter {
458 include: raw.skills.include,
459 exclude: raw.skills.exclude,
460 },
461 hooks: raw.hooks,
462 memory: raw.memory,
463 system_prompt: body.trim().to_owned(),
464 source: None,
465 file_path: None,
466 })
467 }
468
469 pub fn load(path: &Path) -> Result<Self, SubAgentError> {
479 Self::load_with_boundary(path, None, None)
480 }
481
482 pub(crate) fn load_with_boundary(
484 path: &Path,
485 boundary: Option<&Path>,
486 scope: Option<&str>,
487 ) -> Result<Self, SubAgentError> {
488 let path_str = path.display().to_string();
489
490 let canonical = std::fs::canonicalize(path).map_err(|e| SubAgentError::Parse {
492 path: path_str.clone(),
493 reason: format!("cannot resolve path: {e}"),
494 })?;
495
496 if let Some(boundary) = boundary
498 && !canonical.starts_with(boundary)
499 {
500 return Err(SubAgentError::Parse {
501 path: path_str.clone(),
502 reason: format!(
503 "definition file escapes allowed directory boundary ({})",
504 boundary.display()
505 ),
506 });
507 }
508
509 let content = std::fs::read_to_string(&canonical).map_err(|e| SubAgentError::Parse {
510 path: path_str.clone(),
511 reason: e.to_string(),
512 })?;
513 if content.len() > MAX_DEF_SIZE {
514 return Err(SubAgentError::Parse {
515 path: path_str.clone(),
516 reason: format!(
517 "definition file exceeds maximum size of {} KiB",
518 MAX_DEF_SIZE / 1024
519 ),
520 });
521 }
522 let mut def = Self::parse_with_path(&content, &path_str)?;
523
524 if scope == Some("user") {
528 if !def.hooks.pre_tool_use.is_empty() || !def.hooks.post_tool_use.is_empty() {
529 tracing::warn!(
530 path = %path_str,
531 "user-level agent definition contains hooks — stripping for security"
532 );
533 }
534 def.hooks = SubagentHooks::default();
535 }
536
537 let filename = path
539 .file_name()
540 .and_then(|f| f.to_str())
541 .unwrap_or("<unknown>");
542 def.source = Some(if let Some(scope) = scope {
543 format!("{scope}/{filename}")
544 } else {
545 filename.to_owned()
546 });
547 def.file_path = Some(canonical);
549
550 Ok(def)
551 }
552
553 pub fn load_all(paths: &[PathBuf]) -> Result<Vec<Self>, SubAgentError> {
566 Self::load_all_with_sources(paths, &[], None, &[])
567 }
568
569 pub fn load_all_with_sources(
579 ordered_paths: &[PathBuf],
580 cli_agents: &[PathBuf],
581 config_user_dir: Option<&PathBuf>,
582 extra_dirs: &[PathBuf],
583 ) -> Result<Vec<Self>, SubAgentError> {
584 let mut seen: HashSet<String> = HashSet::new();
585 let mut result = Vec::new();
586
587 for path in ordered_paths {
588 if path.is_file() {
589 let is_cli = cli_agents.iter().any(|c| c == path);
593 match Self::load_with_boundary(path, None, Some("cli")) {
594 Ok(def) => {
595 if seen.contains(&def.name) {
596 tracing::debug!(
597 name = %def.name,
598 path = %path.display(),
599 "skipping duplicate sub-agent definition"
600 );
601 } else {
602 seen.insert(def.name.clone());
603 result.push(def);
604 }
605 }
606 Err(e) if is_cli => return Err(e),
607 Err(e) => {
608 tracing::warn!(path = %path.display(), error = %e, "skipping malformed agent definition");
609 }
610 }
611 continue;
612 }
613
614 let Ok(read_dir) = std::fs::read_dir(path) else {
615 continue; };
617
618 let is_cli_dir = cli_agents.iter().any(|c| c == path);
622 let boundary = if is_cli_dir {
623 None
624 } else {
625 std::fs::canonicalize(path).ok()
629 };
630
631 let scope = super::resolve::scope_label(path, cli_agents, config_user_dir, extra_dirs);
632 let is_cli_scope = is_cli_dir;
633
634 let mut entries: Vec<PathBuf> = read_dir
635 .filter_map(std::result::Result::ok)
636 .map(|e| e.path())
637 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
638 .collect();
639
640 entries.sort(); if entries.len() > MAX_ENTRIES_PER_DIR {
643 tracing::warn!(
644 dir = %path.display(),
645 count = entries.len(),
646 cap = MAX_ENTRIES_PER_DIR,
647 "agent directory exceeds entry cap; processing only first {MAX_ENTRIES_PER_DIR} files"
648 );
649 entries.truncate(MAX_ENTRIES_PER_DIR);
650 }
651
652 for entry_path in entries {
653 let load_result =
654 Self::load_with_boundary(&entry_path, boundary.as_deref(), Some(scope));
655
656 let def = match load_result {
657 Ok(d) => d,
658 Err(e) if is_cli_scope => return Err(e),
659 Err(e) => {
660 tracing::warn!(
661 path = %entry_path.display(),
662 error = %e,
663 "skipping malformed agent definition"
664 );
665 continue;
666 }
667 };
668
669 if seen.contains(&def.name) {
670 tracing::debug!(
671 name = %def.name,
672 path = %entry_path.display(),
673 "skipping duplicate sub-agent definition (shadowed by higher-priority path)"
674 );
675 continue;
676 }
677 seen.insert(def.name.clone());
678 result.push(def);
679 }
680 }
681
682 Ok(result)
683 }
684}
685
686#[derive(Serialize)]
691struct WritableRawDef<'a> {
692 name: &'a str,
693 description: &'a str,
694 #[serde(skip_serializing_if = "Option::is_none")]
695 model: Option<&'a str>,
696 #[serde(skip_serializing_if = "WritableToolPolicy::is_inherit_all")]
697 tools: WritableToolPolicy<'a>,
698 #[serde(skip_serializing_if = "WritablePermissions::is_default")]
699 permissions: WritablePermissions<'a>,
700 #[serde(skip_serializing_if = "SkillFilter::is_empty")]
701 skills: &'a SkillFilter,
702 #[serde(skip_serializing_if = "SubagentHooks::is_empty")]
703 hooks: &'a SubagentHooks,
704 #[serde(skip_serializing_if = "Option::is_none")]
705 memory: Option<MemoryScope>,
706}
707
708#[derive(Serialize)]
709struct WritableToolPolicy<'a> {
710 #[serde(skip_serializing_if = "Option::is_none")]
711 allow: Option<&'a Vec<String>>,
712 #[serde(skip_serializing_if = "Option::is_none")]
713 deny: Option<&'a Vec<String>>,
714 #[serde(skip_serializing_if = "Vec::is_empty")]
715 except: &'a Vec<String>,
716}
717
718impl<'a> WritableToolPolicy<'a> {
719 fn from_def(policy: &'a ToolPolicy, except: &'a Vec<String>) -> Self {
720 match policy {
721 ToolPolicy::AllowList(v) => Self {
722 allow: Some(v),
723 deny: None,
724 except,
725 },
726 ToolPolicy::DenyList(v) => Self {
727 allow: None,
728 deny: Some(v),
729 except,
730 },
731 ToolPolicy::InheritAll => Self {
732 allow: None,
733 deny: None,
734 except,
735 },
736 }
737 }
738
739 fn is_inherit_all(&self) -> bool {
740 self.allow.is_none() && self.deny.is_none() && self.except.is_empty()
741 }
742}
743
744#[derive(Serialize)]
745struct WritablePermissions<'a> {
746 #[serde(skip_serializing_if = "Vec::is_empty")]
747 secrets: &'a Vec<String>,
748 max_turns: u32,
749 background: bool,
750 timeout_secs: u64,
751 ttl_secs: u64,
752 permission_mode: PermissionMode,
753}
754
755impl<'a> WritablePermissions<'a> {
756 fn from_def(p: &'a SubAgentPermissions) -> Self {
757 Self {
758 secrets: &p.secrets,
759 max_turns: p.max_turns,
760 background: p.background,
761 timeout_secs: p.timeout_secs,
762 ttl_secs: p.ttl_secs,
763 permission_mode: p.permission_mode,
764 }
765 }
766
767 fn is_default(&self) -> bool {
768 self.secrets.is_empty()
769 && self.max_turns == default_max_turns()
770 && !self.background
771 && self.timeout_secs == default_timeout()
772 && self.ttl_secs == default_ttl()
773 && self.permission_mode == PermissionMode::Default
774 }
775}
776
777impl SkillFilter {
778 fn is_empty(&self) -> bool {
779 self.include.is_empty() && self.exclude.is_empty()
780 }
781}
782
783impl SubagentHooks {
784 fn is_empty(&self) -> bool {
785 self.pre_tool_use.is_empty() && self.post_tool_use.is_empty()
786 }
787}
788
789impl SubAgentDef {
790 #[must_use]
799 pub fn serialize_to_markdown(&self) -> String {
800 let tools = WritableToolPolicy::from_def(&self.tools, &self.disallowed_tools);
801 let permissions = WritablePermissions::from_def(&self.permissions);
802
803 let writable = WritableRawDef {
804 name: &self.name,
805 description: &self.description,
806 model: self.model.as_deref(),
807 tools,
808 permissions,
809 skills: &self.skills,
810 hooks: &self.hooks,
811 memory: self.memory,
812 };
813
814 let yaml = serde_norway::to_string(&writable).expect("serialization cannot fail");
815 if self.system_prompt.is_empty() {
816 format!("---\n{yaml}---\n")
817 } else {
818 format!("---\n{yaml}---\n\n{}\n", self.system_prompt)
819 }
820 }
821
822 pub fn save_atomic(&self, dir: &Path) -> Result<PathBuf, SubAgentError> {
832 if !AGENT_NAME_RE.is_match(&self.name) {
833 return Err(SubAgentError::Invalid(format!(
834 "name '{}' is invalid: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$",
835 self.name
836 )));
837 }
838 std::fs::create_dir_all(dir).map_err(|e| SubAgentError::Io {
839 path: dir.display().to_string(),
840 reason: format!("cannot create directory: {e}"),
841 })?;
842
843 let content = self.serialize_to_markdown();
844 let target = dir.join(format!("{}.md", self.name));
845
846 let mut tmp = NamedTempFile::new_in(dir).map_err(|e| SubAgentError::Io {
847 path: dir.display().to_string(),
848 reason: format!("cannot create temp file: {e}"),
849 })?;
850
851 std::io::Write::write_all(&mut tmp, content.as_bytes()).map_err(|e| SubAgentError::Io {
852 path: dir.display().to_string(),
853 reason: format!("cannot write temp file: {e}"),
854 })?;
855
856 tmp.persist(&target).map_err(|e| SubAgentError::Io {
857 path: target.display().to_string(),
858 reason: format!("cannot rename temp file: {e}"),
859 })?;
860
861 Ok(target)
862 }
863
864 pub fn delete_file(path: &Path) -> Result<(), SubAgentError> {
870 std::fs::remove_file(path).map_err(|e| SubAgentError::Io {
871 path: path.display().to_string(),
872 reason: e.to_string(),
873 })
874 }
875
876 #[must_use]
880 pub fn default_template(name: impl Into<String>, description: impl Into<String>) -> Self {
881 Self {
882 name: name.into(),
883 description: description.into(),
884 model: None,
885 tools: ToolPolicy::InheritAll,
886 disallowed_tools: Vec::new(),
887 permissions: SubAgentPermissions::default(),
888 skills: SkillFilter::default(),
889 hooks: SubagentHooks::default(),
890 memory: None,
891 system_prompt: String::new(),
892 source: None,
893 file_path: None,
894 }
895 }
896}
897
898#[cfg(test)]
901mod tests {
902 #![allow(clippy::cloned_ref_to_slice_refs)]
903
904 use indoc::indoc;
905
906 use super::*;
907
908 const FULL_DEF_YAML: &str = indoc! {"
911 ---
912 name: code-reviewer
913 description: Reviews code changes for correctness and style
914 model: claude-sonnet-4-20250514
915 tools:
916 allow:
917 - shell
918 - web_scrape
919 permissions:
920 secrets:
921 - github-token
922 max_turns: 10
923 background: false
924 timeout_secs: 300
925 ttl_secs: 120
926 skills:
927 include:
928 - \"git-*\"
929 - \"rust-*\"
930 exclude:
931 - \"deploy-*\"
932 ---
933
934 You are a code reviewer. Report findings with severity.
935 "};
936
937 const MINIMAL_DEF_YAML: &str = indoc! {"
938 ---
939 name: bot
940 description: A bot
941 ---
942
943 Do things.
944 "};
945
946 const FULL_DEF_TOML: &str = indoc! {"
949 +++
950 name = \"code-reviewer\"
951 description = \"Reviews code changes for correctness and style\"
952 model = \"claude-sonnet-4-20250514\"
953
954 [tools]
955 allow = [\"shell\", \"web_scrape\"]
956
957 [permissions]
958 secrets = [\"github-token\"]
959 max_turns = 10
960 background = false
961 timeout_secs = 300
962 ttl_secs = 120
963
964 [skills]
965 include = [\"git-*\", \"rust-*\"]
966 exclude = [\"deploy-*\"]
967 +++
968
969 You are a code reviewer. Report findings with severity.
970 "};
971
972 const MINIMAL_DEF_TOML: &str = indoc! {"
973 +++
974 name = \"bot\"
975 description = \"A bot\"
976 +++
977
978 Do things.
979 "};
980
981 #[test]
984 fn parse_yaml_full_definition() {
985 let def = SubAgentDef::parse(FULL_DEF_YAML).unwrap();
986 assert_eq!(def.name, "code-reviewer");
987 assert_eq!(
988 def.description,
989 "Reviews code changes for correctness and style"
990 );
991 assert_eq!(def.model.as_deref(), Some("claude-sonnet-4-20250514"));
992 assert!(matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]));
993 assert_eq!(def.permissions.max_turns, 10);
994 assert_eq!(def.permissions.secrets, ["github-token"]);
995 assert_eq!(def.skills.include, ["git-*", "rust-*"]);
996 assert_eq!(def.skills.exclude, ["deploy-*"]);
997 assert!(def.system_prompt.contains("code reviewer"));
998 }
999
1000 #[test]
1001 fn parse_yaml_minimal_definition() {
1002 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1003 assert_eq!(def.name, "bot");
1004 assert_eq!(def.description, "A bot");
1005 assert!(def.model.is_none());
1006 assert!(matches!(def.tools, ToolPolicy::InheritAll));
1007 assert_eq!(def.permissions.max_turns, 20);
1008 assert_eq!(def.permissions.timeout_secs, 600);
1009 assert_eq!(def.permissions.ttl_secs, 300);
1010 assert!(!def.permissions.background);
1011 assert_eq!(def.system_prompt, "Do things.");
1012 }
1013
1014 #[test]
1015 fn parse_yaml_with_dashes_in_body() {
1016 let content = "---\nname: agent\ndescription: desc\n---\n\nSome text\n---\nMore text\n";
1018 let def = SubAgentDef::parse(content).unwrap();
1019 assert_eq!(def.name, "agent");
1020 assert!(def.system_prompt.contains("Some text"));
1021 assert!(def.system_prompt.contains("More text"));
1022 }
1023
1024 #[test]
1025 fn parse_yaml_tool_deny_list() {
1026 let content = "---\nname: a\ndescription: b\ntools:\n deny:\n - shell\n---\n\nbody\n";
1027 let def = SubAgentDef::parse(content).unwrap();
1028 assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]));
1029 }
1030
1031 #[test]
1032 fn parse_yaml_tool_inherit_all() {
1033 let content = "---\nname: a\ndescription: b\ntools: {}\n---\n\nbody\n";
1035 let def = SubAgentDef::parse(content).unwrap();
1036 assert!(matches!(def.tools, ToolPolicy::InheritAll));
1037 }
1038
1039 #[test]
1040 fn parse_yaml_tool_both_specified_is_error() {
1041 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - x\n deny:\n - y\n---\n\nbody\n";
1042 let err = SubAgentDef::parse(content).unwrap_err();
1043 assert!(matches!(err, SubAgentError::Invalid(_)));
1044 }
1045
1046 #[test]
1047 fn parse_yaml_missing_closing_delimiter() {
1048 let err = SubAgentDef::parse("---\nname: a\ndescription: b\n").unwrap_err();
1049 assert!(matches!(err, SubAgentError::Parse { .. }));
1050 }
1051
1052 #[test]
1053 fn parse_yaml_crlf_line_endings() {
1054 let content = "---\r\nname: bot\r\ndescription: A bot\r\n---\r\n\r\nDo things.\r\n";
1055 let def = SubAgentDef::parse(content).unwrap();
1056 assert_eq!(def.name, "bot");
1057 assert_eq!(def.description, "A bot");
1058 assert!(!def.system_prompt.is_empty());
1059 }
1060
1061 #[test]
1062 fn parse_yaml_missing_required_field_name() {
1063 let content = "---\ndescription: b\n---\n\nbody\n";
1064 let err = SubAgentDef::parse(content).unwrap_err();
1065 assert!(matches!(err, SubAgentError::Parse { .. }));
1066 }
1067
1068 #[test]
1069 fn parse_yaml_missing_required_field_description() {
1070 let content = "---\nname: a\n---\n\nbody\n";
1071 let err = SubAgentDef::parse(content).unwrap_err();
1072 assert!(matches!(err, SubAgentError::Parse { .. }));
1073 }
1074
1075 #[test]
1076 fn parse_yaml_empty_name_is_invalid() {
1077 let content = "---\nname: \"\"\ndescription: b\n---\n\nbody\n";
1078 let err = SubAgentDef::parse(content).unwrap_err();
1079 assert!(matches!(err, SubAgentError::Invalid(_)));
1080 }
1081
1082 #[test]
1083 fn parse_yaml_whitespace_only_description_is_invalid() {
1084 let content = "---\nname: a\ndescription: \" \"\n---\n\nbody\n";
1085 let err = SubAgentDef::parse(content).unwrap_err();
1086 assert!(matches!(err, SubAgentError::Invalid(_)));
1087 }
1088
1089 #[test]
1090 fn parse_yaml_crlf_with_numeric_fields() {
1091 let content = "---\r\nname: bot\r\ndescription: A bot\r\npermissions:\r\n max_turns: 5\r\n timeout_secs: 120\r\n---\r\n\r\nDo things.\r\n";
1092 let def = SubAgentDef::parse(content).unwrap();
1093 assert_eq!(def.permissions.max_turns, 5);
1094 assert_eq!(def.permissions.timeout_secs, 120);
1095 }
1096
1097 #[test]
1098 fn parse_yaml_no_trailing_newline() {
1099 let content = "---\nname: a\ndescription: b\n---";
1100 let def = SubAgentDef::parse(content).unwrap();
1101 assert_eq!(def.system_prompt, "");
1102 }
1103
1104 #[test]
1107 fn parse_full_definition() {
1108 let def = SubAgentDef::parse(FULL_DEF_TOML).unwrap();
1109 assert_eq!(def.name, "code-reviewer");
1110 assert_eq!(
1111 def.description,
1112 "Reviews code changes for correctness and style"
1113 );
1114 assert_eq!(def.model.as_deref(), Some("claude-sonnet-4-20250514"));
1115 assert!(matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]));
1116 assert_eq!(def.permissions.max_turns, 10);
1117 assert_eq!(def.permissions.secrets, ["github-token"]);
1118 assert_eq!(def.skills.include, ["git-*", "rust-*"]);
1119 assert_eq!(def.skills.exclude, ["deploy-*"]);
1120 assert!(def.system_prompt.contains("code reviewer"));
1121 }
1122
1123 #[test]
1124 fn parse_minimal_definition() {
1125 let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1126 assert_eq!(def.name, "bot");
1127 assert_eq!(def.description, "A bot");
1128 assert!(def.model.is_none());
1129 assert!(matches!(def.tools, ToolPolicy::InheritAll));
1130 assert_eq!(def.permissions.max_turns, 20);
1131 assert_eq!(def.permissions.timeout_secs, 600);
1132 assert_eq!(def.permissions.ttl_secs, 300);
1133 assert!(!def.permissions.background);
1134 assert_eq!(def.system_prompt, "Do things.");
1135 }
1136
1137 #[test]
1138 fn tool_policy_deny_list() {
1139 let content =
1140 "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\ndeny = [\"shell\"]\n+++\n\nbody\n";
1141 let def = SubAgentDef::parse(content).unwrap();
1142 assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]));
1143 }
1144
1145 #[test]
1146 fn tool_policy_inherit_all() {
1147 let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1148 assert!(matches!(def.tools, ToolPolicy::InheritAll));
1149 }
1150
1151 #[test]
1152 fn tool_policy_both_specified_is_error() {
1153 let content = "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\nallow = [\"x\"]\ndeny = [\"y\"]\n+++\n\nbody\n";
1154 let err = SubAgentDef::parse(content).unwrap_err();
1155 assert!(matches!(err, SubAgentError::Invalid(_)));
1156 }
1157
1158 #[test]
1159 fn missing_opening_delimiter() {
1160 let err = SubAgentDef::parse("name = \"a\"\n+++\nbody\n").unwrap_err();
1161 assert!(matches!(err, SubAgentError::Parse { .. }));
1162 }
1163
1164 #[test]
1165 fn missing_closing_delimiter() {
1166 let err = SubAgentDef::parse("+++\nname = \"a\"\ndescription = \"b\"\n").unwrap_err();
1167 assert!(matches!(err, SubAgentError::Parse { .. }));
1168 }
1169
1170 #[test]
1171 fn missing_required_field_name() {
1172 let content = "+++\ndescription = \"b\"\n+++\n\nbody\n";
1173 let err = SubAgentDef::parse(content).unwrap_err();
1174 assert!(matches!(err, SubAgentError::Parse { .. }));
1175 }
1176
1177 #[test]
1178 fn missing_required_field_description() {
1179 let content = "+++\nname = \"a\"\n+++\n\nbody\n";
1180 let err = SubAgentDef::parse(content).unwrap_err();
1181 assert!(matches!(err, SubAgentError::Parse { .. }));
1182 }
1183
1184 #[test]
1185 fn empty_name_is_invalid() {
1186 let content = "+++\nname = \"\"\ndescription = \"b\"\n+++\n\nbody\n";
1187 let err = SubAgentDef::parse(content).unwrap_err();
1188 assert!(matches!(err, SubAgentError::Invalid(_)));
1189 }
1190
1191 #[test]
1192 fn load_all_deduplication_by_name() {
1193 use std::io::Write as _;
1194 let dir1 = tempfile::tempdir().unwrap();
1195 let dir2 = tempfile::tempdir().unwrap();
1196
1197 let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1198 let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1199
1200 let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1201 f1.write_all(content1.as_bytes()).unwrap();
1202
1203 let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1204 f2.write_all(content2.as_bytes()).unwrap();
1205
1206 let search_dirs = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1207 let defs = SubAgentDef::load_all(&search_dirs).unwrap();
1208
1209 assert_eq!(defs.len(), 1);
1210 assert_eq!(defs[0].description, "from dir1");
1211 }
1212
1213 #[test]
1214 fn default_permissions_values() {
1215 let p = SubAgentPermissions::default();
1216 assert_eq!(p.max_turns, 20);
1217 assert_eq!(p.timeout_secs, 600);
1218 assert_eq!(p.ttl_secs, 300);
1219 assert!(!p.background);
1220 assert!(p.secrets.is_empty());
1221 }
1222
1223 #[test]
1224 fn whitespace_only_description_is_invalid() {
1225 let content = "+++\nname = \"a\"\ndescription = \" \"\n+++\n\nbody\n";
1226 let err = SubAgentDef::parse(content).unwrap_err();
1227 assert!(matches!(err, SubAgentError::Invalid(_)));
1228 }
1229
1230 #[test]
1231 fn load_nonexistent_file_returns_parse_error() {
1232 let err =
1233 SubAgentDef::load(std::path::Path::new("/tmp/does-not-exist-zeph.md")).unwrap_err();
1234 assert!(matches!(err, SubAgentError::Parse { .. }));
1235 }
1236
1237 #[test]
1238 fn parse_crlf_line_endings() {
1239 let content =
1240 "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\n\r\nDo things.\r\n";
1241 let def = SubAgentDef::parse(content).unwrap();
1242 assert_eq!(def.name, "bot");
1243 assert_eq!(def.description, "A bot");
1244 assert!(!def.system_prompt.is_empty());
1245 }
1246
1247 #[test]
1248 fn parse_crlf_closing_delimiter() {
1249 let content = "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\nPrompt here.\r\n";
1250 let def = SubAgentDef::parse(content).unwrap();
1251 assert!(def.system_prompt.contains("Prompt here"));
1252 }
1253
1254 #[test]
1255 fn load_all_warn_and_skip_on_parse_error_for_non_cli_source() {
1256 use std::io::Write as _;
1257 let dir = tempfile::tempdir().unwrap();
1258
1259 let valid = "---\nname: good\ndescription: ok\n---\n\nbody\n";
1260 let invalid = "this is not valid frontmatter";
1261
1262 let mut f1 = std::fs::File::create(dir.path().join("a_good.md")).unwrap();
1263 f1.write_all(valid.as_bytes()).unwrap();
1264
1265 let mut f2 = std::fs::File::create(dir.path().join("b_bad.md")).unwrap();
1266 f2.write_all(invalid.as_bytes()).unwrap();
1267
1268 let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1270 assert_eq!(defs.len(), 1);
1271 assert_eq!(defs[0].name, "good");
1272 }
1273
1274 #[test]
1275 fn load_all_with_sources_hard_error_for_cli_file() {
1276 use std::io::Write as _;
1277 let dir = tempfile::tempdir().unwrap();
1278
1279 let invalid = "this is not valid frontmatter";
1280 let bad_path = dir.path().join("bad.md");
1281 let mut f = std::fs::File::create(&bad_path).unwrap();
1282 f.write_all(invalid.as_bytes()).unwrap();
1283
1284 let err = SubAgentDef::load_all_with_sources(
1286 std::slice::from_ref(&bad_path),
1287 std::slice::from_ref(&bad_path),
1288 None,
1289 &[],
1290 )
1291 .unwrap_err();
1292 assert!(matches!(err, SubAgentError::Parse { .. }));
1293 }
1294
1295 #[test]
1296 fn load_all_with_sources_max_entries_per_dir_cap() {
1297 let dir = tempfile::tempdir().unwrap();
1299 let total = MAX_ENTRIES_PER_DIR + 10;
1300 for i in 0..total {
1301 let content =
1302 format!("---\nname: agent-{i:04}\ndescription: Agent {i}\n---\n\nBody {i}\n");
1303 std::fs::write(dir.path().join(format!("agent-{i:04}.md")), &content).unwrap();
1304 }
1305 let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1306 assert_eq!(
1307 defs.len(),
1308 MAX_ENTRIES_PER_DIR,
1309 "must cap at MAX_ENTRIES_PER_DIR=100"
1310 );
1311 }
1312
1313 #[test]
1314 fn load_with_boundary_rejects_symlink_escape() {
1315 let dir_a = tempfile::tempdir().unwrap();
1318 let dir_b = tempfile::tempdir().unwrap();
1319
1320 let real_file = dir_b.path().join("agent.md");
1321 std::fs::write(
1322 &real_file,
1323 "---\nname: escape\ndescription: Escaped\n---\n\nBody\n",
1324 )
1325 .unwrap();
1326
1327 #[cfg(not(unix))]
1328 {
1329 let _ = (dir_a, dir_b, real_file);
1331 return;
1332 }
1333
1334 #[cfg(unix)]
1335 {
1336 let link_path = dir_a.path().join("agent.md");
1337 std::os::unix::fs::symlink(&real_file, &link_path).unwrap();
1338 let boundary = std::fs::canonicalize(dir_a.path()).unwrap();
1339 let err =
1340 SubAgentDef::load_with_boundary(&link_path, Some(&boundary), None).unwrap_err();
1341 assert!(
1342 matches!(&err, SubAgentError::Parse { reason, .. } if reason.contains("escapes allowed directory boundary")),
1343 "expected boundary violation error, got: {err}"
1344 );
1345 }
1346 }
1347
1348 #[test]
1349 fn load_all_with_sources_source_field_has_correct_scope_label() {
1350 use std::io::Write as _;
1351 let user_dir = tempfile::tempdir().unwrap();
1353 let user_dir_path = user_dir.path().to_path_buf();
1354 let content = "---\nname: my-agent\ndescription: test\n---\n\nBody\n";
1355 let mut f = std::fs::File::create(user_dir_path.join("my-agent.md")).unwrap();
1356 f.write_all(content.as_bytes()).unwrap();
1357
1358 let paths = vec![user_dir_path.clone()];
1360 let defs =
1361 SubAgentDef::load_all_with_sources(&paths, &[], Some(&user_dir_path), &[]).unwrap();
1362
1363 assert_eq!(defs.len(), 1);
1364 let source = defs[0].source.as_deref().unwrap_or("");
1365 assert!(
1366 source.starts_with("user/"),
1367 "expected source to start with 'user/', got: {source}"
1368 );
1369 }
1370
1371 #[test]
1372 fn load_all_with_sources_priority_first_name_wins() {
1373 use std::io::Write as _;
1374 let dir1 = tempfile::tempdir().unwrap();
1375 let dir2 = tempfile::tempdir().unwrap();
1376
1377 let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1379 let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1380
1381 let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1382 f1.write_all(content1.as_bytes()).unwrap();
1383 let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1384 f2.write_all(content2.as_bytes()).unwrap();
1385
1386 let paths = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1388 let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1389
1390 assert_eq!(defs.len(), 1, "name collision: only first wins");
1391 assert_eq!(defs[0].description, "from dir1");
1392 }
1393
1394 #[test]
1395 fn load_all_with_sources_user_agents_dir_none_skips_gracefully() {
1396 let dir = tempfile::tempdir().unwrap();
1399 let content = "---\nname: ok\ndescription: fine\n---\n\nBody\n";
1400 std::fs::write(dir.path().join("ok.md"), content).unwrap();
1401
1402 let paths = vec![dir.path().to_path_buf()];
1404 let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1405 assert_eq!(defs.len(), 1);
1406 assert_eq!(defs[0].name, "ok");
1407 }
1408
1409 #[test]
1412 fn parse_yaml_permission_mode_default_when_omitted() {
1413 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1414 assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
1415 }
1416
1417 #[test]
1418 fn parse_yaml_permission_mode_dont_ask() {
1419 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: dont_ask\n---\n\nbody\n";
1420 let def = SubAgentDef::parse(content).unwrap();
1421 assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
1422 }
1423
1424 #[test]
1425 fn parse_yaml_permission_mode_accept_edits() {
1426 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: accept_edits\n---\n\nbody\n";
1427 let def = SubAgentDef::parse(content).unwrap();
1428 assert_eq!(def.permissions.permission_mode, PermissionMode::AcceptEdits);
1429 }
1430
1431 #[test]
1432 fn parse_yaml_permission_mode_bypass_permissions() {
1433 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: bypass_permissions\n---\n\nbody\n";
1434 let def = SubAgentDef::parse(content).unwrap();
1435 assert_eq!(
1436 def.permissions.permission_mode,
1437 PermissionMode::BypassPermissions
1438 );
1439 }
1440
1441 #[test]
1442 fn parse_yaml_permission_mode_plan() {
1443 let content =
1444 "---\nname: a\ndescription: b\npermissions:\n permission_mode: plan\n---\n\nbody\n";
1445 let def = SubAgentDef::parse(content).unwrap();
1446 assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1447 }
1448
1449 #[test]
1450 fn parse_yaml_disallowed_tools_from_except() {
1451 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - shell\n - web\n except:\n - shell\n---\n\nbody\n";
1452 let def = SubAgentDef::parse(content).unwrap();
1453 assert!(
1454 matches!(def.tools, ToolPolicy::AllowList(ref v) if v.contains(&"shell".to_owned()))
1455 );
1456 assert_eq!(def.disallowed_tools, ["shell"]);
1457 }
1458
1459 #[test]
1460 fn parse_yaml_disallowed_tools_empty_when_no_except() {
1461 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1462 assert!(def.disallowed_tools.is_empty());
1463 }
1464
1465 #[test]
1466 fn parse_yaml_all_new_fields_together() {
1467 let content = indoc! {"
1468 ---
1469 name: planner
1470 description: Plans things
1471 tools:
1472 allow:
1473 - shell
1474 - web
1475 except:
1476 - dangerous
1477 permissions:
1478 max_turns: 5
1479 background: true
1480 permission_mode: plan
1481 ---
1482
1483 You are a planner.
1484 "};
1485 let def = SubAgentDef::parse(content).unwrap();
1486 assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1487 assert!(def.permissions.background);
1488 assert_eq!(def.permissions.max_turns, 5);
1489 assert_eq!(def.disallowed_tools, ["dangerous"]);
1490 }
1491
1492 #[test]
1493 fn default_permissions_includes_permission_mode_default() {
1494 let p = SubAgentPermissions::default();
1495 assert_eq!(p.permission_mode, PermissionMode::Default);
1496 }
1497
1498 #[test]
1501 fn parse_yaml_unknown_permission_mode_variant_is_error() {
1502 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: banana_mode\n---\n\nbody\n";
1504 let err = SubAgentDef::parse(content).unwrap_err();
1505 assert!(matches!(err, SubAgentError::Parse { .. }));
1506 }
1507
1508 #[test]
1509 fn parse_yaml_permission_mode_case_sensitive_camel_is_error() {
1510 let content =
1512 "---\nname: a\ndescription: b\npermissions:\n permission_mode: DontAsk\n---\n\nbody\n";
1513 let err = SubAgentDef::parse(content).unwrap_err();
1514 assert!(matches!(err, SubAgentError::Parse { .. }));
1515 }
1516
1517 #[test]
1518 fn parse_yaml_explicit_empty_except_gives_empty_disallowed_tools() {
1519 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - shell\n except: []\n---\n\nbody\n";
1520 let def = SubAgentDef::parse(content).unwrap();
1521 assert!(def.disallowed_tools.is_empty());
1522 }
1523
1524 #[test]
1525 fn parse_yaml_disallowed_tools_with_deny_list_deny_wins() {
1526 let content = "---\nname: a\ndescription: b\ntools:\n deny:\n - dangerous\n except:\n - web\n---\n\nbody\n";
1529 let def = SubAgentDef::parse(content).unwrap();
1530 assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["dangerous"]));
1532 assert!(def.disallowed_tools.contains(&"web".to_owned()));
1534 }
1535
1536 #[test]
1537 fn parse_toml_background_true_frontmatter() {
1538 let content = "+++\nname = \"bg-agent\"\ndescription = \"Runs in background\"\n[permissions]\nbackground = true\n+++\n\nSystem prompt.\n";
1540 let def = SubAgentDef::parse(content).unwrap();
1541 assert!(def.permissions.background);
1542 assert_eq!(def.name, "bg-agent");
1543 }
1544
1545 #[test]
1546 fn parse_yaml_unknown_top_level_field_is_error() {
1547 let content = "---\nname: a\ndescription: b\npermisions:\n max_turns: 5\n---\n\nbody\n";
1549 let err = SubAgentDef::parse(content).unwrap_err();
1550 assert!(matches!(err, SubAgentError::Parse { .. }));
1551 }
1552
1553 #[test]
1556 fn parse_yaml_memory_scope_project() {
1557 let content =
1558 "---\nname: reviewer\ndescription: A reviewer\nmemory: project\n---\n\nBody.\n";
1559 let def = SubAgentDef::parse(content).unwrap();
1560 assert_eq!(def.memory, Some(MemoryScope::Project));
1561 }
1562
1563 #[test]
1564 fn parse_yaml_memory_scope_user() {
1565 let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: user\n---\n\nBody.\n";
1566 let def = SubAgentDef::parse(content).unwrap();
1567 assert_eq!(def.memory, Some(MemoryScope::User));
1568 }
1569
1570 #[test]
1571 fn parse_yaml_memory_scope_local() {
1572 let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: local\n---\n\nBody.\n";
1573 let def = SubAgentDef::parse(content).unwrap();
1574 assert_eq!(def.memory, Some(MemoryScope::Local));
1575 }
1576
1577 #[test]
1578 fn parse_yaml_memory_absent_gives_none() {
1579 let content = "---\nname: reviewer\ndescription: A reviewer\n---\n\nBody.\n";
1580 let def = SubAgentDef::parse(content).unwrap();
1581 assert!(def.memory.is_none());
1582 }
1583
1584 #[test]
1585 fn parse_yaml_memory_invalid_value_is_error() {
1586 let content =
1587 "---\nname: reviewer\ndescription: A reviewer\nmemory: global\n---\n\nBody.\n";
1588 let err = SubAgentDef::parse(content).unwrap_err();
1589 assert!(matches!(err, SubAgentError::Parse { .. }));
1590 }
1591
1592 #[test]
1593 fn memory_scope_serde_roundtrip() {
1594 for scope in [MemoryScope::User, MemoryScope::Project, MemoryScope::Local] {
1595 let json = serde_json::to_string(&scope).unwrap();
1596 let parsed: MemoryScope = serde_json::from_str(&json).unwrap();
1597 assert_eq!(parsed, scope);
1598 }
1599 }
1600
1601 #[test]
1604 fn parse_yaml_name_with_unicode_is_invalid() {
1605 let content = "---\nname: аgent\ndescription: b\n---\n\nbody\n";
1607 let err = SubAgentDef::parse(content).unwrap_err();
1608 assert!(matches!(err, SubAgentError::Invalid(_)));
1609 }
1610
1611 #[test]
1612 fn parse_yaml_name_with_space_is_invalid() {
1613 let content = "---\nname: my agent\ndescription: b\n---\n\nbody\n";
1614 let err = SubAgentDef::parse(content).unwrap_err();
1615 assert!(matches!(err, SubAgentError::Invalid(_)));
1616 }
1617
1618 #[test]
1619 fn parse_yaml_name_with_dot_is_invalid() {
1620 let content = "---\nname: my.agent\ndescription: b\n---\n\nbody\n";
1621 let err = SubAgentDef::parse(content).unwrap_err();
1622 assert!(matches!(err, SubAgentError::Invalid(_)));
1623 }
1624
1625 #[test]
1626 fn parse_yaml_name_single_char_is_valid() {
1627 let content = "---\nname: a\ndescription: b\n---\n\nbody\n";
1628 let def = SubAgentDef::parse(content).unwrap();
1629 assert_eq!(def.name, "a");
1630 }
1631
1632 #[test]
1633 fn parse_yaml_name_with_underscore_and_hyphen_is_valid() {
1634 let content = "---\nname: my_agent-v2\ndescription: b\n---\n\nbody\n";
1635 let def = SubAgentDef::parse(content).unwrap();
1636 assert_eq!(def.name, "my_agent-v2");
1637 }
1638
1639 #[test]
1642 fn default_template_valid() {
1643 let def = SubAgentDef::default_template("tester", "Runs tests");
1644 assert_eq!(def.name, "tester");
1645 assert_eq!(def.description, "Runs tests");
1646 assert!(def.model.is_none());
1647 assert!(matches!(def.tools, ToolPolicy::InheritAll));
1648 assert!(def.system_prompt.is_empty());
1649 }
1650
1651 #[test]
1652 fn default_template_roundtrip() {
1653 let def = SubAgentDef::default_template("tester", "Runs tests");
1654 let markdown = def.serialize_to_markdown();
1655 let parsed = SubAgentDef::parse(&markdown).unwrap();
1656 assert_eq!(parsed.name, "tester");
1657 assert_eq!(parsed.description, "Runs tests");
1658 }
1659
1660 #[test]
1661 fn serialize_minimal() {
1662 let def = SubAgentDef::default_template("bot", "A bot");
1663 let md = def.serialize_to_markdown();
1664 assert!(md.starts_with("---\n"));
1665 assert!(md.contains("name: bot"));
1666 assert!(md.contains("description: A bot"));
1667 }
1668
1669 #[test]
1670 fn serialize_roundtrip() {
1671 let content = indoc! {"
1672 ---
1673 name: code-reviewer
1674 description: Reviews code changes for correctness and style
1675 model: claude-sonnet-4-20250514
1676 tools:
1677 allow:
1678 - shell
1679 - web_scrape
1680 permissions:
1681 max_turns: 10
1682 background: false
1683 timeout_secs: 300
1684 ttl_secs: 120
1685 skills:
1686 include:
1687 - \"git-*\"
1688 - \"rust-*\"
1689 exclude:
1690 - \"deploy-*\"
1691 ---
1692
1693 You are a code reviewer. Report findings with severity.
1694 "};
1695 let def = SubAgentDef::parse(content).unwrap();
1696 let serialized = def.serialize_to_markdown();
1697 let reparsed = SubAgentDef::parse(&serialized).unwrap();
1698 assert_eq!(reparsed.name, def.name);
1699 assert_eq!(reparsed.description, def.description);
1700 assert_eq!(reparsed.model, def.model);
1701 assert_eq!(reparsed.permissions.max_turns, def.permissions.max_turns);
1702 assert_eq!(
1703 reparsed.permissions.timeout_secs,
1704 def.permissions.timeout_secs
1705 );
1706 assert_eq!(reparsed.permissions.ttl_secs, def.permissions.ttl_secs);
1707 assert_eq!(reparsed.permissions.background, def.permissions.background);
1708 assert_eq!(
1709 reparsed.permissions.permission_mode,
1710 def.permissions.permission_mode
1711 );
1712 assert_eq!(reparsed.skills.include, def.skills.include);
1713 assert_eq!(reparsed.skills.exclude, def.skills.exclude);
1714 assert_eq!(reparsed.system_prompt, def.system_prompt);
1715 assert!(
1716 matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell", "web_scrape"])
1717 );
1718 }
1719
1720 #[test]
1721 fn serialize_roundtrip_tools_except() {
1722 let content = indoc! {"
1723 ---
1724 name: auditor
1725 description: Security auditor
1726 tools:
1727 allow:
1728 - shell
1729 except:
1730 - shell_sudo
1731 - shell_rm
1732 ---
1733
1734 Audit mode.
1735 "};
1736 let def = SubAgentDef::parse(content).unwrap();
1737 let serialized = def.serialize_to_markdown();
1738 let reparsed = SubAgentDef::parse(&serialized).unwrap();
1739 assert_eq!(reparsed.disallowed_tools, def.disallowed_tools);
1740 assert_eq!(reparsed.disallowed_tools, ["shell_sudo", "shell_rm"]);
1741 assert!(matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell"]));
1742 }
1743
1744 #[test]
1745 fn serialize_all_fields() {
1746 let content = indoc! {"
1747 ---
1748 name: full-agent
1749 description: Full featured agent
1750 model: claude-opus-4-6
1751 tools:
1752 allow:
1753 - shell
1754 except:
1755 - shell_sudo
1756 permissions:
1757 max_turns: 5
1758 background: true
1759 timeout_secs: 120
1760 ttl_secs: 60
1761 skills:
1762 include:
1763 - \"git-*\"
1764 ---
1765
1766 System prompt here.
1767 "};
1768 let def = SubAgentDef::parse(content).unwrap();
1769 let md = def.serialize_to_markdown();
1770 assert!(md.contains("model: claude-opus-4-6"));
1771 assert!(md.contains("except:"));
1772 assert!(md.contains("shell_sudo"));
1773 assert!(md.contains("background: true"));
1774 assert!(md.contains("System prompt here."));
1775 }
1776
1777 #[test]
1778 fn save_atomic_creates_file() {
1779 let dir = tempfile::tempdir().unwrap();
1780 let def = SubAgentDef::default_template("myagent", "A test agent");
1781 let path = def.save_atomic(dir.path()).unwrap();
1782 assert!(path.exists());
1783 assert_eq!(path.file_name().unwrap(), "myagent.md");
1784 let content = std::fs::read_to_string(&path).unwrap();
1785 assert!(content.contains("name: myagent"));
1786 }
1787
1788 #[test]
1789 fn save_atomic_creates_parent_dirs() {
1790 let base = tempfile::tempdir().unwrap();
1791 let nested = base.path().join("a").join("b").join("c");
1792 let def = SubAgentDef::default_template("nested", "Nested dir test");
1793 let path = def.save_atomic(&nested).unwrap();
1794 assert!(path.exists());
1795 }
1796
1797 #[test]
1798 fn save_atomic_overwrites_existing() {
1799 let dir = tempfile::tempdir().unwrap();
1800 let def1 = SubAgentDef::default_template("agent", "First description");
1801 def1.save_atomic(dir.path()).unwrap();
1802
1803 let def2 = SubAgentDef::default_template("agent", "Second description");
1804 def2.save_atomic(dir.path()).unwrap();
1805
1806 let content = std::fs::read_to_string(dir.path().join("agent.md")).unwrap();
1807 assert!(content.contains("Second description"));
1808 assert!(!content.contains("First description"));
1809 }
1810
1811 #[test]
1812 fn delete_file_removes() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let def = SubAgentDef::default_template("todelete", "Will be deleted");
1815 let path = def.save_atomic(dir.path()).unwrap();
1816 assert!(path.exists());
1817 SubAgentDef::delete_file(&path).unwrap();
1818 assert!(!path.exists());
1819 }
1820
1821 #[test]
1822 fn delete_file_nonexistent_errors() {
1823 let path = std::path::PathBuf::from("/tmp/does-not-exist-zeph-test.md");
1824 let result = SubAgentDef::delete_file(&path);
1825 assert!(result.is_err());
1826 assert!(matches!(result.unwrap_err(), SubAgentError::Io { .. }));
1827 }
1828
1829 #[test]
1830 fn save_atomic_rejects_invalid_name() {
1831 let dir = tempfile::tempdir().unwrap();
1832 let mut def = SubAgentDef::default_template("valid-name", "desc");
1833 def.name = "../../etc/cron.d/agent".to_owned();
1835 let result = def.save_atomic(dir.path());
1836 assert!(result.is_err());
1837 assert!(matches!(result.unwrap_err(), SubAgentError::Invalid(_)));
1838 }
1839
1840 #[test]
1841 fn is_valid_agent_name_accepts_valid() {
1842 assert!(super::is_valid_agent_name("reviewer"));
1843 assert!(super::is_valid_agent_name("code-reviewer"));
1844 assert!(super::is_valid_agent_name("code_reviewer"));
1845 assert!(super::is_valid_agent_name("a"));
1846 assert!(super::is_valid_agent_name("A1"));
1847 }
1848
1849 #[test]
1850 fn is_valid_agent_name_rejects_invalid() {
1851 assert!(!super::is_valid_agent_name(""));
1852 assert!(!super::is_valid_agent_name("my agent"));
1853 assert!(!super::is_valid_agent_name("../../etc"));
1854 assert!(!super::is_valid_agent_name("-starts-with-dash"));
1855 assert!(!super::is_valid_agent_name("has.dot"));
1856 }
1857}