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