Skip to main content

vtcode_core/
primary_agent.rs

1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use vtcode_config::constants::{defaults::DEFAULT_PRIMARY_AGENT_NAME, tools};
8use vtcode_config::core::permissions::AgentPermissionsConfig;
9use vtcode_config::core::tools::ToolPolicy;
10use vtcode_config::{
11    DiscoveredSubagents, HookGroupConfig, HooksConfig, McpProviderConfig, SubagentMcpServer, SubagentMemoryScope,
12    SubagentSource, SubagentSpec, builtin_primary_build_agent,
13};
14
15use crate::config::{ReasoningEffortLevel, VTCodeConfig};
16use crate::llm::provider::ToolDefinition;
17use crate::permissions::{PermissionRequest, ResolvedPermissionDecision, evaluate_effective_permissions};
18use crate::prompts::PromptContext;
19use crate::subagents::ResolvedAgentRuntimeView;
20use tracing::warn;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ActivePrimaryAgentSpecIdentity {
24    pub name: String,
25    pub source: SubagentSource,
26    pub file_path: Option<PathBuf>,
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct ActivePrimaryAgent {
31    pub identity: ActivePrimaryAgentSpecIdentity,
32    pub display_name: String,
33    pub description: String,
34    pub color: Option<String>,
35    pub aliases: Vec<String>,
36    pub instructions: String,
37    pub tools: Option<Vec<String>>,
38    pub disallowed_tools: Vec<String>,
39    pub permissions: AgentPermissionsConfig,
40    pub model: Option<String>,
41    pub reasoning_effort: Option<ReasoningEffortLevel>,
42    pub hooks: Option<HooksConfig>,
43    pub skills: Vec<String>,
44    pub mcp_servers: Vec<SubagentMcpServer>,
45    pub memory: Option<SubagentMemoryScope>,
46    pub tool_policy_overrides: BTreeMap<String, ToolPolicy>,
47}
48
49impl ActivePrimaryAgent {
50    #[must_use]
51    pub fn from_spec(spec: &SubagentSpec) -> Self {
52        Self::from_runtime_view(&ResolvedAgentRuntimeView::from_spec(spec))
53    }
54
55    #[must_use]
56    pub fn from_runtime_view(runtime: &ResolvedAgentRuntimeView) -> Self {
57        Self {
58            identity: ActivePrimaryAgentSpecIdentity {
59                name: runtime.canonical_name.clone(),
60                source: runtime.source.clone(),
61                file_path: runtime.file_path.clone(),
62            },
63            display_name: runtime.display_name.clone(),
64            description: runtime.description.clone(),
65            color: runtime.color.clone(),
66            aliases: runtime.aliases.clone(),
67            instructions: runtime.instructions.clone(),
68            tools: runtime.tools.clone(),
69            disallowed_tools: runtime.disallowed_tools.clone(),
70            permissions: runtime.permissions.clone(),
71            model: runtime.model.clone(),
72            reasoning_effort: runtime.reasoning_effort,
73            hooks: runtime.hooks.clone(),
74            skills: runtime.skills.clone(),
75            mcp_servers: runtime.mcp_servers.clone(),
76            memory: runtime.memory,
77            tool_policy_overrides: runtime.tool_policy_overrides.clone(),
78        }
79    }
80
81    /// Canonical primary agent name (the active session "mode").
82    #[must_use]
83    pub fn name(&self) -> &str {
84        &self.identity.name
85    }
86
87    /// Whether this agent contributes lifecycle hooks that originate from
88    /// workspace-controlled content (project agent-spec files inside the
89    /// repository). Such hooks must be gated behind the workspace lifecycle
90    /// hook approval just like hooks declared in a workspace `vtcode.toml`.
91    #[must_use]
92    pub fn contributes_workspace_controlled_hooks(&self) -> bool {
93        self.hooks.is_some()
94            && matches!(
95                self.identity.source,
96                SubagentSource::ProjectVtcode | SubagentSource::ProjectClaude | SubagentSource::ProjectCodex
97            )
98    }
99}
100
101#[derive(Debug, Clone, PartialEq)]
102pub struct ActivePrimaryAgentState {
103    active: ActivePrimaryAgent,
104}
105
106impl Default for ActivePrimaryAgentState {
107    fn default() -> Self {
108        Self {
109            active: ActivePrimaryAgent::from_spec(&builtin_primary_build_agent()),
110        }
111    }
112}
113
114impl ActivePrimaryAgentState {
115    #[must_use]
116    pub const fn active(&self) -> &ActivePrimaryAgent {
117        &self.active
118    }
119
120    #[must_use]
121    pub fn from_discovery(discovered: &DiscoveredSubagents) -> Self {
122        Self::from_specs(&discovered.effective)
123    }
124
125    #[must_use]
126    pub fn from_specs(specs: &[SubagentSpec]) -> Self {
127        Self::from_specs_with_default(specs, DEFAULT_PRIMARY_AGENT_NAME)
128    }
129
130    #[must_use]
131    pub fn from_specs_with_default(specs: &[SubagentSpec], requested_default: &str) -> Self {
132        let requested = if requested_default.trim().is_empty() {
133            DEFAULT_PRIMARY_AGENT_NAME
134        } else {
135            requested_default.trim()
136        };
137        let active = match resolve_primary_agent(specs, requested) {
138            Ok(active) => active,
139            Err(_) => {
140                if let Some(requested_name) = fallback_notice(requested) {
141                    warn!(
142                        requested = %requested_name,
143                        fallback = %DEFAULT_PRIMARY_AGENT_NAME,
144                        "Primary agent '{}' was not discovered; falling back to the built-in '{}' agent. \
145                         Set default_primary_agent to a discovered agent or add a matching primary agent.",
146                        requested_name,
147                        DEFAULT_PRIMARY_AGENT_NAME,
148                    );
149                }
150                ActivePrimaryAgent::from_spec(&builtin_primary_build_agent())
151            }
152        };
153        Self { active }
154    }
155
156    pub fn reset_to_default_from_specs(&mut self, specs: &[SubagentSpec]) -> &ActivePrimaryAgent {
157        self.active = Self::from_specs(specs).active;
158        &self.active
159    }
160
161    /// Restore a previously captured active-agent snapshot. Runloop prompt
162    /// boundaries use this to keep a write-capable Build handoff intact when
163    /// unrelated queued UI input attempts to switch modes mid-turn.
164    pub fn restore_snapshot(&mut self, snapshot: ActivePrimaryAgent) -> &ActivePrimaryAgent {
165        self.active = snapshot;
166        &self.active
167    }
168
169    pub fn select_from_discovery(
170        &mut self,
171        discovered: &DiscoveredSubagents,
172        requested: &str,
173    ) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
174        self.select_from_specs(&discovered.effective, requested)
175    }
176
177    pub fn select_from_specs(
178        &mut self,
179        specs: &[SubagentSpec],
180        requested: &str,
181    ) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
182        let active = resolve_primary_agent(specs, requested)?;
183        self.active = active;
184        Ok(&self.active)
185    }
186}
187
188pub type PrimaryAgentResolutionResult<T> = Result<T, PrimaryAgentResolutionError>;
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum PrimaryAgentResolutionError {
192    UnknownAgent { requested: String },
193}
194
195impl fmt::Display for PrimaryAgentResolutionError {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            Self::UnknownAgent { requested } => write!(f, "Unknown primary agent {requested}"),
199        }
200    }
201}
202
203impl Error for PrimaryAgentResolutionError {}
204
205pub fn resolve_discovered_primary_agent(
206    discovered: &DiscoveredSubagents,
207    requested: &str,
208) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
209    resolve_primary_agent(&discovered.effective, requested)
210}
211
212pub fn resolve_primary_agent(
213    specs: &[SubagentSpec],
214    requested: &str,
215) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
216    specs
217        .iter()
218        .find(|spec| spec.is_primary() && spec.name.eq_ignore_ascii_case(requested))
219        .or_else(|| specs.iter().find(|spec| spec.is_primary() && spec.matches_name(requested)))
220        .map(ActivePrimaryAgent::from_spec)
221        .ok_or_else(|| PrimaryAgentResolutionError::UnknownAgent { requested: requested.to_string() })
222}
223
224/// Returns `true` for cleanup-only child-agent tools that must remain
225/// available regardless of the active primary agent's tool policy.
226///
227/// NOTE: after `wait_agent`/`close_agent` were folded into the unified
228/// `agent` tool (action='wait'/'close'), `LIFECYCLE_CLEANUP_TOOLS` no longer
229/// matches any name that is actually *emitted* to the model — the emitted
230/// name is always `tools::AGENT` ("agent"). This carve-out therefore only
231/// takes effect if a caller passes one of the legacy granular names
232/// (`wait_agent`/`close_agent`) directly rather than the folded name.
233/// Expressing true per-action granularity at runtime (allow cleanup actions
234/// on the folded `agent` tool while denying `spawn`) requires
235/// execution-time action gating, the same mechanism used for
236/// `mcp:connect`/`mcp:disconnect` (see `action_qualified_policy_name` in
237/// `tool_intent.rs`), and is deferred — see
238/// `apply_primary_agent_tool_policy`'s doc comment for the specific gap.
239fn is_subagent_cleanup_tool(tool_name: &str) -> bool {
240    tools::LIFECYCLE_CLEANUP_TOOLS
241        .iter()
242        .any(|cleanup| cleanup.eq_ignore_ascii_case(tool_name))
243}
244
245/// Returns the configured agent name that should be surfaced to the operator
246/// when the resolver cannot find it and falls back to the built-in `build`
247/// agent, or `None` when the fallback is expected and should stay silent.
248///
249/// A fallback is expected (and therefore silent) when the operator asked for
250/// the default `build` agent — whether explicitly or because no
251/// `default_primary_agent` was configured — since the built-in `build` agent
252/// *is* the canonical build. Any other requested name that misses discovery is
253/// a configuration mismatch the operator should hear about.
254///
255/// Extracted as a pure predicate so the decision is unit-testable without a
256/// tracing subscriber.
257fn fallback_notice(requested_default: &str) -> Option<&str> {
258    let requested = requested_default.trim();
259    if requested.is_empty() || requested.eq_ignore_ascii_case(DEFAULT_PRIMARY_AGENT_NAME) {
260        None
261    } else {
262        Some(requested)
263    }
264}
265
266#[must_use]
267pub fn primary_agent_allows_tool(agent: &ActivePrimaryAgent, tool_name: &str) -> bool {
268    let tool_name = tool_name.trim();
269
270    // Cleanup tools stay available so restricted agents can still join or
271    // close already-running child work.
272    if is_subagent_cleanup_tool(tool_name) {
273        return true;
274    }
275
276    // The task tracker stays available so restricted primaries (including
277    // custom `auto` agents with an explicit `tools:` list) can still report
278    // progress. Planning/interview tools stay author-curated: unlike the
279    // tracker they change the session mode, so an explicit omission must
280    // keep working.
281    if tool_name.eq_ignore_ascii_case(tools::TASK_TRACKER) {
282        return true;
283    }
284
285    let allow_list_allows = agent
286        .tools
287        .as_ref()
288        .is_none_or(|tools| tools.iter().any(|allowed| allowed.trim().eq_ignore_ascii_case(tool_name)));
289    if !allow_list_allows {
290        return false;
291    }
292
293    !agent
294        .disallowed_tools
295        .iter()
296        .any(|denied| denied.trim().eq_ignore_ascii_case(tool_name))
297}
298
299/// Filters the emitted tool list down to those `primary_agent_allows_tool`
300/// permits for `agent`.
301///
302/// KNOWN LIMITATION: filtering is by each tool's canonical
303/// `function_name()`, and matching only trims and ignores ASCII case — it
304/// does not alias-resolve. For multi-action tools that fold several legacy
305/// actions into one emitted name (e.g. `agent` folding
306/// spawn/wait/close/send_input/resume, or `mcp` folding connect/disconnect),
307/// a granular allow/deny entry such as `"spawn_agent"` in
308/// `disallowed_tools`/`tools` has no effect on the folded `agent` tool: the
309/// whole tool is either present or absent based on whether `"agent"` itself
310/// is allowed. Expressing "allow cleanup (wait/close) but deny spawn"
311/// therefore cannot be done here; it requires execution-time action gating —
312/// the same action-qualified mechanism already used to keep `mcp:connect`/
313/// `mcp:disconnect` HITL-gated separately from bare `mcp` (see
314/// `action_qualified_policy_name` in `tool_intent.rs`) — applied per-action
315/// at the point the subagent tool call is dispatched. That is deferred.
316#[must_use]
317pub fn apply_primary_agent_tool_policy(
318    tools: Option<Arc<Vec<ToolDefinition>>>,
319    agent: &ActivePrimaryAgent,
320) -> Option<Arc<Vec<ToolDefinition>>> {
321    let tools = tools?;
322    let Some(first_rejected) = tools
323        .iter()
324        .position(|tool| !primary_agent_allows_tool(agent, tool.function_name()))
325    else {
326        return (!tools.is_empty()).then_some(tools);
327    };
328    let filtered = tools
329        .iter()
330        .take(first_rejected)
331        .chain(
332            tools
333                .iter()
334                .skip(first_rejected + 1)
335                .filter(|tool| primary_agent_allows_tool(agent, tool.function_name())),
336        )
337        .cloned()
338        .collect::<Vec<_>>();
339
340    (!filtered.is_empty()).then(|| Arc::new(filtered))
341}
342
343#[must_use]
344pub fn build_primary_agent_runtime_config(parent: &VTCodeConfig, agent: &ActivePrimaryAgent) -> VTCodeConfig {
345    let mut config = parent.clone();
346    // "inherit" (and empty) are sentinels meaning "use the parent's model" — mirrors
347    // resolve_subagent_model's handling in subagents/model.rs. Built-in primary agents
348    // (e.g. "auto", "build") default their `model` field to "inherit", so without this
349    // check every plain `exec` run would clobber a real, explicitly configured model
350    // (via --model or [agent].default_model) with the literal string "inherit", which
351    // then fails ModelId parsing entirely.
352    if let Some(model) = agent.model.as_ref() {
353        let trimmed = model.trim();
354        if !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("inherit") {
355            config.agent.default_model = model.clone();
356        }
357    }
358    if let Some(reasoning_effort) = agent.reasoning_effort {
359        config.agent.reasoning_effort = reasoning_effort;
360    }
361    merge_primary_mcp_servers(&mut config, agent.mcp_servers.as_slice());
362    config
363}
364
365#[must_use]
366pub fn build_primary_agent_hook_config(global: &HooksConfig, agent: &ActivePrimaryAgent) -> HooksConfig {
367    let mut config = global.clone();
368    merge_active_primary_hooks(&mut config, agent.hooks.as_ref());
369    config
370}
371
372pub fn apply_primary_agent_prompt_context(context: &mut PromptContext, agent: &ActivePrimaryAgent) {
373    context.replace_available_skills_with_named(agent.skills.as_slice());
374}
375
376#[must_use]
377pub fn active_primary_agent_permissions(agent: &ActivePrimaryAgent) -> &AgentPermissionsConfig {
378    &agent.permissions
379}
380
381#[must_use]
382pub fn evaluate_active_primary_agent_permissions(
383    config: &VTCodeConfig,
384    agent: &ActivePrimaryAgent,
385    workspace_root: &std::path::Path,
386    current_dir: &std::path::Path,
387    request: &PermissionRequest,
388) -> ResolvedPermissionDecision {
389    evaluate_effective_permissions(
390        &config.permissions,
391        active_primary_agent_permissions(agent),
392        workspace_root,
393        current_dir,
394        request,
395    )
396}
397
398fn merge_primary_mcp_servers(config: &mut VTCodeConfig, servers: &[SubagentMcpServer]) {
399    for server in servers {
400        match server {
401            SubagentMcpServer::Named(_) => {}
402            SubagentMcpServer::Inline(definition) => {
403                for (name, value) in definition {
404                    if config.mcp.providers.iter().any(|provider| provider.name == *name) {
405                        continue;
406                    }
407                    if let Some(provider) = inline_mcp_provider(name, value) {
408                        config.mcp.providers.push(provider);
409                    }
410                }
411            }
412        }
413    }
414}
415
416fn merge_active_primary_hooks(config: &mut HooksConfig, hooks: Option<&HooksConfig>) {
417    let Some(hooks) = hooks else {
418        return;
419    };
420
421    config.lifecycle.quiet_success_output |= hooks.lifecycle.quiet_success_output;
422    append_hook_groups(&mut config.lifecycle.session_start, &hooks.lifecycle.session_start);
423    append_hook_groups(&mut config.lifecycle.session_end, &hooks.lifecycle.session_end);
424    append_hook_groups(&mut config.lifecycle.subagent_start, &hooks.lifecycle.subagent_start);
425    append_hook_groups(&mut config.lifecycle.subagent_stop, &hooks.lifecycle.subagent_stop);
426    append_hook_groups(&mut config.lifecycle.user_prompt_submit, &hooks.lifecycle.user_prompt_submit);
427    append_hook_groups(&mut config.lifecycle.pre_tool_use, &hooks.lifecycle.pre_tool_use);
428    append_hook_groups(&mut config.lifecycle.post_tool_use, &hooks.lifecycle.post_tool_use);
429    append_hook_groups(&mut config.lifecycle.permission_request, &hooks.lifecycle.permission_request);
430    append_hook_groups(&mut config.lifecycle.pre_compact, &hooks.lifecycle.pre_compact);
431    append_hook_groups(&mut config.lifecycle.stop, &hooks.lifecycle.stop);
432    append_hook_groups(&mut config.lifecycle.notification, &hooks.lifecycle.notification);
433}
434
435fn append_hook_groups(target: &mut Vec<HookGroupConfig>, source: &[HookGroupConfig]) {
436    target.extend(source.iter().cloned());
437}
438
439fn inline_mcp_provider(name: &str, value: &serde_json::Value) -> Option<McpProviderConfig> {
440    let object = value.as_object()?;
441    let mut payload = serde_json::Map::with_capacity(object.len().saturating_add(1));
442    payload.insert("name".to_string(), serde_json::Value::String(name.to_string()));
443    for (key, value) in object {
444        if key == "type" {
445            continue;
446        }
447        payload.insert(key.clone(), value.clone());
448    }
449    if payload.contains_key("command") && !payload.contains_key("args") {
450        payload.insert("args".to_string(), serde_json::Value::Array(Vec::new()));
451    }
452    serde_json::from_value(serde_json::Value::Object(payload)).ok()
453}
454
455#[cfg(test)]
456mod tests {
457    use std::collections::BTreeMap;
458
459    use serde_json::json;
460    use tempfile::TempDir;
461    use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
462    use vtcode_config::{
463        HookCommandConfig, HooksConfig, IsolationMode, SubagentDiscoveryInput, SubagentMcpServer, SubagentMemoryScope,
464        SubagentSource, builtin_plan_agent, builtin_primary_auto_agent, builtin_primary_duck_agent, builtin_subagents,
465        discover_subagents,
466    };
467
468    use crate::config::constants::tools;
469    use crate::permissions::{ResolvedPermissionDecision, build_permission_request};
470
471    use super::*;
472
473    #[test]
474    fn resolves_existing_spec_by_name() {
475        let spec = test_spec("planner");
476        let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
477
478        assert_eq!(active.identity.name, "planner");
479        assert_eq!(active.display_name, "planner");
480        assert_eq!(active.description, "planner description");
481        assert_eq!(active.color.as_deref(), Some("blue"));
482        assert_eq!(active.instructions, "planner instructions");
483        assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
484        assert_eq!(active.disallowed_tools, vec!["file_operation".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, Some(ReasoningEffortLevel::High));
488        assert!(active.hooks.is_none());
489    }
490
491    #[test]
492    fn unknown_agent_error_preserves_current_active_agent() {
493        let current = test_spec("current");
494        let specs = vec![current.clone()];
495        let mut state = ActivePrimaryAgentState::default();
496        let original = state.select_from_specs(&specs, "current").expect("initial selection").clone();
497
498        let error = state.select_from_specs(&specs, "missing").expect_err("unknown agent");
499
500        assert_eq!(error, PrimaryAgentResolutionError::UnknownAgent { requested: "missing".to_string() });
501        assert_eq!(state.active(), &original);
502    }
503
504    #[test]
505    fn restore_snapshot_returns_to_write_capable_build_agent() {
506        let mut state = ActivePrimaryAgentState::default();
507        let build_snapshot = state.active().clone();
508        state
509            .select_from_specs(&[test_spec("duck")], "duck")
510            .expect("select leaked agent");
511        assert_eq!(state.active().identity.name, "duck");
512
513        state.restore_snapshot(build_snapshot);
514
515        assert_eq!(state.active().identity.name, "build");
516    }
517
518    #[test]
519    fn alias_resolution_uses_existing_matches_name_semantics() {
520        let mut spec = test_spec("reviewer");
521        spec.aliases = vec!["critic".to_string()];
522
523        let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
524
525        assert_eq!(active.identity.name, "reviewer");
526        assert_eq!(active.display_name, "reviewer");
527    }
528
529    #[test]
530    fn exact_name_resolution_wins_over_alias() {
531        let mut build = test_spec("build");
532        build.aliases = vec!["builder".to_string()];
533        let builder = test_spec("builder");
534
535        let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
536
537        assert_eq!(active.identity.name, "builder");
538    }
539
540    #[test]
541    fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
542        let mut spec = test_spec("worker");
543        spec.aliases = vec!["builder".to_string()];
544        spec.skills = vec!["rust".to_string()];
545        spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
546        spec.background = true;
547        spec.max_turns = Some(12);
548        spec.nickname_candidates = vec!["w".to_string()];
549        spec.initial_prompt = Some("start here".to_string());
550        spec.memory = Some(SubagentMemoryScope::Project);
551        spec.isolation = Some(IsolationMode::Full);
552
553        let active = ActivePrimaryAgent::from_spec(&spec);
554
555        assert_eq!(active.identity.name, "worker");
556        assert_eq!(active.display_name, "worker");
557        assert_eq!(active.description, "worker description");
558        assert_eq!(active.color.as_deref(), Some("blue"));
559        assert_eq!(active.aliases, vec!["builder".to_string()]);
560        assert_eq!(active.instructions, "worker instructions");
561        assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
562        assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
563        assert_eq!(active.permissions.default, PermissionDefault::Deny);
564        assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
565        assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
566        assert_eq!(active.skills, vec!["rust".to_string()]);
567        assert_eq!(active.mcp_servers, vec![SubagentMcpServer::Named("filesystem".to_string())]);
568        assert_eq!(active.memory, Some(SubagentMemoryScope::Project));
569    }
570
571    #[test]
572    fn primary_runtime_adapter_uses_shared_resolved_view_for_overlapping_fields() {
573        let mut spec = test_spec("worker");
574        spec.description = "Worker display metadata".to_string();
575        spec.color = Some("green".to_string());
576        spec.aliases = vec!["builder".to_string()];
577        spec.skills = vec!["rust".to_string(), "repo".to_string()];
578        spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
579        spec.hooks = Some(HooksConfig::default());
580        spec.memory = Some(SubagentMemoryScope::Project);
581
582        let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
583        let active = ActivePrimaryAgent::from_runtime_view(&runtime);
584
585        assert_eq!(runtime.canonical_name, "worker");
586        assert_eq!(runtime.display_name, "worker");
587        assert_eq!(runtime.description, "Worker display metadata");
588        assert_eq!(runtime.color.as_deref(), Some("green"));
589        assert_eq!(runtime.aliases, vec!["builder".to_string()]);
590        assert_eq!(runtime.skills, vec!["rust".to_string(), "repo".to_string()]);
591        assert_eq!(runtime.mcp_servers.len(), 1);
592        assert!(runtime.hooks.is_some());
593        assert_eq!(runtime.memory, Some(SubagentMemoryScope::Project));
594        assert!(runtime.read_only);
595        assert_eq!(active.identity.name, runtime.canonical_name);
596        assert_eq!(active.display_name, runtime.display_name);
597        assert_eq!(active.description, runtime.description);
598        assert_eq!(active.color, runtime.color);
599        assert_eq!(active.aliases, runtime.aliases);
600        assert_eq!(active.instructions, runtime.instructions);
601        assert_eq!(active.tools, runtime.tools);
602        assert_eq!(active.disallowed_tools, runtime.disallowed_tools);
603        assert_eq!(active.permissions, runtime.permissions);
604        assert_eq!(active.model, runtime.model);
605        assert_eq!(active.reasoning_effort, runtime.reasoning_effort);
606        assert_eq!(active.hooks, runtime.hooks);
607        assert_eq!(active.skills, runtime.skills);
608        assert_eq!(active.mcp_servers, runtime.mcp_servers);
609        assert_eq!(active.memory, runtime.memory);
610    }
611
612    #[test]
613    fn default_state_uses_builtin_build_agent() {
614        let mut state = ActivePrimaryAgentState::default();
615
616        assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
617        assert_eq!(state.active().identity.name, "build");
618        assert_eq!(state.active().identity.source, SubagentSource::Builtin);
619
620        state.select_from_specs(&[test_spec("worker")], "worker").expect("selected");
621        assert_eq!(state.active().identity.name, "worker");
622
623        state.reset_to_default_from_specs(&[]);
624
625        assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
626        assert_eq!(state.active().identity.name, "build");
627        assert_eq!(state.active().identity.source, SubagentSource::Builtin);
628    }
629
630    #[test]
631    fn from_specs_falls_back_to_builtin_build_agent() {
632        let active = ActivePrimaryAgentState::from_specs(&[]);
633
634        assert_eq!(active.active().identity.name, "build");
635        assert_eq!(active.active().identity.source, SubagentSource::Builtin);
636    }
637
638    #[test]
639    fn from_specs_with_default_selects_configured_primary_agent() {
640        let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "builder");
641
642        assert_eq!(active.active().identity.name, "builder");
643    }
644
645    #[test]
646    fn from_specs_with_default_falls_back_to_build_for_missing_configured_agent() {
647        let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "missing");
648
649        assert_eq!(active.active().identity.name, "build");
650        assert_eq!(active.active().identity.source, SubagentSource::Builtin);
651    }
652
653    #[test]
654    fn fallback_notice_silent_for_build_and_empty() {
655        // The built-in `build` agent is the canonical default, so falling back
656        // to it when the operator asked for `build` (or nothing) is expected.
657        assert_eq!(fallback_notice("build"), None);
658        assert_eq!(fallback_notice("BUILD"), None);
659        assert_eq!(fallback_notice("Build"), None);
660        assert_eq!(fallback_notice(""), None);
661        assert_eq!(fallback_notice("   "), None);
662    }
663
664    #[test]
665    fn fallback_notice_surfaces_non_default_mismatches() {
666        // Any other requested name that misses discovery is a configuration
667        // mismatch the operator should be warned about.
668        assert_eq!(fallback_notice("duck"), Some("duck"));
669        assert_eq!(fallback_notice("auto"), Some("auto"));
670        assert_eq!(fallback_notice("  duck  "), Some("duck"));
671    }
672
673    #[test]
674    fn discovery_precedence_overrides_builtin_build_agent() {
675        let temp = TempDir::new().expect("tempdir");
676        let discovered = discover_subagents(&SubagentDiscoveryInput {
677            workspace_root: temp.path().to_path_buf(),
678            cli_agents: Some(json!({
679                "build": {
680                    "description": "CLI build",
681                    "prompt": "cli build instructions",
682                    "model": "gpt-cli",
683                    "mode": "primary",
684                    "permissions": { "default": "ask" }
685                }
686            })),
687            plugin_agent_files: Vec::new(),
688            include_user_agents: false,
689        })
690        .expect("discovered subagents");
691
692        let active = ActivePrimaryAgentState::from_discovery(&discovered);
693
694        assert_eq!(active.active().identity.name, "build");
695        assert_eq!(active.active().identity.source, SubagentSource::Cli);
696        assert_eq!(active.active().instructions, "cli build instructions");
697        assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
698    }
699
700    #[test]
701    fn default_build_agent_allows_baseline_read_and_exec_tools() {
702        let active = ActivePrimaryAgentState::default();
703
704        assert!(primary_agent_allows_tool(active.active(), tools::CODE_SEARCH));
705        assert!(primary_agent_allows_tool(active.active(), tools::EXEC_COMMAND));
706        assert!(primary_agent_allows_tool(active.active(), tools::APPLY_PATCH));
707        assert!(primary_agent_allows_tool(active.active(), tools::RUN_PTY_CMD));
708        assert_eq!(active.active().permissions.default, PermissionDefault::Ask);
709    }
710
711    #[test]
712    fn build_and_auto_expose_the_same_tools_and_policy_decisions() {
713        let build = ActivePrimaryAgent::from_spec(&builtin_primary_build_agent());
714        let auto = ActivePrimaryAgent::from_spec(&builtin_primary_auto_agent());
715        let tools_to_compare = [
716            tools::CODE_SEARCH,
717            tools::EXEC_COMMAND,
718            tools::APPLY_PATCH,
719            tools::RUN_PTY_CMD,
720            tools::WRITE_FILE,
721            tools::REQUEST_USER_INPUT,
722        ];
723
724        for tool in tools_to_compare {
725            assert_eq!(
726                primary_agent_allows_tool(&build, tool),
727                primary_agent_allows_tool(&auto, tool),
728                "Build and Auto must expose the same capability for {tool}"
729            );
730        }
731    }
732
733    #[test]
734    fn readonly_primaries_expose_public_read_tools_and_block_apply_patch() {
735        for spec in [builtin_primary_duck_agent(), builtin_plan_agent()] {
736            let name = spec.name.clone();
737            let active = ActivePrimaryAgent::from_spec(&spec);
738
739            assert!(primary_agent_allows_tool(&active, tools::CODE_SEARCH), "{name} should expose code_search");
740            assert!(primary_agent_allows_tool(&active, tools::EXEC_COMMAND), "{name} should expose exec_command");
741            assert!(primary_agent_allows_tool(&active, tools::REQUEST_USER_INPUT));
742            assert!(primary_agent_allows_tool(&active, tools::RECORD_DECISION));
743
744            assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH), "{name} must not expose apply_patch");
745            assert!(!primary_agent_allows_tool(&active, tools::RUN_PTY_CMD), "{name} must not expose run_pty_cmd");
746            assert!(!primary_agent_allows_tool(&active, tools::WRITE_FILE), "{name} must not expose write_file");
747        }
748    }
749
750    #[test]
751    fn tool_policy_intersects_allow_list_then_applies_deny_list() {
752        let mut spec = test_spec("worker");
753        spec.tools = Some(vec![" \tCoDe_SeArCh\n".to_string(), "\u{2003}ExEc_CoMmAnD\u{a0}".to_string()]);
754        spec.disallowed_tools = vec!["\u{a0}CODE_search\u{2003}".to_string()];
755        let active = ActivePrimaryAgent::from_spec(&spec);
756
757        assert!(primary_agent_allows_tool(&active, "\u{a0}EXEC_command\u{2003}"));
758        assert!(!primary_agent_allows_tool(&active, " \tcode_SEARCH\n"));
759        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
760        assert!(!primary_agent_allows_tool(&active, "exec_command_extra"));
761        assert!(!primary_agent_allows_tool(&active, tools::RUN_PTY_CMD));
762    }
763
764    #[test]
765    fn absent_tool_allow_list_allows_names_except_deny_matches() {
766        let mut spec = test_spec("worker");
767        spec.tools = None;
768        spec.disallowed_tools = vec![" \tCoDe_SeArCh\n".to_string()];
769        let active = ActivePrimaryAgent::from_spec(&spec);
770
771        assert!(primary_agent_allows_tool(&active, " ExEc_CoMmAnD "));
772        assert!(primary_agent_allows_tool(&active, "custom_tool"));
773        assert!(!primary_agent_allows_tool(&active, " CODE_search "));
774    }
775
776    #[test]
777    fn tool_policy_preserves_empty_name_matching() {
778        let mut spec = test_spec("worker");
779        spec.tools = None;
780        spec.disallowed_tools.clear();
781        let mut active = ActivePrimaryAgent::from_spec(&spec);
782
783        assert!(primary_agent_allows_tool(&active, ""));
784        assert!(primary_agent_allows_tool(&active, "\u{2003}\t\u{a0}"));
785        active.tools = Some(Vec::new());
786        assert!(!primary_agent_allows_tool(&active, ""));
787        assert!(!primary_agent_allows_tool(&active, "\u{2003}\t\u{a0}"));
788        active.tools = Some(vec!["\u{a0} \t".to_string()]);
789        assert!(primary_agent_allows_tool(&active, ""));
790        assert!(primary_agent_allows_tool(&active, "\u{2003}"));
791        assert!(!primary_agent_allows_tool(&active, "custom_tool"));
792        active.disallowed_tools = vec![String::new()];
793        assert!(!primary_agent_allows_tool(&active, ""));
794        assert!(!primary_agent_allows_tool(&active, "\u{2003}"));
795    }
796
797    #[test]
798    fn tool_policy_keeps_non_ascii_case_and_non_whitespace_distinct() {
799        let mut spec = test_spec("worker");
800        spec.tools = Some(vec!["\u{2003}CAFÉ\u{a0}".to_string()]);
801        spec.disallowed_tools = vec![" café ".to_string()];
802        let mut active = ActivePrimaryAgent::from_spec(&spec);
803
804        assert!(primary_agent_allows_tool(&active, " caFÉ "));
805        assert!(!primary_agent_allows_tool(&active, "café"));
806        assert!(!primary_agent_allows_tool(&active, "CAFE"));
807        assert!(!primary_agent_allows_tool(&active, "\u{200b}CAFÉ\u{200b}"));
808
809        active.tools = None;
810        assert!(primary_agent_allows_tool(&active, "CAFÉ"));
811        assert!(!primary_agent_allows_tool(&active, "CaFé"));
812    }
813
814    #[test]
815    fn mixed_case_cleanup_and_tracker_names_bypass_empty_allow_list_and_explicit_denials() {
816        let mut spec = test_spec("restricted");
817        spec.tools = Some(Vec::new());
818        spec.disallowed_tools = vec![
819            " Wait_AGENT ".to_string(),
820            "\u{2003}Close_Agent\u{a0}".to_string(),
821            "\tTask_TRACKER\n".to_string(),
822        ];
823        let active = ActivePrimaryAgent::from_spec(&spec);
824
825        assert!(primary_agent_allows_tool(&active, "\u{a0}WAIT_agent\u{2003}"));
826        assert!(primary_agent_allows_tool(&active, " Close_AGENT "));
827        assert!(primary_agent_allows_tool(&active, " TaSk_TrAcKeR "));
828        assert!(!primary_agent_allows_tool(&active, " Agent "));
829        assert!(!primary_agent_allows_tool(&active, " Spawn_AGENT "));
830        assert!(!primary_agent_allows_tool(&active, "task_tracker_extra"));
831        assert!(!primary_agent_allows_tool(&active, "wait_agent_extra"));
832    }
833
834    #[test]
835    fn filtered_tool_catalog_preserves_surviving_names_and_order() {
836        let mut spec = test_spec("worker");
837        spec.tools = Some(vec![
838            " APPLY_patch ".to_string(),
839            " EXEC_command ".to_string(),
840            " CODE_search ".to_string(),
841        ]);
842        spec.disallowed_tools = vec![" code_SEARCH ".to_string()];
843        let active = ActivePrimaryAgent::from_spec(&spec);
844        let catalog = Arc::new(
845            [
846                "Exec_Command",
847                "code_search",
848                "WaIt_AgEnT",
849                "apply_patch",
850                "TASK_TRACKER",
851                "run_pty_cmd",
852                "agent",
853            ]
854            .into_iter()
855            .map(|name| ToolDefinition::function(name.to_string(), String::new(), json!({"type": "object"})))
856            .collect(),
857        );
858
859        let filtered = apply_primary_agent_tool_policy(Some(Arc::clone(&catalog)), &active).expect("surviving tools");
860
861        assert!(!Arc::ptr_eq(&filtered, &catalog));
862        assert_eq!(
863            filtered.iter().map(ToolDefinition::function_name).collect::<Vec<_>>(),
864            ["Exec_Command", "WaIt_AgEnT", "apply_patch", "TASK_TRACKER"]
865        );
866        assert_eq!(catalog.len(), 7);
867        assert_eq!(catalog[1].function_name(), "code_search");
868    }
869
870    #[test]
871    fn unchanged_tool_catalog_reuses_shared_definitions() {
872        let mut spec = test_spec("worker");
873        spec.tools = None;
874        spec.disallowed_tools.clear();
875        let active = ActivePrimaryAgent::from_spec(&spec);
876        let catalog = Arc::new(vec![ToolDefinition::function(
877            "exec_command".to_string(),
878            "Run a command".to_string(),
879            json!({"type": "object", "properties": {"cmd": {"type": "string"}}}),
880        )]);
881
882        let filtered = apply_primary_agent_tool_policy(Some(Arc::clone(&catalog)), &active).expect("unchanged catalog");
883
884        assert!(Arc::ptr_eq(&filtered, &catalog));
885        assert_eq!(filtered[0].function_name(), "exec_command");
886    }
887
888    #[test]
889    fn tool_policy_returns_none_for_absent_empty_or_fully_denied_catalogs() {
890        let active = ActivePrimaryAgent::from_spec(&test_spec("worker"));
891        assert!(apply_primary_agent_tool_policy(None, &active).is_none());
892        assert!(apply_primary_agent_tool_policy(Some(Arc::new(Vec::new())), &active).is_none());
893        let denied = Arc::new(vec![ToolDefinition::function(
894            "apply_patch".to_string(),
895            String::new(),
896            json!({"type": "object"}),
897        )]);
898        assert!(apply_primary_agent_tool_policy(Some(denied), &active).is_none());
899    }
900
901    #[test]
902    fn empty_present_tool_allow_list_exposes_no_tools() {
903        let mut spec = test_spec("worker");
904        spec.tools = Some(Vec::new());
905        spec.disallowed_tools = Vec::new();
906        let active = ActivePrimaryAgent::from_spec(&spec);
907
908        assert!(!primary_agent_allows_tool(&active, tools::CODE_SEARCH));
909    }
910
911    // NOTE: `spawn_agent`/`wait_agent`/`close_agent`/etc. are legacy granular
912    // subagent-lifecycle tool names. Since wait/close were folded into the
913    // unified `agent` tool (action='wait'/'close'), these granular names are
914    // never actually emitted to the model — the only subagent tool name that
915    // ever reaches `apply_primary_agent_tool_policy`'s runtime filtering is
916    // `tools::AGENT` ("agent"). The assertions below against the granular
917    // names are legacy-taxonomy coverage of the `primary_agent_allows_tool`/
918    // `is_subagent_cleanup_tool` predicates directly (useful if a caller ever
919    // passes a legacy name explicitly), NOT a test of the runtime filtering
920    // path exercised by `apply_primary_agent_tool_policy`. The final
921    // assertion covers that real runtime path: with an allow-list that
922    // excludes "agent", the whole folded tool is filtered out, and there is
923    // currently no way to allow cleanup (wait/close) while denying spawn for
924    // the emitted "agent" tool (see `apply_primary_agent_tool_policy`'s doc
925    // comment for the deferred execution-time action-gating fix).
926    #[test]
927    fn subagent_cleanup_tools_bypass_policy_but_new_work_obeys_policy() {
928        let mut spec = test_spec("restricted");
929        spec.tools = Some(vec!["code_search".to_string()]);
930        spec.disallowed_tools = vec![
931            tools::SPAWN_AGENT.to_string(),
932            tools::WAIT_AGENT.to_string(),
933            tools::CLOSE_AGENT.to_string(),
934            tools::SEND_INPUT.to_string(),
935            tools::RESUME_AGENT.to_string(),
936            tools::SPAWN_BACKGROUND_SUBPROCESS.to_string(),
937        ];
938        let active = ActivePrimaryAgent::from_spec(&spec);
939
940        // Non-cleanup tools respect the policy.
941        assert!(!primary_agent_allows_tool(&active, "command_session"));
942        assert!(!primary_agent_allows_tool(&active, "file_operation"));
943        assert!(!primary_agent_allows_tool(&active, tools::SPAWN_AGENT));
944        assert!(!primary_agent_allows_tool(&active, tools::SPAWN_BACKGROUND_SUBPROCESS));
945        assert!(!primary_agent_allows_tool(&active, tools::SEND_INPUT));
946        assert!(!primary_agent_allows_tool(&active, tools::RESUME_AGENT));
947
948        // Cleanup tools remain available even when listed in disallowed_tools
949        // and absent from the allow list.
950        assert!(primary_agent_allows_tool(&active, tools::WAIT_AGENT));
951        assert!(primary_agent_allows_tool(&active, tools::CLOSE_AGENT));
952
953        // Runtime reality: the allow list excludes "agent" (the only name
954        // ever emitted for subagent tools post-fold), so the whole tool is
955        // filtered out regardless of the cleanup carve-out above.
956        assert!(!primary_agent_allows_tool(&active, tools::AGENT));
957    }
958
959    #[test]
960    fn task_tracker_bypasses_restricted_tool_allow_list_but_mode_tools_obey_it() {
961        // A custom primary with an explicit `tools:` list omitting the
962        // tracker must still expose it, while planning/interview tools stay
963        // author-curated and other tools stay gated.
964        let mut spec = test_spec("restricted");
965        spec.tools = Some(vec![tools::CODE_SEARCH.to_string()]);
966        let active = ActivePrimaryAgent::from_spec(&spec);
967
968        assert!(primary_agent_allows_tool(&active, tools::TASK_TRACKER));
969        assert!(!primary_agent_allows_tool(&active, tools::START_PLANNING));
970        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
971    }
972
973    #[test]
974    fn task_tracker_bypass_survives_explicit_disallow_entry() {
975        // Mirrors the cleanup-tool guarantee: even a `disallowed_tools`
976        // entry must not silence progress reporting.
977        let mut spec = test_spec("restricted");
978        spec.tools = Some(vec![tools::CODE_SEARCH.to_string()]);
979        spec.disallowed_tools = vec![tools::TASK_TRACKER.to_string()];
980        let active = ActivePrimaryAgent::from_spec(&spec);
981
982        assert!(primary_agent_allows_tool(&active, tools::TASK_TRACKER));
983        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
984    }
985
986    #[test]
987    fn build_primary_agent_runtime_config_preserves_baseline_fields_and_merges_mcp() {
988        let mut parent = VTCodeConfig::default();
989        parent.agent.default_model = "parent-model".to_string();
990        parent.mcp.providers.push(
991            serde_json::from_value(json!({
992                "name": "global",
993                "command": "global-mcp",
994                "args": []
995            }))
996            .expect("global provider"),
997        );
998
999        let mut spec = test_spec("worker");
1000        spec.permissions = AgentPermissionsConfig::new(PermissionDefault::Auto);
1001        spec.model = Some("agent-model".to_string());
1002        spec.reasoning_effort = Some(ReasoningEffortLevel::Low);
1003        spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([
1004            (
1005                "global".to_string(),
1006                json!({
1007                    "type": "stdio",
1008                    "command": "duplicate-mcp"
1009                }),
1010            ),
1011            (
1012                "local".to_string(),
1013                json!({
1014                    "type": "stdio",
1015                    "command": "local-mcp"
1016                }),
1017            ),
1018        ]))];
1019        let active = ActivePrimaryAgent::from_spec(&spec);
1020
1021        let runtime = build_primary_agent_runtime_config(&parent, &active);
1022
1023        assert_eq!(runtime.agent.default_model, "agent-model");
1024        assert_eq!(runtime.agent.reasoning_effort, ReasoningEffortLevel::Low);
1025        assert_eq!(runtime.mcp.providers.len(), 2);
1026        assert_eq!(runtime.mcp.providers[0].name, "global");
1027        assert_eq!(runtime.mcp.providers[1].name, "local");
1028    }
1029
1030    #[test]
1031    fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_says_inherit() {
1032        let mut parent = VTCodeConfig::default();
1033        parent.agent.default_model = "parent-model".to_string();
1034
1035        let mut spec = test_spec("auto");
1036        spec.model = Some("inherit".to_string());
1037        let active = ActivePrimaryAgent::from_spec(&spec);
1038
1039        let runtime = build_primary_agent_runtime_config(&parent, &active);
1040
1041        assert_eq!(runtime.agent.default_model, "parent-model");
1042    }
1043
1044    #[test]
1045    fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_model_empty() {
1046        let mut parent = VTCodeConfig::default();
1047        parent.agent.default_model = "parent-model".to_string();
1048
1049        let mut spec = test_spec("auto");
1050        spec.model = Some(String::new());
1051        let active = ActivePrimaryAgent::from_spec(&spec);
1052
1053        let runtime = build_primary_agent_runtime_config(&parent, &active);
1054
1055        assert_eq!(runtime.agent.default_model, "parent-model");
1056    }
1057
1058    #[test]
1059    fn built_in_primary_agents_resolve_required_permission_policy() {
1060        let builtins = builtin_subagents();
1061
1062        for name in ["duck", "plan", "build", "auto"] {
1063            let active = resolve_primary_agent(&builtins, name)
1064                .unwrap_or_else(|_| panic!("missing built-in primary agent {name}"));
1065            assert_eq!(active.identity.name, name);
1066            let expected_default = match name {
1067                "build" => PermissionDefault::Ask,
1068                "auto" => PermissionDefault::Auto,
1069                "plan" | "duck" => PermissionDefault::Deny,
1070                _ => unreachable!("unexpected built-in primary agent"),
1071            };
1072            assert_eq!(active.permissions.default, expected_default);
1073        }
1074    }
1075
1076    #[test]
1077    fn active_primary_permissions_overlay_runtime_decisions() {
1078        let builtins = builtin_subagents();
1079        let mut state = ActivePrimaryAgentState::from_specs(&builtins);
1080        let config = VTCodeConfig::default();
1081        let workspace = TempDir::new().expect("workspace");
1082        let current_dir = workspace.path();
1083
1084        state.select_from_specs(&builtins, "auto").expect("auto primary");
1085        let exec = build_permission_request(
1086            workspace.path(),
1087            current_dir,
1088            tools::UNIFIED_EXEC,
1089            Some(&json!({"command": "cargo test"})),
1090        );
1091        assert_eq!(
1092            evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &exec,),
1093            ResolvedPermissionDecision::Auto
1094        );
1095
1096        state.select_from_specs(&builtins, "plan").expect("plan primary");
1097        let edit = build_permission_request(
1098            workspace.path(),
1099            current_dir,
1100            tools::UNIFIED_FILE,
1101            Some(&json!({"action": "edit", "path": "src/lib.rs"})),
1102        );
1103        assert_eq!(
1104            evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &edit,),
1105            ResolvedPermissionDecision::Deny
1106        );
1107    }
1108
1109    #[test]
1110    fn primary_agent_switching_changes_permission_policy_without_mutating_parent_config() {
1111        let builtins = builtin_subagents();
1112        let mut state = ActivePrimaryAgentState::from_specs(&builtins);
1113        let initial_model = state.active().model.clone();
1114        let initial_tools = state.active().tools.clone();
1115
1116        state.select_from_specs(&builtins, "auto").expect("auto primary");
1117        assert_eq!(state.active().identity.name, "auto");
1118        assert_eq!(state.active().permissions.default, PermissionDefault::Auto);
1119        assert_eq!(state.active().model, initial_model);
1120        assert_eq!(state.active().tools, initial_tools);
1121
1122        state.select_from_specs(&builtins, "duck").expect("duck primary");
1123        assert_eq!(state.active().identity.name, "duck");
1124        assert_eq!(state.active().permissions.default, PermissionDefault::Deny);
1125        assert_eq!(state.active().model, initial_model);
1126        assert_ne!(state.active().tools, initial_tools);
1127    }
1128
1129    #[test]
1130    fn primary_hook_config_merges_supported_main_session_events_after_global_hooks() {
1131        let mut global = HooksConfig::default();
1132        global.lifecycle.user_prompt_submit = vec![hook_group("global-user")];
1133        global.lifecycle.pre_tool_use = vec![hook_group("global-pre")];
1134        global.lifecycle.post_tool_use = vec![hook_group("global-post")];
1135        global.lifecycle.permission_request = vec![hook_group("global-permission")];
1136        global.lifecycle.pre_compact = vec![hook_group("global-compact")];
1137        global.lifecycle.stop = vec![hook_group("global-stop")];
1138        global.lifecycle.notification = vec![hook_group("global-notification")];
1139
1140        let mut primary_hooks = HooksConfig::default();
1141        primary_hooks.lifecycle.user_prompt_submit = vec![hook_group("primary-user")];
1142        primary_hooks.lifecycle.pre_tool_use = vec![hook_group("primary-pre")];
1143        primary_hooks.lifecycle.post_tool_use = vec![hook_group("primary-post")];
1144        primary_hooks.lifecycle.permission_request = vec![hook_group("primary-permission")];
1145        primary_hooks.lifecycle.pre_compact = vec![hook_group("primary-compact")];
1146        primary_hooks.lifecycle.stop = vec![hook_group("primary-stop")];
1147        primary_hooks.lifecycle.notification = vec![hook_group("primary-notification")];
1148
1149        let mut spec = test_spec("worker");
1150        spec.hooks = Some(primary_hooks);
1151        let active = ActivePrimaryAgent::from_spec(&spec);
1152
1153        let merged = build_primary_agent_hook_config(&global, &active);
1154
1155        assert_hook_commands(&merged.lifecycle.user_prompt_submit, &["global-user", "primary-user"]);
1156        assert_hook_commands(&merged.lifecycle.pre_tool_use, &["global-pre", "primary-pre"]);
1157        assert_hook_commands(&merged.lifecycle.post_tool_use, &["global-post", "primary-post"]);
1158        assert_hook_commands(&merged.lifecycle.permission_request, &["global-permission", "primary-permission"]);
1159        assert_hook_commands(&merged.lifecycle.pre_compact, &["global-compact", "primary-compact"]);
1160        assert_hook_commands(&merged.lifecycle.stop, &["global-stop", "primary-stop"]);
1161        assert_hook_commands(&merged.lifecycle.notification, &["global-notification", "primary-notification"]);
1162    }
1163
1164    #[test]
1165    fn primary_hook_config_merges_session_and_subagent_lifecycle_events() {
1166        let global = HooksConfig::default();
1167        let mut primary_hooks = HooksConfig::default();
1168        primary_hooks.lifecycle.session_start = vec![hook_group("primary-session-start")];
1169        primary_hooks.lifecycle.session_end = vec![hook_group("primary-session-end")];
1170        primary_hooks.lifecycle.subagent_start = vec![hook_group("primary-subagent-start")];
1171        primary_hooks.lifecycle.subagent_stop = vec![hook_group("primary-subagent-stop")];
1172        primary_hooks.lifecycle.task_completion = vec![hook_group("primary-task-completion")];
1173        primary_hooks.lifecycle.task_completed = vec![hook_group("primary-task-completed")];
1174
1175        let mut spec = test_spec("worker");
1176        spec.hooks = Some(primary_hooks);
1177        let active = ActivePrimaryAgent::from_spec(&spec);
1178
1179        let merged = build_primary_agent_hook_config(&global, &active);
1180
1181        assert_hook_commands(&merged.lifecycle.session_start, &["primary-session-start"]);
1182        assert_hook_commands(&merged.lifecycle.session_end, &["primary-session-end"]);
1183        assert_hook_commands(&merged.lifecycle.subagent_start, &["primary-subagent-start"]);
1184        assert_hook_commands(&merged.lifecycle.subagent_stop, &["primary-subagent-stop"]);
1185        assert!(merged.lifecycle.task_completion.is_empty());
1186        assert!(merged.lifecycle.task_completed.is_empty());
1187        assert!(merged.lifecycle.stop.is_empty());
1188    }
1189
1190    #[test]
1191    fn primary_hook_config_recomputes_without_previous_primary_leakage() {
1192        let global = HooksConfig::default();
1193        let mut first_hooks = HooksConfig::default();
1194        first_hooks.lifecycle.pre_tool_use = vec![hook_group("first-pre")];
1195        let mut second_hooks = HooksConfig::default();
1196        second_hooks.lifecycle.pre_tool_use = vec![hook_group("second-pre")];
1197
1198        let mut first = test_spec("first");
1199        first.hooks = Some(first_hooks);
1200        let mut second = test_spec("second");
1201        second.hooks = Some(second_hooks);
1202        let specs = vec![first, second];
1203        let mut state = ActivePrimaryAgentState::default();
1204
1205        state.select_from_specs(&specs, "first").expect("selected first");
1206        let first_config = build_primary_agent_hook_config(&global, state.active());
1207        assert_hook_commands(&first_config.lifecycle.pre_tool_use, &["first-pre"]);
1208
1209        state.select_from_specs(&specs, "second").expect("selected second");
1210        let second_config = build_primary_agent_hook_config(&global, state.active());
1211        assert_hook_commands(&second_config.lifecycle.pre_tool_use, &["second-pre"]);
1212    }
1213
1214    #[test]
1215    fn active_primary_state_recomputes_skills_mcp_and_metadata_on_switch() {
1216        let mut first = test_spec("first");
1217        first.description = "First metadata".to_string();
1218        first.color = Some("red".to_string());
1219        first.aliases = vec!["one".to_string()];
1220        first.skills = vec!["rust".to_string()];
1221        first.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
1222            "first-mcp".to_string(),
1223            json!({
1224                "type": "stdio",
1225                "command": "first-mcp"
1226            }),
1227        )]))];
1228        let second = test_spec("second");
1229        let specs = vec![first, second];
1230        let mut state = ActivePrimaryAgentState::default();
1231
1232        state.select_from_specs(&specs, "one").expect("selected first by alias");
1233        assert_eq!(state.active().identity.name, "first");
1234        assert_eq!(state.active().description, "First metadata");
1235        assert_eq!(state.active().color.as_deref(), Some("red"));
1236        assert_eq!(state.active().aliases, vec!["one".to_string()]);
1237        assert_eq!(state.active().skills, vec!["rust".to_string()]);
1238        assert_eq!(state.active().mcp_servers.len(), 1);
1239
1240        state.select_from_specs(&specs, "second").expect("selected second");
1241        assert_eq!(state.active().identity.name, "second");
1242        assert_eq!(state.active().description, "second description");
1243        assert_eq!(state.active().color.as_deref(), Some("blue"));
1244        assert!(state.active().aliases.is_empty());
1245        assert!(state.active().skills.is_empty());
1246        assert!(state.active().mcp_servers.is_empty());
1247    }
1248
1249    fn test_spec(name: &str) -> SubagentSpec {
1250        SubagentSpec {
1251            name: name.to_string(),
1252            description: format!("{name} description"),
1253            prompt: format!("{name} instructions"),
1254            tools: Some(vec!["code_search".to_string()]),
1255            disallowed_tools: vec!["file_operation".to_string()],
1256            model: Some("gpt-5.1".to_string()),
1257            color: Some("blue".to_string()),
1258            reasoning_effort: Some(ReasoningEffortLevel::High),
1259            permissions: AgentPermissionsConfig::new(PermissionDefault::Deny),
1260            skills: Vec::new(),
1261            mcp_servers: Vec::new(),
1262            hooks: None,
1263            background: false,
1264            mode: vtcode_config::AgentMode::Primary,
1265            max_turns: None,
1266            nickname_candidates: Vec::new(),
1267            initial_prompt: None,
1268            memory: None,
1269            isolation: None,
1270            aliases: Vec::new(),
1271            source: SubagentSource::ProjectVtcode,
1272            file_path: None,
1273            warnings: Vec::new(),
1274            tool_policy_overrides: std::collections::BTreeMap::new(),
1275        }
1276    }
1277
1278    fn hook_group(command: &str) -> HookGroupConfig {
1279        HookGroupConfig {
1280            matcher: None,
1281            hooks: vec![HookCommandConfig {
1282                command: command.to_string(),
1283                ..HookCommandConfig::default()
1284            }],
1285        }
1286    }
1287
1288    fn assert_hook_commands(groups: &[HookGroupConfig], expected: &[&str]) {
1289        let commands = groups
1290            .iter()
1291            .flat_map(|group| group.hooks.iter())
1292            .map(|hook| hook.command.as_str())
1293            .collect::<Vec<_>>();
1294        assert_eq!(commands, expected);
1295    }
1296}