Skip to main content

bamboo_engine/session_app/child_session/
actions.rs

1//! Application-layer action functions for child session management.
2
3use bamboo_domain::Session;
4use chrono::Utc;
5use serde_json::json;
6
7use super::helpers::{
8    compute_status_guidance, format_child_assignment, map_child_entry, metadata_text,
9    normalize_non_empty_optional, normalize_required_text, render_forked_parent_context,
10    replace_or_append_last_user_message, truncate_after_index, truncate_after_last_user,
11};
12use super::DELEGATION_NOTE;
13use super::{
14    ChildSessionEntry, ChildSessionError, ChildSessionPort, CreateChildInput, CreateChildResult,
15};
16
17pub async fn create_child_action(
18    port: &dyn ChildSessionPort,
19    input: CreateChildInput,
20) -> Result<CreateChildResult, ChildSessionError> {
21    use crate::runner::refresh_prompt_snapshot;
22    use bamboo_agent_core::Message;
23
24    let inherited_project_id =
25        match crate::project_context::ProjectContextResolver::session_project_identity(
26            &input.parent_session,
27        ) {
28            crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
29                Some(project_id)
30            }
31            crate::project_context::SessionProjectIdentity::Unassigned => None,
32            crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
33                return Err(ChildSessionError::InvalidArguments(format!(
34                    "parent session carries an invalid Project identity '{raw}': {message}"
35                )));
36            }
37        };
38    let final_workspace = port
39        .validate_child_workspace(inherited_project_id.as_ref(), &input.workspace)
40        .await?;
41
42    // Use `new_child_of` so the child inherits the parent's tree root and a
43    // depth of parent+1. For a root parent this is identical to the old
44    // flat-tree behavior; for a child parent it enables nesting while keeping
45    // `root_session_id` constant across the whole tree (completion/SSE keying).
46    let mut child = Session::new_child_of(
47        input.child_id.clone(),
48        &input.parent_session,
49        input
50            .model_ref_override
51            .as_ref()
52            .map(|model_ref| model_ref.model.clone())
53            .or_else(|| input.model_override.clone())
54            .unwrap_or_else(|| input.parent_session.model.clone()),
55        input.title.clone(),
56    );
57    // Project is stable tree identity: every child/resident/guardian inherits
58    // it even when the requested workspace differs from its parent.
59    if let Some(project_id) = inherited_project_id {
60        child.set_project_id_meta(project_id.to_string());
61    }
62
63    if let Some(model_ref) = input.model_ref_override.clone() {
64        child.model_ref = Some(model_ref.clone());
65        child
66            .metadata
67            .insert("provider_name".to_string(), model_ref.provider);
68    } else if let Some(parent_model_ref) = input.parent_session.model_ref.clone() {
69        child.model_ref = Some(parent_model_ref.clone());
70        child.set_provider_name(parent_model_ref.provider);
71    } else if let Some(parent_provider) = input.parent_session.provider_name() {
72        child.set_provider_name(parent_provider);
73    }
74
75    // Apply explicit reasoning_effort override if the LLM passed one;
76    // otherwise leave at `None` (provider default). Per CreateChildInput
77    // contract, children do NOT inherit the parent's reasoning_effort.
78    if let Some(effort) = input.reasoning_effort {
79        child.reasoning_effort = Some(effort);
80    }
81
82    // Children inherit the parent's "bypass permissions" mode: a bypassed
83    // parent shouldn't be re-gated the moment it delegates work to a sub-agent.
84    // Seed the child's runtime state so the flag is live from its first run
85    // (startup carries it forward thereafter) and mirrored into the index.
86    if input
87        .parent_session
88        .agent_runtime_state
89        .as_ref()
90        .is_some_and(|state| state.bypass_permissions)
91    {
92        child
93            .agent_runtime_state
94            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
95            .bypass_permissions = true;
96    }
97
98    // #73: children inherit "no interactive human approver" too — if the run has
99    // no human to answer approvals (headless / scheduled / deployed), neither do
100    // its sub-agents, so their gated actions must be model-reviewed locally
101    // rather than escalated to a human who will never answer (300s fail-deny).
102    if input
103        .parent_session
104        .agent_runtime_state
105        .as_ref()
106        .is_some_and(|state| state.no_human_approver)
107    {
108        child
109            .agent_runtime_state
110            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
111            .no_human_approver = true;
112    }
113
114    // `set_workspace` returns the FINAL stored path, which may differ from
115    // the requested one when workspace-root confinement (#217) relocated it —
116    // store that back onto the domain field so `child.workspace` never
117    // diverges from where tools actually run.
118    let stored_workspace = bamboo_agent_core::workspace_state::set_workspace(
119        &child.id,
120        std::path::PathBuf::from(final_workspace),
121    );
122    child.workspace = Some(stored_workspace.to_string_lossy().to_string());
123
124    child
125        .metadata
126        .insert("spawned_by".to_string(), "SubAgent".to_string());
127    child.set_subagent_type(input.subagent_type.clone());
128    child
129        .metadata
130        .insert("responsibility".to_string(), input.responsibility.clone());
131    child.metadata.insert(
132        "assignment_prompt".to_string(),
133        input.assignment_prompt.clone(),
134    );
135    // Resident-agent tagging (plain metadata, like `responsibility` above). Only
136    // a resident carries these; their presence is how a later create reuses this
137    // session instead of minting a new one. Mirrored into the session index so
138    // the lookup + the frontend can read them without loading session.json.
139    if input.lifecycle.as_deref() == Some("resident") {
140        child
141            .metadata
142            .insert("lifecycle".to_string(), "resident".to_string());
143        if let Some(name) = input.resident_name.clone().filter(|n| !n.trim().is_empty()) {
144            child.metadata.insert("resident_name".to_string(), name);
145        }
146        child.metadata.insert(
147            "resident_context".to_string(),
148            input
149                .resident_context
150                .clone()
151                .filter(|c| matches!(c.as_str(), "reset" | "accumulate"))
152                .unwrap_or_else(|| "reset".to_string()),
153        );
154    }
155    child.set_last_run_status("pending");
156    child.clear_last_run_error();
157
158    // Apply runtime metadata (e.g. external agent routing).
159    for (key, value) in input.runtime_metadata {
160        child.metadata.insert(key, value);
161    }
162
163    // Sub-agents are first-class agents: assemble the SAME base system prompt a
164    // top-level (root) session uses, then append a short delegation note. The
165    // runtime context enhancement (workspace / instructions / tool guide /
166    // memory / task list) is applied uniformly by the runner to whatever base
167    // prompt the session carries — there is no root-only gate — so swapping the
168    // base prompt is all that's needed to make a child behave like a full agent.
169    let base_prompt = {
170        let global = crate::prompt_defaults::read_global_default_system_prompt_template();
171        if global.trim().is_empty() {
172            crate::context::DEFAULT_BASE_PROMPT.to_string()
173        } else {
174            global
175        }
176    };
177    let system_prompt = format!("{base_prompt}\n\n{DELEGATION_NOTE}");
178
179    child
180        .metadata
181        .insert("base_system_prompt".to_string(), system_prompt.clone());
182
183    child.add_message(Message::system(&system_prompt));
184
185    // Child sessions get more aggressive compression: trigger at 70% instead
186    // of the default 85%, target 35% instead of 40%. This prevents long child
187    // tasks from exhausting the context window before the parent can intervene.
188    if let Some(parent_budget) = input.parent_session.effective_token_budget() {
189        let mut child_budget = parent_budget.clone();
190        child_budget.compression_trigger_percent = 70;
191        child_budget.compression_target_percent = 35;
192        child.token_budget = Some(child_budget);
193    }
194
195    refresh_prompt_snapshot(&mut child);
196    let assignment = format_child_assignment(
197        &input.title,
198        &input.responsibility,
199        &input.subagent_type,
200        &input.assignment_prompt,
201    );
202    // Phase 3: optionally fork a slice of the parent's recent context into the
203    // child's task brief (model-controllable via the SubAgent tool's
204    // `fork_last_messages`). `None`/0 keeps the child on a clean fresh context.
205    let assignment = match input
206        .context_fork
207        .and_then(|n| render_forked_parent_context(&input.parent_session, n))
208    {
209        Some(forked) => format!("{forked}\n\n{assignment}"),
210        None => assignment,
211    };
212    child.add_message(Message::user(assignment));
213
214    if let Some(parent_task_list) = input.parent_session.task_list.clone() {
215        child.set_task_list(parent_task_list);
216    }
217
218    // Persist any per-child tool denylist so the spawn path (enqueue_child_run
219    // → SpawnJob.disabled_tools) can trim the child's toolset (e.g. a read-only
220    // Guardian reviewer). Most children carry none and keep the full toolset.
221    if let Some(ref disabled) = input.disabled_tools {
222        if !disabled.is_empty() {
223            child.metadata.insert(
224                "disabled_tools".to_string(),
225                serde_json::to_string(disabled).unwrap_or_default(),
226            );
227        }
228    }
229
230    let model = child.model.clone();
231    port.save_child_session(&mut child).await?;
232    if input.auto_run {
233        port.enqueue_child_run(&input.parent_session, &child)
234            .await?;
235    }
236
237    Ok(CreateChildResult {
238        child_session_id: child.id,
239        model,
240    })
241}
242
243pub async fn list_children_action(
244    port: &dyn ChildSessionPort,
245    parent_id: &str,
246) -> serde_json::Value {
247    let children = port.list_children(parent_id).await;
248    json!({
249        "parent_session_id": parent_id,
250        "children": children.iter().map(map_child_entry).collect::<Vec<_>>(),
251        "count": children.len(),
252    })
253}
254
255/// A node in the materialized parent→child session graph (Phase 6: persistent
256/// multi-level nesting graph). `children` are the transitive descendants.
257#[derive(Debug, Clone, PartialEq, serde::Serialize)]
258pub struct SessionTreeNode {
259    pub session_id: String,
260    pub title: String,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub last_run_status: Option<String>,
263    pub depth: u32,
264    pub children: Vec<SessionTreeNode>,
265}
266
267/// Assemble the transitive parent→child tree rooted at `root_id` from a
268/// pre-fetched adjacency map (pure — unit-testable without a port). Bounded by
269/// `max_depth`; a first-visit guard breaks cycles (a re-encountered session
270/// becomes a leaf rather than recursing forever).
271pub fn assemble_session_tree(
272    root_id: &str,
273    root_title: &str,
274    adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
275    max_depth: u32,
276) -> SessionTreeNode {
277    fn build(
278        id: &str,
279        title: &str,
280        status: Option<String>,
281        depth: u32,
282        max_depth: u32,
283        adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
284        visited: &mut std::collections::HashSet<String>,
285    ) -> SessionTreeNode {
286        let first_visit = visited.insert(id.to_string());
287        let mut children = Vec::new();
288        if first_visit && depth < max_depth {
289            if let Some(kids) = adjacency.get(id) {
290                for kid in kids {
291                    children.push(build(
292                        &kid.child_session_id,
293                        &kid.title,
294                        kid.last_run_status.clone(),
295                        depth + 1,
296                        max_depth,
297                        adjacency,
298                        visited,
299                    ));
300                }
301            }
302        }
303        SessionTreeNode {
304            session_id: id.to_string(),
305            title: title.to_string(),
306            last_run_status: status,
307            depth,
308            children,
309        }
310    }
311    let mut visited = std::collections::HashSet::new();
312    build(
313        root_id,
314        root_title,
315        None,
316        0,
317        max_depth,
318        adjacency,
319        &mut visited,
320    )
321}
322
323/// Materialize the full transitive parent→child session graph rooted at
324/// `root_id` from the persisted session index (Phase 6). BFS-fetches each
325/// level's children via [`ChildSessionPort::list_children`] (a first-visit guard
326/// and a hard node cap protect against cycles / runaway trees), then assembles the
327/// tree. The graph is derived from durable index state, so it survives restarts.
328pub async fn build_session_tree_action(
329    port: &dyn ChildSessionPort,
330    root_id: &str,
331    max_depth: u32,
332) -> SessionTreeNode {
333    use std::collections::{HashMap, HashSet, VecDeque};
334    const NODE_CAP: usize = 5000;
335
336    let root_title = port
337        .load_root_session(root_id)
338        .await
339        .map(|s| s.title)
340        .unwrap_or_default();
341
342    let mut adjacency: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
343    let mut visited: HashSet<String> = HashSet::new();
344    let mut queue: VecDeque<(String, u32)> = VecDeque::new();
345    queue.push_back((root_id.to_string(), 0));
346
347    while let Some((id, depth)) = queue.pop_front() {
348        if depth >= max_depth || adjacency.len() >= NODE_CAP || !visited.insert(id.clone()) {
349            continue;
350        }
351        let kids = port.list_children(&id).await;
352        for kid in &kids {
353            queue.push_back((kid.child_session_id.clone(), depth + 1));
354        }
355        adjacency.insert(id, kids);
356    }
357
358    assemble_session_tree(root_id, &root_title, &adjacency, max_depth)
359}
360
361pub async fn get_child_action(
362    port: &dyn ChildSessionPort,
363    parent_id: &str,
364    child_session_id: String,
365) -> Result<serde_json::Value, ChildSessionError> {
366    let child = port
367        .load_child_for_parent(parent_id, &child_session_id)
368        .await?;
369
370    let status = metadata_text(&child, "last_run_status");
371    let runner_info = port.get_child_runner_info(&child.id).await;
372
373    Ok(json!({
374        "child_session_id": child.id,
375        "title": child.title,
376        "model": child.model,
377        "pinned": child.pinned,
378        "message_count": child.messages.len(),
379        "is_running": port.is_child_running(&child.id).await,
380        "last_run_status": status,
381        "last_run_error": metadata_text(&child, "last_run_error"),
382        "responsibility": metadata_text(&child, "responsibility"),
383        "subagent_type": metadata_text(&child, "subagent_type"),
384        "prompt": metadata_text(&child, "assignment_prompt"),
385        "latest_user_message": child
386            .messages
387            .iter()
388            .rposition(|message| matches!(message.role, bamboo_agent_core::Role::User))
389            .and_then(|idx| child.messages.get(idx))
390            .map(|message| message.content.clone()),
391        "runtime_kind": metadata_text(&child, "runtime.kind"),
392        "external_protocol": metadata_text(&child, "external.protocol"),
393        "external_agent_id": metadata_text(&child, "external.agent_id"),
394        "a2a_context_id": metadata_text(&child, "a2a.context_id"),
395        "a2a_latest_task_id": metadata_text(&child, "a2a.latest_task_id"),
396        "a2a_last_state": metadata_text(&child, "a2a.last_state"),
397        "runner_started_at": runner_info.as_ref().and_then(|r| r.started_at.map(|t| t.to_rfc3339())),
398        "runner_completed_at": runner_info.as_ref().and_then(|r| r.completed_at.map(|t| t.to_rfc3339())),
399        "last_tool_name": runner_info.as_ref().and_then(|r| r.last_tool_name.clone()),
400        "last_tool_phase": runner_info.as_ref().and_then(|r| r.last_tool_phase.clone()),
401        "last_event_at": runner_info.as_ref().and_then(|r| r.last_event_at.map(|t| t.to_rfc3339())),
402        "round_count": runner_info.as_ref().map(|r| r.round_count).unwrap_or(0),
403        "has_pending_injected_messages": child.has_pending_injected_messages(),
404        "guidance": compute_status_guidance(status.as_deref(), runner_info.as_ref(), child.has_pending_injected_messages()),
405    }))
406}
407
408#[allow(clippy::too_many_arguments)]
409pub async fn update_child_action(
410    port: &dyn ChildSessionPort,
411    parent_id: &str,
412    child_session_id: String,
413    title: Option<String>,
414    responsibility: Option<String>,
415    prompt: Option<String>,
416    subagent_type: Option<String>,
417    reset_after_update: Option<bool>,
418    reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
419) -> Result<serde_json::Value, ChildSessionError> {
420    let mut child = port
421        .load_child_for_parent(parent_id, &child_session_id)
422        .await?;
423
424    let title = normalize_non_empty_optional(title, "title")?;
425    let responsibility = normalize_non_empty_optional(responsibility, "responsibility")?;
426    let prompt = normalize_non_empty_optional(prompt, "prompt")?;
427    let subagent_type = normalize_non_empty_optional(subagent_type, "subagent_type")?;
428
429    let should_refresh_assignment =
430        responsibility.is_some() || prompt.is_some() || subagent_type.is_some();
431
432    if title.is_none() && !should_refresh_assignment && reasoning_effort.is_none() {
433        return Err(ChildSessionError::InvalidArguments(
434            "update requires at least one field: title/responsibility/prompt/subagent_type/reasoning_effort"
435                .to_string(),
436        ));
437    }
438
439    if let Some(effort) = reasoning_effort {
440        child.reasoning_effort = Some(effort);
441    }
442
443    if let Some(title) = title {
444        child.title = title;
445    }
446
447    let mut messages_removed = 0usize;
448
449    if should_refresh_assignment {
450        let effective_responsibility = normalize_required_text(
451            responsibility.or_else(|| metadata_text(&child, "responsibility")),
452            "responsibility",
453        )?;
454        let effective_subagent_type = normalize_required_text(
455            subagent_type.or_else(|| metadata_text(&child, "subagent_type")),
456            "subagent_type",
457        )?;
458        let effective_prompt = normalize_required_text(
459            prompt.or_else(|| metadata_text(&child, "assignment_prompt")),
460            "prompt",
461        )?;
462
463        child.metadata.insert(
464            "responsibility".to_string(),
465            effective_responsibility.clone(),
466        );
467        child
468            .metadata
469            .insert("subagent_type".to_string(), effective_subagent_type.clone());
470        child
471            .metadata
472            .insert("assignment_prompt".to_string(), effective_prompt.clone());
473        child.set_last_run_status("pending");
474        child.clear_last_run_error();
475
476        let assignment = format_child_assignment(
477            &child.title,
478            &effective_responsibility,
479            &effective_subagent_type,
480            &effective_prompt,
481        );
482        let user_index = replace_or_append_last_user_message(&mut child, assignment);
483
484        if reset_after_update.unwrap_or(true) {
485            messages_removed = truncate_after_index(&mut child, user_index);
486        }
487    }
488
489    child.updated_at = Utc::now();
490    port.save_child_session(&mut child).await?;
491
492    Ok(json!({
493        "child_session_id": child.id,
494        "title": child.title,
495        "messages_removed": messages_removed,
496        "last_run_status": metadata_text(&child, "last_run_status"),
497        "note": "Child session updated in place. Use action=run to execute the same child session.",
498    }))
499}
500
501pub async fn run_child_action(
502    port: &dyn ChildSessionPort,
503    parent: &Session,
504    child_session_id: String,
505    reset_to_last_user: Option<bool>,
506) -> Result<serde_json::Value, ChildSessionError> {
507    let mut child = port
508        .load_child_for_parent(&parent.id, &child_session_id)
509        .await?;
510
511    if port.is_child_running(&child.id).await {
512        return Ok(json!({
513            "child_session_id": child.id,
514            "status": "already_running",
515            "note": "Child session is already running.",
516        }));
517    }
518
519    let mut messages_removed = 0usize;
520    if reset_to_last_user.unwrap_or(true) {
521        messages_removed = truncate_after_last_user(&mut child)?;
522    }
523
524    child.set_last_run_status("pending");
525    child.clear_last_run_error();
526    child.updated_at = Utc::now();
527    port.save_child_session(&mut child).await?;
528
529    port.enqueue_child_run(parent, &child).await?;
530
531    Ok(json!({
532        "child_session_id": child.id,
533        "status": "queued",
534        "messages_removed": messages_removed,
535        "note": "Queued existing child session for retry in place.",
536    }))
537}
538
539pub async fn send_message_to_child_action(
540    port: &dyn ChildSessionPort,
541    parent: &Session,
542    child_session_id: String,
543    message: String,
544    auto_run: Option<bool>,
545    interrupt_running: Option<bool>,
546    idempotency_key: Option<&str>,
547) -> Result<serde_json::Value, ChildSessionError> {
548    let mut child = port
549        .load_child_for_parent(&parent.id, &child_session_id)
550        .await?;
551
552    let mut is_running = port.is_child_running(&child.id).await;
553    let should_interrupt = interrupt_running.unwrap_or(false);
554
555    if is_running && should_interrupt {
556        port.cancel_child_run_and_wait(&child.id).await?;
557        child = port
558            .load_child_for_parent(&parent.id, &child_session_id)
559            .await?;
560        // Cancellation changes the routing decision. In particular,
561        // auto_run=false means "leave a draft" after interrupting the old run,
562        // not "wake a successor" based on the pre-cancel snapshot.
563        is_running = port.is_child_running(&child.id).await;
564    }
565
566    let message = normalize_required_text(Some(message), "message")?;
567
568    let should_auto_run = auto_run.unwrap_or(true);
569    // `interrupt_running` is meaningful only for an actually-running child.
570    // On an idle child, `auto_run=false` must retain the historical draft-only
571    // behavior regardless of that otherwise-inert flag.
572    if should_route_child_message_through_inbox(is_running, should_auto_run) {
573        let delivery = port
574            .send_session_message(&parent.id, &child.id, &message, idempotency_key)
575            .await?;
576        let (delivery, activation, status, note, activation_error) = match delivery {
577            super::ChildSessionMessageDelivery::Activated(receipt) => {
578                let (status, note) = match receipt.activation {
579                    bamboo_domain::SessionActivationDisposition::ActiveNotified => (
580                        "message_delivered_live",
581                        "Message durably delivered and the owning active loop was notified.",
582                    ),
583                    bamboo_domain::SessionActivationDisposition::ActivationReserved => (
584                        "queued",
585                        "Message durably delivered and exactly one session activation was reserved.",
586                    ),
587                    bamboo_domain::SessionActivationDisposition::ActivationCoalesced => (
588                        "message_queued",
589                        "Message durably delivered; activation coalesced with existing session work.",
590                    ),
591                };
592                (
593                    receipt.delivery,
594                    Some(receipt.activation),
595                    status,
596                    note,
597                    None,
598                )
599            }
600            super::ChildSessionMessageDelivery::ActivationPending { delivery, error } => (
601                delivery,
602                None,
603                "activation_pending",
604                "Message is durable; activation is pending and will be retried from the inbox watermark.",
605                Some(error),
606            ),
607        };
608        return Ok(json!({
609            "child_session_id": child.id,
610            "status": status,
611            "auto_run": !matches!(
612                activation,
613                Some(bamboo_domain::SessionActivationDisposition::ActiveNotified)
614            ),
615            "message": message,
616            "message_id": delivery.id.to_string(),
617            "inbox_generation": delivery.generation,
618            "activation_error": activation_error,
619            "message_count": child.messages.len(),
620            "note": note,
621        }));
622    }
623
624    // Explicit `auto_run=false` on an idle child retains its historical
625    // draft-only behavior. All runnable/live delivery paths above converge on
626    // SessionMessenger and never rewrite a snapshot to enqueue.
627    child.add_message(bamboo_agent_core::Message::user(message.clone()));
628    child.set_last_run_status("pending");
629    child.clear_last_run_error();
630    port.save_child_session(&mut child).await?;
631
632    Ok(json!({
633        "child_session_id": child.id,
634        "status": "pending",
635        "auto_run": false,
636        "message": message,
637        "message_count": child.messages.len(),
638        "note": "Follow-up message appended. Use action=run to execute the child session.",
639    }))
640}
641
642fn should_route_child_message_through_inbox(is_running: bool, should_auto_run: bool) -> bool {
643    is_running || should_auto_run
644}
645
646pub async fn cancel_child_action(
647    port: &dyn ChildSessionPort,
648    parent_id: &str,
649    child_session_id: String,
650) -> Result<serde_json::Value, ChildSessionError> {
651    // Validate ownership before doing anything.
652    let _ = port
653        .load_child_for_parent(parent_id, &child_session_id)
654        .await?;
655    port.cancel_child_run_and_wait(&child_session_id).await?;
656
657    // RELOAD after the wait — writing the pre-wait snapshot would clobber
658    // whatever the finishing run persisted (its terminal status AND any
659    // messages it appended). And if the child completed naturally while the
660    // cancel was in flight, keep that truth instead of mislabeling it.
661    let mut child = port
662        .load_child_for_parent(parent_id, &child_session_id)
663        .await?;
664    let latest_status = child.last_run_status().unwrap_or_default();
665    if matches!(latest_status.as_str(), "completed" | "error") {
666        return Ok(json!({
667            "child_session_id": child_session_id,
668            "status": latest_status,
669            "note": "Child reached a natural terminal state while the cancel was in flight; its real outcome was kept.",
670        }));
671    }
672    child.set_last_run_status("cancelled");
673    child.set_last_run_error("Cancelled by parent");
674    port.save_child_session(&mut child).await?;
675    Ok(json!({
676        "child_session_id": child_session_id,
677        "status": "cancelled",
678    }))
679}
680
681pub async fn delete_child_action(
682    port: &dyn ChildSessionPort,
683    parent_id: &str,
684    child_session_id: String,
685) -> Result<serde_json::Value, ChildSessionError> {
686    // Load child first to get its ID (port.delete_child_session handles cancellation + cleanup)
687    let child = port
688        .load_child_for_parent(parent_id, &child_session_id)
689        .await?;
690    let result = port.delete_child_session(parent_id, &child.id).await?;
691
692    if !result.deleted {
693        return Err(ChildSessionError::Execution(format!(
694            "child session was not deleted: {}",
695            child.id
696        )));
697    }
698
699    Ok(json!({
700        "child_session_id": child.id,
701        "deleted": true,
702        "cancelled_running_child": result.cancelled_running_child,
703    }))
704}
705
706#[cfg(test)]
707mod tree_tests {
708    use super::super::ChildSessionEntry;
709    use super::assemble_session_tree;
710    use std::collections::HashMap;
711
712    fn entry(id: &str, title: &str) -> ChildSessionEntry {
713        ChildSessionEntry {
714            child_session_id: id.to_string(),
715            title: title.to_string(),
716            pinned: false,
717            message_count: 0,
718            updated_at: String::new(),
719            last_run_status: Some("completed".to_string()),
720            last_run_error: None,
721        }
722    }
723
724    #[test]
725    fn assembles_multi_level_tree() {
726        let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
727        adj.insert(
728            "root".into(),
729            vec![entry("c1", "child 1"), entry("c2", "child 2")],
730        );
731        adj.insert("c1".into(), vec![entry("g1", "grandchild")]);
732
733        let tree = assemble_session_tree("root", "Root", &adj, 8);
734        assert_eq!(tree.session_id, "root");
735        assert_eq!(tree.depth, 0);
736        assert_eq!(tree.children.len(), 2);
737        let c1 = tree.children.iter().find(|n| n.session_id == "c1").unwrap();
738        assert_eq!(c1.depth, 1);
739        assert_eq!(c1.children.len(), 1);
740        assert_eq!(c1.children[0].session_id, "g1");
741        assert_eq!(c1.children[0].depth, 2);
742        let c2 = tree.children.iter().find(|n| n.session_id == "c2").unwrap();
743        assert!(c2.children.is_empty());
744    }
745
746    #[test]
747    fn depth_cap_stops_descent() {
748        let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
749        adj.insert("root".into(), vec![entry("c1", "c1")]);
750        adj.insert("c1".into(), vec![entry("g1", "g1")]);
751        let tree = assemble_session_tree("root", "Root", &adj, 1);
752        assert_eq!(tree.children.len(), 1);
753        assert!(
754            tree.children[0].children.is_empty(),
755            "depth cap stops expansion at depth 1"
756        );
757    }
758
759    #[test]
760    fn cycle_is_broken_by_first_visit_guard() {
761        let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
762        adj.insert("a".into(), vec![entry("b", "b")]);
763        adj.insert("b".into(), vec![entry("a", "a")]); // cycle a → b → a
764        let tree = assemble_session_tree("a", "A", &adj, 100);
765        assert_eq!(tree.children.len(), 1);
766        let b = &tree.children[0];
767        assert_eq!(b.session_id, "b");
768        assert_eq!(b.children.len(), 1);
769        let a2 = &b.children[0];
770        assert_eq!(a2.session_id, "a");
771        assert!(a2.children.is_empty(), "cycle must terminate as a leaf");
772    }
773}
774
775#[cfg(test)]
776mod send_message_tests {
777    use super::should_route_child_message_through_inbox;
778
779    #[test]
780    fn running_auto_run_interrupt_matrix_preserves_idle_draft_contract() {
781        for (is_running, auto_run, interrupt_running, expected_inbox) in [
782            (false, false, false, false),
783            (false, false, true, false),
784            (false, true, false, true),
785            (false, true, true, true),
786            (true, false, false, true),
787            (true, false, true, false),
788            (true, true, false, true),
789            (true, true, true, true),
790        ] {
791            let effective_running = is_running && !interrupt_running;
792            assert_eq!(
793                should_route_child_message_through_inbox(effective_running, auto_run),
794                expected_inbox,
795                "is_running={is_running} auto_run={auto_run} interrupt_running={interrupt_running}"
796            );
797        }
798    }
799}