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