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.contains(&tool_name)
241}
242
243fn 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 if is_subagent_cleanup_tool(&tool_name) {
271 return true;
272 }
273
274 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#[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 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 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 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 #[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 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 assert!(primary_agent_allows_tool(&active, tools::WAIT_AGENT));
802 assert!(primary_agent_allows_tool(&active, tools::CLOSE_AGENT));
803
804 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 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 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}