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, SubagentMcpServer, SubagentMemoryScope, SubagentSource,
12    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, inline_mcp_provider};
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
439#[cfg(test)]
440mod tests {
441    use std::collections::BTreeMap;
442
443    use serde_json::json;
444    use tempfile::TempDir;
445    use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
446    use vtcode_config::{
447        HookCommandConfig, HooksConfig, IsolationMode, SubagentDiscoveryInput, SubagentMcpServer, SubagentMemoryScope,
448        SubagentSource, builtin_plan_agent, builtin_primary_auto_agent, builtin_primary_duck_agent, builtin_subagents,
449        discover_subagents,
450    };
451
452    use crate::config::constants::tools;
453    use crate::permissions::{ResolvedPermissionDecision, build_permission_request};
454
455    use super::*;
456
457    #[test]
458    fn resolves_existing_spec_by_name() {
459        let spec = test_spec("planner");
460        let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
461
462        assert_eq!(active.identity.name, "planner");
463        assert_eq!(active.display_name, "planner");
464        assert_eq!(active.description, "planner description");
465        assert_eq!(active.color.as_deref(), Some("blue"));
466        assert_eq!(active.instructions, "planner instructions");
467        assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
468        assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
469        assert_eq!(active.permissions.default, PermissionDefault::Deny);
470        assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
471        assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
472        assert!(active.hooks.is_none());
473    }
474
475    #[test]
476    fn unknown_agent_error_preserves_current_active_agent() {
477        let current = test_spec("current");
478        let specs = vec![current.clone()];
479        let mut state = ActivePrimaryAgentState::default();
480        let original = state.select_from_specs(&specs, "current").expect("initial selection").clone();
481
482        let error = state.select_from_specs(&specs, "missing").expect_err("unknown agent");
483
484        assert_eq!(error, PrimaryAgentResolutionError::UnknownAgent { requested: "missing".to_string() });
485        assert_eq!(state.active(), &original);
486    }
487
488    #[test]
489    fn restore_snapshot_returns_to_write_capable_build_agent() {
490        let mut state = ActivePrimaryAgentState::default();
491        let build_snapshot = state.active().clone();
492        state
493            .select_from_specs(&[test_spec("duck")], "duck")
494            .expect("select leaked agent");
495        assert_eq!(state.active().identity.name, "duck");
496
497        state.restore_snapshot(build_snapshot);
498
499        assert_eq!(state.active().identity.name, "build");
500    }
501
502    #[test]
503    fn alias_resolution_uses_existing_matches_name_semantics() {
504        let mut spec = test_spec("reviewer");
505        spec.aliases = vec!["critic".to_string()];
506
507        let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
508
509        assert_eq!(active.identity.name, "reviewer");
510        assert_eq!(active.display_name, "reviewer");
511    }
512
513    #[test]
514    fn exact_name_resolution_wins_over_alias() {
515        let mut build = test_spec("build");
516        build.aliases = vec!["builder".to_string()];
517        let builder = test_spec("builder");
518
519        let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
520
521        assert_eq!(active.identity.name, "builder");
522    }
523
524    #[test]
525    fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
526        let mut spec = test_spec("worker");
527        spec.aliases = vec!["builder".to_string()];
528        spec.skills = vec!["rust".to_string()];
529        spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
530        spec.background = true;
531        spec.max_turns = Some(12);
532        spec.nickname_candidates = vec!["w".to_string()];
533        spec.initial_prompt = Some("start here".to_string());
534        spec.memory = Some(SubagentMemoryScope::Project);
535        spec.isolation = Some(IsolationMode::Full);
536
537        let active = ActivePrimaryAgent::from_spec(&spec);
538
539        assert_eq!(active.identity.name, "worker");
540        assert_eq!(active.display_name, "worker");
541        assert_eq!(active.description, "worker description");
542        assert_eq!(active.color.as_deref(), Some("blue"));
543        assert_eq!(active.aliases, vec!["builder".to_string()]);
544        assert_eq!(active.instructions, "worker instructions");
545        assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
546        assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
547        assert_eq!(active.permissions.default, PermissionDefault::Deny);
548        assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
549        assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
550        assert_eq!(active.skills, vec!["rust".to_string()]);
551        assert_eq!(active.mcp_servers, vec![SubagentMcpServer::Named("filesystem".to_string())]);
552        assert_eq!(active.memory, Some(SubagentMemoryScope::Project));
553    }
554
555    #[test]
556    fn primary_runtime_adapter_uses_shared_resolved_view_for_overlapping_fields() {
557        let mut spec = test_spec("worker");
558        spec.description = "Worker display metadata".to_string();
559        spec.color = Some("green".to_string());
560        spec.aliases = vec!["builder".to_string()];
561        spec.skills = vec!["rust".to_string(), "repo".to_string()];
562        spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
563        spec.hooks = Some(HooksConfig::default());
564        spec.memory = Some(SubagentMemoryScope::Project);
565
566        let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
567        let active = ActivePrimaryAgent::from_runtime_view(&runtime);
568
569        assert_eq!(runtime.canonical_name, "worker");
570        assert_eq!(runtime.display_name, "worker");
571        assert_eq!(runtime.description, "Worker display metadata");
572        assert_eq!(runtime.color.as_deref(), Some("green"));
573        assert_eq!(runtime.aliases, vec!["builder".to_string()]);
574        assert_eq!(runtime.skills, vec!["rust".to_string(), "repo".to_string()]);
575        assert_eq!(runtime.mcp_servers.len(), 1);
576        assert!(runtime.hooks.is_some());
577        assert_eq!(runtime.memory, Some(SubagentMemoryScope::Project));
578        assert!(runtime.read_only);
579        assert_eq!(active.identity.name, runtime.canonical_name);
580        assert_eq!(active.display_name, runtime.display_name);
581        assert_eq!(active.description, runtime.description);
582        assert_eq!(active.color, runtime.color);
583        assert_eq!(active.aliases, runtime.aliases);
584        assert_eq!(active.instructions, runtime.instructions);
585        assert_eq!(active.tools, runtime.tools);
586        assert_eq!(active.disallowed_tools, runtime.disallowed_tools);
587        assert_eq!(active.permissions, runtime.permissions);
588        assert_eq!(active.model, runtime.model);
589        assert_eq!(active.reasoning_effort, runtime.reasoning_effort);
590        assert_eq!(active.hooks, runtime.hooks);
591        assert_eq!(active.skills, runtime.skills);
592        assert_eq!(active.mcp_servers, runtime.mcp_servers);
593        assert_eq!(active.memory, runtime.memory);
594    }
595
596    #[test]
597    fn default_state_uses_builtin_build_agent() {
598        let mut state = ActivePrimaryAgentState::default();
599
600        assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
601        assert_eq!(state.active().identity.name, "build");
602        assert_eq!(state.active().identity.source, SubagentSource::Builtin);
603
604        state.select_from_specs(&[test_spec("worker")], "worker").expect("selected");
605        assert_eq!(state.active().identity.name, "worker");
606
607        state.reset_to_default_from_specs(&[]);
608
609        assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
610        assert_eq!(state.active().identity.name, "build");
611        assert_eq!(state.active().identity.source, SubagentSource::Builtin);
612    }
613
614    #[test]
615    fn from_specs_falls_back_to_builtin_build_agent() {
616        let active = ActivePrimaryAgentState::from_specs(&[]);
617
618        assert_eq!(active.active().identity.name, "build");
619        assert_eq!(active.active().identity.source, SubagentSource::Builtin);
620    }
621
622    #[test]
623    fn from_specs_with_default_selects_configured_primary_agent() {
624        let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "builder");
625
626        assert_eq!(active.active().identity.name, "builder");
627    }
628
629    #[test]
630    fn from_specs_with_default_falls_back_to_build_for_missing_configured_agent() {
631        let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "missing");
632
633        assert_eq!(active.active().identity.name, "build");
634        assert_eq!(active.active().identity.source, SubagentSource::Builtin);
635    }
636
637    #[test]
638    fn fallback_notice_silent_for_build_and_empty() {
639        // The built-in `build` agent is the canonical default, so falling back
640        // to it when the operator asked for `build` (or nothing) is expected.
641        assert_eq!(fallback_notice("build"), None);
642        assert_eq!(fallback_notice("BUILD"), None);
643        assert_eq!(fallback_notice("Build"), None);
644        assert_eq!(fallback_notice(""), None);
645        assert_eq!(fallback_notice("   "), None);
646    }
647
648    #[test]
649    fn fallback_notice_surfaces_non_default_mismatches() {
650        // Any other requested name that misses discovery is a configuration
651        // mismatch the operator should be warned about.
652        assert_eq!(fallback_notice("duck"), Some("duck"));
653        assert_eq!(fallback_notice("auto"), Some("auto"));
654        assert_eq!(fallback_notice("  duck  "), Some("duck"));
655    }
656
657    #[test]
658    fn discovery_precedence_overrides_builtin_build_agent() {
659        let temp = TempDir::new().expect("tempdir");
660        let discovered = discover_subagents(&SubagentDiscoveryInput {
661            workspace_root: temp.path().to_path_buf(),
662            cli_agents: Some(json!({
663                "build": {
664                    "description": "CLI build",
665                    "prompt": "cli build instructions",
666                    "model": "gpt-cli",
667                    "mode": "primary",
668                    "permissions": { "default": "ask" }
669                }
670            })),
671            plugin_agent_files: Vec::new(),
672            include_user_agents: false,
673        })
674        .expect("discovered subagents");
675
676        let active = ActivePrimaryAgentState::from_discovery(&discovered);
677
678        assert_eq!(active.active().identity.name, "build");
679        assert_eq!(active.active().identity.source, SubagentSource::Cli);
680        assert_eq!(active.active().instructions, "cli build instructions");
681        assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
682    }
683
684    #[test]
685    fn default_build_agent_allows_baseline_read_and_exec_tools() {
686        let active = ActivePrimaryAgentState::default();
687
688        assert!(primary_agent_allows_tool(active.active(), tools::CODE_SEARCH));
689        assert!(primary_agent_allows_tool(active.active(), tools::EXEC_COMMAND));
690        assert!(primary_agent_allows_tool(active.active(), tools::APPLY_PATCH));
691        assert!(primary_agent_allows_tool(active.active(), tools::RUN_PTY_CMD));
692        assert_eq!(active.active().permissions.default, PermissionDefault::Ask);
693    }
694
695    #[test]
696    fn build_and_auto_expose_the_same_tools_and_policy_decisions() {
697        let build = ActivePrimaryAgent::from_spec(&builtin_primary_build_agent());
698        let auto = ActivePrimaryAgent::from_spec(&builtin_primary_auto_agent());
699        let tools_to_compare = [
700            tools::CODE_SEARCH,
701            tools::EXEC_COMMAND,
702            tools::APPLY_PATCH,
703            tools::RUN_PTY_CMD,
704            tools::WRITE_FILE,
705            tools::REQUEST_USER_INPUT,
706        ];
707
708        for tool in tools_to_compare {
709            assert_eq!(
710                primary_agent_allows_tool(&build, tool),
711                primary_agent_allows_tool(&auto, tool),
712                "Build and Auto must expose the same capability for {tool}"
713            );
714        }
715    }
716
717    #[test]
718    fn readonly_primaries_expose_public_read_tools_and_block_apply_patch() {
719        for spec in [builtin_primary_duck_agent(), builtin_plan_agent()] {
720            let name = spec.name.clone();
721            let active = ActivePrimaryAgent::from_spec(&spec);
722
723            assert!(primary_agent_allows_tool(&active, tools::CODE_SEARCH), "{name} should expose code_search");
724            assert!(primary_agent_allows_tool(&active, tools::EXEC_COMMAND), "{name} should expose exec_command");
725            assert!(primary_agent_allows_tool(&active, tools::REQUEST_USER_INPUT));
726            assert!(primary_agent_allows_tool(&active, tools::RECORD_DECISION));
727
728            assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH), "{name} must not expose apply_patch");
729            assert!(!primary_agent_allows_tool(&active, tools::RUN_PTY_CMD), "{name} must not expose run_pty_cmd");
730            assert!(!primary_agent_allows_tool(&active, tools::WRITE_FILE), "{name} must not expose write_file");
731        }
732    }
733
734    #[test]
735    fn tool_policy_intersects_allow_list_then_applies_deny_list() {
736        let mut spec = test_spec("worker");
737        spec.tools = Some(vec![" \tCoDe_SeArCh\n".to_string(), "\u{2003}ExEc_CoMmAnD\u{a0}".to_string()]);
738        spec.disallowed_tools = vec!["\u{a0}CODE_search\u{2003}".to_string()];
739        let active = ActivePrimaryAgent::from_spec(&spec);
740
741        assert!(primary_agent_allows_tool(&active, "\u{a0}EXEC_command\u{2003}"));
742        assert!(!primary_agent_allows_tool(&active, " \tcode_SEARCH\n"));
743        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
744        assert!(!primary_agent_allows_tool(&active, "exec_command_extra"));
745        assert!(!primary_agent_allows_tool(&active, tools::RUN_PTY_CMD));
746    }
747
748    #[test]
749    fn absent_tool_allow_list_allows_names_except_deny_matches() {
750        let mut spec = test_spec("worker");
751        spec.tools = None;
752        spec.disallowed_tools = vec![" \tCoDe_SeArCh\n".to_string()];
753        let active = ActivePrimaryAgent::from_spec(&spec);
754
755        assert!(primary_agent_allows_tool(&active, " ExEc_CoMmAnD "));
756        assert!(primary_agent_allows_tool(&active, "custom_tool"));
757        assert!(!primary_agent_allows_tool(&active, " CODE_search "));
758    }
759
760    #[test]
761    fn tool_policy_preserves_empty_name_matching() {
762        let mut spec = test_spec("worker");
763        spec.tools = None;
764        spec.disallowed_tools.clear();
765        let mut active = ActivePrimaryAgent::from_spec(&spec);
766
767        assert!(primary_agent_allows_tool(&active, ""));
768        assert!(primary_agent_allows_tool(&active, "\u{2003}\t\u{a0}"));
769        active.tools = Some(Vec::new());
770        assert!(!primary_agent_allows_tool(&active, ""));
771        assert!(!primary_agent_allows_tool(&active, "\u{2003}\t\u{a0}"));
772        active.tools = Some(vec!["\u{a0} \t".to_string()]);
773        assert!(primary_agent_allows_tool(&active, ""));
774        assert!(primary_agent_allows_tool(&active, "\u{2003}"));
775        assert!(!primary_agent_allows_tool(&active, "custom_tool"));
776        active.disallowed_tools = vec![String::new()];
777        assert!(!primary_agent_allows_tool(&active, ""));
778        assert!(!primary_agent_allows_tool(&active, "\u{2003}"));
779    }
780
781    #[test]
782    fn tool_policy_keeps_non_ascii_case_and_non_whitespace_distinct() {
783        let mut spec = test_spec("worker");
784        spec.tools = Some(vec!["\u{2003}CAFÉ\u{a0}".to_string()]);
785        spec.disallowed_tools = vec![" café ".to_string()];
786        let mut active = ActivePrimaryAgent::from_spec(&spec);
787
788        assert!(primary_agent_allows_tool(&active, " caFÉ "));
789        assert!(!primary_agent_allows_tool(&active, "café"));
790        assert!(!primary_agent_allows_tool(&active, "CAFE"));
791        assert!(!primary_agent_allows_tool(&active, "\u{200b}CAFÉ\u{200b}"));
792
793        active.tools = None;
794        assert!(primary_agent_allows_tool(&active, "CAFÉ"));
795        assert!(!primary_agent_allows_tool(&active, "CaFé"));
796    }
797
798    #[test]
799    fn mixed_case_cleanup_and_tracker_names_bypass_empty_allow_list_and_explicit_denials() {
800        let mut spec = test_spec("restricted");
801        spec.tools = Some(Vec::new());
802        spec.disallowed_tools = vec![
803            " Wait_AGENT ".to_string(),
804            "\u{2003}Close_Agent\u{a0}".to_string(),
805            "\tTask_TRACKER\n".to_string(),
806        ];
807        let active = ActivePrimaryAgent::from_spec(&spec);
808
809        assert!(primary_agent_allows_tool(&active, "\u{a0}WAIT_agent\u{2003}"));
810        assert!(primary_agent_allows_tool(&active, " Close_AGENT "));
811        assert!(primary_agent_allows_tool(&active, " TaSk_TrAcKeR "));
812        assert!(!primary_agent_allows_tool(&active, " Agent "));
813        assert!(!primary_agent_allows_tool(&active, " Spawn_AGENT "));
814        assert!(!primary_agent_allows_tool(&active, "task_tracker_extra"));
815        assert!(!primary_agent_allows_tool(&active, "wait_agent_extra"));
816    }
817
818    #[test]
819    fn filtered_tool_catalog_preserves_surviving_names_and_order() {
820        let mut spec = test_spec("worker");
821        spec.tools = Some(vec![
822            " APPLY_patch ".to_string(),
823            " EXEC_command ".to_string(),
824            " CODE_search ".to_string(),
825        ]);
826        spec.disallowed_tools = vec![" code_SEARCH ".to_string()];
827        let active = ActivePrimaryAgent::from_spec(&spec);
828        let catalog = Arc::new(
829            [
830                "Exec_Command",
831                "code_search",
832                "WaIt_AgEnT",
833                "apply_patch",
834                "TASK_TRACKER",
835                "run_pty_cmd",
836                "agent",
837            ]
838            .into_iter()
839            .map(|name| ToolDefinition::function(name.to_string(), String::new(), json!({"type": "object"})))
840            .collect(),
841        );
842
843        let filtered = apply_primary_agent_tool_policy(Some(Arc::clone(&catalog)), &active).expect("surviving tools");
844
845        assert!(!Arc::ptr_eq(&filtered, &catalog));
846        assert_eq!(
847            filtered.iter().map(ToolDefinition::function_name).collect::<Vec<_>>(),
848            ["Exec_Command", "WaIt_AgEnT", "apply_patch", "TASK_TRACKER"]
849        );
850        assert_eq!(catalog.len(), 7);
851        assert_eq!(catalog[1].function_name(), "code_search");
852    }
853
854    #[test]
855    fn unchanged_tool_catalog_reuses_shared_definitions() {
856        let mut spec = test_spec("worker");
857        spec.tools = None;
858        spec.disallowed_tools.clear();
859        let active = ActivePrimaryAgent::from_spec(&spec);
860        let catalog = Arc::new(vec![ToolDefinition::function(
861            "exec_command".to_string(),
862            "Run a command".to_string(),
863            json!({"type": "object", "properties": {"cmd": {"type": "string"}}}),
864        )]);
865
866        let filtered = apply_primary_agent_tool_policy(Some(Arc::clone(&catalog)), &active).expect("unchanged catalog");
867
868        assert!(Arc::ptr_eq(&filtered, &catalog));
869        assert_eq!(filtered[0].function_name(), "exec_command");
870    }
871
872    #[test]
873    fn tool_policy_returns_none_for_absent_empty_or_fully_denied_catalogs() {
874        let active = ActivePrimaryAgent::from_spec(&test_spec("worker"));
875        assert!(apply_primary_agent_tool_policy(None, &active).is_none());
876        assert!(apply_primary_agent_tool_policy(Some(Arc::new(Vec::new())), &active).is_none());
877        let denied = Arc::new(vec![ToolDefinition::function(
878            "apply_patch".to_string(),
879            String::new(),
880            json!({"type": "object"}),
881        )]);
882        assert!(apply_primary_agent_tool_policy(Some(denied), &active).is_none());
883    }
884
885    #[test]
886    fn empty_present_tool_allow_list_exposes_no_tools() {
887        let mut spec = test_spec("worker");
888        spec.tools = Some(Vec::new());
889        spec.disallowed_tools = Vec::new();
890        let active = ActivePrimaryAgent::from_spec(&spec);
891
892        assert!(!primary_agent_allows_tool(&active, tools::CODE_SEARCH));
893    }
894
895    // NOTE: `spawn_agent`/`wait_agent`/`close_agent`/etc. are legacy granular
896    // subagent-lifecycle tool names. Since wait/close were folded into the
897    // unified `agent` tool (action='wait'/'close'), these granular names are
898    // never actually emitted to the model — the only subagent tool name that
899    // ever reaches `apply_primary_agent_tool_policy`'s runtime filtering is
900    // `tools::AGENT` ("agent"). The assertions below against the granular
901    // names are legacy-taxonomy coverage of the `primary_agent_allows_tool`/
902    // `is_subagent_cleanup_tool` predicates directly (useful if a caller ever
903    // passes a legacy name explicitly), NOT a test of the runtime filtering
904    // path exercised by `apply_primary_agent_tool_policy`. The final
905    // assertion covers that real runtime path: with an allow-list that
906    // excludes "agent", the whole folded tool is filtered out, and there is
907    // currently no way to allow cleanup (wait/close) while denying spawn for
908    // the emitted "agent" tool (see `apply_primary_agent_tool_policy`'s doc
909    // comment for the deferred execution-time action-gating fix).
910    #[test]
911    fn subagent_cleanup_tools_bypass_policy_but_new_work_obeys_policy() {
912        let mut spec = test_spec("restricted");
913        spec.tools = Some(vec!["code_search".to_string()]);
914        spec.disallowed_tools = vec![
915            tools::SPAWN_AGENT.to_string(),
916            tools::WAIT_AGENT.to_string(),
917            tools::CLOSE_AGENT.to_string(),
918            tools::SEND_INPUT.to_string(),
919            tools::RESUME_AGENT.to_string(),
920            tools::SPAWN_BACKGROUND_SUBPROCESS.to_string(),
921        ];
922        let active = ActivePrimaryAgent::from_spec(&spec);
923
924        // Non-cleanup tools respect the policy.
925        assert!(!primary_agent_allows_tool(&active, "command_session"));
926        assert!(!primary_agent_allows_tool(&active, "file_operation"));
927        assert!(!primary_agent_allows_tool(&active, tools::SPAWN_AGENT));
928        assert!(!primary_agent_allows_tool(&active, tools::SPAWN_BACKGROUND_SUBPROCESS));
929        assert!(!primary_agent_allows_tool(&active, tools::SEND_INPUT));
930        assert!(!primary_agent_allows_tool(&active, tools::RESUME_AGENT));
931
932        // Cleanup tools remain available even when listed in disallowed_tools
933        // and absent from the allow list.
934        assert!(primary_agent_allows_tool(&active, tools::WAIT_AGENT));
935        assert!(primary_agent_allows_tool(&active, tools::CLOSE_AGENT));
936
937        // Runtime reality: the allow list excludes "agent" (the only name
938        // ever emitted for subagent tools post-fold), so the whole tool is
939        // filtered out regardless of the cleanup carve-out above.
940        assert!(!primary_agent_allows_tool(&active, tools::AGENT));
941    }
942
943    #[test]
944    fn task_tracker_bypasses_restricted_tool_allow_list_but_mode_tools_obey_it() {
945        // A custom primary with an explicit `tools:` list omitting the
946        // tracker must still expose it, while planning/interview tools stay
947        // author-curated and other tools stay gated.
948        let mut spec = test_spec("restricted");
949        spec.tools = Some(vec![tools::CODE_SEARCH.to_string()]);
950        let active = ActivePrimaryAgent::from_spec(&spec);
951
952        assert!(primary_agent_allows_tool(&active, tools::TASK_TRACKER));
953        assert!(!primary_agent_allows_tool(&active, tools::START_PLANNING));
954        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
955    }
956
957    #[test]
958    fn task_tracker_bypass_survives_explicit_disallow_entry() {
959        // Mirrors the cleanup-tool guarantee: even a `disallowed_tools`
960        // entry must not silence progress reporting.
961        let mut spec = test_spec("restricted");
962        spec.tools = Some(vec![tools::CODE_SEARCH.to_string()]);
963        spec.disallowed_tools = vec![tools::TASK_TRACKER.to_string()];
964        let active = ActivePrimaryAgent::from_spec(&spec);
965
966        assert!(primary_agent_allows_tool(&active, tools::TASK_TRACKER));
967        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
968    }
969
970    #[test]
971    fn build_primary_agent_runtime_config_preserves_baseline_fields_and_merges_mcp() {
972        let mut parent = VTCodeConfig::default();
973        parent.agent.default_model = "parent-model".to_string();
974        parent.mcp.providers.push(
975            serde_json::from_value(json!({
976                "name": "global",
977                "command": "global-mcp",
978                "args": []
979            }))
980            .expect("global provider"),
981        );
982
983        let mut spec = test_spec("worker");
984        spec.permissions = AgentPermissionsConfig::new(PermissionDefault::Auto);
985        spec.model = Some("agent-model".to_string());
986        spec.reasoning_effort = Some(ReasoningEffortLevel::Low);
987        spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([
988            (
989                "global".to_string(),
990                json!({
991                    "type": "stdio",
992                    "command": "duplicate-mcp"
993                }),
994            ),
995            (
996                "local".to_string(),
997                json!({
998                    "type": "stdio",
999                    "command": "local-mcp"
1000                }),
1001            ),
1002        ]))];
1003        let active = ActivePrimaryAgent::from_spec(&spec);
1004
1005        let runtime = build_primary_agent_runtime_config(&parent, &active);
1006
1007        assert_eq!(runtime.agent.default_model, "agent-model");
1008        assert_eq!(runtime.agent.reasoning_effort, ReasoningEffortLevel::Low);
1009        assert_eq!(runtime.mcp.providers.len(), 2);
1010        assert_eq!(runtime.mcp.providers[0].name, "global");
1011        assert_eq!(runtime.mcp.providers[1].name, "local");
1012        let providers = serde_json::to_value(&runtime.mcp.providers).expect("serialize providers");
1013        assert_eq!(providers[0]["command"], "global-mcp");
1014        assert_eq!(providers[1]["command"], "local-mcp");
1015        assert_eq!(providers[1]["args"], json!([]));
1016    }
1017
1018    #[test]
1019    fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_says_inherit() {
1020        let mut parent = VTCodeConfig::default();
1021        parent.agent.default_model = "parent-model".to_string();
1022
1023        let mut spec = test_spec("auto");
1024        spec.model = Some("inherit".to_string());
1025        let active = ActivePrimaryAgent::from_spec(&spec);
1026
1027        let runtime = build_primary_agent_runtime_config(&parent, &active);
1028
1029        assert_eq!(runtime.agent.default_model, "parent-model");
1030    }
1031
1032    #[test]
1033    fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_model_empty() {
1034        let mut parent = VTCodeConfig::default();
1035        parent.agent.default_model = "parent-model".to_string();
1036
1037        let mut spec = test_spec("auto");
1038        spec.model = Some(String::new());
1039        let active = ActivePrimaryAgent::from_spec(&spec);
1040
1041        let runtime = build_primary_agent_runtime_config(&parent, &active);
1042
1043        assert_eq!(runtime.agent.default_model, "parent-model");
1044    }
1045
1046    #[test]
1047    fn built_in_primary_agents_resolve_required_permission_policy() {
1048        let builtins = builtin_subagents();
1049
1050        for name in ["duck", "plan", "build", "auto"] {
1051            let active = resolve_primary_agent(&builtins, name)
1052                .unwrap_or_else(|_| panic!("missing built-in primary agent {name}"));
1053            assert_eq!(active.identity.name, name);
1054            let expected_default = match name {
1055                "build" => PermissionDefault::Ask,
1056                "auto" => PermissionDefault::Auto,
1057                "plan" | "duck" => PermissionDefault::Deny,
1058                _ => unreachable!("unexpected built-in primary agent"),
1059            };
1060            assert_eq!(active.permissions.default, expected_default);
1061        }
1062    }
1063
1064    #[test]
1065    fn active_primary_permissions_overlay_runtime_decisions() {
1066        let builtins = builtin_subagents();
1067        let mut state = ActivePrimaryAgentState::from_specs(&builtins);
1068        let config = VTCodeConfig::default();
1069        let workspace = TempDir::new().expect("workspace");
1070        let current_dir = workspace.path();
1071
1072        state.select_from_specs(&builtins, "auto").expect("auto primary");
1073        let exec = build_permission_request(
1074            workspace.path(),
1075            current_dir,
1076            tools::UNIFIED_EXEC,
1077            Some(&json!({"command": "cargo test"})),
1078        );
1079        assert_eq!(
1080            evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &exec,),
1081            ResolvedPermissionDecision::Auto
1082        );
1083
1084        state.select_from_specs(&builtins, "plan").expect("plan primary");
1085        let edit = build_permission_request(
1086            workspace.path(),
1087            current_dir,
1088            tools::UNIFIED_FILE,
1089            Some(&json!({"action": "edit", "path": "src/lib.rs"})),
1090        );
1091        assert_eq!(
1092            evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &edit,),
1093            ResolvedPermissionDecision::Deny
1094        );
1095    }
1096
1097    #[test]
1098    fn primary_agent_switching_changes_permission_policy_without_mutating_parent_config() {
1099        let builtins = builtin_subagents();
1100        let mut state = ActivePrimaryAgentState::from_specs(&builtins);
1101        let initial_model = state.active().model.clone();
1102        let initial_tools = state.active().tools.clone();
1103
1104        state.select_from_specs(&builtins, "auto").expect("auto primary");
1105        assert_eq!(state.active().identity.name, "auto");
1106        assert_eq!(state.active().permissions.default, PermissionDefault::Auto);
1107        assert_eq!(state.active().model, initial_model);
1108        assert_eq!(state.active().tools, initial_tools);
1109
1110        state.select_from_specs(&builtins, "duck").expect("duck primary");
1111        assert_eq!(state.active().identity.name, "duck");
1112        assert_eq!(state.active().permissions.default, PermissionDefault::Deny);
1113        assert_eq!(state.active().model, initial_model);
1114        assert_ne!(state.active().tools, initial_tools);
1115    }
1116
1117    #[test]
1118    fn primary_hook_config_merges_supported_main_session_events_after_global_hooks() {
1119        let mut global = HooksConfig::default();
1120        global.lifecycle.user_prompt_submit = vec![hook_group("global-user")];
1121        global.lifecycle.pre_tool_use = vec![hook_group("global-pre")];
1122        global.lifecycle.post_tool_use = vec![hook_group("global-post")];
1123        global.lifecycle.permission_request = vec![hook_group("global-permission")];
1124        global.lifecycle.pre_compact = vec![hook_group("global-compact")];
1125        global.lifecycle.stop = vec![hook_group("global-stop")];
1126        global.lifecycle.notification = vec![hook_group("global-notification")];
1127
1128        let mut primary_hooks = HooksConfig::default();
1129        primary_hooks.lifecycle.user_prompt_submit = vec![hook_group("primary-user")];
1130        primary_hooks.lifecycle.pre_tool_use = vec![hook_group("primary-pre")];
1131        primary_hooks.lifecycle.post_tool_use = vec![hook_group("primary-post")];
1132        primary_hooks.lifecycle.permission_request = vec![hook_group("primary-permission")];
1133        primary_hooks.lifecycle.pre_compact = vec![hook_group("primary-compact")];
1134        primary_hooks.lifecycle.stop = vec![hook_group("primary-stop")];
1135        primary_hooks.lifecycle.notification = vec![hook_group("primary-notification")];
1136
1137        let mut spec = test_spec("worker");
1138        spec.hooks = Some(primary_hooks);
1139        let active = ActivePrimaryAgent::from_spec(&spec);
1140
1141        let merged = build_primary_agent_hook_config(&global, &active);
1142
1143        assert_hook_commands(&merged.lifecycle.user_prompt_submit, &["global-user", "primary-user"]);
1144        assert_hook_commands(&merged.lifecycle.pre_tool_use, &["global-pre", "primary-pre"]);
1145        assert_hook_commands(&merged.lifecycle.post_tool_use, &["global-post", "primary-post"]);
1146        assert_hook_commands(&merged.lifecycle.permission_request, &["global-permission", "primary-permission"]);
1147        assert_hook_commands(&merged.lifecycle.pre_compact, &["global-compact", "primary-compact"]);
1148        assert_hook_commands(&merged.lifecycle.stop, &["global-stop", "primary-stop"]);
1149        assert_hook_commands(&merged.lifecycle.notification, &["global-notification", "primary-notification"]);
1150    }
1151
1152    #[test]
1153    fn primary_hook_config_merges_session_and_subagent_lifecycle_events() {
1154        let global = HooksConfig::default();
1155        let mut primary_hooks = HooksConfig::default();
1156        primary_hooks.lifecycle.session_start = vec![hook_group("primary-session-start")];
1157        primary_hooks.lifecycle.session_end = vec![hook_group("primary-session-end")];
1158        primary_hooks.lifecycle.subagent_start = vec![hook_group("primary-subagent-start")];
1159        primary_hooks.lifecycle.subagent_stop = vec![hook_group("primary-subagent-stop")];
1160        primary_hooks.lifecycle.task_completion = vec![hook_group("primary-task-completion")];
1161        primary_hooks.lifecycle.task_completed = vec![hook_group("primary-task-completed")];
1162
1163        let mut spec = test_spec("worker");
1164        spec.hooks = Some(primary_hooks);
1165        let active = ActivePrimaryAgent::from_spec(&spec);
1166
1167        let merged = build_primary_agent_hook_config(&global, &active);
1168
1169        assert_hook_commands(&merged.lifecycle.session_start, &["primary-session-start"]);
1170        assert_hook_commands(&merged.lifecycle.session_end, &["primary-session-end"]);
1171        assert_hook_commands(&merged.lifecycle.subagent_start, &["primary-subagent-start"]);
1172        assert_hook_commands(&merged.lifecycle.subagent_stop, &["primary-subagent-stop"]);
1173        assert!(merged.lifecycle.task_completion.is_empty());
1174        assert!(merged.lifecycle.task_completed.is_empty());
1175        assert!(merged.lifecycle.stop.is_empty());
1176    }
1177
1178    #[test]
1179    fn primary_hook_config_recomputes_without_previous_primary_leakage() {
1180        let global = HooksConfig::default();
1181        let mut first_hooks = HooksConfig::default();
1182        first_hooks.lifecycle.pre_tool_use = vec![hook_group("first-pre")];
1183        let mut second_hooks = HooksConfig::default();
1184        second_hooks.lifecycle.pre_tool_use = vec![hook_group("second-pre")];
1185
1186        let mut first = test_spec("first");
1187        first.hooks = Some(first_hooks);
1188        let mut second = test_spec("second");
1189        second.hooks = Some(second_hooks);
1190        let specs = vec![first, second];
1191        let mut state = ActivePrimaryAgentState::default();
1192
1193        state.select_from_specs(&specs, "first").expect("selected first");
1194        let first_config = build_primary_agent_hook_config(&global, state.active());
1195        assert_hook_commands(&first_config.lifecycle.pre_tool_use, &["first-pre"]);
1196
1197        state.select_from_specs(&specs, "second").expect("selected second");
1198        let second_config = build_primary_agent_hook_config(&global, state.active());
1199        assert_hook_commands(&second_config.lifecycle.pre_tool_use, &["second-pre"]);
1200    }
1201
1202    #[test]
1203    fn active_primary_state_recomputes_skills_mcp_and_metadata_on_switch() {
1204        let mut first = test_spec("first");
1205        first.description = "First metadata".to_string();
1206        first.color = Some("red".to_string());
1207        first.aliases = vec!["one".to_string()];
1208        first.skills = vec!["rust".to_string()];
1209        first.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
1210            "first-mcp".to_string(),
1211            json!({
1212                "type": "stdio",
1213                "command": "first-mcp"
1214            }),
1215        )]))];
1216        let second = test_spec("second");
1217        let specs = vec![first, second];
1218        let mut state = ActivePrimaryAgentState::default();
1219
1220        state.select_from_specs(&specs, "one").expect("selected first by alias");
1221        assert_eq!(state.active().identity.name, "first");
1222        assert_eq!(state.active().description, "First metadata");
1223        assert_eq!(state.active().color.as_deref(), Some("red"));
1224        assert_eq!(state.active().aliases, vec!["one".to_string()]);
1225        assert_eq!(state.active().skills, vec!["rust".to_string()]);
1226        assert_eq!(state.active().mcp_servers.len(), 1);
1227
1228        state.select_from_specs(&specs, "second").expect("selected second");
1229        assert_eq!(state.active().identity.name, "second");
1230        assert_eq!(state.active().description, "second description");
1231        assert_eq!(state.active().color.as_deref(), Some("blue"));
1232        assert!(state.active().aliases.is_empty());
1233        assert!(state.active().skills.is_empty());
1234        assert!(state.active().mcp_servers.is_empty());
1235    }
1236
1237    fn test_spec(name: &str) -> SubagentSpec {
1238        SubagentSpec {
1239            name: name.to_string(),
1240            description: format!("{name} description"),
1241            prompt: format!("{name} instructions"),
1242            tools: Some(vec!["code_search".to_string()]),
1243            disallowed_tools: vec!["file_operation".to_string()],
1244            model: Some("gpt-5.1".to_string()),
1245            color: Some("blue".to_string()),
1246            reasoning_effort: Some(ReasoningEffortLevel::High),
1247            permissions: AgentPermissionsConfig::new(PermissionDefault::Deny),
1248            skills: Vec::new(),
1249            mcp_servers: Vec::new(),
1250            hooks: None,
1251            background: false,
1252            mode: vtcode_config::AgentMode::Primary,
1253            max_turns: None,
1254            nickname_candidates: Vec::new(),
1255            initial_prompt: None,
1256            memory: None,
1257            isolation: None,
1258            aliases: Vec::new(),
1259            source: SubagentSource::ProjectVtcode,
1260            file_path: None,
1261            warnings: Vec::new(),
1262            tool_policy_overrides: std::collections::BTreeMap::new(),
1263        }
1264    }
1265
1266    fn hook_group(command: &str) -> HookGroupConfig {
1267        HookGroupConfig {
1268            matcher: None,
1269            hooks: vec![HookCommandConfig {
1270                command: command.to_string(),
1271                ..HookCommandConfig::default()
1272            }],
1273        }
1274    }
1275
1276    fn assert_hook_commands(groups: &[HookGroupConfig], expected: &[&str]) {
1277        let commands = groups
1278            .iter()
1279            .flat_map(|group| group.hooks.iter())
1280            .map(|hook| hook.command.as_str())
1281            .collect::<Vec<_>>();
1282        assert_eq!(commands, expected);
1283    }
1284}