Skip to main content

vtcode_config/subagents/
mod.rs

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/// Subagent isolation mode
102#[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 isolation (separate process)
107    Full,
108    /// Git worktree isolation
109    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    /// Per-tool policy overrides applied when this agent becomes active.
177    /// Keys are tool names, values are the policy to enforce.
178    /// Applied on top of (and overriding) the global `[tools.policies]` from vtcode.toml.
179    #[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
391/// Hard ceiling for subagent concurrency. The configurable `max_concurrent` is
392/// clamped to this value at runtime so it can never be exceeded.
393pub 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                // The allow list must keep every listed tool wire-visible:
1145                // `request_user_input` for the interview, and (plan only)
1146                // `bash` for read-only exec_command inspection gated by the
1147                // planning dispatch checks.
1148                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        // `plan` is primary-only (like `duck`); projects that want a delegatable
1166        // plan subagent define their own `.vtcode/agents/plan.md` (mode: subagent).
1167        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        // `plan` permits read-only `bash` so exec_command stays wire-visible
1230        // during planning, so the static `is_read_only()` heuristic
1231        // classifies it as mutation-capable. Its mutations are instead
1232        // blocked dynamically by the planning-workflow dispatch gate, and
1233        // `resolve_approved_plan_execution_agent` excludes it by name from
1234        // executing approved plans.
1235        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        // The wire catalog filters advertised tools through these permission
1318        // rules; a static tool-list entry is not enough (turn_912 regression:
1319        // the planning wire catalog collapsed to only `code_search` because
1320        // `request_user_input`/`exec_command` hit the default-deny fallback).
1321        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}