use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use vtcode_config::constants::{defaults::DEFAULT_PRIMARY_AGENT_NAME, tools};
use vtcode_config::core::permissions::AgentPermissionsConfig;
use vtcode_config::core::tools::ToolPolicy;
use vtcode_config::{
DiscoveredSubagents, HookGroupConfig, HooksConfig, McpProviderConfig, SubagentMcpServer, SubagentMemoryScope,
SubagentSource, SubagentSpec, builtin_primary_build_agent,
};
use crate::config::{ReasoningEffortLevel, VTCodeConfig};
use crate::llm::provider::ToolDefinition;
use crate::permissions::{PermissionRequest, ResolvedPermissionDecision, evaluate_effective_permissions};
use crate::prompts::PromptContext;
use crate::subagents::ResolvedAgentRuntimeView;
use tracing::warn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActivePrimaryAgentSpecIdentity {
pub name: String,
pub source: SubagentSource,
pub file_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ActivePrimaryAgent {
pub identity: ActivePrimaryAgentSpecIdentity,
pub display_name: String,
pub description: String,
pub color: Option<String>,
pub aliases: Vec<String>,
pub instructions: String,
pub tools: Option<Vec<String>>,
pub disallowed_tools: Vec<String>,
pub permissions: AgentPermissionsConfig,
pub model: Option<String>,
pub reasoning_effort: Option<ReasoningEffortLevel>,
pub hooks: Option<HooksConfig>,
pub skills: Vec<String>,
pub mcp_servers: Vec<SubagentMcpServer>,
pub memory: Option<SubagentMemoryScope>,
pub tool_policy_overrides: BTreeMap<String, ToolPolicy>,
}
impl ActivePrimaryAgent {
#[must_use]
pub fn from_spec(spec: &SubagentSpec) -> Self {
Self::from_runtime_view(&ResolvedAgentRuntimeView::from_spec(spec))
}
#[must_use]
pub fn from_runtime_view(runtime: &ResolvedAgentRuntimeView) -> Self {
Self {
identity: ActivePrimaryAgentSpecIdentity {
name: runtime.canonical_name.clone(),
source: runtime.source.clone(),
file_path: runtime.file_path.clone(),
},
display_name: runtime.display_name.clone(),
description: runtime.description.clone(),
color: runtime.color.clone(),
aliases: runtime.aliases.clone(),
instructions: runtime.instructions.clone(),
tools: runtime.tools.clone(),
disallowed_tools: runtime.disallowed_tools.clone(),
permissions: runtime.permissions.clone(),
model: runtime.model.clone(),
reasoning_effort: runtime.reasoning_effort,
hooks: runtime.hooks.clone(),
skills: runtime.skills.clone(),
mcp_servers: runtime.mcp_servers.clone(),
memory: runtime.memory,
tool_policy_overrides: runtime.tool_policy_overrides.clone(),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.identity.name
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ActivePrimaryAgentState {
active: ActivePrimaryAgent,
}
impl Default for ActivePrimaryAgentState {
fn default() -> Self {
Self {
active: ActivePrimaryAgent::from_spec(&builtin_primary_build_agent()),
}
}
}
impl ActivePrimaryAgentState {
#[must_use]
pub const fn active(&self) -> &ActivePrimaryAgent {
&self.active
}
#[must_use]
pub fn from_discovery(discovered: &DiscoveredSubagents) -> Self {
Self::from_specs(&discovered.effective)
}
#[must_use]
pub fn from_specs(specs: &[SubagentSpec]) -> Self {
Self::from_specs_with_default(specs, DEFAULT_PRIMARY_AGENT_NAME)
}
#[must_use]
pub fn from_specs_with_default(specs: &[SubagentSpec], requested_default: &str) -> Self {
let requested = if requested_default.trim().is_empty() {
DEFAULT_PRIMARY_AGENT_NAME
} else {
requested_default.trim()
};
let active = match resolve_primary_agent(specs, requested) {
Ok(active) => active,
Err(_) => {
if let Some(requested_name) = fallback_notice(requested) {
warn!(
requested = %requested_name,
fallback = %DEFAULT_PRIMARY_AGENT_NAME,
"Primary agent '{}' was not discovered; falling back to the built-in '{}' agent. \
Set default_primary_agent to a discovered agent or add a matching primary agent.",
requested_name,
DEFAULT_PRIMARY_AGENT_NAME,
);
}
ActivePrimaryAgent::from_spec(&builtin_primary_build_agent())
}
};
Self { active }
}
pub fn reset_to_default_from_specs(&mut self, specs: &[SubagentSpec]) -> &ActivePrimaryAgent {
self.active = Self::from_specs(specs).active;
&self.active
}
pub fn select_from_discovery(
&mut self,
discovered: &DiscoveredSubagents,
requested: &str,
) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
self.select_from_specs(&discovered.effective, requested)
}
pub fn select_from_specs(
&mut self,
specs: &[SubagentSpec],
requested: &str,
) -> PrimaryAgentResolutionResult<&ActivePrimaryAgent> {
let active = resolve_primary_agent(specs, requested)?;
self.active = active;
Ok(&self.active)
}
}
pub type PrimaryAgentResolutionResult<T> = Result<T, PrimaryAgentResolutionError>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrimaryAgentResolutionError {
UnknownAgent { requested: String },
}
impl fmt::Display for PrimaryAgentResolutionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownAgent { requested } => write!(f, "Unknown primary agent {requested}"),
}
}
}
impl Error for PrimaryAgentResolutionError {}
pub fn resolve_discovered_primary_agent(
discovered: &DiscoveredSubagents,
requested: &str,
) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
resolve_primary_agent(&discovered.effective, requested)
}
pub fn resolve_primary_agent(
specs: &[SubagentSpec],
requested: &str,
) -> PrimaryAgentResolutionResult<ActivePrimaryAgent> {
specs
.iter()
.find(|spec| spec.is_primary() && spec.name.eq_ignore_ascii_case(requested))
.or_else(|| specs.iter().find(|spec| spec.is_primary() && spec.matches_name(requested)))
.map(ActivePrimaryAgent::from_spec)
.ok_or_else(|| PrimaryAgentResolutionError::UnknownAgent { requested: requested.to_string() })
}
fn is_subagent_cleanup_tool(tool_name: &str) -> bool {
tools::LIFECYCLE_CLEANUP_TOOLS.contains(&tool_name)
}
fn fallback_notice(requested_default: &str) -> Option<&str> {
let requested = requested_default.trim();
if requested.is_empty() || requested.eq_ignore_ascii_case(DEFAULT_PRIMARY_AGENT_NAME) {
None
} else {
Some(requested)
}
}
#[must_use]
pub fn primary_agent_allows_tool(agent: &ActivePrimaryAgent, tool_name: &str) -> bool {
let tool_name = normalise_tool_name(tool_name);
if is_subagent_cleanup_tool(&tool_name) {
return true;
}
let allow_list_allows = agent
.tools
.as_ref()
.is_none_or(|tools| tools.iter().any(|allowed| normalise_tool_name(allowed) == tool_name));
if !allow_list_allows {
return false;
}
!agent
.disallowed_tools
.iter()
.any(|denied| normalise_tool_name(denied) == tool_name)
}
#[must_use]
pub fn apply_primary_agent_tool_policy(
tools: Option<Arc<Vec<ToolDefinition>>>,
agent: &ActivePrimaryAgent,
) -> Option<Arc<Vec<ToolDefinition>>> {
let tools = tools?;
let filtered = tools
.iter()
.filter(|tool| primary_agent_allows_tool(agent, tool.function_name()))
.cloned()
.collect::<Vec<_>>();
(!filtered.is_empty()).then(|| Arc::new(filtered))
}
#[must_use]
pub fn build_primary_agent_runtime_config(parent: &VTCodeConfig, agent: &ActivePrimaryAgent) -> VTCodeConfig {
let mut config = parent.clone();
if let Some(model) = agent.model.as_ref() {
let trimmed = model.trim();
if !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("inherit") {
config.agent.default_model = model.clone();
}
}
if let Some(reasoning_effort) = agent.reasoning_effort {
config.agent.reasoning_effort = reasoning_effort;
}
merge_primary_mcp_servers(&mut config, agent.mcp_servers.as_slice());
config
}
#[must_use]
pub fn build_primary_agent_hook_config(global: &HooksConfig, agent: &ActivePrimaryAgent) -> HooksConfig {
let mut config = global.clone();
merge_active_primary_hooks(&mut config, agent.hooks.as_ref());
config
}
pub fn apply_primary_agent_prompt_context(context: &mut PromptContext, agent: &ActivePrimaryAgent) {
context.replace_available_skills_with_named(agent.skills.as_slice());
}
#[must_use]
pub fn active_primary_agent_permissions(agent: &ActivePrimaryAgent) -> &AgentPermissionsConfig {
&agent.permissions
}
#[must_use]
pub fn evaluate_active_primary_agent_permissions(
config: &VTCodeConfig,
agent: &ActivePrimaryAgent,
workspace_root: &std::path::Path,
current_dir: &std::path::Path,
request: &PermissionRequest,
) -> ResolvedPermissionDecision {
evaluate_effective_permissions(
&config.permissions,
active_primary_agent_permissions(agent),
workspace_root,
current_dir,
request,
)
}
fn normalise_tool_name(tool_name: &str) -> String {
tool_name.trim().to_ascii_lowercase()
}
fn merge_primary_mcp_servers(config: &mut VTCodeConfig, servers: &[SubagentMcpServer]) {
for server in servers {
match server {
SubagentMcpServer::Named(_) => {}
SubagentMcpServer::Inline(definition) => {
for (name, value) in definition {
if config.mcp.providers.iter().any(|provider| provider.name == *name) {
continue;
}
if let Some(provider) = inline_mcp_provider(name, value) {
config.mcp.providers.push(provider);
}
}
}
}
}
}
fn merge_active_primary_hooks(config: &mut HooksConfig, hooks: Option<&HooksConfig>) {
let Some(hooks) = hooks else {
return;
};
config.lifecycle.quiet_success_output |= hooks.lifecycle.quiet_success_output;
append_hook_groups(&mut config.lifecycle.session_start, &hooks.lifecycle.session_start);
append_hook_groups(&mut config.lifecycle.session_end, &hooks.lifecycle.session_end);
append_hook_groups(&mut config.lifecycle.subagent_start, &hooks.lifecycle.subagent_start);
append_hook_groups(&mut config.lifecycle.subagent_stop, &hooks.lifecycle.subagent_stop);
append_hook_groups(&mut config.lifecycle.user_prompt_submit, &hooks.lifecycle.user_prompt_submit);
append_hook_groups(&mut config.lifecycle.pre_tool_use, &hooks.lifecycle.pre_tool_use);
append_hook_groups(&mut config.lifecycle.post_tool_use, &hooks.lifecycle.post_tool_use);
append_hook_groups(&mut config.lifecycle.permission_request, &hooks.lifecycle.permission_request);
append_hook_groups(&mut config.lifecycle.pre_compact, &hooks.lifecycle.pre_compact);
append_hook_groups(&mut config.lifecycle.stop, &hooks.lifecycle.stop);
append_hook_groups(&mut config.lifecycle.notification, &hooks.lifecycle.notification);
}
fn append_hook_groups(target: &mut Vec<HookGroupConfig>, source: &[HookGroupConfig]) {
target.extend(source.iter().cloned());
}
fn inline_mcp_provider(name: &str, value: &serde_json::Value) -> Option<McpProviderConfig> {
let object = value.as_object()?;
let mut payload = serde_json::Map::with_capacity(object.len().saturating_add(1));
payload.insert("name".to_string(), serde_json::Value::String(name.to_string()));
for (key, value) in object {
if key == "type" {
continue;
}
payload.insert(key.clone(), value.clone());
}
if payload.contains_key("command") && !payload.contains_key("args") {
payload.insert("args".to_string(), serde_json::Value::Array(Vec::new()));
}
serde_json::from_value(serde_json::Value::Object(payload)).ok()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::json;
use tempfile::TempDir;
use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
use vtcode_config::{
HookCommandConfig, HooksConfig, IsolationMode, SubagentDiscoveryInput, SubagentMcpServer, SubagentMemoryScope,
SubagentSource, builtin_plan_agent, builtin_primary_duck_agent, builtin_subagents, discover_subagents,
};
use crate::config::constants::tools;
use crate::permissions::{ResolvedPermissionDecision, build_permission_request};
use super::*;
#[test]
fn resolves_existing_spec_by_name() {
let spec = test_spec("planner");
let active = resolve_primary_agent(&[spec], "planner").expect("resolved");
assert_eq!(active.identity.name, "planner");
assert_eq!(active.display_name, "planner");
assert_eq!(active.description, "planner description");
assert_eq!(active.color.as_deref(), Some("blue"));
assert_eq!(active.instructions, "planner instructions");
assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
assert_eq!(active.permissions.default, PermissionDefault::Deny);
assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
assert!(active.hooks.is_none());
}
#[test]
fn unknown_agent_error_preserves_current_active_agent() {
let current = test_spec("current");
let specs = vec![current.clone()];
let mut state = ActivePrimaryAgentState::default();
let original = state.select_from_specs(&specs, "current").expect("initial selection").clone();
let error = state.select_from_specs(&specs, "missing").expect_err("unknown agent");
assert_eq!(error, PrimaryAgentResolutionError::UnknownAgent { requested: "missing".to_string() });
assert_eq!(state.active(), &original);
}
#[test]
fn alias_resolution_uses_existing_matches_name_semantics() {
let mut spec = test_spec("reviewer");
spec.aliases = vec!["critic".to_string()];
let active = resolve_primary_agent(&[spec], "CRITIC").expect("resolved by alias");
assert_eq!(active.identity.name, "reviewer");
assert_eq!(active.display_name, "reviewer");
}
#[test]
fn exact_name_resolution_wins_over_alias() {
let mut build = test_spec("build");
build.aliases = vec!["builder".to_string()];
let builder = test_spec("builder");
let active = resolve_primary_agent(&[build, builder], "builder").expect("resolved");
assert_eq!(active.identity.name, "builder");
}
#[test]
fn ignored_subagent_fields_do_not_enter_primary_agent_runtime() {
let mut spec = test_spec("worker");
spec.aliases = vec!["builder".to_string()];
spec.skills = vec!["rust".to_string()];
spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
spec.background = true;
spec.max_turns = Some(12);
spec.nickname_candidates = vec!["w".to_string()];
spec.initial_prompt = Some("start here".to_string());
spec.memory = Some(SubagentMemoryScope::Project);
spec.isolation = Some(IsolationMode::Full);
let active = ActivePrimaryAgent::from_spec(&spec);
assert_eq!(active.identity.name, "worker");
assert_eq!(active.display_name, "worker");
assert_eq!(active.description, "worker description");
assert_eq!(active.color.as_deref(), Some("blue"));
assert_eq!(active.aliases, vec!["builder".to_string()]);
assert_eq!(active.instructions, "worker instructions");
assert_eq!(active.tools, Some(vec!["code_search".to_string()]));
assert_eq!(active.disallowed_tools, vec!["file_operation".to_string()]);
assert_eq!(active.permissions.default, PermissionDefault::Deny);
assert_eq!(active.model.as_deref(), Some("gpt-5.1"));
assert_eq!(active.reasoning_effort, Some(ReasoningEffortLevel::High));
assert_eq!(active.skills, vec!["rust".to_string()]);
assert_eq!(active.mcp_servers, vec![SubagentMcpServer::Named("filesystem".to_string())]);
assert_eq!(active.memory, Some(SubagentMemoryScope::Project));
}
#[test]
fn primary_runtime_adapter_uses_shared_resolved_view_for_overlapping_fields() {
let mut spec = test_spec("worker");
spec.description = "Worker display metadata".to_string();
spec.color = Some("green".to_string());
spec.aliases = vec!["builder".to_string()];
spec.skills = vec!["rust".to_string(), "repo".to_string()];
spec.mcp_servers = vec![SubagentMcpServer::Named("filesystem".to_string())];
spec.hooks = Some(HooksConfig::default());
spec.memory = Some(SubagentMemoryScope::Project);
let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
let active = ActivePrimaryAgent::from_runtime_view(&runtime);
assert_eq!(runtime.canonical_name, "worker");
assert_eq!(runtime.display_name, "worker");
assert_eq!(runtime.description, "Worker display metadata");
assert_eq!(runtime.color.as_deref(), Some("green"));
assert_eq!(runtime.aliases, vec!["builder".to_string()]);
assert_eq!(runtime.skills, vec!["rust".to_string(), "repo".to_string()]);
assert_eq!(runtime.mcp_servers.len(), 1);
assert!(runtime.hooks.is_some());
assert_eq!(runtime.memory, Some(SubagentMemoryScope::Project));
assert!(runtime.read_only);
assert_eq!(active.identity.name, runtime.canonical_name);
assert_eq!(active.display_name, runtime.display_name);
assert_eq!(active.description, runtime.description);
assert_eq!(active.color, runtime.color);
assert_eq!(active.aliases, runtime.aliases);
assert_eq!(active.instructions, runtime.instructions);
assert_eq!(active.tools, runtime.tools);
assert_eq!(active.disallowed_tools, runtime.disallowed_tools);
assert_eq!(active.permissions, runtime.permissions);
assert_eq!(active.model, runtime.model);
assert_eq!(active.reasoning_effort, runtime.reasoning_effort);
assert_eq!(active.hooks, runtime.hooks);
assert_eq!(active.skills, runtime.skills);
assert_eq!(active.mcp_servers, runtime.mcp_servers);
assert_eq!(active.memory, runtime.memory);
}
#[test]
fn default_state_uses_builtin_build_agent() {
let mut state = ActivePrimaryAgentState::default();
assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
assert_eq!(state.active().identity.name, "build");
assert_eq!(state.active().identity.source, SubagentSource::Builtin);
state.select_from_specs(&[test_spec("worker")], "worker").expect("selected");
assert_eq!(state.active().identity.name, "worker");
state.reset_to_default_from_specs(&[]);
assert_eq!(state.active().identity.name, DEFAULT_PRIMARY_AGENT_NAME);
assert_eq!(state.active().identity.name, "build");
assert_eq!(state.active().identity.source, SubagentSource::Builtin);
}
#[test]
fn from_specs_falls_back_to_builtin_build_agent() {
let active = ActivePrimaryAgentState::from_specs(&[]);
assert_eq!(active.active().identity.name, "build");
assert_eq!(active.active().identity.source, SubagentSource::Builtin);
}
#[test]
fn from_specs_with_default_selects_configured_primary_agent() {
let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "builder");
assert_eq!(active.active().identity.name, "builder");
}
#[test]
fn from_specs_with_default_falls_back_to_build_for_missing_configured_agent() {
let active = ActivePrimaryAgentState::from_specs_with_default(&[test_spec("builder")], "missing");
assert_eq!(active.active().identity.name, "build");
assert_eq!(active.active().identity.source, SubagentSource::Builtin);
}
#[test]
fn fallback_notice_silent_for_build_and_empty() {
assert_eq!(fallback_notice("build"), None);
assert_eq!(fallback_notice("BUILD"), None);
assert_eq!(fallback_notice("Build"), None);
assert_eq!(fallback_notice(""), None);
assert_eq!(fallback_notice(" "), None);
}
#[test]
fn fallback_notice_surfaces_non_default_mismatches() {
assert_eq!(fallback_notice("duck"), Some("duck"));
assert_eq!(fallback_notice("auto"), Some("auto"));
assert_eq!(fallback_notice(" duck "), Some("duck"));
}
#[test]
fn discovery_precedence_overrides_builtin_build_agent() {
let temp = TempDir::new().expect("tempdir");
let discovered = discover_subagents(&SubagentDiscoveryInput {
workspace_root: temp.path().to_path_buf(),
cli_agents: Some(json!({
"build": {
"description": "CLI build",
"prompt": "cli build instructions",
"model": "gpt-cli",
"mode": "primary",
"permissions": { "default": "ask" }
}
})),
plugin_agent_files: Vec::new(),
include_user_agents: false,
})
.expect("discovered subagents");
let active = ActivePrimaryAgentState::from_discovery(&discovered);
assert_eq!(active.active().identity.name, "build");
assert_eq!(active.active().identity.source, SubagentSource::Cli);
assert_eq!(active.active().instructions, "cli build instructions");
assert_eq!(active.active().model.as_deref(), Some("gpt-cli"));
}
#[test]
fn default_build_agent_allows_baseline_read_and_exec_tools() {
let active = ActivePrimaryAgentState::default();
assert!(primary_agent_allows_tool(active.active(), tools::CODE_SEARCH));
assert!(primary_agent_allows_tool(active.active(), tools::EXEC_COMMAND));
assert!(primary_agent_allows_tool(active.active(), tools::APPLY_PATCH));
assert!(primary_agent_allows_tool(active.active(), tools::RUN_PTY_CMD));
assert_eq!(active.active().permissions.default, PermissionDefault::Ask);
}
#[test]
fn readonly_primaries_expose_public_read_tools_and_block_apply_patch() {
for spec in [builtin_primary_duck_agent(), builtin_plan_agent()] {
let name = spec.name.clone();
let active = ActivePrimaryAgent::from_spec(&spec);
assert!(primary_agent_allows_tool(&active, tools::CODE_SEARCH), "{name} should expose code_search");
assert!(primary_agent_allows_tool(&active, tools::EXEC_COMMAND), "{name} should expose exec_command");
assert!(primary_agent_allows_tool(&active, tools::REQUEST_USER_INPUT));
assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH), "{name} must not expose apply_patch");
assert!(!primary_agent_allows_tool(&active, tools::RUN_PTY_CMD), "{name} must not expose run_pty_cmd");
assert!(!primary_agent_allows_tool(&active, tools::WRITE_FILE), "{name} must not expose write_file");
}
}
#[test]
fn tool_policy_intersects_allow_list_then_applies_deny_list() {
let mut spec = test_spec("worker");
spec.tools = Some(vec![tools::CODE_SEARCH.to_string(), tools::EXEC_COMMAND.to_string()]);
spec.disallowed_tools = vec![tools::CODE_SEARCH.to_ascii_uppercase()];
let active = ActivePrimaryAgent::from_spec(&spec);
assert!(primary_agent_allows_tool(&active, tools::EXEC_COMMAND));
assert!(!primary_agent_allows_tool(&active, tools::CODE_SEARCH));
assert!(!primary_agent_allows_tool(&active, tools::APPLY_PATCH));
}
#[test]
fn empty_present_tool_allow_list_exposes_no_tools() {
let mut spec = test_spec("worker");
spec.tools = Some(Vec::new());
spec.disallowed_tools = Vec::new();
let active = ActivePrimaryAgent::from_spec(&spec);
assert!(!primary_agent_allows_tool(&active, tools::CODE_SEARCH));
}
#[test]
fn subagent_cleanup_tools_bypass_policy_but_new_work_obeys_policy() {
let mut spec = test_spec("restricted");
spec.tools = Some(vec!["code_search".to_string()]);
spec.disallowed_tools = vec![
tools::SPAWN_AGENT.to_string(),
tools::WAIT_AGENT.to_string(),
tools::CLOSE_AGENT.to_string(),
tools::SEND_INPUT.to_string(),
tools::RESUME_AGENT.to_string(),
tools::SPAWN_BACKGROUND_SUBPROCESS.to_string(),
];
let active = ActivePrimaryAgent::from_spec(&spec);
assert!(!primary_agent_allows_tool(&active, "command_session"));
assert!(!primary_agent_allows_tool(&active, "file_operation"));
assert!(!primary_agent_allows_tool(&active, tools::SPAWN_AGENT));
assert!(!primary_agent_allows_tool(&active, tools::SPAWN_BACKGROUND_SUBPROCESS));
assert!(!primary_agent_allows_tool(&active, tools::SEND_INPUT));
assert!(!primary_agent_allows_tool(&active, tools::RESUME_AGENT));
assert!(primary_agent_allows_tool(&active, tools::WAIT_AGENT));
assert!(primary_agent_allows_tool(&active, tools::CLOSE_AGENT));
assert!(!primary_agent_allows_tool(&active, tools::AGENT));
}
#[test]
fn build_primary_agent_runtime_config_preserves_baseline_fields_and_merges_mcp() {
let mut parent = VTCodeConfig::default();
parent.agent.default_model = "parent-model".to_string();
parent.mcp.providers.push(
serde_json::from_value(json!({
"name": "global",
"command": "global-mcp",
"args": []
}))
.expect("global provider"),
);
let mut spec = test_spec("worker");
spec.permissions = AgentPermissionsConfig::new(PermissionDefault::Auto);
spec.model = Some("agent-model".to_string());
spec.reasoning_effort = Some(ReasoningEffortLevel::Low);
spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([
(
"global".to_string(),
json!({
"type": "stdio",
"command": "duplicate-mcp"
}),
),
(
"local".to_string(),
json!({
"type": "stdio",
"command": "local-mcp"
}),
),
]))];
let active = ActivePrimaryAgent::from_spec(&spec);
let runtime = build_primary_agent_runtime_config(&parent, &active);
assert_eq!(runtime.agent.default_model, "agent-model");
assert_eq!(runtime.agent.reasoning_effort, ReasoningEffortLevel::Low);
assert_eq!(runtime.mcp.providers.len(), 2);
assert_eq!(runtime.mcp.providers[0].name, "global");
assert_eq!(runtime.mcp.providers[1].name, "local");
}
#[test]
fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_says_inherit() {
let mut parent = VTCodeConfig::default();
parent.agent.default_model = "parent-model".to_string();
let mut spec = test_spec("auto");
spec.model = Some("inherit".to_string());
let active = ActivePrimaryAgent::from_spec(&spec);
let runtime = build_primary_agent_runtime_config(&parent, &active);
assert_eq!(runtime.agent.default_model, "parent-model");
}
#[test]
fn build_primary_agent_runtime_config_inherits_parent_model_when_spec_model_empty() {
let mut parent = VTCodeConfig::default();
parent.agent.default_model = "parent-model".to_string();
let mut spec = test_spec("auto");
spec.model = Some(String::new());
let active = ActivePrimaryAgent::from_spec(&spec);
let runtime = build_primary_agent_runtime_config(&parent, &active);
assert_eq!(runtime.agent.default_model, "parent-model");
}
#[test]
fn built_in_primary_agents_resolve_required_permission_policy() {
let builtins = builtin_subagents();
for name in ["duck", "plan", "build", "auto"] {
let active = resolve_primary_agent(&builtins, name)
.unwrap_or_else(|_| panic!("missing built-in primary agent {name}"));
assert_eq!(active.identity.name, name);
let expected_default = match name {
"build" => PermissionDefault::Ask,
"auto" => PermissionDefault::Auto,
"plan" | "duck" => PermissionDefault::Deny,
_ => unreachable!("unexpected built-in primary agent"),
};
assert_eq!(active.permissions.default, expected_default);
}
}
#[test]
fn active_primary_permissions_overlay_runtime_decisions() {
let builtins = builtin_subagents();
let mut state = ActivePrimaryAgentState::from_specs(&builtins);
let config = VTCodeConfig::default();
let workspace = TempDir::new().expect("workspace");
let current_dir = workspace.path();
state.select_from_specs(&builtins, "auto").expect("auto primary");
let exec = build_permission_request(
workspace.path(),
current_dir,
tools::UNIFIED_EXEC,
Some(&json!({"command": "cargo test"})),
);
assert_eq!(
evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &exec,),
ResolvedPermissionDecision::Auto
);
state.select_from_specs(&builtins, "plan").expect("plan primary");
let edit = build_permission_request(
workspace.path(),
current_dir,
tools::UNIFIED_FILE,
Some(&json!({"action": "edit", "path": "src/lib.rs"})),
);
assert_eq!(
evaluate_active_primary_agent_permissions(&config, state.active(), workspace.path(), current_dir, &edit,),
ResolvedPermissionDecision::Deny
);
}
#[test]
fn primary_agent_switching_changes_permission_policy_without_mutating_parent_config() {
let builtins = builtin_subagents();
let mut state = ActivePrimaryAgentState::from_specs(&builtins);
let initial_model = state.active().model.clone();
let initial_tools = state.active().tools.clone();
state.select_from_specs(&builtins, "auto").expect("auto primary");
assert_eq!(state.active().identity.name, "auto");
assert_eq!(state.active().permissions.default, PermissionDefault::Auto);
assert_eq!(state.active().model, initial_model);
assert_eq!(state.active().tools, initial_tools);
state.select_from_specs(&builtins, "duck").expect("duck primary");
assert_eq!(state.active().identity.name, "duck");
assert_eq!(state.active().permissions.default, PermissionDefault::Deny);
assert_eq!(state.active().model, initial_model);
assert_ne!(state.active().tools, initial_tools);
}
#[test]
fn primary_hook_config_merges_supported_main_session_events_after_global_hooks() {
let mut global = HooksConfig::default();
global.lifecycle.user_prompt_submit = vec![hook_group("global-user")];
global.lifecycle.pre_tool_use = vec![hook_group("global-pre")];
global.lifecycle.post_tool_use = vec![hook_group("global-post")];
global.lifecycle.permission_request = vec![hook_group("global-permission")];
global.lifecycle.pre_compact = vec![hook_group("global-compact")];
global.lifecycle.stop = vec![hook_group("global-stop")];
global.lifecycle.notification = vec![hook_group("global-notification")];
let mut primary_hooks = HooksConfig::default();
primary_hooks.lifecycle.user_prompt_submit = vec![hook_group("primary-user")];
primary_hooks.lifecycle.pre_tool_use = vec![hook_group("primary-pre")];
primary_hooks.lifecycle.post_tool_use = vec![hook_group("primary-post")];
primary_hooks.lifecycle.permission_request = vec![hook_group("primary-permission")];
primary_hooks.lifecycle.pre_compact = vec![hook_group("primary-compact")];
primary_hooks.lifecycle.stop = vec![hook_group("primary-stop")];
primary_hooks.lifecycle.notification = vec![hook_group("primary-notification")];
let mut spec = test_spec("worker");
spec.hooks = Some(primary_hooks);
let active = ActivePrimaryAgent::from_spec(&spec);
let merged = build_primary_agent_hook_config(&global, &active);
assert_hook_commands(&merged.lifecycle.user_prompt_submit, &["global-user", "primary-user"]);
assert_hook_commands(&merged.lifecycle.pre_tool_use, &["global-pre", "primary-pre"]);
assert_hook_commands(&merged.lifecycle.post_tool_use, &["global-post", "primary-post"]);
assert_hook_commands(&merged.lifecycle.permission_request, &["global-permission", "primary-permission"]);
assert_hook_commands(&merged.lifecycle.pre_compact, &["global-compact", "primary-compact"]);
assert_hook_commands(&merged.lifecycle.stop, &["global-stop", "primary-stop"]);
assert_hook_commands(&merged.lifecycle.notification, &["global-notification", "primary-notification"]);
}
#[test]
fn primary_hook_config_merges_session_and_subagent_lifecycle_events() {
let global = HooksConfig::default();
let mut primary_hooks = HooksConfig::default();
primary_hooks.lifecycle.session_start = vec![hook_group("primary-session-start")];
primary_hooks.lifecycle.session_end = vec![hook_group("primary-session-end")];
primary_hooks.lifecycle.subagent_start = vec![hook_group("primary-subagent-start")];
primary_hooks.lifecycle.subagent_stop = vec![hook_group("primary-subagent-stop")];
primary_hooks.lifecycle.task_completion = vec![hook_group("primary-task-completion")];
primary_hooks.lifecycle.task_completed = vec![hook_group("primary-task-completed")];
let mut spec = test_spec("worker");
spec.hooks = Some(primary_hooks);
let active = ActivePrimaryAgent::from_spec(&spec);
let merged = build_primary_agent_hook_config(&global, &active);
assert_hook_commands(&merged.lifecycle.session_start, &["primary-session-start"]);
assert_hook_commands(&merged.lifecycle.session_end, &["primary-session-end"]);
assert_hook_commands(&merged.lifecycle.subagent_start, &["primary-subagent-start"]);
assert_hook_commands(&merged.lifecycle.subagent_stop, &["primary-subagent-stop"]);
assert!(merged.lifecycle.task_completion.is_empty());
assert!(merged.lifecycle.task_completed.is_empty());
assert!(merged.lifecycle.stop.is_empty());
}
#[test]
fn primary_hook_config_recomputes_without_previous_primary_leakage() {
let global = HooksConfig::default();
let mut first_hooks = HooksConfig::default();
first_hooks.lifecycle.pre_tool_use = vec![hook_group("first-pre")];
let mut second_hooks = HooksConfig::default();
second_hooks.lifecycle.pre_tool_use = vec![hook_group("second-pre")];
let mut first = test_spec("first");
first.hooks = Some(first_hooks);
let mut second = test_spec("second");
second.hooks = Some(second_hooks);
let specs = vec![first, second];
let mut state = ActivePrimaryAgentState::default();
state.select_from_specs(&specs, "first").expect("selected first");
let first_config = build_primary_agent_hook_config(&global, state.active());
assert_hook_commands(&first_config.lifecycle.pre_tool_use, &["first-pre"]);
state.select_from_specs(&specs, "second").expect("selected second");
let second_config = build_primary_agent_hook_config(&global, state.active());
assert_hook_commands(&second_config.lifecycle.pre_tool_use, &["second-pre"]);
}
#[test]
fn active_primary_state_recomputes_skills_mcp_and_metadata_on_switch() {
let mut first = test_spec("first");
first.description = "First metadata".to_string();
first.color = Some("red".to_string());
first.aliases = vec!["one".to_string()];
first.skills = vec!["rust".to_string()];
first.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
"first-mcp".to_string(),
json!({
"type": "stdio",
"command": "first-mcp"
}),
)]))];
let second = test_spec("second");
let specs = vec![first, second];
let mut state = ActivePrimaryAgentState::default();
state.select_from_specs(&specs, "one").expect("selected first by alias");
assert_eq!(state.active().identity.name, "first");
assert_eq!(state.active().description, "First metadata");
assert_eq!(state.active().color.as_deref(), Some("red"));
assert_eq!(state.active().aliases, vec!["one".to_string()]);
assert_eq!(state.active().skills, vec!["rust".to_string()]);
assert_eq!(state.active().mcp_servers.len(), 1);
state.select_from_specs(&specs, "second").expect("selected second");
assert_eq!(state.active().identity.name, "second");
assert_eq!(state.active().description, "second description");
assert_eq!(state.active().color.as_deref(), Some("blue"));
assert!(state.active().aliases.is_empty());
assert!(state.active().skills.is_empty());
assert!(state.active().mcp_servers.is_empty());
}
fn test_spec(name: &str) -> SubagentSpec {
SubagentSpec {
name: name.to_string(),
description: format!("{name} description"),
prompt: format!("{name} instructions"),
tools: Some(vec!["code_search".to_string()]),
disallowed_tools: vec!["file_operation".to_string()],
model: Some("gpt-5.1".to_string()),
color: Some("blue".to_string()),
reasoning_effort: Some(ReasoningEffortLevel::High),
permissions: AgentPermissionsConfig::new(PermissionDefault::Deny),
skills: Vec::new(),
mcp_servers: Vec::new(),
hooks: None,
background: false,
mode: vtcode_config::AgentMode::Primary,
max_turns: None,
nickname_candidates: Vec::new(),
initial_prompt: None,
memory: None,
isolation: None,
aliases: Vec::new(),
source: SubagentSource::ProjectVtcode,
file_path: None,
warnings: Vec::new(),
tool_policy_overrides: std::collections::BTreeMap::new(),
}
}
fn hook_group(command: &str) -> HookGroupConfig {
HookGroupConfig {
matcher: None,
hooks: vec![HookCommandConfig {
command: command.to_string(),
..HookCommandConfig::default()
}],
}
}
fn assert_hook_commands(groups: &[HookGroupConfig], expected: &[&str]) {
let commands = groups
.iter()
.flat_map(|group| group.hooks.iter())
.map(|hook| hook.command.as_str())
.collect::<Vec<_>>();
assert_eq!(commands, expected);
}
}