1use anyhow::{Context, Result, anyhow, bail};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map as JsonMap, Value as JsonValue};
4use std::collections::BTreeMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::LazyLock;
8use vtcode_commons::VtCodePaths;
9use vtcode_commons::reasoning::ReasoningEffortLevel;
10
11use crate::constants::tools;
12use crate::constants::ui;
13use crate::core::permissions::{AgentPermissionsConfig, PermissionDefault};
14use crate::core::tools::ToolPolicy;
15use crate::hooks::{HookCommandConfig, HookCommandKind, HookGroupConfig, HooksConfig};
16
17use self::permissions::is_mutating_tool_name;
18
19mod builtin;
20mod discovery;
21mod parse;
22mod permissions;
23
24pub use builtin::{
25 builtin_plan_agent, builtin_primary_auto_agent, builtin_primary_build_agent, builtin_primary_coordinator_agent,
26 builtin_primary_duck_agent, builtin_subagents,
27};
28pub use discovery::{discover_subagents, load_subagent_from_file};
29
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum SubagentSource {
34 Cli,
35 ProjectVtcode,
36 ProjectClaude,
37 ProjectCodex,
38 UserVtcode,
39 UserClaude,
40 UserCodex,
41 Plugin { plugin: String },
42 Builtin,
43}
44
45impl SubagentSource {
46 #[must_use]
47 const fn priority(&self) -> usize {
48 match self {
49 Self::Cli => 0,
50 Self::ProjectVtcode => 1,
51 Self::ProjectClaude => 2,
52 Self::ProjectCodex => 3,
53 Self::UserVtcode => 4,
54 Self::UserClaude => 5,
55 Self::UserCodex => 6,
56 Self::Plugin { .. } => 7,
57 Self::Builtin => 8,
58 }
59 }
60
61 #[must_use]
62 pub fn label(&self) -> String {
63 match self {
64 Self::Cli => "cli".to_string(),
65 Self::ProjectVtcode => "project:.vtcode".to_string(),
66 Self::ProjectClaude => "project:.claude".to_string(),
67 Self::ProjectCodex => "project:.codex".to_string(),
68 Self::UserVtcode => "user:canonical-config".to_string(),
69 Self::UserClaude => "user:~/.claude".to_string(),
70 Self::UserCodex => "user:~/.codex".to_string(),
71 Self::Plugin { plugin } => format!("plugin:{plugin}"),
72 Self::Builtin => "builtin".to_string(),
73 }
74 }
75
76 #[must_use]
77 const fn vtcode_native(&self) -> bool {
78 matches!(self, Self::ProjectVtcode | Self::UserVtcode | Self::Cli)
79 }
80}
81
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum SubagentMemoryScope {
86 User,
87 Project,
88 Local,
89}
90
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
93#[serde(rename_all = "snake_case")]
94pub enum AgentMode {
95 Primary,
96 #[default]
97 Subagent,
98 All,
99}
100
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
104#[serde(rename_all = "snake_case")]
105pub enum IsolationMode {
106 Full,
108 Worktree,
110}
111
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
114#[serde(untagged)]
115pub enum SubagentMcpServer {
116 Named(String),
117 Inline(BTreeMap<String, JsonValue>),
118}
119
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
122#[serde(rename_all = "snake_case")]
123pub enum AgentSpecFieldClass {
124 Shared,
125 PrimaryMetadata,
126 PrimaryRuntime,
127 SubagentOnly,
128 Availability,
129}
130
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132#[derive(Debug, Clone, Deserialize, Serialize)]
133pub struct SubagentSpec {
134 pub name: String,
135 pub description: String,
136 #[serde(default)]
137 pub prompt: String,
138 #[serde(default)]
139 pub tools: Option<Vec<String>>,
140 #[serde(default)]
141 pub disallowed_tools: Vec<String>,
142 #[serde(default)]
143 pub model: Option<String>,
144 #[serde(default)]
145 pub color: Option<String>,
146 #[serde(default)]
147 pub reasoning_effort: Option<ReasoningEffortLevel>,
148 pub permissions: AgentPermissionsConfig,
149 #[serde(default)]
150 pub skills: Vec<String>,
151 #[serde(default)]
152 pub mcp_servers: Vec<SubagentMcpServer>,
153 #[serde(default)]
154 pub hooks: Option<HooksConfig>,
155 #[serde(default)]
156 pub background: bool,
157 #[serde(default)]
158 pub mode: AgentMode,
159 #[serde(default)]
160 pub max_turns: Option<usize>,
161 #[serde(default)]
162 pub nickname_candidates: Vec<String>,
163 #[serde(default)]
164 pub initial_prompt: Option<String>,
165 #[serde(default)]
166 pub memory: Option<SubagentMemoryScope>,
167 #[serde(default)]
168 pub isolation: Option<IsolationMode>,
169 #[serde(default)]
170 pub aliases: Vec<String>,
171 pub source: SubagentSource,
172 #[serde(default)]
173 pub file_path: Option<PathBuf>,
174 #[serde(default)]
175 pub warnings: Vec<String>,
176 #[serde(default)]
180 pub tool_policy_overrides: BTreeMap<String, ToolPolicy>,
181}
182
183impl Default for SubagentSpec {
184 fn default() -> Self {
185 Self {
186 name: String::new(),
187 description: String::new(),
188 prompt: String::new(),
189 tools: None,
190 disallowed_tools: Vec::new(),
191 model: None,
192 color: None,
193 reasoning_effort: None,
194 permissions: AgentPermissionsConfig {
195 default: PermissionDefault::Ask,
196 allow: Vec::new(),
197 ask: Vec::new(),
198 auto: Vec::new(),
199 deny: Vec::new(),
200 },
201 skills: Vec::new(),
202 mcp_servers: Vec::new(),
203 hooks: None,
204 background: false,
205 mode: AgentMode::default(),
206 max_turns: None,
207 nickname_candidates: Vec::new(),
208 initial_prompt: None,
209 memory: None,
210 isolation: None,
211 aliases: Vec::new(),
212 source: SubagentSource::Builtin,
213 file_path: None,
214 warnings: Vec::new(),
215 tool_policy_overrides: BTreeMap::new(),
216 }
217 }
218}
219
220impl SubagentSpec {
221 #[must_use]
222 pub const fn is_primary(&self) -> bool {
223 matches!(self.mode, AgentMode::Primary | AgentMode::All)
224 }
225
226 #[must_use]
227 pub const fn is_subagent(&self) -> bool {
228 matches!(self.mode, AgentMode::Subagent | AgentMode::All)
229 }
230
231 #[must_use]
232 pub fn is_read_only(&self) -> bool {
233 if !self.permissions_allows_mutation() {
234 return true;
235 }
236
237 let tools = self.tools.as_ref().map_or_else(Vec::new, Clone::clone);
238 let lower_tools = tools.iter().map(|tool| tool.to_ascii_lowercase()).collect::<Vec<_>>();
239 let lower_denied = self
240 .disallowed_tools
241 .iter()
242 .map(|tool| tool.to_ascii_lowercase())
243 .collect::<Vec<_>>();
244
245 let denies_writes = lower_denied.iter().any(|tool| is_mutating_tool_name(tool.as_str()));
246
247 if self.tools.is_some() {
248 let exposes_mutation = lower_tools.iter().any(|tool| is_mutating_tool_name(tool.as_str()));
249 !exposes_mutation
250 } else {
251 denies_writes
252 }
253 }
254
255 #[must_use]
256 fn permissions_allows_mutation(&self) -> bool {
257 if matches!(
258 self.permissions.default,
259 PermissionDefault::Ask | PermissionDefault::Allow | PermissionDefault::Auto
260 ) {
261 return true;
262 }
263
264 self.permissions
265 .allow
266 .iter()
267 .chain(self.permissions.auto.iter())
268 .map(|rule| rule.to_ascii_lowercase())
269 .any(|rule| is_mutating_tool_name(rule.as_str()))
270 }
271
272 #[must_use]
273 pub fn matches_name(&self, candidate: &str) -> bool {
274 self.name.eq_ignore_ascii_case(candidate)
275 || self.aliases.iter().any(|alias| alias.eq_ignore_ascii_case(candidate))
276 }
277}
278
279#[must_use]
280pub(crate) fn classify_agent_spec_field(field: &str) -> Option<AgentSpecFieldClass> {
281 match field.trim() {
282 "name" | "prompt" => Some(AgentSpecFieldClass::Shared),
283 "description" | "color" | "aliases" => Some(AgentSpecFieldClass::PrimaryMetadata),
284 "tools" | "disallowed_tools" | "disallowedTools" | "permissions" | "model" | "reasoning_effort" | "skills"
285 | "mcp_servers" | "mcpServers" | "hooks" | "memory" => Some(AgentSpecFieldClass::PrimaryRuntime),
286 "background"
287 | "max_turns"
288 | "maxTurns"
289 | "initial_prompt"
290 | "initialPrompt"
291 | "nickname_candidates"
292 | "isolation" => Some(AgentSpecFieldClass::SubagentOnly),
293 "mode" => Some(AgentSpecFieldClass::Availability),
294 _ => None,
295 }
296}
297
298#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
299#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
300pub struct BackgroundSubagentConfig {
301 #[serde(default = "default_background_subagents_enabled")]
302 pub enabled: bool,
303 #[serde(default)]
304 pub default_agent: Option<String>,
305 #[serde(default = "default_background_refresh_interval_ms")]
306 pub refresh_interval_ms: u64,
307 #[serde(default = "default_background_auto_restore")]
308 pub auto_restore: bool,
309 #[serde(default = "default_background_toggle_shortcut")]
310 toggle_shortcut: String,
311}
312
313impl Default for BackgroundSubagentConfig {
314 fn default() -> Self {
315 Self {
316 enabled: default_background_subagents_enabled(),
317 default_agent: None,
318 refresh_interval_ms: default_background_refresh_interval_ms(),
319 auto_restore: default_background_auto_restore(),
320 toggle_shortcut: default_background_toggle_shortcut(),
321 }
322 }
323}
324
325#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
326#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
327pub struct SubagentRuntimeLimits {
328 #[serde(default = "default_subagents_enabled")]
329 pub enabled: bool,
330 #[serde(default = "default_subagents_max_concurrent")]
331 pub max_concurrent: usize,
332 #[serde(default = "default_subagents_max_depth")]
333 pub max_depth: usize,
334 #[serde(default = "default_subagents_default_timeout_seconds")]
335 pub default_timeout_seconds: u64,
336 #[serde(default = "default_subagents_auto_delegate_read_only")]
337 pub auto_delegate_read_only: bool,
338 #[serde(default)]
339 pub background: BackgroundSubagentConfig,
340}
341
342impl Default for SubagentRuntimeLimits {
343 fn default() -> Self {
344 Self {
345 enabled: default_subagents_enabled(),
346 max_concurrent: default_subagents_max_concurrent(),
347 max_depth: default_subagents_max_depth(),
348 default_timeout_seconds: default_subagents_default_timeout_seconds(),
349 auto_delegate_read_only: default_subagents_auto_delegate_read_only(),
350 background: BackgroundSubagentConfig::default(),
351 }
352 }
353}
354
355#[derive(Debug, Clone, Default)]
356pub struct DiscoveredSubagents {
357 pub effective: Vec<SubagentSpec>,
358 pub shadowed: Vec<SubagentSpec>,
359}
360
361#[derive(Debug, Clone)]
362pub struct SubagentDiscoveryInput {
363 pub workspace_root: PathBuf,
364 pub cli_agents: Option<JsonValue>,
365 pub plugin_agent_files: Vec<(String, PathBuf)>,
366 pub include_user_agents: bool,
367}
368
369impl SubagentDiscoveryInput {
370 #[must_use]
371 pub fn new(workspace_root: PathBuf) -> Self {
372 Self {
373 workspace_root,
374 cli_agents: None,
375 plugin_agent_files: Vec::new(),
376 include_user_agents: true,
377 }
378 }
379}
380
381impl Default for SubagentDiscoveryInput {
382 fn default() -> Self {
383 Self::new(PathBuf::new())
384 }
385}
386
387const fn default_subagents_enabled() -> bool {
388 true
389}
390
391pub const SUBAGENT_HARD_CONCURRENCY_LIMIT: usize = 5;
394
395const fn default_subagents_max_concurrent() -> usize {
396 3
397}
398
399const fn default_subagents_max_depth() -> usize {
400 1
401}
402
403const fn default_subagents_default_timeout_seconds() -> u64 {
404 300
405}
406
407const fn default_subagents_auto_delegate_read_only() -> bool {
408 true
409}
410
411const fn default_background_subagents_enabled() -> bool {
412 false
413}
414
415const fn default_background_refresh_interval_ms() -> u64 {
416 2_000
417}
418
419const fn default_background_auto_restore() -> bool {
420 false
421}
422
423fn default_background_toggle_shortcut() -> String {
424 "ctrl+b".to_string()
425}
426
427#[cfg(test)]
428mod tests {
429 use super::builtin::{readonly_agent_permissions, readonly_interview_agent_permissions};
430 use super::discovery::load_cli_agents;
431 use super::permissions::normalize_subagent_tools;
432 use super::{
433 AgentMode, AgentSpecFieldClass, BackgroundSubagentConfig, IsolationMode, ReasoningEffortLevel,
434 SubagentDiscoveryInput, SubagentMcpServer, SubagentMemoryScope, SubagentRuntimeLimits, SubagentSource,
435 builtin_plan_agent, builtin_primary_auto_agent, builtin_primary_build_agent, builtin_primary_duck_agent,
436 builtin_subagents, classify_agent_spec_field, discover_subagents, load_subagent_from_file,
437 };
438 use crate::constants::tools;
439 use crate::core::permissions::PermissionDefault;
440 use anyhow::Result;
441 use serde_json::json;
442 use std::fs;
443 use tempfile::TempDir;
444
445 #[test]
446 fn classifies_agent_spec_fields_for_primary_and_subagent_roles() {
447 assert_eq!(classify_agent_spec_field("name"), Some(AgentSpecFieldClass::Shared));
448 assert_eq!(classify_agent_spec_field("description"), Some(AgentSpecFieldClass::PrimaryMetadata));
449 assert_eq!(classify_agent_spec_field("aliases"), Some(AgentSpecFieldClass::PrimaryMetadata));
450 assert_eq!(classify_agent_spec_field("disallowedTools"), Some(AgentSpecFieldClass::PrimaryRuntime));
451 assert_eq!(classify_agent_spec_field("permissions"), Some(AgentSpecFieldClass::PrimaryRuntime));
452 assert_eq!(classify_agent_spec_field("mcpServers"), Some(AgentSpecFieldClass::PrimaryRuntime));
453 assert_eq!(classify_agent_spec_field("maxTurns"), Some(AgentSpecFieldClass::SubagentOnly));
454 assert_eq!(classify_agent_spec_field("initial_prompt"), Some(AgentSpecFieldClass::SubagentOnly));
455 assert_eq!(classify_agent_spec_field("mode"), Some(AgentSpecFieldClass::Availability));
456 assert_eq!(classify_agent_spec_field("unknown"), None);
457 }
458
459 #[test]
460 fn parses_agent_availability_modes() -> Result<()> {
461 let temp = TempDir::new()?;
462 for (name, mode, expected) in [
463 ("primary", "primary", AgentMode::Primary),
464 ("subagent", "subagent", AgentMode::Subagent),
465 ("all", "all", AgentMode::All),
466 ] {
467 let path = temp.path().join(format!("{name}.md"));
468 fs::write(
469 &path,
470 format!(
471 r#"---
472name: {name}
473description: {name} agent
474mode: {mode}
475permissions:
476 default: ask
477---
478Prompt."#
479 ),
480 )?;
481
482 let spec = load_subagent_from_file(&path, SubagentSource::ProjectVtcode)?;
483 assert_eq!(spec.mode, expected);
484 }
485
486 Ok(())
487 }
488
489 #[test]
490 fn defaults_missing_permissions_to_ask() -> Result<()> {
491 let temp = TempDir::new()?;
492 let path = temp.path().join("missing-permissions.md");
493 fs::write(
494 &path,
495 r#"---
496name: missing-permissions
497description: Missing permissions
498---
499Prompt."#,
500 )?;
501
502 let spec = load_subagent_from_file(&path, SubagentSource::ProjectVtcode)?;
503 assert_eq!(spec.permissions.default, PermissionDefault::Ask);
504 assert!(spec.permissions.allow.is_empty());
505 assert!(spec.permissions.ask.is_empty());
506 assert!(spec.permissions.auto.is_empty());
507 assert!(spec.permissions.deny.is_empty());
508 Ok(())
509 }
510
511 #[test]
512 fn rejects_invalid_permissions_default() -> Result<()> {
513 let temp = TempDir::new()?;
514 let path = temp.path().join("invalid-permissions.md");
515 fs::write(
516 &path,
517 r#"---
518name: invalid-permissions
519description: Invalid permissions
520permissions:
521 default: plan
522---
523Prompt."#,
524 )?;
525
526 let err = load_subagent_from_file(&path, SubagentSource::ProjectVtcode).unwrap_err();
527 assert!(err.to_string().contains("failed to parse subagent permissions"));
528 Ok(())
529 }
530
531 #[test]
532 fn rejects_legacy_top_level_permission_fields() -> Result<()> {
533 let temp = TempDir::new()?;
534
535 for legacy_field in ["permissionMode", "permission_mode"] {
536 let markdown_path = temp.path().join(format!("{legacy_field}.md"));
537 fs::write(
538 &markdown_path,
539 format!(
540 r#"---
541name: {legacy_field}
542description: Legacy frontmatter permissions
543permissions:
544 default: ask
545{legacy_field}: allow
546---
547Prompt."#
548 ),
549 )?;
550
551 let markdown_err = load_subagent_from_file(&markdown_path, SubagentSource::ProjectVtcode).unwrap_err();
552 assert!(markdown_err.to_string().contains(legacy_field));
553
554 let toml_path = temp.path().join(format!("{legacy_field}.toml"));
555 fs::write(
556 &toml_path,
557 format!(
558 r#"name = "{legacy_field}"
559description = "Legacy TOML permissions"
560prompt = "Prompt."
561permissions = {{ default = "ask" }}
562{legacy_field} = "allow"
563"#
564 ),
565 )?;
566
567 let toml_err = load_subagent_from_file(&toml_path, SubagentSource::ProjectCodex).unwrap_err();
568 assert!(toml_err.to_string().contains(legacy_field));
569
570 let cli_payload = json!({
571 legacy_field: {
572 "description": "Legacy CLI permissions",
573 "permissions": { "default": "ask" },
574 legacy_field: "allow"
575 }
576 });
577
578 let cli_err = load_cli_agents(&cli_payload).unwrap_err();
579 assert!(cli_err.to_string().contains(legacy_field));
580 }
581
582 Ok(())
583 }
584
585 #[test]
586 fn primary_agent_parser_accepts_supported_fields() -> Result<()> {
587 let temp = TempDir::new()?;
588 let path = temp.path().join("build.md");
589 fs::write(
590 &path,
591 r#"---
592name: build
593description: Primary build agent
594tools: [Read, Bash]
595disallowedTools: [Write]
596model: gpt-5.4
597color: blue
598reasoning_effort: high
599permissions:
600 default: ask
601 allow: [code_search]
602 ask: [exec_command]
603 auto: [code_search]
604 deny: [apply_patch]
605skills: [rust, repo]
606mcpServers:
607 - filesystem
608 - demo:
609 command: demo-mcp
610hooks:
611 PreToolUse:
612 - matcher: Bash
613 hooks:
614 - command: echo pre
615memory: project
616aliases: [builder, implementer]
617mode: primary
618---
619Primary prompt."#,
620 )?;
621
622 let spec = load_subagent_from_file(&path, SubagentSource::ProjectVtcode)?;
623
624 assert_eq!(spec.name, "build");
625 assert_eq!(spec.description, "Primary build agent");
626 assert_eq!(spec.prompt, "Primary prompt.");
627 assert_eq!(spec.tools, Some(vec![tools::EXEC_COMMAND.to_string()]));
628 assert_eq!(spec.disallowed_tools, vec![tools::APPLY_PATCH.to_string()]);
629 assert_eq!(spec.permissions.default, PermissionDefault::Ask);
630 assert_eq!(spec.permissions.allow, vec![tools::CODE_SEARCH.to_string()]);
631 assert_eq!(spec.permissions.ask, vec![tools::EXEC_COMMAND.to_string()]);
632 assert_eq!(spec.permissions.auto, vec![tools::CODE_SEARCH.to_string()]);
633 assert_eq!(spec.permissions.deny, vec![tools::APPLY_PATCH.to_string()]);
634 assert_eq!(spec.model.as_deref(), Some("gpt-5.4"));
635 assert_eq!(spec.reasoning_effort, Some(ReasoningEffortLevel::High));
636 assert_eq!(spec.skills, vec!["rust".to_string(), "repo".to_string()]);
637 assert_eq!(spec.mcp_servers.len(), 2);
638 assert!(matches!(spec.mcp_servers[0], SubagentMcpServer::Named(_)));
639 assert!(matches!(spec.mcp_servers[1], SubagentMcpServer::Inline(_)));
640 assert_eq!(spec.memory, Some(SubagentMemoryScope::Project));
641 assert_eq!(spec.color.as_deref(), Some("blue"));
642 assert_eq!(spec.aliases, vec!["builder".to_string(), "implementer".to_string()]);
643 assert_eq!(spec.mode, AgentMode::Primary);
644 assert!(spec.hooks.is_some());
645 assert!(spec.warnings.is_empty());
646 Ok(())
647 }
648
649 #[test]
650 fn primary_agent_specs_warn_for_subagent_only_fields() -> Result<()> {
651 let temp = TempDir::new()?;
652 let path = temp.path().join("primary.md");
653 fs::write(
654 &path,
655 r#"---
656name: primary
657description: Primary with child-only fields
658mode: primary
659permissions:
660 default: ask
661background: true
662maxTurns: 4
663initialPrompt: Start here
664nickname_candidates: [helper]
665isolation: full
666---
667Prompt."#,
668 )?;
669
670 let spec = load_subagent_from_file(&path, SubagentSource::ProjectVtcode)?;
671
672 assert!(spec.background);
673 assert_eq!(spec.max_turns, Some(4));
674 assert_eq!(spec.initial_prompt.as_deref(), Some("Start here"));
675 assert_eq!(spec.nickname_candidates, vec!["helper".to_string()]);
676 assert_eq!(spec.isolation, Some(IsolationMode::Full));
677 assert_eq!(
678 spec.warnings,
679 vec![
680 "field 'background' is for subagents only and is ignored by primary agents".to_string(),
681 "field 'max_turns' is for subagents only and is ignored by primary agents".to_string(),
682 "field 'initial_prompt' is for subagents only and is ignored by primary agents".to_string(),
683 "field 'nickname_candidates' is for subagents only and is ignored by primary agents".to_string(),
684 "field 'isolation' is for subagents only and is ignored by primary agents".to_string(),
685 ]
686 );
687 Ok(())
688 }
689
690 #[test]
691 fn aliases_do_not_replace_canonical_agent_names() -> Result<()> {
692 let temp = TempDir::new()?;
693 let path = temp.path().join("canonical.md");
694 fs::write(
695 &path,
696 r#"---
697name: canonical
698description: Canonical primary
699mode: primary
700permissions:
701 default: ask
702aliases: [alias]
703---
704Prompt."#,
705 )?;
706
707 let spec = load_subagent_from_file(&path, SubagentSource::ProjectVtcode)?;
708
709 assert_eq!(spec.name, "canonical");
710 assert!(spec.matches_name("alias"));
711 assert!(spec.matches_name("canonical"));
712 Ok(())
713 }
714
715 #[test]
716 fn primary_agent_parser_preserves_baseline_runtime_fields() -> Result<()> {
717 let temp = TempDir::new()?;
718 let path = temp.path().join("baseline.toml");
719 fs::write(
720 &path,
721 r#"name = "baseline"
722description = "Baseline primary"
723prompt = "Baseline prompt"
724mode = "primary"
725tools = ["code_search", "exec_command"]
726disallowed_tools = ["exec_command"]
727permissions = { default = "allow" }
728model = "gpt-5.6-sol"
729reasoning_effort = "medium"
730"#,
731 )?;
732
733 let spec = load_subagent_from_file(&path, SubagentSource::ProjectCodex)?;
734
735 assert_eq!(spec.tools, Some(vec![tools::CODE_SEARCH.to_string(), tools::EXEC_COMMAND.to_string(),]));
736 assert_eq!(spec.disallowed_tools, vec![tools::EXEC_COMMAND.to_string()]);
737 assert_eq!(spec.permissions.default, PermissionDefault::Allow);
738 assert!(spec.permissions.allow.is_empty());
739 assert!(spec.permissions.ask.is_empty());
740 assert!(spec.permissions.auto.is_empty());
741 assert!(spec.permissions.deny.is_empty());
742 assert_eq!(spec.model.as_deref(), Some("gpt-5.6-sol"));
743 assert_eq!(spec.reasoning_effort, Some(ReasoningEffortLevel::Medium));
744 assert_eq!(spec.prompt, "Baseline prompt");
745 assert!(spec.warnings.is_empty());
746 Ok(())
747 }
748
749 #[test]
750 fn parses_claude_markdown_frontmatter() -> Result<()> {
751 let temp = TempDir::new()?;
752 let path = temp.path().join("reviewer.md");
753 fs::write(
754 &path,
755 r#"---
756name: reviewer
757description: Review code
758tools: [Read, Grep, Glob]
759disallowedTools: [Write]
760model: sonnet
761color: blue
762permissions:
763 default: deny
764 allow: [code_search]
765skills: [rust]
766memory: project
767background: true
768mode: primary
769maxTurns: 7
770nickname_candidates: [rev]
771---
772
773Review the target changes."#,
774 )?;
775
776 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
777 assert_eq!(spec.name, "reviewer");
778 assert_eq!(spec.description, "Review code");
779 assert_eq!(spec.model.as_deref(), Some("sonnet"));
780 assert_eq!(spec.color.as_deref(), Some("blue"));
781 assert_eq!(spec.tools, Some(vec![tools::EXEC_COMMAND.to_string()]));
782 assert_eq!(spec.disallowed_tools, vec![tools::APPLY_PATCH.to_string()]);
783 assert!(spec.background);
784 assert_eq!(spec.mode, AgentMode::Primary);
785 assert_eq!(spec.max_turns, Some(7));
786 assert_eq!(spec.prompt, "Review the target changes.");
787 Ok(())
788 }
789
790 #[test]
791 fn normalizes_claude_tool_aliases_to_vtcode_tools() -> Result<()> {
792 let temp = TempDir::new()?;
793 let path = temp.path().join("debugger.md");
794 fs::write(
795 &path,
796 r#"---
797name: debugger
798description: Debug agent
799permissions:
800 default: allow
801tools: [Read, Bash, Edit, Write, Glob, Grep]
802disallowedTools: [Task]
803---
804Debug the issue."#,
805 )?;
806
807 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
808 assert_eq!(spec.tools, Some(vec![tools::EXEC_COMMAND.to_string(), tools::APPLY_PATCH.to_string(),]));
809 assert_eq!(spec.disallowed_tools, vec![tools::SPAWN_AGENT.to_string()]);
810 assert!(!spec.is_read_only());
811 Ok(())
812 }
813
814 #[test]
815 fn shell_only_agents_are_not_read_only() -> Result<()> {
816 let temp = TempDir::new()?;
817 let path = temp.path().join("shell.md");
818 fs::write(
819 &path,
820 r#"---
821name: shell
822description: Shell-capable agent
823permissions:
824 default: allow
825tools: [Bash]
826---
827Run shell commands."#,
828 )?;
829
830 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
831 assert_eq!(spec.tools, Some(vec![tools::EXEC_COMMAND.to_string()]));
832 assert!(!spec.is_read_only());
833 Ok(())
834 }
835
836 #[test]
837 fn normalizes_claude_read_aliases_to_one_exec_command() {
838 let normalized = normalize_subagent_tools(
839 ["Read", "Grep", "Glob", "list_files", "LISTFILES", "Read(*)"]
840 .into_iter()
841 .map(ToString::to_string)
842 .collect(),
843 );
844
845 assert_eq!(normalized, vec![tools::EXEC_COMMAND.to_string()]);
846 }
847
848 #[test]
849 fn keeps_explicit_code_search_distinct_from_claude_read_aliases() {
850 let normalized = normalize_subagent_tools(
851 ["code_search", "Read", "Code_Search", "Glob(*)"]
852 .into_iter()
853 .map(ToString::to_string)
854 .collect(),
855 );
856
857 assert_eq!(normalized, vec![tools::CODE_SEARCH.to_string(), tools::EXEC_COMMAND.to_string(),]);
858 }
859
860 #[test]
861 fn parses_codex_toml_definition() -> Result<()> {
862 let temp = TempDir::new()?;
863 let path = temp.path().join("worker.toml");
864 fs::write(
865 &path,
866 r##"name = "worker"
867description = "Write-capable implementation agent"
868developer_instructions = "Implement the assigned change."
869model = "gpt-5.6-sol"
870color = "#4f8fd8"
871model_reasoning_effort = "high"
872nickname_candidates = ["builder"]
873permissions = { default = "ask" }
874"##,
875 )?;
876
877 let spec = load_subagent_from_file(&path, SubagentSource::ProjectCodex)?;
878 assert_eq!(spec.name, "worker");
879 assert_eq!(spec.description, "Write-capable implementation agent");
880 assert_eq!(spec.prompt, "Implement the assigned change.");
881 assert_eq!(spec.model.as_deref(), Some("gpt-5.6-sol"));
882 assert_eq!(spec.color.as_deref(), Some("#4f8fd8"));
883 assert_eq!(spec.reasoning_effort, Some(ReasoningEffortLevel::High));
884 assert_eq!(spec.nickname_candidates, vec!["builder".to_string()]);
885 Ok(())
886 }
887
888 #[test]
889 fn precedence_prefers_project_vtcode_then_claude_then_codex_then_user() -> Result<()> {
890 let temp = TempDir::new()?;
891 fs::create_dir_all(temp.path().join(".codex/agents"))?;
892 fs::create_dir_all(temp.path().join(".claude/agents"))?;
893 fs::create_dir_all(temp.path().join(".vtcode/agents"))?;
894
895 fs::write(
896 temp.path().join(".codex/agents/example.toml"),
897 r#"name = "example"
898description = "codex"
899developer_instructions = "codex"
900permissions = { default = "ask" }
901"#,
902 )?;
903 fs::write(
904 temp.path().join(".claude/agents/example.md"),
905 r#"---
906name: example
907description: claude
908permissions:
909 default: ask
910---
911claude"#,
912 )?;
913 fs::write(
914 temp.path().join(".vtcode/agents/example.md"),
915 r#"---
916name: example
917description: vtcode
918permissions:
919 default: ask
920---
921vtcode"#,
922 )?;
923
924 let mut input = SubagentDiscoveryInput::new(temp.path().to_path_buf());
925 input.include_user_agents = false;
926 let discovered = discover_subagents(&input)?;
927 let effective = discovered
928 .effective
929 .into_iter()
930 .find(|spec| spec.name == "example")
931 .expect("example effective");
932 assert_eq!(effective.description, "vtcode");
933 assert_eq!(effective.source, SubagentSource::ProjectVtcode);
934 Ok(())
935 }
936
937 #[test]
938 fn agent_definitions_with_same_name_shadow_by_precedence() -> Result<()> {
939 let temp = TempDir::new()?;
940 let project_vtcode_agents = temp.path().join(".vtcode/agents");
941 let project_claude_agents = temp.path().join(".claude/agents");
942 fs::create_dir_all(&project_vtcode_agents)?;
943 fs::create_dir_all(&project_claude_agents)?;
944 fs::write(
945 project_claude_agents.join("plan.md"),
946 r#"---
947name: plan
948description: Project delegated plan child
949permissions:
950 default: ask
951---
952Project child plan."#,
953 )?;
954 fs::write(
955 project_vtcode_agents.join("plan.md"),
956 r#"---
957name: plan
958description: Project primary plan
959mode: primary
960permissions:
961 default: ask
962---
963Project primary plan."#,
964 )?;
965
966 let mut input = SubagentDiscoveryInput::new(temp.path().to_path_buf());
967 input.include_user_agents = false;
968 let discovered = discover_subagents(&input)?;
969 let project_plan_specs = discovered
970 .effective
971 .iter()
972 .filter(|spec| spec.name == "plan")
973 .collect::<Vec<_>>();
974
975 assert_eq!(project_plan_specs.len(), 1);
976 assert_eq!(project_plan_specs[0].description, "Project primary plan");
977 assert_eq!(project_plan_specs[0].mode, AgentMode::Primary);
978 assert_eq!(project_plan_specs[0].source, SubagentSource::ProjectVtcode);
979 Ok(())
980 }
981
982 #[test]
983 fn plugin_restrictions_strip_unsafe_overrides() -> Result<()> {
984 let temp = TempDir::new()?;
985 let path = temp.path().join("plugin-agent.md");
986 fs::write(
987 &path,
988 r#"---
989name: plugin-agent
990description: Plugin agent
991permissions:
992 default: ask
993mcpServers:
994 - github
995hooks:
996 PreToolUse:
997 - matcher: Bash
998 hooks:
999 - type: command
1000 command: ./check.sh
1001---
1002Plugin prompt"#,
1003 )?;
1004
1005 let spec = load_subagent_from_file(&path, SubagentSource::Plugin { plugin: "demo".to_string() })?;
1006 assert!(spec.mcp_servers.is_empty());
1007 assert!(spec.hooks.is_none());
1008 assert_eq!(spec.warnings.len(), 2);
1009 Ok(())
1010 }
1011
1012 #[test]
1013 fn plugin_restrictions_normalize_permission_overrides() -> Result<()> {
1014 let temp = TempDir::new()?;
1015 let path = temp.path().join("plugin-agent.md");
1016 fs::write(
1017 &path,
1018 r#"---
1019name: plugin-agent
1020description: Plugin agent
1021permissions:
1022 default: auto
1023 allow: [code_search, "Read(*)", Bash, "Bash(*)", apply_patch, "Edit(/src/**)"]
1024 ask: [exec_command]
1025 auto: [code_search, "Glob(**/*.rs)", Write, "Write(*)", apply_patch, "apply_patch(*)"]
1026---
1027Plugin prompt"#,
1028 )?;
1029
1030 let spec = load_subagent_from_file(&path, SubagentSource::Plugin { plugin: "demo".to_string() })?;
1031
1032 assert_eq!(spec.permissions.default, PermissionDefault::Ask);
1033 assert_eq!(spec.permissions.allow, vec![tools::CODE_SEARCH.to_string(), "Read(*)".to_string()]);
1034 assert_eq!(spec.permissions.ask, vec![tools::EXEC_COMMAND.to_string()]);
1035 assert_eq!(spec.permissions.auto, vec![tools::CODE_SEARCH.to_string(), "Glob(**/*.rs)".to_string(),]);
1036 assert!(
1037 spec.warnings
1038 .iter()
1039 .any(|warning| { warning == "plugin subagent permission overrides are restricted for safety" })
1040 );
1041 Ok(())
1042 }
1043
1044 #[test]
1045 fn parses_subagent_lifecycle_hooks_from_frontmatter() -> Result<()> {
1046 let temp = TempDir::new()?;
1047 let path = temp.path().join("hooks.md");
1048 fs::write(
1049 &path,
1050 r#"---
1051name: hook-agent
1052description: Hooked agent
1053permissions:
1054 default: ask
1055hooks:
1056 SubagentStart:
1057 - matcher: worker
1058 hooks:
1059 - type: command
1060 command: echo start
1061 SubagentStop:
1062 - hooks:
1063 - type: command
1064 command: echo stop
1065---
1066Hook prompt"#,
1067 )?;
1068
1069 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1070 let hooks = spec.hooks.expect("hooks");
1071 assert_eq!(hooks.lifecycle.subagent_start.len(), 1);
1072 assert_eq!(hooks.lifecycle.subagent_stop.len(), 1);
1073 assert_eq!(hooks.lifecycle.subagent_start[0].matcher.as_deref(), Some("worker"));
1074 Ok(())
1075 }
1076
1077 #[test]
1078 fn builtin_aliases_cover_compat_names() {
1079 let builtins = builtin_subagents();
1080 let explorer = builtins.iter().find(|spec| spec.name == "explorer").expect("explorer builtin");
1081 let worker = builtins.iter().find(|spec| spec.name == "worker").expect("worker builtin");
1082 assert!(explorer.matches_name("explore"));
1083 assert!(worker.matches_name("general"));
1084 assert!(worker.matches_name("general-purpose"));
1085 }
1086
1087 #[test]
1088 fn builtin_primary_agents_are_available() {
1089 let builtins = builtin_subagents();
1090 let expected_readonly_tools = vec![
1091 tools::CODE_SEARCH.to_string(),
1092 tools::EXEC_COMMAND.to_string(),
1093 tools::GREP_FILE.to_string(),
1094 tools::READ_FILE.to_string(),
1095 tools::LIST_FILES.to_string(),
1096 ];
1097 let mut expected_primary_readonly_tools = expected_readonly_tools.clone();
1098 expected_primary_readonly_tools.push(tools::REQUEST_USER_INPUT.to_string());
1099 expected_primary_readonly_tools.push(tools::RECORD_DECISION.to_string());
1100 let default = builtins
1101 .iter()
1102 .find(|spec| spec.name == "default")
1103 .expect("missing default built-in");
1104 assert_eq!(default.permissions.default, PermissionDefault::Ask);
1105 let explorer = builtins
1106 .iter()
1107 .find(|spec| spec.name == "explorer")
1108 .expect("missing explorer built-in");
1109 assert_eq!(explorer.permissions.default, PermissionDefault::Deny);
1110 assert_eq!(explorer.permissions.allow, vec!["read".to_string()]);
1111 assert_eq!(explorer.tools.as_deref(), Some(expected_readonly_tools.as_slice()));
1112 assert!(
1113 !explorer
1114 .tools
1115 .as_deref()
1116 .unwrap_or_default()
1117 .contains(&tools::APPLY_PATCH.to_string())
1118 );
1119 assert!(explorer.disallowed_tools.is_empty());
1120
1121 for name in ["build", "auto", "duck", "plan"] {
1122 let spec = builtins
1123 .iter()
1124 .find(|spec| spec.name == name && spec.is_primary())
1125 .unwrap_or_else(|| panic!("missing built-in primary agent {name}"));
1126 assert_eq!(spec.source, SubagentSource::Builtin);
1127 let expected_default = match name {
1128 "build" => PermissionDefault::Ask,
1129 "auto" => PermissionDefault::Auto,
1130 "duck" | "plan" => PermissionDefault::Deny,
1131 _ => unreachable!("unexpected built-in primary agent"),
1132 };
1133 assert_eq!(spec.permissions.default, expected_default);
1134 if matches!(name, "duck" | "plan") {
1135 assert_eq!(spec.tools.as_deref(), Some(expected_primary_readonly_tools.as_slice()));
1136 assert!(
1137 !spec
1138 .tools
1139 .as_deref()
1140 .unwrap_or_default()
1141 .contains(&tools::APPLY_PATCH.to_string())
1142 );
1143 assert!(spec.disallowed_tools.is_empty());
1144 let mut expected_allow = vec![
1149 "read".to_string(),
1150 "request_user_input".to_string(),
1151 tools::RECORD_DECISION.to_string(),
1152 ];
1153 if name == "plan" {
1154 expected_allow.push("bash".to_string());
1155 }
1156 assert_eq!(spec.permissions.allow, expected_allow);
1157 }
1158 }
1159 let plan = builtins
1160 .iter()
1161 .find(|spec| spec.name == "plan" && spec.mode == AgentMode::Primary)
1162 .expect("missing built-in primary-only plan agent");
1163 assert_eq!(plan.source, SubagentSource::Builtin);
1164 assert_eq!(plan.permissions.default, PermissionDefault::Deny);
1165 assert!(!plan.is_subagent());
1168
1169 let auto = builtins
1170 .iter()
1171 .find(|spec| spec.name == "auto" && spec.mode == AgentMode::Primary)
1172 .expect("missing built-in auto primary agent");
1173 assert_eq!(auto.permissions.default, PermissionDefault::Auto);
1174 assert!(
1175 builtins.iter().all(|spec| spec.name != "review"),
1176 "review must not be a built-in primary or subagent"
1177 );
1178 }
1179
1180 #[test]
1181 fn build_and_auto_primary_agents_have_identical_tool_authority() {
1182 let builtins = builtin_subagents();
1183 let build = builtins.iter().find(|spec| spec.name == "build").expect("missing build");
1184 let auto = builtins.iter().find(|spec| spec.name == "auto").expect("missing auto");
1185
1186 assert_eq!(build.tools, auto.tools);
1187 assert_eq!(build.disallowed_tools, auto.disallowed_tools);
1188 assert_eq!(build.tool_policy_overrides, auto.tool_policy_overrides);
1189 assert_eq!(build.mode, auto.mode);
1190 assert_eq!(build.mcp_servers, auto.mcp_servers);
1191 assert_eq!(build.skills, auto.skills);
1192 assert_eq!(build.permissions.allow, auto.permissions.allow);
1193 assert_eq!(build.permissions.ask, auto.permissions.ask);
1194 assert_eq!(build.permissions.auto, auto.permissions.auto);
1195 assert_eq!(build.permissions.deny, auto.permissions.deny);
1196 assert_eq!(build.permissions.default, PermissionDefault::Ask);
1197 assert_eq!(auto.permissions.default, PermissionDefault::Auto);
1198 }
1199
1200 #[test]
1201 fn ask_default_mutating_builtins_are_not_read_only() {
1202 let builtins = builtin_subagents();
1203
1204 for name in ["default", "worker", "build"] {
1205 let spec = builtins
1206 .iter()
1207 .find(|spec| spec.name == name)
1208 .unwrap_or_else(|| panic!("missing built-in mutating agent {name}"));
1209 assert_eq!(spec.permissions.default, PermissionDefault::Ask);
1210 assert!(!spec.is_read_only());
1211 }
1212
1213 let auto = builtins
1214 .iter()
1215 .find(|spec| spec.name == "auto")
1216 .expect("missing built-in auto agent");
1217 assert_eq!(auto.permissions.default, PermissionDefault::Auto);
1218 assert!(!auto.is_read_only());
1219
1220 for name in ["duck", "explorer"] {
1221 let spec = builtins
1222 .iter()
1223 .find(|spec| spec.name == name)
1224 .unwrap_or_else(|| panic!("missing built-in read-only agent {name}"));
1225 assert_eq!(spec.permissions.default, PermissionDefault::Deny);
1226 assert!(spec.is_read_only());
1227 }
1228
1229 let plan = builtins
1236 .iter()
1237 .find(|spec| spec.name == "plan")
1238 .expect("missing built-in plan agent");
1239 assert_eq!(plan.permissions.default, PermissionDefault::Deny);
1240 assert!(!plan.is_read_only());
1241 assert!(
1242 !plan.permissions.allow.iter().any(|rule| rule == "edit" || rule == "write"),
1243 "plan must not gain direct file-mutation permissions"
1244 );
1245 }
1246
1247 #[test]
1248 fn background_subagent_runtime_defaults_match_documented_shortcuts() {
1249 let config = BackgroundSubagentConfig::default();
1250 assert!(!config.enabled);
1251 assert_eq!(config.default_agent, None);
1252 assert_eq!(config.refresh_interval_ms, 2_000);
1253 assert!(!config.auto_restore);
1254 assert_eq!(config.toggle_shortcut, "ctrl+b");
1255 }
1256
1257 #[test]
1258 fn subagent_runtime_limits_embed_background_defaults() {
1259 let limits = SubagentRuntimeLimits::default();
1260 assert_eq!(limits.max_concurrent, 3);
1261 assert_eq!(limits.background.default_agent, None);
1262 assert_eq!(limits.background.toggle_shortcut, "ctrl+b");
1263 }
1264
1265 #[test]
1266 fn background_subagent_runtime_deserializes_explicit_default_agent() {
1267 let config: BackgroundSubagentConfig = toml::from_str(
1268 r#"
1269enabled = true
1270default_agent = "rust-engineer"
1271refresh_interval_ms = 1500
1272auto_restore = true
1273toggle_shortcut = "ctrl+b"
1274"#,
1275 )
1276 .expect("background config");
1277
1278 assert!(config.enabled);
1279 assert_eq!(config.default_agent.as_deref(), Some("rust-engineer"));
1280 assert_eq!(config.refresh_interval_ms, 1_500);
1281 assert!(config.auto_restore);
1282 assert_eq!(config.toggle_shortcut, "ctrl+b");
1283 }
1284
1285 #[test]
1286 fn emits_warning_for_legacy_tool_name_permissions() -> Result<()> {
1287 let temp = TempDir::new()?;
1288 let path = temp.path().join("legacy.md");
1289 fs::write(
1290 &path,
1291 r#"---
1292name: legacy
1293description: Agent with legacy permission rules
1294permissions:
1295 default: deny
1296 allow: [read_file, "write_file(/docs/**)"]
1297 deny: [run_pty_cmd]
1298---
1299Legacy prompt."#,
1300 )?;
1301
1302 let spec = load_subagent_from_file(&path, SubagentSource::ProjectVtcode)?;
1303 assert!(spec.warnings.iter().any(|w| w.contains("read_file")));
1304 assert!(spec.warnings.iter().any(|w| w.contains("write_file")));
1305 assert!(spec.warnings.iter().any(|w| w.contains("run_pty_cmd")));
1306 Ok(())
1307 }
1308
1309 #[test]
1310 fn builtin_plan_agent_exposes_request_user_input() {
1311 let spec = builtin_plan_agent();
1312 let tools = spec.tools.as_ref().expect("plan agent should have tool list");
1313 assert!(
1314 tools.iter().any(|t| t == tools::REQUEST_USER_INPUT),
1315 "plan agent should expose request_user_input for clarifying questions"
1316 );
1317 assert!(
1322 spec.permissions.allow.iter().any(|rule| rule == "request_user_input"),
1323 "plan agent permissions must allow request_user_input so it survives wire shaping"
1324 );
1325 assert!(
1326 spec.permissions.allow.iter().any(|rule| rule == "bash"),
1327 "plan agent permissions must allow bash so exec_command survives wire shaping; \
1328 the planning-workflow dispatch gate keeps execution read-only"
1329 );
1330 }
1331
1332 #[test]
1333 fn builtin_plan_agent_and_duck_share_discussion_first_guidance() {
1334 let plan_prompt = builtin_plan_agent().prompt;
1335 let duck_prompt = builtin_primary_duck_agent().prompt;
1336
1337 for prompt in [&plan_prompt, &duck_prompt] {
1338 assert!(prompt.contains("discussion-first"));
1339 assert!(prompt.contains("Clarify scope, constraints, contradictions, and options"));
1340 assert!(prompt.contains("Resolve ordinary ambiguity from repository evidence when possible"));
1341 assert!(prompt.contains("ask the user directly only when material ambiguity is critical"));
1342 assert!(prompt.contains("Stop researching when existing evidence supports a decision"));
1343 }
1344 assert!(duck_prompt.contains("rubber-ducking only"));
1345 assert!(duck_prompt.contains("pressing Tab to switch to the Build agent"));
1346 assert!(!duck_prompt.contains("<proposed_plan>"));
1347 assert!(!duck_prompt.contains("plan file"));
1348 }
1349
1350 #[test]
1351 fn builtin_plan_agent_prompt_requires_grounded_discovery_and_approval() {
1352 let prompt = builtin_plan_agent().prompt;
1353
1354 assert!(prompt.contains("repository-grounded, read-only discovery"));
1355 assert!(prompt.contains("exactly one final <proposed_plan> block"));
1356 assert!(prompt.contains("Never write the plan file with shell or file-editing tools"));
1357 assert!(prompt.contains("wait for explicit user approval before implementation"));
1358 assert!(prompt.contains("instead of suggesting an immediate edit"));
1359 assert_eq!(prompt.matches("wait for").count(), 1, "approval rule should be stated once");
1360 }
1361
1362 #[test]
1363 fn builtin_agent_prompts_ground_claims_without_repeated_absolutes() {
1364 let mut prompts: Vec<(String, String)> =
1365 builtin_subagents().into_iter().map(|spec| (spec.name, spec.prompt)).collect();
1366 for spec in [
1367 builtin_primary_build_agent(),
1368 builtin_primary_auto_agent(),
1369 builtin_plan_agent(),
1370 builtin_primary_duck_agent(),
1371 ] {
1372 prompts.push((spec.name, spec.prompt));
1373 }
1374
1375 for (name, prompt) in &prompts {
1376 assert!(!prompt.contains("Never speculate"), "{name} prompt repeats a bare absolute");
1377 assert!(!prompt.contains("in parallel for efficiency"), "{name} prompt coaches read strategy");
1378 }
1379 for (name, prompt) in prompts.iter().filter(|(name, _)| name != "duck" && name != "auto") {
1380 assert!(
1381 prompt.to_lowercase().contains("read") && prompt.contains("before"),
1382 "{name} prompt should still ask to read files before claiming or editing"
1383 );
1384 }
1385 }
1386
1387 #[test]
1388 fn builtin_coordinator_restricts_tools_without_denying_worker_permissions() {
1389 let coordinator = super::builtin_primary_coordinator_agent();
1390 assert_eq!(coordinator.mode, AgentMode::Primary);
1391 assert_eq!(coordinator.permissions, builtin_primary_build_agent().permissions);
1392 let tool_ids = coordinator.tools.as_ref().unwrap();
1393 assert_eq!(
1394 tool_ids,
1395 &[
1396 "matrix",
1397 "request_user_input",
1398 "agent",
1399 "record_decision",
1400 "task_tracker"
1401 ]
1402 );
1403 assert!(coordinator.tool_policy_overrides.is_empty());
1404 assert!(
1405 coordinator
1406 .prompt
1407 .contains("Delegate all shell execution, file changes, and verification")
1408 );
1409 assert!(coordinator.prompt.contains("while the matrix is idle"));
1410 assert!(builtin_subagents().iter().any(|spec| spec.name == "coordinator"));
1411 assert_eq!(builtin_primary_build_agent().tools, None);
1412 }
1413
1414 #[test]
1415 fn readonly_permissions_allow_request_user_input_but_not_bash() {
1416 let readonly = readonly_agent_permissions();
1417 assert_eq!(readonly.default, PermissionDefault::Deny);
1418 assert_eq!(readonly.allow, vec!["read".to_string()]);
1419
1420 let interview = readonly_interview_agent_permissions();
1421 assert_eq!(interview.default, PermissionDefault::Deny);
1422 assert!(interview.allow.iter().any(|rule| rule == "read"));
1423 assert!(interview.allow.iter().any(|rule| rule == "request_user_input"));
1424 assert!(!interview.allow.iter().any(|rule| rule == "bash"), "duck must keep exec_command denied");
1425 }
1426
1427 #[test]
1428 fn builtin_duck_agent_exposes_request_user_input() {
1429 let spec = builtin_primary_duck_agent();
1430 let tools = spec.tools.as_ref().expect("duck agent should have tool list");
1431 assert!(
1432 tools.iter().any(|t| t == tools::REQUEST_USER_INPUT),
1433 "duck agent should expose request_user_input for clarifying questions"
1434 );
1435 }
1436}