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    build_child_config, compose_subagent_instructions, filter_child_tools,
19    normalize_background_child_max_turns, normalize_child_max_turns, prepare_child_runtime_config,
20};
21pub use model::{agent_type_for_spec, load_memory_appendix};
22pub use prompt::{
23    contains_explicit_delegation_request, contains_explicit_model_request,
24    delegated_task_requires_clarification, extract_explicit_agent_mentions,
25    normalize_requested_model_override, request_prompt, sanitize_subagent_input_items,
26};
27pub use types::{
28    BackgroundRecord, BackgroundSubprocessEntry, BackgroundSubprocessSnapshot,
29    BackgroundSubprocessStatus, ChildRecord, ChildRunResult, ControllerState,
30    PersistedBackgroundRecord, PersistedBackgroundState, SendInputRequest, SpawnAgentRequest,
31    SpawnBackgroundSubprocessRequest, StatusEntryBuilder, SubagentInputItem, SubagentStatus,
32    SubagentStatusEntry, SubagentThreadSnapshot, TurnDelegationHints,
33};
34
35// ─── Public Utilities ───────────────────────────────────────────────────────
36
37pub fn is_subagent_tool(name: &str) -> bool {
38    SUBAGENT_TOOL_NAMES.contains(&name)
39}
40
41#[derive(Clone, Default)]
42struct BackgroundLaunchOverrides {
43    prompt: Option<String>,
44    max_turns: Option<usize>,
45    model_override: Option<String>,
46    reasoning_override: Option<String>,
47}
48
49#[derive(Clone, Default)]
50struct PreparedDelegationContext {
51    requested_agent: Option<String>,
52    explicit_mentions: Vec<String>,
53    explicit_request: bool,
54    current_input: String,
55}
56
57// ─── Controller ─────────────────────────────────────────────────────────────
58
59use anyhow::{Context, Result, anyhow, bail};
60use chrono::Utc;
61use futures::future::select_all;
62use std::collections::VecDeque;
63use std::path::PathBuf;
64use std::sync::Arc;
65use tokio::sync::{Notify, RwLock};
66
67use crate::config::VTCodeConfig;
68use crate::config::types::ReasoningEffortLevel;
69use crate::core::agent::runner::{AgentRunner, RunnerSettings};
70use crate::core::agent::task::Task;
71use crate::core::threads::{ThreadBootstrap, ThreadId, ThreadRuntimeHandle, ThreadSnapshot};
72use crate::hooks::{LifecycleHookEngine, SessionStartTrigger};
73use crate::llm::provider::Message;
74use crate::tools::exec_session::ExecSessionManager;
75use crate::tools::pty::{PtyManager, PtySize};
76use crate::utils::session_archive::{SessionArchive, find_session_by_identifier};
77use vtcode_config::SubagentSpec;
78use vtcode_config::auth::OpenAIChatGptAuthHandle;
79
80use self::background::*;
81use self::config::*;
82use self::constants::*;
83use self::discovery::discover_controller_subagents;
84use self::model::*;
85
86// ─── Controller Config ─────────────────────────────────────────────────────
87
88#[derive(Clone)]
89pub struct SubagentControllerConfig {
90    pub workspace_root: PathBuf,
91    pub parent_session_id: String,
92    pub parent_model: String,
93    pub parent_provider: String,
94    pub parent_reasoning_effort: ReasoningEffortLevel,
95    pub api_key: String,
96    pub vt_cfg: VTCodeConfig,
97    pub openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
98    pub depth: usize,
99    pub exec_sessions: ExecSessionManager,
100    pub pty_manager: PtyManager,
101    pub managed_background_runtime: bool,
102}
103
104#[derive(Clone)]
105pub struct SubagentController {
106    config: Arc<SubagentControllerConfig>,
107    parent_session_id: Arc<RwLock<String>>,
108    lifecycle_hooks: Option<LifecycleHookEngine>,
109    state: Arc<RwLock<ControllerState>>,
110}
111
112impl SubagentController {
113    pub async fn new(config: SubagentControllerConfig) -> Result<Self> {
114        let discovered = discover_controller_subagents(&config.workspace_root).await?;
115        let lifecycle_hooks = LifecycleHookEngine::new_with_session(
116            config.workspace_root.clone(),
117            &config.vt_cfg.hooks,
118            SessionStartTrigger::Startup,
119            config.parent_session_id.clone(),
120            config.vt_cfg.permissions.default_mode,
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::PermissionMode;
1892    use crate::config::constants::models;
1893    use crate::config::constants::tools;
1894    use crate::config::models::{ModelId, Provider};
1895    use crate::llm::provider::ToolDefinition;
1896    use crate::tools::exec_session::ExecSessionManager;
1897    use crate::tools::registry::PtySessionManager;
1898    use anyhow::{Result, anyhow};
1899    use std::collections::BTreeMap;
1900    use std::collections::VecDeque;
1901    use std::path::PathBuf;
1902    use std::sync::Arc;
1903    use std::time::Duration;
1904    use tempfile::TempDir;
1905    use tokio::sync::Notify;
1906    use vtcode_config::{SubagentMcpServer, SubagentMemoryScope, SubagentSource, SubagentSpec};
1907
1908    fn test_controller_config(
1909        workspace_root: PathBuf,
1910        vt_cfg: VTCodeConfig,
1911    ) -> SubagentControllerConfig {
1912        let pty_sessions = PtySessionManager::new(workspace_root.clone(), vt_cfg.pty.clone());
1913        let exec_sessions = ExecSessionManager::new(workspace_root.clone(), pty_sessions.clone());
1914        SubagentControllerConfig {
1915            workspace_root,
1916            parent_session_id: "parent-session".to_string(),
1917            parent_model: models::openai::GPT_5_4.to_string(),
1918            parent_provider: "openai".to_string(),
1919            parent_reasoning_effort: ReasoningEffortLevel::Medium,
1920            api_key: "test-key".to_string(),
1921            vt_cfg,
1922            openai_chatgpt_auth: None,
1923            depth: 0,
1924            exec_sessions,
1925            pty_manager: pty_sessions.manager().clone(),
1926            managed_background_runtime: false,
1927        }
1928    }
1929
1930    fn test_child_record(
1931        id: &str,
1932        parent_thread_id: &str,
1933        spec: &SubagentSpec,
1934        status: SubagentStatus,
1935        depth: usize,
1936    ) -> ChildRecord {
1937        ChildRecord {
1938            id: id.to_string(),
1939            session_id: format!("session-{id}"),
1940            parent_thread_id: parent_thread_id.to_string(),
1941            spec: spec.clone(),
1942            display_label: subagent_display_label(spec),
1943            status,
1944            background: false,
1945            depth,
1946            created_at: Utc::now(),
1947            updated_at: Utc::now(),
1948            completed_at: status.is_terminal().then_some(Utc::now()),
1949            summary: None,
1950            error: None,
1951            archive_metadata: None,
1952            archive_path: None,
1953            transcript_path: None,
1954            effective_config: Some(VTCodeConfig::default()),
1955            stored_messages: Vec::new(),
1956            last_prompt: Some(format!("prompt-{id}")),
1957            queued_prompts: VecDeque::new(),
1958            max_turns: None,
1959            model_override: None,
1960            reasoning_override: None,
1961            thread_handle: None,
1962            handle: None,
1963            notify: Arc::new(Notify::new()),
1964        }
1965    }
1966
1967    fn write_test_background_subagent(workspace_root: &std::path::Path) {
1968        let agent_dir = workspace_root.join(".vtcode/agents");
1969        std::fs::create_dir_all(&agent_dir).expect("agent dir");
1970        std::fs::write(
1971            agent_dir.join("background-demo.md"),
1972            r#"---
1973name: background-demo
1974description: Minimal demo agent for the managed background subprocess flow.
1975tools:
1976  - unified_exec
1977background: true
1978maxTurns: 2
1979initialPrompt: Report readiness once.
1980---
1981
1982Run the managed background demo.
1983"#,
1984        )
1985        .expect("write background agent");
1986    }
1987
1988    fn write_test_primary_agent(workspace_root: &std::path::Path) {
1989        let agent_dir = workspace_root.join(".vtcode/agents");
1990        std::fs::create_dir_all(&agent_dir).expect("agent dir");
1991        std::fs::write(
1992            agent_dir.join("duck.md"),
1993            r#"---
1994name: duck
1995description: Discussion controller.
1996mode: primary
1997permissionMode: plan
1998---
1999
2000Discuss before implementation.
2001"#,
2002        )
2003        .expect("write primary agent");
2004    }
2005
2006    fn write_test_read_only_subagent(workspace_root: &std::path::Path) {
2007        let agent_dir = workspace_root.join(".vtcode/agents");
2008        std::fs::create_dir_all(&agent_dir).expect("agent dir");
2009        std::fs::write(
2010            agent_dir.join("readonly-demo.md"),
2011            r#"---
2012name: readonly-demo
2013description: Read-only test child agent.
2014tools:
2015  - read_file
2016permissionMode: plan
2017---
2018
2019Inspect the repository.
2020"#,
2021        )
2022        .expect("write read-only agent");
2023    }
2024
2025    #[test]
2026    fn request_prompt_prefers_message() {
2027        let request = SpawnAgentRequest {
2028            message: Some("hello".to_string()),
2029            ..SpawnAgentRequest::default()
2030        };
2031        assert_eq!(
2032            request_prompt(&request.message, &request.items).as_deref(),
2033            Some("hello")
2034        );
2035    }
2036
2037    #[test]
2038    fn delegated_task_requires_clarification_for_vague_prompt() {
2039        assert!(delegated_task_requires_clarification("report"));
2040        assert!(delegated_task_requires_clarification("report findings"));
2041        assert!(!delegated_task_requires_clarification(
2042            "review current code changes"
2043        ));
2044    }
2045
2046    #[test]
2047    fn resolve_subagent_model_maps_aliases() {
2048        let cfg = VTCodeConfig::default();
2049        let resolved = resolve_subagent_model(
2050            &cfg,
2051            models::anthropic::CLAUDE_SONNET_4_6,
2052            "anthropic",
2053            Some("haiku"),
2054            "explorer",
2055        )
2056        .expect("resolve model");
2057        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2058    }
2059
2060    #[test]
2061    fn resolve_subagent_model_defaults_to_parent_when_omitted() {
2062        let cfg = VTCodeConfig::default();
2063        let resolved = resolve_subagent_model(
2064            &cfg,
2065            models::ollama::GPT_OSS_120B_CLOUD,
2066            "ollama",
2067            None,
2068            "worker",
2069        )
2070        .expect("resolve model");
2071        assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2072    }
2073
2074    #[test]
2075    fn resolve_subagent_model_accepts_dotted_claude_aliases_for_anthropic() {
2076        let cfg = VTCodeConfig::default();
2077        let resolved =
2078            resolve_subagent_model(&cfg, "claude-haiku-4.5", "anthropic", None, "worker")
2079                .expect("resolve model");
2080        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2081    }
2082
2083    #[test]
2084    fn resolve_subagent_model_falls_back_to_copilot_default_for_unsupported_inherit_model() {
2085        let cfg = VTCodeConfig::default();
2086        let resolved = resolve_subagent_model(&cfg, "claude-haiku-4.5", "copilot", None, "worker")
2087            .expect("resolve model");
2088        assert_eq!(
2089            resolved,
2090            ModelId::default_orchestrator_for_provider(Provider::Copilot)
2091        );
2092    }
2093
2094    #[test]
2095    fn resolve_effective_subagent_model_uses_explicit_inherit_override() {
2096        let cfg = VTCodeConfig::default();
2097        let resolved = resolve_effective_subagent_model(
2098            &cfg,
2099            models::anthropic::CLAUDE_SONNET_4_6,
2100            "anthropic",
2101            Some("inherit"),
2102            Some("haiku"),
2103            "worker",
2104        )
2105        .expect("resolve model");
2106        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_SONNET_4_6);
2107    }
2108
2109    #[test]
2110    fn resolve_effective_subagent_model_falls_back_to_parent_on_invalid_override() {
2111        let cfg = VTCodeConfig::default();
2112        let resolved = resolve_effective_subagent_model(
2113            &cfg,
2114            models::ollama::GPT_OSS_120B_CLOUD,
2115            "ollama",
2116            Some("not-a-real-model"),
2117            None,
2118            "rust-engineer",
2119        )
2120        .expect("resolve model");
2121        assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2122    }
2123
2124    #[test]
2125    fn resolve_subagent_small_model_rejects_cross_provider_configured_lightweight_model() {
2126        let mut cfg = VTCodeConfig::default();
2127        cfg.agent.small_model.model = models::anthropic::CLAUDE_HAIKU_4_5.to_string();
2128
2129        let resolved = resolve_subagent_model(
2130            &cfg,
2131            models::openai::GPT_5_4,
2132            "openai",
2133            Some("small"),
2134            "worker",
2135        )
2136        .expect("resolve model");
2137
2138        assert_eq!(resolved, ModelId::GPT54Mini);
2139    }
2140
2141    #[test]
2142    fn resolve_effective_subagent_model_falls_back_to_spec_model_on_invalid_override() {
2143        let cfg = VTCodeConfig::default();
2144        let resolved = resolve_effective_subagent_model(
2145            &cfg,
2146            models::anthropic::CLAUDE_SONNET_4_6,
2147            "anthropic",
2148            Some("not-a-real-model"),
2149            Some("haiku"),
2150            "reviewer",
2151        )
2152        .expect("resolve model");
2153        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2154    }
2155
2156    #[test]
2157    fn background_record_ids_are_stable_and_sanitized() {
2158        assert_eq!(
2159            background_record_id("Rust Engineer"),
2160            "background-Rust-Engineer"
2161        );
2162        assert_eq!(
2163            background_record_id("plugin:reviewer/default"),
2164            "background-plugin-reviewer-default"
2165        );
2166    }
2167
2168    #[test]
2169    fn background_subagent_command_includes_expected_flags() {
2170        let workspace = std::env::current_dir().expect("workspace");
2171        let command = build_background_subagent_command(
2172            &workspace,
2173            "rust-engineer",
2174            "session-parent",
2175            "session-child",
2176            "Inspect the repo",
2177            Some(7),
2178            Some("gpt-5.4-mini"),
2179            Some("high"),
2180        )
2181        .expect("background command");
2182
2183        assert!(command.len() >= 15);
2184        assert_eq!(command[1], "background-subagent");
2185        assert!(
2186            command
2187                .windows(2)
2188                .any(|pair| pair == ["--agent-name", "rust-engineer"])
2189        );
2190        assert!(
2191            command
2192                .windows(2)
2193                .any(|pair| pair == ["--parent-session-id", "session-parent"])
2194        );
2195        assert!(
2196            command
2197                .windows(2)
2198                .any(|pair| pair == ["--session-id", "session-child"])
2199        );
2200        assert!(
2201            command
2202                .windows(2)
2203                .any(|pair| pair == ["--prompt", "Inspect the repo"])
2204        );
2205        assert!(command.windows(2).any(|pair| pair == ["--max-turns", "7"]));
2206        assert!(
2207            command
2208                .windows(2)
2209                .any(|pair| pair == ["--model-override", "gpt-5.4-mini"])
2210        );
2211        assert!(
2212            command
2213                .windows(2)
2214                .any(|pair| pair == ["--reasoning-override", "high"])
2215        );
2216    }
2217
2218    #[test]
2219    fn resolve_effective_subagent_model_still_errors_on_invalid_spec_model() {
2220        let cfg = VTCodeConfig::default();
2221        let err = resolve_effective_subagent_model(
2222            &cfg,
2223            models::anthropic::CLAUDE_SONNET_4_6,
2224            "anthropic",
2225            None,
2226            Some("not-a-real-model"),
2227            "reviewer",
2228        )
2229        .expect_err("invalid spec model should fail");
2230        assert!(err.to_string().contains("Failed to resolve model"));
2231    }
2232
2233    async fn wait_for_effective_model(
2234        controller: &SubagentController,
2235        target: &str,
2236    ) -> Result<String> {
2237        for _ in 0..50 {
2238            if let Ok(snapshot) = controller.snapshot_for_thread(target).await {
2239                return Ok(snapshot.effective_config.agent.default_model);
2240            }
2241            tokio::time::sleep(Duration::from_millis(10)).await;
2242        }
2243
2244        Err(anyhow!(
2245            "Subagent {target} did not capture an effective runtime configuration in time"
2246        ))
2247    }
2248
2249    fn read_only_test_spec(name: &str) -> SubagentSpec {
2250        SubagentSpec {
2251            name: name.to_string(),
2252            description: "test".to_string(),
2253            prompt: String::new(),
2254            tools: Some(vec![tools::READ_FILE.to_string()]),
2255            disallowed_tools: Vec::new(),
2256            model: None,
2257            color: None,
2258            reasoning_effort: None,
2259            permission_mode: Some(PermissionMode::Plan),
2260            skills: Vec::new(),
2261            mcp_servers: Vec::new(),
2262            hooks: None,
2263            background: false,
2264            mode: vtcode_config::AgentMode::Subagent,
2265            max_turns: None,
2266            nickname_candidates: Vec::new(),
2267            initial_prompt: None,
2268            memory: None,
2269            isolation: None,
2270            aliases: Vec::new(),
2271            source: SubagentSource::Builtin,
2272            file_path: None,
2273            warnings: Vec::new(),
2274        }
2275    }
2276
2277    #[test]
2278    fn filter_child_tools_removes_subagent_tools_in_children() {
2279        let defs = vec![
2280            ToolDefinition::function(
2281                tools::SPAWN_AGENT.to_string(),
2282                "Spawn".to_string(),
2283                serde_json::json!({"type": "object"}),
2284            ),
2285            ToolDefinition::function(
2286                tools::UNIFIED_SEARCH.to_string(),
2287                "Search".to_string(),
2288                serde_json::json!({"type": "object"}),
2289            ),
2290            ToolDefinition::function(
2291                tools::LIST_FILES.to_string(),
2292                "List".to_string(),
2293                serde_json::json!({"type": "object"}),
2294            ),
2295            ToolDefinition::function(
2296                tools::REQUEST_USER_INPUT.to_string(),
2297                "Ask".to_string(),
2298                serde_json::json!({"type": "object"}),
2299            ),
2300        ];
2301        let spec = vtcode_config::builtin_subagents()
2302            .into_iter()
2303            .find(|spec| spec.name == "explorer")
2304            .expect("explorer");
2305        let filtered = filter_child_tools(&spec, defs, true);
2306        assert_eq!(filtered.len(), 1);
2307        assert_eq!(filtered[0].function_name(), tools::UNIFIED_SEARCH);
2308    }
2309
2310    #[test]
2311    fn filter_child_tools_keeps_unified_exec_for_shell_capable_agents() {
2312        let defs = vec![
2313            ToolDefinition::function(
2314                tools::UNIFIED_EXEC.to_string(),
2315                "Exec".to_string(),
2316                serde_json::json!({"type": "object"}),
2317            ),
2318            ToolDefinition::function(
2319                tools::UNIFIED_SEARCH.to_string(),
2320                "Search".to_string(),
2321                serde_json::json!({"type": "object"}),
2322            ),
2323        ];
2324        let spec = SubagentSpec {
2325            name: "shell-demo".to_string(),
2326            description: "test".to_string(),
2327            prompt: String::new(),
2328            tools: Some(vec![
2329                tools::UNIFIED_EXEC.to_string(),
2330                tools::UNIFIED_SEARCH.to_string(),
2331            ]),
2332            disallowed_tools: Vec::new(),
2333            model: None,
2334            color: None,
2335            reasoning_effort: None,
2336            permission_mode: None,
2337            skills: Vec::new(),
2338            mcp_servers: Vec::new(),
2339            hooks: None,
2340            background: false,
2341            mode: vtcode_config::AgentMode::Subagent,
2342            max_turns: None,
2343            nickname_candidates: Vec::new(),
2344            initial_prompt: None,
2345            memory: None,
2346            isolation: None,
2347            aliases: Vec::new(),
2348            source: SubagentSource::Builtin,
2349            file_path: None,
2350            warnings: Vec::new(),
2351        };
2352
2353        let filtered = filter_child_tools(&spec, defs, spec.is_read_only());
2354        assert_eq!(filtered.len(), 2);
2355        assert_eq!(filtered[0].function_name(), tools::UNIFIED_EXEC);
2356        assert_eq!(filtered[1].function_name(), tools::UNIFIED_SEARCH);
2357    }
2358
2359    #[test]
2360    fn build_child_config_clamps_permissions_and_intersects_allowed_tools() {
2361        let mut parent = VTCodeConfig::default();
2362        parent.permissions.default_mode = PermissionMode::Default;
2363        parent.permissions.allow = vec![
2364            tools::READ_FILE.to_string(),
2365            tools::UNIFIED_SEARCH.to_string(),
2366        ];
2367        parent.permissions.deny = vec![tools::UNIFIED_EXEC.to_string()];
2368
2369        let mut spec = vtcode_config::builtin_subagents()
2370            .into_iter()
2371            .find(|spec| spec.name == "worker")
2372            .expect("worker");
2373        spec.permission_mode = Some(PermissionMode::BypassPermissions);
2374        spec.tools = Some(vec![
2375            tools::SPAWN_AGENT.to_string(),
2376            tools::UNIFIED_SEARCH.to_string(),
2377            tools::READ_FILE.to_string(),
2378        ]);
2379
2380        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2381        assert_eq!(child.permissions.default_mode, PermissionMode::Default);
2382        assert_eq!(
2383            child.permissions.allow,
2384            vec![
2385                tools::READ_FILE.to_string(),
2386                tools::UNIFIED_SEARCH.to_string()
2387            ]
2388        );
2389        assert!(
2390            child
2391                .permissions
2392                .deny
2393                .contains(&tools::UNIFIED_EXEC.to_string())
2394        );
2395        assert!(
2396            child
2397                .permissions
2398                .deny
2399                .contains(&tools::SPAWN_AGENT.to_string())
2400        );
2401    }
2402
2403    #[test]
2404    fn build_child_config_preserves_matching_rule_and_exact_tool_ids() {
2405        let mut parent = VTCodeConfig::default();
2406        parent.permissions.allow = vec![
2407            "Read(/docs/**)".to_string(),
2408            "mcp::context7::search".to_string(),
2409            tools::READ_FILE.to_string(),
2410        ];
2411
2412        let mut spec = vtcode_config::builtin_subagents()
2413            .into_iter()
2414            .find(|spec| spec.name == "worker")
2415            .expect("worker");
2416        spec.tools = Some(vec![
2417            "mcp::context7::search".to_string(),
2418            tools::UNIFIED_EXEC.to_string(),
2419            tools::READ_FILE.to_string(),
2420        ]);
2421
2422        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2423
2424        assert_eq!(
2425            child.permissions.allow,
2426            vec![
2427                "Read(/docs/**)".to_string(),
2428                "mcp::context7::search".to_string(),
2429                tools::READ_FILE.to_string()
2430            ]
2431        );
2432    }
2433
2434    #[test]
2435    fn build_child_config_preserves_parent_rule_shaped_allowlist() {
2436        let mut parent = VTCodeConfig::default();
2437        parent.permissions.allow = vec!["Read".to_string()];
2438
2439        let mut spec = vtcode_config::builtin_subagents()
2440            .into_iter()
2441            .find(|spec| spec.name == "worker")
2442            .expect("worker");
2443        spec.tools = Some(vec![
2444            tools::READ_FILE.to_string(),
2445            tools::UNIFIED_SEARCH.to_string(),
2446            tools::UNIFIED_EXEC.to_string(),
2447        ]);
2448
2449        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2450
2451        assert_eq!(child.permissions.allow, vec!["Read".to_string()]);
2452    }
2453
2454    #[test]
2455    fn build_child_config_promotes_single_turn_budget_to_recovery_budget() {
2456        let parent = VTCodeConfig::default();
2457        let spec = vtcode_config::builtin_subagents()
2458            .into_iter()
2459            .find(|spec| spec.name == "worker")
2460            .expect("worker");
2461
2462        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, Some(1));
2463
2464        assert_eq!(child.automation.full_auto.max_turns, SUBAGENT_MIN_MAX_TURNS);
2465    }
2466
2467    #[test]
2468    fn background_children_get_a_higher_turn_floor() {
2469        assert_eq!(normalize_background_child_max_turns(Some(2), true), Some(4));
2470        assert_eq!(normalize_background_child_max_turns(Some(3), true), Some(4));
2471        assert_eq!(normalize_background_child_max_turns(Some(4), true), Some(4));
2472    }
2473
2474    #[test]
2475    fn foreground_children_keep_the_existing_turn_floor() {
2476        assert_eq!(
2477            normalize_background_child_max_turns(Some(1), false),
2478            Some(SUBAGENT_MIN_MAX_TURNS)
2479        );
2480        assert_eq!(
2481            normalize_background_child_max_turns(Some(2), false),
2482            Some(2)
2483        );
2484        assert_eq!(normalize_background_child_max_turns(None, true), None);
2485    }
2486
2487    #[test]
2488    fn build_child_config_merges_inline_mcp_provider() {
2489        let parent = VTCodeConfig::default();
2490        let mut spec = vtcode_config::builtin_subagents()
2491            .into_iter()
2492            .find(|spec| spec.name == "default")
2493            .expect("default");
2494        spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
2495            "playwright".to_string(),
2496            serde_json::json!({
2497                "type": "stdio",
2498                "command": "npx",
2499                "args": ["-y", "@playwright/mcp@latest"],
2500            }),
2501        )]))];
2502
2503        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2504        let provider = child
2505            .mcp
2506            .providers
2507            .iter()
2508            .find(|provider| provider.name == "playwright")
2509            .expect("playwright provider");
2510        assert_eq!(provider.name, "playwright");
2511    }
2512
2513    #[test]
2514    fn explicit_delegation_request_detects_mentions_and_keywords() {
2515        let direct_mentions = extract_explicit_agent_mentions("@agent-worker fix the issue", &[]);
2516        assert!(contains_explicit_delegation_request(
2517            "@agent-worker fix the issue",
2518            direct_mentions.as_slice()
2519        ));
2520        let no_mentions = extract_explicit_agent_mentions("delegate this in parallel", &[]);
2521        assert!(contains_explicit_delegation_request(
2522            "delegate this in parallel",
2523            no_mentions.as_slice()
2524        ));
2525        let empty_mentions = extract_explicit_agent_mentions("review the repository", &[]);
2526        assert!(!contains_explicit_delegation_request(
2527            "review the repository",
2528            empty_mentions.as_slice()
2529        ));
2530    }
2531
2532    #[test]
2533    fn explicit_agent_mentions_detect_natural_language_selection() {
2534        let rust_engineer = read_only_test_spec("rust-engineer");
2535        assert_eq!(
2536            extract_explicit_agent_mentions(
2537                "use rust-engineer agent to review current code",
2538                &[rust_engineer]
2539            ),
2540            vec!["rust-engineer".to_string()]
2541        );
2542    }
2543
2544    #[test]
2545    fn explicit_agent_mentions_detect_looser_subagent_selection() {
2546        let background_demo = read_only_test_spec("background-demo");
2547        assert_eq!(
2548            extract_explicit_agent_mentions(
2549                "use background-demo and run the subagent",
2550                &[background_demo]
2551            ),
2552            vec!["background-demo".to_string()]
2553        );
2554    }
2555
2556    #[test]
2557    fn explicit_agent_mentions_detect_run_subagent_selection() {
2558        let rust_engineer = read_only_test_spec("rust-engineer");
2559        assert_eq!(
2560            extract_explicit_agent_mentions(
2561                "run rust-engineer subagent and review changes",
2562                &[rust_engineer]
2563            ),
2564            vec!["rust-engineer".to_string()]
2565        );
2566    }
2567
2568    #[test]
2569    fn explicit_agent_mentions_ignore_primary_only_agents() {
2570        let mut duck = read_only_test_spec("duck");
2571        duck.mode = vtcode_config::AgentMode::Primary;
2572
2573        assert_eq!(
2574            extract_explicit_agent_mentions("@agent-duck discuss the task", &[duck.clone()]),
2575            Vec::<String>::new()
2576        );
2577        assert_eq!(
2578            extract_explicit_agent_mentions("run duck agent and discuss the task", &[duck]),
2579            Vec::<String>::new()
2580        );
2581    }
2582
2583    #[test]
2584    fn explicit_model_request_detects_aliases_and_full_ids() {
2585        assert!(contains_explicit_model_request(
2586            "delegate this using gpt-5.4-mini",
2587            "gpt-5.4-mini"
2588        ));
2589        assert!(contains_explicit_model_request(
2590            "use the worker subagent with haiku",
2591            "haiku"
2592        ));
2593        assert!(contains_explicit_model_request(
2594            "run this with the small model",
2595            "small"
2596        ));
2597        assert!(!contains_explicit_model_request(
2598            "delegate this small cleanup task",
2599            "small"
2600        ));
2601        assert!(!contains_explicit_model_request(
2602            "delegate this task",
2603            "gpt-5.4-mini"
2604        ));
2605    }
2606
2607    #[test]
2608    fn normalize_requested_model_override_drops_default_like_values() {
2609        assert_eq!(
2610            normalize_requested_model_override(Some("default".to_string()), "delegate this task"),
2611            None
2612        );
2613        assert_eq!(
2614            normalize_requested_model_override(Some(" inherit ".to_string()), "delegate this task"),
2615            None
2616        );
2617        assert_eq!(
2618            normalize_requested_model_override(
2619                Some(" inherit ".to_string()),
2620                "delegate this task using inherit"
2621            ),
2622            Some("inherit".to_string())
2623        );
2624    }
2625
2626    #[test]
2627    fn sanitize_subagent_input_items_drops_empty_fields() {
2628        let mut items = vec![
2629            SubagentInputItem {
2630                item_type: Some("text".to_string()),
2631                text: Some("  Workspace: /tmp/repo  ".to_string()),
2632                path: Some(String::new()),
2633                name: Some(" ".to_string()),
2634                image_url: None,
2635            },
2636            SubagentInputItem {
2637                item_type: Some("text".to_string()),
2638                text: Some("   ".to_string()),
2639                path: Some(String::new()),
2640                name: None,
2641                image_url: None,
2642            },
2643        ];
2644
2645        sanitize_subagent_input_items(&mut items);
2646
2647        assert_eq!(items.len(), 1);
2648        assert_eq!(items[0].text.as_deref(), Some("Workspace: /tmp/repo"));
2649        assert!(items[0].path.is_none());
2650        assert!(items[0].name.is_none());
2651    }
2652
2653    #[tokio::test]
2654    async fn controller_exposes_builtin_specs() {
2655        let temp = TempDir::new().expect("tempdir");
2656        let controller = SubagentController::new(test_controller_config(
2657            temp.path().to_path_buf(),
2658            VTCodeConfig::default(),
2659        ))
2660        .await
2661        .expect("controller");
2662        let specs = controller.effective_specs().await;
2663        assert!(specs.iter().any(|spec| spec.name == "explorer"));
2664        assert!(specs.iter().any(|spec| spec.name == "worker"));
2665    }
2666
2667    #[tokio::test]
2668    async fn spawn_defaults_to_single_explicit_mention() {
2669        let temp = TempDir::new().expect("tempdir");
2670        let controller = SubagentController::new(test_controller_config(
2671            temp.path().to_path_buf(),
2672            VTCodeConfig::default(),
2673        ))
2674        .await
2675        .expect("controller");
2676
2677        controller
2678            .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2679            .await;
2680
2681        let spawned = controller
2682            .spawn(SpawnAgentRequest {
2683                message: Some("Inspect the codebase.".to_string()),
2684                ..SpawnAgentRequest::default()
2685            })
2686            .await
2687            .expect("spawn");
2688
2689        assert_eq!(spawned.agent_name, "explorer");
2690        controller.close(&spawned.id).await.expect("close");
2691    }
2692
2693    #[tokio::test]
2694    async fn spawn_defaults_to_single_natural_language_selection() {
2695        let temp = TempDir::new().expect("tempdir");
2696        let controller = SubagentController::new(test_controller_config(
2697            temp.path().to_path_buf(),
2698            VTCodeConfig::default(),
2699        ))
2700        .await
2701        .expect("controller");
2702
2703        let mentions = controller
2704            .set_turn_delegation_hints_from_input("use explorer agent to inspect the codebase")
2705            .await;
2706        assert_eq!(mentions, vec!["explorer".to_string()]);
2707
2708        let spawned = controller
2709            .spawn(SpawnAgentRequest {
2710                message: Some("Inspect the codebase.".to_string()),
2711                ..SpawnAgentRequest::default()
2712            })
2713            .await
2714            .expect("spawn");
2715
2716        assert_eq!(spawned.agent_name, "explorer");
2717        controller.close(&spawned.id).await.expect("close");
2718    }
2719
2720    #[tokio::test]
2721    async fn spawn_rejects_mismatched_explicit_mention() {
2722        let temp = TempDir::new().expect("tempdir");
2723        let controller = SubagentController::new(test_controller_config(
2724            temp.path().to_path_buf(),
2725            VTCodeConfig::default(),
2726        ))
2727        .await
2728        .expect("controller");
2729
2730        controller
2731            .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2732            .await;
2733
2734        let err = controller
2735            .spawn(SpawnAgentRequest {
2736                agent_type: Some("worker".to_string()),
2737                message: Some("Implement a change.".to_string()),
2738                ..SpawnAgentRequest::default()
2739            })
2740            .await
2741            .expect_err("mismatched mention should fail");
2742
2743        assert!(
2744            err.to_string()
2745                .contains("user explicitly selected 'explorer'")
2746        );
2747    }
2748
2749    #[tokio::test]
2750    async fn spawn_rejects_write_capable_agent_without_explicit_request_or_agent_type() {
2751        let temp = TempDir::new().expect("tempdir");
2752        let controller = SubagentController::new(test_controller_config(
2753            temp.path().to_path_buf(),
2754            VTCodeConfig::default(),
2755        ))
2756        .await
2757        .expect("controller");
2758
2759        let err = controller
2760            .spawn(SpawnAgentRequest {
2761                message: Some("Implement a change.".to_string()),
2762                ..SpawnAgentRequest::default()
2763            })
2764            .await
2765            .expect_err("write-capable agent should require explicit request or agent_type");
2766
2767        assert!(
2768            err.to_string()
2769                .contains("cannot launch write-capable agent")
2770        );
2771    }
2772
2773    #[tokio::test]
2774    async fn spawn_allows_write_capable_agent_with_explicit_agent_type() {
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
2783        let spawned = controller
2784            .spawn(SpawnAgentRequest {
2785                agent_type: Some("worker".to_string()),
2786                message: Some("Implement a change.".to_string()),
2787                ..SpawnAgentRequest::default()
2788            })
2789            .await
2790            .expect("explicit agent_type should allow write-capable agent");
2791
2792        controller.close(&spawned.id).await.expect("close");
2793    }
2794
2795    #[tokio::test]
2796    async fn spawn_rejects_primary_only_agent_as_child() {
2797        let temp = TempDir::new().expect("tempdir");
2798        write_test_primary_agent(temp.path());
2799        let controller = SubagentController::new(test_controller_config(
2800            temp.path().to_path_buf(),
2801            VTCodeConfig::default(),
2802        ))
2803        .await
2804        .expect("controller");
2805
2806        let mentions = controller
2807            .set_turn_delegation_hints_from_input("@agent-duck discuss the task")
2808            .await;
2809        assert!(mentions.is_empty());
2810
2811        let err = controller
2812            .spawn(SpawnAgentRequest {
2813                agent_type: Some("duck".to_string()),
2814                message: Some("Discuss the task.".to_string()),
2815                ..SpawnAgentRequest::default()
2816            })
2817            .await
2818            .expect_err("primary-only agent should not spawn as child");
2819
2820        assert!(err.to_string().contains("Unknown subagent type duck"));
2821    }
2822
2823    #[tokio::test]
2824    async fn spawn_accepts_background_flag_outside_managed_background_runtime() {
2825        let temp = TempDir::new().expect("tempdir");
2826        let controller = SubagentController::new(test_controller_config(
2827            temp.path().to_path_buf(),
2828            VTCodeConfig::default(),
2829        ))
2830        .await
2831        .expect("controller");
2832
2833        controller
2834            .set_turn_delegation_hints_from_input("delegate this task")
2835            .await;
2836
2837        let spawned = controller
2838            .spawn(SpawnAgentRequest {
2839                agent_type: Some("explorer".to_string()),
2840                message: Some("Inspect the codebase.".to_string()),
2841                background: true,
2842                ..SpawnAgentRequest::default()
2843            })
2844            .await
2845            .expect("background child spawn should succeed");
2846
2847        assert!(spawned.background);
2848        controller.close(&spawned.id).await.expect("close");
2849    }
2850
2851    #[tokio::test]
2852    async fn spawn_allows_background_capable_spec_as_foreground_child() {
2853        let temp = TempDir::new().expect("tempdir");
2854        write_test_background_subagent(temp.path());
2855        let controller = SubagentController::new(test_controller_config(
2856            temp.path().to_path_buf(),
2857            VTCodeConfig::default(),
2858        ))
2859        .await
2860        .expect("controller");
2861
2862        controller
2863            .set_turn_delegation_hints_from_input("run background-demo subagent and demo")
2864            .await;
2865
2866        let spawned = controller
2867            .spawn(SpawnAgentRequest {
2868                agent_type: Some("background-demo".to_string()),
2869                message: Some("Run the demo.".to_string()),
2870                background: false,
2871                ..SpawnAgentRequest::default()
2872            })
2873            .await
2874            .expect("foreground background-capable spawn should succeed");
2875
2876        assert_eq!(spawned.agent_name, "background-demo");
2877        assert!(!spawned.background);
2878        controller.close(&spawned.id).await.expect("close");
2879    }
2880
2881    #[tokio::test]
2882    async fn spawn_rejects_vague_task_even_with_explicit_request() {
2883        let temp = TempDir::new().expect("tempdir");
2884        let controller = SubagentController::new(test_controller_config(
2885            temp.path().to_path_buf(),
2886            VTCodeConfig::default(),
2887        ))
2888        .await
2889        .expect("controller");
2890
2891        controller
2892            .set_turn_delegation_hints_from_input("run worker subagent and report")
2893            .await;
2894
2895        let err = controller
2896            .spawn(SpawnAgentRequest {
2897                agent_type: Some("worker".to_string()),
2898                message: Some("report".to_string()),
2899                ..SpawnAgentRequest::default()
2900            })
2901            .await
2902            .expect_err("vague task should require clarification");
2903
2904        assert!(err.to_string().contains("too vague ('report')"));
2905    }
2906
2907    #[tokio::test]
2908    async fn spawn_defaults_to_write_capable_run_subagent_selection() {
2909        let temp = TempDir::new().expect("tempdir");
2910        let controller = SubagentController::new(test_controller_config(
2911            temp.path().to_path_buf(),
2912            VTCodeConfig::default(),
2913        ))
2914        .await
2915        .expect("controller");
2916
2917        let mentions = controller
2918            .set_turn_delegation_hints_from_input("run worker subagent and implement the change")
2919            .await;
2920        assert_eq!(mentions, vec!["worker".to_string()]);
2921
2922        let spawned = controller
2923            .spawn(SpawnAgentRequest {
2924                message: Some("Implement the change.".to_string()),
2925                ..SpawnAgentRequest::default()
2926            })
2927            .await
2928            .expect("spawn");
2929
2930        assert_eq!(spawned.agent_name, "worker");
2931        controller.close(&spawned.id).await.expect("close");
2932    }
2933
2934    #[tokio::test]
2935    async fn spawn_rejects_read_only_agent_when_auto_delegate_is_disabled() {
2936        let temp = TempDir::new().expect("tempdir");
2937        write_test_read_only_subagent(temp.path());
2938        let mut cfg = VTCodeConfig::default();
2939        cfg.subagents.auto_delegate_read_only = false;
2940        let controller =
2941            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
2942                .await
2943                .expect("controller");
2944
2945        let err = controller
2946            .spawn(SpawnAgentRequest {
2947                agent_type: Some("readonly-demo".to_string()),
2948                message: Some("Inspect the repository.".to_string()),
2949                ..SpawnAgentRequest::default()
2950            })
2951            .await
2952            .expect_err("read-only agent should require explicit delegation");
2953
2954        assert!(
2955            err.to_string()
2956                .contains("cannot proactively launch read-only agent 'readonly-demo'")
2957        );
2958    }
2959
2960    #[test]
2961    fn load_memory_appendix_renders_compact_summary() {
2962        let temp = TempDir::new().expect("tempdir");
2963        let memory_dir = temp.path().join(".vtcode/agent-memory/reviewer");
2964        std::fs::create_dir_all(&memory_dir).expect("memory dir");
2965        std::fs::write(
2966            memory_dir.join("MEMORY.md"),
2967            "# 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",
2968        )
2969        .expect("write memory");
2970
2971        let appendix =
2972            load_memory_appendix(temp.path(), "reviewer", Some(SubagentMemoryScope::Project))
2973                .expect("appendix")
2974                .expect("memory appendix");
2975
2976        assert!(appendix.contains("Persistent memory file:"));
2977        assert!(appendix.contains("Key points:"));
2978        assert!(appendix.contains("Keep diffs surgical."));
2979        assert!(appendix.contains("Open `MEMORY.md` when exact wording or more detail matters."));
2980        assert!(!appendix.contains("Current MEMORY.md excerpt"));
2981        assert!(!appendix.contains("## Preferences"));
2982    }
2983
2984    #[tokio::test]
2985    async fn spawn_ignores_model_override_without_explicit_user_model_request() {
2986        let temp = TempDir::new().expect("tempdir");
2987        let controller = SubagentController::new(test_controller_config(
2988            temp.path().to_path_buf(),
2989            VTCodeConfig::default(),
2990        ))
2991        .await
2992        .expect("controller");
2993
2994        controller
2995            .set_turn_delegation_hints_from_input("delegate this task")
2996            .await;
2997
2998        let spawned = controller
2999            .spawn(SpawnAgentRequest {
3000                agent_type: Some("worker".to_string()),
3001                message: Some("Implement the change.".to_string()),
3002                model: Some(models::openai::GPT_5_4_MINI.to_string()),
3003                ..SpawnAgentRequest::default()
3004            })
3005            .await
3006            .expect("spawn");
3007
3008        let effective_model = wait_for_effective_model(&controller, &spawned.id)
3009            .await
3010            .expect("effective model");
3011        assert_eq!(effective_model, models::openai::GPT_5_4);
3012        controller.close(&spawned.id).await.expect("close");
3013    }
3014
3015    #[tokio::test]
3016    async fn spawn_honors_model_override_when_user_explicitly_requests_it() {
3017        let temp = TempDir::new().expect("tempdir");
3018        let controller = SubagentController::new(test_controller_config(
3019            temp.path().to_path_buf(),
3020            VTCodeConfig::default(),
3021        ))
3022        .await
3023        .expect("controller");
3024
3025        controller
3026            .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3027            .await;
3028
3029        let spawned = controller
3030            .spawn(SpawnAgentRequest {
3031                agent_type: Some("worker".to_string()),
3032                message: Some("Implement the change.".to_string()),
3033                model: Some(models::openai::GPT_5_4_MINI.to_string()),
3034                ..SpawnAgentRequest::default()
3035            })
3036            .await
3037            .expect("spawn");
3038
3039        let effective_model = wait_for_effective_model(&controller, &spawned.id)
3040            .await
3041            .expect("effective model");
3042        assert_eq!(effective_model, models::openai::GPT_5_4_MINI);
3043        controller.close(&spawned.id).await.expect("close");
3044    }
3045
3046    #[tokio::test]
3047    async fn spawn_background_subprocess_rejects_non_background_agent() {
3048        let temp = TempDir::new().expect("tempdir");
3049        let mut cfg = VTCodeConfig::default();
3050        cfg.subagents.background.enabled = true;
3051        let controller =
3052            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3053                .await
3054                .expect("controller");
3055
3056        controller
3057            .set_turn_delegation_hints_from_input("delegate this task")
3058            .await;
3059
3060        let err = controller
3061            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3062                agent_type: Some("worker".to_string()),
3063                message: Some("Implement a change.".to_string()),
3064                ..SpawnBackgroundSubprocessRequest::default()
3065            })
3066            .await
3067            .expect_err("non-background agent should be rejected");
3068
3069        assert!(err.to_string().contains("background: true"));
3070        assert!(err.to_string().contains("Use spawn_agent instead"));
3071    }
3072
3073    #[tokio::test]
3074    async fn spawn_background_subprocess_returns_active_record_when_settings_match() {
3075        let temp = TempDir::new().expect("tempdir");
3076        write_test_background_subagent(temp.path());
3077        let mut cfg = VTCodeConfig::default();
3078        cfg.subagents.background.enabled = true;
3079        let controller =
3080            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3081                .await
3082                .expect("controller");
3083
3084        controller
3085            .set_turn_delegation_hints_from_input("delegate this task")
3086            .await;
3087
3088        let spec = controller
3089            .resolve_requested_spec(Some("background-demo"))
3090            .await
3091            .expect("spec");
3092        let record_id = background_record_id(spec.name.as_str());
3093        let created_at = Utc::now();
3094        {
3095            let mut state = controller.state.write().await;
3096            state.background_children.insert(
3097                record_id.clone(),
3098                BackgroundRecord {
3099                    id: record_id.clone(),
3100                    agent_name: spec.name.clone(),
3101                    display_label: subagent_display_label(&spec),
3102                    description: spec.description.clone(),
3103                    source: spec.source.label(),
3104                    color: spec.color.clone(),
3105                    session_id: "session-background-demo".to_string(),
3106                    exec_session_id: "exec-session-background-demo".to_string(),
3107                    desired_enabled: true,
3108                    status: BackgroundSubprocessStatus::Running,
3109                    created_at,
3110                    updated_at: created_at,
3111                    started_at: Some(created_at),
3112                    ended_at: None,
3113                    pid: Some(42),
3114                    prompt: "Report readiness once.".to_string(),
3115                    summary: Some("ready".to_string()),
3116                    error: None,
3117                    archive_path: None,
3118                    transcript_path: None,
3119                    max_turns: Some(4),
3120                    model_override: None,
3121                    reasoning_override: None,
3122                    restart_attempts: 0,
3123                },
3124            );
3125        }
3126
3127        let entry = controller
3128            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3129                agent_type: Some("background-demo".to_string()),
3130                ..SpawnBackgroundSubprocessRequest::default()
3131            })
3132            .await
3133            .expect("matching active record should be returned");
3134
3135        assert_eq!(entry.id, record_id);
3136        assert_eq!(entry.status, BackgroundSubprocessStatus::Running);
3137        assert_eq!(entry.pid, Some(42));
3138    }
3139
3140    #[tokio::test]
3141    async fn spawn_background_subprocess_rejects_conflicting_active_record_settings() {
3142        let temp = TempDir::new().expect("tempdir");
3143        write_test_background_subagent(temp.path());
3144        let mut cfg = VTCodeConfig::default();
3145        cfg.subagents.background.enabled = true;
3146        let controller =
3147            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3148                .await
3149                .expect("controller");
3150
3151        controller
3152            .set_turn_delegation_hints_from_input("delegate this task")
3153            .await;
3154
3155        let spec = controller
3156            .resolve_requested_spec(Some("background-demo"))
3157            .await
3158            .expect("spec");
3159        let record_id = background_record_id(spec.name.as_str());
3160        let created_at = Utc::now();
3161        {
3162            let mut state = controller.state.write().await;
3163            state.background_children.insert(
3164                record_id,
3165                BackgroundRecord {
3166                    id: background_record_id(spec.name.as_str()),
3167                    agent_name: spec.name.clone(),
3168                    display_label: subagent_display_label(&spec),
3169                    description: spec.description.clone(),
3170                    source: spec.source.label(),
3171                    color: spec.color.clone(),
3172                    session_id: "session-background-demo".to_string(),
3173                    exec_session_id: "exec-session-background-demo".to_string(),
3174                    desired_enabled: true,
3175                    status: BackgroundSubprocessStatus::Running,
3176                    created_at,
3177                    updated_at: created_at,
3178                    started_at: Some(created_at),
3179                    ended_at: None,
3180                    pid: Some(42),
3181                    prompt: "Report readiness once.".to_string(),
3182                    summary: Some("ready".to_string()),
3183                    error: None,
3184                    archive_path: None,
3185                    transcript_path: None,
3186                    max_turns: Some(4),
3187                    model_override: None,
3188                    reasoning_override: None,
3189                    restart_attempts: 0,
3190                },
3191            );
3192        }
3193
3194        let err = controller
3195            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3196                agent_type: Some("background-demo".to_string()),
3197                message: Some("Run a different task.".to_string()),
3198                ..SpawnBackgroundSubprocessRequest::default()
3199            })
3200            .await
3201            .expect_err("conflicting active record should be rejected");
3202
3203        assert!(err.to_string().contains("different prompt"));
3204        assert!(err.to_string().contains("Stop or restart"));
3205    }
3206
3207    #[tokio::test]
3208    async fn resume_preserves_captured_runtime_overrides() {
3209        let temp = TempDir::new().expect("tempdir");
3210        let controller = SubagentController::new(test_controller_config(
3211            temp.path().to_path_buf(),
3212            VTCodeConfig::default(),
3213        ))
3214        .await
3215        .expect("controller");
3216
3217        controller
3218            .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3219            .await;
3220
3221        let spawned = controller
3222            .spawn(SpawnAgentRequest {
3223                agent_type: Some("worker".to_string()),
3224                message: Some("Implement the change.".to_string()),
3225                model: Some(models::openai::GPT_5_4_MINI.to_string()),
3226                max_turns: Some(2),
3227                ..SpawnAgentRequest::default()
3228            })
3229            .await
3230            .expect("spawn");
3231
3232        let initial_model = wait_for_effective_model(&controller, &spawned.id)
3233            .await
3234            .expect("initial effective model");
3235        assert_eq!(initial_model, models::openai::GPT_5_4_MINI);
3236
3237        let closed = controller.close(&spawned.id).await.expect("close");
3238        assert_eq!(closed.status, SubagentStatus::Closed);
3239
3240        controller.resume(&spawned.id).await.expect("resume");
3241
3242        for _ in 0..100 {
3243            let status = controller.status_for(&spawned.id).await.expect("status");
3244            if status.updated_at > closed.updated_at && status.status != SubagentStatus::Closed {
3245                let snapshot = controller
3246                    .snapshot_for_thread(&spawned.id)
3247                    .await
3248                    .expect("snapshot");
3249                assert_eq!(
3250                    snapshot.effective_config.agent.default_model,
3251                    models::openai::GPT_5_4_MINI
3252                );
3253                controller.close(&spawned.id).await.expect("final close");
3254                return;
3255            }
3256            tokio::time::sleep(Duration::from_millis(10)).await;
3257        }
3258
3259        panic!("resumed subagent did not capture runtime config in time");
3260    }
3261
3262    #[tokio::test]
3263    async fn spawn_captures_runtime_config_before_first_child_turn() {
3264        let temp = TempDir::new().expect("tempdir");
3265        let controller = SubagentController::new(test_controller_config(
3266            temp.path().to_path_buf(),
3267            VTCodeConfig::default(),
3268        ))
3269        .await
3270        .expect("controller");
3271
3272        controller
3273            .set_turn_delegation_hints_from_input("delegate this task")
3274            .await;
3275
3276        let spawned = controller
3277            .spawn(SpawnAgentRequest {
3278                agent_type: Some("worker".to_string()),
3279                message: Some("Implement the change.".to_string()),
3280                ..SpawnAgentRequest::default()
3281            })
3282            .await
3283            .expect("spawn");
3284
3285        let snapshot = controller
3286            .snapshot_for_thread(&spawned.id)
3287            .await
3288            .expect("snapshot");
3289
3290        assert_eq!(snapshot.id, spawned.id);
3291        assert!(
3292            !snapshot
3293                .effective_config
3294                .agent
3295                .default_model
3296                .trim()
3297                .is_empty()
3298        );
3299
3300        controller.close(&spawned.id).await.expect("close");
3301    }
3302
3303    #[tokio::test]
3304    async fn spawn_custom_uses_explicit_spec_without_delegation_hints() {
3305        let temp = TempDir::new().expect("tempdir");
3306        let controller = SubagentController::new(test_controller_config(
3307            temp.path().to_path_buf(),
3308            VTCodeConfig::default(),
3309        ))
3310        .await
3311        .expect("controller");
3312
3313        let mut spec = vtcode_config::builtin_subagents()
3314            .into_iter()
3315            .find(|spec| spec.name == "explorer")
3316            .expect("explorer");
3317        spec.name = "init-grounding-explorer".to_string();
3318        spec.description = "VT Code /init grounding explorer.".to_string();
3319        spec.source = SubagentSource::ProjectVtcode;
3320
3321        let spawned = controller
3322            .spawn_custom(
3323                spec,
3324                SpawnAgentRequest {
3325                    message: Some(
3326                        "Inspect the repository and report agent-facing findings.".to_string(),
3327                    ),
3328                    max_turns: Some(2),
3329                    ..SpawnAgentRequest::default()
3330                },
3331            )
3332            .await
3333            .expect("spawn");
3334
3335        assert_eq!(spawned.agent_name, "init-grounding-explorer");
3336        assert_eq!(spawned.source, SubagentSource::ProjectVtcode.label());
3337        controller.close(&spawned.id).await.expect("close");
3338    }
3339
3340    #[tokio::test]
3341    async fn spawn_custom_rejects_write_capable_spec() {
3342        let temp = TempDir::new().expect("tempdir");
3343        let controller = SubagentController::new(test_controller_config(
3344            temp.path().to_path_buf(),
3345            VTCodeConfig::default(),
3346        ))
3347        .await
3348        .expect("controller");
3349
3350        let spec = vtcode_config::builtin_subagents()
3351            .into_iter()
3352            .find(|spec| spec.name == "worker")
3353            .expect("worker");
3354
3355        let err = controller
3356            .spawn_custom(
3357                spec,
3358                SpawnAgentRequest {
3359                    message: Some("Implement a change.".to_string()),
3360                    ..SpawnAgentRequest::default()
3361                },
3362            )
3363            .await
3364            .expect_err("write-capable custom spec should be rejected");
3365
3366        assert!(
3367            err.to_string()
3368                .contains("custom subagent spawn only supports read-only specs")
3369        );
3370    }
3371
3372    #[tokio::test]
3373    async fn spawn_custom_rejects_primary_only_spec() {
3374        let temp = TempDir::new().expect("tempdir");
3375        write_test_primary_agent(temp.path());
3376        let controller = SubagentController::new(test_controller_config(
3377            temp.path().to_path_buf(),
3378            VTCodeConfig::default(),
3379        ))
3380        .await
3381        .expect("controller");
3382
3383        let spec = controller
3384            .effective_specs()
3385            .await
3386            .into_iter()
3387            .find(|spec| spec.name == "duck")
3388            .expect("duck primary agent");
3389
3390        let err = controller
3391            .spawn_custom(
3392                spec,
3393                SpawnAgentRequest {
3394                    message: Some("Discuss the task.".to_string()),
3395                    ..SpawnAgentRequest::default()
3396                },
3397            )
3398            .await
3399            .expect_err("primary-only custom spec should be rejected");
3400
3401        assert!(
3402            err.to_string()
3403                .contains("custom subagent spawn only supports subagent-capable specs")
3404        );
3405    }
3406
3407    #[tokio::test]
3408    async fn close_marks_child_closed() {
3409        let temp = TempDir::new().expect("tempdir");
3410        let controller = SubagentController::new(test_controller_config(
3411            temp.path().to_path_buf(),
3412            VTCodeConfig::default(),
3413        ))
3414        .await
3415        .expect("controller");
3416        controller
3417            .set_turn_delegation_hints_from_input("delegate this task")
3418            .await;
3419        let spawned = controller
3420            .spawn(SpawnAgentRequest {
3421                agent_type: Some("default".to_string()),
3422                message: Some("Summarize the repository.".to_string()),
3423                ..SpawnAgentRequest::default()
3424            })
3425            .await
3426            .expect("spawn");
3427        let closed = controller.close(&spawned.id).await.expect("close");
3428        assert_eq!(closed.status, SubagentStatus::Closed);
3429    }
3430
3431    #[tokio::test]
3432    async fn close_is_idempotent_for_closed_agents() {
3433        let temp = TempDir::new().expect("tempdir");
3434        let controller = SubagentController::new(test_controller_config(
3435            temp.path().to_path_buf(),
3436            VTCodeConfig::default(),
3437        ))
3438        .await
3439        .expect("controller");
3440        controller
3441            .set_turn_delegation_hints_from_input("delegate this task")
3442            .await;
3443        let spawned = controller
3444            .spawn(SpawnAgentRequest {
3445                agent_type: Some("default".to_string()),
3446                message: Some("Summarize the repository.".to_string()),
3447                ..SpawnAgentRequest::default()
3448            })
3449            .await
3450            .expect("spawn");
3451
3452        let closed = controller.close(&spawned.id).await.expect("first close");
3453        let closed_again = controller.close(&spawned.id).await.expect("second close");
3454
3455        assert_eq!(closed_again.status, SubagentStatus::Closed);
3456        assert_eq!(closed_again.updated_at, closed.updated_at);
3457        assert_eq!(closed_again.completed_at, closed.completed_at);
3458    }
3459
3460    #[tokio::test]
3461    async fn close_and_resume_cascade_through_spawn_tree() {
3462        let temp = TempDir::new().expect("tempdir");
3463        let controller = SubagentController::new(test_controller_config(
3464            temp.path().to_path_buf(),
3465            VTCodeConfig::default(),
3466        ))
3467        .await
3468        .expect("controller");
3469
3470        let spec = vtcode_config::builtin_subagents()
3471            .into_iter()
3472            .find(|spec| spec.name == "explorer")
3473            .expect("explorer");
3474
3475        {
3476            let mut state = controller.state.write().await;
3477            state.children.insert(
3478                "parent".to_string(),
3479                test_child_record("parent", "session-root", &spec, SubagentStatus::Running, 1),
3480            );
3481            state.children.insert(
3482                "child".to_string(),
3483                test_child_record("child", "parent", &spec, SubagentStatus::Running, 2),
3484            );
3485            state.children.insert(
3486                "grandchild".to_string(),
3487                test_child_record("grandchild", "child", &spec, SubagentStatus::Running, 3),
3488            );
3489        }
3490
3491        let closed = controller.close("parent").await.expect("close");
3492        assert_eq!(closed.status, SubagentStatus::Closed);
3493        assert_eq!(
3494            controller.status_for("child").await.expect("child").status,
3495            SubagentStatus::Closed
3496        );
3497        assert_eq!(
3498            controller
3499                .status_for("grandchild")
3500                .await
3501                .expect("grandchild")
3502                .status,
3503            SubagentStatus::Closed
3504        );
3505
3506        let subtree_ids = controller
3507            .collect_spawn_subtree_ids("parent")
3508            .await
3509            .expect("collect subtree");
3510        assert_eq!(
3511            subtree_ids,
3512            vec![
3513                "parent".to_string(),
3514                "child".to_string(),
3515                "grandchild".to_string()
3516            ]
3517        );
3518
3519        let mut restart_ids = Vec::new();
3520        for node_id in subtree_ids {
3521            if controller
3522                .reopen_single(node_id.as_str())
3523                .await
3524                .expect("reopen subtree node")
3525            {
3526                restart_ids.push(node_id);
3527            }
3528        }
3529
3530        assert_eq!(
3531            restart_ids,
3532            vec![
3533                "parent".to_string(),
3534                "child".to_string(),
3535                "grandchild".to_string()
3536            ]
3537        );
3538        assert_eq!(
3539            controller
3540                .status_for("parent")
3541                .await
3542                .expect("parent")
3543                .status,
3544            SubagentStatus::Queued
3545        );
3546        assert_eq!(
3547            controller.status_for("child").await.expect("child").status,
3548            SubagentStatus::Queued
3549        );
3550        assert_eq!(
3551            controller
3552                .status_for("grandchild")
3553                .await
3554                .expect("grandchild")
3555                .status,
3556            SubagentStatus::Queued
3557        );
3558    }
3559
3560    #[tokio::test]
3561    async fn spawn_rejects_fourth_active_subagent() {
3562        let temp = TempDir::new().expect("tempdir");
3563        let controller = SubagentController::new(test_controller_config(
3564            temp.path().to_path_buf(),
3565            VTCodeConfig::default(),
3566        ))
3567        .await
3568        .expect("controller");
3569        controller
3570            .set_turn_delegation_hints_from_input("delegate this task")
3571            .await;
3572
3573        let spec = vtcode_config::builtin_subagents()
3574            .into_iter()
3575            .find(|spec| spec.name == "explorer")
3576            .expect("explorer");
3577
3578        {
3579            let mut state = controller.state.write().await;
3580            for idx in 0..SUBAGENT_HARD_CONCURRENCY_LIMIT {
3581                let id = format!("active-{idx}");
3582                state.children.insert(
3583                    id.clone(),
3584                    ChildRecord {
3585                        id: id.clone(),
3586                        session_id: format!("session-{id}"),
3587                        parent_thread_id: "parent-session".to_string(),
3588                        spec: spec.clone(),
3589                        display_label: subagent_display_label(&spec),
3590                        status: SubagentStatus::Running,
3591                        background: false,
3592                        depth: 1,
3593                        created_at: Utc::now(),
3594                        updated_at: Utc::now(),
3595                        completed_at: None,
3596                        summary: None,
3597                        error: None,
3598                        archive_metadata: None,
3599                        archive_path: None,
3600                        transcript_path: None,
3601                        effective_config: None,
3602                        stored_messages: Vec::new(),
3603                        last_prompt: Some("Inspect the codebase.".to_string()),
3604                        queued_prompts: VecDeque::new(),
3605                        max_turns: None,
3606                        model_override: None,
3607                        reasoning_override: None,
3608                        thread_handle: None,
3609                        handle: None,
3610                        notify: Arc::new(Notify::new()),
3611                    },
3612                );
3613            }
3614        }
3615
3616        let err = controller
3617            .spawn(SpawnAgentRequest {
3618                agent_type: Some("explorer".to_string()),
3619                message: Some("Inspect another codepath.".to_string()),
3620                ..SpawnAgentRequest::default()
3621            })
3622            .await
3623            .expect_err("fourth active subagent should be rejected");
3624
3625        assert!(err.to_string().contains(&format!(
3626            "Subagent concurrency limit reached (max_concurrent={})",
3627            SUBAGENT_HARD_CONCURRENCY_LIMIT
3628        )));
3629    }
3630
3631    #[tokio::test]
3632    async fn wait_returns_first_terminal_child() {
3633        let temp = TempDir::new().expect("tempdir");
3634        let controller = SubagentController::new(test_controller_config(
3635            temp.path().to_path_buf(),
3636            VTCodeConfig::default(),
3637        ))
3638        .await
3639        .expect("controller");
3640        let spec = vtcode_config::builtin_subagents()
3641            .into_iter()
3642            .find(|spec| spec.name == "default")
3643            .expect("default");
3644
3645        {
3646            let mut state = controller.state.write().await;
3647            for id in ["first", "second"] {
3648                state.children.insert(
3649                    id.to_string(),
3650                    ChildRecord {
3651                        id: id.to_string(),
3652                        session_id: format!("session-{id}"),
3653                        parent_thread_id: "parent-session".to_string(),
3654                        spec: spec.clone(),
3655                        display_label: subagent_display_label(&spec),
3656                        status: SubagentStatus::Running,
3657                        background: false,
3658                        depth: 1,
3659                        created_at: Utc::now(),
3660                        updated_at: Utc::now(),
3661                        completed_at: None,
3662                        summary: None,
3663                        error: None,
3664                        archive_metadata: None,
3665                        archive_path: None,
3666                        transcript_path: None,
3667                        effective_config: None,
3668                        stored_messages: Vec::new(),
3669                        last_prompt: None,
3670                        queued_prompts: VecDeque::new(),
3671                        max_turns: None,
3672                        model_override: None,
3673                        reasoning_override: None,
3674                        thread_handle: None,
3675                        handle: None,
3676                        notify: Arc::new(Notify::new()),
3677                    },
3678                );
3679            }
3680        }
3681
3682        let controller_clone = controller.clone();
3683        tokio::spawn(async move {
3684            tokio::time::sleep(Duration::from_millis(20)).await;
3685            let mut state = controller_clone.state.write().await;
3686            let record = state.children.get_mut("second").expect("second child");
3687            record.status = SubagentStatus::Completed;
3688            record.summary = Some("done".to_string());
3689            record.completed_at = Some(Utc::now());
3690            record.updated_at = Utc::now();
3691            record.notify.notify_waiters();
3692        });
3693
3694        let result = controller
3695            .wait(&["first".to_string(), "second".to_string()], Some(500))
3696            .await
3697            .expect("wait result")
3698            .expect("terminal child");
3699        assert_eq!(result.id, "second");
3700        assert_eq!(result.status, SubagentStatus::Completed);
3701    }
3702}