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            let notifies = {
926                let state = self.state.read().await;
927                targets
928                    .iter()
929                    .filter_map(|target| {
930                        state
931                            .children
932                            .get(target)
933                            .map(|record| record.notify.clone())
934                    })
935                    .collect::<Vec<_>>()
936            };
937            if notifies.is_empty() {
938                return Ok(None);
939            }
940
941            for target in targets {
942                if let Ok(entry) = self.status_for(target).await
943                    && entry.status.is_terminal()
944                {
945                    return Ok(Some(entry));
946                }
947            }
948
949            let sleep = tokio::time::sleep_until(deadline);
950            tokio::pin!(sleep);
951            let wait_any = select_all(
952                notifies
953                    .into_iter()
954                    .map(|notify| Box::pin(async move { notify.notified().await }))
955                    .collect::<Vec<_>>(),
956            );
957            tokio::pin!(wait_any);
958
959            tokio::select! {
960                _ = &mut sleep => return Ok(None),
961                _ = &mut wait_any => {}
962            }
963        }
964    }
965
966    pub async fn status_for(&self, target: &str) -> Result<SubagentStatusEntry> {
967        let state = self.state.read().await;
968        let record = state
969            .children
970            .get(target)
971            .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
972        Ok(record.build_status_entry())
973    }
974
975    async fn spawn_child_ids_for_parent(&self, parent_thread_id: &str) -> Vec<String> {
976        let state = self.state.read().await;
977        let mut child_ids = state
978            .children
979            .values()
980            .filter(|record| record.parent_thread_id == parent_thread_id)
981            .map(|record| record.id.clone())
982            .collect::<Vec<_>>();
983        child_ids.sort();
984        child_ids
985    }
986
987    async fn collect_spawn_subtree_ids(&self, root_thread_id: &str) -> Result<Vec<String>> {
988        let mut subtree_ids = Vec::new();
989        let mut stack = vec![root_thread_id.to_string()];
990
991        while let Some(thread_id) = stack.pop() {
992            subtree_ids.push(thread_id.clone());
993            let child_ids = self.spawn_child_ids_for_parent(&thread_id).await;
994            for child_id in child_ids.into_iter().rev() {
995                stack.push(child_id);
996            }
997        }
998
999        Ok(subtree_ids)
1000    }
1001
1002    async fn reopen_single(&self, target: &str) -> Result<bool> {
1003        let mut state = self.state.write().await;
1004        let record = state
1005            .children
1006            .get_mut(target)
1007            .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1008        if matches!(
1009            record.status,
1010            SubagentStatus::Running | SubagentStatus::Queued
1011        ) {
1012            return Ok(false);
1013        }
1014        let prompt = record.last_prompt.clone().unwrap_or_else(|| {
1015            "Continue the delegated task from the existing context.".to_string()
1016        });
1017        record.status = SubagentStatus::Queued;
1018        record.updated_at = Utc::now();
1019        record.completed_at = None;
1020        record.error = None;
1021        record.summary = None;
1022        record.queued_prompts.push_back(prompt);
1023        Ok(true)
1024    }
1025
1026    async fn close_single(&self, target: &str) -> Result<SubagentStatusEntry> {
1027        let mut state = self.state.write().await;
1028        let record = state
1029            .children
1030            .get_mut(target)
1031            .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1032        if record.status == SubagentStatus::Closed {
1033            return Ok(record.build_status_entry());
1034        }
1035        if let Some(handle) = record.handle.take() {
1036            handle.abort();
1037        }
1038        record.status = SubagentStatus::Closed;
1039        record.updated_at = Utc::now();
1040        record.completed_at = Some(Utc::now());
1041        record.notify.notify_waiters();
1042        Ok(record.build_status_entry())
1043    }
1044
1045    async fn background_status_for(&self, target: &str) -> Result<BackgroundSubprocessEntry> {
1046        let state = self.state.read().await;
1047        let record = state
1048            .background_children
1049            .get(target)
1050            .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?;
1051        Ok(record.build_status_entry())
1052    }
1053
1054    async fn ensure_background_record_running(
1055        &self,
1056        agent_name: &str,
1057        stable_id: Option<&str>,
1058        restart_attempts: u8,
1059        overrides: Option<BackgroundLaunchOverrides>,
1060    ) -> Result<BackgroundSubprocessEntry> {
1061        let spec = self
1062            .resolve_requested_spec(Some(agent_name))
1063            .await
1064            .with_context(|| format!("Failed to resolve background subagent '{agent_name}'"))?;
1065        let record_id = stable_id
1066            .map(ToOwned::to_owned)
1067            .unwrap_or_else(|| background_record_id(agent_name));
1068        let previous_record = {
1069            let state = self.state.read().await;
1070            state.background_children.get(&record_id).map(|record| {
1071                (
1072                    record.created_at,
1073                    record.prompt.clone(),
1074                    record.max_turns,
1075                    record.model_override.clone(),
1076                    record.reasoning_override.clone(),
1077                )
1078            })
1079        };
1080        let parent_session_id = self.parent_session_id.read().await.clone();
1081        let session_id = format!(
1082            "{}-{}-{}",
1083            sanitize_component(parent_session_id.as_str()),
1084            sanitize_component(record_id.as_str()),
1085            Utc::now().format("%Y%m%dT%H%M%S%3fZ")
1086        );
1087        let exec_session_id = format!("exec-{session_id}");
1088        let (
1089            created_at,
1090            previous_prompt,
1091            previous_max_turns,
1092            previous_model_override,
1093            previous_reasoning_override,
1094        ) = previous_record.unwrap_or((Utc::now(), String::new(), None, None, None));
1095        let prompt = overrides
1096            .as_ref()
1097            .and_then(|overrides| overrides.prompt.clone())
1098            .filter(|value| !value.trim().is_empty())
1099            .or_else(|| (!previous_prompt.trim().is_empty()).then_some(previous_prompt))
1100            .or_else(|| spec.initial_prompt.clone())
1101            .filter(|value| !value.trim().is_empty())
1102            .unwrap_or_else(|| {
1103                format!(
1104                    "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.",
1105                    spec.name
1106                )
1107            });
1108        let max_turns = normalize_background_child_max_turns(
1109            overrides
1110                .as_ref()
1111                .and_then(|overrides| overrides.max_turns)
1112                .or(previous_max_turns)
1113                .or(spec.max_turns),
1114            true,
1115        );
1116        let model_override = overrides
1117            .as_ref()
1118            .and_then(|overrides| overrides.model_override.clone())
1119            .or(previous_model_override)
1120            .or_else(|| spec.model.clone());
1121        let reasoning_override = overrides
1122            .as_ref()
1123            .and_then(|overrides| overrides.reasoning_override.clone())
1124            .or(previous_reasoning_override)
1125            .or_else(|| spec.reasoning_effort.clone());
1126
1127        {
1128            let mut state = self.state.write().await;
1129            state.background_children.insert(
1130                record_id.clone(),
1131                BackgroundRecord {
1132                    id: record_id.clone(),
1133                    agent_name: spec.name.clone(),
1134                    display_label: subagent_display_label(&spec),
1135                    description: spec.description.clone(),
1136                    source: spec.source.label(),
1137                    color: spec.color.clone(),
1138                    session_id: session_id.clone(),
1139                    exec_session_id: exec_session_id.clone(),
1140                    desired_enabled: true,
1141                    status: BackgroundSubprocessStatus::Starting,
1142                    created_at,
1143                    updated_at: Utc::now(),
1144                    started_at: None,
1145                    ended_at: None,
1146                    pid: None,
1147                    prompt: prompt.clone(),
1148                    summary: Some("Starting background subagent".to_string()),
1149                    error: None,
1150                    archive_path: None,
1151                    transcript_path: None,
1152                    max_turns,
1153                    model_override: model_override.clone(),
1154                    reasoning_override: reasoning_override.clone(),
1155                    restart_attempts,
1156                },
1157            );
1158        }
1159
1160        let launch = build_background_launch_spec(
1161            &self.config.workspace_root,
1162            spec.name.as_str(),
1163            parent_session_id.as_str(),
1164            session_id.as_str(),
1165            prompt.as_str(),
1166            max_turns,
1167            model_override.as_deref(),
1168            reasoning_override.as_deref(),
1169        )?;
1170        let metadata = if launch.use_pty {
1171            self.config
1172                .exec_sessions
1173                .create_pty_session(
1174                    exec_session_id.clone().into(),
1175                    launch.command,
1176                    self.config.workspace_root.clone(),
1177                    PtySize {
1178                        rows: 24,
1179                        cols: 80,
1180                        pixel_width: 0,
1181                        pixel_height: 0,
1182                    },
1183                    hashbrown::HashMap::new(),
1184                    None,
1185                )
1186                .await
1187        } else {
1188            self.config
1189                .exec_sessions
1190                .create_pipe_session(
1191                    exec_session_id.clone().into(),
1192                    launch.command,
1193                    self.config.workspace_root.clone(),
1194                    hashbrown::HashMap::new(),
1195                )
1196                .await
1197        }
1198        .with_context(|| {
1199            format!(
1200                "Failed to spawn background subprocess for subagent '{}'",
1201                spec.name
1202            )
1203        })?;
1204
1205        tracing::info!(
1206            agent_name = spec.name.as_str(),
1207            record_id = record_id.as_str(),
1208            exec_session_id = exec_session_id.as_str(),
1209            pid = metadata.child_pid,
1210            "Spawned background subagent subprocess"
1211        );
1212
1213        {
1214            let mut state = self.state.write().await;
1215            let record = state
1216                .background_children
1217                .get_mut(&record_id)
1218                .ok_or_else(|| anyhow!("Unknown background subprocess {}", record_id))?;
1219            record.exec_session_id = exec_session_id;
1220            record.pid = metadata.child_pid;
1221            record.started_at = metadata.started_at;
1222            record.status = BackgroundSubprocessStatus::Running;
1223            record.updated_at = Utc::now();
1224            record.ended_at = None;
1225            record.error = None;
1226            record.summary = Some("Background subagent is running".to_string());
1227        }
1228
1229        self.save_background_state().await?;
1230        self.background_status_for(&record_id).await
1231    }
1232
1233    async fn refresh_background_archive_metadata(&self, target: &str) -> Result<()> {
1234        let session_id = {
1235            let state = self.state.read().await;
1236            state
1237                .background_children
1238                .get(target)
1239                .map(|record| record.session_id.clone())
1240                .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?
1241        };
1242
1243        if let Some(listing) = find_session_by_identifier(&session_id).await? {
1244            let mut state = self.state.write().await;
1245            if let Some(record) = state.background_children.get_mut(target) {
1246                record.archive_path = Some(listing.path.clone());
1247                record.transcript_path = Some(listing.path);
1248            }
1249        }
1250
1251        Ok(())
1252    }
1253
1254    async fn save_background_state(&self) -> Result<()> {
1255        let records = {
1256            let state = self.state.read().await;
1257            state
1258                .background_children
1259                .values()
1260                .cloned()
1261                .map(BackgroundRecord::into_persisted)
1262                .collect()
1263        };
1264        persist_background_state(&self.config.workspace_root, records)
1265    }
1266
1267    async fn find_spec(&self, candidate: &str) -> Option<SubagentSpec> {
1268        self.state
1269            .read()
1270            .await
1271            .discovered
1272            .effective
1273            .iter()
1274            .find(|spec| spec.is_subagent() && spec.matches_name(candidate))
1275            .cloned()
1276    }
1277
1278    async fn resolve_requested_spec(&self, requested: Option<&str>) -> Result<SubagentSpec> {
1279        let requested = requested.unwrap_or("default");
1280        self.find_spec(requested)
1281            .await
1282            .ok_or_else(|| anyhow!("Unknown subagent type {}", requested))
1283    }
1284
1285    async fn prepare_delegation_context(
1286        &self,
1287        requested_agent: Option<String>,
1288        items: &mut Vec<SubagentInputItem>,
1289        model: &mut Option<String>,
1290        tool_name: &'static str,
1291    ) -> Result<PreparedDelegationContext> {
1292        let state = self.state.read().await;
1293        sanitize_subagent_input_items(items);
1294        *model = normalize_requested_model_override(model.take(), &state.turn_hints.current_input);
1295        let requested_agent = if let Some(agent_type) = requested_agent {
1296            Some(agent_type)
1297        } else {
1298            match state.turn_hints.explicit_mentions.as_slice() {
1299                [] => None,
1300                [single] => Some(single.clone()),
1301                mentions => {
1302                    bail!(
1303                        "{} omitted agent_type, but the user explicitly selected multiple agents: {}. Specify agent_type explicitly.",
1304                        tool_name,
1305                        mentions.join(", ")
1306                    );
1307                }
1308            }
1309        };
1310        Ok(PreparedDelegationContext {
1311            requested_agent,
1312            explicit_mentions: state.turn_hints.explicit_mentions.clone(),
1313            explicit_request: state.turn_hints.explicit_request,
1314            current_input: state.turn_hints.current_input.clone(),
1315        })
1316    }
1317
1318    fn prepare_delegation_prompt(
1319        &self,
1320        spec: &SubagentSpec,
1321        delegation: &PreparedDelegationContext,
1322        model: &mut Option<String>,
1323        message: &Option<String>,
1324        items: &[SubagentInputItem],
1325        tool_name: &'static str,
1326        launch_phrase: &'static str,
1327        ignored_model_warning: &'static str,
1328    ) -> Result<String> {
1329        if let Some(explicit) = delegation.explicit_mentions.first()
1330            && delegation.explicit_mentions.len() == 1
1331            && !spec.matches_name(explicit)
1332        {
1333            bail!(
1334                "{} requested agent_type '{}', but the user explicitly selected '{}'. Use the selected agent or ask the user to clarify.",
1335                tool_name,
1336                spec.name,
1337                explicit
1338            );
1339        }
1340        if !spec.is_read_only()
1341            && !delegation.explicit_request
1342            && delegation.requested_agent.is_none()
1343        {
1344            bail!(
1345                "{} 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.",
1346                tool_name,
1347                spec.name
1348            );
1349        }
1350        if spec.is_read_only()
1351            && !self.config.vt_cfg.subagents.auto_delegate_read_only
1352            && !delegation.explicit_request
1353        {
1354            bail!(
1355                "{} cannot proactively launch read-only agent '{}' because `subagents.auto_delegate_read_only` is disabled and the current user turn did not explicitly request delegation.",
1356                tool_name,
1357                spec.name
1358            );
1359        }
1360        if let Some(requested_model) = model.as_deref()
1361            && !contains_explicit_model_request(&delegation.current_input, requested_model)
1362        {
1363            tracing::warn!(
1364                agent_name = spec.name.as_str(),
1365                requested_model = requested_model.trim(),
1366                "{ignored_model_warning}"
1367            );
1368            *model = None;
1369        }
1370        let prompt = request_prompt(message, items)
1371            .or_else(|| spec.initial_prompt.clone())
1372            .filter(|value| !value.trim().is_empty())
1373            .ok_or_else(|| anyhow!("{tool_name} requires a task message or items"))?;
1374        if delegated_task_requires_clarification(&prompt) {
1375            bail!(
1376                "{} task for '{}' is too vague ('{}'). Ask the user for a specific delegated task before {}.",
1377                tool_name,
1378                spec.name,
1379                prompt.trim(),
1380                launch_phrase
1381            );
1382        }
1383        Ok(prompt)
1384    }
1385
1386    fn active_background_launch_conflicts(
1387        record: &BackgroundRecord,
1388        prompt: &str,
1389        max_turns: Option<usize>,
1390        model_override: Option<&str>,
1391        reasoning_override: Option<&str>,
1392    ) -> Vec<&'static str> {
1393        let mut conflicts = Vec::new();
1394        if record.prompt != prompt {
1395            conflicts.push("prompt");
1396        }
1397        if record.max_turns != max_turns {
1398            conflicts.push("max_turns");
1399        }
1400        if record.model_override.as_deref() != model_override {
1401            conflicts.push("model");
1402        }
1403        if record.reasoning_override.as_deref() != reasoning_override {
1404            conflicts.push("reasoning_effort");
1405        }
1406        conflicts
1407    }
1408
1409    async fn spawn_with_spec(
1410        &self,
1411        spec: SubagentSpec,
1412        prompt: String,
1413        fork_context: bool,
1414        background: bool,
1415        max_turns: Option<usize>,
1416        model_override: Option<String>,
1417        reasoning_override: Option<String>,
1418    ) -> Result<SubagentStatusEntry> {
1419        if !self.config.vt_cfg.subagents.enabled {
1420            bail!("Subagents are disabled by configuration");
1421        }
1422        if self.config.depth.saturating_add(1) > self.config.vt_cfg.subagents.max_depth {
1423            bail!(
1424                "Subagent depth limit reached (max_depth={})",
1425                self.config.vt_cfg.subagents.max_depth
1426            );
1427        }
1428        if spec.isolation.as_deref() == Some("worktree") {
1429            bail!("Subagent isolation=worktree is not supported in this VT Code build");
1430        }
1431
1432        let active_count = {
1433            let state = self.state.read().await;
1434            state
1435                .children
1436                .values()
1437                .filter(|record| {
1438                    matches!(
1439                        record.status,
1440                        SubagentStatus::Queued | SubagentStatus::Running | SubagentStatus::Waiting
1441                    )
1442                })
1443                .count()
1444        };
1445        let effective_max_concurrent = self
1446            .config
1447            .vt_cfg
1448            .subagents
1449            .max_concurrent
1450            .min(SUBAGENT_HARD_CONCURRENCY_LIMIT);
1451        if active_count >= effective_max_concurrent {
1452            bail!(
1453                "Subagent concurrency limit reached (max_concurrent={})",
1454                effective_max_concurrent
1455            );
1456        }
1457        let is_background_child = background;
1458        let child_max_turns =
1459            normalize_background_child_max_turns(max_turns.or(spec.max_turns), is_background_child);
1460        let (_, _, effective_config) = prepare_child_runtime_config(
1461            &self.config.vt_cfg,
1462            &spec,
1463            self.config.parent_model.as_str(),
1464            self.config.parent_provider.as_str(),
1465            self.config.parent_reasoning_effort,
1466            child_max_turns,
1467            model_override.as_deref(),
1468            reasoning_override.as_deref(),
1469            resolve_effective_subagent_model,
1470        )?;
1471
1472        let id = format!(
1473            "agent-{}-{}",
1474            sanitize_component(spec.name.as_str()),
1475            Utc::now().format("%Y%m%dT%H%M%S%3fZ")
1476        );
1477        let parent_session_id = self.parent_session_id.read().await.clone();
1478        let session_id = format!(
1479            "{}-{}",
1480            sanitize_component(parent_session_id.as_str()),
1481            sanitize_component(id.as_str())
1482        );
1483        let display_label = subagent_display_label(&spec);
1484        let notify = Arc::new(Notify::new());
1485        let mut state = self.state.write().await;
1486        let initial_messages = if fork_context {
1487            state.parent_messages.clone()
1488        } else {
1489            Vec::new()
1490        };
1491        let entry = ChildRecord {
1492            id: id.clone(),
1493            session_id,
1494            parent_thread_id: parent_session_id,
1495            spec: spec.clone(),
1496            display_label,
1497            status: SubagentStatus::Queued,
1498            background: is_background_child,
1499            depth: self.config.depth.saturating_add(1),
1500            created_at: Utc::now(),
1501            updated_at: Utc::now(),
1502            completed_at: None,
1503            summary: None,
1504            error: None,
1505            archive_metadata: None,
1506            archive_path: None,
1507            transcript_path: None,
1508            effective_config: Some(effective_config),
1509            stored_messages: initial_messages,
1510            last_prompt: Some(prompt.clone()),
1511            queued_prompts: VecDeque::from([prompt]),
1512            max_turns: child_max_turns,
1513            model_override,
1514            reasoning_override,
1515            thread_handle: None,
1516            handle: None,
1517            notify,
1518        };
1519        state.children.insert(id.clone(), entry);
1520        drop(state);
1521
1522        self.launch_child(id.as_str()).await?;
1523        self.status_for(&id).await
1524    }
1525
1526    async fn restart_child(&self, target: &str) -> Result<()> {
1527        let has_queued_input = {
1528            let mut state = self.state.write().await;
1529            let record = state
1530                .children
1531                .get_mut(target)
1532                .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1533            if record.queued_prompts.is_empty()
1534                && let Some(prompt) = record.last_prompt.clone()
1535            {
1536                record.queued_prompts.push_back(prompt);
1537            }
1538            !record.queued_prompts.is_empty()
1539        };
1540        if !has_queued_input {
1541            bail!("Subagent {} has no queued input", target);
1542        }
1543        self.launch_child(target).await
1544    }
1545
1546    async fn launch_child(&self, child_id: &str) -> Result<()> {
1547        let controller = self.clone();
1548        let target = child_id.to_string();
1549        let handle = tokio::spawn(async move {
1550            controller.child_loop(&target).await;
1551        });
1552        let mut state = self.state.write().await;
1553        let record = state
1554            .children
1555            .get_mut(child_id)
1556            .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1557        record.handle = Some(handle);
1558        record.status = SubagentStatus::Queued;
1559        record.updated_at = Utc::now();
1560        Ok(())
1561    }
1562
1563    async fn child_loop(&self, child_id: &str) {
1564        loop {
1565            let request = {
1566                let mut state = self.state.write().await;
1567                let Some(record) = state.children.get_mut(child_id) else {
1568                    return;
1569                };
1570                record.dequeue_run()
1571            };
1572            let Some(request) = request else {
1573                let mut state = self.state.write().await;
1574                if let Some(record) = state.children.get_mut(child_id) {
1575                    record.handle = None;
1576                    record.updated_at = Utc::now();
1577                }
1578                return;
1579            };
1580
1581            let execute = self
1582                .run_child_once(
1583                    child_id,
1584                    request.prompt,
1585                    request.max_turns,
1586                    request.model_override,
1587                    request.reasoning_override,
1588                )
1589                .await;
1590
1591            let (has_more_work, hook_payload) = {
1592                let mut state = self.state.write().await;
1593                let Some(record) = state.children.get_mut(child_id) else {
1594                    return;
1595                };
1596                record.updated_at = Utc::now();
1597                let has_more_work = record.apply_result(execute);
1598                let hook_payload = (!has_more_work).then(|| record.build_hook_payload());
1599                (has_more_work, hook_payload)
1600            };
1601
1602            if let Some((
1603                parent_session_id,
1604                child_thread_id,
1605                agent_name,
1606                display_label,
1607                background,
1608                status,
1609                transcript_path,
1610            )) = hook_payload
1611                && let Some(hooks) = self.lifecycle_hooks.as_ref()
1612                && let Err(err) = hooks
1613                    .run_subagent_stop(
1614                        &parent_session_id,
1615                        &child_thread_id,
1616                        &agent_name,
1617                        &display_label,
1618                        background,
1619                        &status,
1620                        transcript_path.as_deref(),
1621                    )
1622                    .await
1623            {
1624                tracing::warn!(
1625                    child_id,
1626                    error = %err,
1627                    "Failed to run subagent stop hooks"
1628                );
1629            }
1630
1631            if has_more_work {
1632                continue;
1633            } else {
1634                let mut state = self.state.write().await;
1635                if let Some(record) = state.children.get_mut(child_id) {
1636                    record.handle = None;
1637                    record.updated_at = Utc::now();
1638                }
1639                return;
1640            }
1641        }
1642    }
1643
1644    async fn run_child_once(
1645        &self,
1646        child_id: &str,
1647        prompt: String,
1648        max_turns: Option<usize>,
1649        model_override: Option<String>,
1650        reasoning_override: Option<String>,
1651    ) -> Result<ChildRunResult> {
1652        let (spec, session_id, bootstrap_messages, display_label, background) = {
1653            let mut state = self.state.write().await;
1654            let record = state
1655                .children
1656                .get_mut(child_id)
1657                .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1658            record.status = SubagentStatus::Running;
1659            record.updated_at = Utc::now();
1660            (
1661                record.spec.clone(),
1662                record.session_id.clone(),
1663                record.stored_messages.clone(),
1664                record.display_label.clone(),
1665                record.background,
1666            )
1667        };
1668
1669        let (resolved_model, child_reasoning_effort, child_cfg) = prepare_child_runtime_config(
1670            &self.config.vt_cfg,
1671            &spec,
1672            self.config.parent_model.as_str(),
1673            self.config.parent_provider.as_str(),
1674            self.config.parent_reasoning_effort,
1675            max_turns,
1676            model_override.as_deref(),
1677            reasoning_override.as_deref(),
1678            resolve_effective_subagent_model,
1679        )?;
1680        let parent_session_id = self.parent_session_id.read().await.clone();
1681
1682        let archive_metadata = build_subagent_archive_metadata(
1683            &self.config.workspace_root,
1684            child_cfg.agent.default_model.as_str(),
1685            child_cfg.agent.provider.as_str(),
1686            child_cfg.agent.theme.as_str(),
1687            child_reasoning_effort.as_str(),
1688            parent_session_id.as_str(),
1689            !bootstrap_messages.is_empty(),
1690        );
1691        let bootstrap = ThreadBootstrap::new(Some(archive_metadata.clone()))
1692            .with_messages(bootstrap_messages.clone());
1693        let archive = if let Some(listing) = find_session_by_identifier(&session_id).await? {
1694            SessionArchive::resume_from_listing(&listing, archive_metadata.clone())
1695        } else {
1696            SessionArchive::new_with_identifier(archive_metadata.clone(), session_id.clone())
1697                .await?
1698        };
1699        checkpoint_subagent_archive_start(&archive, &bootstrap_messages).await?;
1700        let mut runner = AgentRunner::new_with_bootstrap(
1701            agent_type_for_spec(&spec),
1702            resolved_model,
1703            self.config.api_key.clone(),
1704            self.config.workspace_root.clone(),
1705            session_id.clone(),
1706            RunnerSettings {
1707                reasoning_effort: Some(child_reasoning_effort),
1708                verbosity: None,
1709            },
1710            None,
1711            bootstrap,
1712            Some(child_cfg.clone()),
1713            self.config.openai_chatgpt_auth.clone(),
1714        )
1715        .await?;
1716        runner.set_quiet(true);
1717        let thread_handle = runner.thread_handle();
1718        let archive_path = archive.path().to_path_buf();
1719
1720        {
1721            let mut state = self.state.write().await;
1722            let record = state
1723                .children
1724                .get_mut(child_id)
1725                .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1726            record.archive_metadata = Some(archive_metadata.clone());
1727            record.archive_path = Some(archive_path.clone());
1728            record.effective_config = Some(child_cfg.clone());
1729            record.thread_handle = Some(thread_handle.clone());
1730        }
1731        if let Some(hooks) = self.lifecycle_hooks.as_ref()
1732            && let Err(err) = hooks
1733                .run_subagent_start(
1734                    parent_session_id.as_str(),
1735                    thread_handle.thread_id().as_str(),
1736                    spec.name.as_str(),
1737                    &display_label,
1738                    background,
1739                    SubagentStatus::Running.as_str(),
1740                    Some(archive_path.as_path()),
1741                )
1742                .await
1743        {
1744            tracing::warn!(
1745                child_id,
1746                error = %err,
1747                "Failed to run subagent start hooks"
1748            );
1749        }
1750
1751        let filtered_tools = filter_child_tools(
1752            &spec,
1753            runner.build_universal_tools().await?,
1754            spec.is_read_only(),
1755        );
1756        let allowed_tools = filtered_tools
1757            .iter()
1758            .map(|tool| tool.function_name().to_string())
1759            .collect::<Vec<_>>();
1760        runner.set_tool_definitions_override(filtered_tools);
1761        runner.enable_full_auto(&allowed_tools).await;
1762
1763        let memory_appendix =
1764            load_memory_appendix(&self.config.workspace_root, spec.name.as_str(), spec.memory)?;
1765        let mut task = Task::new(
1766            format!("subagent-{}", spec.name),
1767            format!("Subagent {}", spec.name),
1768            prompt,
1769        );
1770        task.instructions = Some(compose_subagent_instructions(&spec, memory_appendix));
1771
1772        let results = runner.execute_task(&task, &[]).await?;
1773        let messages = runner.session_messages();
1774        let transcript_path =
1775            persist_child_archive(&archive, &messages, spec.name.as_str()).await?;
1776
1777        Ok(ChildRunResult {
1778            messages,
1779            summary: if results.summary.trim().is_empty() {
1780                results.outcome.description()
1781            } else {
1782                results.summary.clone()
1783            },
1784            outcome: results.outcome,
1785            transcript_path,
1786        })
1787    }
1788}
1789
1790fn sanitize_component(value: &str) -> String {
1791    value
1792        .chars()
1793        .map(|ch| {
1794            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1795                ch
1796            } else {
1797                '-'
1798            }
1799        })
1800        .collect::<String>()
1801        .trim_matches('-')
1802        .to_string()
1803}
1804
1805fn load_session_listing(
1806    path: &std::path::Path,
1807) -> Result<crate::utils::session_archive::SessionListing> {
1808    use anyhow::Context;
1809    let raw = std::fs::read_to_string(path)
1810        .with_context(|| format!("Failed to read session archive {}", path.display()))?;
1811    let snapshot: crate::utils::session_archive::SessionSnapshot = serde_json::from_str(&raw)
1812        .with_context(|| format!("Failed to parse session archive {}", path.display()))?;
1813    Ok(crate::utils::session_archive::SessionListing {
1814        path: path.to_path_buf(),
1815        snapshot,
1816    })
1817}
1818
1819async fn checkpoint_subagent_archive_start(
1820    archive: &SessionArchive,
1821    messages: &[Message],
1822) -> Result<()> {
1823    use crate::utils::session_archive::SessionMessage;
1824    let recent_messages = messages
1825        .iter()
1826        .map(SessionMessage::from)
1827        .collect::<Vec<_>>();
1828    archive
1829        .persist_progress_async(crate::utils::session_archive::SessionProgressArgs {
1830            total_messages: recent_messages.len(),
1831            distinct_tools: Vec::new(),
1832            recent_messages,
1833            turn_number: 1,
1834            token_usage: None,
1835            max_context_tokens: None,
1836            loaded_skills: Some(Vec::new()),
1837        })
1838        .await?;
1839    Ok(())
1840}
1841
1842async fn persist_child_archive(
1843    archive: &SessionArchive,
1844    messages: &[Message],
1845    agent_name: &str,
1846) -> Result<Option<PathBuf>> {
1847    use crate::utils::session_archive::SessionMessage;
1848    let transcript = messages
1849        .iter()
1850        .filter_map(transcript_line_from_message)
1851        .take(SUBAGENT_TRANSCRIPT_LINE_LIMIT)
1852        .collect::<Vec<_>>();
1853    let stored_messages = messages
1854        .iter()
1855        .map(SessionMessage::from)
1856        .collect::<Vec<_>>();
1857    let path = archive.finalize(
1858        transcript,
1859        stored_messages.len(),
1860        vec![agent_name.to_string()],
1861        stored_messages,
1862    )?;
1863    Ok(Some(path))
1864}
1865
1866fn transcript_line_from_message(message: &Message) -> Option<String> {
1867    let role = message.role.to_string();
1868    let content = message.content.trim();
1869    if content.is_empty() {
1870        return None;
1871    }
1872    Some(format!("{role}: {content}"))
1873}
1874
1875// ─── Tests ──────────────────────────────────────────────────────────────────
1876
1877#[cfg(test)]
1878mod tests {
1879    use super::*;
1880    use crate::config::PermissionMode;
1881    use crate::config::constants::models;
1882    use crate::config::constants::tools;
1883    use crate::config::models::{ModelId, Provider};
1884    use crate::llm::provider::ToolDefinition;
1885    use crate::tools::exec_session::ExecSessionManager;
1886    use crate::tools::registry::PtySessionManager;
1887    use anyhow::{Result, anyhow};
1888    use std::collections::BTreeMap;
1889    use std::collections::VecDeque;
1890    use std::path::PathBuf;
1891    use std::sync::Arc;
1892    use std::time::Duration;
1893    use tempfile::TempDir;
1894    use tokio::sync::Notify;
1895    use vtcode_config::{SubagentMcpServer, SubagentMemoryScope, SubagentSource, SubagentSpec};
1896
1897    fn test_controller_config(
1898        workspace_root: PathBuf,
1899        vt_cfg: VTCodeConfig,
1900    ) -> SubagentControllerConfig {
1901        let pty_sessions = PtySessionManager::new(workspace_root.clone(), vt_cfg.pty.clone());
1902        let exec_sessions = ExecSessionManager::new(workspace_root.clone(), pty_sessions.clone());
1903        SubagentControllerConfig {
1904            workspace_root,
1905            parent_session_id: "parent-session".to_string(),
1906            parent_model: models::openai::GPT_5_4.to_string(),
1907            parent_provider: "openai".to_string(),
1908            parent_reasoning_effort: ReasoningEffortLevel::Medium,
1909            api_key: "test-key".to_string(),
1910            vt_cfg,
1911            openai_chatgpt_auth: None,
1912            depth: 0,
1913            exec_sessions,
1914            pty_manager: pty_sessions.manager().clone(),
1915            managed_background_runtime: false,
1916        }
1917    }
1918
1919    fn test_child_record(
1920        id: &str,
1921        parent_thread_id: &str,
1922        spec: &SubagentSpec,
1923        status: SubagentStatus,
1924        depth: usize,
1925    ) -> ChildRecord {
1926        ChildRecord {
1927            id: id.to_string(),
1928            session_id: format!("session-{id}"),
1929            parent_thread_id: parent_thread_id.to_string(),
1930            spec: spec.clone(),
1931            display_label: subagent_display_label(spec),
1932            status,
1933            background: false,
1934            depth,
1935            created_at: Utc::now(),
1936            updated_at: Utc::now(),
1937            completed_at: status.is_terminal().then_some(Utc::now()),
1938            summary: None,
1939            error: None,
1940            archive_metadata: None,
1941            archive_path: None,
1942            transcript_path: None,
1943            effective_config: Some(VTCodeConfig::default()),
1944            stored_messages: Vec::new(),
1945            last_prompt: Some(format!("prompt-{id}")),
1946            queued_prompts: VecDeque::new(),
1947            max_turns: None,
1948            model_override: None,
1949            reasoning_override: None,
1950            thread_handle: None,
1951            handle: None,
1952            notify: Arc::new(Notify::new()),
1953        }
1954    }
1955
1956    fn write_test_background_subagent(workspace_root: &std::path::Path) {
1957        let agent_dir = workspace_root.join(".vtcode/agents");
1958        std::fs::create_dir_all(&agent_dir).expect("agent dir");
1959        std::fs::write(
1960            agent_dir.join("background-demo.md"),
1961            r#"---
1962name: background-demo
1963description: Minimal demo agent for the managed background subprocess flow.
1964tools:
1965  - unified_exec
1966background: true
1967maxTurns: 2
1968initialPrompt: Report readiness once.
1969---
1970
1971Run the managed background demo.
1972"#,
1973        )
1974        .expect("write background agent");
1975    }
1976
1977    fn write_test_primary_agent(workspace_root: &std::path::Path) {
1978        let agent_dir = workspace_root.join(".vtcode/agents");
1979        std::fs::create_dir_all(&agent_dir).expect("agent dir");
1980        std::fs::write(
1981            agent_dir.join("duck.md"),
1982            r#"---
1983name: duck
1984description: Discussion controller.
1985mode: primary
1986permissionMode: plan
1987---
1988
1989Discuss before implementation.
1990"#,
1991        )
1992        .expect("write primary agent");
1993    }
1994
1995    fn write_test_read_only_subagent(workspace_root: &std::path::Path) {
1996        let agent_dir = workspace_root.join(".vtcode/agents");
1997        std::fs::create_dir_all(&agent_dir).expect("agent dir");
1998        std::fs::write(
1999            agent_dir.join("readonly-demo.md"),
2000            r#"---
2001name: readonly-demo
2002description: Read-only test child agent.
2003tools:
2004  - read_file
2005permissionMode: plan
2006---
2007
2008Inspect the repository.
2009"#,
2010        )
2011        .expect("write read-only agent");
2012    }
2013
2014    #[test]
2015    fn request_prompt_prefers_message() {
2016        let request = SpawnAgentRequest {
2017            message: Some("hello".to_string()),
2018            ..SpawnAgentRequest::default()
2019        };
2020        assert_eq!(
2021            request_prompt(&request.message, &request.items).as_deref(),
2022            Some("hello")
2023        );
2024    }
2025
2026    #[test]
2027    fn delegated_task_requires_clarification_for_vague_prompt() {
2028        assert!(delegated_task_requires_clarification("report"));
2029        assert!(delegated_task_requires_clarification("report findings"));
2030        assert!(!delegated_task_requires_clarification(
2031            "review current code changes"
2032        ));
2033    }
2034
2035    #[test]
2036    fn resolve_subagent_model_maps_aliases() {
2037        let cfg = VTCodeConfig::default();
2038        let resolved = resolve_subagent_model(
2039            &cfg,
2040            models::anthropic::CLAUDE_SONNET_4_6,
2041            "anthropic",
2042            Some("haiku"),
2043            "explorer",
2044        )
2045        .expect("resolve model");
2046        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2047    }
2048
2049    #[test]
2050    fn resolve_subagent_model_defaults_to_parent_when_omitted() {
2051        let cfg = VTCodeConfig::default();
2052        let resolved = resolve_subagent_model(
2053            &cfg,
2054            models::ollama::GPT_OSS_120B_CLOUD,
2055            "ollama",
2056            None,
2057            "worker",
2058        )
2059        .expect("resolve model");
2060        assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2061    }
2062
2063    #[test]
2064    fn resolve_subagent_model_accepts_dotted_claude_aliases_for_anthropic() {
2065        let cfg = VTCodeConfig::default();
2066        let resolved =
2067            resolve_subagent_model(&cfg, "claude-haiku-4.5", "anthropic", None, "worker")
2068                .expect("resolve model");
2069        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2070    }
2071
2072    #[test]
2073    fn resolve_subagent_model_falls_back_to_copilot_default_for_unsupported_inherit_model() {
2074        let cfg = VTCodeConfig::default();
2075        let resolved = resolve_subagent_model(&cfg, "claude-haiku-4.5", "copilot", None, "worker")
2076            .expect("resolve model");
2077        assert_eq!(
2078            resolved,
2079            ModelId::default_orchestrator_for_provider(Provider::Copilot)
2080        );
2081    }
2082
2083    #[test]
2084    fn resolve_effective_subagent_model_uses_explicit_inherit_override() {
2085        let cfg = VTCodeConfig::default();
2086        let resolved = resolve_effective_subagent_model(
2087            &cfg,
2088            models::anthropic::CLAUDE_SONNET_4_6,
2089            "anthropic",
2090            Some("inherit"),
2091            Some("haiku"),
2092            "worker",
2093        )
2094        .expect("resolve model");
2095        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_SONNET_4_6);
2096    }
2097
2098    #[test]
2099    fn resolve_effective_subagent_model_falls_back_to_parent_on_invalid_override() {
2100        let cfg = VTCodeConfig::default();
2101        let resolved = resolve_effective_subagent_model(
2102            &cfg,
2103            models::ollama::GPT_OSS_120B_CLOUD,
2104            "ollama",
2105            Some("not-a-real-model"),
2106            None,
2107            "rust-engineer",
2108        )
2109        .expect("resolve model");
2110        assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2111    }
2112
2113    #[test]
2114    fn resolve_subagent_small_model_rejects_cross_provider_configured_lightweight_model() {
2115        let mut cfg = VTCodeConfig::default();
2116        cfg.agent.small_model.model = models::anthropic::CLAUDE_HAIKU_4_5.to_string();
2117
2118        let resolved = resolve_subagent_model(
2119            &cfg,
2120            models::openai::GPT_5_4,
2121            "openai",
2122            Some("small"),
2123            "worker",
2124        )
2125        .expect("resolve model");
2126
2127        assert_eq!(resolved, ModelId::GPT54Mini);
2128    }
2129
2130    #[test]
2131    fn resolve_effective_subagent_model_falls_back_to_spec_model_on_invalid_override() {
2132        let cfg = VTCodeConfig::default();
2133        let resolved = resolve_effective_subagent_model(
2134            &cfg,
2135            models::anthropic::CLAUDE_SONNET_4_6,
2136            "anthropic",
2137            Some("not-a-real-model"),
2138            Some("haiku"),
2139            "reviewer",
2140        )
2141        .expect("resolve model");
2142        assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2143    }
2144
2145    #[test]
2146    fn background_record_ids_are_stable_and_sanitized() {
2147        assert_eq!(
2148            background_record_id("Rust Engineer"),
2149            "background-Rust-Engineer"
2150        );
2151        assert_eq!(
2152            background_record_id("plugin:reviewer/default"),
2153            "background-plugin-reviewer-default"
2154        );
2155    }
2156
2157    #[test]
2158    fn background_subagent_command_includes_expected_flags() {
2159        let workspace = std::env::current_dir().expect("workspace");
2160        let command = build_background_subagent_command(
2161            &workspace,
2162            "rust-engineer",
2163            "session-parent",
2164            "session-child",
2165            "Inspect the repo",
2166            Some(7),
2167            Some("gpt-5.4-mini"),
2168            Some("high"),
2169        )
2170        .expect("background command");
2171
2172        assert!(command.len() >= 15);
2173        assert_eq!(command[1], "background-subagent");
2174        assert!(
2175            command
2176                .windows(2)
2177                .any(|pair| pair == ["--agent-name", "rust-engineer"])
2178        );
2179        assert!(
2180            command
2181                .windows(2)
2182                .any(|pair| pair == ["--parent-session-id", "session-parent"])
2183        );
2184        assert!(
2185            command
2186                .windows(2)
2187                .any(|pair| pair == ["--session-id", "session-child"])
2188        );
2189        assert!(
2190            command
2191                .windows(2)
2192                .any(|pair| pair == ["--prompt", "Inspect the repo"])
2193        );
2194        assert!(command.windows(2).any(|pair| pair == ["--max-turns", "7"]));
2195        assert!(
2196            command
2197                .windows(2)
2198                .any(|pair| pair == ["--model-override", "gpt-5.4-mini"])
2199        );
2200        assert!(
2201            command
2202                .windows(2)
2203                .any(|pair| pair == ["--reasoning-override", "high"])
2204        );
2205    }
2206
2207    #[test]
2208    fn resolve_effective_subagent_model_still_errors_on_invalid_spec_model() {
2209        let cfg = VTCodeConfig::default();
2210        let err = resolve_effective_subagent_model(
2211            &cfg,
2212            models::anthropic::CLAUDE_SONNET_4_6,
2213            "anthropic",
2214            None,
2215            Some("not-a-real-model"),
2216            "reviewer",
2217        )
2218        .expect_err("invalid spec model should fail");
2219        assert!(err.to_string().contains("Failed to resolve model"));
2220    }
2221
2222    async fn wait_for_effective_model(
2223        controller: &SubagentController,
2224        target: &str,
2225    ) -> Result<String> {
2226        for _ in 0..50 {
2227            if let Ok(snapshot) = controller.snapshot_for_thread(target).await {
2228                return Ok(snapshot.effective_config.agent.default_model);
2229            }
2230            tokio::time::sleep(Duration::from_millis(10)).await;
2231        }
2232
2233        Err(anyhow!(
2234            "Subagent {target} did not capture an effective runtime configuration in time"
2235        ))
2236    }
2237
2238    fn read_only_test_spec(name: &str) -> SubagentSpec {
2239        SubagentSpec {
2240            name: name.to_string(),
2241            description: "test".to_string(),
2242            prompt: String::new(),
2243            tools: Some(vec![tools::READ_FILE.to_string()]),
2244            disallowed_tools: Vec::new(),
2245            model: None,
2246            color: None,
2247            reasoning_effort: None,
2248            permission_mode: Some(PermissionMode::Plan),
2249            skills: Vec::new(),
2250            mcp_servers: Vec::new(),
2251            hooks: None,
2252            background: false,
2253            mode: vtcode_config::AgentMode::Subagent,
2254            max_turns: None,
2255            nickname_candidates: Vec::new(),
2256            initial_prompt: None,
2257            memory: None,
2258            isolation: None,
2259            aliases: Vec::new(),
2260            source: SubagentSource::Builtin,
2261            file_path: None,
2262            warnings: Vec::new(),
2263        }
2264    }
2265
2266    #[test]
2267    fn filter_child_tools_removes_subagent_tools_in_children() {
2268        let defs = vec![
2269            ToolDefinition::function(
2270                tools::SPAWN_AGENT.to_string(),
2271                "Spawn".to_string(),
2272                serde_json::json!({"type": "object"}),
2273            ),
2274            ToolDefinition::function(
2275                tools::UNIFIED_SEARCH.to_string(),
2276                "Search".to_string(),
2277                serde_json::json!({"type": "object"}),
2278            ),
2279            ToolDefinition::function(
2280                tools::LIST_FILES.to_string(),
2281                "List".to_string(),
2282                serde_json::json!({"type": "object"}),
2283            ),
2284            ToolDefinition::function(
2285                tools::REQUEST_USER_INPUT.to_string(),
2286                "Ask".to_string(),
2287                serde_json::json!({"type": "object"}),
2288            ),
2289        ];
2290        let spec = vtcode_config::builtin_subagents()
2291            .into_iter()
2292            .find(|spec| spec.name == "explorer")
2293            .expect("explorer");
2294        let filtered = filter_child_tools(&spec, defs, true);
2295        assert_eq!(filtered.len(), 1);
2296        assert_eq!(filtered[0].function_name(), tools::UNIFIED_SEARCH);
2297    }
2298
2299    #[test]
2300    fn filter_child_tools_keeps_unified_exec_for_shell_capable_agents() {
2301        let defs = vec![
2302            ToolDefinition::function(
2303                tools::UNIFIED_EXEC.to_string(),
2304                "Exec".to_string(),
2305                serde_json::json!({"type": "object"}),
2306            ),
2307            ToolDefinition::function(
2308                tools::UNIFIED_SEARCH.to_string(),
2309                "Search".to_string(),
2310                serde_json::json!({"type": "object"}),
2311            ),
2312        ];
2313        let spec = SubagentSpec {
2314            name: "shell-demo".to_string(),
2315            description: "test".to_string(),
2316            prompt: String::new(),
2317            tools: Some(vec![
2318                tools::UNIFIED_EXEC.to_string(),
2319                tools::UNIFIED_SEARCH.to_string(),
2320            ]),
2321            disallowed_tools: Vec::new(),
2322            model: None,
2323            color: None,
2324            reasoning_effort: None,
2325            permission_mode: None,
2326            skills: Vec::new(),
2327            mcp_servers: Vec::new(),
2328            hooks: None,
2329            background: false,
2330            mode: vtcode_config::AgentMode::Subagent,
2331            max_turns: None,
2332            nickname_candidates: Vec::new(),
2333            initial_prompt: None,
2334            memory: None,
2335            isolation: None,
2336            aliases: Vec::new(),
2337            source: SubagentSource::Builtin,
2338            file_path: None,
2339            warnings: Vec::new(),
2340        };
2341
2342        let filtered = filter_child_tools(&spec, defs, spec.is_read_only());
2343        assert_eq!(filtered.len(), 2);
2344        assert_eq!(filtered[0].function_name(), tools::UNIFIED_EXEC);
2345        assert_eq!(filtered[1].function_name(), tools::UNIFIED_SEARCH);
2346    }
2347
2348    #[test]
2349    fn build_child_config_clamps_permissions_and_intersects_allowed_tools() {
2350        let mut parent = VTCodeConfig::default();
2351        parent.permissions.default_mode = PermissionMode::Default;
2352        parent.permissions.allow = vec![
2353            tools::READ_FILE.to_string(),
2354            tools::UNIFIED_SEARCH.to_string(),
2355        ];
2356        parent.permissions.deny = vec![tools::UNIFIED_EXEC.to_string()];
2357
2358        let mut spec = vtcode_config::builtin_subagents()
2359            .into_iter()
2360            .find(|spec| spec.name == "worker")
2361            .expect("worker");
2362        spec.permission_mode = Some(PermissionMode::BypassPermissions);
2363        spec.tools = Some(vec![
2364            tools::SPAWN_AGENT.to_string(),
2365            tools::UNIFIED_SEARCH.to_string(),
2366            tools::READ_FILE.to_string(),
2367        ]);
2368
2369        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2370        assert_eq!(child.permissions.default_mode, PermissionMode::Default);
2371        assert_eq!(
2372            child.permissions.allow,
2373            vec![
2374                tools::READ_FILE.to_string(),
2375                tools::UNIFIED_SEARCH.to_string()
2376            ]
2377        );
2378        assert!(
2379            child
2380                .permissions
2381                .deny
2382                .contains(&tools::UNIFIED_EXEC.to_string())
2383        );
2384        assert!(
2385            child
2386                .permissions
2387                .deny
2388                .contains(&tools::SPAWN_AGENT.to_string())
2389        );
2390    }
2391
2392    #[test]
2393    fn build_child_config_preserves_matching_rule_and_exact_tool_ids() {
2394        let mut parent = VTCodeConfig::default();
2395        parent.permissions.allow = vec![
2396            "Read(/docs/**)".to_string(),
2397            "mcp::context7::search".to_string(),
2398            tools::READ_FILE.to_string(),
2399        ];
2400
2401        let mut spec = vtcode_config::builtin_subagents()
2402            .into_iter()
2403            .find(|spec| spec.name == "worker")
2404            .expect("worker");
2405        spec.tools = Some(vec![
2406            "mcp::context7::search".to_string(),
2407            tools::UNIFIED_EXEC.to_string(),
2408            tools::READ_FILE.to_string(),
2409        ]);
2410
2411        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2412
2413        assert_eq!(
2414            child.permissions.allow,
2415            vec![
2416                "Read(/docs/**)".to_string(),
2417                "mcp::context7::search".to_string(),
2418                tools::READ_FILE.to_string()
2419            ]
2420        );
2421    }
2422
2423    #[test]
2424    fn build_child_config_preserves_parent_rule_shaped_allowlist() {
2425        let mut parent = VTCodeConfig::default();
2426        parent.permissions.allow = vec!["Read".to_string()];
2427
2428        let mut spec = vtcode_config::builtin_subagents()
2429            .into_iter()
2430            .find(|spec| spec.name == "worker")
2431            .expect("worker");
2432        spec.tools = Some(vec![
2433            tools::READ_FILE.to_string(),
2434            tools::UNIFIED_SEARCH.to_string(),
2435            tools::UNIFIED_EXEC.to_string(),
2436        ]);
2437
2438        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2439
2440        assert_eq!(child.permissions.allow, vec!["Read".to_string()]);
2441    }
2442
2443    #[test]
2444    fn build_child_config_promotes_single_turn_budget_to_recovery_budget() {
2445        let parent = VTCodeConfig::default();
2446        let spec = vtcode_config::builtin_subagents()
2447            .into_iter()
2448            .find(|spec| spec.name == "worker")
2449            .expect("worker");
2450
2451        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, Some(1));
2452
2453        assert_eq!(child.automation.full_auto.max_turns, SUBAGENT_MIN_MAX_TURNS);
2454    }
2455
2456    #[test]
2457    fn background_children_get_a_higher_turn_floor() {
2458        assert_eq!(normalize_background_child_max_turns(Some(2), true), Some(4));
2459        assert_eq!(normalize_background_child_max_turns(Some(3), true), Some(4));
2460        assert_eq!(normalize_background_child_max_turns(Some(4), true), Some(4));
2461    }
2462
2463    #[test]
2464    fn foreground_children_keep_the_existing_turn_floor() {
2465        assert_eq!(
2466            normalize_background_child_max_turns(Some(1), false),
2467            Some(SUBAGENT_MIN_MAX_TURNS)
2468        );
2469        assert_eq!(
2470            normalize_background_child_max_turns(Some(2), false),
2471            Some(2)
2472        );
2473        assert_eq!(normalize_background_child_max_turns(None, true), None);
2474    }
2475
2476    #[test]
2477    fn build_child_config_merges_inline_mcp_provider() {
2478        let parent = VTCodeConfig::default();
2479        let mut spec = vtcode_config::builtin_subagents()
2480            .into_iter()
2481            .find(|spec| spec.name == "default")
2482            .expect("default");
2483        spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
2484            "playwright".to_string(),
2485            serde_json::json!({
2486                "type": "stdio",
2487                "command": "npx",
2488                "args": ["-y", "@playwright/mcp@latest"],
2489            }),
2490        )]))];
2491
2492        let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2493        let provider = child
2494            .mcp
2495            .providers
2496            .iter()
2497            .find(|provider| provider.name == "playwright")
2498            .expect("playwright provider");
2499        assert_eq!(provider.name, "playwright");
2500    }
2501
2502    #[test]
2503    fn explicit_delegation_request_detects_mentions_and_keywords() {
2504        let direct_mentions = extract_explicit_agent_mentions("@agent-worker fix the issue", &[]);
2505        assert!(contains_explicit_delegation_request(
2506            "@agent-worker fix the issue",
2507            direct_mentions.as_slice()
2508        ));
2509        let no_mentions = extract_explicit_agent_mentions("delegate this in parallel", &[]);
2510        assert!(contains_explicit_delegation_request(
2511            "delegate this in parallel",
2512            no_mentions.as_slice()
2513        ));
2514        let empty_mentions = extract_explicit_agent_mentions("review the repository", &[]);
2515        assert!(!contains_explicit_delegation_request(
2516            "review the repository",
2517            empty_mentions.as_slice()
2518        ));
2519    }
2520
2521    #[test]
2522    fn explicit_agent_mentions_detect_natural_language_selection() {
2523        let rust_engineer = read_only_test_spec("rust-engineer");
2524        assert_eq!(
2525            extract_explicit_agent_mentions(
2526                "use rust-engineer agent to review current code",
2527                &[rust_engineer]
2528            ),
2529            vec!["rust-engineer".to_string()]
2530        );
2531    }
2532
2533    #[test]
2534    fn explicit_agent_mentions_detect_looser_subagent_selection() {
2535        let background_demo = read_only_test_spec("background-demo");
2536        assert_eq!(
2537            extract_explicit_agent_mentions(
2538                "use background-demo and run the subagent",
2539                &[background_demo]
2540            ),
2541            vec!["background-demo".to_string()]
2542        );
2543    }
2544
2545    #[test]
2546    fn explicit_agent_mentions_detect_run_subagent_selection() {
2547        let rust_engineer = read_only_test_spec("rust-engineer");
2548        assert_eq!(
2549            extract_explicit_agent_mentions(
2550                "run rust-engineer subagent and review changes",
2551                &[rust_engineer]
2552            ),
2553            vec!["rust-engineer".to_string()]
2554        );
2555    }
2556
2557    #[test]
2558    fn explicit_agent_mentions_ignore_primary_only_agents() {
2559        let mut duck = read_only_test_spec("duck");
2560        duck.mode = vtcode_config::AgentMode::Primary;
2561
2562        assert_eq!(
2563            extract_explicit_agent_mentions("@agent-duck discuss the task", &[duck.clone()]),
2564            Vec::<String>::new()
2565        );
2566        assert_eq!(
2567            extract_explicit_agent_mentions("run duck agent and discuss the task", &[duck]),
2568            Vec::<String>::new()
2569        );
2570    }
2571
2572    #[test]
2573    fn explicit_model_request_detects_aliases_and_full_ids() {
2574        assert!(contains_explicit_model_request(
2575            "delegate this using gpt-5.4-mini",
2576            "gpt-5.4-mini"
2577        ));
2578        assert!(contains_explicit_model_request(
2579            "use the worker subagent with haiku",
2580            "haiku"
2581        ));
2582        assert!(contains_explicit_model_request(
2583            "run this with the small model",
2584            "small"
2585        ));
2586        assert!(!contains_explicit_model_request(
2587            "delegate this small cleanup task",
2588            "small"
2589        ));
2590        assert!(!contains_explicit_model_request(
2591            "delegate this task",
2592            "gpt-5.4-mini"
2593        ));
2594    }
2595
2596    #[test]
2597    fn normalize_requested_model_override_drops_default_like_values() {
2598        assert_eq!(
2599            normalize_requested_model_override(Some("default".to_string()), "delegate this task"),
2600            None
2601        );
2602        assert_eq!(
2603            normalize_requested_model_override(Some(" inherit ".to_string()), "delegate this task"),
2604            None
2605        );
2606        assert_eq!(
2607            normalize_requested_model_override(
2608                Some(" inherit ".to_string()),
2609                "delegate this task using inherit"
2610            ),
2611            Some("inherit".to_string())
2612        );
2613    }
2614
2615    #[test]
2616    fn sanitize_subagent_input_items_drops_empty_fields() {
2617        let mut items = vec![
2618            SubagentInputItem {
2619                item_type: Some("text".to_string()),
2620                text: Some("  Workspace: /tmp/repo  ".to_string()),
2621                path: Some(String::new()),
2622                name: Some(" ".to_string()),
2623                image_url: None,
2624            },
2625            SubagentInputItem {
2626                item_type: Some("text".to_string()),
2627                text: Some("   ".to_string()),
2628                path: Some(String::new()),
2629                name: None,
2630                image_url: None,
2631            },
2632        ];
2633
2634        sanitize_subagent_input_items(&mut items);
2635
2636        assert_eq!(items.len(), 1);
2637        assert_eq!(items[0].text.as_deref(), Some("Workspace: /tmp/repo"));
2638        assert!(items[0].path.is_none());
2639        assert!(items[0].name.is_none());
2640    }
2641
2642    #[tokio::test]
2643    async fn controller_exposes_builtin_specs() {
2644        let temp = TempDir::new().expect("tempdir");
2645        let controller = SubagentController::new(test_controller_config(
2646            temp.path().to_path_buf(),
2647            VTCodeConfig::default(),
2648        ))
2649        .await
2650        .expect("controller");
2651        let specs = controller.effective_specs().await;
2652        assert!(specs.iter().any(|spec| spec.name == "explorer"));
2653        assert!(specs.iter().any(|spec| spec.name == "worker"));
2654    }
2655
2656    #[tokio::test]
2657    async fn spawn_defaults_to_single_explicit_mention() {
2658        let temp = TempDir::new().expect("tempdir");
2659        let controller = SubagentController::new(test_controller_config(
2660            temp.path().to_path_buf(),
2661            VTCodeConfig::default(),
2662        ))
2663        .await
2664        .expect("controller");
2665
2666        controller
2667            .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2668            .await;
2669
2670        let spawned = controller
2671            .spawn(SpawnAgentRequest {
2672                message: Some("Inspect the codebase.".to_string()),
2673                ..SpawnAgentRequest::default()
2674            })
2675            .await
2676            .expect("spawn");
2677
2678        assert_eq!(spawned.agent_name, "explorer");
2679        controller.close(&spawned.id).await.expect("close");
2680    }
2681
2682    #[tokio::test]
2683    async fn spawn_defaults_to_single_natural_language_selection() {
2684        let temp = TempDir::new().expect("tempdir");
2685        let controller = SubagentController::new(test_controller_config(
2686            temp.path().to_path_buf(),
2687            VTCodeConfig::default(),
2688        ))
2689        .await
2690        .expect("controller");
2691
2692        let mentions = controller
2693            .set_turn_delegation_hints_from_input("use explorer agent to inspect the codebase")
2694            .await;
2695        assert_eq!(mentions, vec!["explorer".to_string()]);
2696
2697        let spawned = controller
2698            .spawn(SpawnAgentRequest {
2699                message: Some("Inspect the codebase.".to_string()),
2700                ..SpawnAgentRequest::default()
2701            })
2702            .await
2703            .expect("spawn");
2704
2705        assert_eq!(spawned.agent_name, "explorer");
2706        controller.close(&spawned.id).await.expect("close");
2707    }
2708
2709    #[tokio::test]
2710    async fn spawn_rejects_mismatched_explicit_mention() {
2711        let temp = TempDir::new().expect("tempdir");
2712        let controller = SubagentController::new(test_controller_config(
2713            temp.path().to_path_buf(),
2714            VTCodeConfig::default(),
2715        ))
2716        .await
2717        .expect("controller");
2718
2719        controller
2720            .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2721            .await;
2722
2723        let err = controller
2724            .spawn(SpawnAgentRequest {
2725                agent_type: Some("worker".to_string()),
2726                message: Some("Implement a change.".to_string()),
2727                ..SpawnAgentRequest::default()
2728            })
2729            .await
2730            .expect_err("mismatched mention should fail");
2731
2732        assert!(
2733            err.to_string()
2734                .contains("user explicitly selected 'explorer'")
2735        );
2736    }
2737
2738    #[tokio::test]
2739    async fn spawn_rejects_write_capable_agent_without_explicit_request_or_agent_type() {
2740        let temp = TempDir::new().expect("tempdir");
2741        let controller = SubagentController::new(test_controller_config(
2742            temp.path().to_path_buf(),
2743            VTCodeConfig::default(),
2744        ))
2745        .await
2746        .expect("controller");
2747
2748        let err = controller
2749            .spawn(SpawnAgentRequest {
2750                message: Some("Implement a change.".to_string()),
2751                ..SpawnAgentRequest::default()
2752            })
2753            .await
2754            .expect_err("write-capable agent should require explicit request or agent_type");
2755
2756        assert!(
2757            err.to_string()
2758                .contains("cannot launch write-capable agent")
2759        );
2760    }
2761
2762    #[tokio::test]
2763    async fn spawn_allows_write_capable_agent_with_explicit_agent_type() {
2764        let temp = TempDir::new().expect("tempdir");
2765        let controller = SubagentController::new(test_controller_config(
2766            temp.path().to_path_buf(),
2767            VTCodeConfig::default(),
2768        ))
2769        .await
2770        .expect("controller");
2771
2772        let spawned = controller
2773            .spawn(SpawnAgentRequest {
2774                agent_type: Some("worker".to_string()),
2775                message: Some("Implement a change.".to_string()),
2776                ..SpawnAgentRequest::default()
2777            })
2778            .await
2779            .expect("explicit agent_type should allow write-capable agent");
2780
2781        controller.close(&spawned.id).await.expect("close");
2782    }
2783
2784    #[tokio::test]
2785    async fn spawn_rejects_primary_only_agent_as_child() {
2786        let temp = TempDir::new().expect("tempdir");
2787        write_test_primary_agent(temp.path());
2788        let controller = SubagentController::new(test_controller_config(
2789            temp.path().to_path_buf(),
2790            VTCodeConfig::default(),
2791        ))
2792        .await
2793        .expect("controller");
2794
2795        let mentions = controller
2796            .set_turn_delegation_hints_from_input("@agent-duck discuss the task")
2797            .await;
2798        assert!(mentions.is_empty());
2799
2800        let err = controller
2801            .spawn(SpawnAgentRequest {
2802                agent_type: Some("duck".to_string()),
2803                message: Some("Discuss the task.".to_string()),
2804                ..SpawnAgentRequest::default()
2805            })
2806            .await
2807            .expect_err("primary-only agent should not spawn as child");
2808
2809        assert!(err.to_string().contains("Unknown subagent type duck"));
2810    }
2811
2812    #[tokio::test]
2813    async fn spawn_accepts_background_flag_outside_managed_background_runtime() {
2814        let temp = TempDir::new().expect("tempdir");
2815        let controller = SubagentController::new(test_controller_config(
2816            temp.path().to_path_buf(),
2817            VTCodeConfig::default(),
2818        ))
2819        .await
2820        .expect("controller");
2821
2822        controller
2823            .set_turn_delegation_hints_from_input("delegate this task")
2824            .await;
2825
2826        let spawned = controller
2827            .spawn(SpawnAgentRequest {
2828                agent_type: Some("explorer".to_string()),
2829                message: Some("Inspect the codebase.".to_string()),
2830                background: true,
2831                ..SpawnAgentRequest::default()
2832            })
2833            .await
2834            .expect("background child spawn should succeed");
2835
2836        assert!(spawned.background);
2837        controller.close(&spawned.id).await.expect("close");
2838    }
2839
2840    #[tokio::test]
2841    async fn spawn_allows_background_capable_spec_as_foreground_child() {
2842        let temp = TempDir::new().expect("tempdir");
2843        write_test_background_subagent(temp.path());
2844        let controller = SubagentController::new(test_controller_config(
2845            temp.path().to_path_buf(),
2846            VTCodeConfig::default(),
2847        ))
2848        .await
2849        .expect("controller");
2850
2851        controller
2852            .set_turn_delegation_hints_from_input("run background-demo subagent and demo")
2853            .await;
2854
2855        let spawned = controller
2856            .spawn(SpawnAgentRequest {
2857                agent_type: Some("background-demo".to_string()),
2858                message: Some("Run the demo.".to_string()),
2859                background: false,
2860                ..SpawnAgentRequest::default()
2861            })
2862            .await
2863            .expect("foreground background-capable spawn should succeed");
2864
2865        assert_eq!(spawned.agent_name, "background-demo");
2866        assert!(!spawned.background);
2867        controller.close(&spawned.id).await.expect("close");
2868    }
2869
2870    #[tokio::test]
2871    async fn spawn_rejects_vague_task_even_with_explicit_request() {
2872        let temp = TempDir::new().expect("tempdir");
2873        let controller = SubagentController::new(test_controller_config(
2874            temp.path().to_path_buf(),
2875            VTCodeConfig::default(),
2876        ))
2877        .await
2878        .expect("controller");
2879
2880        controller
2881            .set_turn_delegation_hints_from_input("run worker subagent and report")
2882            .await;
2883
2884        let err = controller
2885            .spawn(SpawnAgentRequest {
2886                agent_type: Some("worker".to_string()),
2887                message: Some("report".to_string()),
2888                ..SpawnAgentRequest::default()
2889            })
2890            .await
2891            .expect_err("vague task should require clarification");
2892
2893        assert!(err.to_string().contains("too vague ('report')"));
2894    }
2895
2896    #[tokio::test]
2897    async fn spawn_defaults_to_write_capable_run_subagent_selection() {
2898        let temp = TempDir::new().expect("tempdir");
2899        let controller = SubagentController::new(test_controller_config(
2900            temp.path().to_path_buf(),
2901            VTCodeConfig::default(),
2902        ))
2903        .await
2904        .expect("controller");
2905
2906        let mentions = controller
2907            .set_turn_delegation_hints_from_input("run worker subagent and implement the change")
2908            .await;
2909        assert_eq!(mentions, vec!["worker".to_string()]);
2910
2911        let spawned = controller
2912            .spawn(SpawnAgentRequest {
2913                message: Some("Implement the change.".to_string()),
2914                ..SpawnAgentRequest::default()
2915            })
2916            .await
2917            .expect("spawn");
2918
2919        assert_eq!(spawned.agent_name, "worker");
2920        controller.close(&spawned.id).await.expect("close");
2921    }
2922
2923    #[tokio::test]
2924    async fn spawn_rejects_read_only_agent_when_auto_delegate_is_disabled() {
2925        let temp = TempDir::new().expect("tempdir");
2926        write_test_read_only_subagent(temp.path());
2927        let mut cfg = VTCodeConfig::default();
2928        cfg.subagents.auto_delegate_read_only = false;
2929        let controller =
2930            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
2931                .await
2932                .expect("controller");
2933
2934        let err = controller
2935            .spawn(SpawnAgentRequest {
2936                agent_type: Some("readonly-demo".to_string()),
2937                message: Some("Inspect the repository.".to_string()),
2938                ..SpawnAgentRequest::default()
2939            })
2940            .await
2941            .expect_err("read-only agent should require explicit delegation");
2942
2943        assert!(
2944            err.to_string()
2945                .contains("cannot proactively launch read-only agent 'readonly-demo'")
2946        );
2947    }
2948
2949    #[test]
2950    fn load_memory_appendix_renders_compact_summary() {
2951        let temp = TempDir::new().expect("tempdir");
2952        let memory_dir = temp.path().join(".vtcode/agent-memory/reviewer");
2953        std::fs::create_dir_all(&memory_dir).expect("memory dir");
2954        std::fs::write(
2955            memory_dir.join("MEMORY.md"),
2956            "# 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",
2957        )
2958        .expect("write memory");
2959
2960        let appendix =
2961            load_memory_appendix(temp.path(), "reviewer", Some(SubagentMemoryScope::Project))
2962                .expect("appendix")
2963                .expect("memory appendix");
2964
2965        assert!(appendix.contains("Persistent memory file:"));
2966        assert!(appendix.contains("Key points:"));
2967        assert!(appendix.contains("Keep diffs surgical."));
2968        assert!(appendix.contains("Open `MEMORY.md` when exact wording or more detail matters."));
2969        assert!(!appendix.contains("Current MEMORY.md excerpt"));
2970        assert!(!appendix.contains("## Preferences"));
2971    }
2972
2973    #[tokio::test]
2974    async fn spawn_ignores_model_override_without_explicit_user_model_request() {
2975        let temp = TempDir::new().expect("tempdir");
2976        let controller = SubagentController::new(test_controller_config(
2977            temp.path().to_path_buf(),
2978            VTCodeConfig::default(),
2979        ))
2980        .await
2981        .expect("controller");
2982
2983        controller
2984            .set_turn_delegation_hints_from_input("delegate this task")
2985            .await;
2986
2987        let spawned = controller
2988            .spawn(SpawnAgentRequest {
2989                agent_type: Some("worker".to_string()),
2990                message: Some("Implement the change.".to_string()),
2991                model: Some(models::openai::GPT_5_4_MINI.to_string()),
2992                ..SpawnAgentRequest::default()
2993            })
2994            .await
2995            .expect("spawn");
2996
2997        let effective_model = wait_for_effective_model(&controller, &spawned.id)
2998            .await
2999            .expect("effective model");
3000        assert_eq!(effective_model, models::openai::GPT_5_4);
3001        controller.close(&spawned.id).await.expect("close");
3002    }
3003
3004    #[tokio::test]
3005    async fn spawn_honors_model_override_when_user_explicitly_requests_it() {
3006        let temp = TempDir::new().expect("tempdir");
3007        let controller = SubagentController::new(test_controller_config(
3008            temp.path().to_path_buf(),
3009            VTCodeConfig::default(),
3010        ))
3011        .await
3012        .expect("controller");
3013
3014        controller
3015            .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3016            .await;
3017
3018        let spawned = controller
3019            .spawn(SpawnAgentRequest {
3020                agent_type: Some("worker".to_string()),
3021                message: Some("Implement the change.".to_string()),
3022                model: Some(models::openai::GPT_5_4_MINI.to_string()),
3023                ..SpawnAgentRequest::default()
3024            })
3025            .await
3026            .expect("spawn");
3027
3028        let effective_model = wait_for_effective_model(&controller, &spawned.id)
3029            .await
3030            .expect("effective model");
3031        assert_eq!(effective_model, models::openai::GPT_5_4_MINI);
3032        controller.close(&spawned.id).await.expect("close");
3033    }
3034
3035    #[tokio::test]
3036    async fn spawn_background_subprocess_rejects_non_background_agent() {
3037        let temp = TempDir::new().expect("tempdir");
3038        let mut cfg = VTCodeConfig::default();
3039        cfg.subagents.background.enabled = true;
3040        let controller =
3041            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3042                .await
3043                .expect("controller");
3044
3045        controller
3046            .set_turn_delegation_hints_from_input("delegate this task")
3047            .await;
3048
3049        let err = controller
3050            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3051                agent_type: Some("worker".to_string()),
3052                message: Some("Implement a change.".to_string()),
3053                ..SpawnBackgroundSubprocessRequest::default()
3054            })
3055            .await
3056            .expect_err("non-background agent should be rejected");
3057
3058        assert!(err.to_string().contains("background: true"));
3059        assert!(err.to_string().contains("Use spawn_agent instead"));
3060    }
3061
3062    #[tokio::test]
3063    async fn spawn_background_subprocess_returns_active_record_when_settings_match() {
3064        let temp = TempDir::new().expect("tempdir");
3065        write_test_background_subagent(temp.path());
3066        let mut cfg = VTCodeConfig::default();
3067        cfg.subagents.background.enabled = true;
3068        let controller =
3069            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3070                .await
3071                .expect("controller");
3072
3073        controller
3074            .set_turn_delegation_hints_from_input("delegate this task")
3075            .await;
3076
3077        let spec = controller
3078            .resolve_requested_spec(Some("background-demo"))
3079            .await
3080            .expect("spec");
3081        let record_id = background_record_id(spec.name.as_str());
3082        let created_at = Utc::now();
3083        {
3084            let mut state = controller.state.write().await;
3085            state.background_children.insert(
3086                record_id.clone(),
3087                BackgroundRecord {
3088                    id: record_id.clone(),
3089                    agent_name: spec.name.clone(),
3090                    display_label: subagent_display_label(&spec),
3091                    description: spec.description.clone(),
3092                    source: spec.source.label(),
3093                    color: spec.color.clone(),
3094                    session_id: "session-background-demo".to_string(),
3095                    exec_session_id: "exec-session-background-demo".to_string(),
3096                    desired_enabled: true,
3097                    status: BackgroundSubprocessStatus::Running,
3098                    created_at,
3099                    updated_at: created_at,
3100                    started_at: Some(created_at),
3101                    ended_at: None,
3102                    pid: Some(42),
3103                    prompt: "Report readiness once.".to_string(),
3104                    summary: Some("ready".to_string()),
3105                    error: None,
3106                    archive_path: None,
3107                    transcript_path: None,
3108                    max_turns: Some(4),
3109                    model_override: None,
3110                    reasoning_override: None,
3111                    restart_attempts: 0,
3112                },
3113            );
3114        }
3115
3116        let entry = controller
3117            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3118                agent_type: Some("background-demo".to_string()),
3119                ..SpawnBackgroundSubprocessRequest::default()
3120            })
3121            .await
3122            .expect("matching active record should be returned");
3123
3124        assert_eq!(entry.id, record_id);
3125        assert_eq!(entry.status, BackgroundSubprocessStatus::Running);
3126        assert_eq!(entry.pid, Some(42));
3127    }
3128
3129    #[tokio::test]
3130    async fn spawn_background_subprocess_rejects_conflicting_active_record_settings() {
3131        let temp = TempDir::new().expect("tempdir");
3132        write_test_background_subagent(temp.path());
3133        let mut cfg = VTCodeConfig::default();
3134        cfg.subagents.background.enabled = true;
3135        let controller =
3136            SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3137                .await
3138                .expect("controller");
3139
3140        controller
3141            .set_turn_delegation_hints_from_input("delegate this task")
3142            .await;
3143
3144        let spec = controller
3145            .resolve_requested_spec(Some("background-demo"))
3146            .await
3147            .expect("spec");
3148        let record_id = background_record_id(spec.name.as_str());
3149        let created_at = Utc::now();
3150        {
3151            let mut state = controller.state.write().await;
3152            state.background_children.insert(
3153                record_id,
3154                BackgroundRecord {
3155                    id: background_record_id(spec.name.as_str()),
3156                    agent_name: spec.name.clone(),
3157                    display_label: subagent_display_label(&spec),
3158                    description: spec.description.clone(),
3159                    source: spec.source.label(),
3160                    color: spec.color.clone(),
3161                    session_id: "session-background-demo".to_string(),
3162                    exec_session_id: "exec-session-background-demo".to_string(),
3163                    desired_enabled: true,
3164                    status: BackgroundSubprocessStatus::Running,
3165                    created_at,
3166                    updated_at: created_at,
3167                    started_at: Some(created_at),
3168                    ended_at: None,
3169                    pid: Some(42),
3170                    prompt: "Report readiness once.".to_string(),
3171                    summary: Some("ready".to_string()),
3172                    error: None,
3173                    archive_path: None,
3174                    transcript_path: None,
3175                    max_turns: Some(4),
3176                    model_override: None,
3177                    reasoning_override: None,
3178                    restart_attempts: 0,
3179                },
3180            );
3181        }
3182
3183        let err = controller
3184            .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3185                agent_type: Some("background-demo".to_string()),
3186                message: Some("Run a different task.".to_string()),
3187                ..SpawnBackgroundSubprocessRequest::default()
3188            })
3189            .await
3190            .expect_err("conflicting active record should be rejected");
3191
3192        assert!(err.to_string().contains("different prompt"));
3193        assert!(err.to_string().contains("Stop or restart"));
3194    }
3195
3196    #[tokio::test]
3197    async fn resume_preserves_captured_runtime_overrides() {
3198        let temp = TempDir::new().expect("tempdir");
3199        let controller = SubagentController::new(test_controller_config(
3200            temp.path().to_path_buf(),
3201            VTCodeConfig::default(),
3202        ))
3203        .await
3204        .expect("controller");
3205
3206        controller
3207            .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3208            .await;
3209
3210        let spawned = controller
3211            .spawn(SpawnAgentRequest {
3212                agent_type: Some("worker".to_string()),
3213                message: Some("Implement the change.".to_string()),
3214                model: Some(models::openai::GPT_5_4_MINI.to_string()),
3215                max_turns: Some(2),
3216                ..SpawnAgentRequest::default()
3217            })
3218            .await
3219            .expect("spawn");
3220
3221        let initial_model = wait_for_effective_model(&controller, &spawned.id)
3222            .await
3223            .expect("initial effective model");
3224        assert_eq!(initial_model, models::openai::GPT_5_4_MINI);
3225
3226        let closed = controller.close(&spawned.id).await.expect("close");
3227        assert_eq!(closed.status, SubagentStatus::Closed);
3228
3229        controller.resume(&spawned.id).await.expect("resume");
3230
3231        for _ in 0..100 {
3232            let status = controller.status_for(&spawned.id).await.expect("status");
3233            if status.updated_at > closed.updated_at && status.status != SubagentStatus::Closed {
3234                let snapshot = controller
3235                    .snapshot_for_thread(&spawned.id)
3236                    .await
3237                    .expect("snapshot");
3238                assert_eq!(
3239                    snapshot.effective_config.agent.default_model,
3240                    models::openai::GPT_5_4_MINI
3241                );
3242                controller.close(&spawned.id).await.expect("final close");
3243                return;
3244            }
3245            tokio::time::sleep(Duration::from_millis(10)).await;
3246        }
3247
3248        panic!("resumed subagent did not capture runtime config in time");
3249    }
3250
3251    #[tokio::test]
3252    async fn spawn_captures_runtime_config_before_first_child_turn() {
3253        let temp = TempDir::new().expect("tempdir");
3254        let controller = SubagentController::new(test_controller_config(
3255            temp.path().to_path_buf(),
3256            VTCodeConfig::default(),
3257        ))
3258        .await
3259        .expect("controller");
3260
3261        controller
3262            .set_turn_delegation_hints_from_input("delegate this task")
3263            .await;
3264
3265        let spawned = controller
3266            .spawn(SpawnAgentRequest {
3267                agent_type: Some("worker".to_string()),
3268                message: Some("Implement the change.".to_string()),
3269                ..SpawnAgentRequest::default()
3270            })
3271            .await
3272            .expect("spawn");
3273
3274        let snapshot = controller
3275            .snapshot_for_thread(&spawned.id)
3276            .await
3277            .expect("snapshot");
3278
3279        assert_eq!(snapshot.id, spawned.id);
3280        assert!(
3281            !snapshot
3282                .effective_config
3283                .agent
3284                .default_model
3285                .trim()
3286                .is_empty()
3287        );
3288
3289        controller.close(&spawned.id).await.expect("close");
3290    }
3291
3292    #[tokio::test]
3293    async fn spawn_custom_uses_explicit_spec_without_delegation_hints() {
3294        let temp = TempDir::new().expect("tempdir");
3295        let controller = SubagentController::new(test_controller_config(
3296            temp.path().to_path_buf(),
3297            VTCodeConfig::default(),
3298        ))
3299        .await
3300        .expect("controller");
3301
3302        let mut spec = vtcode_config::builtin_subagents()
3303            .into_iter()
3304            .find(|spec| spec.name == "explorer")
3305            .expect("explorer");
3306        spec.name = "init-grounding-explorer".to_string();
3307        spec.description = "VT Code /init grounding explorer.".to_string();
3308        spec.source = SubagentSource::ProjectVtcode;
3309
3310        let spawned = controller
3311            .spawn_custom(
3312                spec,
3313                SpawnAgentRequest {
3314                    message: Some(
3315                        "Inspect the repository and report agent-facing findings.".to_string(),
3316                    ),
3317                    max_turns: Some(2),
3318                    ..SpawnAgentRequest::default()
3319                },
3320            )
3321            .await
3322            .expect("spawn");
3323
3324        assert_eq!(spawned.agent_name, "init-grounding-explorer");
3325        assert_eq!(spawned.source, SubagentSource::ProjectVtcode.label());
3326        controller.close(&spawned.id).await.expect("close");
3327    }
3328
3329    #[tokio::test]
3330    async fn spawn_custom_rejects_write_capable_spec() {
3331        let temp = TempDir::new().expect("tempdir");
3332        let controller = SubagentController::new(test_controller_config(
3333            temp.path().to_path_buf(),
3334            VTCodeConfig::default(),
3335        ))
3336        .await
3337        .expect("controller");
3338
3339        let spec = vtcode_config::builtin_subagents()
3340            .into_iter()
3341            .find(|spec| spec.name == "worker")
3342            .expect("worker");
3343
3344        let err = controller
3345            .spawn_custom(
3346                spec,
3347                SpawnAgentRequest {
3348                    message: Some("Implement a change.".to_string()),
3349                    ..SpawnAgentRequest::default()
3350                },
3351            )
3352            .await
3353            .expect_err("write-capable custom spec should be rejected");
3354
3355        assert!(
3356            err.to_string()
3357                .contains("custom subagent spawn only supports read-only specs")
3358        );
3359    }
3360
3361    #[tokio::test]
3362    async fn spawn_custom_rejects_primary_only_spec() {
3363        let temp = TempDir::new().expect("tempdir");
3364        write_test_primary_agent(temp.path());
3365        let controller = SubagentController::new(test_controller_config(
3366            temp.path().to_path_buf(),
3367            VTCodeConfig::default(),
3368        ))
3369        .await
3370        .expect("controller");
3371
3372        let spec = controller
3373            .effective_specs()
3374            .await
3375            .into_iter()
3376            .find(|spec| spec.name == "duck")
3377            .expect("duck primary agent");
3378
3379        let err = controller
3380            .spawn_custom(
3381                spec,
3382                SpawnAgentRequest {
3383                    message: Some("Discuss the task.".to_string()),
3384                    ..SpawnAgentRequest::default()
3385                },
3386            )
3387            .await
3388            .expect_err("primary-only custom spec should be rejected");
3389
3390        assert!(
3391            err.to_string()
3392                .contains("custom subagent spawn only supports subagent-capable specs")
3393        );
3394    }
3395
3396    #[tokio::test]
3397    async fn close_marks_child_closed() {
3398        let temp = TempDir::new().expect("tempdir");
3399        let controller = SubagentController::new(test_controller_config(
3400            temp.path().to_path_buf(),
3401            VTCodeConfig::default(),
3402        ))
3403        .await
3404        .expect("controller");
3405        controller
3406            .set_turn_delegation_hints_from_input("delegate this task")
3407            .await;
3408        let spawned = controller
3409            .spawn(SpawnAgentRequest {
3410                agent_type: Some("default".to_string()),
3411                message: Some("Summarize the repository.".to_string()),
3412                ..SpawnAgentRequest::default()
3413            })
3414            .await
3415            .expect("spawn");
3416        let closed = controller.close(&spawned.id).await.expect("close");
3417        assert_eq!(closed.status, SubagentStatus::Closed);
3418    }
3419
3420    #[tokio::test]
3421    async fn close_is_idempotent_for_closed_agents() {
3422        let temp = TempDir::new().expect("tempdir");
3423        let controller = SubagentController::new(test_controller_config(
3424            temp.path().to_path_buf(),
3425            VTCodeConfig::default(),
3426        ))
3427        .await
3428        .expect("controller");
3429        controller
3430            .set_turn_delegation_hints_from_input("delegate this task")
3431            .await;
3432        let spawned = controller
3433            .spawn(SpawnAgentRequest {
3434                agent_type: Some("default".to_string()),
3435                message: Some("Summarize the repository.".to_string()),
3436                ..SpawnAgentRequest::default()
3437            })
3438            .await
3439            .expect("spawn");
3440
3441        let closed = controller.close(&spawned.id).await.expect("first close");
3442        let closed_again = controller.close(&spawned.id).await.expect("second close");
3443
3444        assert_eq!(closed_again.status, SubagentStatus::Closed);
3445        assert_eq!(closed_again.updated_at, closed.updated_at);
3446        assert_eq!(closed_again.completed_at, closed.completed_at);
3447    }
3448
3449    #[tokio::test]
3450    async fn close_and_resume_cascade_through_spawn_tree() {
3451        let temp = TempDir::new().expect("tempdir");
3452        let controller = SubagentController::new(test_controller_config(
3453            temp.path().to_path_buf(),
3454            VTCodeConfig::default(),
3455        ))
3456        .await
3457        .expect("controller");
3458
3459        let spec = vtcode_config::builtin_subagents()
3460            .into_iter()
3461            .find(|spec| spec.name == "explorer")
3462            .expect("explorer");
3463
3464        {
3465            let mut state = controller.state.write().await;
3466            state.children.insert(
3467                "parent".to_string(),
3468                test_child_record("parent", "session-root", &spec, SubagentStatus::Running, 1),
3469            );
3470            state.children.insert(
3471                "child".to_string(),
3472                test_child_record("child", "parent", &spec, SubagentStatus::Running, 2),
3473            );
3474            state.children.insert(
3475                "grandchild".to_string(),
3476                test_child_record("grandchild", "child", &spec, SubagentStatus::Running, 3),
3477            );
3478        }
3479
3480        let closed = controller.close("parent").await.expect("close");
3481        assert_eq!(closed.status, SubagentStatus::Closed);
3482        assert_eq!(
3483            controller.status_for("child").await.expect("child").status,
3484            SubagentStatus::Closed
3485        );
3486        assert_eq!(
3487            controller
3488                .status_for("grandchild")
3489                .await
3490                .expect("grandchild")
3491                .status,
3492            SubagentStatus::Closed
3493        );
3494
3495        let subtree_ids = controller
3496            .collect_spawn_subtree_ids("parent")
3497            .await
3498            .expect("collect subtree");
3499        assert_eq!(
3500            subtree_ids,
3501            vec![
3502                "parent".to_string(),
3503                "child".to_string(),
3504                "grandchild".to_string()
3505            ]
3506        );
3507
3508        let mut restart_ids = Vec::new();
3509        for node_id in subtree_ids {
3510            if controller
3511                .reopen_single(node_id.as_str())
3512                .await
3513                .expect("reopen subtree node")
3514            {
3515                restart_ids.push(node_id);
3516            }
3517        }
3518
3519        assert_eq!(
3520            restart_ids,
3521            vec![
3522                "parent".to_string(),
3523                "child".to_string(),
3524                "grandchild".to_string()
3525            ]
3526        );
3527        assert_eq!(
3528            controller
3529                .status_for("parent")
3530                .await
3531                .expect("parent")
3532                .status,
3533            SubagentStatus::Queued
3534        );
3535        assert_eq!(
3536            controller.status_for("child").await.expect("child").status,
3537            SubagentStatus::Queued
3538        );
3539        assert_eq!(
3540            controller
3541                .status_for("grandchild")
3542                .await
3543                .expect("grandchild")
3544                .status,
3545            SubagentStatus::Queued
3546        );
3547    }
3548
3549    #[tokio::test]
3550    async fn spawn_rejects_fourth_active_subagent() {
3551        let temp = TempDir::new().expect("tempdir");
3552        let controller = SubagentController::new(test_controller_config(
3553            temp.path().to_path_buf(),
3554            VTCodeConfig::default(),
3555        ))
3556        .await
3557        .expect("controller");
3558        controller
3559            .set_turn_delegation_hints_from_input("delegate this task")
3560            .await;
3561
3562        let spec = vtcode_config::builtin_subagents()
3563            .into_iter()
3564            .find(|spec| spec.name == "explorer")
3565            .expect("explorer");
3566
3567        {
3568            let mut state = controller.state.write().await;
3569            for idx in 0..SUBAGENT_HARD_CONCURRENCY_LIMIT {
3570                let id = format!("active-{idx}");
3571                state.children.insert(
3572                    id.clone(),
3573                    ChildRecord {
3574                        id: id.clone(),
3575                        session_id: format!("session-{id}"),
3576                        parent_thread_id: "parent-session".to_string(),
3577                        spec: spec.clone(),
3578                        display_label: subagent_display_label(&spec),
3579                        status: SubagentStatus::Running,
3580                        background: false,
3581                        depth: 1,
3582                        created_at: Utc::now(),
3583                        updated_at: Utc::now(),
3584                        completed_at: None,
3585                        summary: None,
3586                        error: None,
3587                        archive_metadata: None,
3588                        archive_path: None,
3589                        transcript_path: None,
3590                        effective_config: None,
3591                        stored_messages: Vec::new(),
3592                        last_prompt: Some("Inspect the codebase.".to_string()),
3593                        queued_prompts: VecDeque::new(),
3594                        max_turns: None,
3595                        model_override: None,
3596                        reasoning_override: None,
3597                        thread_handle: None,
3598                        handle: None,
3599                        notify: Arc::new(Notify::new()),
3600                    },
3601                );
3602            }
3603        }
3604
3605        let err = controller
3606            .spawn(SpawnAgentRequest {
3607                agent_type: Some("explorer".to_string()),
3608                message: Some("Inspect another codepath.".to_string()),
3609                ..SpawnAgentRequest::default()
3610            })
3611            .await
3612            .expect_err("fourth active subagent should be rejected");
3613
3614        assert!(err.to_string().contains(&format!(
3615            "Subagent concurrency limit reached (max_concurrent={})",
3616            SUBAGENT_HARD_CONCURRENCY_LIMIT
3617        )));
3618    }
3619
3620    #[tokio::test]
3621    async fn wait_returns_first_terminal_child() {
3622        let temp = TempDir::new().expect("tempdir");
3623        let controller = SubagentController::new(test_controller_config(
3624            temp.path().to_path_buf(),
3625            VTCodeConfig::default(),
3626        ))
3627        .await
3628        .expect("controller");
3629        let spec = vtcode_config::builtin_subagents()
3630            .into_iter()
3631            .find(|spec| spec.name == "default")
3632            .expect("default");
3633
3634        {
3635            let mut state = controller.state.write().await;
3636            for id in ["first", "second"] {
3637                state.children.insert(
3638                    id.to_string(),
3639                    ChildRecord {
3640                        id: id.to_string(),
3641                        session_id: format!("session-{id}"),
3642                        parent_thread_id: "parent-session".to_string(),
3643                        spec: spec.clone(),
3644                        display_label: subagent_display_label(&spec),
3645                        status: SubagentStatus::Running,
3646                        background: false,
3647                        depth: 1,
3648                        created_at: Utc::now(),
3649                        updated_at: Utc::now(),
3650                        completed_at: None,
3651                        summary: None,
3652                        error: None,
3653                        archive_metadata: None,
3654                        archive_path: None,
3655                        transcript_path: None,
3656                        effective_config: None,
3657                        stored_messages: Vec::new(),
3658                        last_prompt: None,
3659                        queued_prompts: VecDeque::new(),
3660                        max_turns: None,
3661                        model_override: None,
3662                        reasoning_override: None,
3663                        thread_handle: None,
3664                        handle: None,
3665                        notify: Arc::new(Notify::new()),
3666                    },
3667                );
3668            }
3669        }
3670
3671        let controller_clone = controller.clone();
3672        tokio::spawn(async move {
3673            tokio::time::sleep(Duration::from_millis(20)).await;
3674            let mut state = controller_clone.state.write().await;
3675            let record = state.children.get_mut("second").expect("second child");
3676            record.status = SubagentStatus::Completed;
3677            record.summary = Some("done".to_string());
3678            record.completed_at = Some(Utc::now());
3679            record.updated_at = Utc::now();
3680            record.notify.notify_waiters();
3681        });
3682
3683        let result = controller
3684            .wait(&["first".to_string(), "second".to_string()], Some(500))
3685            .await
3686            .expect("wait result")
3687            .expect("terminal child");
3688        assert_eq!(result.id, "second");
3689        assert_eq!(result.status, SubagentStatus::Completed);
3690    }
3691}