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) => match SpawnMode::parse(value) {
838            Some(mode) => Some(mode),
839            None => {
840                return Err(ToolExecutionResult::tool_error(format!(
841                    "Invalid mode: \"{value}\". Valid modes: background, foreground."
842                )));
843            }
844        },
845    };
846    let has_registry = context.session_task_registry.is_some();
847    match explicit {
848        Some(SpawnMode::Background) if !has_registry => Err(ToolExecutionResult::tool_error(
849            "Background mode requires a session task registry, which is not available in this environment. Use mode: \"foreground\" instead.",
850        )),
851        Some(mode) => Ok(mode),
852        None if has_registry => Ok(SpawnMode::Background),
853        None => Ok(SpawnMode::Foreground),
854    }
855}
856
857async fn spawn_agent_subagent_impl(
858    arguments: Value,
859    context: &ToolContext,
860) -> Result<ToolExecutionResult, ToolExecutionResult> {
861    let name = require_str(&arguments, "name")?.trim().to_string();
862    let instructions = require_str(&arguments, "instructions")?.to_string();
863    let goal = arguments
864        .get("goal")
865        .and_then(Value::as_str)
866        .map(str::trim)
867        .filter(|value| !value.is_empty())
868        .map(str::to_string);
869    let mode = resolve_spawn_mode(&arguments, context)?;
870    let lifetime = SpawnLifetime::parse(&arguments)?;
871    let seed = parse_seed(&arguments)?;
872
873    let store = get_platform_store(context)?;
874    let session_store = get_session_store(context)?;
875
876    let blueprint_param = arguments
877        .get("blueprint")
878        .and_then(|v| v.as_str())
879        .filter(|s| !s.trim().is_empty())
880        .map(|s| s.to_string());
881    let config_param = arguments.get("config").filter(|v| !v.is_null()).cloned();
882    let result_schema = normalize_result_schema(&arguments)?;
883    let message_schema = normalize_message_schema(&arguments)?;
884    // SSRF-validate spawn-time push config URLs before they enter the task spec.
885    let push_configs = normalize_push_configs(&arguments)?;
886
887    // Reject config without blueprint
888    if config_param.is_some() && blueprint_param.is_none() {
889        return Ok(ToolExecutionResult::tool_error(
890            "The `config` parameter is only valid when `blueprint` is set.",
891        ));
892    }
893
894    // Nesting check: allow governed nesting up to the resolved depth cap.
895    let parent_session = match session_store.get_session(context.session_id).await {
896        Ok(Some(s)) => s,
897        Ok(None) => return Ok(ToolExecutionResult::tool_error("Current session not found")),
898        Err(e) => return Err(ToolExecutionResult::internal_error(e)),
899    };
900
901    if lifetime == SpawnLifetime::Linked
902        && let Err(error) =
903            enforce_subagent_depth_cap(session_store, &parent_session, context).await
904    {
905        return Ok(error);
906    }
907
908    // Validate blueprint exists and is allowed for this parent session.
909    if let Some(ref bp_id) = blueprint_param {
910        let Some(ref registry) = context.capability_registry else {
911            return Ok(ToolExecutionResult::tool_error(
912                "Blueprint support requires capability_registry context.",
913            ));
914        };
915
916        let Some((blueprint_capability_id, blueprint)) = registry.blueprint_with_capability(bp_id)
917        else {
918            return Ok(ToolExecutionResult::tool_error(format!(
919                "Unknown blueprint: \"{bp_id}\". Check available blueprints."
920            )));
921        };
922
923        // Validate config against schema if blueprint has one.
924        if let Some(ref schema) = blueprint.config_schema
925            && config_param.is_none()
926            && schema
927                .get("required")
928                .is_some_and(|r| r.as_array().is_some_and(|arr| !arr.is_empty()))
929        {
930            return Ok(ToolExecutionResult::tool_error(format!(
931                "Blueprint \"{bp_id}\" requires config. Schema: {}",
932                serde_json::to_string_pretty(schema).unwrap_or_default()
933            )));
934        }
935
936        let allowed_capability_ids = if let Some(agent_id) = parent_session.agent_id {
937            match store.get_agent_by_id(agent_id).await {
938                Ok(Some(agent)) => agent
939                    .capabilities
940                    .iter()
941                    .map(|c| c.capability_id().to_string())
942                    .collect::<Vec<_>>(),
943                Ok(None) => vec![],
944                Err(e) => return Err(ToolExecutionResult::internal_error(e)),
945            }
946        } else {
947            match store.get_harness(parent_session.harness_id).await {
948                Ok(Some(harness)) => harness
949                    .capabilities
950                    .iter()
951                    .map(|c| c.capability_id().to_string())
952                    .collect::<Vec<_>>(),
953                Ok(None) => vec![],
954                Err(e) => return Err(ToolExecutionResult::internal_error(e)),
955            }
956        };
957
958        if !allowed_capability_ids
959            .iter()
960            .any(|capability_id| capability_id == &blueprint_capability_id)
961        {
962            return Ok(ToolExecutionResult::tool_error(format!(
963                "Blueprint \"{bp_id}\" is not enabled for this session."
964            )));
965        }
966    }
967
968    // --- Durable spawn handle claim (EVE-535) ---
969    //
970    // When a spawn store and tool_call_id are available, attempt to claim a
971    // spawn slot before creating the child session.  On reclaim, this lets us
972    // reattach to the existing child instead of spawning a duplicate.
973    if lifetime == SpawnLifetime::Linked
974        && let (Some(spawn_store), Some(tool_call_id)) =
975            (&context.subagent_spawn_store, &context.tool_call_id)
976    {
977        let claim_token = uuid::Uuid::new_v4();
978
979        let claim = match spawn_store
980            .try_claim_spawn(context.session_id, tool_call_id, claim_token)
981            .await
982        {
983            Ok(c) => c,
984            Err(e) => return Err(ToolExecutionResult::internal_error(e)),
985        };
986
987        match claim {
988            SpawnClaimResult::AlreadySettled {
989                child_session_id,
990                terminal_status,
991                terminal_result,
992            } => {
993                // Already settled on a previous execution: return stored result.
994                let task_id = find_subagent_task(context, child_session_id)
995                    .await
996                    .map(|t| t.id);
997                return Ok(ToolExecutionResult::success(json!({
998                    "subagent_id": child_session_id.to_string(),
999                    "name": name,
1000                    "status": terminal_status,
1001                    "result": terminal_result,
1002                    "task_id": task_id,
1003                    "blueprint": blueprint_param,
1004                })));
1005            }
1006            SpawnClaimResult::AlreadyRunning {
1007                child_session_id,
1008                claim_token: stored_claim_token,
1009            } => {
1010                // Child was spawned before but hasn't settled yet — reattach.
1011                // Use the stored claim_token so settle succeeds on this replay.
1012                let task = find_subagent_task(context, child_session_id).await;
1013                let (task_id, task_attempt) =
1014                    task.map(|t| (Some(t.id), t.attempt)).unwrap_or((None, 1));
1015                match mode {
1016                    SpawnMode::Foreground => {
1017                        return Ok(run_subagent_wait_and_settle(
1018                            store,
1019                            context,
1020                            child_session_id,
1021                            &name,
1022                            &instructions,
1023                            &blueprint_param,
1024                            task_id,
1025                            Some((
1026                                spawn_store.as_ref(),
1027                                tool_call_id.as_str(),
1028                                stored_claim_token,
1029                            )),
1030                        )
1031                        .await);
1032                    }
1033                    SpawnMode::Background => {
1034                        let background_run_permit =
1035                            match try_acquire_background_run_permit(context.session_id) {
1036                                Ok(permit) => permit,
1037                                Err(message) => {
1038                                    return Ok(ToolExecutionResult::tool_error(message));
1039                                }
1040                            };
1041                        // Re-arm the detached watcher so the task still settles;
1042                        // the instructions were already sent on the first claim.
1043                        spawn_background_watcher(
1044                            context,
1045                            child_session_id,
1046                            &name,
1047                            None,
1048                            task_id.clone(),
1049                            task_attempt,
1050                            Some(stored_claim_token),
1051                            background_run_permit,
1052                        );
1053                        return Ok(background_running_result(
1054                            child_session_id,
1055                            &name,
1056                            &task_id,
1057                            &blueprint_param,
1058                        ));
1059                    }
1060                }
1061            }
1062            SpawnClaimResult::Claimed {
1063                spawn_handle_id,
1064                claim_token: actual_claim_token,
1065            }
1066            | SpawnClaimResult::ClaimedPendingChild {
1067                spawn_handle_id,
1068                claim_token: actual_claim_token,
1069            } => {
1070                // First claim (or re-claim after crash before register):
1071                // create child and register it durably before waiting.
1072                return Ok(spawn_create_and_wait(
1073                    store,
1074                    context,
1075                    &parent_session,
1076                    &name,
1077                    goal.as_deref(),
1078                    &instructions,
1079                    &blueprint_param,
1080                    &config_param,
1081                    &result_schema,
1082                    &message_schema,
1083                    &push_configs,
1084                    mode,
1085                    lifetime,
1086                    seed,
1087                    Some((
1088                        spawn_store.as_ref(),
1089                        tool_call_id.as_str(),
1090                        spawn_handle_id,
1091                        actual_claim_token,
1092                    )),
1093                )
1094                .await);
1095            }
1096        }
1097    }
1098
1099    // --- No-spawn-store path (dev / noop) ---
1100    Ok(spawn_create_and_wait(
1101        store,
1102        context,
1103        &parent_session,
1104        &name,
1105        goal.as_deref(),
1106        &instructions,
1107        &blueprint_param,
1108        &config_param,
1109        &result_schema,
1110        &message_schema,
1111        &push_configs,
1112        mode,
1113        lifetime,
1114        seed,
1115        None,
1116    )
1117    .await)
1118}
1119
1120/// Immediate tool result for a background spawn: the child is running and the
1121/// task record is the surface for progress and the final result.
1122fn background_running_result(
1123    child_id: crate::typed_id::SessionId,
1124    name: &str,
1125    task_id: &Option<String>,
1126    blueprint_param: &Option<String>,
1127) -> ToolExecutionResult {
1128    ToolExecutionResult::success(json!({
1129        "subagent_id": child_id.to_string(),
1130        "name": name,
1131        "status": "running",
1132        "mode": "background",
1133        "task_id": task_id,
1134        "blueprint": blueprint_param,
1135        "message": "Subagent started in the background. Monitor it with get_task or wait_task using task_id; the session is notified when it finishes.",
1136    }))
1137}
1138
1139// =============================================================================
1140// Helpers for subagent delegation
1141// =============================================================================
1142
1143/// Create a new child session, then either wait for completion (foreground)
1144/// or detach a watcher and return immediately (background). Settles the spawn
1145/// handle (if a settle context is supplied) when the child reaches a terminal
1146/// state.
1147///
1148/// `settle_ctx` = (spawn_store, tool_call_id, spawn_handle_id, claim_token).
1149/// `spawn_handle_id` is used to call `register_child_session` after child creation.
1150#[allow(clippy::too_many_arguments)]
1151async fn spawn_create_and_wait(
1152    store: &dyn PlatformStore,
1153    context: &ToolContext,
1154    parent_session: &crate::session::Session,
1155    name: &str,
1156    goal: Option<&str>,
1157    instructions: &str,
1158    blueprint_param: &Option<String>,
1159    config_param: &Option<Value>,
1160    result_schema: &Option<Value>,
1161    message_schema: &Option<Value>,
1162    push_configs: &Option<Value>,
1163    mode: SpawnMode,
1164    lifetime: SpawnLifetime,
1165    seed: SessionSeedMode,
1166    settle_ctx: Option<(
1167        &dyn crate::traits::SubagentSpawnStore,
1168        &str,
1169        uuid::Uuid,
1170        uuid::Uuid,
1171    )>,
1172) -> ToolExecutionResult {
1173    let background_run_permit = if mode == SpawnMode::Background {
1174        match try_acquire_background_run_permit(context.session_id) {
1175            Ok(permit) => Some(permit),
1176            Err(message) => return ToolExecutionResult::tool_error(message),
1177        }
1178    } else {
1179        None
1180    };
1181
1182    let Some(session_store) = context.session_store.as_ref() else {
1183        return ToolExecutionResult::tool_error("Subagent spawn requires session_store context");
1184    };
1185    // THREAT[TM-AUTHZ-014][TM-AGENT-028][TM-DOS-030]: Detached peers require
1186    // explicit session-creation authority. The host
1187    // returns the org-validated origin root so detached chains cannot reset
1188    // either spend attribution or their count-cap scope.
1189    let budget_root_session_id = if lifetime == SpawnLifetime::Detached {
1190        let Some(authority) = context.session_creation_authority.as_ref() else {
1191            return ToolExecutionResult::tool_error(
1192                "Detached spawn requires session-creation authority.",
1193            );
1194        };
1195        match authority
1196            .authorize_session_creation(context.session_id)
1197            .await
1198        {
1199            Ok(root_session_id) => Some(root_session_id),
1200            Err(error) => {
1201                return ToolExecutionResult::tool_error(format!(
1202                    "Detached spawn is not authorized to create a session: {error}"
1203                ));
1204            }
1205        }
1206    } else {
1207        None
1208    };
1209
1210    // Governance gate before creating the child session. Linked subagents are
1211    // bounded by the descendant task caps; detached peers reset depth but are
1212    // bounded by the detached caps against the same origin root (EVE-767) so a
1213    // loop of detached spawns cannot escape governance.
1214    let caps_result = match lifetime {
1215        SpawnLifetime::Linked => {
1216            enforce_subagent_task_caps(session_store.as_ref(), parent_session, context).await
1217        }
1218        SpawnLifetime::Detached => {
1219            enforce_detached_spawn_caps(
1220                context,
1221                budget_root_session_id.expect("detached authority returned a root"),
1222            )
1223            .await
1224        }
1225    };
1226    if let Err(error) = caps_result {
1227        return error;
1228    }
1229
1230    // Linked sessions are lifecycle children. Detached sessions are peers: no
1231    // parent_session_id, but fork lineage records who spawned them.
1232    let child_session = match store
1233        .create_session_with_options(PlatformCreateSessionRequest {
1234            harness_id: parent_session.harness_id,
1235            agent_id: if blueprint_param.is_some() {
1236                None // Blueprint sessions don't inherit agent
1237            } else {
1238                parent_session.agent_id
1239            },
1240            title: Some(name.to_string()),
1241            goal: goal.map(str::to_string),
1242            locale: parent_session.locale.clone(),
1243            blueprint_id: blueprint_param.clone(),
1244            blueprint_config: config_param.clone(),
1245            parent_session_id: (lifetime == SpawnLifetime::Linked).then_some(context.session_id),
1246            forked_from_session_id: (lifetime == SpawnLifetime::Detached)
1247                .then_some(context.session_id),
1248            budget_root_session_id,
1249            seed,
1250        })
1251        .await
1252    {
1253        Ok(s) => s,
1254        Err(e) => return ToolExecutionResult::internal_error(e),
1255    };
1256    // Create the session task tracking this subagent (specs/session-tasks.md).
1257    // Background tasks wake the parent on terminal transition through the
1258    // registry-level wake policy; foreground spawns already return the result
1259    // inline, so a wake would be noise.
1260    let mut task_id: Option<String> = None;
1261    let mut task_attempt: i32 = 1;
1262    let mut task_spec = json!({
1263        "instructions": instructions,
1264        "blueprint_id": blueprint_param,
1265        "mode": mode.as_str(),
1266        "lifetime": lifetime.as_str(),
1267        "seed": seed.as_str(),
1268    });
1269    if let Some(schema) = result_schema
1270        && let Some(spec) = task_spec.as_object_mut()
1271    {
1272        spec.insert(RESULT_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1273    }
1274    if let Some(schema) = message_schema
1275        && let Some(spec) = task_spec.as_object_mut()
1276    {
1277        spec.insert(MESSAGE_SCHEMA_SPEC_KEY.to_string(), schema.clone());
1278    }
1279    // Spawn-time push configs (EVE-682): embed in the task spec so the webhook
1280    // notifier delivers alongside endpoint-created (DB-backed) configs. URLs
1281    // were SSRF-validated in normalize_push_configs before reaching here.
1282    if let Some(configs) = push_configs
1283        && let Some(spec) = task_spec.as_object_mut()
1284    {
1285        spec.insert(PUSH_CONFIGS_SPEC_KEY.to_string(), configs.clone());
1286    }
1287
1288    if let Some(ref task_registry) = context.session_task_registry
1289        && let Ok(created) = task_registry
1290            .create(CreateSessionTask {
1291                session_id: context.session_id,
1292                id: None,
1293                kind: match lifetime {
1294                    SpawnLifetime::Linked => TASK_KIND_SUBAGENT,
1295                    SpawnLifetime::Detached => TASK_KIND_SESSION,
1296                }
1297                .to_string(),
1298                display_name: name.to_string(),
1299                spec: task_spec,
1300                state: SessionTaskState::Running,
1301                links: TaskLinks {
1302                    child_session_id: Some(child_session.id),
1303                    ..Default::default()
1304                },
1305                wake_policy: match (lifetime, mode, message_schema.is_some()) {
1306                    (SpawnLifetime::Detached, _, _) => TaskWakePolicy::Silent,
1307                    (SpawnLifetime::Linked, SpawnMode::Background, true) => {
1308                        TaskWakePolicy::OnActivity
1309                    }
1310                    (SpawnLifetime::Linked, SpawnMode::Background, false) => {
1311                        TaskWakePolicy::OnTerminal
1312                    }
1313                    (SpawnLifetime::Linked, SpawnMode::Foreground, _) => TaskWakePolicy::Silent,
1314                },
1315            })
1316            .await
1317    {
1318        task_id = Some(created.id);
1319        task_attempt = created.attempt;
1320    }
1321
1322    // Register child session ID durably BEFORE waiting.
1323    // This is the durability boundary: once registered, a reclaim/replay can
1324    // reattach to this child instead of spawning another.
1325    let wait_settle_ctx = if let Some((spawn_store, tool_call_id, spawn_handle_id, claim_token)) =
1326        settle_ctx
1327    {
1328        if let Err(e) = spawn_store
1329            .register_child_session(spawn_handle_id, claim_token, child_session.id)
1330            .await
1331        {
1332            tracing::warn!(
1333                tool_call_id,
1334                error = %e,
1335                "Failed to register child session in spawn handle; proceeding without durable reattach"
1336            );
1337        }
1338        Some((spawn_store, tool_call_id, claim_token))
1339    } else {
1340        None
1341    };
1342
1343    if mode == SpawnMode::Background {
1344        // The first message is sent inside the watcher: local/embedded hosts
1345        // (everruns-runtime) run the child's turn synchronously inside
1346        // send_message, so sending here would block the spawn call.
1347        spawn_background_watcher(
1348            context,
1349            child_session.id,
1350            name,
1351            Some(instructions.to_string()),
1352            task_id.clone(),
1353            task_attempt,
1354            wait_settle_ctx.map(|(_, _, claim_token)| claim_token),
1355            background_run_permit.expect("background permit acquired for background mode"),
1356        );
1357        return background_running_result(child_session.id, name, &task_id, blueprint_param);
1358    }
1359
1360    // Send the instructions as the first message
1361    if let Err(e) = store.send_message(child_session.id, instructions).await {
1362        finish_subagent_task(
1363            context,
1364            task_id.as_deref(),
1365            SessionTaskState::Failed,
1366            None,
1367            Some(TaskError {
1368                kind: "error".to_string(),
1369                message: e.to_string(),
1370            }),
1371        )
1372        .await;
1373        return ToolExecutionResult::internal_error(e);
1374    }
1375
1376    run_subagent_wait_and_settle(
1377        store,
1378        context,
1379        child_session.id,
1380        name,
1381        instructions,
1382        blueprint_param,
1383        task_id,
1384        wait_settle_ctx,
1385    )
1386    .await
1387}
1388
1389/// Wait for a child session to reach idle, collect its result, update the
1390/// registry, and settle the spawn handle (if a settle context is supplied).
1391#[allow(clippy::too_many_arguments)]
1392async fn run_subagent_wait_and_settle(
1393    store: &dyn PlatformStore,
1394    context: &ToolContext,
1395    child_id: crate::typed_id::SessionId,
1396    name: &str,
1397    _instructions: &str,
1398    blueprint_param: &Option<String>,
1399    task_id: Option<String>,
1400    settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
1401) -> ToolExecutionResult {
1402    // Foreground mode: wait for completion
1403    let status = match store.wait_for_idle(child_id, Some(300)).await {
1404        Ok(s) => s,
1405        Err(e) => {
1406            finish_subagent_task(
1407                context,
1408                task_id.as_deref(),
1409                SessionTaskState::Failed,
1410                None,
1411                Some(TaskError {
1412                    kind: "error".to_string(),
1413                    message: e.to_string(),
1414                }),
1415            )
1416            .await;
1417            return ToolExecutionResult::success(json!({
1418                "subagent_id": child_id.to_string(),
1419                "name": name,
1420                "status": "failed",
1421                "error": e.to_string(),
1422                "task_id": task_id,
1423                "blueprint": blueprint_param,
1424            }));
1425        }
1426    };
1427
1428    let result_text = match settle_subagent_outcome(
1429        store,
1430        context,
1431        child_id,
1432        &status,
1433        task_id.as_deref(),
1434        settle_ctx,
1435    )
1436    .await
1437    {
1438        Ok(text) => text,
1439        Err(error) => return error,
1440    };
1441    let result = result_value_for_task(context, task_id.as_deref())
1442        .await
1443        .unwrap_or_else(|| json!(result_text));
1444
1445    ToolExecutionResult::success(json!({
1446        "subagent_id": child_id.to_string(),
1447        "name": name,
1448        "status": status,
1449        "result": result,
1450        "task_id": task_id,
1451        "blueprint": blueprint_param,
1452    }))
1453}
1454
1455/// Collect the child's final message and, when `status` is terminal, settle
1456/// the spawn handle and mirror the outcome onto the session task. Non-terminal
1457/// statuses (paused, waiting_for_tool_results, timeout) only produce the
1458/// result text — the child stays active and the spawn stays reattachable.
1459async fn settle_subagent_outcome(
1460    store: &dyn PlatformStore,
1461    context: &ToolContext,
1462    child_id: crate::typed_id::SessionId,
1463    status: &str,
1464    task_id: Option<&str>,
1465    settle_ctx: Option<(&dyn crate::traits::SubagentSpawnStore, &str, uuid::Uuid)>,
1466) -> Result<String, ToolExecutionResult> {
1467    // Get the subagent's response messages
1468    let messages = match store.get_messages(child_id, Some(5)).await {
1469        Ok(m) => m,
1470        Err(e) => return Err(ToolExecutionResult::internal_error(e)),
1471    };
1472
1473    let result_text = last_agent_message(&messages)
1474        .unwrap_or_else(|| format!("Subagent completed with status: {status}"));
1475
1476    let terminal_status = terminal_subagent_status(status);
1477
1478    // Settle the spawn handle only when the child reached a terminal state.
1479    // Non-terminal waits must stay reattachable on replay.
1480    if let Some((spawn_store, tool_call_id, claim_token)) = settle_ctx
1481        && terminal_status.is_some()
1482        && let Err(e) = spawn_store
1483            .settle_spawn(
1484                context.session_id,
1485                tool_call_id,
1486                claim_token,
1487                status,
1488                &result_text,
1489            )
1490            .await
1491    {
1492        // Best-effort: log but don't fail the tool execution.
1493        tracing::warn!(
1494            tool_call_id,
1495            error = %e,
1496            "Failed to settle subagent spawn handle"
1497        );
1498    }
1499
1500    // Update the session task only when the child reached a terminal state.
1501    if let Some(subagent_status) = terminal_status {
1502        let mut task_state = terminal_subagent_task_state(&subagent_status);
1503        let mut task_error = if task_state == SessionTaskState::Failed {
1504            Some(TaskError {
1505                kind: status.to_string(),
1506                message: format!("Subagent session ended with status: {status}"),
1507            })
1508        } else {
1509            None
1510        };
1511        let mut summary = Some(truncate_summary(&result_text));
1512        if task_state == SessionTaskState::Succeeded
1513            && required_result_is_missing(context, task_id).await
1514        {
1515            task_state = SessionTaskState::Failed;
1516            task_error = Some(TaskError {
1517                kind: "no_result".to_string(),
1518                message:
1519                    "Subagent completed without calling report_result for its result_schema task."
1520                        .to_string(),
1521            });
1522            summary = Some("Subagent completed without reporting a structured result.".to_string());
1523        }
1524        finish_subagent_task(context, task_id, task_state, summary, task_error).await;
1525    }
1526
1527    Ok(result_text)
1528}
1529
1530/// Detach a watcher that drives a background subagent to completion: send the
1531/// first message (fresh spawns only), heartbeat the task registry so the
1532/// reaper can detect worker loss, wait for the child's terminal turn status,
1533/// then settle the task and spawn handle. The task's OnTerminal wake policy
1534/// notifies the parent session at the registry level.
1535#[allow(clippy::too_many_arguments)]
1536fn spawn_background_watcher(
1537    context: &ToolContext,
1538    child_id: crate::typed_id::SessionId,
1539    name: &str,
1540    first_message: Option<String>,
1541    task_id: Option<String>,
1542    task_attempt: i32,
1543    claim_token: Option<uuid::Uuid>,
1544    background_run_permit: BackgroundRunPermit,
1545) {
1546    let context = context.clone();
1547    let name = name.to_string();
1548    tokio::spawn(async move {
1549        let _background_run_permit = background_run_permit;
1550        let Some(store) = context.platform_store.clone() else {
1551            // Callers only enter background mode with a platform store wired.
1552            return;
1553        };
1554
1555        if let Some(instructions) = first_message
1556            && let Err(e) = store.send_message(child_id, &instructions).await
1557        {
1558            finish_subagent_task(
1559                &context,
1560                task_id.as_deref(),
1561                SessionTaskState::Failed,
1562                None,
1563                Some(TaskError {
1564                    kind: "error".to_string(),
1565                    message: e.to_string(),
1566                }),
1567            )
1568            .await;
1569            return;
1570        }
1571
1572        // Heartbeat so the session task reaper can fail an orphaned watcher
1573        // (worker loss) instead of leaving the task running forever. Fenced on
1574        // the attempt captured at spawn so a superseded watcher's writes are
1575        // rejected once the reaper bumps the attempt counter.
1576        let heartbeat = async {
1577            let (Some(registry), Some(task_id)) =
1578                (context.session_task_registry.clone(), task_id.clone())
1579            else {
1580                return std::future::pending::<()>().await;
1581            };
1582            loop {
1583                tokio::time::sleep(std::time::Duration::from_secs(
1584                    BACKGROUND_HEARTBEAT_INTERVAL_SECS,
1585                ))
1586                .await;
1587                let _ = registry
1588                    .update(
1589                        context.session_id,
1590                        &task_id,
1591                        SessionTaskUpdate {
1592                            heartbeat_at: Some(chrono::Utc::now()),
1593                            expected_attempt: Some(task_attempt),
1594                            ..Default::default()
1595                        },
1596                    )
1597                    .await;
1598            }
1599        };
1600
1601        let wait_and_settle = async {
1602            let started = tokio::time::Instant::now();
1603            loop {
1604                let status = match store
1605                    .wait_for_idle(child_id, Some(BACKGROUND_WAIT_SLICE_SECS))
1606                    .await
1607                {
1608                    Ok(s) => s,
1609                    Err(e) => {
1610                        finish_subagent_task(
1611                            &context,
1612                            task_id.as_deref(),
1613                            SessionTaskState::Failed,
1614                            None,
1615                            Some(TaskError {
1616                                kind: "error".to_string(),
1617                                message: e.to_string(),
1618                            }),
1619                        )
1620                        .await;
1621                        return;
1622                    }
1623                };
1624
1625                // Local/embedded hosts (everruns-runtime) run the child's turn
1626                // synchronously inside send_message and report a bare `idle`;
1627                // hosted adapters never return it (they poll until a terminal
1628                // turn event lands). Map it to completion so embedder tasks
1629                // settle instead of looping until the cap.
1630                let effective = if status == "idle" {
1631                    "completed".to_string()
1632                } else {
1633                    status
1634                };
1635
1636                if terminal_subagent_status(&effective).is_some() {
1637                    let settle_ctx = match (
1638                        context.subagent_spawn_store.as_ref(),
1639                        context.tool_call_id.as_ref(),
1640                        claim_token,
1641                    ) {
1642                        (Some(spawn_store), Some(tool_call_id), Some(token)) => Some((
1643                            spawn_store.as_ref() as &dyn crate::traits::SubagentSpawnStore,
1644                            tool_call_id.as_str(),
1645                            token,
1646                        )),
1647                        _ => None,
1648                    };
1649                    if let Err(error) = settle_subagent_outcome(
1650                        store.as_ref(),
1651                        &context,
1652                        child_id,
1653                        &effective,
1654                        task_id.as_deref(),
1655                        settle_ctx,
1656                    )
1657                    .await
1658                    {
1659                        tracing::warn!(
1660                            subagent_name = name,
1661                            child_session_id = %child_id,
1662                            ?error,
1663                            "Background subagent settle failed; marking task failed"
1664                        );
1665                        finish_subagent_task(
1666                            &context,
1667                            task_id.as_deref(),
1668                            SessionTaskState::Failed,
1669                            None,
1670                            Some(TaskError {
1671                                kind: "error".to_string(),
1672                                message: "Failed to read subagent result".to_string(),
1673                            }),
1674                        )
1675                        .await;
1676                    }
1677                    return;
1678                }
1679
1680                if started.elapsed().as_secs() >= BACKGROUND_MAX_WAIT_SECS {
1681                    finish_subagent_task(
1682                        &context,
1683                        task_id.as_deref(),
1684                        SessionTaskState::Failed,
1685                        None,
1686                        Some(TaskError {
1687                            kind: "timeout".to_string(),
1688                            message: format!(
1689                                "Background subagent did not finish within {BACKGROUND_MAX_WAIT_SECS}s (last status: {effective})"
1690                            ),
1691                        }),
1692                    )
1693                    .await;
1694                    return;
1695                }
1696
1697                // Non-terminal: record progress and keep waiting. Statuses
1698                // other than the wait-slice timeout return immediately, so
1699                // back off before re-waiting to avoid spinning.
1700                if let (Some(registry), Some(task_id)) =
1701                    (context.session_task_registry.as_ref(), task_id.as_deref())
1702                {
1703                    let _ = registry
1704                        .update(
1705                            context.session_id,
1706                            task_id,
1707                            SessionTaskUpdate {
1708                                state_detail: Some(format!(
1709                                    "waiting for subagent ({}s elapsed, last status: {effective})",
1710                                    started.elapsed().as_secs()
1711                                )),
1712                                expected_attempt: Some(task_attempt),
1713                                ..Default::default()
1714                            },
1715                        )
1716                        .await;
1717                }
1718                if !effective.starts_with("timeout") {
1719                    tokio::time::sleep(std::time::Duration::from_secs(
1720                        BACKGROUND_POLL_BACKOFF_SECS,
1721                    ))
1722                    .await;
1723                }
1724            }
1725        };
1726
1727        tokio::select! {
1728            () = wait_and_settle => {}
1729            () = heartbeat => {}
1730        }
1731    });
1732}
1733
1734// =============================================================================
1735// Task executor: subagent
1736// =============================================================================
1737
1738/// Control plane for `subagent` tasks. Inbound messages and cooperative
1739/// cancellation route through the child session's message channel — there is
1740/// no hard kill, so cancel delivers a graceful stop request via `cancel_task`.
1741pub struct SubagentTaskExecutor;
1742
1743#[async_trait]
1744impl TaskExecutor for SubagentTaskExecutor {
1745    fn kind(&self) -> &str {
1746        TASK_KIND_SUBAGENT
1747    }
1748
1749    async fn deliver(
1750        &self,
1751        task: &SessionTask,
1752        message: &TaskMessage,
1753        context: &ToolContext,
1754    ) -> crate::error::Result<()> {
1755        let Some(store) = context.platform_store.as_ref() else {
1756            return Err(crate::error::AgentLoopError::tool(
1757                "subagent task delivery requires platform_store context",
1758            ));
1759        };
1760        let Some(child_id) = task.links.child_session_id else {
1761            return Err(crate::error::AgentLoopError::tool(format!(
1762                "subagent task {} has no child session link",
1763                task.id
1764            )));
1765        };
1766        let text = task_message_text(&message.content);
1767        store.send_message(child_id, &text).await
1768    }
1769
1770    async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1771        let Some(store) = context.platform_store.as_ref() else {
1772            return Err(crate::error::AgentLoopError::tool(
1773                "subagent task cancellation requires platform_store context",
1774            ));
1775        };
1776        let Some(child_id) = task.links.child_session_id else {
1777            return Err(crate::error::AgentLoopError::tool(format!(
1778                "subagent task {} has no child session link",
1779                task.id
1780            )));
1781        };
1782        // Graceful stop request; takes effect after the current turn.
1783        store
1784            .send_message(
1785                child_id,
1786                "Cancellation requested by the parent session. Stop work, wind down, and reply with a brief summary of progress so far.",
1787            )
1788            .await
1789    }
1790
1791    /// Converge a subagent task whose background watcher is gone (worker
1792    /// loss): probe the child's terminal turn status and mirror it onto the
1793    /// task. Called from wait_task's poll loop; no-op while the child is
1794    /// still working.
1795    async fn reconcile(
1796        &self,
1797        task: &SessionTask,
1798        context: &ToolContext,
1799    ) -> crate::error::Result<()> {
1800        if task.state.is_terminal() {
1801            return Ok(());
1802        }
1803        let (Some(store), Some(child_id)) =
1804            (context.platform_store.as_ref(), task.links.child_session_id)
1805        else {
1806            return Ok(());
1807        };
1808        // Zero-timeout probe: returns the terminal turn status when the child
1809        // already finished, or a timeout marker while it is still running.
1810        let status = store.wait_for_idle(child_id, Some(0)).await?;
1811        if terminal_subagent_status(&status).is_none() {
1812            return Ok(());
1813        }
1814        settle_subagent_outcome(
1815            store.as_ref(),
1816            context,
1817            child_id,
1818            &status,
1819            Some(&task.id),
1820            None,
1821        )
1822        .await
1823        .map(|_| ())
1824        .map_err(|_| {
1825            crate::error::AgentLoopError::tool("Failed to read subagent result during reconcile")
1826        })
1827    }
1828}
1829
1830inventory::submit! {
1831    TaskExecutorPlugin {
1832        executor: || Arc::new(SubagentTaskExecutor),
1833    }
1834}
1835
1836/// Control plane for detached peer-session tracking tasks. `cancel` means
1837/// cancel everywhere (EVE-766): it cooperatively requests the peer session to
1838/// stop via the standard session cancel path, then settles the tracking task
1839/// `canceled`. The peer is a same-org session (inherited via fork lineage), so
1840/// the cooperative-cancel message routes through the ordinary send path — the
1841/// same mechanism linked subagents use.
1842pub struct DetachedSessionTaskExecutor;
1843
1844#[async_trait]
1845impl TaskExecutor for DetachedSessionTaskExecutor {
1846    fn kind(&self) -> &str {
1847        TASK_KIND_SESSION
1848    }
1849
1850    async fn cancel(&self, task: &SessionTask, context: &ToolContext) -> crate::error::Result<()> {
1851        let Some(registry) = context.session_task_registry.as_ref() else {
1852            return Ok(());
1853        };
1854        // Option A (EVE-766): cancel actually cancels. Deliver a cooperative
1855        // stop to the peer session first; only settle the tracking task once
1856        // the request is in flight, so a delivery failure surfaces to the
1857        // caller instead of silently claiming the peer was canceled.
1858        let summary = match (context.platform_store.as_ref(), task.links.child_session_id) {
1859            (Some(store), Some(peer_id)) => {
1860                store
1861                    .send_message(
1862                        peer_id,
1863                        "Cancellation requested by the session that spawned you. Stop work, wind down, and end your run.",
1864                    )
1865                    .await?;
1866                "Peer session cancellation requested; tracking settled canceled.".to_string()
1867            }
1868            // No peer link to signal (e.g. never wired): nothing to stop, just
1869            // settle the tracking task so the intent is honored.
1870            _ => "Detached session tracking canceled; no peer session link to signal.".to_string(),
1871        };
1872        registry
1873            .update(
1874                task.session_id,
1875                &task.id,
1876                SessionTaskUpdate {
1877                    state: Some(SessionTaskState::Canceled),
1878                    summary: Some(summary),
1879                    ..Default::default()
1880                },
1881            )
1882            .await?;
1883        Ok(())
1884    }
1885}
1886
1887inventory::submit! {
1888    TaskExecutorPlugin {
1889        executor: || Arc::new(DetachedSessionTaskExecutor),
1890    }
1891}
1892
1893#[cfg(test)]
1894mod tests {
1895    use super::*;
1896    use crate::Tool;
1897    use crate::session_task::{TaskMessageDirection, TaskMessagePart, task_result_path};
1898
1899    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
1900
1901    #[test]
1902    fn capability_features() {
1903        let cap = SubagentCapability;
1904        assert_eq!(cap.features(), vec!["subagents"]);
1905    }
1906
1907    #[test]
1908    fn terminal_subagent_status_maps_only_terminal_wait_states() {
1909        // `idle` is not terminal: a failed turn also idles the worker, so it
1910        // must not settle the subagent as completed.
1911        assert_eq!(terminal_subagent_status("idle"), None);
1912        assert_eq!(
1913            terminal_subagent_status("completed"),
1914            Some(crate::session::SubagentStatus::Completed)
1915        );
1916        assert_eq!(
1917            terminal_subagent_status("failed"),
1918            Some(crate::session::SubagentStatus::Failed)
1919        );
1920        assert_eq!(
1921            terminal_subagent_status("cancelled"),
1922            Some(crate::session::SubagentStatus::Cancelled)
1923        );
1924        assert_eq!(
1925            terminal_subagent_status("sealed"),
1926            Some(crate::session::SubagentStatus::Sealed)
1927        );
1928        assert_eq!(
1929            terminal_subagent_task_state(&crate::session::SubagentStatus::Completed),
1930            SessionTaskState::Succeeded
1931        );
1932        // A sealed subagent settles as a terminal, non-retryable failed task.
1933        assert_eq!(
1934            terminal_subagent_task_state(&crate::session::SubagentStatus::Sealed),
1935            SessionTaskState::Failed
1936        );
1937        assert_eq!(
1938            terminal_subagent_task_state(&crate::session::SubagentStatus::Cancelled),
1939            SessionTaskState::Canceled
1940        );
1941        assert_eq!(
1942            terminal_subagent_task_state(&crate::session::SubagentStatus::MaxIterationsReached),
1943            SessionTaskState::Failed
1944        );
1945        assert_eq!(terminal_subagent_status("waiting_for_tool_results"), None);
1946        assert_eq!(terminal_subagent_status("paused"), None);
1947    }
1948
1949    #[test]
1950    fn subagent_nesting_policy_resolves_platform_org_agent_precedence() {
1951        let platform = crate::traits::SubagentNestingPolicy::default().with_platform_default(4);
1952        assert_eq!(platform.max_subagent_depth(), 4);
1953
1954        let org = platform.with_org_override(Some(3));
1955        assert_eq!(org.max_subagent_depth(), 3);
1956
1957        let agent = org.with_agent_override(Some(1));
1958        assert_eq!(agent.max_subagent_depth(), 1);
1959    }
1960
1961    #[test]
1962    fn subagent_capability_is_high_risk() {
1963        assert_eq!(SubagentCapability.risk_level(), RiskLevel::High);
1964    }
1965
1966    #[test]
1967    fn spawn_agent_subagent_schema_advertises_only_subagent_target() {
1968        let tool = SpawnSubagentAsAgentTool;
1969        let schema = tool.parameters_schema();
1970        assert_eq!(
1971            schema["properties"]["target"]["properties"]["type"]["enum"],
1972            json!(["subagent"])
1973        );
1974        let required = schema["required"].as_array().unwrap();
1975        assert!(required.contains(&json!("target")));
1976        assert!(required.contains(&json!("name")));
1977        assert!(required.contains(&json!("instructions")));
1978        let props = schema["properties"].as_object().unwrap();
1979        assert!(props.contains_key("blueprint"));
1980        assert!(props.contains_key("config"));
1981        assert!(props.contains_key("result_schema"));
1982        assert!(props.contains_key("message_schema"));
1983        assert!(!required.contains(&json!("blueprint")));
1984        assert!(!required.contains(&json!("config")));
1985        assert_eq!(
1986            schema["properties"]["mode"]["enum"],
1987            json!(["background", "foreground"])
1988        );
1989        assert_eq!(
1990            tool.hints().concurrency_class.as_deref(),
1991            Some(SPAWN_AGENT_CONCURRENCY_CLASS),
1992            "spawn_agent calls share one scheduler class so cap admission is serialized"
1993        );
1994    }
1995
1996    // =========================================================================
1997    // Spawn handle tests (EVE-535)
1998    // =========================================================================
1999
2000    use crate::traits::{NoopSubagentSpawnStore, SpawnClaimResult, SubagentSpawnStore};
2001    use std::sync::Arc;
2002
2003    /// NoopSubagentSpawnStore always returns Claimed with a fresh token.
2004    #[tokio::test]
2005    async fn noop_spawn_store_always_claims() {
2006        let store = NoopSubagentSpawnStore;
2007        let parent = crate::typed_id::SessionId::new();
2008        let token = uuid::Uuid::new_v4();
2009
2010        let result = store
2011            .try_claim_spawn(parent, "call-1", token)
2012            .await
2013            .expect("noop should not error");
2014
2015        assert!(
2016            matches!(result, SpawnClaimResult::Claimed { claim_token, .. } if claim_token == token),
2017            "noop store should return Claimed with the supplied token"
2018        );
2019    }
2020
2021    /// NoopSubagentSpawnStore register and settle are always successful.
2022    #[tokio::test]
2023    async fn noop_spawn_store_register_and_settle_are_noops() {
2024        let store = NoopSubagentSpawnStore;
2025        let parent = crate::typed_id::SessionId::new();
2026        let child = crate::typed_id::SessionId::new();
2027        let handle_id = uuid::Uuid::new_v4();
2028        let token = uuid::Uuid::new_v4();
2029
2030        store
2031            .register_child_session(handle_id, token, child)
2032            .await
2033            .expect("noop register should not error");
2034
2035        store
2036            .settle_spawn(parent, "call-1", token, "idle", "result text")
2037            .await
2038            .expect("noop settle should not error");
2039    }
2040
2041    /// Arc<dyn SubagentSpawnStore> blanket impl delegates correctly.
2042    #[tokio::test]
2043    async fn arc_spawn_store_delegates() {
2044        let store: Arc<dyn SubagentSpawnStore> = Arc::new(NoopSubagentSpawnStore);
2045        let parent = crate::typed_id::SessionId::new();
2046        let token = uuid::Uuid::new_v4();
2047
2048        let result = store
2049            .try_claim_spawn(parent, "call-arc", token)
2050            .await
2051            .expect("arc delegation should not error");
2052
2053        assert!(matches!(result, SpawnClaimResult::Claimed { .. }));
2054    }
2055
2056    // =========================================================================
2057    // Background mode
2058    // =========================================================================
2059
2060    use crate::capabilities::session_tasks::tests::InMemorySessionTaskRegistry;
2061    use crate::platform_store::tests::MockPlatformStore;
2062    use crate::session_file::SessionFile;
2063    use crate::session_task::SessionTaskRegistry;
2064    use crate::traits::SessionFileSystem;
2065    use chrono::Utc;
2066    use std::collections::HashMap;
2067    use std::sync::Mutex;
2068
2069    /// SessionStore view over the mock platform store (depth-policy lookup).
2070    struct MockSessionStore(Arc<MockPlatformStore>);
2071
2072    struct MockSessionCreationAuthority {
2073        root: crate::typed_id::SessionId,
2074        allowed: bool,
2075    }
2076
2077    #[async_trait]
2078    impl crate::traits::SessionCreationAuthority for MockSessionCreationAuthority {
2079        async fn authorize_session_creation(
2080            &self,
2081            _session_id: crate::typed_id::SessionId,
2082        ) -> crate::error::Result<crate::typed_id::SessionId> {
2083            if self.allowed {
2084                Ok(self.root)
2085            } else {
2086                Err(crate::error::AgentLoopError::tool(
2087                    "org:sessions:manage is required",
2088                ))
2089            }
2090        }
2091    }
2092
2093    #[async_trait]
2094    impl crate::traits::SessionStore for MockSessionStore {
2095        async fn get_session(
2096            &self,
2097            session_id: crate::typed_id::SessionId,
2098        ) -> crate::error::Result<Option<crate::session::Session>> {
2099            self.0.get_session_by_id(session_id).await
2100        }
2101    }
2102
2103    fn spawn_context(
2104        store: &Arc<MockPlatformStore>,
2105        registry: Option<Arc<InMemorySessionTaskRegistry>>,
2106    ) -> ToolContext {
2107        spawn_context_for_session(store, registry, store.session.id)
2108    }
2109
2110    fn spawn_context_for_session(
2111        store: &Arc<MockPlatformStore>,
2112        registry: Option<Arc<InMemorySessionTaskRegistry>>,
2113        session_id: crate::typed_id::SessionId,
2114    ) -> ToolContext {
2115        let mut context = ToolContext::new(session_id);
2116        context.platform_store = Some(store.clone());
2117        context.session_store = Some(Arc::new(MockSessionStore(store.clone())));
2118        context.session_creation_authority = Some(Arc::new(MockSessionCreationAuthority {
2119            root: store.session.id,
2120            allowed: true,
2121        }));
2122        if let Some(registry) = registry {
2123            context.session_task_registry = Some(registry);
2124        }
2125        context
2126    }
2127
2128    async fn spawn(context: &ToolContext, args: Value) -> ToolExecutionResult {
2129        let mut args = args;
2130        if let Some(object) = args.as_object_mut() {
2131            object
2132                .entry("target")
2133                .or_insert_with(|| json!({"type": "subagent"}));
2134        }
2135        SpawnSubagentAsAgentTool
2136            .execute_with_context(args, context)
2137            .await
2138    }
2139
2140    /// Poll the registry until the subagent task reaches `state` (the
2141    /// background watcher settles it from a detached tokio task).
2142    async fn wait_for_task_state(
2143        registry: &InMemorySessionTaskRegistry,
2144        session_id: crate::typed_id::SessionId,
2145        task_id: &str,
2146        state: crate::session_task::SessionTaskState,
2147    ) -> crate::session_task::SessionTask {
2148        for _ in 0..200 {
2149            let task = registry
2150                .get(session_id, task_id)
2151                .await
2152                .expect("registry get")
2153                .expect("task exists");
2154            if task.state == state {
2155                return task;
2156            }
2157            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2158        }
2159        panic!("task {task_id} did not reach {state:?}");
2160    }
2161
2162    #[derive(Default)]
2163    struct MemoryFileStore {
2164        files: Mutex<HashMap<(uuid::Uuid, String), String>>,
2165    }
2166
2167    #[async_trait]
2168    impl SessionFileSystem for MemoryFileStore {
2169        fn is_mount_resolver(&self) -> bool {
2170            false
2171        }
2172
2173        async fn read_file(
2174            &self,
2175            session_id: crate::typed_id::SessionId,
2176            path: &str,
2177        ) -> crate::error::Result<Option<SessionFile>> {
2178            let content = self
2179                .files
2180                .lock()
2181                .unwrap()
2182                .get(&(session_id.uuid(), path.to_string()))
2183                .cloned();
2184            Ok(content.map(|content| SessionFile {
2185                id: uuid::Uuid::new_v4(),
2186                session_id: session_id.uuid(),
2187                path: path.to_string(),
2188                name: path.rsplit('/').next().unwrap_or(path).to_string(),
2189                content: Some(content.clone()),
2190                encoding: "utf-8".to_string(),
2191                is_directory: false,
2192                is_readonly: false,
2193                size_bytes: content.len() as i64,
2194                created_at: Utc::now(),
2195                updated_at: Utc::now(),
2196            }))
2197        }
2198
2199        async fn write_file(
2200            &self,
2201            session_id: crate::typed_id::SessionId,
2202            path: &str,
2203            content: &str,
2204            _encoding: &str,
2205        ) -> crate::error::Result<SessionFile> {
2206            self.files
2207                .lock()
2208                .unwrap()
2209                .insert((session_id.uuid(), path.to_string()), content.to_string());
2210            Ok(SessionFile {
2211                id: uuid::Uuid::new_v4(),
2212                session_id: session_id.uuid(),
2213                path: path.to_string(),
2214                name: path.rsplit('/').next().unwrap_or(path).to_string(),
2215                content: Some(content.to_string()),
2216                encoding: "utf-8".to_string(),
2217                is_directory: false,
2218                is_readonly: false,
2219                size_bytes: content.len() as i64,
2220                created_at: Utc::now(),
2221                updated_at: Utc::now(),
2222            })
2223        }
2224
2225        async fn delete_file(
2226            &self,
2227            session_id: crate::typed_id::SessionId,
2228            path: &str,
2229            _recursive: bool,
2230        ) -> crate::error::Result<bool> {
2231            Ok(self
2232                .files
2233                .lock()
2234                .unwrap()
2235                .remove(&(session_id.uuid(), path.to_string()))
2236                .is_some())
2237        }
2238
2239        async fn list_directory(
2240            &self,
2241            _session_id: crate::typed_id::SessionId,
2242            _path: &str,
2243        ) -> crate::error::Result<Vec<crate::session_file::FileInfo>> {
2244            Ok(vec![])
2245        }
2246
2247        async fn stat_file(
2248            &self,
2249            session_id: crate::typed_id::SessionId,
2250            path: &str,
2251        ) -> crate::error::Result<Option<crate::session_file::FileStat>> {
2252            let content = self
2253                .files
2254                .lock()
2255                .unwrap()
2256                .get(&(session_id.uuid(), path.to_string()))
2257                .cloned();
2258            Ok(content.map(|content| crate::session_file::FileStat {
2259                path: path.to_string(),
2260                name: path.rsplit('/').next().unwrap_or(path).to_string(),
2261                is_directory: false,
2262                is_readonly: false,
2263                size_bytes: content.len() as i64,
2264                created_at: Utc::now(),
2265                updated_at: Utc::now(),
2266            }))
2267        }
2268
2269        async fn grep_files(
2270            &self,
2271            _session_id: crate::typed_id::SessionId,
2272            _pattern: &str,
2273            _path_pattern: Option<&str>,
2274        ) -> crate::error::Result<Vec<crate::session_file::GrepMatch>> {
2275            Ok(vec![])
2276        }
2277
2278        async fn create_directory(
2279            &self,
2280            session_id: crate::typed_id::SessionId,
2281            path: &str,
2282        ) -> crate::error::Result<crate::session_file::FileInfo> {
2283            Ok(crate::session_file::FileInfo {
2284                id: uuid::Uuid::new_v4(),
2285                session_id: session_id.uuid(),
2286                path: path.to_string(),
2287                name: path.rsplit('/').next().unwrap_or(path).to_string(),
2288                is_directory: true,
2289                is_readonly: false,
2290                size_bytes: 0,
2291                created_at: Utc::now(),
2292                updated_at: Utc::now(),
2293            })
2294        }
2295    }
2296
2297    #[tokio::test]
2298    async fn spawn_agent_subagent_rejects_invalid_mode() {
2299        let context = ToolContext::new(crate::typed_id::SessionId::new());
2300        let result = spawn(
2301            &context,
2302            json!({"name": "Runner", "instructions": "go", "mode": "asap"}),
2303        )
2304        .await;
2305        let ToolExecutionResult::ToolError(msg) = result else {
2306            panic!("expected ToolError, got {result:?}");
2307        };
2308        assert!(msg.contains("Invalid mode"), "got: {msg}");
2309    }
2310
2311    #[tokio::test]
2312    async fn spawn_agent_subagent_rejects_other_target_types() {
2313        let context = ToolContext::new(crate::typed_id::SessionId::new());
2314        let result = SpawnSubagentAsAgentTool
2315            .execute_with_context(
2316                json!({
2317                    "name": "Runner",
2318                    "instructions": "go",
2319                    "target": {"type": "external_a2a"}
2320                }),
2321                &context,
2322            )
2323            .await;
2324        let ToolExecutionResult::ToolError(msg) = result else {
2325            panic!("expected ToolError, got {result:?}");
2326        };
2327        assert!(msg.contains("subagent"), "got: {msg}");
2328    }
2329
2330    #[tokio::test]
2331    async fn spawn_agent_subagent_creates_subagent_task() {
2332        let store = Arc::new(MockPlatformStore::new());
2333        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2334        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2335        let context = spawn_context(&store, Some(registry.clone()));
2336
2337        let result = SpawnSubagentAsAgentTool
2338            .execute_with_context(
2339                json!({
2340                    "name": "Runner",
2341                    "instructions": "go",
2342                    "target": {"type": "subagent"},
2343                    "mode": "foreground"
2344                }),
2345                &context,
2346            )
2347            .await;
2348        let ToolExecutionResult::Success(value) = result else {
2349            panic!("expected success, got {result:?}");
2350        };
2351        let task_id = value["task_id"].as_str().expect("task_id");
2352        let task = registry
2353            .get(context.session_id, task_id)
2354            .await
2355            .unwrap()
2356            .unwrap();
2357        assert_eq!(task.kind, TASK_KIND_SUBAGENT);
2358        assert_eq!(task.spec["mode"], "foreground");
2359        assert!(task.links.child_session_id.is_some());
2360    }
2361
2362    #[tokio::test]
2363    async fn detached_spawn_creates_peer_session_task_with_goal_and_lineage() {
2364        let store = Arc::new(MockPlatformStore::new());
2365        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2366        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2367        let context = spawn_context(&store, Some(registry.clone()));
2368
2369        let result = spawn(
2370            &context,
2371            json!({
2372                "name": "Research Peer",
2373                "goal": "Investigate latency",
2374                "instructions": "go",
2375                "lifetime": "detached",
2376                "seed": "workspace",
2377                "mode": "foreground"
2378            }),
2379        )
2380        .await;
2381        let ToolExecutionResult::Success(value) = result else {
2382            panic!("expected success, got {result:?}");
2383        };
2384        let child_id: crate::typed_id::SessionId = value["subagent_id"]
2385            .as_str()
2386            .expect("subagent_id")
2387            .parse()
2388            .expect("valid session id");
2389        let child = store
2390            .get_session_by_id(child_id)
2391            .await
2392            .unwrap()
2393            .expect("child session");
2394        assert_eq!(child.parent_session_id, None);
2395        assert_eq!(child.forked_from_session_id, Some(context.session_id));
2396        assert_eq!(child.title.as_deref(), Some("Research Peer"));
2397        assert_eq!(child.goal.as_deref(), Some("Investigate latency"));
2398        assert_eq!(
2399            store
2400                .created_session_budget_roots
2401                .lock()
2402                .unwrap()
2403                .as_slice(),
2404            &[Some(store.session.id)]
2405        );
2406
2407        let task_id = value["task_id"].as_str().expect("task_id");
2408        let task = registry
2409            .get(context.session_id, task_id)
2410            .await
2411            .unwrap()
2412            .expect("task");
2413        assert_eq!(task.kind, TASK_KIND_SESSION);
2414        assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
2415        assert_eq!(task.links.child_session_id, Some(child_id));
2416        assert_eq!(task.spec["lifetime"], "detached");
2417        assert_eq!(task.spec["seed"], "workspace");
2418    }
2419
2420    #[tokio::test]
2421    async fn detached_spawn_requires_session_creation_authority_before_creation() {
2422        let store = Arc::new(MockPlatformStore::new());
2423        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2424        let mut context = spawn_context(&store, Some(registry));
2425        context.session_creation_authority = None;
2426
2427        let result = spawn(
2428            &context,
2429            json!({"name": "Denied", "instructions": "go", "lifetime": "detached"}),
2430        )
2431        .await;
2432        let ToolExecutionResult::ToolError(message) = result else {
2433            panic!("expected authority ToolError, got {result:?}");
2434        };
2435        assert!(message.contains("session-creation authority"));
2436        assert!(
2437            store
2438                .created_session_budget_roots
2439                .lock()
2440                .unwrap()
2441                .is_empty()
2442        );
2443    }
2444
2445    #[tokio::test]
2446    async fn detached_spawn_reports_permission_denial_before_creation() {
2447        let store = Arc::new(MockPlatformStore::new());
2448        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2449        let mut context = spawn_context(&store, Some(registry));
2450        context.session_creation_authority = Some(Arc::new(MockSessionCreationAuthority {
2451            root: store.session.id,
2452            allowed: false,
2453        }));
2454
2455        let result = spawn(
2456            &context,
2457            json!({"name": "Denied", "instructions": "go", "lifetime": "detached"}),
2458        )
2459        .await;
2460        let ToolExecutionResult::ToolError(message) = result else {
2461            panic!("expected permission ToolError, got {result:?}");
2462        };
2463        assert!(message.contains("not authorized"));
2464        assert!(message.contains("org:sessions:manage"));
2465        assert!(
2466            store
2467                .created_session_budget_roots
2468                .lock()
2469                .unwrap()
2470                .is_empty()
2471        );
2472    }
2473
2474    #[tokio::test]
2475    async fn detached_spawn_bypasses_subagent_depth_guard() {
2476        let store = Arc::new(MockPlatformStore::new());
2477        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2478        let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2479            crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
2480        );
2481
2482        let linked = spawn(
2483            &context,
2484            json!({"name": "Linked", "instructions": "go", "mode": "foreground"}),
2485        )
2486        .await;
2487        assert!(matches!(linked, ToolExecutionResult::ToolError(_)));
2488
2489        let detached = spawn(
2490            &context,
2491            json!({
2492                "name": "Detached",
2493                "instructions": "go",
2494                "mode": "foreground",
2495                "lifetime": "detached"
2496            }),
2497        )
2498        .await;
2499        assert!(
2500            matches!(detached, ToolExecutionResult::Success(_)),
2501            "detached spawn should bypass linked depth guard, got {detached:?}"
2502        );
2503    }
2504
2505    // EVE-767: detached spawns reset depth but are still capped against the
2506    // origin root so a loop of detached spawns cannot run unbounded (TM-DOS).
2507
2508    fn session_task_under(
2509        root: crate::typed_id::SessionId,
2510        kind: &str,
2511        state: SessionTaskState,
2512    ) -> CreateSessionTask {
2513        CreateSessionTask {
2514            session_id: root,
2515            id: None,
2516            kind: kind.to_string(),
2517            display_name: "t".to_string(),
2518            spec: json!({}),
2519            state,
2520            links: TaskLinks {
2521                child_session_id: Some(crate::typed_id::SessionId::new()),
2522                ..Default::default()
2523            },
2524            wake_policy: TaskWakePolicy::Silent,
2525        }
2526    }
2527
2528    #[tokio::test]
2529    async fn detached_task_counts_ignore_subagent_and_terminal_active() {
2530        let store = Arc::new(MockPlatformStore::new());
2531        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2532        let root = store.session.id;
2533
2534        registry
2535            .create(session_task_under(
2536                root,
2537                TASK_KIND_SESSION,
2538                SessionTaskState::Running,
2539            ))
2540            .await
2541            .unwrap();
2542        registry
2543            .create(session_task_under(
2544                root,
2545                TASK_KIND_SESSION,
2546                SessionTaskState::Running,
2547            ))
2548            .await
2549            .unwrap();
2550        // Terminal detached task: counts toward total, not active.
2551        registry
2552            .create(session_task_under(
2553                root,
2554                TASK_KIND_SESSION,
2555                SessionTaskState::Canceled,
2556            ))
2557            .await
2558            .unwrap();
2559        // Subagent task: must not count toward the detached budget at all.
2560        registry
2561            .create(session_task_under(
2562                root,
2563                TASK_KIND_SUBAGENT,
2564                SessionTaskState::Running,
2565            ))
2566            .await
2567            .unwrap();
2568
2569        let counts = descendant_detached_task_counts(registry.as_ref(), root, 100, 100)
2570            .await
2571            .unwrap();
2572        assert_eq!(
2573            counts.active, 2,
2574            "only non-terminal session tasks are active"
2575        );
2576        assert_eq!(
2577            counts.total, 3,
2578            "terminal session task counts toward total; subagent task excluded"
2579        );
2580    }
2581
2582    #[tokio::test]
2583    async fn detached_spawn_rejected_at_cap_and_allowed_under_cap() {
2584        let store = Arc::new(MockPlatformStore::new());
2585        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2586        let context = spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(
2587            crate::traits::SubagentNestingPolicy::default()
2588                .with_agent_detached_task_caps_override(Some(1), Some(4)),
2589        );
2590
2591        // Under the ceiling (0 existing): one authorized detached spawn succeeds.
2592        let ok = spawn(
2593            &context,
2594            json!({"name": "D0", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2595        )
2596        .await;
2597        assert!(
2598            matches!(ok, ToolExecutionResult::Success(_)),
2599            "detached spawn under cap should succeed, got {ok:?}"
2600        );
2601
2602        // The spawn created one active detached peer task under the root → at
2603        // the active cap. The next detached spawn is refused with a clear error.
2604        let refused = spawn(
2605            &context,
2606            json!({"name": "D1", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2607        )
2608        .await;
2609        let ToolExecutionResult::ToolError(msg) = refused else {
2610            panic!("expected detached active cap ToolError, got {refused:?}");
2611        };
2612        assert!(
2613            msg.contains("max_active_detached_tasks is 1"),
2614            "cap error should name the limit, got: {msg}"
2615        );
2616    }
2617
2618    #[tokio::test]
2619    async fn detached_cap_does_not_affect_linked_subagent_spawn() {
2620        // A root already at the detached ceiling must still allow linked
2621        // subagent spawns — the two budgets are independent (regression guard).
2622        let store = Arc::new(MockPlatformStore::new());
2623        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2624        let context = spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(
2625            crate::traits::SubagentNestingPolicy::default()
2626                .with_agent_detached_task_caps_override(Some(1), Some(4)),
2627        );
2628        let root = store.session.id;
2629
2630        // Saturate the detached active cap.
2631        registry
2632            .create(session_task_under(
2633                root,
2634                TASK_KIND_SESSION,
2635                SessionTaskState::Running,
2636            ))
2637            .await
2638            .unwrap();
2639
2640        // A detached spawn is refused…
2641        let refused = spawn(
2642            &context,
2643            json!({"name": "D", "instructions": "go", "mode": "background", "lifetime": "detached"}),
2644        )
2645        .await;
2646        assert!(matches!(refused, ToolExecutionResult::ToolError(_)));
2647
2648        // …but a linked subagent spawn is unaffected by the detached cap.
2649        let linked = spawn(
2650            &context,
2651            json!({"name": "L", "instructions": "go", "mode": "background"}),
2652        )
2653        .await;
2654        assert!(
2655            matches!(linked, ToolExecutionResult::Success(_)),
2656            "linked subagent spawn must not be blocked by the detached cap, got {linked:?}"
2657        );
2658    }
2659
2660    #[tokio::test]
2661    async fn detached_session_task_cancel_requests_peer_cancellation() {
2662        // EVE-766: cancel_task on a detached-session task must cooperatively
2663        // cancel the peer session, not just detach the tracking chip.
2664        let store = Arc::new(MockPlatformStore::new());
2665        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2666        let context = spawn_context(&store, Some(registry.clone()));
2667        let child_id = crate::typed_id::SessionId::new();
2668        let task = registry
2669            .create(CreateSessionTask {
2670                session_id: context.session_id,
2671                id: None,
2672                kind: TASK_KIND_SESSION.to_string(),
2673                display_name: "Peer".to_string(),
2674                spec: json!({}),
2675                state: SessionTaskState::Running,
2676                links: TaskLinks {
2677                    child_session_id: Some(child_id),
2678                    ..Default::default()
2679                },
2680                wake_policy: TaskWakePolicy::Silent,
2681            })
2682            .await
2683            .unwrap();
2684
2685        DetachedSessionTaskExecutor
2686            .cancel(&task, &context)
2687            .await
2688            .unwrap();
2689
2690        // The peer session was signaled to stop via the standard send path.
2691        let sent = store.sent_messages.lock().unwrap().clone();
2692        assert_eq!(
2693            sent.len(),
2694            1,
2695            "exactly one cooperative-cancel message expected, got {sent:?}"
2696        );
2697        assert_eq!(sent[0].0, child_id, "cancel must target the peer session");
2698        assert!(
2699            sent[0].1.contains("Cancellation requested"),
2700            "cancel message should ask the peer to stop, got {:?}",
2701            sent[0].1
2702        );
2703
2704        // The tracking task settles canceled and keeps its peer link.
2705        let updated = registry
2706            .get(context.session_id, &task.id)
2707            .await
2708            .unwrap()
2709            .expect("task should remain present");
2710        assert_eq!(updated.state, SessionTaskState::Canceled);
2711        assert_eq!(updated.links.child_session_id, Some(child_id));
2712        assert_eq!(
2713            updated.summary.as_deref(),
2714            Some("Peer session cancellation requested; tracking settled canceled.")
2715        );
2716    }
2717
2718    #[tokio::test]
2719    async fn detached_session_task_cancel_without_peer_link_still_settles() {
2720        // Defensive: a session-kind task with no peer link has nothing to
2721        // signal, but the cancel intent must still be honored.
2722        let store = Arc::new(MockPlatformStore::new());
2723        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2724        let context = spawn_context(&store, Some(registry.clone()));
2725        let task = registry
2726            .create(CreateSessionTask {
2727                session_id: context.session_id,
2728                id: None,
2729                kind: TASK_KIND_SESSION.to_string(),
2730                display_name: "Peer".to_string(),
2731                spec: json!({}),
2732                state: SessionTaskState::Running,
2733                links: TaskLinks::default(),
2734                wake_policy: TaskWakePolicy::Silent,
2735            })
2736            .await
2737            .unwrap();
2738
2739        DetachedSessionTaskExecutor
2740            .cancel(&task, &context)
2741            .await
2742            .unwrap();
2743
2744        assert!(store.sent_messages.lock().unwrap().is_empty());
2745        let updated = registry
2746            .get(context.session_id, &task.id)
2747            .await
2748            .unwrap()
2749            .expect("task should remain present");
2750        assert_eq!(updated.state, SessionTaskState::Canceled);
2751        assert_eq!(
2752            updated.summary.as_deref(),
2753            Some("Detached session tracking canceled; no peer session link to signal.")
2754        );
2755    }
2756
2757    #[tokio::test]
2758    async fn spawn_agent_subagent_allows_depth_two_and_rejects_depth_three_by_default() {
2759        let store = Arc::new(MockPlatformStore::new());
2760        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2761        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2762        let root_context = spawn_context(&store, Some(registry.clone()));
2763
2764        let first = spawn(
2765            &root_context,
2766            json!({"name": "B", "instructions": "go", "mode": "background"}),
2767        )
2768        .await;
2769        let ToolExecutionResult::Success(first_value) = first else {
2770            panic!("expected first spawn success, got {first:?}");
2771        };
2772        let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
2773            .as_str()
2774            .expect("subagent_id")
2775            .parse()
2776            .expect("valid session id");
2777
2778        let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id);
2779        let second = spawn(
2780            &b_context,
2781            json!({"name": "C", "instructions": "go", "mode": "background"}),
2782        )
2783        .await;
2784        let ToolExecutionResult::Success(second_value) = second else {
2785            panic!("expected second spawn success, got {second:?}");
2786        };
2787        let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
2788            .as_str()
2789            .expect("subagent_id")
2790            .parse()
2791            .expect("valid session id");
2792
2793        let c_context = spawn_context_for_session(&store, Some(registry), c_id);
2794        let third = spawn(
2795            &c_context,
2796            json!({"name": "D", "instructions": "go", "mode": "background"}),
2797        )
2798        .await;
2799        let ToolExecutionResult::ToolError(message) = third else {
2800            panic!("expected depth cap ToolError, got {third:?}");
2801        };
2802        assert!(
2803            message.contains("max_subagent_depth is 2"),
2804            "got: {message}"
2805        );
2806        assert!(message.contains("depth 3"), "got: {message}");
2807    }
2808
2809    #[tokio::test]
2810    async fn spawn_agent_subagent_depth_zero_restores_hard_block() {
2811        let store = Arc::new(MockPlatformStore::new());
2812        let mut context = spawn_context(&store, None).with_subagent_nesting_policy(
2813            crate::traits::SubagentNestingPolicy::default().with_agent_override(Some(0)),
2814        );
2815        context.session_task_registry = Some(Arc::new(InMemorySessionTaskRegistry::default()));
2816
2817        let result = spawn(
2818            &context,
2819            json!({"name": "Blocked", "instructions": "go", "mode": "background"}),
2820        )
2821        .await;
2822        let ToolExecutionResult::ToolError(message) = result else {
2823            panic!("expected depth cap ToolError, got {result:?}");
2824        };
2825        assert!(
2826            message.contains("max_subagent_depth is 0"),
2827            "got: {message}"
2828        );
2829        assert!(message.contains("depth 1"), "got: {message}");
2830    }
2831
2832    #[tokio::test]
2833    async fn spawn_agent_subagent_rejects_when_active_descendant_cap_is_full() {
2834        let store = Arc::new(MockPlatformStore::new());
2835        *store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
2836        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2837        let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2838            crate::traits::SubagentNestingPolicy::default()
2839                .with_agent_task_caps_override(Some(1), Some(200)),
2840        );
2841
2842        let first = spawn(
2843            &context,
2844            json!({"name": "First", "instructions": "go", "mode": "background"}),
2845        )
2846        .await;
2847        assert!(
2848            matches!(first, ToolExecutionResult::Success(_)),
2849            "expected first spawn success, got {first:?}"
2850        );
2851
2852        let second = spawn(
2853            &context,
2854            json!({"name": "Second", "instructions": "go", "mode": "background"}),
2855        )
2856        .await;
2857        let ToolExecutionResult::ToolError(message) = second else {
2858            panic!("expected active cap ToolError, got {second:?}");
2859        };
2860        assert!(
2861            message.contains("max_active_descendant_tasks is 1"),
2862            "got: {message}"
2863        );
2864        assert!(
2865            message.contains("2 non-terminal descendant tasks"),
2866            "got: {message}"
2867        );
2868    }
2869
2870    #[tokio::test]
2871    async fn spawn_agent_subagent_counts_grandchildren_for_active_descendant_cap() {
2872        let store = Arc::new(MockPlatformStore::new());
2873        *store.wait_for_idle_status.lock().unwrap() = "waiting_for_tool_results".to_string();
2874        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2875        let policy = crate::traits::SubagentNestingPolicy::default()
2876            .with_agent_override(Some(4))
2877            .with_agent_task_caps_override(Some(2), Some(200));
2878        let root_context =
2879            spawn_context(&store, Some(registry.clone())).with_subagent_nesting_policy(policy);
2880
2881        let first = spawn(
2882            &root_context,
2883            json!({"name": "B", "instructions": "go", "mode": "background"}),
2884        )
2885        .await;
2886        let ToolExecutionResult::Success(first_value) = first else {
2887            panic!("expected first spawn success, got {first:?}");
2888        };
2889        let b_id: crate::typed_id::SessionId = first_value["subagent_id"]
2890            .as_str()
2891            .expect("subagent_id")
2892            .parse()
2893            .expect("valid session id");
2894
2895        let b_context = spawn_context_for_session(&store, Some(registry.clone()), b_id)
2896            .with_subagent_nesting_policy(policy);
2897        let second = spawn(
2898            &b_context,
2899            json!({"name": "C", "instructions": "go", "mode": "background"}),
2900        )
2901        .await;
2902        let ToolExecutionResult::Success(second_value) = second else {
2903            panic!("expected second spawn success, got {second:?}");
2904        };
2905        let c_id: crate::typed_id::SessionId = second_value["subagent_id"]
2906            .as_str()
2907            .expect("subagent_id")
2908            .parse()
2909            .expect("valid session id");
2910
2911        let c_context = spawn_context_for_session(&store, Some(registry), c_id)
2912            .with_subagent_nesting_policy(policy);
2913        let third = spawn(
2914            &c_context,
2915            json!({"name": "D", "instructions": "go", "mode": "background"}),
2916        )
2917        .await;
2918        let ToolExecutionResult::ToolError(message) = third else {
2919            panic!("expected active cap ToolError, got {third:?}");
2920        };
2921        assert!(
2922            message.contains("max_active_descendant_tasks is 2"),
2923            "got: {message}"
2924        );
2925        assert!(message.contains("root session"), "got: {message}");
2926    }
2927
2928    #[tokio::test]
2929    async fn spawn_agent_subagent_total_descendant_cap_counts_terminal_tasks() {
2930        let store = Arc::new(MockPlatformStore::new());
2931        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2932        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2933        let context = spawn_context(&store, Some(registry)).with_subagent_nesting_policy(
2934            crate::traits::SubagentNestingPolicy::default()
2935                .with_agent_task_caps_override(Some(16), Some(1)),
2936        );
2937
2938        let first = spawn(
2939            &context,
2940            json!({"name": "First", "instructions": "go", "mode": "foreground"}),
2941        )
2942        .await;
2943        assert!(
2944            matches!(first, ToolExecutionResult::Success(_)),
2945            "expected first spawn success, got {first:?}"
2946        );
2947
2948        let second = spawn(
2949            &context,
2950            json!({"name": "Second", "instructions": "go", "mode": "foreground"}),
2951        )
2952        .await;
2953        let ToolExecutionResult::ToolError(message) = second else {
2954            panic!("expected total cap ToolError, got {second:?}");
2955        };
2956        assert!(
2957            message.contains("max_total_descendant_tasks is 1"),
2958            "got: {message}"
2959        );
2960        assert!(
2961            message.contains("2 descendant task records"),
2962            "got: {message}"
2963        );
2964    }
2965
2966    #[test]
2967    fn subagents_config_validates_descendant_task_caps() {
2968        let capability = SubagentCapability;
2969        assert!(
2970            capability
2971                .validate_config(&json!({
2972                    "max_active_descendant_tasks": 16,
2973                    "max_total_descendant_tasks": 200
2974                }))
2975                .is_ok()
2976        );
2977        assert_eq!(
2978            capability
2979                .validate_config(&json!({"max_active_descendant_tasks": 1025}))
2980                .unwrap_err(),
2981            "max_active_descendant_tasks must be <= 1024"
2982        );
2983        assert_eq!(
2984            capability
2985                .validate_config(&json!({"max_total_descendant_tasks": 10001}))
2986                .unwrap_err(),
2987            "max_total_descendant_tasks must be <= 10000"
2988        );
2989    }
2990
2991    #[tokio::test]
2992    async fn spawn_agent_subagent_stores_result_schema_on_task() {
2993        let store = Arc::new(MockPlatformStore::new());
2994        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
2995        let registry = Arc::new(InMemorySessionTaskRegistry::default());
2996        let context = spawn_context(&store, Some(registry.clone()));
2997
2998        let result = SpawnSubagentAsAgentTool
2999            .execute_with_context(
3000                json!({
3001                    "name": "Runner",
3002                    "instructions": "go",
3003                    "target": {"type": "subagent"},
3004                    "mode": "foreground",
3005                    "result_schema": {
3006                        "type": "object",
3007                        "properties": {"answer": {"type": "string"}},
3008                        "required": ["answer"],
3009                        "additionalProperties": false
3010                    }
3011                }),
3012                &context,
3013            )
3014            .await;
3015        let ToolExecutionResult::Success(value) = result else {
3016            panic!("expected success, got {result:?}");
3017        };
3018        let task_id = value["task_id"].as_str().expect("task_id");
3019        let task = registry
3020            .get(context.session_id, task_id)
3021            .await
3022            .unwrap()
3023            .unwrap();
3024        assert_eq!(task.spec["result_schema"]["required"], json!(["answer"]));
3025        assert_eq!(task.state, SessionTaskState::Failed);
3026        assert_eq!(
3027            task.error.as_ref().map(|e| e.kind.as_str()),
3028            Some("no_result")
3029        );
3030    }
3031
3032    #[tokio::test]
3033    async fn report_result_writes_result_file_and_updates_task() {
3034        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3035        let file_store = Arc::new(MemoryFileStore::default());
3036        let parent_session_id = crate::typed_id::SessionId::new();
3037        let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
3038        let child_session_id = crate::typed_id::SessionId::new();
3039        let task = registry
3040            .create(CreateSessionTask {
3041                session_id: parent_session_id,
3042                id: None,
3043                kind: TASK_KIND_SUBAGENT.to_string(),
3044                display_name: "Runner".to_string(),
3045                spec: json!({
3046                    "result_schema": {
3047                        "type": "object",
3048                        "properties": {"answer": {"type": "string"}},
3049                        "required": ["answer"],
3050                        "additionalProperties": false
3051                    }
3052                }),
3053                state: SessionTaskState::Running,
3054                links: TaskLinks {
3055                    child_session_id: Some(child_session_id),
3056                    ..Default::default()
3057                },
3058                wake_policy: TaskWakePolicy::Silent,
3059            })
3060            .await
3061            .unwrap();
3062
3063        let tool = ReportResultTool::new(
3064            parent_session_id,
3065            parent_workspace_id,
3066            child_session_id,
3067            task.id.clone(),
3068            task.spec["result_schema"].clone(),
3069        )
3070        .with_file_store(file_store.clone());
3071        let mut context = ToolContext::new(child_session_id);
3072        context.session_task_registry = Some(registry.clone());
3073
3074        let result = tool
3075            .execute_with_context(json!({"answer": "done"}), &context)
3076            .await;
3077        let ToolExecutionResult::Success(value) = result else {
3078            panic!("expected success, got {result:?}");
3079        };
3080        assert_eq!(value["result_path"], task_result_path(&task.id));
3081
3082        let task = registry
3083            .get(parent_session_id, &task.id)
3084            .await
3085            .unwrap()
3086            .unwrap();
3087        let result_path = task.result_path.as_deref().expect("result_path");
3088        let file = file_store
3089            .read_file(
3090                SessionId::from_uuid(parent_workspace_id.uuid()),
3091                result_path,
3092            )
3093            .await
3094            .unwrap()
3095            .expect("result file");
3096        assert_eq!(
3097            serde_json::from_str::<Value>(file.content.as_deref().unwrap()).unwrap(),
3098            json!({"answer": "done"})
3099        );
3100    }
3101
3102    #[tokio::test]
3103    async fn report_result_rejects_terminal_task_without_overwriting_result() {
3104        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3105        let file_store = Arc::new(MemoryFileStore::default());
3106        let parent_session_id = crate::typed_id::SessionId::new();
3107        let parent_workspace_id = crate::typed_id::WorkspaceId::from_uuid(parent_session_id.uuid());
3108        let child_session_id = crate::typed_id::SessionId::new();
3109        let task = registry
3110            .create(CreateSessionTask {
3111                session_id: parent_session_id,
3112                id: None,
3113                kind: TASK_KIND_SUBAGENT.to_string(),
3114                display_name: "Runner".to_string(),
3115                spec: json!({
3116                    "result_schema": {
3117                        "type": "object",
3118                        "properties": {"answer": {"type": "string"}},
3119                        "required": ["answer"],
3120                        "additionalProperties": false
3121                    }
3122                }),
3123                state: SessionTaskState::Succeeded,
3124                links: TaskLinks {
3125                    child_session_id: Some(child_session_id),
3126                    ..Default::default()
3127                },
3128                wake_policy: TaskWakePolicy::Silent,
3129            })
3130            .await
3131            .unwrap();
3132        let existing_path = task_result_path(&task.id);
3133        registry
3134            .update(
3135                parent_session_id,
3136                &task.id,
3137                SessionTaskUpdate {
3138                    result_path: Some(existing_path.clone()),
3139                    summary: Some("original".to_string()),
3140                    ..Default::default()
3141                },
3142            )
3143            .await
3144            .unwrap();
3145        file_store
3146            .write_file(
3147                SessionId::from_uuid(parent_workspace_id.uuid()),
3148                &existing_path,
3149                "{\n  \"answer\": \"original\"\n}",
3150                "utf-8",
3151            )
3152            .await
3153            .unwrap();
3154
3155        let tool = ReportResultTool::new(
3156            parent_session_id,
3157            parent_workspace_id,
3158            child_session_id,
3159            task.id.clone(),
3160            task.spec["result_schema"].clone(),
3161        )
3162        .with_file_store(file_store.clone());
3163        let mut context = ToolContext::new(child_session_id);
3164        context.session_task_registry = Some(registry.clone());
3165
3166        let result = tool
3167            .execute_with_context(json!({"answer": "tampered"}), &context)
3168            .await;
3169        let ToolExecutionResult::ToolError(message) = result else {
3170            panic!("expected terminal rejection, got {result:?}");
3171        };
3172        assert!(message.contains("terminal"), "got: {message}");
3173
3174        let file = file_store
3175            .read_file(
3176                SessionId::from_uuid(parent_workspace_id.uuid()),
3177                &existing_path,
3178            )
3179            .await
3180            .unwrap()
3181            .expect("result file");
3182        assert!(
3183            file.content.as_deref().unwrap().contains("original"),
3184            "file was overwritten: {file:?}"
3185        );
3186    }
3187
3188    #[tokio::test]
3189    async fn report_result_rejects_invalid_result_schema_payload() {
3190        let tool = ReportResultTool::new(
3191            crate::typed_id::SessionId::new(),
3192            crate::typed_id::WorkspaceId::from_uuid(uuid::Uuid::new_v4()),
3193            crate::typed_id::SessionId::new(),
3194            "task_test".to_string(),
3195            json!({
3196                "type": "object",
3197                "properties": {"answer": {"type": "string"}},
3198                "required": ["answer"],
3199                "additionalProperties": false
3200            }),
3201        );
3202        let result = tool
3203            .execute_with_context(json!({"extra": true}), &ToolContext::new(SessionId::new()))
3204            .await;
3205        let ToolExecutionResult::ToolError(message) = result else {
3206            panic!("expected validation error, got {result:?}");
3207        };
3208        assert!(
3209            message.contains("answer") && message.contains("required"),
3210            "got: {message}"
3211        );
3212        assert!(
3213            message.contains("extra")
3214                && (message.contains("additional") || message.contains("not allowed")),
3215            "got: {message}"
3216        );
3217    }
3218
3219    #[tokio::test]
3220    async fn spawn_agent_subagent_stores_message_schema_and_wakes_on_activity() {
3221        let store = Arc::new(MockPlatformStore::new());
3222        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3223        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3224        let context = spawn_context(&store, Some(registry.clone()));
3225
3226        let result = SpawnSubagentAsAgentTool
3227            .execute_with_context(
3228                json!({
3229                    "name": "Runner",
3230                    "instructions": "go",
3231                    "target": {"type": "subagent"},
3232                    "message_schema": {
3233                        "type": "object",
3234                        "properties": {"step": {"type": "string"}},
3235                        "required": ["step"],
3236                        "additionalProperties": false
3237                    }
3238                }),
3239                &context,
3240            )
3241            .await;
3242        let ToolExecutionResult::Success(value) = result else {
3243            panic!("expected success, got {result:?}");
3244        };
3245        let task_id = value["task_id"].as_str().expect("task_id");
3246        let task = registry
3247            .get(context.session_id, task_id)
3248            .await
3249            .unwrap()
3250            .unwrap();
3251        assert_eq!(task.spec["message_schema"]["required"], json!(["step"]));
3252        assert_eq!(task.wake_policy, TaskWakePolicy::OnActivity);
3253    }
3254
3255    #[tokio::test]
3256    async fn report_task_progress_posts_structured_outbound_message() {
3257        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3258        let parent_session_id = crate::typed_id::SessionId::new();
3259        let task = registry
3260            .create(CreateSessionTask {
3261                session_id: parent_session_id,
3262                id: None,
3263                kind: TASK_KIND_SUBAGENT.to_string(),
3264                display_name: "Runner".to_string(),
3265                spec: json!({
3266                    "message_schema": {
3267                        "type": "object",
3268                        "properties": {"step": {"type": "string"}},
3269                        "required": ["step"],
3270                        "additionalProperties": false
3271                    }
3272                }),
3273                state: SessionTaskState::Running,
3274                links: TaskLinks::default(),
3275                wake_policy: TaskWakePolicy::OnActivity,
3276            })
3277            .await
3278            .unwrap();
3279
3280        let tool = ReportTaskProgressTool::new(
3281            parent_session_id,
3282            task.id.clone(),
3283            task.attempt,
3284            task.spec["message_schema"].clone(),
3285        );
3286        assert_eq!(tool.name(), "report_task_progress");
3287        let mut context = ToolContext::new(crate::typed_id::SessionId::new());
3288        context.session_task_registry = Some(registry.clone());
3289
3290        let result = tool
3291            .execute_with_context(json!({"step": "tests-running"}), &context)
3292            .await;
3293        let ToolExecutionResult::Success(value) = result else {
3294            panic!("expected success, got {result:?}");
3295        };
3296        assert_eq!(value["status"], "posted");
3297
3298        let messages = registry
3299            .list_messages(parent_session_id, &task.id, None, None)
3300            .await
3301            .unwrap();
3302        assert_eq!(messages.len(), 1);
3303        assert_eq!(messages[0].direction, TaskMessageDirection::Outbound);
3304        assert_eq!(
3305            messages[0].content,
3306            vec![TaskMessagePart::Data {
3307                data: json!({"step": "tests-running"})
3308            }]
3309        );
3310    }
3311
3312    #[tokio::test]
3313    async fn report_task_progress_rejects_stale_task_attempt() {
3314        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3315        let parent_session_id = crate::typed_id::SessionId::new();
3316        let task = registry
3317            .create(CreateSessionTask {
3318                session_id: parent_session_id,
3319                id: None,
3320                kind: TASK_KIND_SUBAGENT.to_string(),
3321                display_name: "Runner".to_string(),
3322                spec: json!({"message_schema": {"type": "object"}}),
3323                state: SessionTaskState::Running,
3324                links: TaskLinks::default(),
3325                wake_policy: TaskWakePolicy::OnActivity,
3326            })
3327            .await
3328            .unwrap();
3329        let tool = ReportTaskProgressTool::new(
3330            parent_session_id,
3331            task.id.clone(),
3332            task.attempt,
3333            task.spec["message_schema"].clone(),
3334        );
3335
3336        // Supersede the attempt the tool captured at construction.
3337        registry
3338            .update(
3339                parent_session_id,
3340                &task.id,
3341                SessionTaskUpdate {
3342                    state: Some(SessionTaskState::Failed),
3343                    increment_attempt: true,
3344                    ..Default::default()
3345                },
3346            )
3347            .await
3348            .unwrap();
3349
3350        let mut context = ToolContext::new(crate::typed_id::SessionId::new());
3351        context.session_task_registry = Some(registry.clone());
3352        let result = tool
3353            .execute_with_context(json!({"step": "late"}), &context)
3354            .await;
3355        assert!(
3356            matches!(result, ToolExecutionResult::InternalError(_)),
3357            "stale progress must be rejected, got {result:?}"
3358        );
3359        let messages = registry
3360            .list_messages(parent_session_id, &task.id, None, None)
3361            .await
3362            .unwrap();
3363        assert!(
3364            messages.is_empty(),
3365            "stale progress must not append messages"
3366        );
3367    }
3368
3369    #[tokio::test]
3370    async fn report_task_progress_rejects_invalid_message_schema_payload() {
3371        let tool = ReportTaskProgressTool::new(
3372            crate::typed_id::SessionId::new(),
3373            "task_test".to_string(),
3374            1,
3375            json!({
3376                "type": "object",
3377                "properties": {"step": {"type": "string"}},
3378                "required": ["step"],
3379                "additionalProperties": false
3380            }),
3381        );
3382        let result = tool
3383            .execute_with_context(
3384                json!({"step": 42, "extra": true}),
3385                &ToolContext::new(SessionId::new()),
3386            )
3387            .await;
3388        let ToolExecutionResult::ToolError(message) = result else {
3389            panic!("expected validation error, got {result:?}");
3390        };
3391        assert!(
3392            message.contains("step") && message.contains("string"),
3393            "got: {message}"
3394        );
3395        assert!(
3396            message.contains("extra")
3397                && (message.contains("additional") || message.contains("not allowed")),
3398            "got: {message}"
3399        );
3400    }
3401
3402    #[test]
3403    fn subagent_and_channel_progress_tools_have_distinct_names() {
3404        // EVE-727: the subagent interim-progress tool must not share a wire name
3405        // with the channel-facing `report_progress` tool. `ToolRegistry` is keyed
3406        // by name, so a collision would silently drop one tool. This guards the
3407        // rename against regressions: both tools must coexist in one registry.
3408        use crate::progress_reporting::{
3409            REPORT_PROGRESS_TOOL_NAME, ReportProgressTool as ChannelReportProgressTool,
3410        };
3411        use crate::tools::ToolRegistry;
3412
3413        let subagent = ReportTaskProgressTool::new(
3414            crate::typed_id::SessionId::new(),
3415            "task_test".to_string(),
3416            1,
3417            json!({"type": "object"}),
3418        );
3419        assert_eq!(subagent.name(), "report_task_progress");
3420        assert_eq!(REPORT_PROGRESS_TOOL_NAME, "report_progress");
3421        assert_ne!(subagent.name(), REPORT_PROGRESS_TOOL_NAME);
3422
3423        let mut registry = ToolRegistry::new();
3424        registry.register(ChannelReportProgressTool);
3425        registry.register(subagent);
3426        assert!(
3427            registry.has("report_progress"),
3428            "channel report_progress tool must survive"
3429        );
3430        assert!(
3431            registry.has("report_task_progress"),
3432            "subagent report_task_progress tool must survive"
3433        );
3434    }
3435
3436    #[tokio::test]
3437    async fn explicit_background_without_registry_errors() {
3438        let context = ToolContext::new(crate::typed_id::SessionId::new());
3439        let result = spawn(
3440            &context,
3441            json!({"name": "Runner", "instructions": "go", "mode": "background"}),
3442        )
3443        .await;
3444        let ToolExecutionResult::ToolError(msg) = result else {
3445            panic!("expected ToolError, got {result:?}");
3446        };
3447        assert!(
3448            msg.contains("task registry") && msg.contains("foreground"),
3449            "got: {msg}"
3450        );
3451    }
3452
3453    #[tokio::test]
3454    async fn default_mode_without_registry_degrades_to_foreground() {
3455        let store = Arc::new(MockPlatformStore::new());
3456        let context = spawn_context(&store, None);
3457        let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3458        let ToolExecutionResult::Success(value) = result else {
3459            panic!("expected success, got {result:?}");
3460        };
3461        // Foreground semantics: waited inline and returned the child's reply.
3462        assert_eq!(value["status"], "idle");
3463        assert_eq!(value["result"], "Hi!");
3464        assert!(value.get("mode").is_none());
3465    }
3466
3467    #[tokio::test]
3468    async fn background_spawn_returns_immediately_and_settles_task() {
3469        let store = Arc::new(MockPlatformStore::new());
3470        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3471        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3472        let context = spawn_context(&store, Some(registry.clone()));
3473
3474        let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3475        let ToolExecutionResult::Success(value) = result else {
3476            panic!("expected success, got {result:?}");
3477        };
3478        assert_eq!(value["status"], "running");
3479        assert_eq!(value["mode"], "background");
3480        let task_id = value["task_id"].as_str().expect("task_id").to_string();
3481
3482        let task = wait_for_task_state(
3483            &registry,
3484            context.session_id,
3485            &task_id,
3486            SessionTaskState::Succeeded,
3487        )
3488        .await;
3489        // Background tasks wake the parent on terminal transition.
3490        assert_eq!(task.wake_policy, TaskWakePolicy::OnTerminal);
3491        assert_eq!(task.spec["mode"], "background");
3492        // Summary carries the child's last agent message.
3493        assert_eq!(task.summary.as_deref(), Some("Hi!"));
3494    }
3495
3496    #[tokio::test]
3497    async fn background_spawn_rejects_when_session_active_run_limit_reached() {
3498        let store = Arc::new(MockPlatformStore::new());
3499        *store.wait_for_idle_status.lock().unwrap() = "paused".to_string();
3500        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3501        let context = spawn_context(&store, Some(registry));
3502
3503        for index in 0..crate::tools::MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION {
3504            let result = spawn(
3505                &context,
3506                json!({
3507                    "name": format!("Runner {index}"),
3508                    "instructions": "go",
3509                }),
3510            )
3511            .await;
3512            let ToolExecutionResult::Success(value) = result else {
3513                panic!("background spawn below the session limit should start: {result:?}");
3514            };
3515            assert_eq!(value["status"], "running");
3516        }
3517
3518        let result = spawn(
3519            &context,
3520            json!({
3521                "name": "Runner over limit",
3522                "instructions": "go",
3523            }),
3524        )
3525        .await;
3526        let ToolExecutionResult::ToolError(message) = result else {
3527            panic!("background spawn should reject once the session limit is reached: {result:?}");
3528        };
3529        assert!(message.contains("active background runs per session"));
3530    }
3531
3532    #[tokio::test]
3533    async fn background_settles_bare_idle_as_completed() {
3534        // Local/embedded hosts run the child's turn synchronously inside
3535        // send_message and report a bare `idle`; the watcher must settle it.
3536        let store = Arc::new(MockPlatformStore::new());
3537        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3538        let context = spawn_context(&store, Some(registry.clone()));
3539
3540        let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3541        let ToolExecutionResult::Success(value) = result else {
3542            panic!("expected success, got {result:?}");
3543        };
3544        let task_id = value["task_id"].as_str().expect("task_id").to_string();
3545        wait_for_task_state(
3546            &registry,
3547            context.session_id,
3548            &task_id,
3549            SessionTaskState::Succeeded,
3550        )
3551        .await;
3552    }
3553
3554    #[tokio::test]
3555    async fn background_failed_child_settles_task_failed() {
3556        let store = Arc::new(MockPlatformStore::new());
3557        *store.wait_for_idle_status.lock().unwrap() = "failed".to_string();
3558        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3559        let context = spawn_context(&store, Some(registry.clone()));
3560
3561        let result = spawn(&context, json!({"name": "Runner", "instructions": "go"})).await;
3562        let ToolExecutionResult::Success(value) = result else {
3563            panic!("expected success, got {result:?}");
3564        };
3565        let task_id = value["task_id"].as_str().expect("task_id").to_string();
3566        let task = wait_for_task_state(
3567            &registry,
3568            context.session_id,
3569            &task_id,
3570            SessionTaskState::Failed,
3571        )
3572        .await;
3573        assert_eq!(task.error.as_ref().map(|e| e.kind.as_str()), Some("failed"));
3574    }
3575
3576    #[tokio::test]
3577    async fn explicit_foreground_blocks_and_returns_result() {
3578        let store = Arc::new(MockPlatformStore::new());
3579        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3580        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3581        let context = spawn_context(&store, Some(registry.clone()));
3582
3583        let result = spawn(
3584            &context,
3585            json!({"name": "Runner", "instructions": "go", "mode": "foreground"}),
3586        )
3587        .await;
3588        let ToolExecutionResult::Success(value) = result else {
3589            panic!("expected success, got {result:?}");
3590        };
3591        assert_eq!(value["status"], "completed");
3592        assert_eq!(value["result"], "Hi!");
3593        // Foreground spawn settles the task before returning.
3594        let task_id = value["task_id"].as_str().expect("task_id");
3595        let task = registry
3596            .get(context.session_id, task_id)
3597            .await
3598            .unwrap()
3599            .unwrap();
3600        assert_eq!(task.state, SessionTaskState::Succeeded);
3601        assert_eq!(task.wake_policy, TaskWakePolicy::Silent);
3602    }
3603
3604    #[tokio::test]
3605    async fn reconcile_settles_finished_child() {
3606        let store = Arc::new(MockPlatformStore::new());
3607        *store.wait_for_idle_status.lock().unwrap() = "completed".to_string();
3608        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3609        let context = spawn_context(&store, Some(registry.clone()));
3610
3611        let child_id = crate::typed_id::SessionId::new();
3612        let task = registry
3613            .create(CreateSessionTask {
3614                session_id: context.session_id,
3615                id: None,
3616                kind: TASK_KIND_SUBAGENT.to_string(),
3617                display_name: "Runner".to_string(),
3618                spec: json!({"mode": "background"}),
3619                state: SessionTaskState::Running,
3620                links: TaskLinks {
3621                    child_session_id: Some(child_id),
3622                    ..Default::default()
3623                },
3624                wake_policy: TaskWakePolicy::OnTerminal,
3625            })
3626            .await
3627            .unwrap();
3628
3629        SubagentTaskExecutor
3630            .reconcile(&task, &context)
3631            .await
3632            .expect("reconcile succeeds");
3633
3634        let task = registry
3635            .get(context.session_id, &task.id)
3636            .await
3637            .unwrap()
3638            .unwrap();
3639        assert_eq!(task.state, SessionTaskState::Succeeded);
3640        assert_eq!(task.summary.as_deref(), Some("Hi!"));
3641    }
3642
3643    #[tokio::test]
3644    async fn reconcile_is_noop_while_child_still_working() {
3645        let store = Arc::new(MockPlatformStore::new());
3646        *store.wait_for_idle_status.lock().unwrap() = "timeout (last status: Active)".to_string();
3647        let registry = Arc::new(InMemorySessionTaskRegistry::default());
3648        let context = spawn_context(&store, Some(registry.clone()));
3649
3650        let task = registry
3651            .create(CreateSessionTask {
3652                session_id: context.session_id,
3653                id: None,
3654                kind: TASK_KIND_SUBAGENT.to_string(),
3655                display_name: "Runner".to_string(),
3656                spec: json!({"mode": "background"}),
3657                state: SessionTaskState::Running,
3658                links: TaskLinks {
3659                    child_session_id: Some(crate::typed_id::SessionId::new()),
3660                    ..Default::default()
3661                },
3662                wake_policy: TaskWakePolicy::OnTerminal,
3663            })
3664            .await
3665            .unwrap();
3666
3667        SubagentTaskExecutor
3668            .reconcile(&task, &context)
3669            .await
3670            .expect("reconcile succeeds");
3671
3672        let task = registry
3673            .get(context.session_id, &task.id)
3674            .await
3675            .unwrap()
3676            .unwrap();
3677        assert_eq!(task.state, SessionTaskState::Running);
3678    }
3679}