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