Skip to main content

everruns_core/capabilities/
subagents.rs

1// Subagent Capability
2//
3// Decision: 1 delegation tool — spawn_agent(target.type = "subagent").
4// - subagent delegation creates a child session with parent_session_id set
5//
6// Blueprint support: the subagent target accepts optional `blueprint` and `config`
7// params. When blueprint is set, the child session uses the blueprint's
8// RuntimeAgent (own prompt, tools, model) instead of inheriting parent's.
9//
10// Background mode (default): returns immediately with a task_id; a detached
11// watcher (same pattern as spawn_background runs) sends the instructions,
12// heartbeats the task registry, and settles the task on the child's terminal
13// turn status. The task's OnTerminal wake policy notifies the parent session
14// through the registry-level waker (specs/session-tasks.md, Wake-ups).
15// Foreground mode: blocks until subagent completes (send_message + wait_for_idle).
16// When no session task registry is wired (embedders without background
17// tracking), an unspecified mode degrades to foreground so results are not lost.
18//
19// Subagent naming: human-readable ("Test Runner"), unique per parent, case-insensitive.
20// Spawn governance: child depth and root-tree task fan-out are bounded.
21
22use super::delegation_result::{
23    MESSAGE_SCHEMA_SPEC_KEY, RESULT_SCHEMA_SPEC_KEY, normalize_message_schema,
24    normalize_result_schema, required_result_is_missing, result_value_for_task, truncate_summary,
25};
26#[cfg(test)]
27use super::delegation_result::{ReportResultTool, ReportTaskProgressTool};
28use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel, SpawnMode};
29use crate::platform_store::{PlatformCreateSessionRequest, PlatformStore};
30use crate::session::SessionSeedMode;
31use crate::session_task::{
32    CreateSessionTask, SessionTask, SessionTaskFilter, SessionTaskState, SessionTaskUpdate,
33    TASK_KIND_SESSION, TASK_KIND_SUBAGENT, TaskError, TaskExecutor, TaskExecutorPlugin, TaskLinks,
34    TaskMessage, TaskWakePolicy, task_message_text,
35};
36use crate::tool_types::ToolHints;
37use crate::tools::{
38    BackgroundRunPermit, Tool, ToolExecutionResult, try_acquire_background_run_permit,
39};
40use crate::traits::{SessionStore, SpawnClaimResult, ToolContext};
41use crate::typed_id::SessionId;
42use async_trait::async_trait;
43
44pub(crate) const SPAWN_AGENT_CONCURRENCY_CLASS: &str = "spawn_agent";
45use serde_json::{Value, json};
46use std::collections::{HashSet, VecDeque};
47use std::sync::Arc;
48
49pub const SUBAGENTS_CAPABILITY_ID: &str = "subagents";
50
51/// Subagent capability — spawn and manage child agent sessions.
52pub struct SubagentCapability;
53
54impl Capability for SubagentCapability {
55    fn id(&self) -> &str {
56        SUBAGENTS_CAPABILITY_ID
57    }
58
59    fn name(&self) -> &str {
60        "Subagents"
61    }
62
63    fn description(&self) -> &str {
64        "Spawn and manage subagents for parallel task execution in isolated context windows."
65    }
66
67    fn localizations(&self) -> Vec<CapabilityLocalization> {
68        vec![CapabilityLocalization::text(
69            "uk",
70            "Субагенти",
71            "Запускайте субагентів і керуйте ними для паралельного виконання завдань в ізольованих контекстних вікнах.",
72        )]
73    }
74
75    fn status(&self) -> CapabilityStatus {
76        CapabilityStatus::Available
77    }
78
79    fn icon(&self) -> Option<&str> {
80        Some("git-branch")
81    }
82
83    fn category(&self) -> Option<&str> {
84        Some("Core")
85    }
86
87    fn risk_level(&self) -> RiskLevel {
88        // Subagent recursion controls bound org cost/DoS exposure; keep
89        // caller-supplied session capability overrides behind the admin gate.
90        RiskLevel::High
91    }
92
93    fn features(&self) -> Vec<&'static str> {
94        vec!["subagents"]
95    }
96
97    fn config_schema(&self) -> Option<Value> {
98        Some(json!({
99            "type": "object",
100            "additionalProperties": false,
101            "properties": {
102                "max_subagent_depth": {
103                    "type": "integer",
104                    "minimum": 0,
105                    "maximum": 16,
106                    "default": crate::traits::DEFAULT_MAX_SUBAGENT_DEPTH,
107                    "description": "Maximum child depth allowed from a top-level session. Top-level sessions are depth 0; setting 0 blocks all subagent spawning."
108                },
109                "max_depth": {
110                    "type": "integer",
111                    "minimum": 0,
112                    "maximum": 16,
113                    "description": "Alias for max_subagent_depth."
114                },
115                "max_active_descendant_tasks": {
116                    "type": "integer",
117                    "minimum": 0,
118                    "maximum": 1024,
119                    "default": crate::traits::DEFAULT_MAX_ACTIVE_DESCENDANT_SUBAGENT_TASKS,
120                    "description": "Maximum non-terminal descendant subagent tasks allowed under one root session. Counts queued, running, and awaiting_input tasks."
121                },
122                "max_concurrent_descendant_tasks": {
123                    "type": "integer",
124                    "minimum": 0,
125                    "maximum": 1024,
126                    "description": "Alias for max_active_descendant_tasks."
127                },
128                "max_total_descendant_tasks": {
129                    "type": "integer",
130                    "minimum": 0,
131                    "maximum": 10000,
132                    "default": crate::traits::DEFAULT_MAX_TOTAL_DESCENDANT_SUBAGENT_TASKS,
133                    "description": "Maximum descendant subagent task records allowed under one root session before rejecting new spawns."
134                },
135                "max_active_detached_tasks": {
136                    "type": "integer",
137                    "minimum": 0,
138                    "maximum": 1024,
139                    "default": crate::traits::DEFAULT_MAX_ACTIVE_DETACHED_TASKS,
140                    "description": "Maximum non-terminal detached peer sessions allowed under one origin root session. Detached spawns reset depth but are still capped here so a loop cannot run unbounded (EVE-767)."
141                },
142                "max_total_detached_tasks": {
143                    "type": "integer",
144                    "minimum": 0,
145                    "maximum": 10000,
146                    "default": crate::traits::DEFAULT_MAX_TOTAL_DETACHED_TASKS,
147                    "description": "Maximum detached peer session task records allowed under one origin root session before rejecting new detached spawns."
148                }
149            }
150        }))
151    }
152
153    fn validate_config(&self, config: &Value) -> Result<(), String> {
154        for key in ["max_subagent_depth", "max_depth"] {
155            let Some(value) = config.get(key) else {
156                continue;
157            };
158            let Some(depth) = value.as_u64() else {
159                return Err(format!("{key} must be a non-negative integer"));
160            };
161            if depth > 16 {
162                return Err(format!("{key} must be <= 16"));
163            }
164        }
165        for key in [
166            "max_active_descendant_tasks",
167            "max_concurrent_descendant_tasks",
168        ] {
169            let Some(value) = config.get(key) else {
170                continue;
171            };
172            let Some(max_active) = value.as_u64() else {
173                return Err(format!("{key} must be a non-negative integer"));
174            };
175            if max_active > 1024 {
176                return Err(format!("{key} must be <= 1024"));
177            }
178        }
179        for key in ["max_total_descendant_tasks", "max_total_detached_tasks"] {
180            let Some(value) = config.get(key) else {
181                continue;
182            };
183            let Some(max_total) = value.as_u64() else {
184                return Err(format!("{key} must be a non-negative integer"));
185            };
186            if max_total > 10_000 {
187                return Err(format!("{key} must be <= 10000"));
188            }
189        }
190        if let Some(value) = config.get("max_active_detached_tasks") {
191            let Some(max_active) = value.as_u64() else {
192                return Err("max_active_detached_tasks must be a non-negative integer".to_string());
193            };
194            if max_active > 1024 {
195                return Err("max_active_detached_tasks must be <= 1024".to_string());
196            }
197        }
198        Ok(())
199    }
200
201    fn system_prompt_addition(&self) -> Option<&str> {
202        Some(SUBAGENT_SYSTEM_PROMPT)
203    }
204
205    fn tools(&self) -> Vec<Box<dyn Tool>> {
206        vec![]
207    }
208}
209
210const SUBAGENT_SYSTEM_PROMPT: &str = "Spawn subagents for independent parallel work or separate context; avoid immediate sequential steps. Spawns are background by default: you get a task_id, keep working, and are notified on completion (monitor with get_task/wait_task). Use mode \"foreground\" only when blocked on the result. Nested subagents are allowed up to max_subagent_depth and root-tree task caps. Use blueprints for specialist tools/model.";
211/// Task spec key holding spawn-time per-task push configs (EVE-682). The
212/// webhook notifier reads this in addition to the DB-backed configs so
213/// spawn-time and endpoint-created configs share one delivery path.
214const PUSH_CONFIGS_SPEC_KEY: &str = "push_configs";
215/// Valid `event_filter` members for a per-task push config.
216const VALID_PUSH_EVENT_FILTERS: [&str; 3] = ["terminal", "awaiting_input", "message"];
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219enum SpawnLifetime {
220    Linked,
221    Detached,
222}
223
224impl SpawnLifetime {
225    fn parse(arguments: &Value) -> Result<Self, ToolExecutionResult> {
226        match arguments.get("lifetime").and_then(Value::as_str) {
227            None | Some("linked") => Ok(Self::Linked),
228            Some("detached") => Ok(Self::Detached),
229            Some(other) => Err(ToolExecutionResult::tool_error(format!(
230                "Invalid lifetime: {other}. Expected 'linked' or 'detached'."
231            ))),
232        }
233    }
234
235    fn as_str(self) -> &'static str {
236        match self {
237            Self::Linked => "linked",
238            Self::Detached => "detached",
239        }
240    }
241}
242
243fn parse_seed(arguments: &Value) -> Result<SessionSeedMode, ToolExecutionResult> {
244    match arguments.get("seed").and_then(Value::as_str) {
245        None | Some("fresh") => Ok(SessionSeedMode::Fresh),
246        Some("fork") => Ok(SessionSeedMode::Fork),
247        Some("workspace") => Ok(SessionSeedMode::Workspace),
248        Some(other) => Err(ToolExecutionResult::tool_error(format!(
249            "Invalid seed: {other}. Expected 'fresh', 'fork', or 'workspace'."
250        ))),
251    }
252}
253
254/// Per-slice wait used by the background watcher; the watcher loops slices
255/// until the child reaches a terminal state or the overall cap is hit.
256const BACKGROUND_WAIT_SLICE_SECS: u64 = 300;
257/// Overall cap on a background subagent run. The child's own max-iterations
258/// guard bounds each turn; this bounds pathological never-terminal children.
259const BACKGROUND_MAX_WAIT_SECS: u64 = 6 * 60 * 60;
260/// Watcher heartbeat cadence; the session task reaper treats heartbeats
261/// stale after ~5 minutes, so this keeps live watchers well inside that.
262const BACKGROUND_HEARTBEAT_INTERVAL_SECS: u64 = 15;
263/// Backoff between wait slices for statuses that return immediately
264/// (paused / waiting_for_tool_results) so the watcher does not spin.
265const BACKGROUND_POLL_BACKOFF_SECS: u64 = 5;
266
267fn terminal_subagent_status(wait_status: &str) -> Option<crate::session::SubagentStatus> {
268    match wait_status {
269        // Plain `idle` only means the worker is ready for another turn — failed
270        // turns also leave the session idle — so only explicit terminal
271        // outcomes may settle the spawn handle and persist terminal metadata.
272        "completed" => Some(crate::session::SubagentStatus::Completed),
273        "error" | "failed" => Some(crate::session::SubagentStatus::Failed),
274        "cancelled" => Some(crate::session::SubagentStatus::Cancelled),
275        "max_iterations_reached" => Some(crate::session::SubagentStatus::MaxIterationsReached),
276        // A sealed turn (no forward progress / budget exhausted) is terminal but
277        // distinct from a failure — surface it so the parent can decide next steps.
278        "sealed" => Some(crate::session::SubagentStatus::Sealed),
279        _ => None,
280    }
281}
282
283fn terminal_subagent_task_state(
284    subagent_status: &crate::session::SubagentStatus,
285) -> SessionTaskState {
286    match subagent_status {
287        crate::session::SubagentStatus::Completed => SessionTaskState::Succeeded,
288        crate::session::SubagentStatus::Cancelled => SessionTaskState::Canceled,
289        _ => SessionTaskState::Failed,
290    }
291}
292
293/// Parse + validate the optional `push_configs` spawn arg (EVE-682).
294///
295/// # Security
296///
297/// Each config URL is SSRF-validated here at create time via
298/// `validate_safe_url`, before it is embedded in the task spec. Delivery
299/// (the webhook notifier) additionally pins DNS, closing the create→deliver
300/// rebinding window. Returns the normalized array to embed under
301/// `PUSH_CONFIGS_SPEC_KEY`, or `None` when absent/empty.
302fn normalize_push_configs(arguments: &Value) -> Result<Option<Value>, ToolExecutionResult> {
303    let Some(raw) = arguments
304        .get(PUSH_CONFIGS_SPEC_KEY)
305        .filter(|v| !v.is_null())
306    else {
307        return Ok(None);
308    };
309    let Some(entries) = raw.as_array() else {
310        return Err(ToolExecutionResult::tool_error(
311            "push_configs must be an array of { url, secret?, event_filter? } objects.",
312        ));
313    };
314    if entries.is_empty() {
315        return Ok(None);
316    }
317    let mut normalized = Vec::with_capacity(entries.len());
318    for entry in entries {
319        let Some(url) = entry.get("url").and_then(Value::as_str) else {
320            return Err(ToolExecutionResult::tool_error(
321                "Each push_configs entry requires a string `url`.",
322            ));
323        };
324        if let Err(e) = crate::url_validation::validate_safe_url(url) {
325            return Err(ToolExecutionResult::tool_error(format!(
326                "Invalid push_configs url \"{url}\": {e}"
327            )));
328        }
329        let mut obj = serde_json::Map::new();
330        obj.insert("url".to_string(), Value::String(url.to_string()));
331        if let Some(secret) = entry
332            .get("secret")
333            .and_then(Value::as_str)
334            .filter(|s| !s.is_empty())
335        {
336            obj.insert("secret".to_string(), Value::String(secret.to_string()));
337        }
338        if let Some(filters) = entry.get("event_filter").filter(|v| !v.is_null()) {
339            let Some(arr) = filters.as_array() else {
340                return Err(ToolExecutionResult::tool_error(
341                    "push_configs event_filter must be an array of strings.",
342                ));
343            };
344            let mut out: Vec<Value> = Vec::new();
345            for f in arr {
346                let Some(f) = f.as_str() else {
347                    return Err(ToolExecutionResult::tool_error(
348                        "push_configs event_filter members must be strings.",
349                    ));
350                };
351                if !VALID_PUSH_EVENT_FILTERS.contains(&f) {
352                    return Err(ToolExecutionResult::tool_error(format!(
353                        "Unknown push_configs event_filter \"{f}\". Valid: {}.",
354                        VALID_PUSH_EVENT_FILTERS.join(", ")
355                    )));
356                }
357                if !out.iter().any(|x| x.as_str() == Some(f)) {
358                    out.push(Value::String(f.to_string()));
359                }
360            }
361            if !out.is_empty() {
362                obj.insert("event_filter".to_string(), Value::Array(out));
363            }
364        }
365        normalized.push(Value::Object(obj));
366    }
367    Ok(Some(Value::Array(normalized)))
368}
369
370// =============================================================================
371// Helper: get platform store from context
372// =============================================================================
373
374use super::util::{get_platform_store, require_str_nonblank as require_str};
375
376fn get_session_store(
377    context: &ToolContext,
378) -> Result<&dyn crate::traits::SessionStore, ToolExecutionResult> {
379    context
380        .session_store
381        .as_ref()
382        .map(|s| s.as_ref())
383        .ok_or_else(|| {
384            ToolExecutionResult::tool_error("Subagent tools require session_store context")
385        })
386}
387
388async fn current_subagent_depth(
389    session_store: &dyn SessionStore,
390    session: &crate::session::Session,
391    max_subagent_depth: u32,
392) -> Result<u32, ToolExecutionResult> {
393    let mut depth = 0_u32;
394    let mut cursor = session.parent_session_id;
395
396    while let Some(parent_id) = cursor {
397        depth = depth.saturating_add(1);
398        if depth > max_subagent_depth {
399            return Ok(depth);
400        }
401
402        let parent = match session_store.get_session(parent_id).await {
403            Ok(Some(parent)) => parent,
404            Ok(None) => {
405                return Err(ToolExecutionResult::tool_error(format!(
406                    "Cannot enforce max_subagent_depth: parent session {parent_id} was not found."
407                )));
408            }
409            Err(error) => return Err(ToolExecutionResult::internal_error(error)),
410        };
411        cursor = parent.parent_session_id;
412    }
413
414    Ok(depth)
415}
416
417async fn root_session_for_subagent_tree(
418    session_store: &dyn SessionStore,
419    session: &crate::session::Session,
420) -> Result<SessionId, ToolExecutionResult> {
421    let mut root_id = session.id;
422    let mut cursor = session.parent_session_id;
423    let mut seen = HashSet::new();
424    seen.insert(session.id);
425
426    while let Some(parent_id) = cursor {
427        if !seen.insert(parent_id) {
428            return Err(ToolExecutionResult::tool_error(format!(
429                "Cannot enforce subagent descendant task caps: session parent cycle detected at {parent_id}."
430            )));
431        }
432
433        let parent = match session_store.get_session(parent_id).await {
434            Ok(Some(parent)) => parent,
435            Ok(None) => {
436                return Err(ToolExecutionResult::tool_error(format!(
437                    "Cannot enforce subagent descendant task caps: parent session {parent_id} was not found."
438                )));
439            }
440            Err(error) => return Err(ToolExecutionResult::internal_error(error)),
441        };
442        root_id = parent.id;
443        cursor = parent.parent_session_id;
444    }
445
446    Ok(root_id)
447}
448
449#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
450struct DescendantTaskCounts {
451    active: u32,
452    total: u32,
453}
454
455async fn descendant_subagent_task_counts(
456    registry: &dyn crate::session_task::SessionTaskRegistry,
457    root_session_id: SessionId,
458    max_active: u32,
459    max_total: u32,
460) -> Result<DescendantTaskCounts, ToolExecutionResult> {
461    let mut counts = DescendantTaskCounts::default();
462    let mut queue = VecDeque::from([root_session_id]);
463    let mut visited_sessions = HashSet::from([root_session_id]);
464
465    while let Some(session_id) = queue.pop_front() {
466        let tasks = registry
467            .list(
468                session_id,
469                Some(&SessionTaskFilter {
470                    kind: Some(TASK_KIND_SUBAGENT.to_string()),
471                    state: None,
472                }),
473            )
474            .await
475            .map_err(ToolExecutionResult::internal_error)?;
476
477        for task in tasks {
478            counts.total = counts.total.saturating_add(1);
479            if !task.state.is_terminal() {
480                counts.active = counts.active.saturating_add(1);
481            }
482
483            if let Some(child_session_id) = task.links.child_session_id
484                && visited_sessions.insert(child_session_id)
485            {
486                queue.push_back(child_session_id);
487            }
488
489            if counts.active >= max_active || counts.total >= max_total {
490                return Ok(counts);
491            }
492        }
493    }
494
495    Ok(counts)
496}
497
498async fn enforce_subagent_task_caps(
499    session_store: &dyn SessionStore,
500    session: &crate::session::Session,
501    context: &ToolContext,
502) -> Result<(), ToolExecutionResult> {
503    let Some(registry) = context.session_task_registry.as_ref() else {
504        return Ok(());
505    };
506    let policy = context.subagent_nesting_policy;
507    let max_active = policy.max_active_descendant_tasks();
508    let max_total = policy.max_total_descendant_tasks();
509    let root_session_id = root_session_for_subagent_tree(session_store, session).await?;
510    let counts =
511        descendant_subagent_task_counts(registry.as_ref(), root_session_id, max_active, max_total)
512            .await?;
513
514    if counts.active >= max_active {
515        let attempted = counts.active.saturating_add(1);
516        return Err(ToolExecutionResult::tool_error(format!(
517            "Subagent active descendant task cap exceeded: spawning this subagent would create {attempted} non-terminal descendant tasks under root session {root_session_id}, but max_active_descendant_tasks is {max_active}."
518        )));
519    }
520
521    if counts.total >= max_total {
522        let attempted = counts.total.saturating_add(1);
523        return Err(ToolExecutionResult::tool_error(format!(
524            "Subagent total descendant task cap exceeded: spawning this subagent would create {attempted} descendant task records under root session {root_session_id}, but max_total_descendant_tasks is {max_total}."
525        )));
526    }
527
528    Ok(())
529}
530
531/// Count detached peer tasks (`TASK_KIND_SESSION`) anywhere under the origin
532/// subagent tree root (EVE-767). Unlike `descendant_subagent_task_counts`, the
533/// BFS follows *every* task's `child_session_id` (subagent and detached alike)
534/// so detached spawns made deep in the tree — by subagents or by other detached
535/// peers — are all attributed to the origin root. Only `session`-kind tasks are
536/// counted; subagent accounting is untouched.
537async fn descendant_detached_task_counts(
538    registry: &dyn crate::session_task::SessionTaskRegistry,
539    root_session_id: SessionId,
540    max_active: u32,
541    max_total: u32,
542) -> Result<DescendantTaskCounts, ToolExecutionResult> {
543    let mut counts = DescendantTaskCounts::default();
544    let mut queue = VecDeque::from([root_session_id]);
545    let mut visited_sessions = HashSet::from([root_session_id]);
546
547    while let Some(session_id) = queue.pop_front() {
548        // No kind filter: traversal must cross both subagent and detached
549        // subtrees to find every detached spawn under the root.
550        let tasks = registry
551            .list(session_id, None)
552            .await
553            .map_err(ToolExecutionResult::internal_error)?;
554
555        for task in tasks {
556            if task.kind == TASK_KIND_SESSION {
557                counts.total = counts.total.saturating_add(1);
558                if !task.state.is_terminal() {
559                    counts.active = counts.active.saturating_add(1);
560                }
561            }
562
563            if let Some(child_session_id) = task.links.child_session_id
564                && visited_sessions.insert(child_session_id)
565            {
566                queue.push_back(child_session_id);
567            }
568
569            if counts.active >= max_active || counts.total >= max_total {
570                return Ok(counts);
571            }
572        }
573    }
574
575    Ok(counts)
576}
577
578/// Governance gate for a detached spawn (EVE-767). A detached peer resets depth
579/// but is priced against the origin tree root: a loop of detached spawns is
580/// bounded by the active/total detached caps, closing the uncapped-runaway side
581/// door (TM-DOS). Non-detached subagent caps are enforced separately and are
582/// unchanged.
583async fn enforce_detached_spawn_caps(
584    context: &ToolContext,
585    root_session_id: SessionId,
586) -> Result<(), ToolExecutionResult> {
587    let Some(registry) = context.session_task_registry.as_ref() else {
588        return Ok(());
589    };
590    let policy = context.subagent_nesting_policy;
591    let max_active = policy.max_active_detached_tasks();
592    let max_total = policy.max_total_detached_tasks();
593    let counts =
594        descendant_detached_task_counts(registry.as_ref(), root_session_id, max_active, max_total)
595            .await?;
596
597    if counts.active >= max_active {
598        let attempted = counts.active.saturating_add(1);
599        return Err(ToolExecutionResult::tool_error(format!(
600            "Detached spawn active cap exceeded: spawning this detached session would create {attempted} non-terminal detached peer tasks under origin root session {root_session_id}, but max_active_detached_tasks is {max_active}."
601        )));
602    }
603
604    if counts.total >= max_total {
605        let attempted = counts.total.saturating_add(1);
606        return Err(ToolExecutionResult::tool_error(format!(
607            "Detached spawn total cap exceeded: spawning this detached session would create {attempted} detached peer task records under origin root session {root_session_id}, but max_total_detached_tasks is {max_total}."
608        )));
609    }
610
611    Ok(())
612}
613
614async fn enforce_subagent_depth_cap(
615    session_store: &dyn SessionStore,
616    session: &crate::session::Session,
617    context: &ToolContext,
618) -> Result<(), ToolExecutionResult> {
619    let max_subagent_depth = context.subagent_nesting_policy.max_subagent_depth();
620    let current_depth = current_subagent_depth(session_store, session, max_subagent_depth).await?;
621    let child_depth = current_depth.saturating_add(1);
622
623    if child_depth > max_subagent_depth {
624        return Err(ToolExecutionResult::tool_error(format!(
625            "Subagent nesting depth cap exceeded: spawning this subagent would create depth {child_depth}, but max_subagent_depth is {max_subagent_depth}."
626        )));
627    }
628
629    Ok(())
630}
631
632/// Extract the last assistant/agent message content from a list of messages.
633fn last_agent_message(messages: &[crate::platform_store::PlatformMessage]) -> Option<String> {
634    messages
635        .iter()
636        .rfind(|m| m.role == "agent" || m.role == "assistant")
637        .map(|m| m.content.clone())
638}
639
640/// Mirror a terminal outcome onto the subagent's session task (best-effort;
641/// tolerates a missing registry or task).
642async fn finish_subagent_task(
643    context: &ToolContext,
644    task_id: Option<&str>,
645    state: SessionTaskState,
646    summary: Option<String>,
647    error: Option<TaskError>,
648) {
649    let (Some(registry), Some(task_id)) = (context.session_task_registry.as_ref(), task_id) else {
650        return;
651    };
652    let _ = registry
653        .update(
654            context.session_id,
655            task_id,
656            SessionTaskUpdate {
657                state: Some(state),
658                summary,
659                error,
660                ..Default::default()
661            },
662        )
663        .await;
664}
665
666/// Find the session task tracking a subagent by its child session id.
667async fn find_subagent_task(context: &ToolContext, child_id: SessionId) -> Option<SessionTask> {
668    let registry = context.session_task_registry.as_ref()?;
669    let tasks = registry
670        .list(
671            context.session_id,
672            Some(&SessionTaskFilter {
673                kind: Some(TASK_KIND_SUBAGENT.to_string()),
674                state: None,
675            }),
676        )
677        .await
678        .ok()?;
679    tasks
680        .into_iter()
681        .find(|task| task.links.child_session_id == Some(child_id))
682}
683
684// =============================================================================
685/// Unified delegation wrapper for the subagent target of `spawn_agent`.
686pub struct SpawnSubagentAsAgentTool;
687
688#[async_trait]
689impl Tool for SpawnSubagentAsAgentTool {
690    fn narrate(
691        &self,
692        tool_call: &crate::tool_types::ToolCall,
693        phase: crate::tool_narration::ToolNarrationPhase,
694        locale: Option<&str>,
695        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
696    ) -> Option<String> {
697        Some(crate::tool_narration::narrate_subagent_spawn(
698            &tool_call.arguments,
699            phase,
700            locale,
701        ))
702    }
703
704    fn name(&self) -> &str {
705        "spawn_agent"
706    }
707
708    fn display_name(&self) -> Option<&str> {
709        Some("Spawn Agent")
710    }
711
712    fn description(&self) -> &str {
713        "Delegate a task to a subagent in its own context window. Set target.type to \"subagent\". Runs in the background by default and returns a task_id immediately; set mode to \"foreground\" to block until it completes."
714    }
715
716    fn parameters_schema(&self) -> Value {
717        json!({
718            "type": "object",
719            "properties": {
720                "name": {
721                    "type": "string",
722                    "description": "Human-readable name for the subagent (e.g. 'Test Runner', 'Auth Explorer'). Must be unique within this session."
723                },
724                "instructions": {
725                    "type": "string",
726                    "description": "Instructions for the subagent — what it should do."
727                },
728                "target": {
729                    "type": "object",
730                    "properties": {
731                        "type": {
732                            "type": "string",
733                            "enum": ["subagent"],
734                            "description": "Delegation target type. Use \"subagent\" for a same-agent child session."
735                        }
736                    },
737                    "required": ["type"],
738                    "additionalProperties": false
739                },
740                "mode": {
741                    "type": "string",
742                    "enum": ["background", "foreground"],
743                    "description": "Execution mode. \"background\" (default) returns immediately with a task_id — monitor with get_task/wait_task; the session is notified when the subagent finishes. \"foreground\" blocks until the subagent completes and returns its result inline."
744                },
745                "blueprint": {
746                    "type": "string",
747                    "description": "Blueprint ID to spawn a specialist agent with its own tools and model. Omit to inherit parent's configuration."
748                },
749                "config": {
750                    "type": "object",
751                    "description": "Blueprint-specific configuration. Only valid when `blueprint` is set. Validated against the blueprint's config schema."
752                },
753                "result_schema": {
754                    "type": "object",
755                    "description": "Optional JSON Schema for the subagent's final structured result. When set, the child receives report_result and must call it before the task can succeed."
756                },
757                "message_schema": {
758                    "type": "object",
759                    "description": "Optional JSON Schema for structured progress messages. When set, the child receives report_task_progress and valid calls post data messages to the task thread."
760                },
761                "push_configs": {
762                    "type": "array",
763                    "description": "Optional per-task webhook targets notified on task events. Each entry: { url, secret? (HMAC-SHA256 signing key), event_filter? (subset of [\"terminal\", \"awaiting_input\", \"message\"]; defaults to [\"terminal\"]) }. URLs are SSRF-validated.",
764                    "items": {
765                        "type": "object",
766                        "properties": {
767                            "url": { "type": "string" },
768                            "secret": { "type": "string" },
769                            "event_filter": {
770                                "type": "array",
771                                "items": {
772                                    "type": "string",
773                                    "enum": ["terminal", "awaiting_input", "message"]
774                                }
775                            }
776                        },
777                        "required": ["url"],
778                        "additionalProperties": false
779                    }
780                }
781            },
782            "required": ["name", "instructions", "target"],
783            "additionalProperties": false
784        })
785    }
786
787    fn hints(&self) -> ToolHints {
788        ToolHints::default()
789            .with_long_running(true)
790            .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS)
791    }
792
793    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
794        ToolExecutionResult::tool_error(
795            "spawn_agent requires context. This tool must be executed with session context.",
796        )
797    }
798
799    async fn execute_with_context(
800        &self,
801        arguments: Value,
802        context: &ToolContext,
803    ) -> ToolExecutionResult {
804        let target = arguments.get("target").unwrap_or(&Value::Null);
805        if target.get("type").and_then(Value::as_str) != Some("subagent") {
806            return ToolExecutionResult::tool_error(
807                "spawn_agent target.type must be \"subagent\" for the subagents capability",
808            );
809        }
810        spawn_agent_subagent_impl(arguments, context)
811            .await
812            .unwrap_or_else(|e| e)
813    }
814
815    fn requires_context(&self) -> bool {
816        true
817    }
818}
819
820/// Resolve the effective spawn mode from the `mode` argument.
821///
822/// Background needs a session task registry (it is the only surface through
823/// which the parent can observe the result): an explicit `background` without
824/// one is an error, while the unspecified default degrades to foreground so
825/// embedders without background tracking keep blocking semantics.
826fn resolve_spawn_mode(
827    arguments: &Value,
828    context: &ToolContext,
829) -> Result<SpawnMode, ToolExecutionResult> {
830    let explicit = match arguments
831        .get("mode")
832        .and_then(Value::as_str)
833        .map(str::trim)
834        .filter(|s| !s.is_empty())
835    {
836        None => None,
837        Some(value) if let Some(mode) = SpawnMode::parse(value) => Some(mode),
838        Some(other) => {
839            return Err(ToolExecutionResult::tool_error(format!(
840                "Invalid mode: \"{other}\". Valid modes: background, foreground."
841            )));
842        }
843    };
844    let has_registry = context.session_task_registry.is_some();
845    match explicit {
846        Some(SpawnMode::Background) if !has_registry => Err(ToolExecutionResult::tool_error(
847            "Background mode requires a session task registry, which is not available in this environment. Use mode: \"foreground\" instead.",
848        )),
849        Some(mode) => Ok(mode),
850        None if has_registry => Ok(SpawnMode::Background),
851        None => Ok(SpawnMode::Foreground),
852    }
853}
854
855async fn spawn_agent_subagent_impl(
856    arguments: Value,
857    context: &ToolContext,
858) -> Result<ToolExecutionResult, ToolExecutionResult> {
859    let name = require_str(&arguments, "name")?.trim().to_string();
860    let instructions = require_str(&arguments, "instructions")?.to_string();
861    let goal = arguments
862        .get("goal")
863        .and_then(Value::as_str)
864        .map(str::trim)
865        .filter(|value| !value.is_empty())
866        .map(str::to_string);
867    let mode = resolve_spawn_mode(&arguments, context)?;
868    let lifetime = SpawnLifetime::parse(&arguments)?;
869    let seed = parse_seed(&arguments)?;
870
871    let store = get_platform_store(context)?;
872    let session_store = get_session_store(context)?;
873
874    let blueprint_param = arguments
875        .get("blueprint")
876        .and_then(|v| v.as_str())
877        .filter(|s| !s.trim().is_empty())
878        .map(|s| s.to_string());
879    let config_param = arguments.get("config").filter(|v| !v.is_null()).cloned();
880    let result_schema = normalize_result_schema(&arguments)?;
881    let message_schema = normalize_message_schema(&arguments)?;
882    // SSRF-validate spawn-time push config URLs before they enter the task spec.
883    let push_configs = normalize_push_configs(&arguments)?;
884
885    // Reject config without blueprint
886    if config_param.is_some() && blueprint_param.is_none() {
887        return Ok(ToolExecutionResult::tool_error(
888            "The `config` parameter is only valid when `blueprint` is set.",
889        ));
890    }
891
892    // Nesting check: allow governed nesting up to the resolved depth cap.
893    let parent_session = match session_store.get_session(context.session_id).await {
894        Ok(Some(s)) => s,
895        Ok(None) => return Ok(ToolExecutionResult::tool_error("Current session not found")),
896        Err(e) => return Err(ToolExecutionResult::internal_error(e)),
897    };
898
899    if lifetime == SpawnLifetime::Linked
900        && let Err(error) =
901            enforce_subagent_depth_cap(session_store, &parent_session, context).await
902    {
903        return Ok(error);
904    }
905
906    // Validate blueprint exists and is allowed for this parent session.
907    if let Some(ref bp_id) = blueprint_param {
908        let Some(ref registry) = context.capability_registry else {
909            return Ok(ToolExecutionResult::tool_error(
910                "Blueprint support requires capability_registry context.",
911            ));
912        };
913
914        let Some((blueprint_capability_id, blueprint)) = registry.blueprint_with_capability(bp_id)
915        else {
916            return Ok(ToolExecutionResult::tool_error(format!(
917                "Unknown blueprint: \"{bp_id}\". Check available blueprints."
918            )));
919        };
920
921        // Validate config against schema if blueprint has one.
922        if let Some(ref schema) = blueprint.config_schema
923            && config_param.is_none()
924            && schema
925                .get("required")
926                .is_some_and(|r| r.as_array().is_some_and(|arr| !arr.is_empty()))
927        {
928            return Ok(ToolExecutionResult::tool_error(format!(
929                "Blueprint \"{bp_id}\" requires config. Schema: {}",
930                serde_json::to_string_pretty(schema).unwrap_or_default()
931            )));
932        }
933
934        let allowed_capability_ids = if let Some(agent_id) = parent_session.agent_id {
935            match store.get_agent_by_id(agent_id).await {
936                Ok(Some(agent)) => agent
937                    .capabilities
938                    .iter()
939                    .map(|c| c.capability_id().to_string())
940                    .collect::<Vec<_>>(),
941                Ok(None) => vec![],
942                Err(e) => return Err(ToolExecutionResult::internal_error(e)),
943            }
944        } else {
945            match store.get_harness(parent_session.harness_id).await {
946                Ok(Some(harness)) => harness
947                    .capabilities
948                    .iter()
949                    .map(|c| c.capability_id().to_string())
950                    .collect::<Vec<_>>(),
951                Ok(None) => vec![],
952                Err(e) => return Err(ToolExecutionResult::internal_error(e)),
953            }
954        };
955
956        if !allowed_capability_ids
957            .iter()
958            .any(|capability_id| capability_id == &blueprint_capability_id)
959        {
960            return Ok(ToolExecutionResult::tool_error(format!(
961                "Blueprint \"{bp_id}\" is not enabled for this session."
962            )));
963        }
964    }
965
966    // --- Durable spawn handle claim (EVE-535) ---
967    //
968    // When a spawn store and tool_call_id are available, attempt to claim a
969    // spawn slot before creating the child session.  On reclaim, this lets us
970    // reattach to the existing child instead of spawning a duplicate.
971    if lifetime == SpawnLifetime::Linked
972        && let (Some(spawn_store), Some(tool_call_id)) =
973            (&context.subagent_spawn_store, &context.tool_call_id)
974    {
975        let claim_token = uuid::Uuid::new_v4();
976
977        let claim = match spawn_store
978            .try_claim_spawn(context.session_id, tool_call_id, claim_token)
979            .await
980        {
981            Ok(c) => c,
982            Err(e) => return Err(ToolExecutionResult::internal_error(e)),
983        };
984
985        match claim {
986            SpawnClaimResult::AlreadySettled {
987                child_session_id,
988                terminal_status,
989                terminal_result,
990            } => {
991                // Already settled on a previous execution: return stored result.
992                let task_id = find_subagent_task(context, child_session_id)
993                    .await
994                    .map(|t| t.id);
995                return Ok(ToolExecutionResult::success(json!({
996                    "subagent_id": child_session_id.to_string(),
997                    "name": name,
998                    "status": terminal_status,
999                    "result": terminal_result,
1000                    "task_id": task_id,
1001                    "blueprint": blueprint_param,
1002                })));
1003            }
1004            SpawnClaimResult::AlreadyRunning {
1005                child_session_id,
1006                claim_token: stored_claim_token,
1007            } => {
1008                // Child was spawned before but hasn't settled yet — reattach.
1009                // Use the stored claim_token so settle succeeds on this replay.
1010                let task = find_subagent_task(context, child_session_id).await;
1011                let (task_id, task_attempt) =
1012                    task.map(|t| (Some(t.id), t.attempt)).unwrap_or((None, 1));
1013                match mode {
1014                    SpawnMode::Foreground => {
1015                        return Ok(run_subagent_wait_and_settle(
1016                            store,
1017                            context,
1018                            child_session_id,
1019                            &name,
1020                            &instructions,
1021                            &blueprint_param,
1022                            task_id,
1023                            Some((
1024                                spawn_store.as_ref(),
1025                                tool_call_id.as_str(),
1026                                stored_claim_token,
1027                            )),
1028                        )
1029                        .await);
1030                    }
1031                    SpawnMode::Background => {
1032                        let background_run_permit =
1033                            match try_acquire_background_run_permit(context.session_id) {
1034                                Ok(permit) => permit,
1035                                Err(message) => {
1036                                    return Ok(ToolExecutionResult::tool_error(message));
1037                                }
1038                            };
1039                        // Re-arm the detached watcher so the task still settles;
1040                        // the instructions were already sent on the first claim.
1041                        spawn_background_watcher(
1042                            context,
1043                            child_session_id,
1044                            &name,
1045                            None,
1046                            task_id.clone(),
1047                            task_attempt,
1048                            Some(stored_claim_token),
1049                            background_run_permit,
1050                        );
1051                        return Ok(background_running_result(
1052                            child_session_id,
1053                            &name,
1054                            &task_id,
1055                            &blueprint_param,
1056                        ));
1057                    }
1058                }
1059            }
1060            SpawnClaimResult::Claimed {
1061                spawn_handle_id,
1062                claim_token: actual_claim_token,
1063            }
1064            | SpawnClaimResult::ClaimedPendingChild {
1065                spawn_handle_id,
1066                claim_token: actual_claim_token,
1067            } => {
1068                // First claim (or re-claim after crash before register):
1069                // create child and register it durably before waiting.
1070                return Ok(spawn_create_and_wait(
1071                    store,
1072                    context,
1073                    &parent_session,
1074                    &name,
1075                    goal.as_deref(),
1076                    &instructions,
1077                    &blueprint_param,
1078                    &config_param,
1079                    &result_schema,
1080                    &message_schema,
1081                    &push_configs,
1082                    mode,
1083                    lifetime,
1084                    seed,
1085                    Some((
1086                        spawn_store.as_ref(),
1087                        tool_call_id.as_str(),
1088                        spawn_handle_id,
1089                        actual_claim_token,
1090                    )),
1091                )
1092                .await);
1093            }
1094        }
1095    }
1096
1097    // --- No-spawn-store path (dev / noop) ---
1098    Ok(spawn_create_and_wait(
1099        store,
1100        context,
1101        &parent_session,
1102        &name,
1103        goal.as_deref(),
1104        &instructions,
1105        &blueprint_param,
1106        &config_param,
1107        &result_schema,
1108        &message_schema,
1109        &push_configs,
1110        mode,
1111        lifetime,
1112        seed,
1113        None,
1114    )
1115    .await)
1116}
1117
1118/// Immediate tool result for a background spawn: the child is running and the
1119/// task record is the surface for progress and the final result.
1120fn background_running_result(
1121    child_id: crate::typed_id::SessionId,
1122    name: &str,
1123    task_id: &Option<String>,
1124    blueprint_param: &Option<String>,
1125) -> ToolExecutionResult {
1126    ToolExecutionResult::success(json!({
1127        "subagent_id": child_id.to_string(),
1128        "name": name,
1129        "status": "running",
1130        "mode": "background",
1131        "task_id": task_id,
1132        "blueprint": blueprint_param,
1133        "message": "Subagent started in the background. Monitor it with get_task or wait_task using task_id; the session is notified when it finishes.",
1134    }))
1135}
1136
1137// =============================================================================
1138// Helpers for subagent delegation
1139// =============================================================================
1140
1141/// Create a new child session, then either wait for completion (foreground)
1142/// or detach a watcher and return immediately (background). Settles the spawn
1143/// handle (if a settle context is supplied) when the child reaches a terminal
1144/// state.
1145///
1146/// `settle_ctx` = (spawn_store, tool_call_id, spawn_handle_id, claim_token).
1147/// `spawn_handle_id` is used to call `register_child_session` after child creation.
1148#[allow(clippy::too_many_arguments)]
1149async fn spawn_create_and_wait(
1150    store: &dyn PlatformStore,
1151    context: &ToolContext,
1152    parent_session: &crate::session::Session,
1153    name: &str,
1154    goal: Option<&str>,
1155    instructions: &str,
1156    blueprint_param: &Option<String>,
1157    config_param: &Option<Value>,
1158    result_schema: &Option<Value>,
1159    message_schema: &Option<Value>,
1160    push_configs: &Option<Value>,
1161    mode: SpawnMode,
1162    lifetime: SpawnLifetime,
1163    seed: SessionSeedMode,
1164    settle_ctx: Option<(
1165        &dyn crate::traits::SubagentSpawnStore,
1166        &str,
1167        uuid::Uuid,
1168        uuid::Uuid,
1169    )>,
1170) -> ToolExecutionResult {
1171    let background_run_permit = if mode == SpawnMode::Background {
1172        match try_acquire_background_run_permit(context.session_id) {
1173            Ok(permit) => Some(permit),
1174            Err(message) => return ToolExecutionResult::tool_error(message),
1175        }
1176    } else {
1177        None
1178    };
1179
1180    let Some(session_store) = context.session_store.as_ref() else {
1181        return ToolExecutionResult::tool_error("Subagent spawn requires session_store context");
1182    };
1183    // THREAT[TM-AUTHZ-014][TM-AGENT-028][TM-DOS-030]: Detached peers require
1184    // explicit session-creation authority. The host
1185    // returns the org-validated origin root so detached chains cannot reset
1186    // either spend attribution or their count-cap scope.
1187    let budget_root_session_id = if lifetime == SpawnLifetime::Detached {
1188        let Some(authority) = context.session_creation_authority.as_ref() else {
1189            return ToolExecutionResult::tool_error(
1190                "Detached spawn requires session-creation authority.",
1191            );
1192        };
1193        match authority
1194            .authorize_session_creation(context.session_id)
1195            .await
1196        {
1197            Ok(root_session_id) => Some(root_session_id),
1198            Err(error) => {
1199                return ToolExecutionResult::tool_error(format!(
1200                    "Detached spawn is not authorized to create a session: {error}"
1201                ));
1202            }
1203        }
1204    } else {
1205        None
1206    };
1207
1208    // Governance gate before creating the child session. Linked subagents are
1209    // bounded by the descendant task caps; detached peers reset depth but are
1210    // bounded by the detached caps against the same origin root (EVE-767) so a
1211    // loop of detached spawns cannot escape governance.
1212    let caps_result = match lifetime {
1213        SpawnLifetime::Linked => {
1214            enforce_subagent_task_caps(session_store.as_ref(), parent_session, context).await
1215        }
1216        SpawnLifetime::Detached => {
1217            enforce_detached_spawn_caps(
1218                context,
1219                budget_root_session_id.expect("detached authority returned a root"),
1220            )
1221            .await
1222        }
1223    };
1224    if let Err(error) = caps_result {
1225        return error;
1226    }
1227
1228    // Linked sessions are lifecycle children. Detached sessions are peers: no
1229    // parent_session_id, but fork lineage records who spawned them.
1230    let child_session = match store
1231        .create_session_with_options(PlatformCreateSessionRequest {
1232            harness_id: parent_session.harness_id,
1233            agent_id: if blueprint_param.is_some() {
1234                None // Blueprint sessions don't inherit agent
1235            } else {
1236                parent_session.agent_id
1237            },
1238            title: Some(name.to_string()),
1239            goal: goal.map(str::to_string),
1240            locale: parent_session.locale.clone(),
1241            blueprint_id: blueprint_param.clone(),
1242            blueprint_config: config_param.clone(),
1243            parent_session_id: (lifetime == SpawnLifetime::Linked).then_some(context.session_id),
1244            forked_from_session_id: (lifetime == SpawnLifetime::Detached)
1245                .then_some(context.session_id),
1246            budget_root_session_id,
1247            seed,
1248        })
1249        .await
1250    {
1251        Ok(s) => s,
1252        Err(e) => return ToolExecutionResult::internal_error(e),
1253    };
1254    // Create the session task tracking this subagent (specs/session-tasks.md).
1255    // Background tasks wake the parent on terminal transition through the
1256    // registry-level wake policy; foreground spawns already return the result
1257    // inline, so a wake would be noise.
1258    let mut task_id: Option<String> = None;
1259    let mut task_attempt: i32 = 1;
1260    let mut task_spec = json!({
1261        "instructions": instructions,
1262        "blueprint_id": blueprint_param,
1263        "mode": mode.as_str(),
1264        "lifetime": lifetime.as_str(),
1265        "seed": seed.as_str(),
1266    });
1267    if let Some(schema) = result_schema
1268        && let Some(spec) = task_spec.as_object_mut()
1269    {
1270        spec.insert(RESULT_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1271    }
1272    if let Some(schema) = message_schema
1273        && let Some(spec) = task_spec.as_object_mut()
1274    {
1275        spec.insert(MESSAGE_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1276    }
1277    // Spawn-time push configs (EVE-682): embed in the task spec so the webhook
1278    // notifier delivers alongside endpoint-created (DB-backed) configs. URLs
1279    // were SSRF-validated in normalize_push_configs before reaching here.
1280    if let Some(configs) = push_configs
1281        && let Some(spec) = task_spec.as_object_mut()
1282    {
1283        spec.insert(PUSH_CONFIGS_SPEC_KEY.to_string(), configs.clone());
1284    }
1285
1286    if let Some(ref task_registry) = context.session_task_registry
1287        && let Ok(created) = task_registry
1288            .create(CreateSessionTask {
1289                session_id: context.session_id,
1290                id: None,
1291                kind: match lifetime {
1292                    SpawnLifetime::Linked => TASK_KIND_SUBAGENT,
1293                    SpawnLifetime::Detached => TASK_KIND_SESSION,
1294                }
1295                .to_string(),
1296                display_name: name.to_string(),
1297                spec: task_spec,
1298                state: SessionTaskState::Running,
1299                links: TaskLinks {
1300                    child_session_id: Some(child_session.id),
1301                    ..Default::default()
1302                },
1303                wake_policy: match (lifetime, mode, message_schema.is_some()) {
1304                    (SpawnLifetime::Detached, _, _) => TaskWakePolicy::Silent,
1305                    (SpawnLifetime::Linked, SpawnMode::Background, true) => {
1306                        TaskWakePolicy::OnActivity
1307                    }
1308                    (SpawnLifetime::Linked, SpawnMode::Background, false) => {
1309                        TaskWakePolicy::OnTerminal
1310                    }
1311                    (SpawnLifetime::Linked, SpawnMode::Foreground, _) => TaskWakePolicy::Silent,
1312                },
1313            })
1314            .await
1315    {
1316        task_id = Some(created.id);
1317        task_attempt = created.attempt;
1318    }
1319
1320    // Register child session ID durably BEFORE waiting.
1321    // This is the durability boundary: once registered, a reclaim/replay can
1322    // reattach to this child instead of spawning another.
1323    let wait_settle_ctx = if let Some((spawn_store, tool_call_id, spawn_handle_id, claim_token)) =
1324        settle_ctx
1325    {
1326        if let Err(e) = spawn_store
1327            .register_child_session(spawn_handle_id, claim_token, child_session.id)
1328            .await
1329        {
1330            tracing::warn!(
1331                tool_call_id,
1332                error = %e,
1333                "Failed to register child session in spawn handle; proceeding without durable reattach"
1334            );
1335        }
1336        Some((spawn_store, tool_call_id, claim_token))
1337    } else {
1338        None
1339    };
1340
1341    if mode == SpawnMode::Background {
1342        // The first message is sent inside the watcher: local/embedded hosts
1343        // (everruns-runtime) run the child's turn synchronously inside
1344        // send_message, so sending here would block the spawn call.
1345        spawn_background_watcher(
1346            context,
1347            child_session.id,
1348            name,
1349            Some(instructions.to_string()),
1350            task_id.clone(),
1351            task_attempt,
1352            wait_settle_ctx.map(|(_, _, claim_token)| claim_token),
1353            background_run_permit.expect("background permit acquired for background mode"),
1354        );
1355        return background_running_result(child_session.id, name, &task_id, blueprint_param);
1356    }
1357
1358    // Send the instructions as the first message
1359    if let Err(e) = store.send_message(child_session.id, instructions).await {
1360        finish_subagent_task(
1361            context,
1362            task_id.as_deref(),
1363            SessionTaskState::Failed,
1364            None,
1365            Some(TaskError {
1366                kind: "error".to_string(),
1367                message: e.to_string(),
1368            }),
1369        )
1370        .await;
1371        return ToolExecutionResult::internal_error(e);
1372    }
1373
1374    run_subagent_wait_and_settle(
1375        store,
1376        context,
1377        child_session.id,
1378        name,
1379        instructions,
1380        blueprint_param,
1381        task_id,
1382        wait_settle_ctx,
1383    )
1384    .await
1385}
1386
1387/// Wait for a child session to reach idle, collect its result, update the
1388/// registry, and settle the spawn handle (if a settle context is supplied).
1389#[allow(clippy::too_many_arguments)]
1390async fn run_subagent_wait_and_settle(
1391    store: &dyn PlatformStore,
1392    context: &ToolContext,
1393    child_id: crate::typed_id::SessionId,
1394    name: &str,
1395    _instructions: &str,
1396    blueprint_param: &Option<String>,
1397    task_id: Option<String>,
1398    settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
1399) -> ToolExecutionResult {
1400    // Foreground mode: wait for completion
1401    let status = match store.wait_for_idle(child_id, Some(300)).await {
1402        Ok(s) => s,
1403        Err(e) => {
1404            finish_subagent_task(
1405                context,
1406                task_id.as_deref(),
1407                SessionTaskState::Failed,
1408                None,
1409                Some(TaskError {
1410                    kind: "error".to_string(),
1411                    message: e.to_string(),
1412                }),
1413            )
1414            .await;
1415            return ToolExecutionResult::success(json!({
1416                "subagent_id": child_id.to_string(),
1417                "name": name,
1418                "status": "failed",
1419                "error": e.to_string(),
1420                "task_id": task_id,
1421                "blueprint": blueprint_param,
1422            }));
1423        }
1424    };
1425
1426    let result_text = match settle_subagent_outcome(
1427        store,
1428        context,
1429        child_id,
1430        &status,
1431        task_id.as_deref(),
1432        settle_ctx,
1433    )
1434    .await
1435    {
1436        Ok(text) => text,
1437        Err(error) => return error,
1438    };
1439    let result = result_value_for_task(context, task_id.as_deref())
1440        .await
1441        .unwrap_or_else(|| json!(result_text));
1442
1443    ToolExecutionResult::success(json!({
1444        "subagent_id": child_id.to_string(),
1445        "name": name,
1446        "status": status,
1447        "result": result,
1448        "task_id": task_id,
1449        "blueprint": blueprint_param,
1450    }))
1451}
1452
1453/// Collect the child's final message and, when `status` is terminal, settle
1454/// the spawn handle and mirror the outcome onto the session task. Non-terminal
1455/// statuses (paused, waiting_for_tool_results, timeout) only produce the
1456/// result text — the child stays active and the spawn stays reattachable.
1457async fn settle_subagent_outcome(
1458    store: &dyn PlatformStore,
1459    context: &ToolContext,
1460    child_id: crate::typed_id::SessionId,
1461    status: &str,
1462    task_id: Option<&str>,
1463    settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
1464) -> Result<String, ToolExecutionResult> {
1465    // Get the subagent's response messages
1466    let messages = match store.get_messages(child_id, Some(5)).await {
1467        Ok(m) => m,
1468        Err(e) => return Err(ToolExecutionResult::internal_error(e)),
1469    };
1470
1471    let result_text = last_agent_message(&messages)
1472        .unwrap_or_else(|| format!("Subagent completed with status: {status}"));
1473
1474    let terminal_status = terminal_subagent_status(status);
1475
1476    // Settle the spawn handle only when the child reached a terminal state.
1477    // Non-terminal waits must stay reattachable on replay.
1478    if let Some((spawn_store, tool_call_id, claim_token)) = settle_ctx
1479        && terminal_status.is_some()
1480        && let Err(e) = spawn_store
1481            .settle_spawn(
1482                context.session_id,
1483                tool_call_id,
1484                claim_token,
1485                status,
1486                &result_text,
1487            )
1488            .await
1489    {
1490        // Best-effort: log but don't fail the tool execution.
1491        tracing::warn!(
1492            tool_call_id,
1493            error = %e,
1494            "Failed to settle subagent spawn handle"
1495        );
1496    }
1497
1498    // Update the session task only when the child reached a terminal state.
1499    if let Some(subagent_status) = terminal_status {
1500        let mut task_state = terminal_subagent_task_state(&subagent_status);
1501        let mut task_error = if task_state == SessionTaskState::Failed {
1502            Some(TaskError {
1503                kind: status.to_string(),
1504                message: format!("Subagent session ended with status: {status}"),
1505            })
1506        } else {
1507            None
1508        };
1509        let mut summary = Some(truncate_summary(&result_text));
1510        if task_state == SessionTaskState::Succeeded
1511            && required_result_is_missing(context, task_id).await
1512        {
1513            task_state = SessionTaskState::Failed;
1514            task_error = Some(TaskError {
1515                kind: "no_result".to_string(),
1516                message:
1517                    "Subagent completed without calling report_result for its result_schema task."
1518                        .to_string(),
1519            });
1520            summary = Some("Subagent completed without reporting a structured result.".to_string());
1521        }
1522        finish_subagent_task(context, task_id, task_state, summary, task_error).await;
1523    }
1524
1525    Ok(result_text)
1526}
1527
1528/// Detach a watcher that drives a background subagent to completion: send the
1529/// first message (fresh spawns only), heartbeat the task registry so the
1530/// reaper can detect worker loss, wait for the child's terminal turn status,
1531/// then settle the task and spawn handle. The task's OnTerminal wake policy
1532/// notifies the parent session at the registry level.
1533#[allow(clippy::too_many_arguments)]
1534fn spawn_background_watcher(
1535    context: &ToolContext,
1536    child_id: crate::typed_id::SessionId,
1537    name: &str,
1538    first_message: Option<String>,
1539    task_id: Option<String>,
1540    task_attempt: i32,
1541    claim_token: Option<uuid::Uuid>,
1542    background_run_permit: BackgroundRunPermit,
1543) {
1544    let context = context.clone();
1545    let name = name.to_string();
1546    tokio::spawn(async move {
1547        let _background_run_permit = background_run_permit;
1548        let Some(store) = context.platform_store.clone() else {
1549            // Callers only enter background mode with a platform store wired.
1550            return;
1551        };
1552
1553        if let Some(instructions) = first_message
1554            && let Err(e) = store.send_message(child_id, &instructions).await
1555        {
1556            finish_subagent_task(
1557                &context,
1558                task_id.as_deref(),
1559                SessionTaskState::Failed,
1560                None,
1561                Some(TaskError {
1562                    kind: "error".to_string(),
1563                    message: e.to_string(),
1564                }),
1565            )
1566            .await;
1567            return;
1568        }
1569
1570        // Heartbeat so the session task reaper can fail an orphaned watcher
1571        // (worker loss) instead of leaving the task running forever. Fenced on
1572        // the attempt captured at spawn so a superseded watcher's writes are
1573        // rejected once the reaper bumps the attempt counter.
1574        let heartbeat = async {
1575            let (Some(registry), Some(task_id)) =
1576                (context.session_task_registry.clone(), task_id.clone())
1577            else {
1578                return std::future::pending::<()>().await;
1579            };
1580            loop {
1581                tokio::time::sleep(std::time::Duration::from_secs(
1582                    BACKGROUND_HEARTBEAT_INTERVAL_SECS,
1583                ))
1584                .await;
1585                let _ = registry
1586                    .update(
1587                        context.session_id,
1588                        &task_id,
1589                        SessionTaskUpdate {
1590                            heartbeat_at: Some(chrono::Utc::now()),
1591                            expected_attempt: Some(task_attempt),
1592                            ..Default::default()
1593                        },
1594                    )
1595                    .await;
1596            }
1597        };
1598
1599        let wait_and_settle = async {
1600            let started = tokio::time::Instant::now();
1601            loop {
1602                let status = match store
1603                    .wait_for_idle(child_id, Some(BACKGROUND_WAIT_SLICE_SECS))
1604                    .await
1605                {
1606                    Ok(s) => s,
1607                    Err(e) => {
1608                        finish_subagent_task(
1609                            &context,
1610                            task_id.as_deref(),
1611                            SessionTaskState::Failed,
1612                            None,
1613                            Some(TaskError {
1614                                kind: "error".to_string(),
1615                                message: e.to_string(),
1616                            }),
1617                        )
1618                        .await;
1619                        return;
1620                    }
1621                };
1622
1623                // Local/embedded hosts (everruns-runtime) run the child's turn
1624                // synchronously inside send_message and report a bare `idle`;
1625                // hosted adapters never return it (they poll until a terminal
1626                // turn event lands). Map it to completion so embedder tasks
1627                // settle instead of looping until the cap.
1628                let effective = if status == "idle" {
1629                    "completed".to_string()
1630                } else {
1631                    status
1632                };
1633
1634                if terminal_subagent_status(&effective).is_some() {
1635                    let settle_ctx = match (
1636                        context.subagent_spawn_store.as_ref(),
1637                        context.tool_call_id.as_ref(),
1638                        claim_token,
1639                    ) {
1640                        (Some(spawn_store), Some(tool_call_id), Some(token)) => Some((
1641                            spawn_store.as_ref() as &dyn crate::traits::SubagentSpawnStore,
1642                            tool_call_id.as_str(),
1643                            token,
1644                        )),
1645                        _ => None,
1646                    };
1647                    if let Err(error) = settle_subagent_outcome(
1648                        store.as_ref(),
1649                        &context,
1650                        child_id,
1651                        &effective,
1652                        task_id.as_deref(),
1653                        settle_ctx,
1654                    )
1655                    .await
1656                    {
1657                        tracing::warn!(
1658                            subagent_name = name,
1659                            child_session_id = %child_id,
1660                            ?error,
1661                            "Background subagent settle failed; marking task failed"
1662                        );
1663                        finish_subagent_task(
1664                            &context,
1665                            task_id.as_deref(),
1666                            SessionTaskState::Failed,
1667                            None,
1668                            Some(TaskError {
1669                                kind: "error".to_string(),
1670                                message: "Failed to read subagent result".to_string(),
1671                            }),
1672                        )
1673                        .await;
1674                    }
1675                    return;
1676                }
1677
1678                if started.elapsed().as_secs() >= BACKGROUND_MAX_WAIT_SECS {
1679                    finish_subagent_task(
1680                        &context,
1681                        task_id.as_deref(),
1682                        SessionTaskState::Failed,
1683                        None,
1684                        Some(TaskError {
1685                            kind: "timeout".to_string(),
1686                            message: format!(
1687                                "Background subagent did not finish within {BACKGROUND_MAX_WAIT_SECS}s (last status: {effective})"
1688                            ),
1689                        }),
1690                    )
1691                    .await;
1692                    return;
1693                }
1694
1695                // Non-terminal: record progress and keep waiting. Statuses
1696                // other than the wait-slice timeout return immediately, so
1697                // back off before re-waiting to avoid spinning.
1698                if let (Some(registry), Some(task_id)) =
1699                    (context.session_task_registry.as_ref(), task_id.as_deref())
1700                {
1701                    let _ = registry
1702                        .update(
1703                            context.session_id,
1704                            task_id,
1705                            SessionTaskUpdate {
1706                                state_detail: Some(format!(
1707                                    "waiting for subagent ({}s elapsed, last status: {effective})",
1708                                    started.elapsed().as_secs()
1709                                )),
1710                                expected_attempt: Some(task_attempt),
1711                                ..Default::default()
1712                            },
1713                        )
1714                        .await;
1715                }
1716                if !effective.starts_with("timeout") {
1717                    tokio::time::sleep(std::time::Duration::from_secs(
1718                        BACKGROUND_POLL_BACKOFF_SECS,
1719                    ))
1720                    .await;
1721                }
1722            }
1723        };
1724
1725        tokio::select! {
1726            () = wait_and_settle => {}
1727            () = heartbeat => {}
1728        }
1729    });
1730}
1731
1732// =============================================================================
1733// Task executor: subagent
1734// =============================================================================
1735
1736/// Control plane for `subagent` tasks. Inbound messages and cooperative
1737/// cancellation route through the child session's message channel — there is
1738/// no hard kill, so cancel delivers a graceful stop request via `cancel_task`.
1739pub struct SubagentTaskExecutor;
1740
1741#[async_trait]
1742impl TaskExecutor for SubagentTaskExecutor {
1743    fn kind(&self) -> &str {
1744        TASK_KIND_SUBAGENT
1745    }
1746
1747    async fn deliver(
1748        &self,
1749        task: &SessionTask,
1750        message: &TaskMessage,
1751        context: &ToolContext,
1752    ) -> crate::error::Result<()> {
1753        let Some(store) = context.platform_store.as_ref() else {
1754            return Err(crate::error::AgentLoopError::tool(
1755                "subagent task delivery requires platform_store context",
1756            ));
1757        };
1758        let Some(child_id) = task.links.child_session_id else {
1759            return Err(crate::error::AgentLoopError::tool(format!(
1760                "subagent task {} has no child session link",
1761                task.id
1762            )));
1763        };
1764        let text = task_message_text(&message.content);
1765        store.send_message(child_id, &text).await
1766    }
1767
1768    async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1769        let Some(store) = context.platform_store.as_ref() else {
1770            return Err(crate::error::AgentLoopError::tool(
1771                "subagent task cancellation requires platform_store context",
1772            ));
1773        };
1774        let Some(child_id) = task.links.child_session_id else {
1775            return Err(crate::error::AgentLoopError::tool(format!(
1776                "subagent task {} has no child session link",
1777                task.id
1778            )));
1779        };
1780        // Graceful stop request; takes effect after the current turn.
1781        store
1782            .send_message(
1783                child_id,
1784                "Cancellation requested by the parent session. Stop work, wind down, and reply with a brief summary of progress so far.",
1785            )
1786            .await
1787    }
1788
1789    /// Converge a subagent task whose background watcher is gone (worker
1790    /// loss): probe the child's terminal turn status and mirror it onto the
1791    /// task. Called from wait_task's poll loop; no-op while the child is
1792    /// still working.
1793    async fn reconcile(
1794        &self,
1795        task: &SessionTask,
1796        context: &ToolContext,
1797    ) -> crate::error::Result<()> {
1798        if task.state.is_terminal() {
1799            return Ok(());
1800        }
1801        let (Some(store), Some(child_id)) =
1802            (context.platform_store.as_ref(), task.links.child_session_id)
1803        else {
1804            return Ok(());
1805        };
1806        // Zero-timeout probe: returns the terminal turn status when the child
1807        // already finished, or a timeout marker while it is still running.
1808        let status = store.wait_for_idle(child_id, Some(0)).await?;
1809        if terminal_subagent_status(&status).is_none() {
1810            return Ok(());
1811        }
1812        settle_subagent_outcome(
1813            store.as_ref(),
1814            context,
1815            child_id,
1816            &status,
1817            Some(&task.id),
1818            None,
1819        )
1820        .await
1821        .map(|_| ())
1822        .map_err(|_| {
1823            crate::error::AgentLoopError::tool("Failed to read subagent result during reconcile")
1824        })
1825    }
1826}
1827
1828inventory::submit! {
1829    TaskExecutorPlugin {
1830        executor: || Arc::new(SubagentTaskExecutor),
1831    }
1832}
1833
1834/// Control plane for detached peer-session tracking tasks. `cancel` means
1835/// cancel everywhere (EVE-766): it cooperatively requests the peer session to
1836/// stop via the standard session cancel path, then settles the tracking task
1837/// `canceled`. The peer is a same-org session (inherited via fork lineage), so
1838/// the cooperative-cancel message routes through the ordinary send path — the
1839/// same mechanism linked subagents use.
1840pub struct DetachedSessionTaskExecutor;
1841
1842#[async_trait]
1843impl TaskExecutor for DetachedSessionTaskExecutor {
1844    fn kind(&self) -> &str {
1845        TASK_KIND_SESSION
1846    }
1847
1848    async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1849        let Some(registry) = context.session_task_registry.as_ref() else {
1850            return Ok(());
1851        };
1852        // Option A (EVE-766): cancel actually cancels. Deliver a cooperative
1853        // stop to the peer session first; only settle the tracking task once
1854        // the request is in flight, so a delivery failure surfaces to the
1855        // caller instead of silently claiming the peer was canceled.
1856        let summary = match (context.platform_store.as_ref(), task.links.child_session_id) {
1857            (Some(store), Some(peer_id)) => {
1858                store
1859                    .send_message(
1860                        peer_id,
1861                        "Cancellation requested by the session that spawned you. Stop work, wind down, and end your run.",
1862                    )
1863                    .await?;
1864                "Peer session cancellation requested; tracking settled canceled.".to_string()
1865            }
1866            // No peer link to signal (e.g. never wired): nothing to stop, just
1867            // settle the tracking task so the intent is honored.
1868            _ => "Detached session tracking canceled; no peer session link to signal.".to_string(),
1869        };
1870        registry
1871            .update(
1872                task.session_id,
1873                &task.id,
1874                SessionTaskUpdate {
1875                    state: Some(SessionTaskState::Canceled),
1876                    summary: Some(summary),
1877                    ..Default::default()
1878                },
1879            )
1880            .await?;
1881        Ok(())
1882    }
1883}
1884
1885inventory::submit! {
1886    TaskExecutorPlugin {
1887        executor: || Arc::new(DetachedSessionTaskExecutor),
1888    }
1889}
1890
1891#[cfg(test)]
1892mod tests {
1893    use super::*;
1894    use crate::Tool;
1895    use crate::session_task::{TaskMessageDirection, TaskMessagePart, task_result_path};
1896
1897    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
1898
1899    #[test]
1900    fn capability_features() {
1901        let cap = SubagentCapability;
1902        assert_eq!(cap.features(), vec!["subagents"]);
1903    }
1904
1905    #[test]
1906    fn terminal_subagent_status_maps_only_terminal_wait_states() {
1907        // `idle` is not terminal: a failed turn also idles the worker, so it
1908        // must not settle the subagent as completed.
1909        assert_eq!(terminal_subagent_status("idle"), None);
1910        assert_eq!(
1911            terminal_subagent_status("completed"),
1912            Some(crate::session::SubagentStatus::Completed)
1913        );
1914        assert_eq!(
1915            terminal_subagent_status("failed"),
1916            Some(crate::session::SubagentStatus::Failed)
1917        );
1918        assert_eq!(
1919            terminal_subagent_status("cancelled"),
1920            Some(crate::session::SubagentStatus::Cancelled)
1921        );
1922        assert_eq!(
1923            terminal_subagent_status("sealed"),
1924            Some(crate::session::SubagentStatus::Sealed)
1925        );
1926        assert_eq!(
1927            terminal_subagent_task_state(&crate::session::SubagentStatus::Completed),
1928            SessionTaskState::Succeeded
1929        );
1930        // A sealed subagent settles as a terminal, non-retryable failed task.
1931        assert_eq!(
1932            terminal_subagent_task_state(&crate::session::SubagentStatus::Sealed),
1933            SessionTaskState::Failed
1934        );
1935        assert_eq!(
1936            terminal_subagent_task_state(&crate::session::SubagentStatus::Cancelled),
1937            SessionTaskState::Canceled
1938        );
1939        assert_eq!(
1940            terminal_subagent_task_state(&crate::session::SubagentStatus::MaxIterationsReached),
1941            SessionTaskState::Failed
1942        );
1943        assert_eq!(terminal_subagent_status("waiting_for_tool_results"), None);
1944        assert_eq!(terminal_subagent_status("paused"), None);
1945    }
1946
1947    #[test]
1948    fn subagent_nesting_policy_resolves_platform_org_agent_precedence() {
1949        let platform = crate::traits::SubagentNestingPolicy::default().with_platform_default(4);
1950        assert_eq!(platform.max_subagent_depth(), 4);
1951
1952        let org = platform.with_org_override(Some(3));
1953        assert_eq!(org.max_subagent_depth(), 3);
1954
1955        let agent = org.with_agent_override(Some(1));
1956        assert_eq!(agent.max_subagent_depth(), 1);
1957    }
1958
1959    #[test]
1960    fn subagent_capability_is_high_risk() {
1961        assert_eq!(SubagentCapability.risk_level(), RiskLevel::High);
1962    }
1963
1964    #[test]
1965    fn spawn_agent_subagent_schema_advertises_only_subagent_target() {
1966        let tool = SpawnSubagentAsAgentTool;
1967        let schema = tool.parameters_schema();
1968        assert_eq!(
1969            schema["properties"]["target"]["properties"]["type"]["enum"],
1970            json!(["subagent"])
1971        );
1972        let required = schema["required"].as_array().unwrap();
1973        assert!(required.contains(&json!("target")));
1974        assert!(required.contains(&json!("name")));
1975        assert!(required.contains(&json!("instructions")));
1976        let props = schema["properties"].as_object().unwrap();
1977        assert!(props.contains_key("blueprint"));
1978        assert!(props.contains_key("config"));
1979        assert!(props.contains_key("result_schema"));
1980        assert!(props.contains_key("message_schema"));
1981        assert!(!required.contains(&json!("blueprint")));
1982        assert!(!required.contains(&json!("config")));
1983        assert_eq!(
1984            schema["properties"]["mode"]["enum"],
1985            json!(["background", "foreground"])
1986        );
1987        assert_eq!(
1988            tool.hints().concurrency_class.as_deref(),
1989            Some(SPAWN_AGENT_CONCURRENCY_CLASS),
1990            "spawn_agent calls share one scheduler class so cap admission is serialized"
1991        );
1992    }
1993
1994    // =========================================================================
1995    // Spawn handle tests (EVE-535)
1996    // =========================================================================
1997
1998    use crate::traits::{NoopSubagentSpawnStore, SpawnClaimResult, SubagentSpawnStore};
1999    use std::sync::Arc;
2000
2001    /// NoopSubagentSpawnStore always returns Claimed with a fresh token.
2002    #[tokio::test]
2003    async fn noop_spawn_store_always_claims() {
2004        let store = NoopSubagentSpawnStore;
2005        let parent = crate::typed_id::SessionId::new();
2006        let token = uuid::Uuid::new_v4();
2007
2008        let result = store
2009            .try_claim_spawn(parent, "call-1", token)
2010            .await
2011            .expect("noop should not error");
2012
2013        assert!(
2014            matches!(result, SpawnClaimResult::Claimed { claim_token, .. } if claim_token == token),
2015            "noop store should return Claimed with the supplied token"
2016        );
2017    }
2018
2019    /// NoopSubagentSpawnStore register and settle are always successful.
2020    #[tokio::test]
2021    async fn noop_spawn_store_register_and_settle_are_noops() {
2022        let store = NoopSubagentSpawnStore;
2023        let parent = crate::typed_id::SessionId::new();
2024        let child = crate::typed_id::SessionId::new();
2025        let handle_id = uuid::Uuid::new_v4();
2026        let token = uuid::Uuid::new_v4();
2027
2028        store
2029            .register_child_session(handle_id, token, child)
2030            .await
2031            .expect("noop register should not error");
2032
2033        store
2034            .settle_spawn(parent, "call-1", token, "idle", "result text")
2035            .await
2036            .expect("noop settle should not error");
2037    }
2038
2039    /// Arc<dyn SubagentSpawnStore> blanket impl delegates correctly.
2040    #[tokio::test]
2041    async fn arc_spawn_store_delegates() {
2042        let store: Arc<dyn SubagentSpawnStore> = Arc::new(NoopSubagentSpawnStore);
2043        let parent = crate::typed_id::SessionId::new();
2044        let token = uuid::Uuid::new_v4();
2045
2046        let result = store
2047            .try_claim_spawn(parent, "call-arc", token)
2048            .await
2049            .expect("arc delegation should not error");
2050
2051        assert!(matches!(result, SpawnClaimResult::Claimed { .. }));
2052    }
2053
2054    // =========================================================================
2055    // Background mode
2056    // =========================================================================
2057
2058    use crate::capabilities::session_tasks::tests::InMemorySessionTaskRegistry;
2059    use crate::platform_store::tests::MockPlatformStore;
2060    use crate::session_file::SessionFile;
2061    use crate::session_task::SessionTaskRegistry;
2062    use crate::traits::SessionFileSystem;
2063    use chrono::Utc;
2064    use std::collections::HashMap;
2065    use std::sync::Mutex;
2066
2067    /// SessionStore view over the mock platform store (depth-policy lookup).
2068    struct MockSessionStore(Arc<MockPlatformStore>);
2069
2070    struct MockSessionCreationAuthority {
2071        root: crate::typed_id::SessionId,
2072        allowed: bool,
2073    }
2074
2075    #[async_trait]
2076    impl crate::traits::SessionCreationAuthority for MockSessionCreationAuthority {
2077        async fn authorize_session_creation(
2078            &self,
2079            _session_id: crate::typed_id::SessionId,
2080        ) -> crate::error::Result<crate::typed_id::SessionId> {
2081            if self.allowed {
2082                Ok(self.root)
2083            } else {
2084                Err(crate::error::AgentLoopError::tool(
2085                    "org:sessions:manage is required",
2086                ))
2087            }
2088        }
2089    }
2090
2091    #[async_trait]
2092    impl crate::traits::SessionStore for MockSessionStore {
2093        async fn get_session(
2094            &self,
2095            session_id: crate::typed_id::SessionId,
2096        ) -> crate::error::Result<Option<crate::session::Session>> {
2097            self.0.get_session_by_id(session_id).await
2098        }
2099    }
2100
2101    fn spawn_context(
2102        store: &Arc<MockPlatformStore>,
2103        registry: Option<Arc<InMemorySessionTaskRegistry>>,
2104    ) -> ToolContext {
2105        spawn_context_for_session(store, registry, store.session.id)
2106    }
2107
2108    fn spawn_context_for_session(
2109        store: &Arc<MockPlatformStore>,
2110        registry: Option<Arc<InMemorySessionTaskRegistry>>,
2111        session_id: crate::typed_id::SessionId,
2112    ) -> ToolContext {
2113        let mut context = ToolContext::new(session_id);
2114        context.platform_store = Some(store.clone());
2115        context.session_store = Some(Arc::new(MockSessionStore(store.clone())));
2116        context.session_creation_authority = Some(Arc::new(MockSessionCreationAuthority {
2117            root: store.session.id,
2118            allowed: true,
2119        }));
2120        if let Some(registry) = registry {
2121            context.session_task_registry = Some(registry);
2122        }
2123        context
2124    }
2125
2126    async fn spawn(context: &ToolContext, args: Value) -> ToolExecutionResult {
2127        let mut args = args;
2128        if let Some(object) = args.as_object_mut() {
2129            object
2130                .entry("target")
2131                .or_insert_with(|| json!({"type": "subagent"}));
2132        }
2133        SpawnSubagentAsAgentTool
2134            .execute_with_context(args, context)
2135            .await
2136    }
2137
2138    /// Poll the registry until the subagent task reaches `state` (the
2139    /// background watcher settles it from a detached tokio task).
2140    async fn wait_for_task_state(
2141        registry: &InMemorySessionTaskRegistry,
2142        session_id: crate::typed_id::SessionId,
2143        task_id: &str,
2144        state: crate::session_task::SessionTaskState,
2145    ) -> crate::session_task::SessionTask {
2146        for _ in 0..200 {
2147            let task = registry
2148                .get(session_id, task_id)
2149                .await
2150                .expect("registry get")
2151                .expect("task exists");
2152            if task.state == state {
2153                return task;
2154            }
2155            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2156        }
2157        panic!("task {task_id} did not reach {state:?}");
2158    }
2159
2160    #[derive(Default)]
2161    struct MemoryFileStore {
2162        files: Mutex<HashMap<(uuid::Uuid, String), String>>,
2163    }
2164
2165    #[async_trait]
2166    impl SessionFileSystem for MemoryFileStore {
2167        fn is_mount_resolver(&self) -> bool {
2168            false
2169        }
2170
2171        async fn read_file(
2172            &self,
2173            session_id: crate::typed_id::SessionId,
2174            path: &str,
2175        ) -> crate::error::Result<Option<SessionFile>> {
2176            let content = self
2177                .files
2178                .lock()
2179                .unwrap()
2180                .get(&(session_id.uuid(), path.to_string()))
2181                .cloned();
2182            Ok(content.map(|content| SessionFile {
2183                id: uuid::Uuid::new_v4(),
2184                session_id: session_id.uuid(),
2185                path: path.to_string(),
2186                name: path.rsplit('/').next().unwrap_or(path).to_string(),
2187                content: Some(content.clone()),
2188                encoding: "utf-8".to_string(),
2189                is_directory: false,
2190                is_readonly: false,
2191                size_bytes: content.len() as i64,
2192                created_at: Utc::now(),
2193                updated_at: Utc::now(),
2194            }))
2195        }
2196
2197        async fn write_file(
2198            &self,
2199            session_id: crate::typed_id::SessionId,
2200            path: &str,
2201            content: &str,
2202            _encoding: &str,
2203        ) -> crate::error::Result<SessionFile> {
2204            self.files
2205                .lock()
2206                .unwrap()
2207                .insert((session_id.uuid(), path.to_string()), content.to_string());
2208            Ok(SessionFile {
2209                id: uuid::Uuid::new_v4(),
2210                session_id: session_id.uuid(),
2211                path: path.to_string(),
2212                name: path.rsplit('/').next().unwrap_or(path).to_string(),
2213                content: Some(content.to_string()),
2214                encoding: "utf-8".to_string(),
2215                is_directory: false,
2216                is_readonly: false,
2217                size_bytes: content.len() as i64,
2218                created_at: Utc::now(),
2219                updated_at: Utc::now(),
2220            })
2221        }
2222
2223        async fn delete_file(
2224            &self,
2225            session_id: crate::typed_id::SessionId,
2226            path: &str,
2227            _recursive: bool,
2228        ) -> crate::error::Result<bool> {
2229            Ok(self
2230                .files
2231                .lock()
2232                .unwrap()
2233                .remove(&(session_id.uuid(), path.to_string()))
2234                .is_some())
2235        }
2236
2237        async fn list_directory(
2238            &self,
2239            _session_id: crate::typed_id::SessionId,
2240            _path: &str,
2241        ) -> crate::error::Result<Vec<crate::session_file::FileInfo>> {
2242            Ok(vec![])
2243        }
2244
2245        async fn stat_file(
2246            &self,
2247            session_id: crate::typed_id::SessionId,
2248            path: &str,
2249        ) -> crate::error::Result<Option<crate::session_file::FileStat>> {
2250            let content = self
2251                .files
2252                .lock()
2253                .unwrap()
2254                .get(&(session_id.uuid(), path.to_string()))
2255                .cloned();
2256            Ok(content.map(|content| crate::session_file::FileStat {
2257                path: path.to_string(),
2258                name: path.rsplit('/').next().unwrap_or(path).to_string(),
2259                is_directory: false,
2260                is_readonly: false,
2261                size_bytes: content.len() as i64,
2262                created_at: Utc::now(),
2263                updated_at: Utc::now(),
2264            }))
2265        }
2266
2267        async fn grep_files(
2268            &self,
2269            _session_id: crate::typed_id::SessionId,
2270            _pattern: &str,
2271            _path_pattern: Option<&str>,
2272        ) -> crate::error::Result<Vec<crate::session_file::GrepMatch>> {
2273            Ok(vec![])
2274        }
2275
2276        async fn create_directory(
2277            &self,
2278            session_id: crate::typed_id::SessionId,
2279            path: &str,
2280        ) -> crate::error::Result<crate::session_file::FileInfo> {
2281            Ok(crate::session_file::FileInfo {
2282                id: uuid::Uuid::new_v4(),
2283                session_id: session_id.uuid(),
2284                path: path.to_string(),
2285                name: path.rsplit('/').next().unwrap_or(path).to_string(),
2286                is_directory: true,
2287                is_readonly: false,
2288                size_bytes: 0,
2289                created_at: Utc::now(),
2290                updated_at: Utc::now(),
2291            })
2292        }
2293    }
2294
2295    #[tokio::test]
2296    async fn spawn_agent_subagent_rejects_invalid_mode() {
2297        let context = ToolContext::new(crate::typed_id::SessionId::new());
2298        let result = spawn(
2299            &context,
2300            json!({"name": "Runner", "instructions": "go", "mode": "asap"}),
2301        )
2302        .await;
2303        let ToolExecutionResult::ToolError(msg) = result else {
2304            panic!("expected ToolError, got {result:?}");
2305        };
2306        assert!(msg.contains("Invalid mode"), "got: {msg}");
2307    }
2308
2309    #[tokio::test]
2310    async fn spawn_agent_subagent_rejects_other_target_types() {
2311        let context = ToolContext::new(crate::typed_id::SessionId::new());
2312        let result = SpawnSubagentAsAgentTool
2313            .execute_with_context(
2314                json!({
2315                    "name": "Runner",
2316                    "instructions": "go",
2317                    "target": {"type": "external_a2a"}
2318                }),
2319                &context,
2320            )
2321            .await;
2322        let ToolExecutionResult::ToolError(msg) = result else {
2323            panic!("expected ToolError, got {result:?}");
2324        };
2325        assert!(msg.contains("subagent"), "got: {msg}");
2326    }
2327
2328    #[tokio::test]
2329    async fn spawn_agent_subagent_creates_subagent_task() {
2330        let store = Arc::new(MockPlatformStore::new());
2331        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2332        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2333        let context = spawn_context(&store, Some(registry.clone()));
2334
2335        let result = SpawnSubagentAsAgentTool
2336            .execute_with_context(
2337                json!({
2338                    "name": "Runner",
2339                    "instructions": "go",
2340                    "target": {"type": "subagent"},
2341                    "mode": "foreground"
2342                }),
2343                &context,
2344            )
2345            .await;
2346        let ToolExecutionResult::Success(value) = result else {
2347            panic!("expected success, got {result:?}");
2348        };
2349        let task_id = value["task_id"].as_str().expect("task_id");
2350        let task = registry
2351            .get(context.session_id, task_id)
2352            .await
2353            .unwrap()
2354            .unwrap();
2355        assert_eq!(task.kind, TASK_KIND_SUBAGENT);
2356        assert_eq!(task.spec["mode"], "foreground");
2357        assert!(task.links.child_session_id.is_some());
2358    }
2359
2360    #[tokio::test]
2361    async fn detached_spawn_creates_peer_session_task_with_goal_and_lineage() {
2362        let store = Arc::new(MockPlatformStore::new());
2363        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2364        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2365        let context = spawn_context(&store, Some(registry.clone()));
2366
2367        let result = spawn(
2368            &context,
2369            json!({
2370                "name": "Research Peer",
2371                "goal": "Investigate latency",
2372                "instructions": "go",
2373                "lifetime": "detached",
2374                "seed": "workspace",
2375                "mode": "foreground"
2376            }),
2377        )
2378        .await;
2379        let ToolExecutionResult::Success(value) = result else {
2380            panic!("expected success, got {result:?}");
2381        };
2382        let child_id: crate::typed_id::SessionId = value["subagent_id"]
2383            .as_str()
2384            .expect("subagent_id")
2385            .parse()
2386            .expect("valid session id");
2387        let child = store
2388            .get_session_by_id(child_id)
2389            .await
2390            .unwrap()
2391            .expect("child session");
2392        assert_eq!(child.parent_session_id, None);
2393        assert_eq!(child.forked_from_session_id, Some(context.session_id));
2394        assert_eq!(child.title.as_deref(), Some("Research Peer"));
2395        assert_eq!(child.goal.as_deref(), Some("Investigate latency"));
2396        assert_eq!(
2397            store
2398                .created_session_budget_roots
2399                .lock()
2400                .unwrap()
2401                .as_slice(),
2402            &[Some(store.session.id)]
2403        );
2404
2405        let task_id = value["task_id"].as_str().expect("task_id");
2406        let task = registry
2407            .get(context.session_id, task_id)
2408            .await
2409            .unwrap()
2410            .expect("task");
2411        assert_eq!(task.kind, TASK_KIND_SESSION);
2412        assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
2413        assert_eq!(task.links.child_session_id, Some(child_id));
2414        assert_eq!(task.spec["lifetime"], "detached");
2415        assert_eq!(task.spec["seed"], "workspace");
2416    }
2417
2418    #[tokio::test]
2419    async fn detached_spawn_requires_session_creation_authority_before_creation() {
2420        let store = Arc::new(MockPlatformStore::new());
2421        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2422        let mut context = spawn_context(&store, Some(registry));
2423        context.session_creation_authority = None;
2424
2425        let result = spawn(
2426            &context,
2427            json!({"name": "Denied", "instructions": "go", "lifetime": "detached"}),
2428        )
2429        .await;
2430        let ToolExecutionResult::ToolError(message) = result else {
2431            panic!("expected authority ToolError, got {result:?}");
2432        };
2433        assert!(message.contains("session-creation authority"));
2434        assert!(
2435            store
2436                .created_session_budget_roots
2437                .lock()
2438                .unwrap()
2439                .is_empty()
2440        );
2441    }
2442
2443    #[tokio::test]
2444    async fn detached_spawn_reports_permission_denial_before_creation() {
2445        let store = Arc::new(MockPlatformStore::new());
2446        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2447        let mut context = spawn_context(&store, Some(registry));
2448        context.session_creation_authority = Some(Arc::new(MockSessionCreationAuthority {
2449            root: store.session.id,
2450            allowed: false,
2451        }));
2452
2453        let result = spawn(
2454            &context,
2455            json!({"name": "Denied", "instructions": "go", "lifetime": "detached"}),
2456        )
2457        .await;
2458        let ToolExecutionResult::ToolError(message) = result else {
2459            panic!("expected permission ToolError, got {result:?}");
2460        };
2461        assert!(message.contains("not authorized"));
2462        assert!(message.contains("org:sessions:manage"));
2463        assert!(
2464            store
2465                .created_session_budget_roots
2466                .lock()
2467                .unwrap()
2468                .is_empty()
2469        );
2470    }
2471
2472    #[tokio::test]
2473    async fn detached_spawn_bypasses_subagent_depth_guard() {
2474        let store = Arc::new(MockPlatformStore::new());
2475        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2476        let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2477            crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
2478        );
2479
2480        let linked = spawn(
2481            &context,
2482            json!({"name": "Linked", "instructions": "go", "mode": "foreground"}),
2483        )
2484        .await;
2485        assert!(matches!(linked, ToolExecutionResult::ToolError(_)));
2486
2487        let detached = spawn(
2488            &context,
2489            json!({
2490                "name": "Detached",
2491                "instructions": "go",
2492                "mode": "foreground",
2493                "lifetime": "detached"
2494            }),
2495        )
2496        .await;
2497        assert!(
2498            matches!(detached, ToolExecutionResult::Success(_)),
2499            "detached spawn should bypass linked depth guard, got {detached:?}"
2500        );
2501    }
2502
2503    // EVE-767: detached spawns reset depth but are still capped against the
2504    // origin root so a loop of detached spawns cannot run unbounded (TM-DOS).
2505
2506    fn session_task_under(
2507        root: crate::typed_id::SessionId,
2508        kind: &str,
2509        state: SessionTaskState,
2510    ) -> CreateSessionTask {
2511        CreateSessionTask {
2512            session_id: root,
2513            id: None,
2514            kind: kind.to_string(),
2515            display_name: "t".to_string(),
2516            spec: json!({}),
2517            state,
2518            links: TaskLinks {
2519                child_session_id: Some(crate::typed_id::SessionId::new()),
2520                ..Default::default()
2521            },
2522            wake_policy: TaskWakePolicy::Silent,
2523        }
2524    }
2525
2526    #[tokio::test]
2527    async fn detached_task_counts_ignore_subagent_and_terminal_active() {
2528        let store = Arc::new(MockPlatformStore::new());
2529        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2530        let root = store.session.id;
2531
2532        registry
2533            .create(session_task_under(
2534                root,
2535                TASK_KIND_SESSION,
2536                SessionTaskState::Running,
2537            ))
2538            .await
2539            .unwrap();
2540        registry
2541            .create(session_task_under(
2542                root,
2543                TASK_KIND_SESSION,
2544                SessionTaskState::Running,
2545            ))
2546            .await
2547            .unwrap();
2548        // Terminal detached task: counts toward total, not active.
2549        registry
2550            .create(session_task_under(
2551                root,
2552                TASK_KIND_SESSION,
2553                SessionTaskState::Canceled,
2554            ))
2555            .await
2556            .unwrap();
2557        // Subagent task: must not count toward the detached budget at all.
2558        registry
2559            .create(session_task_under(
2560                root,
2561                TASK_KIND_SUBAGENT,
2562                SessionTaskState::Running,
2563            ))
2564            .await
2565            .unwrap();
2566
2567        let counts = descendant_detached_task_counts(registry.as_ref(), root, 100, 100)
2568            .await
2569            .unwrap();
2570        assert_eq!(
2571            counts.active, 2,
2572            "only non-terminal session tasks are active"
2573        );
2574        assert_eq!(
2575            counts.total, 3,
2576            "terminal session task counts toward total; subagent task excluded"
2577        );
2578    }
2579
2580    #[tokio::test]
2581    async fn detached_spawn_rejected_at_cap_and_allowed_under_cap() {
2582        let store = Arc::new(MockPlatformStore::new());
2583        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2584        let context = spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(
2585            crate::traits::SubagentNestingPolicy::default()
2586                .with_agent_detached_task_caps_override(Some(1), Some(4)),
2587        );
2588
2589        // Under the ceiling (0 existing): one authorized detached spawn succeeds.
2590        let ok = spawn(
2591            &context,
2592            json!({"name": "D0", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2593        )
2594        .await;
2595        assert!(
2596            matches!(ok, ToolExecutionResult::Success(_)),
2597            "detached spawn under cap should succeed, got {ok:?}"
2598        );
2599
2600        // The spawn created one active detached peer task under the root → at
2601        // the active cap. The next detached spawn is refused with a clear error.
2602        let refused = spawn(
2603            &context,
2604            json!({"name": "D1", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2605        )
2606        .await;
2607        let ToolExecutionResult::ToolError(msg) = refused else {
2608            panic!("expected detached active cap ToolError, got {refused:?}");
2609        };
2610        assert!(
2611            msg.contains("max_active_detached_tasks is 1"),
2612            "cap error should name the limit, got: {msg}"
2613        );
2614    }
2615
2616    #[tokio::test]
2617    async fn detached_cap_does_not_affect_linked_subagent_spawn() {
2618        // A root already at the detached ceiling must still allow linked
2619        // subagent spawns — the two budgets are independent (regression guard).
2620        let store = Arc::new(MockPlatformStore::new());
2621        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2622        let context = spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(
2623            crate::traits::SubagentNestingPolicy::default()
2624                .with_agent_detached_task_caps_override(Some(1), Some(4)),
2625        );
2626        let root = store.session.id;
2627
2628        // Saturate the detached active cap.
2629        registry
2630            .create(session_task_under(
2631                root,
2632                TASK_KIND_SESSION,
2633                SessionTaskState::Running,
2634            ))
2635            .await
2636            .unwrap();
2637
2638        // A detached spawn is refused…
2639        let refused = spawn(
2640            &context,
2641            json!({"name": "D", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2642        )
2643        .await;
2644        assert!(matches!(refused, ToolExecutionResult::ToolError(_)));
2645
2646        // …but a linked subagent spawn is unaffected by the detached cap.
2647        let linked = spawn(
2648            &context,
2649            json!({"name": "L", "instructions": "go", "mode": "background"}),
2650        )
2651        .await;
2652        assert!(
2653            matches!(linked, ToolExecutionResult::Success(_)),
2654            "linked subagent spawn must not be blocked by the detached cap, got {linked:?}"
2655        );
2656    }
2657
2658    #[tokio::test]
2659    async fn detached_session_task_cancel_requests_peer_cancellation() {
2660        // EVE-766: cancel_task on a detached-session task must cooperatively
2661        // cancel the peer session, not just detach the tracking chip.
2662        let store = Arc::new(MockPlatformStore::new());
2663        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2664        let context = spawn_context(&store, Some(registry.clone()));
2665        let child_id = crate::typed_id::SessionId::new();
2666        let task = registry
2667            .create(CreateSessionTask {
2668                session_id: context.session_id,
2669                id: None,
2670                kind: TASK_KIND_SESSION.to_string(),
2671                display_name: "Peer".to_string(),
2672                spec: json!({}),
2673                state: SessionTaskState::Running,
2674                links: TaskLinks {
2675                    child_session_id: Some(child_id),
2676                    ..Default::default()
2677                },
2678                wake_policy: TaskWakePolicy::Silent,
2679            })
2680            .await
2681            .unwrap();
2682
2683        DetachedSessionTaskExecutor
2684            .cancel(&task, &context)
2685            .await
2686            .unwrap();
2687
2688        // The peer session was signaled to stop via the standard send path.
2689        let sent = store.sent_messages.lock().unwrap().clone();
2690        assert_eq!(
2691            sent.len(),
2692            1,
2693            "exactly one cooperative-cancel message expected, got {sent:?}"
2694        );
2695        assert_eq!(sent[0].0, child_id, "cancel must target the peer session");
2696        assert!(
2697            sent[0].1.contains("Cancellation requested"),
2698            "cancel message should ask the peer to stop, got {:?}",
2699            sent[0].1
2700        );
2701
2702        // The tracking task settles canceled and keeps its peer link.
2703        let updated = registry
2704            .get(context.session_id, &task.id)
2705            .await
2706            .unwrap()
2707            .expect("task should remain present");
2708        assert_eq!(updated.state, SessionTaskState::Canceled);
2709        assert_eq!(updated.links.child_session_id, Some(child_id));
2710        assert_eq!(
2711            updated.summary.as_deref(),
2712            Some("Peer session cancellation requested; tracking settled canceled.")
2713        );
2714    }
2715
2716    #[tokio::test]
2717    async fn detached_session_task_cancel_without_peer_link_still_settles() {
2718        // Defensive: a session-kind task with no peer link has nothing to
2719        // signal, but the cancel intent must still be honored.
2720        let store = Arc::new(MockPlatformStore::new());
2721        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2722        let context = spawn_context(&store, Some(registry.clone()));
2723        let task = registry
2724            .create(CreateSessionTask {
2725                session_id: context.session_id,
2726                id: None,
2727                kind: TASK_KIND_SESSION.to_string(),
2728                display_name: "Peer".to_string(),
2729                spec: json!({}),
2730                state: SessionTaskState::Running,
2731                links: TaskLinks::default(),
2732                wake_policy: TaskWakePolicy::Silent,
2733            })
2734            .await
2735            .unwrap();
2736
2737        DetachedSessionTaskExecutor
2738            .cancel(&task, &context)
2739            .await
2740            .unwrap();
2741
2742        assert!(store.sent_messages.lock().unwrap().is_empty());
2743        let updated = registry
2744            .get(context.session_id, &task.id)
2745            .await
2746            .unwrap()
2747            .expect("task should remain present");
2748        assert_eq!(updated.state, SessionTaskState::Canceled);
2749        assert_eq!(
2750            updated.summary.as_deref(),
2751            Some("Detached session tracking canceled; no peer session link to signal.")
2752        );
2753    }
2754
2755    #[tokio::test]
2756    async fn spawn_agent_subagent_allows_depth_two_and_rejects_depth_three_by_default() {
2757        let store = Arc::new(MockPlatformStore::new());
2758        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2759        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2760        let root_context = spawn_context(&store, Some(registry.clone()));
2761
2762        let first = spawn(
2763            &root_context,
2764            json!({"name": "B", "instructions": "go", "mode": "background"}),
2765        )
2766        .await;
2767        let ToolExecutionResult::Success(first_value) = first else {
2768            panic!("expected first spawn success, got {first:?}");
2769        };
2770        let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
2771            .as_str()
2772            .expect("subagent_id")
2773            .parse()
2774            .expect("valid session id");
2775
2776        let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id);
2777        let second = spawn(
2778            &b_context,
2779            json!({"name": "C", "instructions": "go", "mode": "background"}),
2780        )
2781        .await;
2782        let ToolExecutionResult::Success(second_value) = second else {
2783            panic!("expected second spawn success, got {second:?}");
2784        };
2785        let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
2786            .as_str()
2787            .expect("subagent_id")
2788            .parse()
2789            .expect("valid session id");
2790
2791        let c_context = spawn_context_for_session(&store, Some(registry), c_id);
2792        let third = spawn(
2793            &c_context,
2794            json!({"name": "D", "instructions": "go", "mode": "background"}),
2795        )
2796        .await;
2797        let ToolExecutionResult::ToolError(message) = third else {
2798            panic!("expected depth cap ToolError, got {third:?}");
2799        };
2800        assert!(
2801            message.contains("max_subagent_depth is 2"),
2802            "got: {message}"
2803        );
2804        assert!(message.contains("depth 3"), "got: {message}");
2805    }
2806
2807    #[tokio::test]
2808    async fn spawn_agent_subagent_depth_zero_restores_hard_block() {
2809        let store = Arc::new(MockPlatformStore::new());
2810        let mut context = spawn_context(&store, None).with_subagent_nesting_policy(
2811            crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
2812        );
2813        context.session_task_registry = Some(Arc::new(InMemorySessionTaskRegistry::default()));
2814
2815        let result = spawn(
2816            &context,
2817            json!({"name": "Blocked", "instructions": "go", "mode": "background"}),
2818        )
2819        .await;
2820        let ToolExecutionResult::ToolError(message) = result else {
2821            panic!("expected depth cap ToolError, got {result:?}");
2822        };
2823        assert!(
2824            message.contains("max_subagent_depth is 0"),
2825            "got: {message}"
2826        );
2827        assert!(message.contains("depth 1"), "got: {message}");
2828    }
2829
2830    #[tokio::test]
2831    async fn spawn_agent_subagent_rejects_when_active_descendant_cap_is_full() {
2832        let store = Arc::new(MockPlatformStore::new());
2833        *store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
2834        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2835        let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2836            crate::traits::SubagentNestingPolicy::default()
2837                .with_agent_task_caps_override(Some(1), Some(200)),
2838        );
2839
2840        let first = spawn(
2841            &context,
2842            json!({"name": "First", "instructions": "go", "mode": "background"}),
2843        )
2844        .await;
2845        assert!(
2846            matches!(first, ToolExecutionResult::Success(_)),
2847            "expected first spawn success, got {first:?}"
2848        );
2849
2850        let second = spawn(
2851            &context,
2852            json!({"name": "Second", "instructions": "go", "mode": "background"}),
2853        )
2854        .await;
2855        let ToolExecutionResult::ToolError(message) = second else {
2856            panic!("expected active cap ToolError, got {second:?}");
2857        };
2858        assert!(
2859            message.contains("max_active_descendant_tasks is 1"),
2860            "got: {message}"
2861        );
2862        assert!(
2863            message.contains("2 non-terminal descendant tasks"),
2864            "got: {message}"
2865        );
2866    }
2867
2868    #[tokio::test]
2869    async fn spawn_agent_subagent_counts_grandchildren_for_active_descendant_cap() {
2870        let store = Arc::new(MockPlatformStore::new());
2871        *store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
2872        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2873        let policy = crate::traits::SubagentNestingPolicy::default()
2874            .with_agent_override(Some(4))
2875            .with_agent_task_caps_override(Some(2), Some(200));
2876        let root_context =
2877            spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(policy);
2878
2879        let first = spawn(
2880            &root_context,
2881            json!({"name": "B", "instructions": "go", "mode": "background"}),
2882        )
2883        .await;
2884        let ToolExecutionResult::Success(first_value) = first else {
2885            panic!("expected first spawn success, got {first:?}");
2886        };
2887        let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
2888            .as_str()
2889            .expect("subagent_id")
2890            .parse()
2891            .expect("valid session id");
2892
2893        let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id)
2894            .with_subagent_nesting_policy(policy);
2895        let second = spawn(
2896            &b_context,
2897            json!({"name": "C", "instructions": "go", "mode": "background"}),
2898        )
2899        .await;
2900        let ToolExecutionResult::Success(second_value) = second else {
2901            panic!("expected second spawn success, got {second:?}");
2902        };
2903        let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
2904            .as_str()
2905            .expect("subagent_id")
2906            .parse()
2907            .expect("valid session id");
2908
2909        let c_context = spawn_context_for_session(&store, Some(registry), c_id)
2910            .with_subagent_nesting_policy(policy);
2911        let third = spawn(
2912            &c_context,
2913            json!({"name": "D", "instructions": "go", "mode": "background"}),
2914        )
2915        .await;
2916        let ToolExecutionResult::ToolError(message) = third else {
2917            panic!("expected active cap ToolError, got {third:?}");
2918        };
2919        assert!(
2920            message.contains("max_active_descendant_tasks is 2"),
2921            "got: {message}"
2922        );
2923        assert!(message.contains("root session"), "got: {message}");
2924    }
2925
2926    #[tokio::test]
2927    async fn spawn_agent_subagent_total_descendant_cap_counts_terminal_tasks() {
2928        let store = Arc::new(MockPlatformStore::new());
2929        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2930        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2931        let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2932            crate::traits::SubagentNestingPolicy::default()
2933                .with_agent_task_caps_override(Some(16), Some(1)),
2934        );
2935
2936        let first = spawn(
2937            &context,
2938            json!({"name": "First", "instructions": "go", "mode": "foreground"}),
2939        )
2940        .await;
2941        assert!(
2942            matches!(first, ToolExecutionResult::Success(_)),
2943            "expected first spawn success, got {first:?}"
2944        );
2945
2946        let second = spawn(
2947            &context,
2948            json!({"name": "Second", "instructions": "go", "mode": "foreground"}),
2949        )
2950        .await;
2951        let ToolExecutionResult::ToolError(message) = second else {
2952            panic!("expected total cap ToolError, got {second:?}");
2953        };
2954        assert!(
2955            message.contains("max_total_descendant_tasks is 1"),
2956            "got: {message}"
2957        );
2958        assert!(
2959            message.contains("2 descendant task records"),
2960            "got: {message}"
2961        );
2962    }
2963
2964    #[test]
2965    fn subagents_config_validates_descendant_task_caps() {
2966        let capability = SubagentCapability;
2967        assert!(
2968            capability
2969                .validate_config(&json!({
2970                    "max_active_descendant_tasks": 16,
2971                    "max_total_descendant_tasks": 200
2972                }))
2973                .is_ok()
2974        );
2975        assert_eq!(
2976            capability
2977                .validate_config(&json!({"max_active_descendant_tasks": 1025}))
2978                .unwrap_err(),
2979            "max_active_descendant_tasks must be <= 1024"
2980        );
2981        assert_eq!(
2982            capability
2983                .validate_config(&json!({"max_total_descendant_tasks": 10001}))
2984                .unwrap_err(),
2985            "max_total_descendant_tasks must be <= 10000"
2986        );
2987    }
2988
2989    #[tokio::test]
2990    async fn spawn_agent_subagent_stores_result_schema_on_task() {
2991        let store = Arc::new(MockPlatformStore::new());
2992        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2993        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2994        let context = spawn_context(&store, Some(registry.clone()));
2995
2996        let result = SpawnSubagentAsAgentTool
2997            .execute_with_context(
2998                json!({
2999                    "name": "Runner",
3000                    "instructions": "go",
3001                    "target": {"type": "subagent"},
3002                    "mode": "foreground",
3003                    "result_schema": {
3004                        "type": "object",
3005                        "properties": {"answer": {"type": "string"}},
3006                        "required": ["answer"],
3007                        "additionalProperties": false
3008                    }
3009                }),
3010                &context,
3011            )
3012            .await;
3013        let ToolExecutionResult::Success(value) = result else {
3014            panic!("expected success, got {result:?}");
3015        };
3016        let task_id = value["task_id"].as_str().expect("task_id");
3017        let task = registry
3018            .get(context.session_id, task_id)
3019            .await
3020            .unwrap()
3021            .unwrap();
3022        assert_eq!(task.spec["result_schema"]["required"], json!(["answer"]));
3023        assert_eq!(task.state, SessionTaskState::Failed);
3024        assert_eq!(
3025            task.error.as_ref().map(|e| e.kind.as_str()),
3026            Some("no_result")
3027        );
3028    }
3029
3030    #[tokio::test]
3031    async fn report_result_writes_result_file_and_updates_task() {
3032        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3033        let file_store = Arc::new(MemoryFileStore::default());
3034        let parent_session_id = crate::typed_id::SessionId::new();
3035        let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
3036        let child_session_id = crate::typed_id::SessionId::new();
3037        let task = registry
3038            .create(CreateSessionTask {
3039                session_id: parent_session_id,
3040                id: None,
3041                kind: TASK_KIND_SUBAGENT.to_string(),
3042                display_name: "Runner".to_string(),
3043                spec: json!({
3044                    "result_schema": {
3045                        "type": "object",
3046                        "properties": {"answer": {"type": "string"}},
3047                        "required": ["answer"],
3048                        "additionalProperties": false
3049                    }
3050                }),
3051                state: SessionTaskState::Running,
3052                links: TaskLinks {
3053                    child_session_id: Some(child_session_id),
3054                    ..Default::default()
3055                },
3056                wake_policy: TaskWakePolicy::Silent,
3057            })
3058            .await
3059            .unwrap();
3060
3061        let tool = ReportResultTool::new(
3062            parent_session_id,
3063            parent_workspace_id,
3064            child_session_id,
3065            task.id.clone(),
3066            task.spec["result_schema"].clone(),
3067        )
3068        .with_file_store(file_store.clone());
3069        let mut context = ToolContext::new(child_session_id);
3070        context.session_task_registry = Some(registry.clone());
3071
3072        let result = tool
3073            .execute_with_context(json!({"answer": "done"}), &context)
3074            .await;
3075        let ToolExecutionResult::Success(value) = result else {
3076            panic!("expected success, got {result:?}");
3077        };
3078        assert_eq!(value["result_path"], task_result_path(&task.id));
3079
3080        let task = registry
3081            .get(parent_session_id, &task.id)
3082            .await
3083            .unwrap()
3084            .unwrap();
3085        let result_path = task.result_path.as_deref().expect("result_path");
3086        let file = file_store
3087            .read_file(
3088                SessionId::from_uuid(parent_workspace_id.uuid()),
3089                result_path,
3090            )
3091            .await
3092            .unwrap()
3093            .expect("result file");
3094        assert_eq!(
3095            serde_json::from_str::<Value>(file.content.as_deref().unwrap()).unwrap(),
3096            json!({"answer": "done"})
3097        );
3098    }
3099
3100    #[tokio::test]
3101    async fn report_result_rejects_terminal_task_without_overwriting_result() {
3102        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3103        let file_store = Arc::new(MemoryFileStore::default());
3104        let parent_session_id = crate::typed_id::SessionId::new();
3105        let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
3106        let child_session_id = crate::typed_id::SessionId::new();
3107        let task = registry
3108            .create(CreateSessionTask {
3109                session_id: parent_session_id,
3110                id: None,
3111                kind: TASK_KIND_SUBAGENT.to_string(),
3112                display_name: "Runner".to_string(),
3113                spec: json!({
3114                    "result_schema": {
3115                        "type": "object",
3116                        "properties": {"answer": {"type": "string"}},
3117                        "required": ["answer"],
3118                        "additionalProperties": false
3119                    }
3120                }),
3121                state: SessionTaskState::Succeeded,
3122                links: TaskLinks {
3123                    child_session_id: Some(child_session_id),
3124                    ..Default::default()
3125                },
3126                wake_policy: TaskWakePolicy::Silent,
3127            })
3128            .await
3129            .unwrap();
3130        let existing_path = task_result_path(&task.id);
3131        registry
3132            .update(
3133                parent_session_id,
3134                &task.id,
3135                SessionTaskUpdate {
3136                    result_path: Some(existing_path.clone()),
3137                    summary: Some("original".to_string()),
3138                    ..Default::default()
3139                },
3140            )
3141            .await
3142            .unwrap();
3143        file_store
3144            .write_file(
3145                SessionId::from_uuid(parent_workspace_id.uuid()),
3146                &existing_path,
3147                "{\n  \"answer\": \"original\"\n}",
3148                "utf-8",
3149            )
3150            .await
3151            .unwrap();
3152
3153        let tool = ReportResultTool::new(
3154            parent_session_id,
3155            parent_workspace_id,
3156            child_session_id,
3157            task.id.clone(),
3158            task.spec["result_schema"].clone(),
3159        )
3160        .with_file_store(file_store.clone());
3161        let mut context = ToolContext::new(child_session_id);
3162        context.session_task_registry = Some(registry.clone());
3163
3164        let result = tool
3165            .execute_with_context(json!({"answer": "tampered"}), &context)
3166            .await;
3167        let ToolExecutionResult::ToolError(message) = result else {
3168            panic!("expected terminal rejection, got {result:?}");
3169        };
3170        assert!(message.contains("terminal"), "got: {message}");
3171
3172        let file = file_store
3173            .read_file(
3174                SessionId::from_uuid(parent_workspace_id.uuid()),
3175                &existing_path,
3176            )
3177            .await
3178            .unwrap()
3179            .expect("result file");
3180        assert!(
3181            file.content.as_deref().unwrap().contains("original"),
3182            "file was overwritten: {file:?}"
3183        );
3184    }
3185
3186    #[tokio::test]
3187    async fn report_result_rejects_invalid_result_schema_payload() {
3188        let tool = ReportResultTool::new(
3189            crate::typed_id::SessionId::new(),
3190            crate::typed_id::WorkspaceId::from_uuid(uuid::Uuid::new_v4()),
3191            crate::typed_id::SessionId::new(),
3192            "task_test".to_string(),
3193            json!({
3194                "type": "object",
3195                "properties": {"answer": {"type": "string"}},
3196                "required": ["answer"],
3197                "additionalProperties": false
3198            }),
3199        );
3200        let result = tool
3201            .execute_with_context(json!({"extra": true}), &ToolContext::new(SessionId::new()))
3202            .await;
3203        let ToolExecutionResult::ToolError(message) = result else {
3204            panic!("expected validation error, got {result:?}");
3205        };
3206        assert!(
3207            message.contains("answer") && message.contains("required"),
3208            "got: {message}"
3209        );
3210        assert!(
3211            message.contains("extra")
3212                && (message.contains("additional") || message.contains("not allowed")),
3213            "got: {message}"
3214        );
3215    }
3216
3217    #[tokio::test]
3218    async fn spawn_agent_subagent_stores_message_schema_and_wakes_on_activity() {
3219        let store = Arc::new(MockPlatformStore::new());
3220        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3221        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3222        let context = spawn_context(&store, Some(registry.clone()));
3223
3224        let result = SpawnSubagentAsAgentTool
3225            .execute_with_context(
3226                json!({
3227                    "name": "Runner",
3228                    "instructions": "go",
3229                    "target": {"type": "subagent"},
3230                    "message_schema": {
3231                        "type": "object",
3232                        "properties": {"step": {"type": "string"}},
3233                        "required": ["step"],
3234                        "additionalProperties": false
3235                    }
3236                }),
3237                &context,
3238            )
3239            .await;
3240        let ToolExecutionResult::Success(value) = result else {
3241            panic!("expected success, got {result:?}");
3242        };
3243        let task_id = value["task_id"].as_str().expect("task_id");
3244        let task = registry
3245            .get(context.session_id, task_id)
3246            .await
3247            .unwrap()
3248            .unwrap();
3249        assert_eq!(task.spec["message_schema"]["required"], json!(["step"]));
3250        assert_eq!(task.wake_policy, TaskWakePolicy::OnActivity);
3251    }
3252
3253    #[tokio::test]
3254    async fn report_task_progress_posts_structured_outbound_message() {
3255        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3256        let parent_session_id = crate::typed_id::SessionId::new();
3257        let task = registry
3258            .create(CreateSessionTask {
3259                session_id: parent_session_id,
3260                id: None,
3261                kind: TASK_KIND_SUBAGENT.to_string(),
3262                display_name: "Runner".to_string(),
3263                spec: json!({
3264                    "message_schema": {
3265                        "type": "object",
3266                        "properties": {"step": {"type": "string"}},
3267                        "required": ["step"],
3268                        "additionalProperties": false
3269                    }
3270                }),
3271                state: SessionTaskState::Running,
3272                links: TaskLinks::default(),
3273                wake_policy: TaskWakePolicy::OnActivity,
3274            })
3275            .await
3276            .unwrap();
3277
3278        let tool = ReportTaskProgressTool::new(
3279            parent_session_id,
3280            task.id.clone(),
3281            task.attempt,
3282            task.spec["message_schema"].clone(),
3283        );
3284        assert_eq!(tool.name(), "report_task_progress");
3285        let mut context = ToolContext::new(crate::typed_id::SessionId::new());
3286        context.session_task_registry = Some(registry.clone());
3287
3288        let result = tool
3289            .execute_with_context(json!({"step": "tests-running"}), &context)
3290            .await;
3291        let ToolExecutionResult::Success(value) = result else {
3292            panic!("expected success, got {result:?}");
3293        };
3294        assert_eq!(value["status"], "posted");
3295
3296        let messages = registry
3297            .list_messages(parent_session_id, &task.id, None, None)
3298            .await
3299            .unwrap();
3300        assert_eq!(messages.len(), 1);
3301        assert_eq!(messages[0].direction, TaskMessageDirection::Outbound);
3302        assert_eq!(
3303            messages[0].content,
3304            vec![TaskMessagePart::Data {
3305                data: json!({"step": "tests-running"})
3306            }]
3307        );
3308    }
3309
3310    #[tokio::test]
3311    async fn report_task_progress_rejects_stale_task_attempt() {
3312        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3313        let parent_session_id = crate::typed_id::SessionId::new();
3314        let task = registry
3315            .create(CreateSessionTask {
3316                session_id: parent_session_id,
3317                id: None,
3318                kind: TASK_KIND_SUBAGENT.to_string(),
3319                display_name: "Runner".to_string(),
3320                spec: json!({"message_schema": {"type": "object"}}),
3321                state: SessionTaskState::Running,
3322                links: TaskLinks::default(),
3323                wake_policy: TaskWakePolicy::OnActivity,
3324            })
3325            .await
3326            .unwrap();
3327        let tool = ReportTaskProgressTool::new(
3328            parent_session_id,
3329            task.id.clone(),
3330            task.attempt,
3331            task.spec["message_schema"].clone(),
3332        );
3333
3334        // Supersede the attempt the tool captured at construction.
3335        registry
3336            .update(
3337                parent_session_id,
3338                &task.id,
3339                SessionTaskUpdate {
3340                    state: Some(SessionTaskState::Failed),
3341                    increment_attempt: true,
3342                    ..Default::default()
3343                },
3344            )
3345            .await
3346            .unwrap();
3347
3348        let mut context = ToolContext::new(crate::typed_id::SessionId::new());
3349        context.session_task_registry = Some(registry.clone());
3350        let result = tool
3351            .execute_with_context(json!({"step": "late"}), &context)
3352            .await;
3353        assert!(
3354            matches!(result, ToolExecutionResult::InternalError(_)),
3355            "stale progress must be rejected, got {result:?}"
3356        );
3357        let messages = registry
3358            .list_messages(parent_session_id, &task.id, None, None)
3359            .await
3360            .unwrap();
3361        assert!(
3362            messages.is_empty(),
3363            "stale progress must not append messages"
3364        );
3365    }
3366
3367    #[tokio::test]
3368    async fn report_task_progress_rejects_invalid_message_schema_payload() {
3369        let tool = ReportTaskProgressTool::new(
3370            crate::typed_id::SessionId::new(),
3371            "task_test".to_string(),
3372            1,
3373            json!({
3374                "type": "object",
3375                "properties": {"step": {"type": "string"}},
3376                "required": ["step"],
3377                "additionalProperties": false
3378            }),
3379        );
3380        let result = tool
3381            .execute_with_context(
3382                json!({"step": 42, "extra": true}),
3383                &ToolContext::new(SessionId::new()),
3384            )
3385            .await;
3386        let ToolExecutionResult::ToolError(message) = result else {
3387            panic!("expected validation error, got {result:?}");
3388        };
3389        assert!(
3390            message.contains("step") && message.contains("string"),
3391            "got: {message}"
3392        );
3393        assert!(
3394            message.contains("extra")
3395                && (message.contains("additional") || message.contains("not allowed")),
3396            "got: {message}"
3397        );
3398    }
3399
3400    #[test]
3401    fn subagent_and_channel_progress_tools_have_distinct_names() {
3402        // EVE-727: the subagent interim-progress tool must not share a wire name
3403        // with the channel-facing `report_progress` tool. `ToolRegistry` is keyed
3404        // by name, so a collision would silently drop one tool. This guards the
3405        // rename against regressions: both tools must coexist in one registry.
3406        use crate::progress_reporting::{
3407            REPORT_PROGRESS_TOOL_NAME, ReportProgressTool as ChannelReportProgressTool,
3408        };
3409        use crate::tools::ToolRegistry;
3410
3411        let subagent = ReportTaskProgressTool::new(
3412            crate::typed_id::SessionId::new(),
3413            "task_test".to_string(),
3414            1,
3415            json!({"type": "object"}),
3416        );
3417        assert_eq!(subagent.name(), "report_task_progress");
3418        assert_eq!(REPORT_PROGRESS_TOOL_NAME, "report_progress");
3419        assert_ne!(subagent.name(), REPORT_PROGRESS_TOOL_NAME);
3420
3421        let mut registry = ToolRegistry::new();
3422        registry.register(ChannelReportProgressTool);
3423        registry.register(subagent);
3424        assert!(
3425            registry.has("report_progress"),
3426            "channel report_progress tool must survive"
3427        );
3428        assert!(
3429            registry.has("report_task_progress"),
3430            "subagent report_task_progress tool must survive"
3431        );
3432    }
3433
3434    #[tokio::test]
3435    async fn explicit_background_without_registry_errors() {
3436        let context = ToolContext::new(crate::typed_id::SessionId::new());
3437        let result = spawn(
3438            &context,
3439            json!({"name": "Runner", "instructions": "go", "mode": "background"}),
3440        )
3441        .await;
3442        let ToolExecutionResult::ToolError(msg) = result else {
3443            panic!("expected ToolError, got {result:?}");
3444        };
3445        assert!(
3446            msg.contains("task registry") && msg.contains("foreground"),
3447            "got: {msg}"
3448        );
3449    }
3450
3451    #[tokio::test]
3452    async fn default_mode_without_registry_degrades_to_foreground() {
3453        let store = Arc::new(MockPlatformStore::new());
3454        let context = spawn_context(&store, None);
3455        let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3456        let ToolExecutionResult::Success(value) = result else {
3457            panic!("expected success, got {result:?}");
3458        };
3459        // Foreground semantics: waited inline and returned the child's reply.
3460        assert_eq!(value["status"], "idle");
3461        assert_eq!(value["result"], "Hi!");
3462        assert!(value.get("mode").is_none());
3463    }
3464
3465    #[tokio::test]
3466    async fn background_spawn_returns_immediately_and_settles_task() {
3467        let store = Arc::new(MockPlatformStore::new());
3468        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3469        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3470        let context = spawn_context(&store, Some(registry.clone()));
3471
3472        let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3473        let ToolExecutionResult::Success(value) = result else {
3474            panic!("expected success, got {result:?}");
3475        };
3476        assert_eq!(value["status"], "running");
3477        assert_eq!(value["mode"], "background");
3478        let task_id = value["task_id"].as_str().expect("task_id").to_string();
3479
3480        let task = wait_for_task_state(
3481            &registry,
3482            context.session_id,
3483            &task_id,
3484            SessionTaskState::Succeeded,
3485        )
3486        .await;
3487        // Background tasks wake the parent on terminal transition.
3488        assert_eq!(task.wake_policy, TaskWakePolicy::OnTerminal);
3489        assert_eq!(task.spec["mode"], "background");
3490        // Summary carries the child's last agent message.
3491        assert_eq!(task.summary.as_deref(), Some("Hi!"));
3492    }
3493
3494    #[tokio::test]
3495    async fn background_spawn_rejects_when_session_active_run_limit_reached() {
3496        let store = Arc::new(MockPlatformStore::new());
3497        *store.wait_for_idle_status.lock().unwrap() = "paused".to_string();
3498        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3499        let context = spawn_context(&store, Some(registry));
3500
3501        for index in 0..crate::tools::MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION {
3502            let result = spawn(
3503                &context,
3504                json!({
3505                    "name": format!("Runner {index}"),
3506                    "instructions": "go",
3507                }),
3508            )
3509            .await;
3510            let ToolExecutionResult::Success(value) = result else {
3511                panic!("background spawn below the session limit should start: {result:?}");
3512            };
3513            assert_eq!(value["status"], "running");
3514        }
3515
3516        let result = spawn(
3517            &context,
3518            json!({
3519                "name": "Runner over limit",
3520                "instructions": "go",
3521            }),
3522        )
3523        .await;
3524        let ToolExecutionResult::ToolError(message) = result else {
3525            panic!("background spawn should reject once the session limit is reached: {result:?}");
3526        };
3527        assert!(message.contains("active background runs per session"));
3528    }
3529
3530    #[tokio::test]
3531    async fn background_settles_bare_idle_as_completed() {
3532        // Local/embedded hosts run the child's turn synchronously inside
3533        // send_message and report a bare `idle`; the watcher must settle it.
3534        let store = Arc::new(MockPlatformStore::new());
3535        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3536        let context = spawn_context(&store, Some(registry.clone()));
3537
3538        let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3539        let ToolExecutionResult::Success(value) = result else {
3540            panic!("expected success, got {result:?}");
3541        };
3542        let task_id = value["task_id"].as_str().expect("task_id").to_string();
3543        wait_for_task_state(
3544            &registry,
3545            context.session_id,
3546            &task_id,
3547            SessionTaskState::Succeeded,
3548        )
3549        .await;
3550    }
3551
3552    #[tokio::test]
3553    async fn background_failed_child_settles_task_failed() {
3554        let store = Arc::new(MockPlatformStore::new());
3555        *store.wait_for_idle_status.lock().unwrap() = "failed".to_string();
3556        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3557        let context = spawn_context(&store, Some(registry.clone()));
3558
3559        let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3560        let ToolExecutionResult::Success(value) = result else {
3561            panic!("expected success, got {result:?}");
3562        };
3563        let task_id = value["task_id"].as_str().expect("task_id").to_string();
3564        let task = wait_for_task_state(
3565            &registry,
3566            context.session_id,
3567            &task_id,
3568            SessionTaskState::Failed,
3569        )
3570        .await;
3571        assert_eq!(task.error.as_ref().map(|e| e.kind.as_str()), Some("failed"));
3572    }
3573
3574    #[tokio::test]
3575    async fn explicit_foreground_blocks_and_returns_result() {
3576        let store = Arc::new(MockPlatformStore::new());
3577        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3578        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3579        let context = spawn_context(&store, Some(registry.clone()));
3580
3581        let result = spawn(
3582            &context,
3583            json!({"name": "Runner", "instructions": "go", "mode": "foreground"}),
3584        )
3585        .await;
3586        let ToolExecutionResult::Success(value) = result else {
3587            panic!("expected success, got {result:?}");
3588        };
3589        assert_eq!(value["status"], "completed");
3590        assert_eq!(value["result"], "Hi!");
3591        // Foreground spawn settles the task before returning.
3592        let task_id = value["task_id"].as_str().expect("task_id");
3593        let task = registry
3594            .get(context.session_id, task_id)
3595            .await
3596            .unwrap()
3597            .unwrap();
3598        assert_eq!(task.state, SessionTaskState::Succeeded);
3599        assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
3600    }
3601
3602    #[tokio::test]
3603    async fn reconcile_settles_finished_child() {
3604        let store = Arc::new(MockPlatformStore::new());
3605        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3606        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3607        let context = spawn_context(&store, Some(registry.clone()));
3608
3609        let child_id = crate::typed_id::SessionId::new();
3610        let task = registry
3611            .create(CreateSessionTask {
3612                session_id: context.session_id,
3613                id: None,
3614                kind: TASK_KIND_SUBAGENT.to_string(),
3615                display_name: "Runner".to_string(),
3616                spec: json!({"mode": "background"}),
3617                state: SessionTaskState::Running,
3618                links: TaskLinks {
3619                    child_session_id: Some(child_id),
3620                    ..Default::default()
3621                },
3622                wake_policy: TaskWakePolicy::OnTerminal,
3623            })
3624            .await
3625            .unwrap();
3626
3627        SubagentTaskExecutor
3628            .reconcile(&task, &context)
3629            .await
3630            .expect("reconcile succeeds");
3631
3632        let task = registry
3633            .get(context.session_id, &task.id)
3634            .await
3635            .unwrap()
3636            .unwrap();
3637        assert_eq!(task.state, SessionTaskState::Succeeded);
3638        assert_eq!(task.summary.as_deref(), Some("Hi!"));
3639    }
3640
3641    #[tokio::test]
3642    async fn reconcile_is_noop_while_child_still_working() {
3643        let store = Arc::new(MockPlatformStore::new());
3644        *store.wait_for_idle_status.lock().unwrap() = "timeout (last status: Active)".to_string();
3645        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3646        let context = spawn_context(&store, Some(registry.clone()));
3647
3648        let task = registry
3649            .create(CreateSessionTask {
3650                session_id: context.session_id,
3651                id: None,
3652                kind: TASK_KIND_SUBAGENT.to_string(),
3653                display_name: "Runner".to_string(),
3654                spec: json!({"mode": "background"}),
3655                state: SessionTaskState::Running,
3656                links: TaskLinks {
3657                    child_session_id: Some(crate::typed_id::SessionId::new()),
3658                    ..Default::default()
3659                },
3660                wake_policy: TaskWakePolicy::OnTerminal,
3661            })
3662            .await
3663            .unwrap();
3664
3665        SubagentTaskExecutor
3666            .reconcile(&task, &context)
3667            .await
3668            .expect("reconcile succeeds");
3669
3670        let task = registry
3671            .get(context.session_id, &task.id)
3672            .await
3673            .unwrap()
3674            .unwrap();
3675        assert_eq!(task.state, SessionTaskState::Running);
3676    }
3677}