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.contains(&tool_name)
241}
242
243/// Returns the configured agent name that should be surfaced to the operator
244/// when the resolver cannot find it and falls back to the built-in `build`
245/// agent, or `None` when the fallback is expected and should stay silent.
246///
247/// A fallback is expected (and therefore silent) when the operator asked for
248/// the default `build` agent — whether explicitly or because no
249/// `default_primary_agent` was configured — since the built-in `build` agent
250/// *is* the canonical build. Any other requested name that misses discovery is
251/// a configuration mismatch the operator should hear about.
252///
253/// Extracted as a pure predicate so the decision is unit-testable without a
254/// tracing subscriber.
255fn fallback_notice(requested_default: &str) -> Option<&str> {
256    let requested = requested_default.trim();
257    if requested.is_empty() || requested.eq_ignore_ascii_case(DEFAULT_PRIMARY_AGENT_NAME) {
258        None
259    } else {
260        Some(requested)
261    }
262}
263
264#[must_use]
265pub fn primary_agent_allows_tool(agent: &ActivePrimaryAgent, tool_name: &str) -> bool {
266    let tool_name = normalise_tool_name(tool_name);
267
268    // Cleanup tools stay available so restricted agents can still join or
269    // close already-running child work.
270    if is_subagent_cleanup_tool(&tool_name) {
271        return true;
272    }
273
274    // The task tracker stays available so restricted primaries (including
275    // custom `auto` agents with an explicit `tools:` list) can still report
276    // progress. Planning/interview tools stay author-curated: unlike the
277    // tracker they change the session mode, so an explicit omission must
278    // keep working.
279    if tool_name == tools::TASK_TRACKER {
280        return true;
281    }
282
283    let allow_list_allows = agent
284        .tools
285        .as_ref()
286        .is_none_or(|tools| tools.iter().any(|allowed| normalise_tool_name(allowed) == tool_name));
287    if !allow_list_allows {
288        return false;
289    }
290
291    !agent
292        .disallowed_tools
293        .iter()
294        .any(|denied| normalise_tool_name(denied) == tool_name)
295}
296
297/// Filters the emitted tool list down to those `primary_agent_allows_tool`
298/// permits for `agent`.
299///
300/// KNOWN LIMITATION: filtering is by each tool's canonical
301/// `function_name()`, and `normalise_tool_name` only lowercases/trims — it
302/// does not alias-resolve. For multi-action tools that fold several legacy
303/// actions into one emitted name (e.g. `agent` folding
304/// spawn/wait/close/send_input/resume, or `mcp` folding connect/disconnect),
305/// a granular allow/deny entry such as `"spawn_agent"` in
306/// `disallowed_tools`/`tools` has no effect on the folded `agent` tool: the
307/// whole tool is either present or absent based on whether `"agent"` itself
308/// is allowed. Expressing "allow cleanup (wait/close) but deny spawn"
309/// therefore cannot be done here; it requires execution-time action gating —
310/// the same action-qualified mechanism already used to keep `mcp:connect`/
311/// `mcp:disconnect` HITL-gated separately from bare `mcp` (see
312/// `action_qualified_policy_name` in `tool_intent.rs`) — applied per-action
313/// at the point the subagent tool call is dispatched. That is deferred.
314#[must_use]
315pub fn apply_primary_agent_tool_policy(
316    tools: Option<Arc<Vec<ToolDefinition>>>,
317    agent: &ActivePrimaryAgent,
318) -> Option<Arc<Vec<ToolDefinition>>> {
319    let tools = tools?;
320    let filtered = tools
321        .iter()
322        .filter(|tool| primary_agent_allows_tool(agent, tool.function_name()))
323        .cloned()
324        .collect::<Vec<_>>();
325
326    (!filtered.is_empty()).then(|| Arc::new(filtered))
327}
328
329#[must_use]
330pub fn build_primary_agent_runtime_config(parent: &VTCodeConfig, agent: &ActivePrimaryAgent) -> VTCodeConfig {
331    let mut config = parent.clone();
332    // "inherit" (and empty) are sentinels meaning "use the parent's model" — mirrors
333    // resolve_subagent_model's handling in subagents/model.rs. Built-in primary agents
334    // (e.g. "auto", "build") default their `model` field to "inherit", so without this
335    // check every plain `exec` run would clobber a real, explicitly configured model
336    // (via --model or [agent].default_model) with the literal string "inherit", which
337    // then fails ModelId parsing entirely.
338    if let Some(model) = agent.model.as_ref() {
339        let trimmed = model.trim();
340        if !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("inherit") {
341            config.agent.default_model = model.clone();
342        }
343    }
344    if let Some(reasoning_effort) = agent.reasoning_effort {
345        config.agent.reasoning_effort = reasoning_effort;
346    }
347    merge_primary_mcp_servers(&mut config, agent.mcp_servers.as_slice());
348    config
349}
350
351#[must_use]
352pub fn build_primary_agent_hook_config(global: &HooksConfig, agent: &ActivePrimaryAgent) -> HooksConfig {
353    let mut config = global.clone();
354    merge_active_primary_hooks(&mut config, agent.hooks.as_ref());
355    config
356}
357
358pub fn apply_primary_agent_prompt_context(context: &mut PromptContext, agent: &ActivePrimaryAgent) {
359    context.replace_available_skills_with_named(agent.skills.as_slice());
360}
361
362#[must_use]
363pub fn active_primary_agent_permissions(agent: &ActivePrimaryAgent) -> &AgentPermissionsConfig {
364    &agent.permissions
365}
366
367#[must_use]
368pub fn evaluate_active_primary_agent_permissions(
369    config: &VTCodeConfig,
370    agent: &ActivePrimaryAgent,
371    workspace_root: &std::path::Path,
372    current_dir: &std::path::Path,
373    request: &PermissionRequest,
374) -> ResolvedPermissionDecision {
375    evaluate_effective_permissions(
376        &config.permissions,
377        active_primary_agent_permissions(agent),
378        workspace_root,
379        current_dir,
380        request,
381    )
382}
383
384fn normalise_tool_name(tool_name: &str) -> String {
385    tool_name.trim().to_ascii_lowercase()
386}
387
388fn merge_primary_mcp_servers(config: &mut VTCodeConfig, servers: &[SubagentMcpServer]) {
389    for server in servers {
390        match server {
391            SubagentMcpServer::Named(_) => {}
392            SubagentMcpServer::Inline(definition) => {
393                for (name, value) in definition {
394                    if config.mcp.providers.iter().any(|provider| provider.name == *name) {
395                        continue;
396                    }
397                    if let Some(provider) = inline_mcp_provider(name, value) {
398                        config.mcp.providers.push(provider);
399                    }
400                }
401            }
402        }
403    }
404}
405
406fn merge_active_primary_hooks(config: &mut HooksConfig, hooks: Option<&HooksConfig>) {
407    let Some(hooks) = hooks else {
408        return;
409    };
410
411    config.lifecycle.quiet_success_output |= hooks.lifecycle.quiet_success_output;
412    append_hook_groups(&mut config.lifecycle.session_start, &hooks.lifecycle.session_start);
413    append_hook_groups(&mut config.lifecycle.session_end, &hooks.lifecycle.session_end);
414    append_hook_groups(&mut config.lifecycle.subagent_start, &hooks.lifecycle.subagent_start);
415    append_hook_groups(&mut config.lifecycle.subagent_stop, &hooks.lifecycle.subagent_stop);
416    append_hook_groups(&mut config.lifecycle.user_prompt_submit, &hooks.lifecycle.user_prompt_submit);
417    append_hook_groups(&mut config.lifecycle.pre_tool_use, &hooks.lifecycle.pre_tool_use);
418    append_hook_groups(&mut config.lifecycle.post_tool_use, &hooks.lifecycle.post_tool_use);
419    append_hook_groups(&mut config.lifecycle.permission_request, &hooks.lifecycle.permission_request);
420    append_hook_groups(&mut config.lifecycle.pre_compact, &hooks.lifecycle.pre_compact);
421    append_hook_groups(&mut config.lifecycle.stop, &hooks.lifecycle.stop);
422    append_hook_groups(&mut config.lifecycle.notification, &hooks.lifecycle.notification);
423}
424
425fn append_hook_groups(target: &mut Vec<HookGroupConfig>, source: &[HookGroupConfig]) {
426    target.extend(source.iter().cloned());
427}
428
429fn inline_mcp_provider(name: &str, value: &serde_json::Value) -> Option<McpProviderConfig> {
430    let object = value.as_object()?;
431    let mut payload = serde_json::Map::with_capacity(object.len().saturating_add(1));
432    payload.insert("name".to_string(), serde_json::Value::String(name.to_string()));
433    for (key, value) in object {
434        if key == "type" {
435            continue;
436        }
437        payload.insert(key.clone(), value.clone());
438    }
439    if payload.contains_key("command") && !payload.contains_key("args") {
440        payload.insert("args".to_string(), serde_json::Value::Array(Vec::new()));
441    }
442    serde_json::from_value(serde_json::Value::Object(payload)).ok()
443}
444
445#[cfg(test)]
446mod tests {
447    use std::collections::BTreeMap;
448
449    use serde_json::json;
450    use tempfile::TempDir;
451    use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
452    use vtcode_config::{
453        HookCommandConfig, HooksConfig, IsolationMode, SubagentDiscoveryInput, SubagentMcpServer, SubagentMemoryScope,
454        SubagentSource, builtin_plan_agent, builtin_primary_auto_agent, builtin_primary_duck_agent, builtin_subagents,
455        discover_subagents,
456    };
457
458    use crate::config::constants::tools;
459    use crate::permissions::{ResolvedPermissionDecision, build_permission_request};
460
461    use super::*;
462
463    #[test]
464    fn resolves_existing_spec_by_name() {
465        let spec = test_spec("planner");
466        let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
467
468        assert_eq!(active.identity.name, "planner");
469        assert_eq!(active.display_name, "planner");
470        assert_eq!(active.description, "planner description");
471        assert_eq!(active.color.as_deref(), Some("blue"));
472        assert_eq!(active.instructions, "planner instructions");
473        assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
474        assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
475        assert_eq!(active.permissions.default, PermissionDefault::Deny);
476        assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
477        assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
478        assert!(active.hooks.is_none());
479    }
480
481    #[test]
482    fn unknown_agent_error_preserves_current_active_agent() {
483        let current = test_spec("current");
484        let specs = vec![current.clone()];
485        let mut state = ActivePrimaryAgentState::default();
486        let original = state.select_from_specs(&specs, "current").expect("initial selection").clone();
487
488        let error = state.select_from_specs(&specs, "missing").expect_err("unknown agent");
489
490        assert_eq!(error, PrimaryAgentResolutionError::UnknownAgent { requested: "missing".to_string() });
491        assert_eq!(state.active(), &original);
492    }
493
494    #[test]
495    fn restore_snapshot_returns_to_write_capable_build_agent() {
496        let mut state = ActivePrimaryAgentState::default();
497        let build_snapshot = state.active().clone();
498        state
499            .select_from_specs(&[test_spec("duck")], "duck")
500            .expect("select leaked agent");
501        assert_eq!(state.active().identity.name, "duck");
502
503        state.restore_snapshot(build_snapshot);
504
505        assert_eq!(state.active().identity.name, "build");
506    }
507
508    #[test]
509    fn alias_resolution_uses_existing_matches_name_semantics() {
510        let mut spec = test_spec("reviewer");
511        spec.aliases = vec!["critic".to_string()];
512
513        let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
514
515        assert_eq!(active.identity.name, "reviewer");
516        assert_eq!(active.display_name, "reviewer");
517    }
518
519    #[test]
520    fn exact_name_resolution_wins_over_alias() {
521        let mut build = test_spec("build");
522        build.aliases = vec!["builder".to_string()];
523        let builder = test_spec("builder");
524
525        let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
526
527        assert_eq!(active.identity.name, "builder");
528    }
529
530    #[test]
531    fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
532        let mut spec = test_spec("worker");
533        spec.aliases = vec!["builder".to_string()];
534        spec.skills = vec!["rust".to_string()];
535        spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
536        spec.background = true;
537        spec.max_turns = Some(12);
538        spec.nickname_candidates = vec!["w".to_string()];
539        spec.initial_prompt = Some("start here".to_string());
540        spec.memory = Some(SubagentMemoryScope::Project);
541        spec.isolation = Some(IsolationMode::Full);
542
543        let active = ActivePrimaryAgent::from_spec(&spec);
544
545        assert_eq!(active.identity.name, "worker");
546        assert_eq!(active.display_name, "worker");
547        assert_eq!(active.description, "worker description");
548        assert_eq!(active.color.as_deref(), Some("blue"));
549        assert_eq!(active.aliases, vec!["builder".to_string()]);
550        assert_eq!(active.instructions, "worker instructions");
551        assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
552        assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
553        assert_eq!(active.permissions.default, PermissionDefault::Deny);
554        assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
555        assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
556        assert_eq!(active.skills, vec!["rust".to_string()]);
557        assert_eq!(active.mcp_servers, vec![SubagentMcpServer::Named("filesystem".to_string())]);
558        assert_eq!(active.memory, Some(SubagentMemoryScope::Project));
559    }
560
561    #[test]
562    fn primary_runtime_adapter_uses_shared_resolved_view_for_overlapping_fields() {
563        let mut spec = test_spec("worker");
564        spec.description = "Worker display metadata".to_string();
565        spec.color = Some("green".to_string());
566        spec.aliases = vec!["builder".to_string()];
567        spec.skills = vec!["rust".to_string(), "repo".to_string()];
568        spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
569        spec.hooks = Some(HooksConfig::default());
570        spec.memory = Some(SubagentMemoryScope::Project);
571
572        let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
573        let active = ActivePrimaryAgent::from_runtime_view(&runtime);
574
575        assert_eq!(runtime.canonical_name, "worker");
576        assert_eq!(runtime.display_name, "worker");
577        assert_eq!(runtime.description, "Worker display metadata");
578        assert_eq!(runtime.color.as_deref(), Some("green"));
579        assert_eq!(runtime.aliases, vec!["builder".to_string()]);
580        assert_eq!(runtime.skills, vec!["rust".to_string(), "repo".to_string()]);
581        assert_eq!(runtime.mcp_servers.len(), 1);
582        assert!(runtime.hooks.is_some());
583        assert_eq!(runtime.memory, Some(SubagentMemoryScope::Project));
584        assert!(runtime.read_only);
585        assert_eq!(active.identity.name, runtime.canonical_name);
586        assert_eq!(active.display_name, runtime.display_name);
587        assert_eq!(active.description, runtime.description);
588        assert_eq!(active.color, runtime.color);
589        assert_eq!(active.aliases, runtime.aliases);
590        assert_eq!(active.instructions, runtime.instructions);
591        assert_eq!(active.tools, runtime.tools);
592        assert_eq!(active.disallowed_tools, runtime.disallowed_tools);
593        assert_eq!(active.permissions, runtime.permissions);
594        assert_eq!(active.model, runtime.model);
595        assert_eq!(active.reasoning_effort, runtime.reasoning_effort);
596        assert_eq!(active.hooks, runtime.hooks);
597        assert_eq!(active.skills, runtime.skills);
598        assert_eq!(active.mcp_servers, runtime.mcp_servers);
599        assert_eq!(active.memory, runtime.memory);
600    }
601
602    #[test]
603    fn default_state_uses_builtin_build_agent() {
604        let mut state = ActivePrimaryAgentState::default();
605
606        assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
607        assert_eq!(state.active().identity.name, "build");
608        assert_eq!(state.active().identity.source, SubagentSource::Builtin);
609
610        state.select_from_specs(&[test_spec("worker")], "worker").expect("selected");
611        assert_eq!(state.active().identity.name, "worker");
612
613        state.reset_to_default_from_specs(&[]);
614
615        assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
616        assert_eq!(state.active().identity.name, "build");
617        assert_eq!(state.active().identity.source, SubagentSource::Builtin);
618    }
619
620    #[test]
621    fn from_specs_falls_back_to_builtin_build_agent() {
622        let active = ActivePrimaryAgentState::from_specs(&[]);
623
624        assert_eq!(active.active().identity.name, "build");
625        assert_eq!(active.active().identity.source, SubagentSource::Builtin);
626    }
627
628    #[test]
629    fn from_specs_with_default_selects_configured_primary_agent() {
630        let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "builder");
631
632        assert_eq!(active.active().identity.name, "builder");
633    }
634
635    #[test]
636    fn from_specs_with_default_falls_back_to_build_for_missing_configured_agent() {
637        let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "missing");
638
639        assert_eq!(active.active().identity.name, "build");
640        assert_eq!(active.active().identity.source, SubagentSource::Builtin);
641    }
642
643    #[test]
644    fn fallback_notice_silent_for_build_and_empty() {
645        // The built-in `build` agent is the canonical default, so falling back
646        // to it when the operator asked for `build` (or nothing) is expected.
647        assert_eq!(fallback_notice("build"), None);
648        assert_eq!(fallback_notice("BUILD"), None);
649        assert_eq!(fallback_notice("Build"), None);
650        assert_eq!(fallback_notice(""), None);
651        assert_eq!(fallback_notice("   "), None);
652    }
653
654    #[test]
655    fn fallback_notice_surfaces_non_default_mismatches() {
656        // Any other requested name that misses discovery is a configuration
657        // mismatch the operator should be warned about.
658        assert_eq!(fallback_notice("duck"), Some("duck"));
659        assert_eq!(fallback_notice("auto"), Some("auto"));
660        assert_eq!(fallback_notice("  duck  "), Some("duck"));
661    }
662
663    #[test]
664    fn discovery_precedence_overrides_builtin_build_agent() {
665        let temp = TempDir::new().expect("tempdir");
666        let discovered = discover_subagents(&SubagentDiscoveryInput {
667            workspace_root: temp.path().to_path_buf(),
668            cli_agents: Some(json!({
669                "build": {
670                    "description": "CLI build",
671                    "prompt": "cli build instructions",
672                    "model": "gpt-cli",
673                    "mode": "primary",
674                    "permissions": { "default": "ask" }
675                }
676            })),
677            plugin_agent_files: Vec::new(),
678            include_user_agents: false,
679        })
680        .expect("discovered subagents");
681
682        let active = ActivePrimaryAgentState::from_discovery(&discovered);
683
684        assert_eq!(active.active().identity.name, "build");
685        assert_eq!(active.active().identity.source, SubagentSource::Cli);
686        assert_eq!(active.active().instructions, "cli build instructions");
687        assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
688    }
689
690    #[test]
691    fn default_build_agent_allows_baseline_read_and_exec_tools() {
692        let active = ActivePrimaryAgentState::default();
693
694        assert!(primary_agent_allows_tool(active.active(), tools::CODE_SEARCH));
695        assert!(primary_agent_allows_tool(active.active(), tools::EXEC_COMMAND));
696        assert!(primary_agent_allows_tool(active.active(), tools::APPLY_PATCH));
697        assert!(primary_agent_allows_tool(active.active(), tools::RUN_PTY_CMD));
698        assert_eq!(active.active().permissions.default, PermissionDefault::Ask);
699    }
700
701    #[test]
702    fn build_and_auto_expose_the_same_tools_and_policy_decisions() {
703        let build = ActivePrimaryAgent::from_spec(&builtin_primary_build_agent());
704        let auto = ActivePrimaryAgent::from_spec(&builtin_primary_auto_agent());
705        let tools_to_compare = [
706            tools::CODE_SEARCH,
707            tools::EXEC_COMMAND,
708            tools::APPLY_PATCH,
709            tools::RUN_PTY_CMD,
710            tools::WRITE_FILE,
711            tools::REQUEST_USER_INPUT,
712        ];
713
714        for tool in tools_to_compare {
715            assert_eq!(
716                primary_agent_allows_tool(&build, tool),
717                primary_agent_allows_tool(&auto, tool),
718                "Build and Auto must expose the same capability for {tool}"
719            );
720        }
721    }
722
723    #[test]
724    fn readonly_primaries_expose_public_read_tools_and_block_apply_patch() {
725        for spec in [builtin_primary_duck_agent(), builtin_plan_agent()] {
726            let name = spec.name.clone();
727            let active = ActivePrimaryAgent::from_spec(&spec);
728
729            assert!(primary_agent_allows_tool(&active, tools::CODE_SEARCH), "{name} should expose code_search");
730            assert!(primary_agent_allows_tool(&active, tools::EXEC_COMMAND), "{name} should expose exec_command");
731            assert!(primary_agent_allows_tool(&active, tools::REQUEST_USER_INPUT));
732            assert!(primary_agent_allows_tool(&active, tools::RECORD_DECISION));
733
734            assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH), "{name} must not expose apply_patch");
735            assert!(!primary_agent_allows_tool(&active, tools::RUN_PTY_CMD), "{name} must not expose run_pty_cmd");
736            assert!(!primary_agent_allows_tool(&active, tools::WRITE_FILE), "{name} must not expose write_file");
737        }
738    }
739
740    #[test]
741    fn tool_policy_intersects_allow_list_then_applies_deny_list() {
742        let mut spec = test_spec("worker");
743        spec.tools = Some(vec![tools::CODE_SEARCH.to_string(), tools::EXEC_COMMAND.to_string()]);
744        spec.disallowed_tools = vec![tools::CODE_SEARCH.to_ascii_uppercase()];
745        let active = ActivePrimaryAgent::from_spec(&spec);
746
747        assert!(primary_agent_allows_tool(&active, tools::EXEC_COMMAND));
748        assert!(!primary_agent_allows_tool(&active, tools::CODE_SEARCH));
749        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
750    }
751
752    #[test]
753    fn empty_present_tool_allow_list_exposes_no_tools() {
754        let mut spec = test_spec("worker");
755        spec.tools = Some(Vec::new());
756        spec.disallowed_tools = Vec::new();
757        let active = ActivePrimaryAgent::from_spec(&spec);
758
759        assert!(!primary_agent_allows_tool(&active, tools::CODE_SEARCH));
760    }
761
762    // NOTE: `spawn_agent`/`wait_agent`/`close_agent`/etc. are legacy granular
763    // subagent-lifecycle tool names. Since wait/close were folded into the
764    // unified `agent` tool (action='wait'/'close'), these granular names are
765    // never actually emitted to the model — the only subagent tool name that
766    // ever reaches `apply_primary_agent_tool_policy`'s runtime filtering is
767    // `tools::AGENT` ("agent"). The assertions below against the granular
768    // names are legacy-taxonomy coverage of the `primary_agent_allows_tool`/
769    // `is_subagent_cleanup_tool` predicates directly (useful if a caller ever
770    // passes a legacy name explicitly), NOT a test of the runtime filtering
771    // path exercised by `apply_primary_agent_tool_policy`. The final
772    // assertion covers that real runtime path: with an allow-list that
773    // excludes "agent", the whole folded tool is filtered out, and there is
774    // currently no way to allow cleanup (wait/close) while denying spawn for
775    // the emitted "agent" tool (see `apply_primary_agent_tool_policy`'s doc
776    // comment for the deferred execution-time action-gating fix).
777    #[test]
778    fn subagent_cleanup_tools_bypass_policy_but_new_work_obeys_policy() {
779        let mut spec = test_spec("restricted");
780        spec.tools = Some(vec!["code_search".to_string()]);
781        spec.disallowed_tools = vec![
782            tools::SPAWN_AGENT.to_string(),
783            tools::WAIT_AGENT.to_string(),
784            tools::CLOSE_AGENT.to_string(),
785            tools::SEND_INPUT.to_string(),
786            tools::RESUME_AGENT.to_string(),
787            tools::SPAWN_BACKGROUND_SUBPROCESS.to_string(),
788        ];
789        let active = ActivePrimaryAgent::from_spec(&spec);
790
791        // Non-cleanup tools respect the policy.
792        assert!(!primary_agent_allows_tool(&active, "command_session"));
793        assert!(!primary_agent_allows_tool(&active, "file_operation"));
794        assert!(!primary_agent_allows_tool(&active, tools::SPAWN_AGENT));
795        assert!(!primary_agent_allows_tool(&active, tools::SPAWN_BACKGROUND_SUBPROCESS));
796        assert!(!primary_agent_allows_tool(&active, tools::SEND_INPUT));
797        assert!(!primary_agent_allows_tool(&active, tools::RESUME_AGENT));
798
799        // Cleanup tools remain available even when listed in disallowed_tools
800        // and absent from the allow list.
801        assert!(primary_agent_allows_tool(&active, tools::WAIT_AGENT));
802        assert!(primary_agent_allows_tool(&active, tools::CLOSE_AGENT));
803
804        // Runtime reality: the allow list excludes "agent" (the only name
805        // ever emitted for subagent tools post-fold), so the whole tool is
806        // filtered out regardless of the cleanup carve-out above.
807        assert!(!primary_agent_allows_tool(&active, tools::AGENT));
808    }
809
810    #[test]
811    fn task_tracker_bypasses_restricted_tool_allow_list_but_mode_tools_obey_it() {
812        // A custom primary with an explicit `tools:` list omitting the
813        // tracker must still expose it, while planning/interview tools stay
814        // author-curated and other tools stay gated.
815        let mut spec = test_spec("restricted");
816        spec.tools = Some(vec![tools::CODE_SEARCH.to_string()]);
817        let active = ActivePrimaryAgent::from_spec(&spec);
818
819        assert!(primary_agent_allows_tool(&active, tools::TASK_TRACKER));
820        assert!(!primary_agent_allows_tool(&active, tools::START_PLANNING));
821        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
822    }
823
824    #[test]
825    fn task_tracker_bypass_survives_explicit_disallow_entry() {
826        // Mirrors the cleanup-tool guarantee: even a `disallowed_tools`
827        // entry must not silence progress reporting.
828        let mut spec = test_spec("restricted");
829        spec.tools = Some(vec![tools::CODE_SEARCH.to_string()]);
830        spec.disallowed_tools = vec![tools::TASK_TRACKER.to_string()];
831        let active = ActivePrimaryAgent::from_spec(&spec);
832
833        assert!(primary_agent_allows_tool(&active, tools::TASK_TRACKER));
834        assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
835    }
836
837    #[test]
838    fn build_primary_agent_runtime_config_preserves_baseline_fields_and_merges_mcp() {
839        let mut parent = VTCodeConfig::default();
840        parent.agent.default_model = "parent-model".to_string();
841        parent.mcp.providers.push(
842            serde_json::from_value(json!({
843                "name": "global",
844                "command": "global-mcp",
845                "args": []
846            }))
847            .expect("global provider"),
848        );
849
850        let mut spec = test_spec("worker");
851        spec.permissions = AgentPermissionsConfig::new(PermissionDefault::Auto);
852        spec.model = Some("agent-model".to_string());
853        spec.reasoning_effort = Some(ReasoningEffortLevel::Low);
854        spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([
855            (
856                "global".to_string(),
857                json!({
858                    "type": "stdio",
859                    "command": "duplicate-mcp"
860                }),
861            ),
862            (
863                "local".to_string(),
864                json!({
865                    "type": "stdio",
866                    "command": "local-mcp"
867                }),
868            ),
869        ]))];
870        let active = ActivePrimaryAgent::from_spec(&spec);
871
872        let runtime = build_primary_agent_runtime_config(&parent, &active);
873
874        assert_eq!(runtime.agent.default_model, "agent-model");
875        assert_eq!(runtime.agent.reasoning_effort, ReasoningEffortLevel::Low);
876        assert_eq!(runtime.mcp.providers.len(), 2);
877        assert_eq!(runtime.mcp.providers[0].name, "global");
878        assert_eq!(runtime.mcp.providers[1].name, "local");
879    }
880
881    #[test]
882    fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_says_inherit() {
883        let mut parent = VTCodeConfig::default();
884        parent.agent.default_model = "parent-model".to_string();
885
886        let mut spec = test_spec("auto");
887        spec.model = Some("inherit".to_string());
888        let active = ActivePrimaryAgent::from_spec(&spec);
889
890        let runtime = build_primary_agent_runtime_config(&parent, &active);
891
892        assert_eq!(runtime.agent.default_model, "parent-model");
893    }
894
895    #[test]
896    fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_model_empty() {
897        let mut parent = VTCodeConfig::default();
898        parent.agent.default_model = "parent-model".to_string();
899
900        let mut spec = test_spec("auto");
901        spec.model = Some(String::new());
902        let active = ActivePrimaryAgent::from_spec(&spec);
903
904        let runtime = build_primary_agent_runtime_config(&parent, &active);
905
906        assert_eq!(runtime.agent.default_model, "parent-model");
907    }
908
909    #[test]
910    fn built_in_primary_agents_resolve_required_permission_policy() {
911        let builtins = builtin_subagents();
912
913        for name in ["duck", "plan", "build", "auto"] {
914            let active = resolve_primary_agent(&builtins, name)
915                .unwrap_or_else(|_| panic!("missing built-in primary agent {name}"));
916            assert_eq!(active.identity.name, name);
917            let expected_default = match name {
918                "build" => PermissionDefault::Ask,
919                "auto" => PermissionDefault::Auto,
920                "plan" | "duck" => PermissionDefault::Deny,
921                _ => unreachable!("unexpected built-in primary agent"),
922            };
923            assert_eq!(active.permissions.default, expected_default);
924        }
925    }
926
927    #[test]
928    fn active_primary_permissions_overlay_runtime_decisions() {
929        let builtins = builtin_subagents();
930        let mut state = ActivePrimaryAgentState::from_specs(&builtins);
931        let config = VTCodeConfig::default();
932        let workspace = TempDir::new().expect("workspace");
933        let current_dir = workspace.path();
934
935        state.select_from_specs(&builtins, "auto").expect("auto primary");
936        let exec = build_permission_request(
937            workspace.path(),
938            current_dir,
939            tools::UNIFIED_EXEC,
940            Some(&json!({"command": "cargo test"})),
941        );
942        assert_eq!(
943            evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &exec,),
944            ResolvedPermissionDecision::Auto
945        );
946
947        state.select_from_specs(&builtins, "plan").expect("plan primary");
948        let edit = build_permission_request(
949            workspace.path(),
950            current_dir,
951            tools::UNIFIED_FILE,
952            Some(&json!({"action": "edit", "path": "src/lib.rs"})),
953        );
954        assert_eq!(
955            evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &edit,),
956            ResolvedPermissionDecision::Deny
957        );
958    }
959
960    #[test]
961    fn primary_agent_switching_changes_permission_policy_without_mutating_parent_config() {
962        let builtins = builtin_subagents();
963        let mut state = ActivePrimaryAgentState::from_specs(&builtins);
964        let initial_model = state.active().model.clone();
965        let initial_tools = state.active().tools.clone();
966
967        state.select_from_specs(&builtins, "auto").expect("auto primary");
968        assert_eq!(state.active().identity.name, "auto");
969        assert_eq!(state.active().permissions.default, PermissionDefault::Auto);
970        assert_eq!(state.active().model, initial_model);
971        assert_eq!(state.active().tools, initial_tools);
972
973        state.select_from_specs(&builtins, "duck").expect("duck primary");
974        assert_eq!(state.active().identity.name, "duck");
975        assert_eq!(state.active().permissions.default, PermissionDefault::Deny);
976        assert_eq!(state.active().model, initial_model);
977        assert_ne!(state.active().tools, initial_tools);
978    }
979
980    #[test]
981    fn primary_hook_config_merges_supported_main_session_events_after_global_hooks() {
982        let mut global = HooksConfig::default();
983        global.lifecycle.user_prompt_submit = vec![hook_group("global-user")];
984        global.lifecycle.pre_tool_use = vec![hook_group("global-pre")];
985        global.lifecycle.post_tool_use = vec![hook_group("global-post")];
986        global.lifecycle.permission_request = vec![hook_group("global-permission")];
987        global.lifecycle.pre_compact = vec![hook_group("global-compact")];
988        global.lifecycle.stop = vec![hook_group("global-stop")];
989        global.lifecycle.notification = vec![hook_group("global-notification")];
990
991        let mut primary_hooks = HooksConfig::default();
992        primary_hooks.lifecycle.user_prompt_submit = vec![hook_group("primary-user")];
993        primary_hooks.lifecycle.pre_tool_use = vec![hook_group("primary-pre")];
994        primary_hooks.lifecycle.post_tool_use = vec![hook_group("primary-post")];
995        primary_hooks.lifecycle.permission_request = vec![hook_group("primary-permission")];
996        primary_hooks.lifecycle.pre_compact = vec![hook_group("primary-compact")];
997        primary_hooks.lifecycle.stop = vec![hook_group("primary-stop")];
998        primary_hooks.lifecycle.notification = vec![hook_group("primary-notification")];
999
1000        let mut spec = test_spec("worker");
1001        spec.hooks = Some(primary_hooks);
1002        let active = ActivePrimaryAgent::from_spec(&spec);
1003
1004        let merged = build_primary_agent_hook_config(&global, &active);
1005
1006        assert_hook_commands(&merged.lifecycle.user_prompt_submit, &["global-user", "primary-user"]);
1007        assert_hook_commands(&merged.lifecycle.pre_tool_use, &["global-pre", "primary-pre"]);
1008        assert_hook_commands(&merged.lifecycle.post_tool_use, &["global-post", "primary-post"]);
1009        assert_hook_commands(&merged.lifecycle.permission_request, &["global-permission", "primary-permission"]);
1010        assert_hook_commands(&merged.lifecycle.pre_compact, &["global-compact", "primary-compact"]);
1011        assert_hook_commands(&merged.lifecycle.stop, &["global-stop", "primary-stop"]);
1012        assert_hook_commands(&merged.lifecycle.notification, &["global-notification", "primary-notification"]);
1013    }
1014
1015    #[test]
1016    fn primary_hook_config_merges_session_and_subagent_lifecycle_events() {
1017        let global = HooksConfig::default();
1018        let mut primary_hooks = HooksConfig::default();
1019        primary_hooks.lifecycle.session_start = vec![hook_group("primary-session-start")];
1020        primary_hooks.lifecycle.session_end = vec![hook_group("primary-session-end")];
1021        primary_hooks.lifecycle.subagent_start = vec![hook_group("primary-subagent-start")];
1022        primary_hooks.lifecycle.subagent_stop = vec![hook_group("primary-subagent-stop")];
1023        primary_hooks.lifecycle.task_completion = vec![hook_group("primary-task-completion")];
1024        primary_hooks.lifecycle.task_completed = vec![hook_group("primary-task-completed")];
1025
1026        let mut spec = test_spec("worker");
1027        spec.hooks = Some(primary_hooks);
1028        let active = ActivePrimaryAgent::from_spec(&spec);
1029
1030        let merged = build_primary_agent_hook_config(&global, &active);
1031
1032        assert_hook_commands(&merged.lifecycle.session_start, &["primary-session-start"]);
1033        assert_hook_commands(&merged.lifecycle.session_end, &["primary-session-end"]);
1034        assert_hook_commands(&merged.lifecycle.subagent_start, &["primary-subagent-start"]);
1035        assert_hook_commands(&merged.lifecycle.subagent_stop, &["primary-subagent-stop"]);
1036        assert!(merged.lifecycle.task_completion.is_empty());
1037        assert!(merged.lifecycle.task_completed.is_empty());
1038        assert!(merged.lifecycle.stop.is_empty());
1039    }
1040
1041    #[test]
1042    fn primary_hook_config_recomputes_without_previous_primary_leakage() {
1043        let global = HooksConfig::default();
1044        let mut first_hooks = HooksConfig::default();
1045        first_hooks.lifecycle.pre_tool_use = vec![hook_group("first-pre")];
1046        let mut second_hooks = HooksConfig::default();
1047        second_hooks.lifecycle.pre_tool_use = vec![hook_group("second-pre")];
1048
1049        let mut first = test_spec("first");
1050        first.hooks = Some(first_hooks);
1051        let mut second = test_spec("second");
1052        second.hooks = Some(second_hooks);
1053        let specs = vec![first, second];
1054        let mut state = ActivePrimaryAgentState::default();
1055
1056        state.select_from_specs(&specs, "first").expect("selected first");
1057        let first_config = build_primary_agent_hook_config(&global, state.active());
1058        assert_hook_commands(&first_config.lifecycle.pre_tool_use, &["first-pre"]);
1059
1060        state.select_from_specs(&specs, "second").expect("selected second");
1061        let second_config = build_primary_agent_hook_config(&global, state.active());
1062        assert_hook_commands(&second_config.lifecycle.pre_tool_use, &["second-pre"]);
1063    }
1064
1065    #[test]
1066    fn active_primary_state_recomputes_skills_mcp_and_metadata_on_switch() {
1067        let mut first = test_spec("first");
1068        first.description = "First metadata".to_string();
1069        first.color = Some("red".to_string());
1070        first.aliases = vec!["one".to_string()];
1071        first.skills = vec!["rust".to_string()];
1072        first.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
1073            "first-mcp".to_string(),
1074            json!({
1075                "type": "stdio",
1076                "command": "first-mcp"
1077            }),
1078        )]))];
1079        let second = test_spec("second");
1080        let specs = vec![first, second];
1081        let mut state = ActivePrimaryAgentState::default();
1082
1083        state.select_from_specs(&specs, "one").expect("selected first by alias");
1084        assert_eq!(state.active().identity.name, "first");
1085        assert_eq!(state.active().description, "First metadata");
1086        assert_eq!(state.active().color.as_deref(), Some("red"));
1087        assert_eq!(state.active().aliases, vec!["one".to_string()]);
1088        assert_eq!(state.active().skills, vec!["rust".to_string()]);
1089        assert_eq!(state.active().mcp_servers.len(), 1);
1090
1091        state.select_from_specs(&specs, "second").expect("selected second");
1092        assert_eq!(state.active().identity.name, "second");
1093        assert_eq!(state.active().description, "second description");
1094        assert_eq!(state.active().color.as_deref(), Some("blue"));
1095        assert!(state.active().aliases.is_empty());
1096        assert!(state.active().skills.is_empty());
1097        assert!(state.active().mcp_servers.is_empty());
1098    }
1099
1100    fn test_spec(name: &str) -> SubagentSpec {
1101        SubagentSpec {
1102            name: name.to_string(),
1103            description: format!("{name} description"),
1104            prompt: format!("{name} instructions"),
1105            tools: Some(vec!["code_search".to_string()]),
1106            disallowed_tools: vec!["file_operation".to_string()],
1107            model: Some("gpt-5.1".to_string()),
1108            color: Some("blue".to_string()),
1109            reasoning_effort: Some(ReasoningEffortLevel::High),
1110            permissions: AgentPermissionsConfig::new(PermissionDefault::Deny),
1111            skills: Vec::new(),
1112            mcp_servers: Vec::new(),
1113            hooks: None,
1114            background: false,
1115            mode: vtcode_config::AgentMode::Primary,
1116            max_turns: None,
1117            nickname_candidates: Vec::new(),
1118            initial_prompt: None,
1119            memory: None,
1120            isolation: None,
1121            aliases: Vec::new(),
1122            source: SubagentSource::ProjectVtcode,
1123            file_path: None,
1124            warnings: Vec::new(),
1125            tool_policy_overrides: std::collections::BTreeMap::new(),
1126        }
1127    }
1128
1129    fn hook_group(command: &str) -> HookGroupConfig {
1130        HookGroupConfig {
1131            matcher: None,
1132            hooks: vec![HookCommandConfig {
1133                command: command.to_string(),
1134                ..HookCommandConfig::default()
1135            }],
1136        }
1137    }
1138
1139    fn assert_hook_commands(groups: &[HookGroupConfig], expected: &[&str]) {
1140        let commands = groups
1141            .iter()
1142            .flat_map(|group| group.hooks.iter())
1143            .map(|hook| hook.command.as_str())
1144            .collect::<Vec<_>>();
1145        assert_eq!(commands, expected);
1146    }
1147}