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