Skip to main content

vtcode_core/
primary_agent.rs

1use std::error::Error;
2use std::fmt;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use vtcode_config::constants::defaults::DEFAULT_PRIMARY_AGENT_NAME;
7use vtcode_config::core::permissions::AgentPermissionsConfig;
8use vtcode_config::{
9    DiscoveredSubagents, HookGroupConfig, HooksConfig, McpProviderConfig, SubagentMcpServer,
10    SubagentMemoryScope, SubagentSource, SubagentSpec, builtin_primary_duck_agent,
11};
12
13use crate::config::{ReasoningEffortLevel, VTCodeConfig};
14use crate::llm::provider::ToolDefinition;
15use crate::permissions::{
16    PermissionRequest, ResolvedPermissionDecision, evaluate_effective_permissions,
17};
18use crate::prompts::PromptContext;
19use crate::subagents::ResolvedAgentRuntimeView;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ActivePrimaryAgentSpecIdentity {
23    pub name: String,
24    pub source: SubagentSource,
25    pub file_path: Option<PathBuf>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct ActivePrimaryAgent {
30    pub identity: ActivePrimaryAgentSpecIdentity,
31    pub display_name: String,
32    pub description: String,
33    pub color: Option<String>,
34    pub aliases: Vec<String>,
35    pub instructions: String,
36    pub tools: Option<Vec<String>>,
37    pub disallowed_tools: Vec<String>,
38    pub permissions: AgentPermissionsConfig,
39    pub model: Option<String>,
40    pub reasoning_effort: Option<String>,
41    pub hooks: Option<HooksConfig>,
42    pub skills: Vec<String>,
43    pub mcp_servers: Vec<SubagentMcpServer>,
44    pub memory: Option<SubagentMemoryScope>,
45}
46
47impl ActivePrimaryAgent {
48    #[must_use]
49    pub fn from_spec(spec: &SubagentSpec) -> Self {
50        Self::from_runtime_view(&ResolvedAgentRuntimeView::from_spec(spec))
51    }
52
53    #[must_use]
54    pub fn from_runtime_view(runtime: &ResolvedAgentRuntimeView) -> Self {
55        Self {
56            identity: ActivePrimaryAgentSpecIdentity {
57                name: runtime.canonical_name.clone(),
58                source: runtime.source.clone(),
59                file_path: runtime.file_path.clone(),
60            },
61            display_name: runtime.display_name.clone(),
62            description: runtime.description.clone(),
63            color: runtime.color.clone(),
64            aliases: runtime.aliases.clone(),
65            instructions: runtime.instructions.clone(),
66            tools: runtime.tools.clone(),
67            disallowed_tools: runtime.disallowed_tools.clone(),
68            permissions: runtime.permissions.clone(),
69            model: runtime.model.clone(),
70            reasoning_effort: runtime.reasoning_effort.clone(),
71            hooks: runtime.hooks.clone(),
72            skills: runtime.skills.clone(),
73            mcp_servers: runtime.mcp_servers.clone(),
74            memory: runtime.memory,
75        }
76    }
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct ActivePrimaryAgentState {
81    active: ActivePrimaryAgent,
82}
83
84impl Default for ActivePrimaryAgentState {
85    fn default() -> Self {
86        Self {
87            active: ActivePrimaryAgent::from_spec(&builtin_primary_duck_agent()),
88        }
89    }
90}
91
92impl ActivePrimaryAgentState {
93    #[must_use]
94    pub const fn active(&self) -> &ActivePrimaryAgent {
95        &self.active
96    }
97
98    #[must_use]
99    pub fn from_discovery(discovered: &DiscoveredSubagents) -> Self {
100        Self::from_specs(&discovered.effective)
101    }
102
103    #[must_use]
104    pub fn from_specs(specs: &[SubagentSpec]) -> Self {
105        Self::from_specs_with_default(specs, DEFAULT_PRIMARY_AGENT_NAME)
106    }
107
108    #[must_use]
109    pub fn from_specs_with_default(specs: &[SubagentSpec], requested_default: &str) -> Self {
110        let requested = if requested_default.trim().is_empty() {
111            DEFAULT_PRIMARY_AGENT_NAME
112        } else {
113            requested_default.trim()
114        };
115        let active = resolve_primary_agent(specs, requested)
116            .unwrap_or_else(|_| ActivePrimaryAgent::from_spec(&builtin_primary_duck_agent()));
117        Self { active }
118    }
119
120    pub fn reset_to_default_from_specs(&mut self, specs: &[SubagentSpec]) -> &ActivePrimaryAgent {
121        self.active = Self::from_specs(specs).active;
122        &self.active
123    }
124
125    pub fn select_from_discovery(
126        &mut self,
127        discovered: &DiscoveredSubagents,
128        requested: &str,
129    ) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
130        self.select_from_specs(&discovered.effective, requested)
131    }
132
133    pub fn select_from_specs(
134        &mut self,
135        specs: &[SubagentSpec],
136        requested: &str,
137    ) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
138        let active = resolve_primary_agent(specs, requested)?;
139        self.active = active;
140        Ok(&self.active)
141    }
142}
143
144pub type PrimaryAgentResolutionResult<T> = Result<T, PrimaryAgentResolutionError>;
145
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum PrimaryAgentResolutionError {
148    UnknownAgent { requested: String },
149}
150
151impl fmt::Display for PrimaryAgentResolutionError {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        match self {
154            Self::UnknownAgent { requested } => write!(f, "Unknown primary agent {requested}"),
155        }
156    }
157}
158
159impl Error for PrimaryAgentResolutionError {}
160
161pub fn resolve_discovered_primary_agent(
162    discovered: &DiscoveredSubagents,
163    requested: &str,
164) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
165    resolve_primary_agent(&discovered.effective, requested)
166}
167
168pub fn resolve_primary_agent(
169    specs: &[SubagentSpec],
170    requested: &str,
171) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
172    specs
173        .iter()
174        .find(|spec| spec.is_primary() && spec.name.eq_ignore_ascii_case(requested))
175        .or_else(|| {
176            specs
177                .iter()
178                .find(|spec| spec.is_primary() && spec.matches_name(requested))
179        })
180        .map(ActivePrimaryAgent::from_spec)
181        .ok_or_else(|| PrimaryAgentResolutionError::UnknownAgent {
182            requested: requested.to_string(),
183        })
184}
185
186/// Returns `true` when `tool_name` is a subagent lifecycle tool that must
187/// remain available regardless of the active primary agent's tool policy.
188/// These tools manage running subagents (spawn, wait, close, send input,
189/// resume, background subprocess). Blocking them would orphan active
190/// subagents when a restricted primary agent (e.g. `plan`) is selected.
191fn is_subagent_lifecycle_tool(tool_name: &str) -> bool {
192    matches!(
193        tool_name,
194        "spawn_agent"
195            | "spawn_background_subprocess"
196            | "send_input"
197            | "wait_agent"
198            | "resume_agent"
199            | "close_agent"
200    )
201}
202
203#[must_use]
204pub fn primary_agent_allows_tool(agent: &ActivePrimaryAgent, tool_name: &str) -> bool {
205    let tool_name = normalise_tool_name(tool_name);
206
207    // Subagent lifecycle tools are always allowed -- they manage running
208    // subagents regardless of which primary agent is active.
209    if is_subagent_lifecycle_tool(&tool_name) {
210        return true;
211    }
212
213    let allow_list_allows = agent.tools.as_ref().is_none_or(|tools| {
214        tools
215            .iter()
216            .any(|allowed| normalise_tool_name(allowed) == tool_name)
217    });
218    if !allow_list_allows {
219        return false;
220    }
221
222    !agent
223        .disallowed_tools
224        .iter()
225        .any(|denied| normalise_tool_name(denied) == tool_name)
226}
227
228#[must_use]
229pub fn apply_primary_agent_tool_policy(
230    tools: Option<Arc<Vec<ToolDefinition>>>,
231    agent: &ActivePrimaryAgent,
232) -> Option<Arc<Vec<ToolDefinition>>> {
233    let tools = tools?;
234    let filtered = tools
235        .iter()
236        .filter(|tool| primary_agent_allows_tool(agent, tool.function_name()))
237        .cloned()
238        .collect::<Vec<_>>();
239
240    (!filtered.is_empty()).then(|| Arc::new(filtered))
241}
242
243#[must_use]
244pub fn build_primary_agent_runtime_config(
245    parent: &VTCodeConfig,
246    agent: &ActivePrimaryAgent,
247) -> VTCodeConfig {
248    let mut config = parent.clone();
249    if let Some(model) = agent.model.as_ref() {
250        config.agent.default_model = model.clone();
251    }
252    if let Some(reasoning_effort) = agent
253        .reasoning_effort
254        .as_deref()
255        .and_then(ReasoningEffortLevel::parse)
256    {
257        config.agent.reasoning_effort = reasoning_effort;
258    }
259    merge_primary_mcp_servers(&mut config, agent.mcp_servers.as_slice());
260    config
261}
262
263#[must_use]
264pub fn build_primary_agent_hook_config(
265    global: &HooksConfig,
266    agent: &ActivePrimaryAgent,
267) -> HooksConfig {
268    let mut config = global.clone();
269    merge_active_primary_hooks(&mut config, agent.hooks.as_ref());
270    config
271}
272
273pub fn apply_primary_agent_prompt_context(context: &mut PromptContext, agent: &ActivePrimaryAgent) {
274    context.replace_available_skills_with_named(agent.skills.as_slice());
275}
276
277#[must_use]
278pub fn active_primary_agent_permissions(agent: &ActivePrimaryAgent) -> &AgentPermissionsConfig {
279    &agent.permissions
280}
281
282#[must_use]
283pub fn evaluate_active_primary_agent_permissions(
284    config: &VTCodeConfig,
285    agent: &ActivePrimaryAgent,
286    workspace_root: &std::path::Path,
287    current_dir: &std::path::Path,
288    request: &PermissionRequest,
289) -> ResolvedPermissionDecision {
290    evaluate_effective_permissions(
291        &config.permissions,
292        active_primary_agent_permissions(agent),
293        workspace_root,
294        current_dir,
295        request,
296    )
297}
298
299fn normalise_tool_name(tool_name: &str) -> String {
300    tool_name.trim().to_ascii_lowercase()
301}
302
303fn merge_primary_mcp_servers(config: &mut VTCodeConfig, servers: &[SubagentMcpServer]) {
304    for server in servers {
305        match server {
306            SubagentMcpServer::Named(_) => {}
307            SubagentMcpServer::Inline(definition) => {
308                for (name, value) in definition {
309                    if config
310                        .mcp
311                        .providers
312                        .iter()
313                        .any(|provider| provider.name == *name)
314                    {
315                        continue;
316                    }
317                    if let Some(provider) = inline_mcp_provider(name, value) {
318                        config.mcp.providers.push(provider);
319                    }
320                }
321            }
322        }
323    }
324}
325
326fn merge_active_primary_hooks(config: &mut HooksConfig, hooks: Option<&HooksConfig>) {
327    let Some(hooks) = hooks else {
328        return;
329    };
330
331    config.lifecycle.quiet_success_output |= hooks.lifecycle.quiet_success_output;
332    append_hook_groups(
333        &mut config.lifecycle.user_prompt_submit,
334        &hooks.lifecycle.user_prompt_submit,
335    );
336    append_hook_groups(
337        &mut config.lifecycle.pre_tool_use,
338        &hooks.lifecycle.pre_tool_use,
339    );
340    append_hook_groups(
341        &mut config.lifecycle.post_tool_use,
342        &hooks.lifecycle.post_tool_use,
343    );
344    append_hook_groups(
345        &mut config.lifecycle.permission_request,
346        &hooks.lifecycle.permission_request,
347    );
348    append_hook_groups(
349        &mut config.lifecycle.pre_compact,
350        &hooks.lifecycle.pre_compact,
351    );
352    append_hook_groups(&mut config.lifecycle.stop, &hooks.lifecycle.stop);
353    append_hook_groups(
354        &mut config.lifecycle.notification,
355        &hooks.lifecycle.notification,
356    );
357}
358
359fn append_hook_groups(target: &mut Vec<HookGroupConfig>, source: &[HookGroupConfig]) {
360    target.extend(source.iter().cloned());
361}
362
363fn inline_mcp_provider(name: &str, value: &serde_json::Value) -> Option<McpProviderConfig> {
364    let object = value.as_object()?;
365    let mut payload = serde_json::Map::with_capacity(object.len().saturating_add(1));
366    payload.insert(
367        "name".to_string(),
368        serde_json::Value::String(name.to_string()),
369    );
370    for (key, value) in object {
371        if key == "type" {
372            continue;
373        }
374        payload.insert(key.clone(), value.clone());
375    }
376    if payload.contains_key("command") && !payload.contains_key("args") {
377        payload.insert("args".to_string(), serde_json::Value::Array(Vec::new()));
378    }
379    serde_json::from_value(serde_json::Value::Object(payload)).ok()
380}
381
382#[cfg(test)]
383mod tests {
384    use std::collections::BTreeMap;
385
386    use serde_json::json;
387    use tempfile::TempDir;
388    use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
389    use vtcode_config::{
390        HookCommandConfig, HooksConfig, SubagentDiscoveryInput, SubagentMcpServer,
391        SubagentMemoryScope, SubagentSource, builtin_subagents, discover_subagents,
392    };
393
394    use crate::config::constants::tools;
395    use crate::permissions::{ResolvedPermissionDecision, build_permission_request};
396
397    use super::*;
398
399    #[test]
400    fn resolves_existing_spec_by_name() {
401        let spec = test_spec("planner");
402        let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
403
404        assert_eq!(active.identity.name, "planner");
405        assert_eq!(active.display_name, "planner");
406        assert_eq!(active.description, "planner description");
407        assert_eq!(active.color.as_deref(), Some("blue"));
408        assert_eq!(active.instructions, "planner instructions");
409        assert_eq!(active.tools, Some(vec!["unified_search".to_string()]));
410        assert_eq!(active.disallowed_tools, vec!["unified_file".to_string()]);
411        assert_eq!(active.permissions.default, PermissionDefault::Deny);
412        assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
413        assert_eq!(active.reasoning_effort.as_deref(), Some("high"));
414        assert!(active.hooks.is_none());
415    }
416
417    #[test]
418    fn unknown_agent_error_preserves_current_active_agent() {
419        let current = test_spec("current");
420        let specs = vec![current.clone()];
421        let mut state = ActivePrimaryAgentState::default();
422        let original = state
423            .select_from_specs(&specs, "current")
424            .expect("initial selection")
425            .clone();
426
427        let error = state
428            .select_from_specs(&specs, "missing")
429            .expect_err("unknown agent");
430
431        assert_eq!(
432            error,
433            PrimaryAgentResolutionError::UnknownAgent {
434                requested: "missing".to_string()
435            }
436        );
437        assert_eq!(state.active(), &original);
438    }
439
440    #[test]
441    fn alias_resolution_uses_existing_matches_name_semantics() {
442        let mut spec = test_spec("reviewer");
443        spec.aliases = vec!["critic".to_string()];
444
445        let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
446
447        assert_eq!(active.identity.name, "reviewer");
448        assert_eq!(active.display_name, "reviewer");
449    }
450
451    #[test]
452    fn exact_name_resolution_wins_over_alias() {
453        let mut build = test_spec("build");
454        build.aliases = vec!["builder".to_string()];
455        let builder = test_spec("builder");
456
457        let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
458
459        assert_eq!(active.identity.name, "builder");
460    }
461
462    #[test]
463    fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
464        let mut spec = test_spec("worker");
465        spec.aliases = vec!["builder".to_string()];
466        spec.skills = vec!["rust".to_string()];
467        spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
468        spec.background = true;
469        spec.max_turns = Some(12);
470        spec.nickname_candidates = vec!["w".to_string()];
471        spec.initial_prompt = Some("start here".to_string());
472        spec.memory = Some(SubagentMemoryScope::Project);
473        spec.isolation = Some("full".to_string());
474
475        let active = ActivePrimaryAgent::from_spec(&spec);
476
477        assert_eq!(active.identity.name, "worker");
478        assert_eq!(active.display_name, "worker");
479        assert_eq!(active.description, "worker description");
480        assert_eq!(active.color.as_deref(), Some("blue"));
481        assert_eq!(active.aliases, vec!["builder".to_string()]);
482        assert_eq!(active.instructions, "worker instructions");
483        assert_eq!(active.tools, Some(vec!["unified_search".to_string()]));
484        assert_eq!(active.disallowed_tools, vec!["unified_file".to_string()]);
485        assert_eq!(active.permissions.default, PermissionDefault::Deny);
486        assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
487        assert_eq!(active.reasoning_effort.as_deref(), Some("high"));
488        assert_eq!(active.skills, vec!["rust".to_string()]);
489        assert_eq!(
490            active.mcp_servers,
491            vec![SubagentMcpServer::Named("filesystem".to_string())]
492        );
493        assert_eq!(active.memory, Some(SubagentMemoryScope::Project));
494    }
495
496    #[test]
497    fn primary_runtime_adapter_uses_shared_resolved_view_for_overlapping_fields() {
498        let mut spec = test_spec("worker");
499        spec.description = "Worker display metadata".to_string();
500        spec.color = Some("green".to_string());
501        spec.aliases = vec!["builder".to_string()];
502        spec.skills = vec!["rust".to_string(), "repo".to_string()];
503        spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
504        spec.hooks = Some(HooksConfig::default());
505        spec.memory = Some(SubagentMemoryScope::Project);
506
507        let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
508        let active = ActivePrimaryAgent::from_runtime_view(&runtime);
509
510        assert_eq!(runtime.canonical_name, "worker");
511        assert_eq!(runtime.display_name, "worker");
512        assert_eq!(runtime.description, "Worker display metadata");
513        assert_eq!(runtime.color.as_deref(), Some("green"));
514        assert_eq!(runtime.aliases, vec!["builder".to_string()]);
515        assert_eq!(runtime.skills, vec!["rust".to_string(), "repo".to_string()]);
516        assert_eq!(runtime.mcp_servers.len(), 1);
517        assert!(runtime.hooks.is_some());
518        assert_eq!(runtime.memory, Some(SubagentMemoryScope::Project));
519        assert!(runtime.read_only);
520        assert_eq!(active.identity.name, runtime.canonical_name);
521        assert_eq!(active.display_name, runtime.display_name);
522        assert_eq!(active.description, runtime.description);
523        assert_eq!(active.color, runtime.color);
524        assert_eq!(active.aliases, runtime.aliases);
525        assert_eq!(active.instructions, runtime.instructions);
526        assert_eq!(active.tools, runtime.tools);
527        assert_eq!(active.disallowed_tools, runtime.disallowed_tools);
528        assert_eq!(active.permissions, runtime.permissions);
529        assert_eq!(active.model, runtime.model);
530        assert_eq!(active.reasoning_effort, runtime.reasoning_effort);
531        assert_eq!(active.hooks, runtime.hooks);
532        assert_eq!(active.skills, runtime.skills);
533        assert_eq!(active.mcp_servers, runtime.mcp_servers);
534        assert_eq!(active.memory, runtime.memory);
535    }
536
537    #[test]
538    fn default_state_uses_builtin_duck_agent() {
539        let mut state = ActivePrimaryAgentState::default();
540
541        assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
542        assert_eq!(state.active().identity.name, "duck");
543        assert_eq!(state.active().identity.source, SubagentSource::Builtin);
544
545        state
546            .select_from_specs(&[test_spec("worker")], "worker")
547            .expect("selected");
548        assert_eq!(state.active().identity.name, "worker");
549
550        state.reset_to_default_from_specs(&[]);
551
552        assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
553        assert_eq!(state.active().identity.name, "duck");
554        assert_eq!(state.active().identity.source, SubagentSource::Builtin);
555    }
556
557    #[test]
558    fn from_specs_falls_back_to_builtin_duck_agent() {
559        let active = ActivePrimaryAgentState::from_specs(&[]);
560
561        assert_eq!(active.active().identity.name, "duck");
562        assert_eq!(active.active().identity.source, SubagentSource::Builtin);
563    }
564
565    #[test]
566    fn from_specs_with_default_selects_configured_primary_agent() {
567        let active =
568            ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "builder");
569
570        assert_eq!(active.active().identity.name, "builder");
571    }
572
573    #[test]
574    fn from_specs_with_default_falls_back_to_duck_for_missing_configured_agent() {
575        let active =
576            ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "missing");
577
578        assert_eq!(active.active().identity.name, "duck");
579        assert_eq!(active.active().identity.source, SubagentSource::Builtin);
580    }
581
582    #[test]
583    fn discovery_precedence_overrides_builtin_duck_agent() {
584        let temp = TempDir::new().expect("tempdir");
585        let discovered = discover_subagents(&SubagentDiscoveryInput {
586            workspace_root: temp.path().to_path_buf(),
587            cli_agents: Some(json!({
588                "duck": {
589                    "description": "CLI duck",
590                    "prompt": "cli duck instructions",
591                    "model": "gpt-cli",
592                    "mode": "primary",
593                    "permissions": { "default": "deny" }
594                }
595            })),
596            plugin_agent_files: Vec::new(),
597            include_user_agents: false,
598        })
599        .expect("discovered subagents");
600
601        let active = ActivePrimaryAgentState::from_discovery(&discovered);
602
603        assert_eq!(active.active().identity.name, "duck");
604        assert_eq!(active.active().identity.source, SubagentSource::Cli);
605        assert_eq!(active.active().instructions, "cli duck instructions");
606        assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
607    }
608
609    #[test]
610    fn default_duck_agent_allows_baseline_read_tools() {
611        let active = ActivePrimaryAgentState::default();
612
613        assert!(primary_agent_allows_tool(active.active(), "unified_search"));
614        assert!(!primary_agent_allows_tool(active.active(), "unified_file"));
615    }
616
617    #[test]
618    fn tool_policy_intersects_allow_list_then_applies_deny_list() {
619        let mut spec = test_spec("worker");
620        spec.tools = Some(vec![
621            "unified_search".to_string(),
622            "unified_file".to_string(),
623        ]);
624        spec.disallowed_tools = vec!["UNIFIED_SEARCH".to_string()];
625        let active = ActivePrimaryAgent::from_spec(&spec);
626
627        assert!(!primary_agent_allows_tool(&active, "unified_exec"));
628        assert!(!primary_agent_allows_tool(&active, "unified_search"));
629        assert!(primary_agent_allows_tool(&active, "unified_file"));
630    }
631
632    #[test]
633    fn empty_present_tool_allow_list_exposes_no_tools() {
634        let mut spec = test_spec("worker");
635        spec.tools = Some(Vec::new());
636        spec.disallowed_tools = Vec::new();
637        let active = ActivePrimaryAgent::from_spec(&spec);
638
639        assert!(!primary_agent_allows_tool(&active, "unified_search"));
640    }
641
642    #[test]
643    fn subagent_lifecycle_tools_bypass_tool_policy() {
644        let mut spec = test_spec("restricted");
645        spec.tools = Some(vec!["unified_search".to_string()]);
646        spec.disallowed_tools = vec![
647            "spawn_agent".to_string(),
648            "wait_agent".to_string(),
649            "close_agent".to_string(),
650            "send_input".to_string(),
651            "resume_agent".to_string(),
652            "spawn_background_subprocess".to_string(),
653        ];
654        let active = ActivePrimaryAgent::from_spec(&spec);
655
656        // Non-lifecycle tools respect the policy.
657        assert!(!primary_agent_allows_tool(&active, "unified_exec"));
658        assert!(!primary_agent_allows_tool(&active, "unified_file"));
659
660        // Subagent lifecycle tools are always allowed even when listed in
661        // disallowed_tools and absent from the allow list.
662        assert!(primary_agent_allows_tool(&active, "spawn_agent"));
663        assert!(primary_agent_allows_tool(&active, "wait_agent"));
664        assert!(primary_agent_allows_tool(&active, "close_agent"));
665        assert!(primary_agent_allows_tool(&active, "send_input"));
666        assert!(primary_agent_allows_tool(&active, "resume_agent"));
667        assert!(primary_agent_allows_tool(
668            &active,
669            "spawn_background_subprocess"
670        ));
671    }
672
673    #[test]
674    fn build_primary_agent_runtime_config_preserves_baseline_fields_and_merges_mcp() {
675        let mut parent = VTCodeConfig::default();
676        parent.agent.default_model = "parent-model".to_string();
677        parent.mcp.providers.push(
678            serde_json::from_value(json!({
679                "name": "global",
680                "command": "global-mcp",
681                "args": []
682            }))
683            .expect("global provider"),
684        );
685
686        let mut spec = test_spec("worker");
687        spec.permissions = AgentPermissionsConfig::new(PermissionDefault::Auto);
688        spec.model = Some("agent-model".to_string());
689        spec.reasoning_effort = Some("low".to_string());
690        spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([
691            (
692                "global".to_string(),
693                json!({
694                    "type": "stdio",
695                    "command": "duplicate-mcp"
696                }),
697            ),
698            (
699                "local".to_string(),
700                json!({
701                    "type": "stdio",
702                    "command": "local-mcp"
703                }),
704            ),
705        ]))];
706        let active = ActivePrimaryAgent::from_spec(&spec);
707
708        let runtime = build_primary_agent_runtime_config(&parent, &active);
709
710        assert_eq!(runtime.agent.default_model, "agent-model");
711        assert_eq!(runtime.agent.reasoning_effort, ReasoningEffortLevel::Low);
712        assert_eq!(runtime.mcp.providers.len(), 2);
713        assert_eq!(runtime.mcp.providers[0].name, "global");
714        assert_eq!(runtime.mcp.providers[1].name, "local");
715    }
716
717    #[test]
718    fn built_in_primary_agents_resolve_required_permission_policy() {
719        let builtins = builtin_subagents();
720
721        for name in ["duck", "plan", "build", "auto"] {
722            let active = resolve_primary_agent(&builtins, name)
723                .unwrap_or_else(|_| panic!("missing built-in primary agent {name}"));
724            assert_eq!(active.identity.name, name);
725            let expected_default = match name {
726                "build" => PermissionDefault::Ask,
727                "auto" => PermissionDefault::Auto,
728                "plan" | "duck" => PermissionDefault::Deny,
729                _ => unreachable!("unexpected built-in primary agent"),
730            };
731            assert_eq!(active.permissions.default, expected_default);
732        }
733    }
734
735    #[test]
736    fn active_primary_permissions_overlay_runtime_decisions() {
737        let builtins = builtin_subagents();
738        let mut state = ActivePrimaryAgentState::from_specs(&builtins);
739        let config = VTCodeConfig::default();
740        let workspace = TempDir::new().expect("workspace");
741        let current_dir = workspace.path();
742
743        state
744            .select_from_specs(&builtins, "auto")
745            .expect("auto primary");
746        let exec = build_permission_request(
747            workspace.path(),
748            current_dir,
749            tools::UNIFIED_EXEC,
750            Some(&json!({"command": "cargo test"})),
751        );
752        assert_eq!(
753            evaluate_active_primary_agent_permissions(
754                &config,
755                state.active(),
756                workspace.path(),
757                current_dir,
758                &exec,
759            ),
760            ResolvedPermissionDecision::Auto
761        );
762
763        state
764            .select_from_specs(&builtins, "plan")
765            .expect("plan primary");
766        let edit = build_permission_request(
767            workspace.path(),
768            current_dir,
769            tools::UNIFIED_FILE,
770            Some(&json!({"action": "edit", "path": "src/lib.rs"})),
771        );
772        assert_eq!(
773            evaluate_active_primary_agent_permissions(
774                &config,
775                state.active(),
776                workspace.path(),
777                current_dir,
778                &edit,
779            ),
780            ResolvedPermissionDecision::Deny
781        );
782    }
783
784    #[test]
785    fn primary_agent_switching_changes_permission_policy_without_mutating_parent_config() {
786        let builtins = builtin_subagents();
787        let mut state = ActivePrimaryAgentState::from_specs(&builtins);
788        let initial_model = state.active().model.clone();
789        let initial_tools = state.active().tools.clone();
790
791        state
792            .select_from_specs(&builtins, "auto")
793            .expect("auto primary");
794        assert_eq!(state.active().identity.name, "auto");
795        assert_eq!(state.active().permissions.default, PermissionDefault::Auto);
796        assert_eq!(state.active().model, initial_model);
797        assert_ne!(state.active().tools, initial_tools);
798
799        state
800            .select_from_specs(&builtins, "duck")
801            .expect("duck primary");
802        assert_eq!(state.active().identity.name, "duck");
803        assert_eq!(state.active().permissions.default, PermissionDefault::Deny);
804        assert_eq!(state.active().model, initial_model);
805        assert_eq!(state.active().tools, initial_tools);
806    }
807
808    #[test]
809    fn primary_hook_config_merges_supported_main_session_events_after_global_hooks() {
810        let mut global = HooksConfig::default();
811        global.lifecycle.user_prompt_submit = vec![hook_group("global-user")];
812        global.lifecycle.pre_tool_use = vec![hook_group("global-pre")];
813        global.lifecycle.post_tool_use = vec![hook_group("global-post")];
814        global.lifecycle.permission_request = vec![hook_group("global-permission")];
815        global.lifecycle.pre_compact = vec![hook_group("global-compact")];
816        global.lifecycle.stop = vec![hook_group("global-stop")];
817        global.lifecycle.notification = vec![hook_group("global-notification")];
818
819        let mut primary_hooks = HooksConfig::default();
820        primary_hooks.lifecycle.user_prompt_submit = vec![hook_group("primary-user")];
821        primary_hooks.lifecycle.pre_tool_use = vec![hook_group("primary-pre")];
822        primary_hooks.lifecycle.post_tool_use = vec![hook_group("primary-post")];
823        primary_hooks.lifecycle.permission_request = vec![hook_group("primary-permission")];
824        primary_hooks.lifecycle.pre_compact = vec![hook_group("primary-compact")];
825        primary_hooks.lifecycle.stop = vec![hook_group("primary-stop")];
826        primary_hooks.lifecycle.notification = vec![hook_group("primary-notification")];
827
828        let mut spec = test_spec("worker");
829        spec.hooks = Some(primary_hooks);
830        let active = ActivePrimaryAgent::from_spec(&spec);
831
832        let merged = build_primary_agent_hook_config(&global, &active);
833
834        assert_hook_commands(
835            &merged.lifecycle.user_prompt_submit,
836            &["global-user", "primary-user"],
837        );
838        assert_hook_commands(
839            &merged.lifecycle.pre_tool_use,
840            &["global-pre", "primary-pre"],
841        );
842        assert_hook_commands(
843            &merged.lifecycle.post_tool_use,
844            &["global-post", "primary-post"],
845        );
846        assert_hook_commands(
847            &merged.lifecycle.permission_request,
848            &["global-permission", "primary-permission"],
849        );
850        assert_hook_commands(
851            &merged.lifecycle.pre_compact,
852            &["global-compact", "primary-compact"],
853        );
854        assert_hook_commands(&merged.lifecycle.stop, &["global-stop", "primary-stop"]);
855        assert_hook_commands(
856            &merged.lifecycle.notification,
857            &["global-notification", "primary-notification"],
858        );
859    }
860
861    #[test]
862    fn primary_hook_config_excludes_global_and_subagent_lifecycle_events() {
863        let global = HooksConfig::default();
864        let mut primary_hooks = HooksConfig::default();
865        primary_hooks.lifecycle.session_start = vec![hook_group("primary-session-start")];
866        primary_hooks.lifecycle.session_end = vec![hook_group("primary-session-end")];
867        primary_hooks.lifecycle.subagent_start = vec![hook_group("primary-subagent-start")];
868        primary_hooks.lifecycle.subagent_stop = vec![hook_group("primary-subagent-stop")];
869        primary_hooks.lifecycle.task_completion = vec![hook_group("primary-task-completion")];
870        primary_hooks.lifecycle.task_completed = vec![hook_group("primary-task-completed")];
871
872        let mut spec = test_spec("worker");
873        spec.hooks = Some(primary_hooks);
874        let active = ActivePrimaryAgent::from_spec(&spec);
875
876        let merged = build_primary_agent_hook_config(&global, &active);
877
878        assert!(merged.lifecycle.session_start.is_empty());
879        assert!(merged.lifecycle.session_end.is_empty());
880        assert!(merged.lifecycle.subagent_start.is_empty());
881        assert!(merged.lifecycle.subagent_stop.is_empty());
882        assert!(merged.lifecycle.task_completion.is_empty());
883        assert!(merged.lifecycle.task_completed.is_empty());
884        assert!(merged.lifecycle.stop.is_empty());
885    }
886
887    #[test]
888    fn primary_hook_config_recomputes_without_previous_primary_leakage() {
889        let global = HooksConfig::default();
890        let mut first_hooks = HooksConfig::default();
891        first_hooks.lifecycle.pre_tool_use = vec![hook_group("first-pre")];
892        let mut second_hooks = HooksConfig::default();
893        second_hooks.lifecycle.pre_tool_use = vec![hook_group("second-pre")];
894
895        let mut first = test_spec("first");
896        first.hooks = Some(first_hooks);
897        let mut second = test_spec("second");
898        second.hooks = Some(second_hooks);
899        let specs = vec![first, second];
900        let mut state = ActivePrimaryAgentState::default();
901
902        state
903            .select_from_specs(&specs, "first")
904            .expect("selected first");
905        let first_config = build_primary_agent_hook_config(&global, state.active());
906        assert_hook_commands(&first_config.lifecycle.pre_tool_use, &["first-pre"]);
907
908        state
909            .select_from_specs(&specs, "second")
910            .expect("selected second");
911        let second_config = build_primary_agent_hook_config(&global, state.active());
912        assert_hook_commands(&second_config.lifecycle.pre_tool_use, &["second-pre"]);
913    }
914
915    #[test]
916    fn active_primary_state_recomputes_skills_mcp_and_metadata_on_switch() {
917        let mut first = test_spec("first");
918        first.description = "First metadata".to_string();
919        first.color = Some("red".to_string());
920        first.aliases = vec!["one".to_string()];
921        first.skills = vec!["rust".to_string()];
922        first.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
923            "first-mcp".to_string(),
924            json!({
925                "type": "stdio",
926                "command": "first-mcp"
927            }),
928        )]))];
929        let second = test_spec("second");
930        let specs = vec![first, second];
931        let mut state = ActivePrimaryAgentState::default();
932
933        state
934            .select_from_specs(&specs, "one")
935            .expect("selected first by alias");
936        assert_eq!(state.active().identity.name, "first");
937        assert_eq!(state.active().description, "First metadata");
938        assert_eq!(state.active().color.as_deref(), Some("red"));
939        assert_eq!(state.active().aliases, vec!["one".to_string()]);
940        assert_eq!(state.active().skills, vec!["rust".to_string()]);
941        assert_eq!(state.active().mcp_servers.len(), 1);
942
943        state
944            .select_from_specs(&specs, "second")
945            .expect("selected second");
946        assert_eq!(state.active().identity.name, "second");
947        assert_eq!(state.active().description, "second description");
948        assert_eq!(state.active().color.as_deref(), Some("blue"));
949        assert!(state.active().aliases.is_empty());
950        assert!(state.active().skills.is_empty());
951        assert!(state.active().mcp_servers.is_empty());
952    }
953
954    fn test_spec(name: &str) -> SubagentSpec {
955        SubagentSpec {
956            name: name.to_string(),
957            description: format!("{name} description"),
958            prompt: format!("{name} instructions"),
959            tools: Some(vec!["unified_search".to_string()]),
960            disallowed_tools: vec!["unified_file".to_string()],
961            model: Some("gpt-5.1".to_string()),
962            color: Some("blue".to_string()),
963            reasoning_effort: Some("high".to_string()),
964            permissions: AgentPermissionsConfig::new(PermissionDefault::Deny),
965            skills: Vec::new(),
966            mcp_servers: Vec::new(),
967            hooks: None,
968            background: false,
969            mode: vtcode_config::AgentMode::Primary,
970            max_turns: None,
971            nickname_candidates: Vec::new(),
972            initial_prompt: None,
973            memory: None,
974            isolation: None,
975            aliases: Vec::new(),
976            source: SubagentSource::ProjectVtcode,
977            file_path: None,
978            warnings: Vec::new(),
979        }
980    }
981
982    fn hook_group(command: &str) -> HookGroupConfig {
983        HookGroupConfig {
984            matcher: None,
985            hooks: vec![HookCommandConfig {
986                command: command.to_string(),
987                ..HookCommandConfig::default()
988            }],
989        }
990    }
991
992    fn assert_hook_commands(groups: &[HookGroupConfig], expected: &[&str]) {
993        let commands = groups
994            .iter()
995            .flat_map(|group| group.hooks.iter())
996            .map(|hook| hook.command.as_str())
997            .collect::<Vec<_>>();
998        assert_eq!(commands, expected);
999    }
1000}