1use std::error::Error;
2use std::fmt;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use vtcode_config::constants::defaults::DEFAULT_PRIMARY_AGENT_NAME;
7use vtcode_config::core::permissions::AgentPermissionsConfig;
8use vtcode_config::{
9 DiscoveredSubagents, HookGroupConfig, HooksConfig, McpProviderConfig, SubagentMcpServer,
10 SubagentMemoryScope, SubagentSource, SubagentSpec, builtin_primary_duck_agent,
11};
12
13use crate::config::{ReasoningEffortLevel, VTCodeConfig};
14use crate::llm::provider::ToolDefinition;
15use crate::permissions::{
16 PermissionRequest, ResolvedPermissionDecision, evaluate_effective_permissions,
17};
18use crate::prompts::PromptContext;
19use crate::subagents::ResolvedAgentRuntimeView;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ActivePrimaryAgentSpecIdentity {
23 pub name: String,
24 pub source: SubagentSource,
25 pub file_path: Option<PathBuf>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct ActivePrimaryAgent {
30 pub identity: ActivePrimaryAgentSpecIdentity,
31 pub display_name: String,
32 pub description: String,
33 pub color: Option<String>,
34 pub aliases: Vec<String>,
35 pub instructions: String,
36 pub tools: Option<Vec<String>>,
37 pub disallowed_tools: Vec<String>,
38 pub permissions: AgentPermissionsConfig,
39 pub model: Option<String>,
40 pub reasoning_effort: Option<String>,
41 pub hooks: Option<HooksConfig>,
42 pub skills: Vec<String>,
43 pub mcp_servers: Vec<SubagentMcpServer>,
44 pub memory: Option<SubagentMemoryScope>,
45}
46
47impl ActivePrimaryAgent {
48 #[must_use]
49 pub fn from_spec(spec: &SubagentSpec) -> Self {
50 Self::from_runtime_view(&ResolvedAgentRuntimeView::from_spec(spec))
51 }
52
53 #[must_use]
54 pub fn from_runtime_view(runtime: &ResolvedAgentRuntimeView) -> Self {
55 Self {
56 identity: ActivePrimaryAgentSpecIdentity {
57 name: runtime.canonical_name.clone(),
58 source: runtime.source.clone(),
59 file_path: runtime.file_path.clone(),
60 },
61 display_name: runtime.display_name.clone(),
62 description: runtime.description.clone(),
63 color: runtime.color.clone(),
64 aliases: runtime.aliases.clone(),
65 instructions: runtime.instructions.clone(),
66 tools: runtime.tools.clone(),
67 disallowed_tools: runtime.disallowed_tools.clone(),
68 permissions: runtime.permissions.clone(),
69 model: runtime.model.clone(),
70 reasoning_effort: runtime.reasoning_effort.clone(),
71 hooks: runtime.hooks.clone(),
72 skills: runtime.skills.clone(),
73 mcp_servers: runtime.mcp_servers.clone(),
74 memory: runtime.memory,
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct ActivePrimaryAgentState {
81 active: ActivePrimaryAgent,
82}
83
84impl Default for ActivePrimaryAgentState {
85 fn default() -> Self {
86 Self {
87 active: ActivePrimaryAgent::from_spec(&builtin_primary_duck_agent()),
88 }
89 }
90}
91
92impl ActivePrimaryAgentState {
93 #[must_use]
94 pub const fn active(&self) -> &ActivePrimaryAgent {
95 &self.active
96 }
97
98 #[must_use]
99 pub fn from_discovery(discovered: &DiscoveredSubagents) -> Self {
100 Self::from_specs(&discovered.effective)
101 }
102
103 #[must_use]
104 pub fn from_specs(specs: &[SubagentSpec]) -> Self {
105 Self::from_specs_with_default(specs, DEFAULT_PRIMARY_AGENT_NAME)
106 }
107
108 #[must_use]
109 pub fn from_specs_with_default(specs: &[SubagentSpec], requested_default: &str) -> Self {
110 let requested = if requested_default.trim().is_empty() {
111 DEFAULT_PRIMARY_AGENT_NAME
112 } else {
113 requested_default.trim()
114 };
115 let active = resolve_primary_agent(specs, requested)
116 .unwrap_or_else(|_| ActivePrimaryAgent::from_spec(&builtin_primary_duck_agent()));
117 Self { active }
118 }
119
120 pub fn reset_to_default_from_specs(&mut self, specs: &[SubagentSpec]) -> &ActivePrimaryAgent {
121 self.active = Self::from_specs(specs).active;
122 &self.active
123 }
124
125 pub fn select_from_discovery(
126 &mut self,
127 discovered: &DiscoveredSubagents,
128 requested: &str,
129 ) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
130 self.select_from_specs(&discovered.effective, requested)
131 }
132
133 pub fn select_from_specs(
134 &mut self,
135 specs: &[SubagentSpec],
136 requested: &str,
137 ) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
138 let active = resolve_primary_agent(specs, requested)?;
139 self.active = active;
140 Ok(&self.active)
141 }
142}
143
144pub type PrimaryAgentResolutionResult<T> = Result<T, PrimaryAgentResolutionError>;
145
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum PrimaryAgentResolutionError {
148 UnknownAgent { requested: String },
149}
150
151impl fmt::Display for PrimaryAgentResolutionError {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 Self::UnknownAgent { requested } => write!(f, "Unknown primary agent {requested}"),
155 }
156 }
157}
158
159impl Error for PrimaryAgentResolutionError {}
160
161pub fn resolve_discovered_primary_agent(
162 discovered: &DiscoveredSubagents,
163 requested: &str,
164) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
165 resolve_primary_agent(&discovered.effective, requested)
166}
167
168pub fn resolve_primary_agent(
169 specs: &[SubagentSpec],
170 requested: &str,
171) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
172 specs
173 .iter()
174 .find(|spec| spec.is_primary() && spec.name.eq_ignore_ascii_case(requested))
175 .or_else(|| {
176 specs
177 .iter()
178 .find(|spec| spec.is_primary() && spec.matches_name(requested))
179 })
180 .map(ActivePrimaryAgent::from_spec)
181 .ok_or_else(|| PrimaryAgentResolutionError::UnknownAgent {
182 requested: requested.to_string(),
183 })
184}
185
186fn is_subagent_lifecycle_tool(tool_name: &str) -> bool {
192 matches!(
193 tool_name,
194 "spawn_agent"
195 | "spawn_background_subprocess"
196 | "send_input"
197 | "wait_agent"
198 | "resume_agent"
199 | "close_agent"
200 )
201}
202
203#[must_use]
204pub fn primary_agent_allows_tool(agent: &ActivePrimaryAgent, tool_name: &str) -> bool {
205 let tool_name = normalise_tool_name(tool_name);
206
207 if is_subagent_lifecycle_tool(&tool_name) {
210 return true;
211 }
212
213 let allow_list_allows = agent.tools.as_ref().is_none_or(|tools| {
214 tools
215 .iter()
216 .any(|allowed| normalise_tool_name(allowed) == tool_name)
217 });
218 if !allow_list_allows {
219 return false;
220 }
221
222 !agent
223 .disallowed_tools
224 .iter()
225 .any(|denied| normalise_tool_name(denied) == tool_name)
226}
227
228#[must_use]
229pub fn apply_primary_agent_tool_policy(
230 tools: Option<Arc<Vec<ToolDefinition>>>,
231 agent: &ActivePrimaryAgent,
232) -> Option<Arc<Vec<ToolDefinition>>> {
233 let tools = tools?;
234 let filtered = tools
235 .iter()
236 .filter(|tool| primary_agent_allows_tool(agent, tool.function_name()))
237 .cloned()
238 .collect::<Vec<_>>();
239
240 (!filtered.is_empty()).then(|| Arc::new(filtered))
241}
242
243#[must_use]
244pub fn build_primary_agent_runtime_config(
245 parent: &VTCodeConfig,
246 agent: &ActivePrimaryAgent,
247) -> VTCodeConfig {
248 let mut config = parent.clone();
249 if let Some(model) = agent.model.as_ref() {
250 config.agent.default_model = model.clone();
251 }
252 if let Some(reasoning_effort) = agent
253 .reasoning_effort
254 .as_deref()
255 .and_then(ReasoningEffortLevel::parse)
256 {
257 config.agent.reasoning_effort = reasoning_effort;
258 }
259 merge_primary_mcp_servers(&mut config, agent.mcp_servers.as_slice());
260 config
261}
262
263#[must_use]
264pub fn build_primary_agent_hook_config(
265 global: &HooksConfig,
266 agent: &ActivePrimaryAgent,
267) -> HooksConfig {
268 let mut config = global.clone();
269 merge_active_primary_hooks(&mut config, agent.hooks.as_ref());
270 config
271}
272
273pub fn apply_primary_agent_prompt_context(context: &mut PromptContext, agent: &ActivePrimaryAgent) {
274 context.replace_available_skills_with_named(agent.skills.as_slice());
275}
276
277#[must_use]
278pub fn active_primary_agent_permissions(agent: &ActivePrimaryAgent) -> &AgentPermissionsConfig {
279 &agent.permissions
280}
281
282#[must_use]
283pub fn evaluate_active_primary_agent_permissions(
284 config: &VTCodeConfig,
285 agent: &ActivePrimaryAgent,
286 workspace_root: &std::path::Path,
287 current_dir: &std::path::Path,
288 request: &PermissionRequest,
289) -> ResolvedPermissionDecision {
290 evaluate_effective_permissions(
291 &config.permissions,
292 active_primary_agent_permissions(agent),
293 workspace_root,
294 current_dir,
295 request,
296 )
297}
298
299fn normalise_tool_name(tool_name: &str) -> String {
300 tool_name.trim().to_ascii_lowercase()
301}
302
303fn merge_primary_mcp_servers(config: &mut VTCodeConfig, servers: &[SubagentMcpServer]) {
304 for server in servers {
305 match server {
306 SubagentMcpServer::Named(_) => {}
307 SubagentMcpServer::Inline(definition) => {
308 for (name, value) in definition {
309 if config
310 .mcp
311 .providers
312 .iter()
313 .any(|provider| provider.name == *name)
314 {
315 continue;
316 }
317 if let Some(provider) = inline_mcp_provider(name, value) {
318 config.mcp.providers.push(provider);
319 }
320 }
321 }
322 }
323 }
324}
325
326fn merge_active_primary_hooks(config: &mut HooksConfig, hooks: Option<&HooksConfig>) {
327 let Some(hooks) = hooks else {
328 return;
329 };
330
331 config.lifecycle.quiet_success_output |= hooks.lifecycle.quiet_success_output;
332 append_hook_groups(
333 &mut config.lifecycle.user_prompt_submit,
334 &hooks.lifecycle.user_prompt_submit,
335 );
336 append_hook_groups(
337 &mut config.lifecycle.pre_tool_use,
338 &hooks.lifecycle.pre_tool_use,
339 );
340 append_hook_groups(
341 &mut config.lifecycle.post_tool_use,
342 &hooks.lifecycle.post_tool_use,
343 );
344 append_hook_groups(
345 &mut config.lifecycle.permission_request,
346 &hooks.lifecycle.permission_request,
347 );
348 append_hook_groups(
349 &mut config.lifecycle.pre_compact,
350 &hooks.lifecycle.pre_compact,
351 );
352 append_hook_groups(&mut config.lifecycle.stop, &hooks.lifecycle.stop);
353 append_hook_groups(
354 &mut config.lifecycle.notification,
355 &hooks.lifecycle.notification,
356 );
357}
358
359fn append_hook_groups(target: &mut Vec<HookGroupConfig>, source: &[HookGroupConfig]) {
360 target.extend(source.iter().cloned());
361}
362
363fn inline_mcp_provider(name: &str, value: &serde_json::Value) -> Option<McpProviderConfig> {
364 let object = value.as_object()?;
365 let mut payload = serde_json::Map::with_capacity(object.len().saturating_add(1));
366 payload.insert(
367 "name".to_string(),
368 serde_json::Value::String(name.to_string()),
369 );
370 for (key, value) in object {
371 if key == "type" {
372 continue;
373 }
374 payload.insert(key.clone(), value.clone());
375 }
376 if payload.contains_key("command") && !payload.contains_key("args") {
377 payload.insert("args".to_string(), serde_json::Value::Array(Vec::new()));
378 }
379 serde_json::from_value(serde_json::Value::Object(payload)).ok()
380}
381
382#[cfg(test)]
383mod tests {
384 use std::collections::BTreeMap;
385
386 use serde_json::json;
387 use tempfile::TempDir;
388 use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
389 use vtcode_config::{
390 HookCommandConfig, HooksConfig, SubagentDiscoveryInput, SubagentMcpServer,
391 SubagentMemoryScope, SubagentSource, builtin_subagents, discover_subagents,
392 };
393
394 use crate::config::constants::tools;
395 use crate::permissions::{ResolvedPermissionDecision, build_permission_request};
396
397 use super::*;
398
399 #[test]
400 fn resolves_existing_spec_by_name() {
401 let spec = test_spec("planner");
402 let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
403
404 assert_eq!(active.identity.name, "planner");
405 assert_eq!(active.display_name, "planner");
406 assert_eq!(active.description, "planner description");
407 assert_eq!(active.color.as_deref(), Some("blue"));
408 assert_eq!(active.instructions, "planner instructions");
409 assert_eq!(active.tools, Some(vec!["unified_search".to_string()]));
410 assert_eq!(active.disallowed_tools, vec!["unified_file".to_string()]);
411 assert_eq!(active.permissions.default, PermissionDefault::Deny);
412 assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
413 assert_eq!(active.reasoning_effort.as_deref(), Some("high"));
414 assert!(active.hooks.is_none());
415 }
416
417 #[test]
418 fn unknown_agent_error_preserves_current_active_agent() {
419 let current = test_spec("current");
420 let specs = vec![current.clone()];
421 let mut state = ActivePrimaryAgentState::default();
422 let original = state
423 .select_from_specs(&specs, "current")
424 .expect("initial selection")
425 .clone();
426
427 let error = state
428 .select_from_specs(&specs, "missing")
429 .expect_err("unknown agent");
430
431 assert_eq!(
432 error,
433 PrimaryAgentResolutionError::UnknownAgent {
434 requested: "missing".to_string()
435 }
436 );
437 assert_eq!(state.active(), &original);
438 }
439
440 #[test]
441 fn alias_resolution_uses_existing_matches_name_semantics() {
442 let mut spec = test_spec("reviewer");
443 spec.aliases = vec!["critic".to_string()];
444
445 let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
446
447 assert_eq!(active.identity.name, "reviewer");
448 assert_eq!(active.display_name, "reviewer");
449 }
450
451 #[test]
452 fn exact_name_resolution_wins_over_alias() {
453 let mut build = test_spec("build");
454 build.aliases = vec!["builder".to_string()];
455 let builder = test_spec("builder");
456
457 let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
458
459 assert_eq!(active.identity.name, "builder");
460 }
461
462 #[test]
463 fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
464 let mut spec = test_spec("worker");
465 spec.aliases = vec!["builder".to_string()];
466 spec.skills = vec!["rust".to_string()];
467 spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
468 spec.background = true;
469 spec.max_turns = Some(12);
470 spec.nickname_candidates = vec!["w".to_string()];
471 spec.initial_prompt = Some("start here".to_string());
472 spec.memory = Some(SubagentMemoryScope::Project);
473 spec.isolation = Some("full".to_string());
474
475 let active = ActivePrimaryAgent::from_spec(&spec);
476
477 assert_eq!(active.identity.name, "worker");
478 assert_eq!(active.display_name, "worker");
479 assert_eq!(active.description, "worker description");
480 assert_eq!(active.color.as_deref(), Some("blue"));
481 assert_eq!(active.aliases, vec!["builder".to_string()]);
482 assert_eq!(active.instructions, "worker instructions");
483 assert_eq!(active.tools, Some(vec!["unified_search".to_string()]));
484 assert_eq!(active.disallowed_tools, vec!["unified_file".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.as_deref(), Some("high"));
488 assert_eq!(active.skills, vec!["rust".to_string()]);
489 assert_eq!(
490 active.mcp_servers,
491 vec![SubagentMcpServer::Named("filesystem".to_string())]
492 );
493 assert_eq!(active.memory, Some(SubagentMemoryScope::Project));
494 }
495
496 #[test]
497 fn primary_runtime_adapter_uses_shared_resolved_view_for_overlapping_fields() {
498 let mut spec = test_spec("worker");
499 spec.description = "Worker display metadata".to_string();
500 spec.color = Some("green".to_string());
501 spec.aliases = vec!["builder".to_string()];
502 spec.skills = vec!["rust".to_string(), "repo".to_string()];
503 spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
504 spec.hooks = Some(HooksConfig::default());
505 spec.memory = Some(SubagentMemoryScope::Project);
506
507 let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
508 let active = ActivePrimaryAgent::from_runtime_view(&runtime);
509
510 assert_eq!(runtime.canonical_name, "worker");
511 assert_eq!(runtime.display_name, "worker");
512 assert_eq!(runtime.description, "Worker display metadata");
513 assert_eq!(runtime.color.as_deref(), Some("green"));
514 assert_eq!(runtime.aliases, vec!["builder".to_string()]);
515 assert_eq!(runtime.skills, vec!["rust".to_string(), "repo".to_string()]);
516 assert_eq!(runtime.mcp_servers.len(), 1);
517 assert!(runtime.hooks.is_some());
518 assert_eq!(runtime.memory, Some(SubagentMemoryScope::Project));
519 assert!(runtime.read_only);
520 assert_eq!(active.identity.name, runtime.canonical_name);
521 assert_eq!(active.display_name, runtime.display_name);
522 assert_eq!(active.description, runtime.description);
523 assert_eq!(active.color, runtime.color);
524 assert_eq!(active.aliases, runtime.aliases);
525 assert_eq!(active.instructions, runtime.instructions);
526 assert_eq!(active.tools, runtime.tools);
527 assert_eq!(active.disallowed_tools, runtime.disallowed_tools);
528 assert_eq!(active.permissions, runtime.permissions);
529 assert_eq!(active.model, runtime.model);
530 assert_eq!(active.reasoning_effort, runtime.reasoning_effort);
531 assert_eq!(active.hooks, runtime.hooks);
532 assert_eq!(active.skills, runtime.skills);
533 assert_eq!(active.mcp_servers, runtime.mcp_servers);
534 assert_eq!(active.memory, runtime.memory);
535 }
536
537 #[test]
538 fn default_state_uses_builtin_duck_agent() {
539 let mut state = ActivePrimaryAgentState::default();
540
541 assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
542 assert_eq!(state.active().identity.name, "duck");
543 assert_eq!(state.active().identity.source, SubagentSource::Builtin);
544
545 state
546 .select_from_specs(&[test_spec("worker")], "worker")
547 .expect("selected");
548 assert_eq!(state.active().identity.name, "worker");
549
550 state.reset_to_default_from_specs(&[]);
551
552 assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
553 assert_eq!(state.active().identity.name, "duck");
554 assert_eq!(state.active().identity.source, SubagentSource::Builtin);
555 }
556
557 #[test]
558 fn from_specs_falls_back_to_builtin_duck_agent() {
559 let active = ActivePrimaryAgentState::from_specs(&[]);
560
561 assert_eq!(active.active().identity.name, "duck");
562 assert_eq!(active.active().identity.source, SubagentSource::Builtin);
563 }
564
565 #[test]
566 fn from_specs_with_default_selects_configured_primary_agent() {
567 let active =
568 ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "builder");
569
570 assert_eq!(active.active().identity.name, "builder");
571 }
572
573 #[test]
574 fn from_specs_with_default_falls_back_to_duck_for_missing_configured_agent() {
575 let active =
576 ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "missing");
577
578 assert_eq!(active.active().identity.name, "duck");
579 assert_eq!(active.active().identity.source, SubagentSource::Builtin);
580 }
581
582 #[test]
583 fn discovery_precedence_overrides_builtin_duck_agent() {
584 let temp = TempDir::new().expect("tempdir");
585 let discovered = discover_subagents(&SubagentDiscoveryInput {
586 workspace_root: temp.path().to_path_buf(),
587 cli_agents: Some(json!({
588 "duck": {
589 "description": "CLI duck",
590 "prompt": "cli duck instructions",
591 "model": "gpt-cli",
592 "mode": "primary",
593 "permissions": { "default": "deny" }
594 }
595 })),
596 plugin_agent_files: Vec::new(),
597 include_user_agents: false,
598 })
599 .expect("discovered subagents");
600
601 let active = ActivePrimaryAgentState::from_discovery(&discovered);
602
603 assert_eq!(active.active().identity.name, "duck");
604 assert_eq!(active.active().identity.source, SubagentSource::Cli);
605 assert_eq!(active.active().instructions, "cli duck instructions");
606 assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
607 }
608
609 #[test]
610 fn default_duck_agent_allows_baseline_read_tools() {
611 let active = ActivePrimaryAgentState::default();
612
613 assert!(primary_agent_allows_tool(active.active(), "unified_search"));
614 assert!(!primary_agent_allows_tool(active.active(), "unified_file"));
615 }
616
617 #[test]
618 fn tool_policy_intersects_allow_list_then_applies_deny_list() {
619 let mut spec = test_spec("worker");
620 spec.tools = Some(vec![
621 "unified_search".to_string(),
622 "unified_file".to_string(),
623 ]);
624 spec.disallowed_tools = vec!["UNIFIED_SEARCH".to_string()];
625 let active = ActivePrimaryAgent::from_spec(&spec);
626
627 assert!(!primary_agent_allows_tool(&active, "unified_exec"));
628 assert!(!primary_agent_allows_tool(&active, "unified_search"));
629 assert!(primary_agent_allows_tool(&active, "unified_file"));
630 }
631
632 #[test]
633 fn empty_present_tool_allow_list_exposes_no_tools() {
634 let mut spec = test_spec("worker");
635 spec.tools = Some(Vec::new());
636 spec.disallowed_tools = Vec::new();
637 let active = ActivePrimaryAgent::from_spec(&spec);
638
639 assert!(!primary_agent_allows_tool(&active, "unified_search"));
640 }
641
642 #[test]
643 fn subagent_lifecycle_tools_bypass_tool_policy() {
644 let mut spec = test_spec("restricted");
645 spec.tools = Some(vec!["unified_search".to_string()]);
646 spec.disallowed_tools = vec![
647 "spawn_agent".to_string(),
648 "wait_agent".to_string(),
649 "close_agent".to_string(),
650 "send_input".to_string(),
651 "resume_agent".to_string(),
652 "spawn_background_subprocess".to_string(),
653 ];
654 let active = ActivePrimaryAgent::from_spec(&spec);
655
656 assert!(!primary_agent_allows_tool(&active, "unified_exec"));
658 assert!(!primary_agent_allows_tool(&active, "unified_file"));
659
660 assert!(primary_agent_allows_tool(&active, "spawn_agent"));
663 assert!(primary_agent_allows_tool(&active, "wait_agent"));
664 assert!(primary_agent_allows_tool(&active, "close_agent"));
665 assert!(primary_agent_allows_tool(&active, "send_input"));
666 assert!(primary_agent_allows_tool(&active, "resume_agent"));
667 assert!(primary_agent_allows_tool(
668 &active,
669 "spawn_background_subprocess"
670 ));
671 }
672
673 #[test]
674 fn build_primary_agent_runtime_config_preserves_baseline_fields_and_merges_mcp() {
675 let mut parent = VTCodeConfig::default();
676 parent.agent.default_model = "parent-model".to_string();
677 parent.mcp.providers.push(
678 serde_json::from_value(json!({
679 "name": "global",
680 "command": "global-mcp",
681 "args": []
682 }))
683 .expect("global provider"),
684 );
685
686 let mut spec = test_spec("worker");
687 spec.permissions = AgentPermissionsConfig::new(PermissionDefault::Auto);
688 spec.model = Some("agent-model".to_string());
689 spec.reasoning_effort = Some("low".to_string());
690 spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([
691 (
692 "global".to_string(),
693 json!({
694 "type": "stdio",
695 "command": "duplicate-mcp"
696 }),
697 ),
698 (
699 "local".to_string(),
700 json!({
701 "type": "stdio",
702 "command": "local-mcp"
703 }),
704 ),
705 ]))];
706 let active = ActivePrimaryAgent::from_spec(&spec);
707
708 let runtime = build_primary_agent_runtime_config(&parent, &active);
709
710 assert_eq!(runtime.agent.default_model, "agent-model");
711 assert_eq!(runtime.agent.reasoning_effort, ReasoningEffortLevel::Low);
712 assert_eq!(runtime.mcp.providers.len(), 2);
713 assert_eq!(runtime.mcp.providers[0].name, "global");
714 assert_eq!(runtime.mcp.providers[1].name, "local");
715 }
716
717 #[test]
718 fn built_in_primary_agents_resolve_required_permission_policy() {
719 let builtins = builtin_subagents();
720
721 for name in ["duck", "plan", "build", "auto"] {
722 let active = resolve_primary_agent(&builtins, name)
723 .unwrap_or_else(|_| panic!("missing built-in primary agent {name}"));
724 assert_eq!(active.identity.name, name);
725 let expected_default = match name {
726 "build" => PermissionDefault::Ask,
727 "auto" => PermissionDefault::Auto,
728 "plan" | "duck" => PermissionDefault::Deny,
729 _ => unreachable!("unexpected built-in primary agent"),
730 };
731 assert_eq!(active.permissions.default, expected_default);
732 }
733 }
734
735 #[test]
736 fn active_primary_permissions_overlay_runtime_decisions() {
737 let builtins = builtin_subagents();
738 let mut state = ActivePrimaryAgentState::from_specs(&builtins);
739 let config = VTCodeConfig::default();
740 let workspace = TempDir::new().expect("workspace");
741 let current_dir = workspace.path();
742
743 state
744 .select_from_specs(&builtins, "auto")
745 .expect("auto primary");
746 let exec = build_permission_request(
747 workspace.path(),
748 current_dir,
749 tools::UNIFIED_EXEC,
750 Some(&json!({"command": "cargo test"})),
751 );
752 assert_eq!(
753 evaluate_active_primary_agent_permissions(
754 &config,
755 state.active(),
756 workspace.path(),
757 current_dir,
758 &exec,
759 ),
760 ResolvedPermissionDecision::Auto
761 );
762
763 state
764 .select_from_specs(&builtins, "plan")
765 .expect("plan primary");
766 let edit = build_permission_request(
767 workspace.path(),
768 current_dir,
769 tools::UNIFIED_FILE,
770 Some(&json!({"action": "edit", "path": "src/lib.rs"})),
771 );
772 assert_eq!(
773 evaluate_active_primary_agent_permissions(
774 &config,
775 state.active(),
776 workspace.path(),
777 current_dir,
778 &edit,
779 ),
780 ResolvedPermissionDecision::Deny
781 );
782 }
783
784 #[test]
785 fn primary_agent_switching_changes_permission_policy_without_mutating_parent_config() {
786 let builtins = builtin_subagents();
787 let mut state = ActivePrimaryAgentState::from_specs(&builtins);
788 let initial_model = state.active().model.clone();
789 let initial_tools = state.active().tools.clone();
790
791 state
792 .select_from_specs(&builtins, "auto")
793 .expect("auto primary");
794 assert_eq!(state.active().identity.name, "auto");
795 assert_eq!(state.active().permissions.default, PermissionDefault::Auto);
796 assert_eq!(state.active().model, initial_model);
797 assert_ne!(state.active().tools, initial_tools);
798
799 state
800 .select_from_specs(&builtins, "duck")
801 .expect("duck primary");
802 assert_eq!(state.active().identity.name, "duck");
803 assert_eq!(state.active().permissions.default, PermissionDefault::Deny);
804 assert_eq!(state.active().model, initial_model);
805 assert_eq!(state.active().tools, initial_tools);
806 }
807
808 #[test]
809 fn primary_hook_config_merges_supported_main_session_events_after_global_hooks() {
810 let mut global = HooksConfig::default();
811 global.lifecycle.user_prompt_submit = vec![hook_group("global-user")];
812 global.lifecycle.pre_tool_use = vec![hook_group("global-pre")];
813 global.lifecycle.post_tool_use = vec![hook_group("global-post")];
814 global.lifecycle.permission_request = vec![hook_group("global-permission")];
815 global.lifecycle.pre_compact = vec![hook_group("global-compact")];
816 global.lifecycle.stop = vec![hook_group("global-stop")];
817 global.lifecycle.notification = vec![hook_group("global-notification")];
818
819 let mut primary_hooks = HooksConfig::default();
820 primary_hooks.lifecycle.user_prompt_submit = vec![hook_group("primary-user")];
821 primary_hooks.lifecycle.pre_tool_use = vec![hook_group("primary-pre")];
822 primary_hooks.lifecycle.post_tool_use = vec![hook_group("primary-post")];
823 primary_hooks.lifecycle.permission_request = vec![hook_group("primary-permission")];
824 primary_hooks.lifecycle.pre_compact = vec![hook_group("primary-compact")];
825 primary_hooks.lifecycle.stop = vec![hook_group("primary-stop")];
826 primary_hooks.lifecycle.notification = vec![hook_group("primary-notification")];
827
828 let mut spec = test_spec("worker");
829 spec.hooks = Some(primary_hooks);
830 let active = ActivePrimaryAgent::from_spec(&spec);
831
832 let merged = build_primary_agent_hook_config(&global, &active);
833
834 assert_hook_commands(
835 &merged.lifecycle.user_prompt_submit,
836 &["global-user", "primary-user"],
837 );
838 assert_hook_commands(
839 &merged.lifecycle.pre_tool_use,
840 &["global-pre", "primary-pre"],
841 );
842 assert_hook_commands(
843 &merged.lifecycle.post_tool_use,
844 &["global-post", "primary-post"],
845 );
846 assert_hook_commands(
847 &merged.lifecycle.permission_request,
848 &["global-permission", "primary-permission"],
849 );
850 assert_hook_commands(
851 &merged.lifecycle.pre_compact,
852 &["global-compact", "primary-compact"],
853 );
854 assert_hook_commands(&merged.lifecycle.stop, &["global-stop", "primary-stop"]);
855 assert_hook_commands(
856 &merged.lifecycle.notification,
857 &["global-notification", "primary-notification"],
858 );
859 }
860
861 #[test]
862 fn primary_hook_config_excludes_global_and_subagent_lifecycle_events() {
863 let global = HooksConfig::default();
864 let mut primary_hooks = HooksConfig::default();
865 primary_hooks.lifecycle.session_start = vec![hook_group("primary-session-start")];
866 primary_hooks.lifecycle.session_end = vec![hook_group("primary-session-end")];
867 primary_hooks.lifecycle.subagent_start = vec![hook_group("primary-subagent-start")];
868 primary_hooks.lifecycle.subagent_stop = vec![hook_group("primary-subagent-stop")];
869 primary_hooks.lifecycle.task_completion = vec![hook_group("primary-task-completion")];
870 primary_hooks.lifecycle.task_completed = vec![hook_group("primary-task-completed")];
871
872 let mut spec = test_spec("worker");
873 spec.hooks = Some(primary_hooks);
874 let active = ActivePrimaryAgent::from_spec(&spec);
875
876 let merged = build_primary_agent_hook_config(&global, &active);
877
878 assert!(merged.lifecycle.session_start.is_empty());
879 assert!(merged.lifecycle.session_end.is_empty());
880 assert!(merged.lifecycle.subagent_start.is_empty());
881 assert!(merged.lifecycle.subagent_stop.is_empty());
882 assert!(merged.lifecycle.task_completion.is_empty());
883 assert!(merged.lifecycle.task_completed.is_empty());
884 assert!(merged.lifecycle.stop.is_empty());
885 }
886
887 #[test]
888 fn primary_hook_config_recomputes_without_previous_primary_leakage() {
889 let global = HooksConfig::default();
890 let mut first_hooks = HooksConfig::default();
891 first_hooks.lifecycle.pre_tool_use = vec![hook_group("first-pre")];
892 let mut second_hooks = HooksConfig::default();
893 second_hooks.lifecycle.pre_tool_use = vec![hook_group("second-pre")];
894
895 let mut first = test_spec("first");
896 first.hooks = Some(first_hooks);
897 let mut second = test_spec("second");
898 second.hooks = Some(second_hooks);
899 let specs = vec![first, second];
900 let mut state = ActivePrimaryAgentState::default();
901
902 state
903 .select_from_specs(&specs, "first")
904 .expect("selected first");
905 let first_config = build_primary_agent_hook_config(&global, state.active());
906 assert_hook_commands(&first_config.lifecycle.pre_tool_use, &["first-pre"]);
907
908 state
909 .select_from_specs(&specs, "second")
910 .expect("selected second");
911 let second_config = build_primary_agent_hook_config(&global, state.active());
912 assert_hook_commands(&second_config.lifecycle.pre_tool_use, &["second-pre"]);
913 }
914
915 #[test]
916 fn active_primary_state_recomputes_skills_mcp_and_metadata_on_switch() {
917 let mut first = test_spec("first");
918 first.description = "First metadata".to_string();
919 first.color = Some("red".to_string());
920 first.aliases = vec!["one".to_string()];
921 first.skills = vec!["rust".to_string()];
922 first.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
923 "first-mcp".to_string(),
924 json!({
925 "type": "stdio",
926 "command": "first-mcp"
927 }),
928 )]))];
929 let second = test_spec("second");
930 let specs = vec![first, second];
931 let mut state = ActivePrimaryAgentState::default();
932
933 state
934 .select_from_specs(&specs, "one")
935 .expect("selected first by alias");
936 assert_eq!(state.active().identity.name, "first");
937 assert_eq!(state.active().description, "First metadata");
938 assert_eq!(state.active().color.as_deref(), Some("red"));
939 assert_eq!(state.active().aliases, vec!["one".to_string()]);
940 assert_eq!(state.active().skills, vec!["rust".to_string()]);
941 assert_eq!(state.active().mcp_servers.len(), 1);
942
943 state
944 .select_from_specs(&specs, "second")
945 .expect("selected second");
946 assert_eq!(state.active().identity.name, "second");
947 assert_eq!(state.active().description, "second description");
948 assert_eq!(state.active().color.as_deref(), Some("blue"));
949 assert!(state.active().aliases.is_empty());
950 assert!(state.active().skills.is_empty());
951 assert!(state.active().mcp_servers.is_empty());
952 }
953
954 fn test_spec(name: &str) -> SubagentSpec {
955 SubagentSpec {
956 name: name.to_string(),
957 description: format!("{name} description"),
958 prompt: format!("{name} instructions"),
959 tools: Some(vec!["unified_search".to_string()]),
960 disallowed_tools: vec!["unified_file".to_string()],
961 model: Some("gpt-5.1".to_string()),
962 color: Some("blue".to_string()),
963 reasoning_effort: Some("high".to_string()),
964 permissions: AgentPermissionsConfig::new(PermissionDefault::Deny),
965 skills: Vec::new(),
966 mcp_servers: Vec::new(),
967 hooks: None,
968 background: false,
969 mode: vtcode_config::AgentMode::Primary,
970 max_turns: None,
971 nickname_candidates: Vec::new(),
972 initial_prompt: None,
973 memory: None,
974 isolation: None,
975 aliases: Vec::new(),
976 source: SubagentSource::ProjectVtcode,
977 file_path: None,
978 warnings: Vec::new(),
979 }
980 }
981
982 fn hook_group(command: &str) -> HookGroupConfig {
983 HookGroupConfig {
984 matcher: None,
985 hooks: vec![HookCommandConfig {
986 command: command.to_string(),
987 ..HookCommandConfig::default()
988 }],
989 }
990 }
991
992 fn assert_hook_commands(groups: &[HookGroupConfig], expected: &[&str]) {
993 let commands = groups
994 .iter()
995 .flat_map(|group| group.hooks.iter())
996 .map(|hook| hook.command.as_str())
997 .collect::<Vec<_>>();
998 assert_eq!(commands, expected);
999 }
1000}