Skip to main content

vtcode_core/subagents/
mod.rs

1// ─── Module Structure ───────────────────────────────────────────────────────
2
3mod background;
4mod config;
5mod constants;
6mod discovery;
7mod model;
8mod prompt;
9mod types;
10
11// ─── Re-exports ─────────────────────────────────────────────────────────────
12
13pub use background::{
14    background_record_id, build_background_subagent_command, extract_tail_lines,
15    load_archive_preview, subagent_display_label,
16};
17pub use config::{
18    ResolvedAgentRuntimeView, build_child_config, compose_subagent_instructions,
19    filter_child_tools, normalize_background_child_max_turns, normalize_child_max_turns,
20    prepare_child_runtime_config,
21};
22pub use model::{agent_type_for_spec, load_memory_appendix, load_primary_memory_appendix};
23pub use prompt::{
24    contains_explicit_delegation_request, contains_explicit_model_request,
25    delegated_task_requires_clarification, extract_explicit_agent_mentions,
26    normalize_requested_model_override, request_prompt, sanitize_subagent_input_items,
27};
28pub use types::{
29    BackgroundRecord, BackgroundSubprocessEntry, BackgroundSubprocessSnapshot,
30    BackgroundSubprocessStatus, ChildRecord, ChildRunResult, ControllerState,
31    PersistedBackgroundRecord, PersistedBackgroundState, SendInputRequest, SpawnAgentRequest,
32    SpawnBackgroundSubprocessRequest, StatusEntryBuilder, SubagentInputItem, SubagentStatus,
33    SubagentStatusEntry, SubagentThreadSnapshot, TurnDelegationHints,
34};
35
36// ─── Public Utilities ───────────────────────────────────────────────────────
37
38pub fn is_subagent_tool(name: &str) -> bool {
39    SUBAGENT_TOOL_NAMES.contains(&name)
40}
41
42#[derive(Clone, Default)]
43struct BackgroundLaunchOverrides {
44    prompt: Option<String>,
45    max_turns: Option<usize>,
46    model_override: Option<String>,
47    reasoning_override: Option<String>,
48}
49
50#[derive(Clone, Default)]
51struct PreparedDelegationContext {
52    requested_agent: Option<String>,
53    explicit_mentions: Vec<String>,
54    explicit_request: bool,
55    current_input: String,
56}
57
58// ─── Controller ─────────────────────────────────────────────────────────────
59
60use anyhow::{Context, Result, anyhow, bail};
61use chrono::Utc;
62use futures::future::select_all;
63use std::collections::VecDeque;
64use std::path::PathBuf;
65use std::sync::Arc;
66use tokio::sync::{Notify, RwLock};
67
68use crate::config::VTCodeConfig;
69use crate::config::types::ReasoningEffortLevel;
70use crate::core::agent::runner::{AgentRunner, RunnerSettings};
71use crate::core::agent::task::Task;
72use crate::core::threads::{ThreadBootstrap, ThreadId, ThreadRuntimeHandle, ThreadSnapshot};
73use crate::hooks::{LifecycleHookEngine, SessionStartTrigger};
74use crate::llm::provider::Message;
75use crate::tools::exec_session::ExecSessionManager;
76use crate::tools::pty::{PtyManager, PtySize};
77use crate::utils::session_archive::{SessionArchive, find_session_by_identifier};
78use vtcode_config::SubagentSpec;
79use vtcode_config::auth::OpenAIChatGptAuthHandle;
80
81use self::background::*;
82use self::config::*;
83use self::constants::*;
84use self::discovery::discover_controller_subagents;
85use self::model::*;
86
87// ─── Controller Config ─────────────────────────────────────────────────────
88
89#[derive(Clone)]
90pub struct SubagentControllerConfig {
91    pub workspace_root: PathBuf,
92    pub parent_session_id: String,
93    pub parent_model: String,
94    pub parent_provider: String,
95    pub parent_reasoning_effort: ReasoningEffortLevel,
96    pub api_key: String,
97    pub vt_cfg: VTCodeConfig,
98    pub openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
99    pub depth: usize,
100    pub exec_sessions: ExecSessionManager,
101    pub pty_manager: PtyManager,
102    pub managed_background_runtime: bool,
103}
104
105#[derive(Clone)]
106pub struct SubagentController {
107    config: Arc<SubagentControllerConfig>,
108    parent_session_id: Arc<RwLock<String>>,
109    lifecycle_hooks: Option<LifecycleHookEngine>,
110    state: Arc<RwLock<ControllerState>>,
111}
112
113impl SubagentController {
114    pub async fn new(config: SubagentControllerConfig) -> Result<Self> {
115        let discovered = discover_controller_subagents(&config.workspace_root).await?;
116        let lifecycle_hooks = LifecycleHookEngine::new_with_session(
117            config.workspace_root.clone(),
118            &config.vt_cfg.hooks,
119            SessionStartTrigger::Startup,
120            config.parent_session_id.clone(),
121        )?;
122        let background_children = load_background_state(&config.workspace_root)?
123            .records
124            .into_iter()
125            .map(|record| (record.id.clone(), BackgroundRecord::from_persisted(record)))
126            .collect();
127        Ok(Self {
128            parent_session_id: Arc::new(RwLock::new(config.parent_session_id.clone())),
129            lifecycle_hooks,
130            config: Arc::new(config),
131            state: Arc::new(RwLock::new(ControllerState {
132                discovered,
133                parent_messages: Vec::new(),
134                turn_hints: TurnDelegationHints::default(),
135                children: std::collections::BTreeMap::new(),
136                background_children,
137            })),
138        })
139    }
140
141    pub async fn reload(&self) -> Result<()> {
142        let discovered = discover_controller_subagents(&self.config.workspace_root).await?;
143        self.state.write().await.discovered = discovered;
144        Ok(())
145    }
146
147    pub async fn set_parent_messages(&self, messages: &[Message]) {
148        let cloned = messages.to_vec();
149        self.state.write().await.parent_messages = cloned;
150    }
151
152    pub async fn set_turn_delegation_hints_from_input(&self, input: &str) -> Vec<String> {
153        let mut state = self.state.write().await;
154        let explicit_mentions =
155            extract_explicit_agent_mentions(input, state.discovered.effective.as_slice());
156        let explicit_request =
157            contains_explicit_delegation_request(input, explicit_mentions.as_slice());
158        state.turn_hints = TurnDelegationHints {
159            explicit_mentions: explicit_mentions.clone(),
160            explicit_request,
161            current_input: input.to_string(),
162        };
163        explicit_mentions
164    }
165
166    pub async fn clear_turn_delegation_hints(&self) {
167        self.state.write().await.turn_hints = TurnDelegationHints::default();
168    }
169
170    pub async fn set_parent_session_id(&self, session_id: impl Into<String>) {
171        *self.parent_session_id.write().await = session_id.into();
172    }
173
174    pub async fn effective_specs(&self) -> Vec<SubagentSpec> {
175        self.state.read().await.discovered.effective.clone()
176    }
177
178    pub async fn shadowed_specs(&self) -> Vec<SubagentSpec> {
179        self.state.read().await.discovered.shadowed.clone()
180    }
181
182    pub async fn status_entries(&self) -> Vec<SubagentStatusEntry> {
183        let state = self.state.read().await;
184        state
185            .children
186            .values()
187            .map(ChildRecord::build_status_entry)
188            .collect()
189    }
190
191    pub async fn background_status_entries(&self) -> Vec<BackgroundSubprocessEntry> {
192        let state = self.state.read().await;
193        state
194            .background_children
195            .values()
196            .map(BackgroundRecord::build_status_entry)
197            .collect()
198    }
199
200    pub async fn background_snapshot(&self, target: &str) -> Result<BackgroundSubprocessSnapshot> {
201        let _ = self.refresh_background_processes().await?;
202
203        let entry = {
204            let state = self.state.read().await;
205            state
206                .background_children
207                .get(target)
208                .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?
209                .build_status_entry()
210        };
211
212        let preview = if entry.exec_session_id.is_empty() {
213            String::new()
214        } else {
215            match self
216                .config
217                .exec_sessions
218                .read_session_output(&entry.exec_session_id, false)
219                .await
220            {
221                Ok(Some(output)) => extract_tail_lines(&output, SUBAGENT_PREVIEW_LINES),
222                Ok(None) | Err(_) => {
223                    if let Some(path) = entry
224                        .transcript_path
225                        .as_ref()
226                        .or(entry.archive_path.as_ref())
227                    {
228                        load_archive_preview(path).unwrap_or_default()
229                    } else {
230                        String::new()
231                    }
232                }
233            }
234        };
235
236        Ok(BackgroundSubprocessSnapshot { entry, preview })
237    }
238
239    #[must_use]
240    pub fn background_subagents_enabled(&self) -> bool {
241        self.config.vt_cfg.subagents.background.enabled
242    }
243
244    #[must_use]
245    pub fn configured_default_background_agent(&self) -> Option<&str> {
246        self.config
247            .vt_cfg
248            .subagents
249            .background
250            .default_agent
251            .as_deref()
252            .map(str::trim)
253            .filter(|agent| !agent.is_empty())
254    }
255
256    pub async fn toggle_default_background_subagent(&self) -> Result<BackgroundSubprocessEntry> {
257        if !self.background_subagents_enabled() {
258            bail!("Background subagents are disabled by configuration");
259        }
260
261        let agent_name = self
262            .configured_default_background_agent()
263            .ok_or_else(|| anyhow!("No default background subagent is configured"))?
264            .to_string();
265        let target_id = background_record_id(agent_name.as_str());
266        let should_stop = {
267            let state = self.state.read().await;
268            state
269                .background_children
270                .get(&target_id)
271                .is_some_and(|record| record.desired_enabled && record.status.is_active())
272        };
273
274        if should_stop {
275            self.graceful_stop_background(&target_id).await
276        } else {
277            self.ensure_background_record_running(
278                agent_name.as_str(),
279                Some(target_id.as_str()),
280                0,
281                None,
282            )
283            .await
284        }
285    }
286
287    pub async fn restore_background_subagents(&self) -> Result<Vec<BackgroundSubprocessEntry>> {
288        let desired_records = {
289            let state = self.state.read().await;
290            state
291                .background_children
292                .values()
293                .filter(|record| record.desired_enabled)
294                .map(|record| {
295                    (
296                        record.id.clone(),
297                        record.agent_name.clone(),
298                        record.exec_session_id.clone(),
299                        record.restart_attempts,
300                    )
301                })
302                .collect::<Vec<_>>()
303        };
304
305        for (record_id, agent_name, exec_session_id, restart_attempts) in desired_records {
306            let is_live = !exec_session_id.is_empty()
307                && self
308                    .config
309                    .exec_sessions
310                    .snapshot_session(&exec_session_id)
311                    .await
312                    .ok()
313                    .is_some_and(|snapshot| exec_session_is_running(&snapshot));
314
315            if is_live || !self.config.vt_cfg.subagents.background.auto_restore {
316                continue;
317            }
318            tracing::info!(
319                agent_name = agent_name.as_str(),
320                record_id = record_id.as_str(),
321                "Restoring background subagent subprocess"
322            );
323            self.ensure_background_record_running(
324                agent_name.as_str(),
325                Some(record_id.as_str()),
326                restart_attempts,
327                None,
328            )
329            .await?;
330        }
331
332        self.refresh_background_processes().await
333    }
334
335    pub async fn refresh_background_processes(&self) -> Result<Vec<BackgroundSubprocessEntry>> {
336        let record_ids = {
337            let state = self.state.read().await;
338            state
339                .background_children
340                .keys()
341                .cloned()
342                .collect::<Vec<_>>()
343        };
344
345        for record_id in record_ids {
346            let snapshot_target = {
347                let state = self.state.read().await;
348                state
349                    .background_children
350                    .get(&record_id)
351                    .map(|record| record.exec_session_id.clone())
352            };
353
354            let snapshot = if let Some(exec_session_id) = snapshot_target.as_ref()
355                && !exec_session_id.is_empty()
356            {
357                self.config
358                    .exec_sessions
359                    .snapshot_session(exec_session_id)
360                    .await
361                    .ok()
362            } else {
363                None
364            };
365
366            let respawn = self
367                .update_background_record_state(&record_id, snapshot)
368                .await?;
369
370            if let Some((agent_name, stable_id, restart_attempts)) = respawn {
371                self.ensure_background_record_running(
372                    agent_name.as_str(),
373                    Some(stable_id.as_str()),
374                    restart_attempts,
375                    None,
376                )
377                .await?;
378            }
379
380            self.refresh_background_archive_metadata(&record_id).await?;
381        }
382
383        self.save_background_state().await?;
384        Ok(self.background_status_entries().await)
385    }
386
387    async fn update_background_record_state(
388        &self,
389        record_id: &str,
390        snapshot: Option<crate::tools::types::VTCodeExecSession>,
391    ) -> Result<Option<(String, String, u8)>> {
392        let mut state = self.state.write().await;
393        let Some(record) = state.background_children.get_mut(record_id) else {
394            return Ok(None);
395        };
396        record.updated_at = Utc::now();
397
398        let Some(snapshot) = snapshot else {
399            return Self::handle_missing_background_snapshot(record, &self.config);
400        };
401
402        record.pid = snapshot.child_pid;
403        record.started_at = snapshot.started_at.or(record.started_at);
404
405        match snapshot.lifecycle_state {
406            Some(crate::tools::types::VTCodeSessionLifecycleState::Running) => {
407                record.status = BackgroundSubprocessStatus::Running;
408                record.ended_at = None;
409                record.error = None;
410            }
411            Some(crate::tools::types::VTCodeSessionLifecycleState::Exited) | None => {
412                record.ended_at.get_or_insert(Utc::now());
413                if record.desired_enabled
414                    && self.config.vt_cfg.subagents.background.auto_restore
415                    && record.restart_attempts < 1
416                {
417                    let next_restart_attempt = record.restart_attempts.saturating_add(1);
418                    record.restart_attempts = next_restart_attempt;
419                    record.status = BackgroundSubprocessStatus::Starting;
420                    tracing::warn!(
421                        agent_name = record.agent_name.as_str(),
422                        record_id = record.id.as_str(),
423                        attempt = next_restart_attempt,
424                        "Background subprocess exited unexpectedly; scheduling restart"
425                    );
426                    return Ok(Some((
427                        record.agent_name.clone(),
428                        record.id.clone(),
429                        next_restart_attempt,
430                    )));
431                }
432                Self::mark_background_record_stopped_or_error(record, &snapshot, &self.config);
433            }
434        }
435
436        Ok(None)
437    }
438
439    fn handle_missing_background_snapshot(
440        record: &mut BackgroundRecord,
441        config: &SubagentControllerConfig,
442    ) -> Result<Option<(String, String, u8)>> {
443        if record.desired_enabled && config.vt_cfg.subagents.background.auto_restore {
444            if record.restart_attempts < 1 {
445                let next_restart_attempt = record.restart_attempts.saturating_add(1);
446                record.restart_attempts = next_restart_attempt;
447                record.status = BackgroundSubprocessStatus::Starting;
448                tracing::warn!(
449                    agent_name = record.agent_name.as_str(),
450                    record_id = record.id.as_str(),
451                    attempt = next_restart_attempt,
452                    "Background subprocess is missing; scheduling restart"
453                );
454                return Ok(Some((
455                    record.agent_name.clone(),
456                    record.id.clone(),
457                    next_restart_attempt,
458                )));
459            }
460            record.status = BackgroundSubprocessStatus::Error;
461            record.error = Some("Background subprocess is not running".to_string());
462            record.ended_at.get_or_insert(Utc::now());
463        } else if !record.desired_enabled {
464            record.status = BackgroundSubprocessStatus::Stopped;
465            record.ended_at.get_or_insert(Utc::now());
466        }
467        Ok(None)
468    }
469
470    fn mark_background_record_stopped_or_error(
471        record: &mut BackgroundRecord,
472        snapshot: &crate::tools::types::VTCodeExecSession,
473        _config: &SubagentControllerConfig,
474    ) {
475        if record.desired_enabled {
476            record.status = BackgroundSubprocessStatus::Error;
477            record.error = Some(match snapshot.exit_code {
478                Some(exit_code) => format!("Background subprocess exited with code {exit_code}"),
479                None => "Background subprocess exited unexpectedly".to_string(),
480            });
481        } else {
482            record.status = BackgroundSubprocessStatus::Stopped;
483        }
484    }
485
486    pub async fn graceful_stop_background(
487        &self,
488        target: &str,
489    ) -> Result<BackgroundSubprocessEntry> {
490        let (agent_name, exec_session_id) = {
491            let mut state = self.state.write().await;
492            let record = state
493                .background_children
494                .get_mut(target)
495                .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?;
496            record.desired_enabled = false;
497            record.status = BackgroundSubprocessStatus::Stopped;
498            record.updated_at = Utc::now();
499            record.ended_at = Some(Utc::now());
500            (record.agent_name.clone(), record.exec_session_id.clone())
501        };
502
503        tracing::info!(
504            agent_name = agent_name.as_str(),
505            record_id = target,
506            exec_session_id = exec_session_id.as_str(),
507            "Gracefully stopping background subagent subprocess"
508        );
509
510        if !exec_session_id.is_empty() {
511            let _ = self
512                .config
513                .exec_sessions
514                .terminate_session(&exec_session_id)
515                .await;
516            let _ = self
517                .config
518                .exec_sessions
519                .prune_exited_session(&exec_session_id)
520                .await;
521        }
522
523        self.refresh_background_archive_metadata(target).await?;
524        self.save_background_state().await?;
525        self.background_status_for(target).await
526    }
527
528    pub async fn force_cancel_background(&self, target: &str) -> Result<BackgroundSubprocessEntry> {
529        let (agent_name, exec_session_id) = {
530            let mut state = self.state.write().await;
531            let record = state
532                .background_children
533                .get_mut(target)
534                .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?;
535            record.desired_enabled = false;
536            record.status = BackgroundSubprocessStatus::Stopped;
537            record.updated_at = Utc::now();
538            record.ended_at = Some(Utc::now());
539            (record.agent_name.clone(), record.exec_session_id.clone())
540        };
541
542        tracing::info!(
543            agent_name = agent_name.as_str(),
544            record_id = target,
545            exec_session_id = exec_session_id.as_str(),
546            "Force cancelling background subagent subprocess"
547        );
548
549        if !exec_session_id.is_empty() {
550            let _ = self
551                .config
552                .exec_sessions
553                .close_session(&exec_session_id)
554                .await;
555        }
556
557        self.refresh_background_archive_metadata(target).await?;
558        self.save_background_state().await?;
559        self.background_status_for(target).await
560    }
561
562    pub async fn snapshot_for_thread(&self, target: &str) -> Result<SubagentThreadSnapshot> {
563        let (
564            id,
565            session_id,
566            parent_thread_id,
567            agent_name,
568            display_label,
569            status,
570            background,
571            created_at,
572            updated_at,
573            archive_path,
574            transcript_path,
575            effective_config,
576            thread_handle,
577            archive_metadata,
578            stored_messages,
579            recent_events,
580        ) = {
581            let state = self.state.read().await;
582            let record = state
583                .children
584                .get(target)
585                .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
586            (
587                record.id.clone(),
588                record.session_id.clone(),
589                record.parent_thread_id.clone(),
590                record.spec.name.clone(),
591                record.display_label.clone(),
592                record.status,
593                record.background,
594                record.created_at,
595                record.updated_at,
596                record.archive_path.clone(),
597                record.transcript_path.clone(),
598                record.effective_config.clone(),
599                record.thread_handle.clone(),
600                record.archive_metadata.clone(),
601                record.stored_messages.clone(),
602                record
603                    .thread_handle
604                    .as_ref()
605                    .map(ThreadRuntimeHandle::recent_events)
606                    .unwrap_or_default(),
607            )
608        };
609
610        let effective_config = effective_config.ok_or_else(|| {
611            anyhow!(
612                "Subagent {} does not have a captured runtime configuration yet",
613                target
614            )
615        })?;
616        let snapshot = match thread_handle {
617            Some(handle) => handle.snapshot(),
618            None => {
619                let archive_listing = match archive_path.as_ref() {
620                    Some(path) if path.exists() => load_session_listing(path).ok(),
621                    _ => None,
622                };
623                let metadata = archive_listing
624                    .as_ref()
625                    .map(|listing| listing.snapshot.metadata.clone())
626                    .or(archive_metadata)
627                    .or_else(|| {
628                        Some(crate::core::threads::build_thread_archive_metadata(
629                            &self.config.workspace_root,
630                            effective_config.agent.default_model.as_str(),
631                            effective_config.agent.provider.as_str(),
632                            effective_config.agent.theme.as_str(),
633                            effective_config.agent.reasoning_effort.as_str(),
634                        ))
635                    });
636                ThreadSnapshot {
637                    thread_id: ThreadId::new(session_id.clone()),
638                    metadata,
639                    archive_listing,
640                    messages: stored_messages,
641                    loaded_skills: Vec::new(),
642                    turn_in_flight: false,
643                }
644            }
645        };
646
647        Ok(SubagentThreadSnapshot {
648            id,
649            session_id,
650            parent_thread_id,
651            agent_name,
652            display_label,
653            status,
654            background,
655            created_at,
656            updated_at,
657            archive_path,
658            transcript_path,
659            effective_config,
660            snapshot,
661            recent_events,
662        })
663    }
664
665    pub async fn spawn(&self, request: SpawnAgentRequest) -> Result<SubagentStatusEntry> {
666        let mut request = request;
667        let delegation = self
668            .prepare_delegation_context(
669                request.agent_type.clone(),
670                &mut request.items,
671                &mut request.model,
672                "spawn_agent",
673            )
674            .await?;
675        let spec = self
676            .resolve_requested_spec(delegation.requested_agent.as_deref())
677            .await?;
678        let prompt = self.prepare_delegation_prompt(
679            &spec,
680            &delegation,
681            &mut request.model,
682            &request.message,
683            &request.items,
684            "spawn_agent",
685            "spawning the subagent",
686            "Ignoring subagent model override because the current user turn did not explicitly request that model",
687        )?;
688        self.spawn_with_spec(
689            spec,
690            prompt,
691            request.fork_context,
692            request.background,
693            request.max_turns,
694            request.model,
695            request.reasoning_effort,
696        )
697        .await
698    }
699
700    pub async fn spawn_background_subprocess(
701        &self,
702        request: SpawnBackgroundSubprocessRequest,
703    ) -> Result<BackgroundSubprocessEntry> {
704        if self.config.managed_background_runtime {
705            bail!("managed background subprocesses cannot launch nested background subprocesses");
706        }
707        if !self.config.vt_cfg.subagents.background.enabled {
708            bail!("Background subagents are disabled by configuration");
709        }
710
711        let mut request = request;
712        let delegation = self
713            .prepare_delegation_context(
714                request.agent_type.clone(),
715                &mut request.items,
716                &mut request.model,
717                "spawn_background_subprocess",
718            )
719            .await?;
720        let spec = self
721            .resolve_requested_spec(delegation.requested_agent.as_deref())
722            .await?;
723        if !spec.background {
724            bail!(
725                "spawn_background_subprocess requires an agent with `background: true`; '{}' is a normal delegated child agent. Use spawn_agent instead.",
726                spec.name
727            );
728        }
729        let prompt = self.prepare_delegation_prompt(
730            &spec,
731            &delegation,
732            &mut request.model,
733            &request.message,
734            &request.items,
735            "spawn_background_subprocess",
736            "launching the background subprocess",
737            "Ignoring background subprocess model override because the current user turn did not explicitly request that model",
738        )?;
739        let desired_max_turns =
740            normalize_background_child_max_turns(request.max_turns.or(spec.max_turns), true);
741        let desired_model_override = request.model.clone().or_else(|| spec.model.clone());
742        let desired_reasoning_override = request
743            .reasoning_effort
744            .clone()
745            .or_else(|| spec.reasoning_effort.clone());
746
747        let record_id = background_record_id(spec.name.as_str());
748        let _ = self.refresh_background_processes().await?;
749        {
750            let state = self.state.read().await;
751            if let Some(record) = state.background_children.get(&record_id)
752                && record.desired_enabled
753                && record.status.is_active()
754            {
755                let conflicts = Self::active_background_launch_conflicts(
756                    record,
757                    prompt.as_str(),
758                    desired_max_turns,
759                    desired_model_override.as_deref(),
760                    desired_reasoning_override.as_deref(),
761                );
762                if !conflicts.is_empty() {
763                    bail!(
764                        "spawn_background_subprocess found active background subprocess '{}' with different {}. Stop or restart the existing subprocess before changing its launch settings.",
765                        spec.name,
766                        conflicts.join(", "),
767                    );
768                }
769                return Ok(record.build_status_entry());
770            }
771        }
772
773        self.ensure_background_record_running(
774            spec.name.as_str(),
775            Some(record_id.as_str()),
776            0,
777            Some(BackgroundLaunchOverrides {
778                prompt: Some(prompt),
779                max_turns: request.max_turns,
780                model_override: request.model,
781                reasoning_override: request.reasoning_effort,
782            }),
783        )
784        .await
785    }
786
787    pub async fn spawn_custom(
788        &self,
789        spec: SubagentSpec,
790        request: SpawnAgentRequest,
791    ) -> Result<SubagentStatusEntry> {
792        if !spec.is_subagent() {
793            bail!(
794                "custom subagent spawn only supports subagent-capable specs; '{}' is primary-only",
795                spec.name
796            );
797        }
798
799        if !spec.is_read_only() {
800            bail!(
801                "custom subagent spawn only supports read-only specs; '{}' exposes write-capable behavior",
802                spec.name
803            );
804        }
805
806        let mut request = request;
807        sanitize_subagent_input_items(&mut request.items);
808
809        let prompt = request_prompt(&request.message, &request.items)
810            .or_else(|| spec.initial_prompt.clone())
811            .filter(|value| !value.trim().is_empty())
812            .ok_or_else(|| anyhow!("custom subagent spawn requires a task message or items"))?;
813        if delegated_task_requires_clarification(&prompt) {
814            bail!(
815                "custom subagent task for '{}' is too vague ('{}'). Provide a specific delegated task before spawning the subagent.",
816                spec.name,
817                prompt.trim()
818            );
819        }
820
821        self.spawn_with_spec(
822            spec,
823            prompt,
824            request.fork_context,
825            request.background,
826            request.max_turns,
827            request.model,
828            request.reasoning_effort,
829        )
830        .await
831    }
832
833    pub async fn send_input(&self, request: SendInputRequest) -> Result<SubagentStatusEntry> {
834        let prompt = request_prompt(&request.message, &request.items)
835            .ok_or_else(|| anyhow!("send_input requires a message or items"))?;
836
837        let maybe_restart = {
838            let mut state = self.state.write().await;
839            let record = state
840                .children
841                .get_mut(&request.target)
842                .ok_or_else(|| anyhow!("Unknown subagent id {}", request.target))?;
843
844            if record.status == SubagentStatus::Closed {
845                bail!("Subagent {} is closed", request.target);
846            }
847
848            record.updated_at = Utc::now();
849            record.last_prompt = Some(prompt.clone());
850
851            if request.interrupt {
852                if let Some(handle) = record.handle.take() {
853                    handle.abort();
854                }
855                record.status = SubagentStatus::Queued;
856                record.queued_prompts.clear();
857                record.queued_prompts.push_back(prompt.clone());
858                true
859            } else if matches!(
860                record.status,
861                SubagentStatus::Running | SubagentStatus::Queued
862            ) {
863                record.status = SubagentStatus::Waiting;
864                record.queued_prompts.push_back(prompt.clone());
865                false
866            } else {
867                record.status = SubagentStatus::Queued;
868                record.queued_prompts.push_back(prompt.clone());
869                true
870            }
871        };
872
873        if maybe_restart {
874            self.restart_child(&request.target).await?;
875        }
876
877        self.status_for(&request.target).await
878    }
879
880    pub async fn resume(&self, target: &str) -> Result<SubagentStatusEntry> {
881        let subtree_ids = self.collect_spawn_subtree_ids(target).await?;
882        let mut restart_ids = Vec::new();
883        for node_id in subtree_ids {
884            if self.reopen_single(node_id.as_str()).await? {
885                restart_ids.push(node_id);
886            }
887        }
888        for restart_id in restart_ids {
889            self.restart_child(&restart_id).await?;
890        }
891        self.status_for(target).await
892    }
893
894    pub async fn close(&self, target: &str) -> Result<SubagentStatusEntry> {
895        let subtree_ids = self.collect_spawn_subtree_ids(target).await?;
896        for node_id in subtree_ids.into_iter().rev() {
897            self.close_single(node_id.as_str()).await?;
898        }
899        self.status_for(target).await
900    }
901
902    pub async fn wait(
903        &self,
904        targets: &[String],
905        timeout_ms: Option<u64>,
906    ) -> Result<Option<SubagentStatusEntry>> {
907        for target in targets {
908            if let Ok(entry) = self.status_for(target).await
909                && entry.status.is_terminal()
910            {
911                return Ok(Some(entry));
912            }
913        }
914
915        let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or_else(|| {
916            self.config
917                .vt_cfg
918                .subagents
919                .default_timeout_seconds
920                .saturating_mul(1000)
921        }));
922        let deadline = tokio::time::Instant::now() + timeout;
923
924        loop {
925            // Collect notify handles from child records.
926            let notifies = {
927                let state = self.state.read().await;
928                targets
929                    .iter()
930                    .filter_map(|target| {
931                        state
932                            .children
933                            .get(target)
934                            .map(|record| record.notify.clone())
935                    })
936                    .collect::<Vec<_>>()
937            };
938            if notifies.is_empty() {
939                return Ok(None);
940            }
941
942            // Register notified() futures BEFORE checking terminal status.
943            // This prevents a Tokio Notify race condition: if apply_result()
944            // calls notify_waiters() between a status check and future
945            // creation, the notification is permanently lost. By registering
946            // futures first, any concurrent notification either:
947            //   (a) arrives before we poll the future → stored as a permit,
948            //       select! returns immediately, loop re-checks status, or
949            //   (b) arrives after we start waiting → wakes the future normally.
950            let wait_any = select_all(
951                notifies
952                    .into_iter()
953                    .map(|notify| Box::pin(async move { notify.notified().await }))
954                    .collect::<Vec<_>>(),
955            );
956            tokio::pin!(wait_any);
957
958            // Now check if any target is already terminal.
959            for target in targets {
960                if let Ok(entry) = self.status_for(target).await
961                    && entry.status.is_terminal()
962                {
963                    return Ok(Some(entry));
964                }
965            }
966
967            let sleep = tokio::time::sleep_until(deadline);
968            tokio::pin!(sleep);
969
970            tokio::select! {
971                _ = &mut sleep => return Ok(None),
972                _ = &mut wait_any => {}
973            }
974        }
975    }
976
977    pub async fn status_for(&self, target: &str) -> Result<SubagentStatusEntry> {
978        let state = self.state.read().await;
979        let record = state
980            .children
981            .get(target)
982            .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
983        Ok(record.build_status_entry())
984    }
985
986    async fn spawn_child_ids_for_parent(&self, parent_thread_id: &str) -> Vec<String> {
987        let state = self.state.read().await;
988        let mut child_ids = state
989            .children
990            .values()
991            .filter(|record| record.parent_thread_id == parent_thread_id)
992            .map(|record| record.id.clone())
993            .collect::<Vec<_>>();
994        child_ids.sort();
995        child_ids
996    }
997
998    async fn collect_spawn_subtree_ids(&self, root_thread_id: &str) -> Result<Vec<String>> {
999        let mut subtree_ids = Vec::new();
1000        let mut stack = vec![root_thread_id.to_string()];
1001
1002        while let Some(thread_id) = stack.pop() {
1003            subtree_ids.push(thread_id.clone());
1004            let child_ids = self.spawn_child_ids_for_parent(&thread_id).await;
1005            for child_id in child_ids.into_iter().rev() {
1006                stack.push(child_id);
1007            }
1008        }
1009
1010        Ok(subtree_ids)
1011    }
1012
1013    async fn reopen_single(&self, target: &str) -> Result<bool> {
1014        let mut state = self.state.write().await;
1015        let record = state
1016            .children
1017            .get_mut(target)
1018            .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1019        if matches!(
1020            record.status,
1021            SubagentStatus::Running | SubagentStatus::Queued
1022        ) {
1023            return Ok(false);
1024        }
1025        let prompt = record.last_prompt.clone().unwrap_or_else(|| {
1026            "Continue the delegated task from the existing context.".to_string()
1027        });
1028        record.status = SubagentStatus::Queued;
1029        record.updated_at = Utc::now();
1030        record.completed_at = None;
1031        record.error = None;
1032        record.summary = None;
1033        record.queued_prompts.push_back(prompt);
1034        Ok(true)
1035    }
1036
1037    async fn close_single(&self, target: &str) -> Result<SubagentStatusEntry> {
1038        let mut state = self.state.write().await;
1039        let record = state
1040            .children
1041            .get_mut(target)
1042            .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1043        if record.status == SubagentStatus::Closed {
1044            return Ok(record.build_status_entry());
1045        }
1046        if let Some(handle) = record.handle.take() {
1047            handle.abort();
1048        }
1049        record.status = SubagentStatus::Closed;
1050        record.updated_at = Utc::now();
1051        record.completed_at = Some(Utc::now());
1052        record.notify.notify_waiters();
1053        Ok(record.build_status_entry())
1054    }
1055
1056    async fn background_status_for(&self, target: &str) -> Result<BackgroundSubprocessEntry> {
1057        let state = self.state.read().await;
1058        let record = state
1059            .background_children
1060            .get(target)
1061            .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?;
1062        Ok(record.build_status_entry())
1063    }
1064
1065    async fn ensure_background_record_running(
1066        &self,
1067        agent_name: &str,
1068        stable_id: Option<&str>,
1069        restart_attempts: u8,
1070        overrides: Option<BackgroundLaunchOverrides>,
1071    ) -> Result<BackgroundSubprocessEntry> {
1072        let spec = self
1073            .resolve_requested_spec(Some(agent_name))
1074            .await
1075            .with_context(|| format!("Failed to resolve background subagent '{agent_name}'"))?;
1076        let record_id = stable_id
1077            .map(ToOwned::to_owned)
1078            .unwrap_or_else(|| background_record_id(agent_name));
1079        let previous_record = {
1080            let state = self.state.read().await;
1081            state.background_children.get(&record_id).map(|record| {
1082                (
1083                    record.created_at,
1084                    record.prompt.clone(),
1085                    record.max_turns,
1086                    record.model_override.clone(),
1087                    record.reasoning_override.clone(),
1088                )
1089            })
1090        };
1091        let parent_session_id = self.parent_session_id.read().await.clone();
1092        let session_id = format!(
1093            "{}-{}-{}",
1094            sanitize_component(parent_session_id.as_str()),
1095            sanitize_component(record_id.as_str()),
1096            Utc::now().format("%Y%m%dT%H%M%S%3fZ")
1097        );
1098        let exec_session_id = format!("exec-{session_id}");
1099        let (
1100            created_at,
1101            previous_prompt,
1102            previous_max_turns,
1103            previous_model_override,
1104            previous_reasoning_override,
1105        ) = previous_record.unwrap_or((Utc::now(), String::new(), None, None, None));
1106        let prompt = overrides
1107            .as_ref()
1108            .and_then(|overrides| overrides.prompt.clone())
1109            .filter(|value| !value.trim().is_empty())
1110            .or_else(|| (!previous_prompt.trim().is_empty()).then_some(previous_prompt))
1111            .or_else(|| spec.initial_prompt.clone())
1112            .filter(|value| !value.trim().is_empty())
1113            .unwrap_or_else(|| {
1114                format!(
1115                    "You are the VT Code background subagent `{}`. Summarize readiness briefly, inspect the workspace at a high level, then remain idle until the process is terminated.",
1116                    spec.name
1117                )
1118            });
1119        let max_turns = normalize_background_child_max_turns(
1120            overrides
1121                .as_ref()
1122                .and_then(|overrides| overrides.max_turns)
1123                .or(previous_max_turns)
1124                .or(spec.max_turns),
1125            true,
1126        );
1127        let model_override = overrides
1128            .as_ref()
1129            .and_then(|overrides| overrides.model_override.clone())
1130            .or(previous_model_override)
1131            .or_else(|| spec.model.clone());
1132        let reasoning_override = overrides
1133            .as_ref()
1134            .and_then(|overrides| overrides.reasoning_override.clone())
1135            .or(previous_reasoning_override)
1136            .or_else(|| spec.reasoning_effort.clone());
1137
1138        {
1139            let mut state = self.state.write().await;
1140            state.background_children.insert(
1141                record_id.clone(),
1142                BackgroundRecord {
1143                    id: record_id.clone(),
1144                    agent_name: spec.name.clone(),
1145                    display_label: subagent_display_label(&spec),
1146                    description: spec.description.clone(),
1147                    source: spec.source.label(),
1148                    color: spec.color.clone(),
1149                    session_id: session_id.clone(),
1150                    exec_session_id: exec_session_id.clone(),
1151                    desired_enabled: true,
1152                    status: BackgroundSubprocessStatus::Starting,
1153                    created_at,
1154                    updated_at: Utc::now(),
1155                    started_at: None,
1156                    ended_at: None,
1157                    pid: None,
1158                    prompt: prompt.clone(),
1159                    summary: Some("Starting background subagent".to_string()),
1160                    error: None,
1161                    archive_path: None,
1162                    transcript_path: None,
1163                    max_turns,
1164                    model_override: model_override.clone(),
1165                    reasoning_override: reasoning_override.clone(),
1166                    restart_attempts,
1167                },
1168            );
1169        }
1170
1171        let launch = build_background_launch_spec(
1172            &self.config.workspace_root,
1173            spec.name.as_str(),
1174            parent_session_id.as_str(),
1175            session_id.as_str(),
1176            prompt.as_str(),
1177            max_turns,
1178            model_override.as_deref(),
1179            reasoning_override.as_deref(),
1180        )?;
1181        let metadata = if launch.use_pty {
1182            self.config
1183                .exec_sessions
1184                .create_pty_session(
1185                    exec_session_id.clone().into(),
1186                    launch.command,
1187                    self.config.workspace_root.clone(),
1188                    PtySize {
1189                        rows: 24,
1190                        cols: 80,
1191                        pixel_width: 0,
1192                        pixel_height: 0,
1193                    },
1194                    hashbrown::HashMap::new(),
1195                    None,
1196                )
1197                .await
1198        } else {
1199            self.config
1200                .exec_sessions
1201                .create_pipe_session(
1202                    exec_session_id.clone().into(),
1203                    launch.command,
1204                    self.config.workspace_root.clone(),
1205                    hashbrown::HashMap::new(),
1206                )
1207                .await
1208        }
1209        .with_context(|| {
1210            format!(
1211                "Failed to spawn background subprocess for subagent '{}'",
1212                spec.name
1213            )
1214        })?;
1215
1216        tracing::info!(
1217            agent_name = spec.name.as_str(),
1218            record_id = record_id.as_str(),
1219            exec_session_id = exec_session_id.as_str(),
1220            pid = metadata.child_pid,
1221            "Spawned background subagent subprocess"
1222        );
1223
1224        {
1225            let mut state = self.state.write().await;
1226            let record = state
1227                .background_children
1228                .get_mut(&record_id)
1229                .ok_or_else(|| anyhow!("Unknown background subprocess {}", record_id))?;
1230            record.exec_session_id = exec_session_id;
1231            record.pid = metadata.child_pid;
1232            record.started_at = metadata.started_at;
1233            record.status = BackgroundSubprocessStatus::Running;
1234            record.updated_at = Utc::now();
1235            record.ended_at = None;
1236            record.error = None;
1237            record.summary = Some("Background subagent is running".to_string());
1238        }
1239
1240        self.save_background_state().await?;
1241        self.background_status_for(&record_id).await
1242    }
1243
1244    async fn refresh_background_archive_metadata(&self, target: &str) -> Result<()> {
1245        let session_id = {
1246            let state = self.state.read().await;
1247            state
1248                .background_children
1249                .get(target)
1250                .map(|record| record.session_id.clone())
1251                .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?
1252        };
1253
1254        if let Some(listing) = find_session_by_identifier(&session_id).await? {
1255            let mut state = self.state.write().await;
1256            if let Some(record) = state.background_children.get_mut(target) {
1257                record.archive_path = Some(listing.path.clone());
1258                record.transcript_path = Some(listing.path);
1259            }
1260        }
1261
1262        Ok(())
1263    }
1264
1265    async fn save_background_state(&self) -> Result<()> {
1266        let records = {
1267            let state = self.state.read().await;
1268            state
1269                .background_children
1270                .values()
1271                .cloned()
1272                .map(BackgroundRecord::into_persisted)
1273                .collect()
1274        };
1275        persist_background_state(&self.config.workspace_root, records)
1276    }
1277
1278    async fn find_spec(&self, candidate: &str) -> Option<SubagentSpec> {
1279        self.state
1280            .read()
1281            .await
1282            .discovered
1283            .effective
1284            .iter()
1285            .find(|spec| spec.is_subagent() && spec.matches_name(candidate))
1286            .cloned()
1287    }
1288
1289    async fn resolve_requested_spec(&self, requested: Option<&str>) -> Result<SubagentSpec> {
1290        let requested = requested.unwrap_or("default");
1291        self.find_spec(requested)
1292            .await
1293            .ok_or_else(|| anyhow!("Unknown subagent type {}", requested))
1294    }
1295
1296    async fn prepare_delegation_context(
1297        &self,
1298        requested_agent: Option<String>,
1299        items: &mut Vec<SubagentInputItem>,
1300        model: &mut Option<String>,
1301        tool_name: &'static str,
1302    ) -> Result<PreparedDelegationContext> {
1303        let state = self.state.read().await;
1304        sanitize_subagent_input_items(items);
1305        *model = normalize_requested_model_override(model.take(), &state.turn_hints.current_input);
1306        let requested_agent = if let Some(agent_type) = requested_agent {
1307            Some(agent_type)
1308        } else {
1309            match state.turn_hints.explicit_mentions.as_slice() {
1310                [] => None,
1311                [single] => Some(single.clone()),
1312                mentions => {
1313                    bail!(
1314                        "{} omitted agent_type, but the user explicitly selected multiple agents: {}. Specify agent_type explicitly.",
1315                        tool_name,
1316                        mentions.join(", ")
1317                    );
1318                }
1319            }
1320        };
1321        Ok(PreparedDelegationContext {
1322            requested_agent,
1323            explicit_mentions: state.turn_hints.explicit_mentions.clone(),
1324            explicit_request: state.turn_hints.explicit_request,
1325            current_input: state.turn_hints.current_input.clone(),
1326        })
1327    }
1328
1329    fn prepare_delegation_prompt(
1330        &self,
1331        spec: &SubagentSpec,
1332        delegation: &PreparedDelegationContext,
1333        model: &mut Option<String>,
1334        message: &Option<String>,
1335        items: &[SubagentInputItem],
1336        tool_name: &'static str,
1337        launch_phrase: &'static str,
1338        ignored_model_warning: &'static str,
1339    ) -> Result<String> {
1340        if let Some(explicit) = delegation.explicit_mentions.first()
1341            && delegation.explicit_mentions.len() == 1
1342            && !spec.matches_name(explicit)
1343        {
1344            bail!(
1345                "{} requested agent_type '{}', but the user explicitly selected '{}'. Use the selected agent or ask the user to clarify.",
1346                tool_name,
1347                spec.name,
1348                explicit
1349            );
1350        }
1351        if !spec.is_read_only()
1352            && !delegation.explicit_request
1353            && delegation.requested_agent.is_none()
1354        {
1355            bail!(
1356                "{} cannot launch write-capable agent '{}' without an explicit delegation signal from the current user turn. Ask the user to mention the agent, say 'delegate'/'spawn', or request parallel work.",
1357                tool_name,
1358                spec.name
1359            );
1360        }
1361        if spec.is_read_only()
1362            && !self.config.vt_cfg.subagents.auto_delegate_read_only
1363            && !delegation.explicit_request
1364        {
1365            bail!(
1366                "{} cannot proactively launch read-only agent '{}' because `subagents.auto_delegate_read_only` is disabled and the current user turn did not explicitly request delegation.",
1367                tool_name,
1368                spec.name
1369            );
1370        }
1371        if let Some(requested_model) = model.as_deref()
1372            && !contains_explicit_model_request(&delegation.current_input, requested_model)
1373        {
1374            tracing::warn!(
1375                agent_name = spec.name.as_str(),
1376                requested_model = requested_model.trim(),
1377                "{ignored_model_warning}"
1378            );
1379            *model = None;
1380        }
1381        let prompt = request_prompt(message, items)
1382            .or_else(|| spec.initial_prompt.clone())
1383            .filter(|value| !value.trim().is_empty())
1384            .ok_or_else(|| anyhow!("{tool_name} requires a task message or items"))?;
1385        if delegated_task_requires_clarification(&prompt) {
1386            bail!(
1387                "{} task for '{}' is too vague ('{}'). Ask the user for a specific delegated task before {}.",
1388                tool_name,
1389                spec.name,
1390                prompt.trim(),
1391                launch_phrase
1392            );
1393        }
1394        Ok(prompt)
1395    }
1396
1397    fn active_background_launch_conflicts(
1398        record: &BackgroundRecord,
1399        prompt: &str,
1400        max_turns: Option<usize>,
1401        model_override: Option<&str>,
1402        reasoning_override: Option<&str>,
1403    ) -> Vec<&'static str> {
1404        let mut conflicts = Vec::new();
1405        if record.prompt != prompt {
1406            conflicts.push("prompt");
1407        }
1408        if record.max_turns != max_turns {
1409            conflicts.push("max_turns");
1410        }
1411        if record.model_override.as_deref() != model_override {
1412            conflicts.push("model");
1413        }
1414        if record.reasoning_override.as_deref() != reasoning_override {
1415            conflicts.push("reasoning_effort");
1416        }
1417        conflicts
1418    }
1419
1420    async fn spawn_with_spec(
1421        &self,
1422        spec: SubagentSpec,
1423        prompt: String,
1424        fork_context: bool,
1425        background: bool,
1426        max_turns: Option<usize>,
1427        model_override: Option<String>,
1428        reasoning_override: Option<String>,
1429    ) -> Result<SubagentStatusEntry> {
1430        if !self.config.vt_cfg.subagents.enabled {
1431            bail!("Subagents are disabled by configuration");
1432        }
1433        if self.config.depth.saturating_add(1) > self.config.vt_cfg.subagents.max_depth {
1434            bail!(
1435                "Subagent depth limit reached (max_depth={})",
1436                self.config.vt_cfg.subagents.max_depth
1437            );
1438        }
1439        if spec.isolation.as_deref() == Some("worktree") {
1440            bail!("Subagent isolation=worktree is not supported in this VT Code build");
1441        }
1442
1443        let active_count = {
1444            let state = self.state.read().await;
1445            state
1446                .children
1447                .values()
1448                .filter(|record| {
1449                    matches!(
1450                        record.status,
1451                        SubagentStatus::Queued | SubagentStatus::Running | SubagentStatus::Waiting
1452                    )
1453                })
1454                .count()
1455        };
1456        let effective_max_concurrent = self
1457            .config
1458            .vt_cfg
1459            .subagents
1460            .max_concurrent
1461            .min(SUBAGENT_HARD_CONCURRENCY_LIMIT);
1462        if active_count >= effective_max_concurrent {
1463            bail!(
1464                "Subagent concurrency limit reached (max_concurrent={})",
1465                effective_max_concurrent
1466            );
1467        }
1468        let is_background_child = background;
1469        let child_max_turns =
1470            normalize_background_child_max_turns(max_turns.or(spec.max_turns), is_background_child);
1471        let (_, _, effective_config) = prepare_child_runtime_config(
1472            &self.config.vt_cfg,
1473            &spec,
1474            self.config.parent_model.as_str(),
1475            self.config.parent_provider.as_str(),
1476            self.config.parent_reasoning_effort,
1477            child_max_turns,
1478            model_override.as_deref(),
1479            reasoning_override.as_deref(),
1480            resolve_effective_subagent_model,
1481        )?;
1482
1483        let id = format!(
1484            "agent-{}-{}",
1485            sanitize_component(spec.name.as_str()),
1486            Utc::now().format("%Y%m%dT%H%M%S%3fZ")
1487        );
1488        let parent_session_id = self.parent_session_id.read().await.clone();
1489        let session_id = format!(
1490            "{}-{}",
1491            sanitize_component(parent_session_id.as_str()),
1492            sanitize_component(id.as_str())
1493        );
1494        let display_label = subagent_display_label(&spec);
1495        let notify = Arc::new(Notify::new());
1496        let mut state = self.state.write().await;
1497        let initial_messages = if fork_context {
1498            state.parent_messages.clone()
1499        } else {
1500            Vec::new()
1501        };
1502        let entry = ChildRecord {
1503            id: id.clone(),
1504            session_id,
1505            parent_thread_id: parent_session_id,
1506            spec: spec.clone(),
1507            display_label,
1508            status: SubagentStatus::Queued,
1509            background: is_background_child,
1510            depth: self.config.depth.saturating_add(1),
1511            created_at: Utc::now(),
1512            updated_at: Utc::now(),
1513            completed_at: None,
1514            summary: None,
1515            error: None,
1516            archive_metadata: None,
1517            archive_path: None,
1518            transcript_path: None,
1519            effective_config: Some(effective_config),
1520            stored_messages: initial_messages,
1521            last_prompt: Some(prompt.clone()),
1522            queued_prompts: VecDeque::from([prompt]),
1523            max_turns: child_max_turns,
1524            model_override,
1525            reasoning_override,
1526            thread_handle: None,
1527            handle: None,
1528            notify,
1529        };
1530        state.children.insert(id.clone(), entry);
1531        drop(state);
1532
1533        self.launch_child(id.as_str()).await?;
1534        self.status_for(&id).await
1535    }
1536
1537    async fn restart_child(&self, target: &str) -> Result<()> {
1538        let has_queued_input = {
1539            let mut state = self.state.write().await;
1540            let record = state
1541                .children
1542                .get_mut(target)
1543                .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1544            if record.queued_prompts.is_empty()
1545                && let Some(prompt) = record.last_prompt.clone()
1546            {
1547                record.queued_prompts.push_back(prompt);
1548            }
1549            !record.queued_prompts.is_empty()
1550        };
1551        if !has_queued_input {
1552            bail!("Subagent {} has no queued input", target);
1553        }
1554        self.launch_child(target).await
1555    }
1556
1557    async fn launch_child(&self, child_id: &str) -> Result<()> {
1558        let controller = self.clone();
1559        let target = child_id.to_string();
1560        let handle = tokio::spawn(async move {
1561            controller.child_loop(&target).await;
1562        });
1563        let mut state = self.state.write().await;
1564        let record = state
1565            .children
1566            .get_mut(child_id)
1567            .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1568        record.handle = Some(handle);
1569        record.status = SubagentStatus::Queued;
1570        record.updated_at = Utc::now();
1571        Ok(())
1572    }
1573
1574    async fn child_loop(&self, child_id: &str) {
1575        loop {
1576            let request = {
1577                let mut state = self.state.write().await;
1578                let Some(record) = state.children.get_mut(child_id) else {
1579                    return;
1580                };
1581                record.dequeue_run()
1582            };
1583            let Some(request) = request else {
1584                let mut state = self.state.write().await;
1585                if let Some(record) = state.children.get_mut(child_id) {
1586                    record.handle = None;
1587                    record.updated_at = Utc::now();
1588                }
1589                return;
1590            };
1591
1592            let execute = self
1593                .run_child_once(
1594                    child_id,
1595                    request.prompt,
1596                    request.max_turns,
1597                    request.model_override,
1598                    request.reasoning_override,
1599                )
1600                .await;
1601
1602            let (has_more_work, hook_payload) = {
1603                let mut state = self.state.write().await;
1604                let Some(record) = state.children.get_mut(child_id) else {
1605                    return;
1606                };
1607                record.updated_at = Utc::now();
1608                let has_more_work = record.apply_result(execute);
1609                let hook_payload = (!has_more_work).then(|| record.build_hook_payload());
1610                (has_more_work, hook_payload)
1611            };
1612
1613            if let Some((
1614                parent_session_id,
1615                child_thread_id,
1616                agent_name,
1617                display_label,
1618                background,
1619                status,
1620                transcript_path,
1621            )) = hook_payload
1622                && let Some(hooks) = self.lifecycle_hooks.as_ref()
1623                && let Err(err) = hooks
1624                    .run_subagent_stop(
1625                        &parent_session_id,
1626                        &child_thread_id,
1627                        &agent_name,
1628                        &display_label,
1629                        background,
1630                        &status,
1631                        transcript_path.as_deref(),
1632                    )
1633                    .await
1634            {
1635                tracing::warn!(
1636                    child_id,
1637                    error = %err,
1638                    "Failed to run subagent stop hooks"
1639                );
1640            }
1641
1642            if has_more_work {
1643                continue;
1644            } else {
1645                let mut state = self.state.write().await;
1646                if let Some(record) = state.children.get_mut(child_id) {
1647                    record.handle = None;
1648                    record.updated_at = Utc::now();
1649                }
1650                return;
1651            }
1652        }
1653    }
1654
1655    async fn run_child_once(
1656        &self,
1657        child_id: &str,
1658        prompt: String,
1659        max_turns: Option<usize>,
1660        model_override: Option<String>,
1661        reasoning_override: Option<String>,
1662    ) -> Result<ChildRunResult> {
1663        let (spec, session_id, bootstrap_messages, display_label, background) = {
1664            let mut state = self.state.write().await;
1665            let record = state
1666                .children
1667                .get_mut(child_id)
1668                .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1669            record.status = SubagentStatus::Running;
1670            record.updated_at = Utc::now();
1671            (
1672                record.spec.clone(),
1673                record.session_id.clone(),
1674                record.stored_messages.clone(),
1675                record.display_label.clone(),
1676                record.background,
1677            )
1678        };
1679
1680        let (resolved_model, child_reasoning_effort, child_cfg) = prepare_child_runtime_config(
1681            &self.config.vt_cfg,
1682            &spec,
1683            self.config.parent_model.as_str(),
1684            self.config.parent_provider.as_str(),
1685            self.config.parent_reasoning_effort,
1686            max_turns,
1687            model_override.as_deref(),
1688            reasoning_override.as_deref(),
1689            resolve_effective_subagent_model,
1690        )?;
1691        let parent_session_id = self.parent_session_id.read().await.clone();
1692
1693        let archive_metadata = build_subagent_archive_metadata(
1694            &self.config.workspace_root,
1695            child_cfg.agent.default_model.as_str(),
1696            child_cfg.agent.provider.as_str(),
1697            child_cfg.agent.theme.as_str(),
1698            child_reasoning_effort.as_str(),
1699            parent_session_id.as_str(),
1700            !bootstrap_messages.is_empty(),
1701        );
1702        let bootstrap = ThreadBootstrap::new(Some(archive_metadata.clone()))
1703            .with_messages(bootstrap_messages.clone());
1704        let archive = if let Some(listing) = find_session_by_identifier(&session_id).await? {
1705            SessionArchive::resume_from_listing(&listing, archive_metadata.clone())
1706        } else {
1707            SessionArchive::new_with_identifier(archive_metadata.clone(), session_id.clone())
1708                .await?
1709        };
1710        checkpoint_subagent_archive_start(&archive, &bootstrap_messages).await?;
1711        let mut runner = AgentRunner::new_with_bootstrap(
1712            agent_type_for_spec(&spec),
1713            resolved_model,
1714            self.config.api_key.clone(),
1715            self.config.workspace_root.clone(),
1716            session_id.clone(),
1717            RunnerSettings {
1718                reasoning_effort: Some(child_reasoning_effort),
1719                verbosity: None,
1720            },
1721            None,
1722            bootstrap,
1723            Some(child_cfg.clone()),
1724            self.config.openai_chatgpt_auth.clone(),
1725        )
1726        .await?;
1727        runner.set_quiet(true);
1728        let thread_handle = runner.thread_handle();
1729        let archive_path = archive.path().to_path_buf();
1730
1731        {
1732            let mut state = self.state.write().await;
1733            let record = state
1734                .children
1735                .get_mut(child_id)
1736                .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1737            record.archive_metadata = Some(archive_metadata.clone());
1738            record.archive_path = Some(archive_path.clone());
1739            record.effective_config = Some(child_cfg.clone());
1740            record.thread_handle = Some(thread_handle.clone());
1741        }
1742        if let Some(hooks) = self.lifecycle_hooks.as_ref()
1743            && let Err(err) = hooks
1744                .run_subagent_start(
1745                    parent_session_id.as_str(),
1746                    thread_handle.thread_id().as_str(),
1747                    spec.name.as_str(),
1748                    &display_label,
1749                    background,
1750                    SubagentStatus::Running.as_str(),
1751                    Some(archive_path.as_path()),
1752                )
1753                .await
1754        {
1755            tracing::warn!(
1756                child_id,
1757                error = %err,
1758                "Failed to run subagent start hooks"
1759            );
1760        }
1761
1762        let filtered_tools = filter_child_tools(
1763            &spec,
1764            runner.build_universal_tools().await?,
1765            spec.is_read_only(),
1766        );
1767        let allowed_tools = filtered_tools
1768            .iter()
1769            .map(|tool| tool.function_name().to_string())
1770            .collect::<Vec<_>>();
1771        runner.set_tool_definitions_override(filtered_tools);
1772        runner.enable_full_auto(&allowed_tools).await;
1773
1774        let memory_appendix =
1775            load_memory_appendix(&self.config.workspace_root, spec.name.as_str(), spec.memory)?;
1776        let mut task = Task::new(
1777            format!("subagent-{}", spec.name),
1778            format!("Subagent {}", spec.name),
1779            prompt,
1780        );
1781        task.instructions = Some(compose_subagent_instructions(&spec, memory_appendix));
1782
1783        let results = runner.execute_task(&task, &[]).await?;
1784        let messages = runner.session_messages();
1785        let transcript_path =
1786            persist_child_archive(&archive, &messages, spec.name.as_str()).await?;
1787
1788        Ok(ChildRunResult {
1789            messages,
1790            summary: if results.summary.trim().is_empty() {
1791                results.outcome.description()
1792            } else {
1793                results.summary.clone()
1794            },
1795            outcome: results.outcome,
1796            transcript_path,
1797        })
1798    }
1799}
1800
1801fn sanitize_component(value: &str) -> String {
1802    value
1803        .chars()
1804        .map(|ch| {
1805            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1806                ch
1807            } else {
1808                '-'
1809            }
1810        })
1811        .collect::<String>()
1812        .trim_matches('-')
1813        .to_string()
1814}
1815
1816fn load_session_listing(
1817    path: &std::path::Path,
1818) -> Result<crate::utils::session_archive::SessionListing> {
1819    use anyhow::Context;
1820    let raw = std::fs::read_to_string(path)
1821        .with_context(|| format!("Failed to read session archive {}", path.display()))?;
1822    let snapshot: crate::utils::session_archive::SessionSnapshot = serde_json::from_str(&raw)
1823        .with_context(|| format!("Failed to parse session archive {}", path.display()))?;
1824    Ok(crate::utils::session_archive::SessionListing {
1825        path: path.to_path_buf(),
1826        snapshot,
1827    })
1828}
1829
1830async fn checkpoint_subagent_archive_start(
1831    archive: &SessionArchive,
1832    messages: &[Message],
1833) -> Result<()> {
1834    use crate::utils::session_archive::SessionMessage;
1835    let recent_messages = messages
1836        .iter()
1837        .map(SessionMessage::from)
1838        .collect::<Vec<_>>();
1839    archive
1840        .persist_progress_async(crate::utils::session_archive::SessionProgressArgs {
1841            total_messages: recent_messages.len(),
1842            distinct_tools: Vec::new(),
1843            recent_messages,
1844            turn_number: 1,
1845            token_usage: None,
1846            max_context_tokens: None,
1847            loaded_skills: Some(Vec::new()),
1848        })
1849        .await?;
1850    Ok(())
1851}
1852
1853async fn persist_child_archive(
1854    archive: &SessionArchive,
1855    messages: &[Message],
1856    agent_name: &str,
1857) -> Result<Option<PathBuf>> {
1858    use crate::utils::session_archive::SessionMessage;
1859    let transcript = messages
1860        .iter()
1861        .filter_map(transcript_line_from_message)
1862        .take(SUBAGENT_TRANSCRIPT_LINE_LIMIT)
1863        .collect::<Vec<_>>();
1864    let stored_messages = messages
1865        .iter()
1866        .map(SessionMessage::from)
1867        .collect::<Vec<_>>();
1868    let path = archive.finalize(
1869        transcript,
1870        stored_messages.len(),
1871        vec![agent_name.to_string()],
1872        stored_messages,
1873    )?;
1874    Ok(Some(path))
1875}
1876
1877fn transcript_line_from_message(message: &Message) -> Option<String> {
1878    let role = message.role.to_string();
1879    let content = message.content.trim();
1880    if content.is_empty() {
1881        return None;
1882    }
1883    Some(format!("{role}: {content}"))
1884}
1885
1886// ─── Tests ──────────────────────────────────────────────────────────────────
1887
1888#[cfg(test)]
1889mod tests {
1890    use super::*;
1891    use crate::config::constants::models;
1892    use crate::config::constants::tools;
1893    use crate::config::models::{ModelId, Provider};
1894    use crate::llm::provider::ToolDefinition;
1895    use crate::tools::exec_session::ExecSessionManager;
1896    use crate::tools::registry::PtySessionManager;
1897    use anyhow::{Result, anyhow};
1898    use std::collections::BTreeMap;
1899    use std::collections::VecDeque;
1900    use std::path::PathBuf;
1901    use std::sync::Arc;
1902    use std::time::Duration;
1903    use tempfile::TempDir;
1904    use tokio::sync::Notify;
1905    use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
1906    use vtcode_config::{
1907        HookCommandConfig, HookGroupConfig, HooksConfig, SubagentMcpServer, SubagentMemoryScope,
1908        SubagentSource, SubagentSpec,
1909    };
1910
1911    fn readonly_agent_permissions() -> AgentPermissionsConfig {
1912        let mut permissions = AgentPermissionsConfig::new(PermissionDefault::Deny);
1913        permissions.allow = vec![tools::READ_FILE.to_string()];
1914        permissions
1915    }
1916
1917    fn test_controller_config(
1918        workspace_root: PathBuf,
1919        vt_cfg: VTCodeConfig,
1920    ) -> SubagentControllerConfig {
1921        let pty_sessions = PtySessionManager::new(workspace_root.clone(), vt_cfg.pty.clone());
1922        let exec_sessions = ExecSessionManager::new(workspace_root.clone(), pty_sessions.clone());
1923        SubagentControllerConfig {
1924            workspace_root,
1925            parent_session_id: "parent-session".to_string(),
1926            parent_model: models::openai::GPT_5_4.to_string(),
1927            parent_provider: "openai".to_string(),
1928            parent_reasoning_effort: ReasoningEffortLevel::Medium,
1929            api_key: "test-key".to_string(),
1930            vt_cfg,
1931            openai_chatgpt_auth: None,
1932            depth: 0,
1933            exec_sessions,
1934            pty_manager: pty_sessions.manager().clone(),
1935            managed_background_runtime: false,
1936        }
1937    }
1938
1939    fn test_child_record(
1940        id: &str,
1941        parent_thread_id: &str,
1942        spec: &SubagentSpec,
1943        status: SubagentStatus,
1944        depth: usize,
1945    ) -> ChildRecord {
1946        ChildRecord {
1947            id: id.to_string(),
1948            session_id: format!("session-{id}"),
1949            parent_thread_id: parent_thread_id.to_string(),
1950            spec: spec.clone(),
1951            display_label: subagent_display_label(spec),
1952            status,
1953            background: false,
1954            depth,
1955            created_at: Utc::now(),
1956            updated_at: Utc::now(),
1957            completed_at: status.is_terminal().then_some(Utc::now()),
1958            summary: None,
1959            error: None,
1960            archive_metadata: None,
1961            archive_path: None,
1962            transcript_path: None,
1963            effective_config: Some(VTCodeConfig::default()),
1964            stored_messages: Vec::new(),
1965            last_prompt: Some(format!("prompt-{id}")),
1966            queued_prompts: VecDeque::new(),
1967            max_turns: None,
1968            model_override: None,
1969            reasoning_override: None,
1970            thread_handle: None,
1971            handle: None,
1972            notify: Arc::new(Notify::new()),
1973        }
1974    }
1975
1976    fn write_test_background_subagent(workspace_root: &std::path::Path) {
1977        let agent_dir = workspace_root.join(".vtcode/agents");
1978        std::fs::create_dir_all(&agent_dir).expect("agent dir");
1979        std::fs::write(
1980            agent_dir.join("background-demo.md"),
1981            r#"---
1982name: background-demo
1983description: Minimal demo agent for the managed background subprocess flow.
1984tools:
1985  - unified_exec
1986background: true
1987maxTurns: 2
1988initialPrompt: Report readiness once.
1989---
1990
1991Run the managed background demo.
1992"#,
1993        )
1994        .expect("write background agent");
1995    }
1996
1997    fn write_test_primary_agent(workspace_root: &std::path::Path) {
1998        let agent_dir = workspace_root.join(".vtcode/agents");
1999        std::fs::create_dir_all(&agent_dir).expect("agent dir");
2000        std::fs::write(
2001            agent_dir.join("duck.md"),
2002            r#"---
2003name: duck
2004description: Discussion controller.
2005mode: primary
2006permissions:
2007  default: ask
2008---
2009
2010Discuss before implementation.
2011"#,
2012        )
2013        .expect("write primary agent");
2014    }
2015
2016    fn write_test_read_only_subagent(workspace_root: &std::path::Path) {
2017        let agent_dir = workspace_root.join(".vtcode/agents");
2018        std::fs::create_dir_all(&agent_dir).expect("agent dir");
2019        std::fs::write(
2020            agent_dir.join("readonly-demo.md"),
2021            r#"---
2022name: readonly-demo
2023description: Read-only test child agent.
2024tools:
2025  - read_file
2026permissions:
2027  default: ask
2028---
2029
2030Inspect the repository.
2031"#,
2032        )
2033        .expect("write read-only agent");
2034    }
2035
2036    #[test]
2037    fn request_prompt_prefers_message() {
2038        let request = SpawnAgentRequest {
2039            message: Some("hello".to_string()),
2040            ..SpawnAgentRequest::default()
2041        };
2042        assert_eq!(
2043            request_prompt(&request.message, &request.items).as_deref(),
2044            Some("hello")
2045        );
2046    }
2047
2048    #[test]
2049    fn delegated_task_requires_clarification_for_vague_prompt() {
2050        assert!(delegated_task_requires_clarification("report"));
2051        assert!(delegated_task_requires_clarification("report findings"));
2052        assert!(!delegated_task_requires_clarification(
2053            "review current code changes"
2054        ));
2055    }
2056
2057    #[test]
2058    fn resolve_subagent_model_maps_aliases() {
2059        let cfg = VTCodeConfig::default();
2060        let resolved = resolve_subagent_model(
2061            &cfg,
2062            models::anthropic::CLAUDE_SONNET_4_6,
2063            "anthropic",
2064            Some("haiku"),
2065            "explorer",
2066        )
2067        .expect("resolve model");
2068        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2069    }
2070
2071    #[test]
2072    fn resolve_subagent_model_defaults_to_parent_when_omitted() {
2073        let cfg = VTCodeConfig::default();
2074        let resolved = resolve_subagent_model(
2075            &cfg,
2076            models::ollama::GPT_OSS_120B_CLOUD,
2077            "ollama",
2078            None,
2079            "worker",
2080        )
2081        .expect("resolve model");
2082        assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2083    }
2084
2085    #[test]
2086    fn resolve_subagent_model_accepts_dotted_claude_aliases_for_anthropic() {
2087        let cfg = VTCodeConfig::default();
2088        let resolved =
2089            resolve_subagent_model(&cfg, "claude-haiku-4.5", "anthropic", None, "worker")
2090                .expect("resolve model");
2091        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2092    }
2093
2094    #[test]
2095    fn resolve_subagent_model_falls_back_to_copilot_default_for_unsupported_inherit_model() {
2096        let cfg = VTCodeConfig::default();
2097        let resolved = resolve_subagent_model(&cfg, "claude-haiku-4.5", "copilot", None, "worker")
2098            .expect("resolve model");
2099        assert_eq!(
2100            resolved,
2101            ModelId::default_orchestrator_for_provider(Provider::Copilot)
2102        );
2103    }
2104
2105    #[test]
2106    fn resolve_effective_subagent_model_uses_explicit_inherit_override() {
2107        let cfg = VTCodeConfig::default();
2108        let resolved = resolve_effective_subagent_model(
2109            &cfg,
2110            models::anthropic::CLAUDE_SONNET_4_6,
2111            "anthropic",
2112            Some("inherit"),
2113            Some("haiku"),
2114            "worker",
2115        )
2116        .expect("resolve model");
2117        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_SONNET_4_6);
2118    }
2119
2120    #[test]
2121    fn resolve_effective_subagent_model_falls_back_to_parent_on_invalid_override() {
2122        let cfg = VTCodeConfig::default();
2123        let resolved = resolve_effective_subagent_model(
2124            &cfg,
2125            models::ollama::GPT_OSS_120B_CLOUD,
2126            "ollama",
2127            Some("not-a-real-model"),
2128            None,
2129            "rust-engineer",
2130        )
2131        .expect("resolve model");
2132        assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2133    }
2134
2135    #[test]
2136    fn resolve_subagent_small_model_rejects_cross_provider_configured_lightweight_model() {
2137        let mut cfg = VTCodeConfig::default();
2138        cfg.agent.small_model.model = models::anthropic::CLAUDE_HAIKU_4_5.to_string();
2139
2140        let resolved = resolve_subagent_model(
2141            &cfg,
2142            models::openai::GPT_5_4,
2143            "openai",
2144            Some("small"),
2145            "worker",
2146        )
2147        .expect("resolve model");
2148
2149        assert_eq!(resolved, ModelId::GPT54Mini);
2150    }
2151
2152    #[test]
2153    fn resolve_effective_subagent_model_falls_back_to_spec_model_on_invalid_override() {
2154        let cfg = VTCodeConfig::default();
2155        let resolved = resolve_effective_subagent_model(
2156            &cfg,
2157            models::anthropic::CLAUDE_SONNET_4_6,
2158            "anthropic",
2159            Some("not-a-real-model"),
2160            Some("haiku"),
2161            "reviewer",
2162        )
2163        .expect("resolve model");
2164        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2165    }
2166
2167    #[test]
2168    fn background_record_ids_are_stable_and_sanitized() {
2169        assert_eq!(
2170            background_record_id("Rust Engineer"),
2171            "background-Rust-Engineer"
2172        );
2173        assert_eq!(
2174            background_record_id("plugin:reviewer/default"),
2175            "background-plugin-reviewer-default"
2176        );
2177    }
2178
2179    #[test]
2180    fn background_subagent_command_includes_expected_flags() {
2181        let workspace = std::env::current_dir().expect("workspace");
2182        let command = build_background_subagent_command(
2183            &workspace,
2184            "rust-engineer",
2185            "session-parent",
2186            "session-child",
2187            "Inspect the repo",
2188            Some(7),
2189            Some("gpt-5.4-mini"),
2190            Some("high"),
2191        )
2192        .expect("background command");
2193
2194        assert!(command.len() >= 15);
2195        assert_eq!(command[1], "background-subagent");
2196        assert!(
2197            command
2198                .windows(2)
2199                .any(|pair| pair == ["--agent-name", "rust-engineer"])
2200        );
2201        assert!(
2202            command
2203                .windows(2)
2204                .any(|pair| pair == ["--parent-session-id", "session-parent"])
2205        );
2206        assert!(
2207            command
2208                .windows(2)
2209                .any(|pair| pair == ["--session-id", "session-child"])
2210        );
2211        assert!(
2212            command
2213                .windows(2)
2214                .any(|pair| pair == ["--prompt", "Inspect the repo"])
2215        );
2216        assert!(command.windows(2).any(|pair| pair == ["--max-turns", "7"]));
2217        assert!(
2218            command
2219                .windows(2)
2220                .any(|pair| pair == ["--model-override", "gpt-5.4-mini"])
2221        );
2222        assert!(
2223            command
2224                .windows(2)
2225                .any(|pair| pair == ["--reasoning-override", "high"])
2226        );
2227    }
2228
2229    #[test]
2230    fn resolve_effective_subagent_model_still_errors_on_invalid_spec_model() {
2231        let cfg = VTCodeConfig::default();
2232        let err = resolve_effective_subagent_model(
2233            &cfg,
2234            models::anthropic::CLAUDE_SONNET_4_6,
2235            "anthropic",
2236            None,
2237            Some("not-a-real-model"),
2238            "reviewer",
2239        )
2240        .expect_err("invalid spec model should fail");
2241        assert!(err.to_string().contains("Failed to resolve model"));
2242    }
2243
2244    async fn wait_for_effective_model(
2245        controller: &SubagentController,
2246        target: &str,
2247    ) -> Result<String> {
2248        for _ in 0..50 {
2249            if let Ok(snapshot) = controller.snapshot_for_thread(target).await {
2250                return Ok(snapshot.effective_config.agent.default_model);
2251            }
2252            tokio::time::sleep(Duration::from_millis(10)).await;
2253        }
2254
2255        Err(anyhow!(
2256            "Subagent {target} did not capture an effective runtime configuration in time"
2257        ))
2258    }
2259
2260    fn read_only_test_spec(name: &str) -> SubagentSpec {
2261        SubagentSpec {
2262            name: name.to_string(),
2263            description: "test".to_string(),
2264            prompt: String::new(),
2265            tools: Some(vec![tools::READ_FILE.to_string()]),
2266            disallowed_tools: Vec::new(),
2267            model: None,
2268            color: None,
2269            reasoning_effort: None,
2270            permissions: readonly_agent_permissions(),
2271            skills: Vec::new(),
2272            mcp_servers: Vec::new(),
2273            hooks: None,
2274            background: false,
2275            mode: vtcode_config::AgentMode::Subagent,
2276            max_turns: None,
2277            nickname_candidates: Vec::new(),
2278            initial_prompt: None,
2279            memory: None,
2280            isolation: None,
2281            aliases: Vec::new(),
2282            source: SubagentSource::Builtin,
2283            file_path: None,
2284            warnings: Vec::new(),
2285        }
2286    }
2287
2288    #[test]
2289    fn filter_child_tools_removes_subagent_tools_in_children() {
2290        let defs = vec![
2291            ToolDefinition::function(
2292                tools::SPAWN_AGENT.to_string(),
2293                "Spawn".to_string(),
2294                serde_json::json!({"type": "object"}),
2295            ),
2296            ToolDefinition::function(
2297                tools::UNIFIED_SEARCH.to_string(),
2298                "Search".to_string(),
2299                serde_json::json!({"type": "object"}),
2300            ),
2301            ToolDefinition::function(
2302                tools::LIST_FILES.to_string(),
2303                "List".to_string(),
2304                serde_json::json!({"type": "object"}),
2305            ),
2306            ToolDefinition::function(
2307                tools::REQUEST_USER_INPUT.to_string(),
2308                "Ask".to_string(),
2309                serde_json::json!({"type": "object"}),
2310            ),
2311        ];
2312        let spec = vtcode_config::builtin_subagents()
2313            .into_iter()
2314            .find(|spec| spec.name == "explorer")
2315            .expect("explorer");
2316        let filtered = filter_child_tools(&spec, defs, true);
2317        assert_eq!(filtered.len(), 1);
2318        assert_eq!(filtered[0].function_name(), tools::UNIFIED_SEARCH);
2319    }
2320
2321    #[test]
2322    fn filter_child_tools_keeps_unified_exec_for_shell_capable_agents() {
2323        let defs = vec![
2324            ToolDefinition::function(
2325                tools::UNIFIED_EXEC.to_string(),
2326                "Exec".to_string(),
2327                serde_json::json!({"type": "object"}),
2328            ),
2329            ToolDefinition::function(
2330                tools::UNIFIED_SEARCH.to_string(),
2331                "Search".to_string(),
2332                serde_json::json!({"type": "object"}),
2333            ),
2334        ];
2335        let spec = SubagentSpec {
2336            name: "shell-demo".to_string(),
2337            description: "test".to_string(),
2338            prompt: String::new(),
2339            tools: Some(vec![
2340                tools::UNIFIED_EXEC.to_string(),
2341                tools::UNIFIED_SEARCH.to_string(),
2342            ]),
2343            disallowed_tools: Vec::new(),
2344            model: None,
2345            color: None,
2346            reasoning_effort: None,
2347            permissions: AgentPermissionsConfig::new(PermissionDefault::Ask),
2348            skills: Vec::new(),
2349            mcp_servers: Vec::new(),
2350            hooks: None,
2351            background: false,
2352            mode: vtcode_config::AgentMode::Subagent,
2353            max_turns: None,
2354            nickname_candidates: Vec::new(),
2355            initial_prompt: None,
2356            memory: None,
2357            isolation: None,
2358            aliases: Vec::new(),
2359            source: SubagentSource::Builtin,
2360            file_path: None,
2361            warnings: Vec::new(),
2362        };
2363
2364        let filtered = filter_child_tools(&spec, defs, spec.is_read_only());
2365        assert_eq!(filtered.len(), 2);
2366        assert_eq!(filtered[0].function_name(), tools::UNIFIED_EXEC);
2367        assert_eq!(filtered[1].function_name(), tools::UNIFIED_SEARCH);
2368    }
2369
2370    #[test]
2371    fn build_child_config_intersects_allowed_tools_and_preserves_global_denies() {
2372        let mut parent = VTCodeConfig::default();
2373        parent.permissions.allow = vec![
2374            tools::READ_FILE.to_string(),
2375            tools::UNIFIED_SEARCH.to_string(),
2376        ];
2377        parent.permissions.deny = vec![tools::UNIFIED_EXEC.to_string()];
2378
2379        let mut spec = vtcode_config::builtin_subagents()
2380            .into_iter()
2381            .find(|spec| spec.name == "worker")
2382            .expect("worker");
2383        spec.permissions = AgentPermissionsConfig {
2384            auto: vec!["Bash(*)".to_string()],
2385            ..AgentPermissionsConfig::new(PermissionDefault::Auto)
2386        };
2387        spec.tools = Some(vec![
2388            tools::SPAWN_AGENT.to_string(),
2389            tools::UNIFIED_SEARCH.to_string(),
2390            tools::READ_FILE.to_string(),
2391        ]);
2392
2393        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2394        assert_eq!(
2395            child.runtime_agent_permissions.as_ref(),
2396            Some(&spec.permissions)
2397        );
2398        assert_eq!(
2399            child.permissions.allow,
2400            vec![
2401                tools::READ_FILE.to_string(),
2402                tools::UNIFIED_SEARCH.to_string()
2403            ]
2404        );
2405        assert!(
2406            child
2407                .permissions
2408                .deny
2409                .contains(&tools::UNIFIED_EXEC.to_string())
2410        );
2411        assert!(
2412            child
2413                .permissions
2414                .deny
2415                .contains(&tools::SPAWN_AGENT.to_string())
2416        );
2417    }
2418
2419    #[test]
2420    fn build_child_config_preserves_subagent_lifecycle_stripping_and_hook_merging() {
2421        let mut parent = VTCodeConfig::default();
2422        parent.permissions.allow = vec![
2423            tools::SPAWN_AGENT.to_string(),
2424            tools::UNIFIED_SEARCH.to_string(),
2425            tools::UNIFIED_EXEC.to_string(),
2426        ];
2427
2428        let mut spec = vtcode_config::builtin_subagents()
2429            .into_iter()
2430            .find(|spec| spec.name == "worker")
2431            .expect("worker");
2432        spec.tools = Some(parent.permissions.allow.clone());
2433        let mut hooks = HooksConfig::default();
2434        hooks.lifecycle.pre_tool_use.push(HookGroupConfig {
2435            matcher: Some("*".to_string()),
2436            hooks: vec![HookCommandConfig {
2437                command: "echo child".to_string(),
2438                ..HookCommandConfig::default()
2439            }],
2440        });
2441        spec.hooks = Some(hooks);
2442
2443        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2444
2445        assert_eq!(
2446            child.permissions.allow,
2447            vec![
2448                tools::UNIFIED_SEARCH.to_string(),
2449                tools::UNIFIED_EXEC.to_string()
2450            ]
2451        );
2452        assert!(
2453            child
2454                .permissions
2455                .deny
2456                .contains(&tools::SPAWN_AGENT.to_string())
2457        );
2458        assert_eq!(child.hooks.lifecycle.pre_tool_use.len(), 1);
2459        assert_eq!(
2460            child.hooks.lifecycle.pre_tool_use[0].hooks[0].command,
2461            "echo child"
2462        );
2463    }
2464
2465    #[test]
2466    fn prepare_child_runtime_config_uses_shared_view_for_model_and_reasoning() {
2467        let parent = VTCodeConfig::default();
2468        let mut spec = vtcode_config::builtin_subagents()
2469            .into_iter()
2470            .find(|spec| spec.name == "worker")
2471            .expect("worker");
2472        spec.model = Some(models::openai::GPT_5_4_MINI.to_string());
2473        spec.reasoning_effort = Some("high".to_string());
2474
2475        let (resolved_model, child_reasoning_effort, child_cfg) = prepare_child_runtime_config(
2476            &parent,
2477            &spec,
2478            models::openai::GPT_5_4,
2479            "openai",
2480            ReasoningEffortLevel::Low,
2481            None,
2482            None,
2483            None,
2484            |_, parent_model, parent_provider, model_override, spec_model, agent_name| {
2485                assert_eq!(parent_model, models::openai::GPT_5_4);
2486                assert_eq!(parent_provider, "openai");
2487                assert_eq!(model_override, None);
2488                assert_eq!(spec_model, Some(models::openai::GPT_5_4_MINI));
2489                assert_eq!(agent_name, "worker");
2490                Ok(models::openai::GPT_5_4_MINI
2491                    .parse::<ModelId>()
2492                    .expect("valid model"))
2493            },
2494        )
2495        .expect("prepared child runtime config");
2496
2497        assert_eq!(resolved_model.as_str(), models::openai::GPT_5_4_MINI);
2498        assert_eq!(child_cfg.agent.default_model, models::openai::GPT_5_4_MINI);
2499        assert_eq!(child_reasoning_effort, ReasoningEffortLevel::High);
2500        assert_eq!(child_cfg.agent.reasoning_effort, ReasoningEffortLevel::High);
2501    }
2502
2503    #[test]
2504    fn subagent_instruction_composition_uses_shared_runtime_prompt_and_skill_appendix() {
2505        let mut spec = vtcode_config::builtin_subagents()
2506            .into_iter()
2507            .find(|spec| spec.name == "worker")
2508            .expect("worker");
2509        spec.prompt = "Worker instructions".to_string();
2510        spec.skills = vec!["rust".to_string(), "repo".to_string()];
2511
2512        let instructions =
2513            compose_subagent_instructions(&spec, Some("Memory appendix".to_string()));
2514
2515        assert!(instructions.contains("Worker instructions"));
2516        assert!(instructions.contains("Preloaded skill names: rust, repo."));
2517        assert!(instructions.contains("Memory appendix"));
2518        assert!(
2519            instructions.contains("Return your final response using this exact Markdown contract")
2520        );
2521    }
2522
2523    #[test]
2524    fn build_child_config_preserves_matching_rule_and_exact_tool_ids() {
2525        let mut parent = VTCodeConfig::default();
2526        parent.permissions.allow = vec![
2527            "Read(/docs/**)".to_string(),
2528            "mcp::context7::search".to_string(),
2529            tools::READ_FILE.to_string(),
2530        ];
2531
2532        let mut spec = vtcode_config::builtin_subagents()
2533            .into_iter()
2534            .find(|spec| spec.name == "worker")
2535            .expect("worker");
2536        spec.tools = Some(vec![
2537            "mcp::context7::search".to_string(),
2538            tools::UNIFIED_EXEC.to_string(),
2539            tools::READ_FILE.to_string(),
2540        ]);
2541
2542        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2543
2544        assert_eq!(
2545            child.permissions.allow,
2546            vec![
2547                "Read(/docs/**)".to_string(),
2548                "mcp::context7::search".to_string(),
2549                tools::READ_FILE.to_string()
2550            ]
2551        );
2552    }
2553
2554    #[test]
2555    fn build_child_config_preserves_parent_rule_shaped_allowlist() {
2556        let mut parent = VTCodeConfig::default();
2557        parent.permissions.allow = vec!["Read".to_string()];
2558
2559        let mut spec = vtcode_config::builtin_subagents()
2560            .into_iter()
2561            .find(|spec| spec.name == "worker")
2562            .expect("worker");
2563        spec.tools = Some(vec![
2564            tools::READ_FILE.to_string(),
2565            tools::UNIFIED_SEARCH.to_string(),
2566            tools::UNIFIED_EXEC.to_string(),
2567        ]);
2568
2569        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2570
2571        assert_eq!(child.permissions.allow, vec!["Read".to_string()]);
2572    }
2573
2574    #[test]
2575    fn build_child_config_promotes_single_turn_budget_to_recovery_budget() {
2576        let parent = VTCodeConfig::default();
2577        let spec = vtcode_config::builtin_subagents()
2578            .into_iter()
2579            .find(|spec| spec.name == "worker")
2580            .expect("worker");
2581
2582        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, Some(1));
2583
2584        assert_eq!(child.automation.full_auto.max_turns, SUBAGENT_MIN_MAX_TURNS);
2585    }
2586
2587    #[test]
2588    fn background_children_get_a_higher_turn_floor() {
2589        assert_eq!(normalize_background_child_max_turns(Some(2), true), Some(4));
2590        assert_eq!(normalize_background_child_max_turns(Some(3), true), Some(4));
2591        assert_eq!(normalize_background_child_max_turns(Some(4), true), Some(4));
2592    }
2593
2594    #[test]
2595    fn foreground_children_keep_the_existing_turn_floor() {
2596        assert_eq!(
2597            normalize_background_child_max_turns(Some(1), false),
2598            Some(SUBAGENT_MIN_MAX_TURNS)
2599        );
2600        assert_eq!(
2601            normalize_background_child_max_turns(Some(2), false),
2602            Some(2)
2603        );
2604        assert_eq!(normalize_background_child_max_turns(None, true), None);
2605    }
2606
2607    #[test]
2608    fn build_child_config_merges_inline_mcp_provider() {
2609        let parent = VTCodeConfig::default();
2610        let mut spec = vtcode_config::builtin_subagents()
2611            .into_iter()
2612            .find(|spec| spec.name == "default")
2613            .expect("default");
2614        spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
2615            "playwright".to_string(),
2616            serde_json::json!({
2617                "type": "stdio",
2618                "command": "npx",
2619                "args": ["-y", "@playwright/mcp@latest"],
2620            }),
2621        )]))];
2622
2623        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2624        let provider = child
2625            .mcp
2626            .providers
2627            .iter()
2628            .find(|provider| provider.name == "playwright")
2629            .expect("playwright provider");
2630        assert_eq!(provider.name, "playwright");
2631    }
2632
2633    #[test]
2634    fn explicit_delegation_request_detects_mentions_and_keywords() {
2635        let direct_mentions = extract_explicit_agent_mentions("@agent-worker fix the issue", &[]);
2636        assert!(contains_explicit_delegation_request(
2637            "@agent-worker fix the issue",
2638            direct_mentions.as_slice()
2639        ));
2640        let no_mentions = extract_explicit_agent_mentions("delegate this in parallel", &[]);
2641        assert!(contains_explicit_delegation_request(
2642            "delegate this in parallel",
2643            no_mentions.as_slice()
2644        ));
2645        let empty_mentions = extract_explicit_agent_mentions("review the repository", &[]);
2646        assert!(!contains_explicit_delegation_request(
2647            "review the repository",
2648            empty_mentions.as_slice()
2649        ));
2650    }
2651
2652    #[test]
2653    fn explicit_agent_mentions_detect_natural_language_selection() {
2654        let rust_engineer = read_only_test_spec("rust-engineer");
2655        assert_eq!(
2656            extract_explicit_agent_mentions(
2657                "use rust-engineer agent to review current code",
2658                &[rust_engineer]
2659            ),
2660            vec!["rust-engineer".to_string()]
2661        );
2662    }
2663
2664    #[test]
2665    fn explicit_agent_mentions_detect_looser_subagent_selection() {
2666        let background_demo = read_only_test_spec("background-demo");
2667        assert_eq!(
2668            extract_explicit_agent_mentions(
2669                "use background-demo and run the subagent",
2670                &[background_demo]
2671            ),
2672            vec!["background-demo".to_string()]
2673        );
2674    }
2675
2676    #[test]
2677    fn explicit_agent_mentions_detect_run_subagent_selection() {
2678        let rust_engineer = read_only_test_spec("rust-engineer");
2679        assert_eq!(
2680            extract_explicit_agent_mentions(
2681                "run rust-engineer subagent and review changes",
2682                &[rust_engineer]
2683            ),
2684            vec!["rust-engineer".to_string()]
2685        );
2686    }
2687
2688    #[test]
2689    fn explicit_agent_mentions_ignore_primary_only_agents() {
2690        let mut duck = read_only_test_spec("duck");
2691        duck.mode = vtcode_config::AgentMode::Primary;
2692
2693        assert_eq!(
2694            extract_explicit_agent_mentions("@agent-duck discuss the task", &[duck.clone()]),
2695            Vec::<String>::new()
2696        );
2697        assert_eq!(
2698            extract_explicit_agent_mentions("run duck agent and discuss the task", &[duck]),
2699            Vec::<String>::new()
2700        );
2701    }
2702
2703    #[test]
2704    fn explicit_model_request_detects_aliases_and_full_ids() {
2705        assert!(contains_explicit_model_request(
2706            "delegate this using gpt-5.4-mini",
2707            "gpt-5.4-mini"
2708        ));
2709        assert!(contains_explicit_model_request(
2710            "use the worker subagent with haiku",
2711            "haiku"
2712        ));
2713        assert!(contains_explicit_model_request(
2714            "run this with the small model",
2715            "small"
2716        ));
2717        assert!(!contains_explicit_model_request(
2718            "delegate this small cleanup task",
2719            "small"
2720        ));
2721        assert!(!contains_explicit_model_request(
2722            "delegate this task",
2723            "gpt-5.4-mini"
2724        ));
2725    }
2726
2727    #[test]
2728    fn normalize_requested_model_override_drops_default_like_values() {
2729        assert_eq!(
2730            normalize_requested_model_override(Some("default".to_string()), "delegate this task"),
2731            None
2732        );
2733        assert_eq!(
2734            normalize_requested_model_override(Some(" inherit ".to_string()), "delegate this task"),
2735            None
2736        );
2737        assert_eq!(
2738            normalize_requested_model_override(
2739                Some(" inherit ".to_string()),
2740                "delegate this task using inherit"
2741            ),
2742            Some("inherit".to_string())
2743        );
2744    }
2745
2746    #[test]
2747    fn sanitize_subagent_input_items_drops_empty_fields() {
2748        let mut items = vec![
2749            SubagentInputItem {
2750                item_type: Some("text".to_string()),
2751                text: Some("  Workspace: /tmp/repo  ".to_string()),
2752                path: Some(String::new()),
2753                name: Some(" ".to_string()),
2754                image_url: None,
2755            },
2756            SubagentInputItem {
2757                item_type: Some("text".to_string()),
2758                text: Some("   ".to_string()),
2759                path: Some(String::new()),
2760                name: None,
2761                image_url: None,
2762            },
2763        ];
2764
2765        sanitize_subagent_input_items(&mut items);
2766
2767        assert_eq!(items.len(), 1);
2768        assert_eq!(items[0].text.as_deref(), Some("Workspace: /tmp/repo"));
2769        assert!(items[0].path.is_none());
2770        assert!(items[0].name.is_none());
2771    }
2772
2773    #[tokio::test]
2774    async fn controller_exposes_builtin_specs() {
2775        let temp = TempDir::new().expect("tempdir");
2776        let controller = SubagentController::new(test_controller_config(
2777            temp.path().to_path_buf(),
2778            VTCodeConfig::default(),
2779        ))
2780        .await
2781        .expect("controller");
2782        let specs = controller.effective_specs().await;
2783        assert!(specs.iter().any(|spec| spec.name == "explorer"));
2784        assert!(specs.iter().any(|spec| spec.name == "worker"));
2785    }
2786
2787    #[tokio::test]
2788    async fn spawn_defaults_to_single_explicit_mention() {
2789        let temp = TempDir::new().expect("tempdir");
2790        let controller = SubagentController::new(test_controller_config(
2791            temp.path().to_path_buf(),
2792            VTCodeConfig::default(),
2793        ))
2794        .await
2795        .expect("controller");
2796
2797        controller
2798            .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2799            .await;
2800
2801        let spawned = controller
2802            .spawn(SpawnAgentRequest {
2803                message: Some("Inspect the codebase.".to_string()),
2804                ..SpawnAgentRequest::default()
2805            })
2806            .await
2807            .expect("spawn");
2808
2809        assert_eq!(spawned.agent_name, "explorer");
2810        controller.close(&spawned.id).await.expect("close");
2811    }
2812
2813    #[tokio::test]
2814    async fn spawn_defaults_to_single_natural_language_selection() {
2815        let temp = TempDir::new().expect("tempdir");
2816        let controller = SubagentController::new(test_controller_config(
2817            temp.path().to_path_buf(),
2818            VTCodeConfig::default(),
2819        ))
2820        .await
2821        .expect("controller");
2822
2823        let mentions = controller
2824            .set_turn_delegation_hints_from_input("use explorer agent to inspect the codebase")
2825            .await;
2826        assert_eq!(mentions, vec!["explorer".to_string()]);
2827
2828        let spawned = controller
2829            .spawn(SpawnAgentRequest {
2830                message: Some("Inspect the codebase.".to_string()),
2831                ..SpawnAgentRequest::default()
2832            })
2833            .await
2834            .expect("spawn");
2835
2836        assert_eq!(spawned.agent_name, "explorer");
2837        controller.close(&spawned.id).await.expect("close");
2838    }
2839
2840    #[tokio::test]
2841    async fn spawn_rejects_mismatched_explicit_mention() {
2842        let temp = TempDir::new().expect("tempdir");
2843        let controller = SubagentController::new(test_controller_config(
2844            temp.path().to_path_buf(),
2845            VTCodeConfig::default(),
2846        ))
2847        .await
2848        .expect("controller");
2849
2850        controller
2851            .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2852            .await;
2853
2854        let err = controller
2855            .spawn(SpawnAgentRequest {
2856                agent_type: Some("worker".to_string()),
2857                message: Some("Implement a change.".to_string()),
2858                ..SpawnAgentRequest::default()
2859            })
2860            .await
2861            .expect_err("mismatched mention should fail");
2862
2863        assert!(
2864            err.to_string()
2865                .contains("user explicitly selected 'explorer'")
2866        );
2867    }
2868
2869    #[tokio::test]
2870    async fn spawn_rejects_write_capable_agent_without_explicit_request_or_agent_type() {
2871        let temp = TempDir::new().expect("tempdir");
2872        let controller = SubagentController::new(test_controller_config(
2873            temp.path().to_path_buf(),
2874            VTCodeConfig::default(),
2875        ))
2876        .await
2877        .expect("controller");
2878
2879        let err = controller
2880            .spawn(SpawnAgentRequest {
2881                message: Some("Implement a change.".to_string()),
2882                ..SpawnAgentRequest::default()
2883            })
2884            .await
2885            .expect_err("write-capable agent should require explicit request or agent_type");
2886
2887        assert!(
2888            err.to_string()
2889                .contains("cannot launch write-capable agent")
2890        );
2891    }
2892
2893    #[tokio::test]
2894    async fn spawn_allows_write_capable_agent_with_explicit_agent_type() {
2895        let temp = TempDir::new().expect("tempdir");
2896        let controller = SubagentController::new(test_controller_config(
2897            temp.path().to_path_buf(),
2898            VTCodeConfig::default(),
2899        ))
2900        .await
2901        .expect("controller");
2902
2903        let spawned = controller
2904            .spawn(SpawnAgentRequest {
2905                agent_type: Some("worker".to_string()),
2906                message: Some("Implement a change.".to_string()),
2907                ..SpawnAgentRequest::default()
2908            })
2909            .await
2910            .expect("explicit agent_type should allow write-capable agent");
2911
2912        controller.close(&spawned.id).await.expect("close");
2913    }
2914
2915    #[tokio::test]
2916    async fn spawn_rejects_primary_only_agent_as_child() {
2917        let temp = TempDir::new().expect("tempdir");
2918        write_test_primary_agent(temp.path());
2919        let controller = SubagentController::new(test_controller_config(
2920            temp.path().to_path_buf(),
2921            VTCodeConfig::default(),
2922        ))
2923        .await
2924        .expect("controller");
2925
2926        let mentions = controller
2927            .set_turn_delegation_hints_from_input("@agent-duck discuss the task")
2928            .await;
2929        assert!(mentions.is_empty());
2930
2931        let err = controller
2932            .spawn(SpawnAgentRequest {
2933                agent_type: Some("duck".to_string()),
2934                message: Some("Discuss the task.".to_string()),
2935                ..SpawnAgentRequest::default()
2936            })
2937            .await
2938            .expect_err("primary-only agent should not spawn as child");
2939
2940        assert!(err.to_string().contains("Unknown subagent type duck"));
2941    }
2942
2943    #[tokio::test]
2944    async fn spawn_accepts_background_flag_outside_managed_background_runtime() {
2945        let temp = TempDir::new().expect("tempdir");
2946        let controller = SubagentController::new(test_controller_config(
2947            temp.path().to_path_buf(),
2948            VTCodeConfig::default(),
2949        ))
2950        .await
2951        .expect("controller");
2952
2953        controller
2954            .set_turn_delegation_hints_from_input("delegate this task")
2955            .await;
2956
2957        let spawned = controller
2958            .spawn(SpawnAgentRequest {
2959                agent_type: Some("explorer".to_string()),
2960                message: Some("Inspect the codebase.".to_string()),
2961                background: true,
2962                ..SpawnAgentRequest::default()
2963            })
2964            .await
2965            .expect("background child spawn should succeed");
2966
2967        assert!(spawned.background);
2968        controller.close(&spawned.id).await.expect("close");
2969    }
2970
2971    #[tokio::test]
2972    async fn spawn_allows_background_capable_spec_as_foreground_child() {
2973        let temp = TempDir::new().expect("tempdir");
2974        write_test_background_subagent(temp.path());
2975        let controller = SubagentController::new(test_controller_config(
2976            temp.path().to_path_buf(),
2977            VTCodeConfig::default(),
2978        ))
2979        .await
2980        .expect("controller");
2981
2982        controller
2983            .set_turn_delegation_hints_from_input("run background-demo subagent and demo")
2984            .await;
2985
2986        let spawned = controller
2987            .spawn(SpawnAgentRequest {
2988                agent_type: Some("background-demo".to_string()),
2989                message: Some("Run the demo.".to_string()),
2990                background: false,
2991                ..SpawnAgentRequest::default()
2992            })
2993            .await
2994            .expect("foreground background-capable spawn should succeed");
2995
2996        assert_eq!(spawned.agent_name, "background-demo");
2997        assert!(!spawned.background);
2998        controller.close(&spawned.id).await.expect("close");
2999    }
3000
3001    #[tokio::test]
3002    async fn spawn_rejects_vague_task_even_with_explicit_request() {
3003        let temp = TempDir::new().expect("tempdir");
3004        let controller = SubagentController::new(test_controller_config(
3005            temp.path().to_path_buf(),
3006            VTCodeConfig::default(),
3007        ))
3008        .await
3009        .expect("controller");
3010
3011        controller
3012            .set_turn_delegation_hints_from_input("run worker subagent and report")
3013            .await;
3014
3015        let err = controller
3016            .spawn(SpawnAgentRequest {
3017                agent_type: Some("worker".to_string()),
3018                message: Some("report".to_string()),
3019                ..SpawnAgentRequest::default()
3020            })
3021            .await
3022            .expect_err("vague task should require clarification");
3023
3024        assert!(err.to_string().contains("too vague ('report')"));
3025    }
3026
3027    #[tokio::test]
3028    async fn spawn_defaults_to_write_capable_run_subagent_selection() {
3029        let temp = TempDir::new().expect("tempdir");
3030        let controller = SubagentController::new(test_controller_config(
3031            temp.path().to_path_buf(),
3032            VTCodeConfig::default(),
3033        ))
3034        .await
3035        .expect("controller");
3036
3037        let mentions = controller
3038            .set_turn_delegation_hints_from_input("run worker subagent and implement the change")
3039            .await;
3040        assert_eq!(mentions, vec!["worker".to_string()]);
3041
3042        let spawned = controller
3043            .spawn(SpawnAgentRequest {
3044                message: Some("Implement the change.".to_string()),
3045                ..SpawnAgentRequest::default()
3046            })
3047            .await
3048            .expect("spawn");
3049
3050        assert_eq!(spawned.agent_name, "worker");
3051        controller.close(&spawned.id).await.expect("close");
3052    }
3053
3054    #[tokio::test]
3055    async fn spawn_rejects_read_only_agent_when_auto_delegate_is_disabled() {
3056        let temp = TempDir::new().expect("tempdir");
3057        write_test_read_only_subagent(temp.path());
3058        let mut cfg = VTCodeConfig::default();
3059        cfg.subagents.auto_delegate_read_only = false;
3060        let controller =
3061            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3062                .await
3063                .expect("controller");
3064
3065        let err = controller
3066            .spawn(SpawnAgentRequest {
3067                agent_type: Some("readonly-demo".to_string()),
3068                message: Some("Inspect the repository.".to_string()),
3069                ..SpawnAgentRequest::default()
3070            })
3071            .await
3072            .expect_err("read-only agent should require explicit delegation");
3073
3074        assert!(
3075            err.to_string()
3076                .contains("cannot proactively launch read-only agent 'readonly-demo'")
3077        );
3078    }
3079
3080    #[test]
3081    fn load_memory_appendix_renders_compact_summary() {
3082        let temp = TempDir::new().expect("tempdir");
3083        let memory_dir = temp.path().join(".vtcode/agent-memory/reviewer");
3084        std::fs::create_dir_all(&memory_dir).expect("memory dir");
3085        std::fs::write(
3086            memory_dir.join("MEMORY.md"),
3087            "# Reviewer Memory\n\n## Preferences\n- Keep diffs surgical.\n- Run focused tests before broad checks.\n- Prefer repo docs for orientation.\n- Ask only when a decision is materially blocked.\n- Additional long-form notes that should stay out of the prompt body.\n",
3088        )
3089        .expect("write memory");
3090
3091        let appendix =
3092            load_memory_appendix(temp.path(), "reviewer", Some(SubagentMemoryScope::Project))
3093                .expect("appendix")
3094                .expect("memory appendix");
3095
3096        assert!(appendix.contains("Persistent memory file:"));
3097        assert!(appendix.contains("Key points:"));
3098        assert!(appendix.contains("Keep diffs surgical."));
3099        assert!(appendix.contains("Open `MEMORY.md` when exact wording or more detail matters."));
3100        assert!(!appendix.contains("Current MEMORY.md excerpt"));
3101        assert!(!appendix.contains("## Preferences"));
3102    }
3103
3104    #[test]
3105    fn load_primary_memory_appendix_reads_existing_memory_without_write_guidance() {
3106        let temp = TempDir::new().expect("tempdir");
3107        let memory_dir = temp.path().join(".vtcode/agent-memory/reviewer");
3108        std::fs::create_dir_all(&memory_dir).expect("memory dir");
3109        std::fs::write(
3110            memory_dir.join("MEMORY.md"),
3111            "# Reviewer Memory\n\n## Preferences\n- Keep diffs surgical.\n- Run focused tests before broad checks.\n",
3112        )
3113        .expect("write memory");
3114
3115        let appendix = load_primary_memory_appendix(
3116            temp.path(),
3117            "reviewer",
3118            Some(SubagentMemoryScope::Project),
3119        )
3120        .expect("appendix")
3121        .expect("memory appendix");
3122
3123        assert!(appendix.contains("Primary-agent memory file:"));
3124        assert!(appendix.contains("Loaded read-only for this request."));
3125        assert!(appendix.contains("Key points:"));
3126        assert!(appendix.contains("Keep diffs surgical."));
3127        assert!(!appendix.contains("Read and maintain `MEMORY.md`"));
3128        assert!(!appendix.contains("Create or update `MEMORY.md`"));
3129        assert!(!appendix.contains("Open `MEMORY.md` when exact wording or more detail matters."));
3130    }
3131
3132    #[test]
3133    fn load_primary_memory_appendix_missing_memory_is_noop_without_directory_creation() {
3134        let temp = TempDir::new().expect("tempdir");
3135        let memory_dir = temp.path().join(".vtcode/agent-memory/reviewer");
3136
3137        let appendix = load_primary_memory_appendix(
3138            temp.path(),
3139            "reviewer",
3140            Some(SubagentMemoryScope::Project),
3141        )
3142        .expect("appendix");
3143
3144        assert!(appendix.is_none());
3145        assert!(!memory_dir.exists());
3146    }
3147
3148    #[tokio::test]
3149    async fn spawn_ignores_model_override_without_explicit_user_model_request() {
3150        let temp = TempDir::new().expect("tempdir");
3151        let controller = SubagentController::new(test_controller_config(
3152            temp.path().to_path_buf(),
3153            VTCodeConfig::default(),
3154        ))
3155        .await
3156        .expect("controller");
3157
3158        controller
3159            .set_turn_delegation_hints_from_input("delegate this task")
3160            .await;
3161
3162        let spawned = controller
3163            .spawn(SpawnAgentRequest {
3164                agent_type: Some("worker".to_string()),
3165                message: Some("Implement the change.".to_string()),
3166                model: Some(models::openai::GPT_5_4_MINI.to_string()),
3167                ..SpawnAgentRequest::default()
3168            })
3169            .await
3170            .expect("spawn");
3171
3172        let effective_model = wait_for_effective_model(&controller, &spawned.id)
3173            .await
3174            .expect("effective model");
3175        assert_eq!(effective_model, models::openai::GPT_5_4);
3176        controller.close(&spawned.id).await.expect("close");
3177    }
3178
3179    #[tokio::test]
3180    async fn spawn_honors_model_override_when_user_explicitly_requests_it() {
3181        let temp = TempDir::new().expect("tempdir");
3182        let controller = SubagentController::new(test_controller_config(
3183            temp.path().to_path_buf(),
3184            VTCodeConfig::default(),
3185        ))
3186        .await
3187        .expect("controller");
3188
3189        controller
3190            .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3191            .await;
3192
3193        let spawned = controller
3194            .spawn(SpawnAgentRequest {
3195                agent_type: Some("worker".to_string()),
3196                message: Some("Implement the change.".to_string()),
3197                model: Some(models::openai::GPT_5_4_MINI.to_string()),
3198                ..SpawnAgentRequest::default()
3199            })
3200            .await
3201            .expect("spawn");
3202
3203        let effective_model = wait_for_effective_model(&controller, &spawned.id)
3204            .await
3205            .expect("effective model");
3206        assert_eq!(effective_model, models::openai::GPT_5_4_MINI);
3207        controller.close(&spawned.id).await.expect("close");
3208    }
3209
3210    #[tokio::test]
3211    async fn spawn_background_subprocess_rejects_non_background_agent() {
3212        let temp = TempDir::new().expect("tempdir");
3213        let mut cfg = VTCodeConfig::default();
3214        cfg.subagents.background.enabled = true;
3215        let controller =
3216            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3217                .await
3218                .expect("controller");
3219
3220        controller
3221            .set_turn_delegation_hints_from_input("delegate this task")
3222            .await;
3223
3224        let err = controller
3225            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3226                agent_type: Some("worker".to_string()),
3227                message: Some("Implement a change.".to_string()),
3228                ..SpawnBackgroundSubprocessRequest::default()
3229            })
3230            .await
3231            .expect_err("non-background agent should be rejected");
3232
3233        assert!(err.to_string().contains("background: true"));
3234        assert!(err.to_string().contains("Use spawn_agent instead"));
3235    }
3236
3237    #[tokio::test]
3238    async fn spawn_background_subprocess_returns_active_record_when_settings_match() {
3239        let temp = TempDir::new().expect("tempdir");
3240        write_test_background_subagent(temp.path());
3241        let mut cfg = VTCodeConfig::default();
3242        cfg.subagents.background.enabled = true;
3243        let controller =
3244            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3245                .await
3246                .expect("controller");
3247
3248        controller
3249            .set_turn_delegation_hints_from_input("delegate this task")
3250            .await;
3251
3252        let spec = controller
3253            .resolve_requested_spec(Some("background-demo"))
3254            .await
3255            .expect("spec");
3256        let record_id = background_record_id(spec.name.as_str());
3257        let created_at = Utc::now();
3258        {
3259            let mut state = controller.state.write().await;
3260            state.background_children.insert(
3261                record_id.clone(),
3262                BackgroundRecord {
3263                    id: record_id.clone(),
3264                    agent_name: spec.name.clone(),
3265                    display_label: subagent_display_label(&spec),
3266                    description: spec.description.clone(),
3267                    source: spec.source.label(),
3268                    color: spec.color.clone(),
3269                    session_id: "session-background-demo".to_string(),
3270                    exec_session_id: "exec-session-background-demo".to_string(),
3271                    desired_enabled: true,
3272                    status: BackgroundSubprocessStatus::Running,
3273                    created_at,
3274                    updated_at: created_at,
3275                    started_at: Some(created_at),
3276                    ended_at: None,
3277                    pid: Some(42),
3278                    prompt: "Report readiness once.".to_string(),
3279                    summary: Some("ready".to_string()),
3280                    error: None,
3281                    archive_path: None,
3282                    transcript_path: None,
3283                    max_turns: Some(4),
3284                    model_override: None,
3285                    reasoning_override: None,
3286                    restart_attempts: 0,
3287                },
3288            );
3289        }
3290
3291        let entry = controller
3292            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3293                agent_type: Some("background-demo".to_string()),
3294                ..SpawnBackgroundSubprocessRequest::default()
3295            })
3296            .await
3297            .expect("matching active record should be returned");
3298
3299        assert_eq!(entry.id, record_id);
3300        assert_eq!(entry.status, BackgroundSubprocessStatus::Running);
3301        assert_eq!(entry.pid, Some(42));
3302    }
3303
3304    #[tokio::test]
3305    async fn spawn_background_subprocess_rejects_conflicting_active_record_settings() {
3306        let temp = TempDir::new().expect("tempdir");
3307        write_test_background_subagent(temp.path());
3308        let mut cfg = VTCodeConfig::default();
3309        cfg.subagents.background.enabled = true;
3310        let controller =
3311            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3312                .await
3313                .expect("controller");
3314
3315        controller
3316            .set_turn_delegation_hints_from_input("delegate this task")
3317            .await;
3318
3319        let spec = controller
3320            .resolve_requested_spec(Some("background-demo"))
3321            .await
3322            .expect("spec");
3323        let record_id = background_record_id(spec.name.as_str());
3324        let created_at = Utc::now();
3325        {
3326            let mut state = controller.state.write().await;
3327            state.background_children.insert(
3328                record_id,
3329                BackgroundRecord {
3330                    id: background_record_id(spec.name.as_str()),
3331                    agent_name: spec.name.clone(),
3332                    display_label: subagent_display_label(&spec),
3333                    description: spec.description.clone(),
3334                    source: spec.source.label(),
3335                    color: spec.color.clone(),
3336                    session_id: "session-background-demo".to_string(),
3337                    exec_session_id: "exec-session-background-demo".to_string(),
3338                    desired_enabled: true,
3339                    status: BackgroundSubprocessStatus::Running,
3340                    created_at,
3341                    updated_at: created_at,
3342                    started_at: Some(created_at),
3343                    ended_at: None,
3344                    pid: Some(42),
3345                    prompt: "Report readiness once.".to_string(),
3346                    summary: Some("ready".to_string()),
3347                    error: None,
3348                    archive_path: None,
3349                    transcript_path: None,
3350                    max_turns: Some(4),
3351                    model_override: None,
3352                    reasoning_override: None,
3353                    restart_attempts: 0,
3354                },
3355            );
3356        }
3357
3358        let err = controller
3359            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3360                agent_type: Some("background-demo".to_string()),
3361                message: Some("Run a different task.".to_string()),
3362                ..SpawnBackgroundSubprocessRequest::default()
3363            })
3364            .await
3365            .expect_err("conflicting active record should be rejected");
3366
3367        assert!(err.to_string().contains("different prompt"));
3368        assert!(err.to_string().contains("Stop or restart"));
3369    }
3370
3371    #[tokio::test]
3372    async fn resume_preserves_captured_runtime_overrides() {
3373        let temp = TempDir::new().expect("tempdir");
3374        let controller = SubagentController::new(test_controller_config(
3375            temp.path().to_path_buf(),
3376            VTCodeConfig::default(),
3377        ))
3378        .await
3379        .expect("controller");
3380
3381        controller
3382            .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3383            .await;
3384
3385        let spawned = controller
3386            .spawn(SpawnAgentRequest {
3387                agent_type: Some("worker".to_string()),
3388                message: Some("Implement the change.".to_string()),
3389                model: Some(models::openai::GPT_5_4_MINI.to_string()),
3390                max_turns: Some(2),
3391                ..SpawnAgentRequest::default()
3392            })
3393            .await
3394            .expect("spawn");
3395
3396        let initial_model = wait_for_effective_model(&controller, &spawned.id)
3397            .await
3398            .expect("initial effective model");
3399        assert_eq!(initial_model, models::openai::GPT_5_4_MINI);
3400
3401        let closed = controller.close(&spawned.id).await.expect("close");
3402        assert_eq!(closed.status, SubagentStatus::Closed);
3403
3404        controller.resume(&spawned.id).await.expect("resume");
3405
3406        for _ in 0..100 {
3407            let status = controller.status_for(&spawned.id).await.expect("status");
3408            if status.updated_at > closed.updated_at && status.status != SubagentStatus::Closed {
3409                let snapshot = controller
3410                    .snapshot_for_thread(&spawned.id)
3411                    .await
3412                    .expect("snapshot");
3413                assert_eq!(
3414                    snapshot.effective_config.agent.default_model,
3415                    models::openai::GPT_5_4_MINI
3416                );
3417                controller.close(&spawned.id).await.expect("final close");
3418                return;
3419            }
3420            tokio::time::sleep(Duration::from_millis(10)).await;
3421        }
3422
3423        panic!("resumed subagent did not capture runtime config in time");
3424    }
3425
3426    #[tokio::test]
3427    async fn spawn_captures_runtime_config_before_first_child_turn() {
3428        let temp = TempDir::new().expect("tempdir");
3429        let controller = SubagentController::new(test_controller_config(
3430            temp.path().to_path_buf(),
3431            VTCodeConfig::default(),
3432        ))
3433        .await
3434        .expect("controller");
3435
3436        controller
3437            .set_turn_delegation_hints_from_input("delegate this task")
3438            .await;
3439
3440        let spawned = controller
3441            .spawn(SpawnAgentRequest {
3442                agent_type: Some("worker".to_string()),
3443                message: Some("Implement the change.".to_string()),
3444                ..SpawnAgentRequest::default()
3445            })
3446            .await
3447            .expect("spawn");
3448
3449        let snapshot = controller
3450            .snapshot_for_thread(&spawned.id)
3451            .await
3452            .expect("snapshot");
3453
3454        assert_eq!(snapshot.id, spawned.id);
3455        assert!(
3456            !snapshot
3457                .effective_config
3458                .agent
3459                .default_model
3460                .trim()
3461                .is_empty()
3462        );
3463
3464        controller.close(&spawned.id).await.expect("close");
3465    }
3466
3467    #[tokio::test]
3468    async fn spawn_custom_uses_explicit_spec_without_delegation_hints() {
3469        let temp = TempDir::new().expect("tempdir");
3470        let controller = SubagentController::new(test_controller_config(
3471            temp.path().to_path_buf(),
3472            VTCodeConfig::default(),
3473        ))
3474        .await
3475        .expect("controller");
3476
3477        let mut spec = vtcode_config::builtin_subagents()
3478            .into_iter()
3479            .find(|spec| spec.name == "explorer")
3480            .expect("explorer");
3481        spec.name = "init-grounding-explorer".to_string();
3482        spec.description = "VT Code /init grounding explorer.".to_string();
3483        spec.source = SubagentSource::ProjectVtcode;
3484
3485        let spawned = controller
3486            .spawn_custom(
3487                spec,
3488                SpawnAgentRequest {
3489                    message: Some(
3490                        "Inspect the repository and report agent-facing findings.".to_string(),
3491                    ),
3492                    max_turns: Some(2),
3493                    ..SpawnAgentRequest::default()
3494                },
3495            )
3496            .await
3497            .expect("spawn");
3498
3499        assert_eq!(spawned.agent_name, "init-grounding-explorer");
3500        assert_eq!(spawned.source, SubagentSource::ProjectVtcode.label());
3501        controller.close(&spawned.id).await.expect("close");
3502    }
3503
3504    #[tokio::test]
3505    async fn spawn_custom_rejects_write_capable_spec() {
3506        let temp = TempDir::new().expect("tempdir");
3507        let controller = SubagentController::new(test_controller_config(
3508            temp.path().to_path_buf(),
3509            VTCodeConfig::default(),
3510        ))
3511        .await
3512        .expect("controller");
3513
3514        let spec = vtcode_config::builtin_subagents()
3515            .into_iter()
3516            .find(|spec| spec.name == "worker")
3517            .expect("worker");
3518
3519        let err = controller
3520            .spawn_custom(
3521                spec,
3522                SpawnAgentRequest {
3523                    message: Some("Implement a change.".to_string()),
3524                    ..SpawnAgentRequest::default()
3525                },
3526            )
3527            .await
3528            .expect_err("write-capable custom spec should be rejected");
3529
3530        assert!(
3531            err.to_string()
3532                .contains("custom subagent spawn only supports read-only specs")
3533        );
3534    }
3535
3536    #[tokio::test]
3537    async fn spawn_custom_rejects_primary_only_spec() {
3538        let temp = TempDir::new().expect("tempdir");
3539        write_test_primary_agent(temp.path());
3540        let controller = SubagentController::new(test_controller_config(
3541            temp.path().to_path_buf(),
3542            VTCodeConfig::default(),
3543        ))
3544        .await
3545        .expect("controller");
3546
3547        let spec = controller
3548            .effective_specs()
3549            .await
3550            .into_iter()
3551            .find(|spec| spec.name == "duck")
3552            .expect("duck primary agent");
3553
3554        let err = controller
3555            .spawn_custom(
3556                spec,
3557                SpawnAgentRequest {
3558                    message: Some("Discuss the task.".to_string()),
3559                    ..SpawnAgentRequest::default()
3560                },
3561            )
3562            .await
3563            .expect_err("primary-only custom spec should be rejected");
3564
3565        assert!(
3566            err.to_string()
3567                .contains("custom subagent spawn only supports subagent-capable specs")
3568        );
3569    }
3570
3571    #[tokio::test]
3572    async fn close_marks_child_closed() {
3573        let temp = TempDir::new().expect("tempdir");
3574        let controller = SubagentController::new(test_controller_config(
3575            temp.path().to_path_buf(),
3576            VTCodeConfig::default(),
3577        ))
3578        .await
3579        .expect("controller");
3580        controller
3581            .set_turn_delegation_hints_from_input("delegate this task")
3582            .await;
3583        let spawned = controller
3584            .spawn(SpawnAgentRequest {
3585                agent_type: Some("default".to_string()),
3586                message: Some("Summarize the repository.".to_string()),
3587                ..SpawnAgentRequest::default()
3588            })
3589            .await
3590            .expect("spawn");
3591        let closed = controller.close(&spawned.id).await.expect("close");
3592        assert_eq!(closed.status, SubagentStatus::Closed);
3593    }
3594
3595    #[tokio::test]
3596    async fn close_is_idempotent_for_closed_agents() {
3597        let temp = TempDir::new().expect("tempdir");
3598        let controller = SubagentController::new(test_controller_config(
3599            temp.path().to_path_buf(),
3600            VTCodeConfig::default(),
3601        ))
3602        .await
3603        .expect("controller");
3604        controller
3605            .set_turn_delegation_hints_from_input("delegate this task")
3606            .await;
3607        let spawned = controller
3608            .spawn(SpawnAgentRequest {
3609                agent_type: Some("default".to_string()),
3610                message: Some("Summarize the repository.".to_string()),
3611                ..SpawnAgentRequest::default()
3612            })
3613            .await
3614            .expect("spawn");
3615
3616        let closed = controller.close(&spawned.id).await.expect("first close");
3617        let closed_again = controller.close(&spawned.id).await.expect("second close");
3618
3619        assert_eq!(closed_again.status, SubagentStatus::Closed);
3620        assert_eq!(closed_again.updated_at, closed.updated_at);
3621        assert_eq!(closed_again.completed_at, closed.completed_at);
3622    }
3623
3624    #[tokio::test]
3625    async fn close_and_resume_cascade_through_spawn_tree() {
3626        let temp = TempDir::new().expect("tempdir");
3627        let controller = SubagentController::new(test_controller_config(
3628            temp.path().to_path_buf(),
3629            VTCodeConfig::default(),
3630        ))
3631        .await
3632        .expect("controller");
3633
3634        let spec = vtcode_config::builtin_subagents()
3635            .into_iter()
3636            .find(|spec| spec.name == "explorer")
3637            .expect("explorer");
3638
3639        {
3640            let mut state = controller.state.write().await;
3641            state.children.insert(
3642                "parent".to_string(),
3643                test_child_record("parent", "session-root", &spec, SubagentStatus::Running, 1),
3644            );
3645            state.children.insert(
3646                "child".to_string(),
3647                test_child_record("child", "parent", &spec, SubagentStatus::Running, 2),
3648            );
3649            state.children.insert(
3650                "grandchild".to_string(),
3651                test_child_record("grandchild", "child", &spec, SubagentStatus::Running, 3),
3652            );
3653        }
3654
3655        let closed = controller.close("parent").await.expect("close");
3656        assert_eq!(closed.status, SubagentStatus::Closed);
3657        assert_eq!(
3658            controller.status_for("child").await.expect("child").status,
3659            SubagentStatus::Closed
3660        );
3661        assert_eq!(
3662            controller
3663                .status_for("grandchild")
3664                .await
3665                .expect("grandchild")
3666                .status,
3667            SubagentStatus::Closed
3668        );
3669
3670        let subtree_ids = controller
3671            .collect_spawn_subtree_ids("parent")
3672            .await
3673            .expect("collect subtree");
3674        assert_eq!(
3675            subtree_ids,
3676            vec![
3677                "parent".to_string(),
3678                "child".to_string(),
3679                "grandchild".to_string()
3680            ]
3681        );
3682
3683        let mut restart_ids = Vec::new();
3684        for node_id in subtree_ids {
3685            if controller
3686                .reopen_single(node_id.as_str())
3687                .await
3688                .expect("reopen subtree node")
3689            {
3690                restart_ids.push(node_id);
3691            }
3692        }
3693
3694        assert_eq!(
3695            restart_ids,
3696            vec![
3697                "parent".to_string(),
3698                "child".to_string(),
3699                "grandchild".to_string()
3700            ]
3701        );
3702        assert_eq!(
3703            controller
3704                .status_for("parent")
3705                .await
3706                .expect("parent")
3707                .status,
3708            SubagentStatus::Queued
3709        );
3710        assert_eq!(
3711            controller.status_for("child").await.expect("child").status,
3712            SubagentStatus::Queued
3713        );
3714        assert_eq!(
3715            controller
3716                .status_for("grandchild")
3717                .await
3718                .expect("grandchild")
3719                .status,
3720            SubagentStatus::Queued
3721        );
3722    }
3723
3724    #[tokio::test]
3725    async fn spawn_rejects_fourth_active_subagent() {
3726        let temp = TempDir::new().expect("tempdir");
3727        let controller = SubagentController::new(test_controller_config(
3728            temp.path().to_path_buf(),
3729            VTCodeConfig::default(),
3730        ))
3731        .await
3732        .expect("controller");
3733        controller
3734            .set_turn_delegation_hints_from_input("delegate this task")
3735            .await;
3736
3737        let spec = vtcode_config::builtin_subagents()
3738            .into_iter()
3739            .find(|spec| spec.name == "explorer")
3740            .expect("explorer");
3741
3742        {
3743            let mut state = controller.state.write().await;
3744            for idx in 0..SUBAGENT_HARD_CONCURRENCY_LIMIT {
3745                let id = format!("active-{idx}");
3746                state.children.insert(
3747                    id.clone(),
3748                    ChildRecord {
3749                        id: id.clone(),
3750                        session_id: format!("session-{id}"),
3751                        parent_thread_id: "parent-session".to_string(),
3752                        spec: spec.clone(),
3753                        display_label: subagent_display_label(&spec),
3754                        status: SubagentStatus::Running,
3755                        background: false,
3756                        depth: 1,
3757                        created_at: Utc::now(),
3758                        updated_at: Utc::now(),
3759                        completed_at: None,
3760                        summary: None,
3761                        error: None,
3762                        archive_metadata: None,
3763                        archive_path: None,
3764                        transcript_path: None,
3765                        effective_config: None,
3766                        stored_messages: Vec::new(),
3767                        last_prompt: Some("Inspect the codebase.".to_string()),
3768                        queued_prompts: VecDeque::new(),
3769                        max_turns: None,
3770                        model_override: None,
3771                        reasoning_override: None,
3772                        thread_handle: None,
3773                        handle: None,
3774                        notify: Arc::new(Notify::new()),
3775                    },
3776                );
3777            }
3778        }
3779
3780        let err = controller
3781            .spawn(SpawnAgentRequest {
3782                agent_type: Some("explorer".to_string()),
3783                message: Some("Inspect another codepath.".to_string()),
3784                ..SpawnAgentRequest::default()
3785            })
3786            .await
3787            .expect_err("fourth active subagent should be rejected");
3788
3789        assert!(err.to_string().contains(&format!(
3790            "Subagent concurrency limit reached (max_concurrent={})",
3791            SUBAGENT_HARD_CONCURRENCY_LIMIT
3792        )));
3793    }
3794
3795    #[tokio::test]
3796    async fn wait_returns_first_terminal_child() {
3797        let temp = TempDir::new().expect("tempdir");
3798        let controller = SubagentController::new(test_controller_config(
3799            temp.path().to_path_buf(),
3800            VTCodeConfig::default(),
3801        ))
3802        .await
3803        .expect("controller");
3804        let spec = vtcode_config::builtin_subagents()
3805            .into_iter()
3806            .find(|spec| spec.name == "default")
3807            .expect("default");
3808
3809        {
3810            let mut state = controller.state.write().await;
3811            for id in ["first", "second"] {
3812                state.children.insert(
3813                    id.to_string(),
3814                    ChildRecord {
3815                        id: id.to_string(),
3816                        session_id: format!("session-{id}"),
3817                        parent_thread_id: "parent-session".to_string(),
3818                        spec: spec.clone(),
3819                        display_label: subagent_display_label(&spec),
3820                        status: SubagentStatus::Running,
3821                        background: false,
3822                        depth: 1,
3823                        created_at: Utc::now(),
3824                        updated_at: Utc::now(),
3825                        completed_at: None,
3826                        summary: None,
3827                        error: None,
3828                        archive_metadata: None,
3829                        archive_path: None,
3830                        transcript_path: None,
3831                        effective_config: None,
3832                        stored_messages: Vec::new(),
3833                        last_prompt: None,
3834                        queued_prompts: VecDeque::new(),
3835                        max_turns: None,
3836                        model_override: None,
3837                        reasoning_override: None,
3838                        thread_handle: None,
3839                        handle: None,
3840                        notify: Arc::new(Notify::new()),
3841                    },
3842                );
3843            }
3844        }
3845
3846        let controller_clone = controller.clone();
3847        tokio::spawn(async move {
3848            tokio::time::sleep(Duration::from_millis(20)).await;
3849            let mut state = controller_clone.state.write().await;
3850            let record = state.children.get_mut("second").expect("second child");
3851            record.status = SubagentStatus::Completed;
3852            record.summary = Some("done".to_string());
3853            record.completed_at = Some(Utc::now());
3854            record.updated_at = Utc::now();
3855            record.notify.notify_waiters();
3856        });
3857
3858        let result = controller
3859            .wait(&["first".to_string(), "second".to_string()], Some(500))
3860            .await
3861            .expect("wait result")
3862            .expect("terminal child");
3863        assert_eq!(result.id, "second");
3864        assert_eq!(result.status, SubagentStatus::Completed);
3865    }
3866}