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 #[must_use]
83 pub fn name(&self) -> &str {
84 &self.identity.name
85 }
86
87 #[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 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
224fn 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
245fn 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 if is_subagent_cleanup_tool(tool_name) {
273 return true;
274 }
275
276 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#[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 if let Some(model) = agent.model.as_ref() {
353 let trimmed = model.trim();
354 if !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("inherit") {
355 config.agent.default_model = model.clone();
356 }
357 }
358 if let Some(reasoning_effort) = agent.reasoning_effort {
359 config.agent.reasoning_effort = reasoning_effort;
360 }
361 merge_primary_mcp_servers(&mut config, agent.mcp_servers.as_slice());
362 config
363}
364
365#[must_use]
366pub fn build_primary_agent_hook_config(global: &HooksConfig, agent: &ActivePrimaryAgent) -> HooksConfig {
367 let mut config = global.clone();
368 merge_active_primary_hooks(&mut config, agent.hooks.as_ref());
369 config
370}
371
372pub fn apply_primary_agent_prompt_context(context: &mut PromptContext, agent: &ActivePrimaryAgent) {
373 context.replace_available_skills_with_named(agent.skills.as_slice());
374}
375
376#[must_use]
377pub fn active_primary_agent_permissions(agent: &ActivePrimaryAgent) -> &AgentPermissionsConfig {
378 &agent.permissions
379}
380
381#[must_use]
382pub fn evaluate_active_primary_agent_permissions(
383 config: &VTCodeConfig,
384 agent: &ActivePrimaryAgent,
385 workspace_root: &std::path::Path,
386 current_dir: &std::path::Path,
387 request: &PermissionRequest,
388) -> ResolvedPermissionDecision {
389 evaluate_effective_permissions(
390 &config.permissions,
391 active_primary_agent_permissions(agent),
392 workspace_root,
393 current_dir,
394 request,
395 )
396}
397
398fn merge_primary_mcp_servers(config: &mut VTCodeConfig, servers: &[SubagentMcpServer]) {
399 for server in servers {
400 match server {
401 SubagentMcpServer::Named(_) => {}
402 SubagentMcpServer::Inline(definition) => {
403 for (name, value) in definition {
404 if config.mcp.providers.iter().any(|provider| provider.name == *name) {
405 continue;
406 }
407 if let Some(provider) = inline_mcp_provider(name, value) {
408 config.mcp.providers.push(provider);
409 }
410 }
411 }
412 }
413 }
414}
415
416fn merge_active_primary_hooks(config: &mut HooksConfig, hooks: Option<&HooksConfig>) {
417 let Some(hooks) = hooks else {
418 return;
419 };
420
421 config.lifecycle.quiet_success_output |= hooks.lifecycle.quiet_success_output;
422 append_hook_groups(&mut config.lifecycle.session_start, &hooks.lifecycle.session_start);
423 append_hook_groups(&mut config.lifecycle.session_end, &hooks.lifecycle.session_end);
424 append_hook_groups(&mut config.lifecycle.subagent_start, &hooks.lifecycle.subagent_start);
425 append_hook_groups(&mut config.lifecycle.subagent_stop, &hooks.lifecycle.subagent_stop);
426 append_hook_groups(&mut config.lifecycle.user_prompt_submit, &hooks.lifecycle.user_prompt_submit);
427 append_hook_groups(&mut config.lifecycle.pre_tool_use, &hooks.lifecycle.pre_tool_use);
428 append_hook_groups(&mut config.lifecycle.post_tool_use, &hooks.lifecycle.post_tool_use);
429 append_hook_groups(&mut config.lifecycle.permission_request, &hooks.lifecycle.permission_request);
430 append_hook_groups(&mut config.lifecycle.pre_compact, &hooks.lifecycle.pre_compact);
431 append_hook_groups(&mut config.lifecycle.stop, &hooks.lifecycle.stop);
432 append_hook_groups(&mut config.lifecycle.notification, &hooks.lifecycle.notification);
433}
434
435fn append_hook_groups(target: &mut Vec<HookGroupConfig>, source: &[HookGroupConfig]) {
436 target.extend(source.iter().cloned());
437}
438
439fn inline_mcp_provider(name: &str, value: &serde_json::Value) -> Option<McpProviderConfig> {
440 let object = value.as_object()?;
441 let mut payload = serde_json::Map::with_capacity(object.len().saturating_add(1));
442 payload.insert("name".to_string(), serde_json::Value::String(name.to_string()));
443 for (key, value) in object {
444 if key == "type" {
445 continue;
446 }
447 payload.insert(key.clone(), value.clone());
448 }
449 if payload.contains_key("command") && !payload.contains_key("args") {
450 payload.insert("args".to_string(), serde_json::Value::Array(Vec::new()));
451 }
452 serde_json::from_value(serde_json::Value::Object(payload)).ok()
453}
454
455#[cfg(test)]
456mod tests {
457 use std::collections::BTreeMap;
458
459 use serde_json::json;
460 use tempfile::TempDir;
461 use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
462 use vtcode_config::{
463 HookCommandConfig, HooksConfig, IsolationMode, SubagentDiscoveryInput, SubagentMcpServer, SubagentMemoryScope,
464 SubagentSource, builtin_plan_agent, builtin_primary_auto_agent, builtin_primary_duck_agent, builtin_subagents,
465 discover_subagents,
466 };
467
468 use crate::config::constants::tools;
469 use crate::permissions::{ResolvedPermissionDecision, build_permission_request};
470
471 use super::*;
472
473 #[test]
474 fn resolves_existing_spec_by_name() {
475 let spec = test_spec("planner");
476 let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
477
478 assert_eq!(active.identity.name, "planner");
479 assert_eq!(active.display_name, "planner");
480 assert_eq!(active.description, "planner description");
481 assert_eq!(active.color.as_deref(), Some("blue"));
482 assert_eq!(active.instructions, "planner instructions");
483 assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
484 assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
485 assert_eq!(active.permissions.default, PermissionDefault::Deny);
486 assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
487 assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
488 assert!(active.hooks.is_none());
489 }
490
491 #[test]
492 fn unknown_agent_error_preserves_current_active_agent() {
493 let current = test_spec("current");
494 let specs = vec![current.clone()];
495 let mut state = ActivePrimaryAgentState::default();
496 let original = state.select_from_specs(&specs, "current").expect("initial selection").clone();
497
498 let error = state.select_from_specs(&specs, "missing").expect_err("unknown agent");
499
500 assert_eq!(error, PrimaryAgentResolutionError::UnknownAgent { requested: "missing".to_string() });
501 assert_eq!(state.active(), &original);
502 }
503
504 #[test]
505 fn restore_snapshot_returns_to_write_capable_build_agent() {
506 let mut state = ActivePrimaryAgentState::default();
507 let build_snapshot = state.active().clone();
508 state
509 .select_from_specs(&[test_spec("duck")], "duck")
510 .expect("select leaked agent");
511 assert_eq!(state.active().identity.name, "duck");
512
513 state.restore_snapshot(build_snapshot);
514
515 assert_eq!(state.active().identity.name, "build");
516 }
517
518 #[test]
519 fn alias_resolution_uses_existing_matches_name_semantics() {
520 let mut spec = test_spec("reviewer");
521 spec.aliases = vec!["critic".to_string()];
522
523 let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
524
525 assert_eq!(active.identity.name, "reviewer");
526 assert_eq!(active.display_name, "reviewer");
527 }
528
529 #[test]
530 fn exact_name_resolution_wins_over_alias() {
531 let mut build = test_spec("build");
532 build.aliases = vec!["builder".to_string()];
533 let builder = test_spec("builder");
534
535 let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
536
537 assert_eq!(active.identity.name, "builder");
538 }
539
540 #[test]
541 fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
542 let mut spec = test_spec("worker");
543 spec.aliases = vec!["builder".to_string()];
544 spec.skills = vec!["rust".to_string()];
545 spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
546 spec.background = true;
547 spec.max_turns = Some(12);
548 spec.nickname_candidates = vec!["w".to_string()];
549 spec.initial_prompt = Some("start here".to_string());
550 spec.memory = Some(SubagentMemoryScope::Project);
551 spec.isolation = Some(IsolationMode::Full);
552
553 let active = ActivePrimaryAgent::from_spec(&spec);
554
555 assert_eq!(active.identity.name, "worker");
556 assert_eq!(active.display_name, "worker");
557 assert_eq!(active.description, "worker description");
558 assert_eq!(active.color.as_deref(), Some("blue"));
559 assert_eq!(active.aliases, vec!["builder".to_string()]);
560 assert_eq!(active.instructions, "worker instructions");
561 assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
562 assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
563 assert_eq!(active.permissions.default, PermissionDefault::Deny);
564 assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
565 assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
566 assert_eq!(active.skills, vec!["rust".to_string()]);
567 assert_eq!(active.mcp_servers, vec![SubagentMcpServer::Named("filesystem".to_string())]);
568 assert_eq!(active.memory, Some(SubagentMemoryScope::Project));
569 }
570
571 #[test]
572 fn primary_runtime_adapter_uses_shared_resolved_view_for_overlapping_fields() {
573 let mut spec = test_spec("worker");
574 spec.description = "Worker display metadata".to_string();
575 spec.color = Some("green".to_string());
576 spec.aliases = vec!["builder".to_string()];
577 spec.skills = vec!["rust".to_string(), "repo".to_string()];
578 spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
579 spec.hooks = Some(HooksConfig::default());
580 spec.memory = Some(SubagentMemoryScope::Project);
581
582 let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
583 let active = ActivePrimaryAgent::from_runtime_view(&runtime);
584
585 assert_eq!(runtime.canonical_name, "worker");
586 assert_eq!(runtime.display_name, "worker");
587 assert_eq!(runtime.description, "Worker display metadata");
588 assert_eq!(runtime.color.as_deref(), Some("green"));
589 assert_eq!(runtime.aliases, vec!["builder".to_string()]);
590 assert_eq!(runtime.skills, vec!["rust".to_string(), "repo".to_string()]);
591 assert_eq!(runtime.mcp_servers.len(), 1);
592 assert!(runtime.hooks.is_some());
593 assert_eq!(runtime.memory, Some(SubagentMemoryScope::Project));
594 assert!(runtime.read_only);
595 assert_eq!(active.identity.name, runtime.canonical_name);
596 assert_eq!(active.display_name, runtime.display_name);
597 assert_eq!(active.description, runtime.description);
598 assert_eq!(active.color, runtime.color);
599 assert_eq!(active.aliases, runtime.aliases);
600 assert_eq!(active.instructions, runtime.instructions);
601 assert_eq!(active.tools, runtime.tools);
602 assert_eq!(active.disallowed_tools, runtime.disallowed_tools);
603 assert_eq!(active.permissions, runtime.permissions);
604 assert_eq!(active.model, runtime.model);
605 assert_eq!(active.reasoning_effort, runtime.reasoning_effort);
606 assert_eq!(active.hooks, runtime.hooks);
607 assert_eq!(active.skills, runtime.skills);
608 assert_eq!(active.mcp_servers, runtime.mcp_servers);
609 assert_eq!(active.memory, runtime.memory);
610 }
611
612 #[test]
613 fn default_state_uses_builtin_build_agent() {
614 let mut state = ActivePrimaryAgentState::default();
615
616 assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
617 assert_eq!(state.active().identity.name, "build");
618 assert_eq!(state.active().identity.source, SubagentSource::Builtin);
619
620 state.select_from_specs(&[test_spec("worker")], "worker").expect("selected");
621 assert_eq!(state.active().identity.name, "worker");
622
623 state.reset_to_default_from_specs(&[]);
624
625 assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
626 assert_eq!(state.active().identity.name, "build");
627 assert_eq!(state.active().identity.source, SubagentSource::Builtin);
628 }
629
630 #[test]
631 fn from_specs_falls_back_to_builtin_build_agent() {
632 let active = ActivePrimaryAgentState::from_specs(&[]);
633
634 assert_eq!(active.active().identity.name, "build");
635 assert_eq!(active.active().identity.source, SubagentSource::Builtin);
636 }
637
638 #[test]
639 fn from_specs_with_default_selects_configured_primary_agent() {
640 let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "builder");
641
642 assert_eq!(active.active().identity.name, "builder");
643 }
644
645 #[test]
646 fn from_specs_with_default_falls_back_to_build_for_missing_configured_agent() {
647 let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "missing");
648
649 assert_eq!(active.active().identity.name, "build");
650 assert_eq!(active.active().identity.source, SubagentSource::Builtin);
651 }
652
653 #[test]
654 fn fallback_notice_silent_for_build_and_empty() {
655 assert_eq!(fallback_notice("build"), None);
658 assert_eq!(fallback_notice("BUILD"), None);
659 assert_eq!(fallback_notice("Build"), None);
660 assert_eq!(fallback_notice(""), None);
661 assert_eq!(fallback_notice(" "), None);
662 }
663
664 #[test]
665 fn fallback_notice_surfaces_non_default_mismatches() {
666 assert_eq!(fallback_notice("duck"), Some("duck"));
669 assert_eq!(fallback_notice("auto"), Some("auto"));
670 assert_eq!(fallback_notice(" duck "), Some("duck"));
671 }
672
673 #[test]
674 fn discovery_precedence_overrides_builtin_build_agent() {
675 let temp = TempDir::new().expect("tempdir");
676 let discovered = discover_subagents(&SubagentDiscoveryInput {
677 workspace_root: temp.path().to_path_buf(),
678 cli_agents: Some(json!({
679 "build": {
680 "description": "CLI build",
681 "prompt": "cli build instructions",
682 "model": "gpt-cli",
683 "mode": "primary",
684 "permissions": { "default": "ask" }
685 }
686 })),
687 plugin_agent_files: Vec::new(),
688 include_user_agents: false,
689 })
690 .expect("discovered subagents");
691
692 let active = ActivePrimaryAgentState::from_discovery(&discovered);
693
694 assert_eq!(active.active().identity.name, "build");
695 assert_eq!(active.active().identity.source, SubagentSource::Cli);
696 assert_eq!(active.active().instructions, "cli build instructions");
697 assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
698 }
699
700 #[test]
701 fn default_build_agent_allows_baseline_read_and_exec_tools() {
702 let active = ActivePrimaryAgentState::default();
703
704 assert!(primary_agent_allows_tool(active.active(), tools::CODE_SEARCH));
705 assert!(primary_agent_allows_tool(active.active(), tools::EXEC_COMMAND));
706 assert!(primary_agent_allows_tool(active.active(), tools::APPLY_PATCH));
707 assert!(primary_agent_allows_tool(active.active(), tools::RUN_PTY_CMD));
708 assert_eq!(active.active().permissions.default, PermissionDefault::Ask);
709 }
710
711 #[test]
712 fn build_and_auto_expose_the_same_tools_and_policy_decisions() {
713 let build = ActivePrimaryAgent::from_spec(&builtin_primary_build_agent());
714 let auto = ActivePrimaryAgent::from_spec(&builtin_primary_auto_agent());
715 let tools_to_compare = [
716 tools::CODE_SEARCH,
717 tools::EXEC_COMMAND,
718 tools::APPLY_PATCH,
719 tools::RUN_PTY_CMD,
720 tools::WRITE_FILE,
721 tools::REQUEST_USER_INPUT,
722 ];
723
724 for tool in tools_to_compare {
725 assert_eq!(
726 primary_agent_allows_tool(&build, tool),
727 primary_agent_allows_tool(&auto, tool),
728 "Build and Auto must expose the same capability for {tool}"
729 );
730 }
731 }
732
733 #[test]
734 fn readonly_primaries_expose_public_read_tools_and_block_apply_patch() {
735 for spec in [builtin_primary_duck_agent(), builtin_plan_agent()] {
736 let name = spec.name.clone();
737 let active = ActivePrimaryAgent::from_spec(&spec);
738
739 assert!(primary_agent_allows_tool(&active, tools::CODE_SEARCH), "{name} should expose code_search");
740 assert!(primary_agent_allows_tool(&active, tools::EXEC_COMMAND), "{name} should expose exec_command");
741 assert!(primary_agent_allows_tool(&active, tools::REQUEST_USER_INPUT));
742 assert!(primary_agent_allows_tool(&active, tools::RECORD_DECISION));
743
744 assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH), "{name} must not expose apply_patch");
745 assert!(!primary_agent_allows_tool(&active, tools::RUN_PTY_CMD), "{name} must not expose run_pty_cmd");
746 assert!(!primary_agent_allows_tool(&active, tools::WRITE_FILE), "{name} must not expose write_file");
747 }
748 }
749
750 #[test]
751 fn tool_policy_intersects_allow_list_then_applies_deny_list() {
752 let mut spec = test_spec("worker");
753 spec.tools = Some(vec![" \tCoDe_SeArCh\n".to_string(), "\u{2003}ExEc_CoMmAnD\u{a0}".to_string()]);
754 spec.disallowed_tools = vec!["\u{a0}CODE_search\u{2003}".to_string()];
755 let active = ActivePrimaryAgent::from_spec(&spec);
756
757 assert!(primary_agent_allows_tool(&active, "\u{a0}EXEC_command\u{2003}"));
758 assert!(!primary_agent_allows_tool(&active, " \tcode_SEARCH\n"));
759 assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
760 assert!(!primary_agent_allows_tool(&active, "exec_command_extra"));
761 assert!(!primary_agent_allows_tool(&active, tools::RUN_PTY_CMD));
762 }
763
764 #[test]
765 fn absent_tool_allow_list_allows_names_except_deny_matches() {
766 let mut spec = test_spec("worker");
767 spec.tools = None;
768 spec.disallowed_tools = vec![" \tCoDe_SeArCh\n".to_string()];
769 let active = ActivePrimaryAgent::from_spec(&spec);
770
771 assert!(primary_agent_allows_tool(&active, " ExEc_CoMmAnD "));
772 assert!(primary_agent_allows_tool(&active, "custom_tool"));
773 assert!(!primary_agent_allows_tool(&active, " CODE_search "));
774 }
775
776 #[test]
777 fn tool_policy_preserves_empty_name_matching() {
778 let mut spec = test_spec("worker");
779 spec.tools = None;
780 spec.disallowed_tools.clear();
781 let mut active = ActivePrimaryAgent::from_spec(&spec);
782
783 assert!(primary_agent_allows_tool(&active, ""));
784 assert!(primary_agent_allows_tool(&active, "\u{2003}\t\u{a0}"));
785 active.tools = Some(Vec::new());
786 assert!(!primary_agent_allows_tool(&active, ""));
787 assert!(!primary_agent_allows_tool(&active, "\u{2003}\t\u{a0}"));
788 active.tools = Some(vec!["\u{a0} \t".to_string()]);
789 assert!(primary_agent_allows_tool(&active, ""));
790 assert!(primary_agent_allows_tool(&active, "\u{2003}"));
791 assert!(!primary_agent_allows_tool(&active, "custom_tool"));
792 active.disallowed_tools = vec![String::new()];
793 assert!(!primary_agent_allows_tool(&active, ""));
794 assert!(!primary_agent_allows_tool(&active, "\u{2003}"));
795 }
796
797 #[test]
798 fn tool_policy_keeps_non_ascii_case_and_non_whitespace_distinct() {
799 let mut spec = test_spec("worker");
800 spec.tools = Some(vec!["\u{2003}CAFÉ\u{a0}".to_string()]);
801 spec.disallowed_tools = vec![" café ".to_string()];
802 let mut active = ActivePrimaryAgent::from_spec(&spec);
803
804 assert!(primary_agent_allows_tool(&active, " caFÉ "));
805 assert!(!primary_agent_allows_tool(&active, "café"));
806 assert!(!primary_agent_allows_tool(&active, "CAFE"));
807 assert!(!primary_agent_allows_tool(&active, "\u{200b}CAFÉ\u{200b}"));
808
809 active.tools = None;
810 assert!(primary_agent_allows_tool(&active, "CAFÉ"));
811 assert!(!primary_agent_allows_tool(&active, "CaFé"));
812 }
813
814 #[test]
815 fn mixed_case_cleanup_and_tracker_names_bypass_empty_allow_list_and_explicit_denials() {
816 let mut spec = test_spec("restricted");
817 spec.tools = Some(Vec::new());
818 spec.disallowed_tools = vec![
819 " Wait_AGENT ".to_string(),
820 "\u{2003}Close_Agent\u{a0}".to_string(),
821 "\tTask_TRACKER\n".to_string(),
822 ];
823 let active = ActivePrimaryAgent::from_spec(&spec);
824
825 assert!(primary_agent_allows_tool(&active, "\u{a0}WAIT_agent\u{2003}"));
826 assert!(primary_agent_allows_tool(&active, " Close_AGENT "));
827 assert!(primary_agent_allows_tool(&active, " TaSk_TrAcKeR "));
828 assert!(!primary_agent_allows_tool(&active, " Agent "));
829 assert!(!primary_agent_allows_tool(&active, " Spawn_AGENT "));
830 assert!(!primary_agent_allows_tool(&active, "task_tracker_extra"));
831 assert!(!primary_agent_allows_tool(&active, "wait_agent_extra"));
832 }
833
834 #[test]
835 fn filtered_tool_catalog_preserves_surviving_names_and_order() {
836 let mut spec = test_spec("worker");
837 spec.tools = Some(vec![
838 " APPLY_patch ".to_string(),
839 " EXEC_command ".to_string(),
840 " CODE_search ".to_string(),
841 ]);
842 spec.disallowed_tools = vec![" code_SEARCH ".to_string()];
843 let active = ActivePrimaryAgent::from_spec(&spec);
844 let catalog = Arc::new(
845 [
846 "Exec_Command",
847 "code_search",
848 "WaIt_AgEnT",
849 "apply_patch",
850 "TASK_TRACKER",
851 "run_pty_cmd",
852 "agent",
853 ]
854 .into_iter()
855 .map(|name| ToolDefinition::function(name.to_string(), String::new(), json!({"type": "object"})))
856 .collect(),
857 );
858
859 let filtered = apply_primary_agent_tool_policy(Some(Arc::clone(&catalog)), &active).expect("surviving tools");
860
861 assert!(!Arc::ptr_eq(&filtered, &catalog));
862 assert_eq!(
863 filtered.iter().map(ToolDefinition::function_name).collect::<Vec<_>>(),
864 ["Exec_Command", "WaIt_AgEnT", "apply_patch", "TASK_TRACKER"]
865 );
866 assert_eq!(catalog.len(), 7);
867 assert_eq!(catalog[1].function_name(), "code_search");
868 }
869
870 #[test]
871 fn unchanged_tool_catalog_reuses_shared_definitions() {
872 let mut spec = test_spec("worker");
873 spec.tools = None;
874 spec.disallowed_tools.clear();
875 let active = ActivePrimaryAgent::from_spec(&spec);
876 let catalog = Arc::new(vec![ToolDefinition::function(
877 "exec_command".to_string(),
878 "Run a command".to_string(),
879 json!({"type": "object", "properties": {"cmd": {"type": "string"}}}),
880 )]);
881
882 let filtered = apply_primary_agent_tool_policy(Some(Arc::clone(&catalog)), &active).expect("unchanged catalog");
883
884 assert!(Arc::ptr_eq(&filtered, &catalog));
885 assert_eq!(filtered[0].function_name(), "exec_command");
886 }
887
888 #[test]
889 fn tool_policy_returns_none_for_absent_empty_or_fully_denied_catalogs() {
890 let active = ActivePrimaryAgent::from_spec(&test_spec("worker"));
891 assert!(apply_primary_agent_tool_policy(None, &active).is_none());
892 assert!(apply_primary_agent_tool_policy(Some(Arc::new(Vec::new())), &active).is_none());
893 let denied = Arc::new(vec![ToolDefinition::function(
894 "apply_patch".to_string(),
895 String::new(),
896 json!({"type": "object"}),
897 )]);
898 assert!(apply_primary_agent_tool_policy(Some(denied), &active).is_none());
899 }
900
901 #[test]
902 fn empty_present_tool_allow_list_exposes_no_tools() {
903 let mut spec = test_spec("worker");
904 spec.tools = Some(Vec::new());
905 spec.disallowed_tools = Vec::new();
906 let active = ActivePrimaryAgent::from_spec(&spec);
907
908 assert!(!primary_agent_allows_tool(&active, tools::CODE_SEARCH));
909 }
910
911 #[test]
927 fn subagent_cleanup_tools_bypass_policy_but_new_work_obeys_policy() {
928 let mut spec = test_spec("restricted");
929 spec.tools = Some(vec!["code_search".to_string()]);
930 spec.disallowed_tools = vec![
931 tools::SPAWN_AGENT.to_string(),
932 tools::WAIT_AGENT.to_string(),
933 tools::CLOSE_AGENT.to_string(),
934 tools::SEND_INPUT.to_string(),
935 tools::RESUME_AGENT.to_string(),
936 tools::SPAWN_BACKGROUND_SUBPROCESS.to_string(),
937 ];
938 let active = ActivePrimaryAgent::from_spec(&spec);
939
940 assert!(!primary_agent_allows_tool(&active, "command_session"));
942 assert!(!primary_agent_allows_tool(&active, "file_operation"));
943 assert!(!primary_agent_allows_tool(&active, tools::SPAWN_AGENT));
944 assert!(!primary_agent_allows_tool(&active, tools::SPAWN_BACKGROUND_SUBPROCESS));
945 assert!(!primary_agent_allows_tool(&active, tools::SEND_INPUT));
946 assert!(!primary_agent_allows_tool(&active, tools::RESUME_AGENT));
947
948 assert!(primary_agent_allows_tool(&active, tools::WAIT_AGENT));
951 assert!(primary_agent_allows_tool(&active, tools::CLOSE_AGENT));
952
953 assert!(!primary_agent_allows_tool(&active, tools::AGENT));
957 }
958
959 #[test]
960 fn task_tracker_bypasses_restricted_tool_allow_list_but_mode_tools_obey_it() {
961 let mut spec = test_spec("restricted");
965 spec.tools = Some(vec![tools::CODE_SEARCH.to_string()]);
966 let active = ActivePrimaryAgent::from_spec(&spec);
967
968 assert!(primary_agent_allows_tool(&active, tools::TASK_TRACKER));
969 assert!(!primary_agent_allows_tool(&active, tools::START_PLANNING));
970 assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
971 }
972
973 #[test]
974 fn task_tracker_bypass_survives_explicit_disallow_entry() {
975 let mut spec = test_spec("restricted");
978 spec.tools = Some(vec![tools::CODE_SEARCH.to_string()]);
979 spec.disallowed_tools = vec![tools::TASK_TRACKER.to_string()];
980 let active = ActivePrimaryAgent::from_spec(&spec);
981
982 assert!(primary_agent_allows_tool(&active, tools::TASK_TRACKER));
983 assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
984 }
985
986 #[test]
987 fn build_primary_agent_runtime_config_preserves_baseline_fields_and_merges_mcp() {
988 let mut parent = VTCodeConfig::default();
989 parent.agent.default_model = "parent-model".to_string();
990 parent.mcp.providers.push(
991 serde_json::from_value(json!({
992 "name": "global",
993 "command": "global-mcp",
994 "args": []
995 }))
996 .expect("global provider"),
997 );
998
999 let mut spec = test_spec("worker");
1000 spec.permissions = AgentPermissionsConfig::new(PermissionDefault::Auto);
1001 spec.model = Some("agent-model".to_string());
1002 spec.reasoning_effort = Some(ReasoningEffortLevel::Low);
1003 spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([
1004 (
1005 "global".to_string(),
1006 json!({
1007 "type": "stdio",
1008 "command": "duplicate-mcp"
1009 }),
1010 ),
1011 (
1012 "local".to_string(),
1013 json!({
1014 "type": "stdio",
1015 "command": "local-mcp"
1016 }),
1017 ),
1018 ]))];
1019 let active = ActivePrimaryAgent::from_spec(&spec);
1020
1021 let runtime = build_primary_agent_runtime_config(&parent, &active);
1022
1023 assert_eq!(runtime.agent.default_model, "agent-model");
1024 assert_eq!(runtime.agent.reasoning_effort, ReasoningEffortLevel::Low);
1025 assert_eq!(runtime.mcp.providers.len(), 2);
1026 assert_eq!(runtime.mcp.providers[0].name, "global");
1027 assert_eq!(runtime.mcp.providers[1].name, "local");
1028 }
1029
1030 #[test]
1031 fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_says_inherit() {
1032 let mut parent = VTCodeConfig::default();
1033 parent.agent.default_model = "parent-model".to_string();
1034
1035 let mut spec = test_spec("auto");
1036 spec.model = Some("inherit".to_string());
1037 let active = ActivePrimaryAgent::from_spec(&spec);
1038
1039 let runtime = build_primary_agent_runtime_config(&parent, &active);
1040
1041 assert_eq!(runtime.agent.default_model, "parent-model");
1042 }
1043
1044 #[test]
1045 fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_model_empty() {
1046 let mut parent = VTCodeConfig::default();
1047 parent.agent.default_model = "parent-model".to_string();
1048
1049 let mut spec = test_spec("auto");
1050 spec.model = Some(String::new());
1051 let active = ActivePrimaryAgent::from_spec(&spec);
1052
1053 let runtime = build_primary_agent_runtime_config(&parent, &active);
1054
1055 assert_eq!(runtime.agent.default_model, "parent-model");
1056 }
1057
1058 #[test]
1059 fn built_in_primary_agents_resolve_required_permission_policy() {
1060 let builtins = builtin_subagents();
1061
1062 for name in ["duck", "plan", "build", "auto"] {
1063 let active = resolve_primary_agent(&builtins, name)
1064 .unwrap_or_else(|_| panic!("missing built-in primary agent {name}"));
1065 assert_eq!(active.identity.name, name);
1066 let expected_default = match name {
1067 "build" => PermissionDefault::Ask,
1068 "auto" => PermissionDefault::Auto,
1069 "plan" | "duck" => PermissionDefault::Deny,
1070 _ => unreachable!("unexpected built-in primary agent"),
1071 };
1072 assert_eq!(active.permissions.default, expected_default);
1073 }
1074 }
1075
1076 #[test]
1077 fn active_primary_permissions_overlay_runtime_decisions() {
1078 let builtins = builtin_subagents();
1079 let mut state = ActivePrimaryAgentState::from_specs(&builtins);
1080 let config = VTCodeConfig::default();
1081 let workspace = TempDir::new().expect("workspace");
1082 let current_dir = workspace.path();
1083
1084 state.select_from_specs(&builtins, "auto").expect("auto primary");
1085 let exec = build_permission_request(
1086 workspace.path(),
1087 current_dir,
1088 tools::UNIFIED_EXEC,
1089 Some(&json!({"command": "cargo test"})),
1090 );
1091 assert_eq!(
1092 evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &exec,),
1093 ResolvedPermissionDecision::Auto
1094 );
1095
1096 state.select_from_specs(&builtins, "plan").expect("plan primary");
1097 let edit = build_permission_request(
1098 workspace.path(),
1099 current_dir,
1100 tools::UNIFIED_FILE,
1101 Some(&json!({"action": "edit", "path": "src/lib.rs"})),
1102 );
1103 assert_eq!(
1104 evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &edit,),
1105 ResolvedPermissionDecision::Deny
1106 );
1107 }
1108
1109 #[test]
1110 fn primary_agent_switching_changes_permission_policy_without_mutating_parent_config() {
1111 let builtins = builtin_subagents();
1112 let mut state = ActivePrimaryAgentState::from_specs(&builtins);
1113 let initial_model = state.active().model.clone();
1114 let initial_tools = state.active().tools.clone();
1115
1116 state.select_from_specs(&builtins, "auto").expect("auto primary");
1117 assert_eq!(state.active().identity.name, "auto");
1118 assert_eq!(state.active().permissions.default, PermissionDefault::Auto);
1119 assert_eq!(state.active().model, initial_model);
1120 assert_eq!(state.active().tools, initial_tools);
1121
1122 state.select_from_specs(&builtins, "duck").expect("duck primary");
1123 assert_eq!(state.active().identity.name, "duck");
1124 assert_eq!(state.active().permissions.default, PermissionDefault::Deny);
1125 assert_eq!(state.active().model, initial_model);
1126 assert_ne!(state.active().tools, initial_tools);
1127 }
1128
1129 #[test]
1130 fn primary_hook_config_merges_supported_main_session_events_after_global_hooks() {
1131 let mut global = HooksConfig::default();
1132 global.lifecycle.user_prompt_submit = vec![hook_group("global-user")];
1133 global.lifecycle.pre_tool_use = vec![hook_group("global-pre")];
1134 global.lifecycle.post_tool_use = vec![hook_group("global-post")];
1135 global.lifecycle.permission_request = vec![hook_group("global-permission")];
1136 global.lifecycle.pre_compact = vec![hook_group("global-compact")];
1137 global.lifecycle.stop = vec![hook_group("global-stop")];
1138 global.lifecycle.notification = vec![hook_group("global-notification")];
1139
1140 let mut primary_hooks = HooksConfig::default();
1141 primary_hooks.lifecycle.user_prompt_submit = vec![hook_group("primary-user")];
1142 primary_hooks.lifecycle.pre_tool_use = vec![hook_group("primary-pre")];
1143 primary_hooks.lifecycle.post_tool_use = vec![hook_group("primary-post")];
1144 primary_hooks.lifecycle.permission_request = vec![hook_group("primary-permission")];
1145 primary_hooks.lifecycle.pre_compact = vec![hook_group("primary-compact")];
1146 primary_hooks.lifecycle.stop = vec![hook_group("primary-stop")];
1147 primary_hooks.lifecycle.notification = vec![hook_group("primary-notification")];
1148
1149 let mut spec = test_spec("worker");
1150 spec.hooks = Some(primary_hooks);
1151 let active = ActivePrimaryAgent::from_spec(&spec);
1152
1153 let merged = build_primary_agent_hook_config(&global, &active);
1154
1155 assert_hook_commands(&merged.lifecycle.user_prompt_submit, &["global-user", "primary-user"]);
1156 assert_hook_commands(&merged.lifecycle.pre_tool_use, &["global-pre", "primary-pre"]);
1157 assert_hook_commands(&merged.lifecycle.post_tool_use, &["global-post", "primary-post"]);
1158 assert_hook_commands(&merged.lifecycle.permission_request, &["global-permission", "primary-permission"]);
1159 assert_hook_commands(&merged.lifecycle.pre_compact, &["global-compact", "primary-compact"]);
1160 assert_hook_commands(&merged.lifecycle.stop, &["global-stop", "primary-stop"]);
1161 assert_hook_commands(&merged.lifecycle.notification, &["global-notification", "primary-notification"]);
1162 }
1163
1164 #[test]
1165 fn primary_hook_config_merges_session_and_subagent_lifecycle_events() {
1166 let global = HooksConfig::default();
1167 let mut primary_hooks = HooksConfig::default();
1168 primary_hooks.lifecycle.session_start = vec![hook_group("primary-session-start")];
1169 primary_hooks.lifecycle.session_end = vec![hook_group("primary-session-end")];
1170 primary_hooks.lifecycle.subagent_start = vec![hook_group("primary-subagent-start")];
1171 primary_hooks.lifecycle.subagent_stop = vec![hook_group("primary-subagent-stop")];
1172 primary_hooks.lifecycle.task_completion = vec![hook_group("primary-task-completion")];
1173 primary_hooks.lifecycle.task_completed = vec![hook_group("primary-task-completed")];
1174
1175 let mut spec = test_spec("worker");
1176 spec.hooks = Some(primary_hooks);
1177 let active = ActivePrimaryAgent::from_spec(&spec);
1178
1179 let merged = build_primary_agent_hook_config(&global, &active);
1180
1181 assert_hook_commands(&merged.lifecycle.session_start, &["primary-session-start"]);
1182 assert_hook_commands(&merged.lifecycle.session_end, &["primary-session-end"]);
1183 assert_hook_commands(&merged.lifecycle.subagent_start, &["primary-subagent-start"]);
1184 assert_hook_commands(&merged.lifecycle.subagent_stop, &["primary-subagent-stop"]);
1185 assert!(merged.lifecycle.task_completion.is_empty());
1186 assert!(merged.lifecycle.task_completed.is_empty());
1187 assert!(merged.lifecycle.stop.is_empty());
1188 }
1189
1190 #[test]
1191 fn primary_hook_config_recomputes_without_previous_primary_leakage() {
1192 let global = HooksConfig::default();
1193 let mut first_hooks = HooksConfig::default();
1194 first_hooks.lifecycle.pre_tool_use = vec![hook_group("first-pre")];
1195 let mut second_hooks = HooksConfig::default();
1196 second_hooks.lifecycle.pre_tool_use = vec![hook_group("second-pre")];
1197
1198 let mut first = test_spec("first");
1199 first.hooks = Some(first_hooks);
1200 let mut second = test_spec("second");
1201 second.hooks = Some(second_hooks);
1202 let specs = vec![first, second];
1203 let mut state = ActivePrimaryAgentState::default();
1204
1205 state.select_from_specs(&specs, "first").expect("selected first");
1206 let first_config = build_primary_agent_hook_config(&global, state.active());
1207 assert_hook_commands(&first_config.lifecycle.pre_tool_use, &["first-pre"]);
1208
1209 state.select_from_specs(&specs, "second").expect("selected second");
1210 let second_config = build_primary_agent_hook_config(&global, state.active());
1211 assert_hook_commands(&second_config.lifecycle.pre_tool_use, &["second-pre"]);
1212 }
1213
1214 #[test]
1215 fn active_primary_state_recomputes_skills_mcp_and_metadata_on_switch() {
1216 let mut first = test_spec("first");
1217 first.description = "First metadata".to_string();
1218 first.color = Some("red".to_string());
1219 first.aliases = vec!["one".to_string()];
1220 first.skills = vec!["rust".to_string()];
1221 first.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
1222 "first-mcp".to_string(),
1223 json!({
1224 "type": "stdio",
1225 "command": "first-mcp"
1226 }),
1227 )]))];
1228 let second = test_spec("second");
1229 let specs = vec![first, second];
1230 let mut state = ActivePrimaryAgentState::default();
1231
1232 state.select_from_specs(&specs, "one").expect("selected first by alias");
1233 assert_eq!(state.active().identity.name, "first");
1234 assert_eq!(state.active().description, "First metadata");
1235 assert_eq!(state.active().color.as_deref(), Some("red"));
1236 assert_eq!(state.active().aliases, vec!["one".to_string()]);
1237 assert_eq!(state.active().skills, vec!["rust".to_string()]);
1238 assert_eq!(state.active().mcp_servers.len(), 1);
1239
1240 state.select_from_specs(&specs, "second").expect("selected second");
1241 assert_eq!(state.active().identity.name, "second");
1242 assert_eq!(state.active().description, "second description");
1243 assert_eq!(state.active().color.as_deref(), Some("blue"));
1244 assert!(state.active().aliases.is_empty());
1245 assert!(state.active().skills.is_empty());
1246 assert!(state.active().mcp_servers.is_empty());
1247 }
1248
1249 fn test_spec(name: &str) -> SubagentSpec {
1250 SubagentSpec {
1251 name: name.to_string(),
1252 description: format!("{name} description"),
1253 prompt: format!("{name} instructions"),
1254 tools: Some(vec!["code_search".to_string()]),
1255 disallowed_tools: vec!["file_operation".to_string()],
1256 model: Some("gpt-5.1".to_string()),
1257 color: Some("blue".to_string()),
1258 reasoning_effort: Some(ReasoningEffortLevel::High),
1259 permissions: AgentPermissionsConfig::new(PermissionDefault::Deny),
1260 skills: Vec::new(),
1261 mcp_servers: Vec::new(),
1262 hooks: None,
1263 background: false,
1264 mode: vtcode_config::AgentMode::Primary,
1265 max_turns: None,
1266 nickname_candidates: Vec::new(),
1267 initial_prompt: None,
1268 memory: None,
1269 isolation: None,
1270 aliases: Vec::new(),
1271 source: SubagentSource::ProjectVtcode,
1272 file_path: None,
1273 warnings: Vec::new(),
1274 tool_policy_overrides: std::collections::BTreeMap::new(),
1275 }
1276 }
1277
1278 fn hook_group(command: &str) -> HookGroupConfig {
1279 HookGroupConfig {
1280 matcher: None,
1281 hooks: vec![HookCommandConfig {
1282 command: command.to_string(),
1283 ..HookCommandConfig::default()
1284 }],
1285 }
1286 }
1287
1288 fn assert_hook_commands(groups: &[HookGroupConfig], expected: &[&str]) {
1289 let commands = groups
1290 .iter()
1291 .flat_map(|group| group.hooks.iter())
1292 .map(|hook| hook.command.as_str())
1293 .collect::<Vec<_>>();
1294 assert_eq!(commands, expected);
1295 }
1296}