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