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