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    QueuedInjectedMessage,
16};
17
18pub async fn create_child_action(
19    port: &dyn ChildSessionPort,
20    input: CreateChildInput,
21) -> Result<CreateChildResult, ChildSessionError> {
22    use crate::runner::refresh_prompt_snapshot;
23    use bamboo_agent_core::Message;
24
25    let inherited_project_id =
26        match crate::project_context::ProjectContextResolver::session_project_identity(
27            &input.parent_session,
28        ) {
29            crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
30                Some(project_id)
31            }
32            crate::project_context::SessionProjectIdentity::Unassigned => None,
33            crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
34                return Err(ChildSessionError::InvalidArguments(format!(
35                    "parent session carries an invalid Project identity '{raw}': {message}"
36                )));
37            }
38        };
39    let final_workspace = port
40        .validate_child_workspace(inherited_project_id.as_ref(), &input.workspace)
41        .await?;
42
43    // Use `new_child_of` so the child inherits the parent's tree root and a
44    // depth of parent+1. For a root parent this is identical to the old
45    // flat-tree behavior; for a child parent it enables nesting while keeping
46    // `root_session_id` constant across the whole tree (completion/SSE keying).
47    let mut child = Session::new_child_of(
48        input.child_id.clone(),
49        &input.parent_session,
50        input
51            .model_ref_override
52            .as_ref()
53            .map(|model_ref| model_ref.model.clone())
54            .or_else(|| input.model_override.clone())
55            .unwrap_or_else(|| input.parent_session.model.clone()),
56        input.title.clone(),
57    );
58    // Project is stable tree identity: every child/resident/guardian inherits
59    // it even when the requested workspace differs from its parent.
60    if let Some(project_id) = inherited_project_id {
61        child.set_project_id_meta(project_id.to_string());
62    }
63
64    if let Some(model_ref) = input.model_ref_override.clone() {
65        child.model_ref = Some(model_ref.clone());
66        child
67            .metadata
68            .insert("provider_name".to_string(), model_ref.provider);
69    } else if let Some(parent_model_ref) = input.parent_session.model_ref.clone() {
70        child.model_ref = Some(parent_model_ref.clone());
71        child.set_provider_name(parent_model_ref.provider);
72    } else if let Some(parent_provider) = input.parent_session.provider_name() {
73        child.set_provider_name(parent_provider);
74    }
75
76    // Apply explicit reasoning_effort override if the LLM passed one;
77    // otherwise leave at `None` (provider default). Per CreateChildInput
78    // contract, children do NOT inherit the parent's reasoning_effort.
79    if let Some(effort) = input.reasoning_effort {
80        child.reasoning_effort = Some(effort);
81    }
82
83    // Children inherit the parent's "bypass permissions" mode: a bypassed
84    // parent shouldn't be re-gated the moment it delegates work to a sub-agent.
85    // Seed the child's runtime state so the flag is live from its first run
86    // (startup carries it forward thereafter) and mirrored into the index.
87    if input
88        .parent_session
89        .agent_runtime_state
90        .as_ref()
91        .is_some_and(|state| state.bypass_permissions)
92    {
93        child
94            .agent_runtime_state
95            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
96            .bypass_permissions = true;
97    }
98
99    // #73: children inherit "no interactive human approver" too — if the run has
100    // no human to answer approvals (headless / scheduled / deployed), neither do
101    // its sub-agents, so their gated actions must be model-reviewed locally
102    // rather than escalated to a human who will never answer (300s fail-deny).
103    if input
104        .parent_session
105        .agent_runtime_state
106        .as_ref()
107        .is_some_and(|state| state.no_human_approver)
108    {
109        child
110            .agent_runtime_state
111            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
112            .no_human_approver = true;
113    }
114
115    // `set_workspace` returns the FINAL stored path, which may differ from
116    // the requested one when workspace-root confinement (#217) relocated it —
117    // store that back onto the domain field so `child.workspace` never
118    // diverges from where tools actually run.
119    let stored_workspace = bamboo_agent_core::workspace_state::set_workspace(
120        &child.id,
121        std::path::PathBuf::from(final_workspace),
122    );
123    child.workspace = Some(stored_workspace.to_string_lossy().to_string());
124
125    child
126        .metadata
127        .insert("spawned_by".to_string(), "SubAgent".to_string());
128    child.set_subagent_type(input.subagent_type.clone());
129    child
130        .metadata
131        .insert("responsibility".to_string(), input.responsibility.clone());
132    child.metadata.insert(
133        "assignment_prompt".to_string(),
134        input.assignment_prompt.clone(),
135    );
136    // Resident-agent tagging (plain metadata, like `responsibility` above). Only
137    // a resident carries these; their presence is how a later create reuses this
138    // session instead of minting a new one. Mirrored into the session index so
139    // the lookup + the frontend can read them without loading session.json.
140    if input.lifecycle.as_deref() == Some("resident") {
141        child
142            .metadata
143            .insert("lifecycle".to_string(), "resident".to_string());
144        if let Some(name) = input.resident_name.clone().filter(|n| !n.trim().is_empty()) {
145            child.metadata.insert("resident_name".to_string(), name);
146        }
147        child.metadata.insert(
148            "resident_context".to_string(),
149            input
150                .resident_context
151                .clone()
152                .filter(|c| matches!(c.as_str(), "reset" | "accumulate"))
153                .unwrap_or_else(|| "reset".to_string()),
154        );
155    }
156    child.set_last_run_status("pending");
157    child.clear_last_run_error();
158
159    // Apply runtime metadata (e.g. external agent routing).
160    for (key, value) in input.runtime_metadata {
161        child.metadata.insert(key, value);
162    }
163
164    // Sub-agents are first-class agents: assemble the SAME base system prompt a
165    // top-level (root) session uses, then append a short delegation note. The
166    // runtime context enhancement (workspace / instructions / tool guide /
167    // memory / task list) is applied uniformly by the runner to whatever base
168    // prompt the session carries — there is no root-only gate — so swapping the
169    // base prompt is all that's needed to make a child behave like a full agent.
170    let base_prompt = {
171        let global = crate::prompt_defaults::read_global_default_system_prompt_template();
172        if global.trim().is_empty() {
173            crate::context::DEFAULT_BASE_PROMPT.to_string()
174        } else {
175            global
176        }
177    };
178    let system_prompt = format!("{base_prompt}\n\n{DELEGATION_NOTE}");
179
180    child
181        .metadata
182        .insert("base_system_prompt".to_string(), system_prompt.clone());
183
184    child.add_message(Message::system(&system_prompt));
185
186    // Child sessions get more aggressive compression: trigger at 70% instead
187    // of the default 85%, target 35% instead of 40%. This prevents long child
188    // tasks from exhausting the context window before the parent can intervene.
189    if let Some(parent_budget) = input.parent_session.effective_token_budget() {
190        let mut child_budget = parent_budget.clone();
191        child_budget.compression_trigger_percent = 70;
192        child_budget.compression_target_percent = 35;
193        child.token_budget = Some(child_budget);
194    }
195
196    refresh_prompt_snapshot(&mut child);
197    let assignment = format_child_assignment(
198        &input.title,
199        &input.responsibility,
200        &input.subagent_type,
201        &input.assignment_prompt,
202    );
203    // Phase 3: optionally fork a slice of the parent's recent context into the
204    // child's task brief (model-controllable via the SubAgent tool's
205    // `fork_last_messages`). `None`/0 keeps the child on a clean fresh context.
206    let assignment = match input
207        .context_fork
208        .and_then(|n| render_forked_parent_context(&input.parent_session, n))
209    {
210        Some(forked) => format!("{forked}\n\n{assignment}"),
211        None => assignment,
212    };
213    child.add_message(Message::user(assignment));
214
215    if let Some(parent_task_list) = input.parent_session.task_list.clone() {
216        child.set_task_list(parent_task_list);
217    }
218
219    // Persist any per-child tool denylist so the spawn path (enqueue_child_run
220    // → SpawnJob.disabled_tools) can trim the child's toolset (e.g. a read-only
221    // Guardian reviewer). Most children carry none and keep the full toolset.
222    if let Some(ref disabled) = input.disabled_tools {
223        if !disabled.is_empty() {
224            child.metadata.insert(
225                "disabled_tools".to_string(),
226                serde_json::to_string(disabled).unwrap_or_default(),
227            );
228        }
229    }
230
231    let model = child.model.clone();
232    port.save_child_session(&mut child).await?;
233    if input.auto_run {
234        port.enqueue_child_run(&input.parent_session, &child)
235            .await?;
236    }
237
238    Ok(CreateChildResult {
239        child_session_id: child.id,
240        model,
241    })
242}
243
244pub async fn list_children_action(
245    port: &dyn ChildSessionPort,
246    parent_id: &str,
247) -> serde_json::Value {
248    let children = port.list_children(parent_id).await;
249    json!({
250        "parent_session_id": parent_id,
251        "children": children.iter().map(map_child_entry).collect::<Vec<_>>(),
252        "count": children.len(),
253    })
254}
255
256/// A node in the materialized parent→child session graph (Phase 6: persistent
257/// multi-level nesting graph). `children` are the transitive descendants.
258#[derive(Debug, Clone, PartialEq, serde::Serialize)]
259pub struct SessionTreeNode {
260    pub session_id: String,
261    pub title: String,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub last_run_status: Option<String>,
264    pub depth: u32,
265    pub children: Vec<SessionTreeNode>,
266}
267
268/// Assemble the transitive parent→child tree rooted at `root_id` from a
269/// pre-fetched adjacency map (pure — unit-testable without a port). Bounded by
270/// `max_depth`; a first-visit guard breaks cycles (a re-encountered session
271/// becomes a leaf rather than recursing forever).
272pub fn assemble_session_tree(
273    root_id: &str,
274    root_title: &str,
275    adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
276    max_depth: u32,
277) -> SessionTreeNode {
278    fn build(
279        id: &str,
280        title: &str,
281        status: Option<String>,
282        depth: u32,
283        max_depth: u32,
284        adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
285        visited: &mut std::collections::HashSet<String>,
286    ) -> SessionTreeNode {
287        let first_visit = visited.insert(id.to_string());
288        let mut children = Vec::new();
289        if first_visit && depth < max_depth {
290            if let Some(kids) = adjacency.get(id) {
291                for kid in kids {
292                    children.push(build(
293                        &kid.child_session_id,
294                        &kid.title,
295                        kid.last_run_status.clone(),
296                        depth + 1,
297                        max_depth,
298                        adjacency,
299                        visited,
300                    ));
301                }
302            }
303        }
304        SessionTreeNode {
305            session_id: id.to_string(),
306            title: title.to_string(),
307            last_run_status: status,
308            depth,
309            children,
310        }
311    }
312    let mut visited = std::collections::HashSet::new();
313    build(
314        root_id,
315        root_title,
316        None,
317        0,
318        max_depth,
319        adjacency,
320        &mut visited,
321    )
322}
323
324/// Materialize the full transitive parent→child session graph rooted at
325/// `root_id` from the persisted session index (Phase 6). BFS-fetches each
326/// level's children via [`ChildSessionPort::list_children`] (a first-visit guard
327/// and a hard node cap protect against cycles / runaway trees), then assembles the
328/// tree. The graph is derived from durable index state, so it survives restarts.
329pub async fn build_session_tree_action(
330    port: &dyn ChildSessionPort,
331    root_id: &str,
332    max_depth: u32,
333) -> SessionTreeNode {
334    use std::collections::{HashMap, HashSet, VecDeque};
335    const NODE_CAP: usize = 5000;
336
337    let root_title = port
338        .load_root_session(root_id)
339        .await
340        .map(|s| s.title)
341        .unwrap_or_default();
342
343    let mut adjacency: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
344    let mut visited: HashSet<String> = HashSet::new();
345    let mut queue: VecDeque<(String, u32)> = VecDeque::new();
346    queue.push_back((root_id.to_string(), 0));
347
348    while let Some((id, depth)) = queue.pop_front() {
349        if depth >= max_depth || adjacency.len() >= NODE_CAP || !visited.insert(id.clone()) {
350            continue;
351        }
352        let kids = port.list_children(&id).await;
353        for kid in &kids {
354            queue.push_back((kid.child_session_id.clone(), depth + 1));
355        }
356        adjacency.insert(id, kids);
357    }
358
359    assemble_session_tree(root_id, &root_title, &adjacency, max_depth)
360}
361
362pub async fn get_child_action(
363    port: &dyn ChildSessionPort,
364    parent_id: &str,
365    child_session_id: String,
366) -> Result<serde_json::Value, ChildSessionError> {
367    let child = port
368        .load_child_for_parent(parent_id, &child_session_id)
369        .await?;
370
371    let status = metadata_text(&child, "last_run_status");
372    let runner_info = port.get_child_runner_info(&child.id).await;
373
374    Ok(json!({
375        "child_session_id": child.id,
376        "title": child.title,
377        "model": child.model,
378        "pinned": child.pinned,
379        "message_count": child.messages.len(),
380        "is_running": port.is_child_running(&child.id).await,
381        "last_run_status": status,
382        "last_run_error": metadata_text(&child, "last_run_error"),
383        "responsibility": metadata_text(&child, "responsibility"),
384        "subagent_type": metadata_text(&child, "subagent_type"),
385        "prompt": metadata_text(&child, "assignment_prompt"),
386        "latest_user_message": child
387            .messages
388            .iter()
389            .rposition(|message| matches!(message.role, bamboo_agent_core::Role::User))
390            .and_then(|idx| child.messages.get(idx))
391            .map(|message| message.content.clone()),
392        "runtime_kind": metadata_text(&child, "runtime.kind"),
393        "external_protocol": metadata_text(&child, "external.protocol"),
394        "external_agent_id": metadata_text(&child, "external.agent_id"),
395        "a2a_context_id": metadata_text(&child, "a2a.context_id"),
396        "a2a_latest_task_id": metadata_text(&child, "a2a.latest_task_id"),
397        "a2a_last_state": metadata_text(&child, "a2a.last_state"),
398        "runner_started_at": runner_info.as_ref().and_then(|r| r.started_at.map(|t| t.to_rfc3339())),
399        "runner_completed_at": runner_info.as_ref().and_then(|r| r.completed_at.map(|t| t.to_rfc3339())),
400        "last_tool_name": runner_info.as_ref().and_then(|r| r.last_tool_name.clone()),
401        "last_tool_phase": runner_info.as_ref().and_then(|r| r.last_tool_phase.clone()),
402        "last_event_at": runner_info.as_ref().and_then(|r| r.last_event_at.map(|t| t.to_rfc3339())),
403        "round_count": runner_info.as_ref().map(|r| r.round_count).unwrap_or(0),
404        "has_pending_injected_messages": child.has_pending_injected_messages(),
405        "guidance": compute_status_guidance(status.as_deref(), runner_info.as_ref(), child.has_pending_injected_messages()),
406    }))
407}
408
409#[allow(clippy::too_many_arguments)]
410pub async fn update_child_action(
411    port: &dyn ChildSessionPort,
412    parent_id: &str,
413    child_session_id: String,
414    title: Option<String>,
415    responsibility: Option<String>,
416    prompt: Option<String>,
417    subagent_type: Option<String>,
418    reset_after_update: Option<bool>,
419    reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
420) -> Result<serde_json::Value, ChildSessionError> {
421    let mut child = port
422        .load_child_for_parent(parent_id, &child_session_id)
423        .await?;
424
425    let title = normalize_non_empty_optional(title, "title")?;
426    let responsibility = normalize_non_empty_optional(responsibility, "responsibility")?;
427    let prompt = normalize_non_empty_optional(prompt, "prompt")?;
428    let subagent_type = normalize_non_empty_optional(subagent_type, "subagent_type")?;
429
430    let should_refresh_assignment =
431        responsibility.is_some() || prompt.is_some() || subagent_type.is_some();
432
433    if title.is_none() && !should_refresh_assignment && reasoning_effort.is_none() {
434        return Err(ChildSessionError::InvalidArguments(
435            "update requires at least one field: title/responsibility/prompt/subagent_type/reasoning_effort"
436                .to_string(),
437        ));
438    }
439
440    if let Some(effort) = reasoning_effort {
441        child.reasoning_effort = Some(effort);
442    }
443
444    if let Some(title) = title {
445        child.title = title;
446    }
447
448    let mut messages_removed = 0usize;
449
450    if should_refresh_assignment {
451        let effective_responsibility = normalize_required_text(
452            responsibility.or_else(|| metadata_text(&child, "responsibility")),
453            "responsibility",
454        )?;
455        let effective_subagent_type = normalize_required_text(
456            subagent_type.or_else(|| metadata_text(&child, "subagent_type")),
457            "subagent_type",
458        )?;
459        let effective_prompt = normalize_required_text(
460            prompt.or_else(|| metadata_text(&child, "assignment_prompt")),
461            "prompt",
462        )?;
463
464        child.metadata.insert(
465            "responsibility".to_string(),
466            effective_responsibility.clone(),
467        );
468        child
469            .metadata
470            .insert("subagent_type".to_string(), effective_subagent_type.clone());
471        child
472            .metadata
473            .insert("assignment_prompt".to_string(), effective_prompt.clone());
474        child.set_last_run_status("pending");
475        child.clear_last_run_error();
476
477        let assignment = format_child_assignment(
478            &child.title,
479            &effective_responsibility,
480            &effective_subagent_type,
481            &effective_prompt,
482        );
483        let user_index = replace_or_append_last_user_message(&mut child, assignment);
484
485        if reset_after_update.unwrap_or(true) {
486            messages_removed = truncate_after_index(&mut child, user_index);
487        }
488    }
489
490    child.updated_at = Utc::now();
491    port.save_child_session(&mut child).await?;
492
493    Ok(json!({
494        "child_session_id": child.id,
495        "title": child.title,
496        "messages_removed": messages_removed,
497        "last_run_status": metadata_text(&child, "last_run_status"),
498        "note": "Child session updated in place. Use action=run to execute the same child session.",
499    }))
500}
501
502pub async fn run_child_action(
503    port: &dyn ChildSessionPort,
504    parent: &Session,
505    child_session_id: String,
506    reset_to_last_user: Option<bool>,
507) -> Result<serde_json::Value, ChildSessionError> {
508    let mut child = port
509        .load_child_for_parent(&parent.id, &child_session_id)
510        .await?;
511
512    if port.is_child_running(&child.id).await {
513        return Ok(json!({
514            "child_session_id": child.id,
515            "status": "already_running",
516            "note": "Child session is already running.",
517        }));
518    }
519
520    let mut messages_removed = 0usize;
521    if reset_to_last_user.unwrap_or(true) {
522        messages_removed = truncate_after_last_user(&mut child)?;
523    }
524
525    child.set_last_run_status("pending");
526    child.clear_last_run_error();
527    child.updated_at = Utc::now();
528    port.save_child_session(&mut child).await?;
529
530    port.enqueue_child_run(parent, &child).await?;
531
532    Ok(json!({
533        "child_session_id": child.id,
534        "status": "queued",
535        "messages_removed": messages_removed,
536        "note": "Queued existing child session for retry in place.",
537    }))
538}
539
540pub async fn send_message_to_child_action(
541    port: &dyn ChildSessionPort,
542    parent: &Session,
543    child_session_id: String,
544    message: String,
545    auto_run: Option<bool>,
546    interrupt_running: Option<bool>,
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 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    }
561
562    let message = normalize_required_text(Some(message), "message")?;
563
564    if is_running && !should_interrupt {
565        // Actor child with a live WS connection: deliver in-band. The worker's
566        // agent loop admits it at the next round boundary — the same semantics
567        // as the queued path below, extended across the process boundary. The
568        // message is appended to the durable transcript immediately so the
569        // next activation rehydrates with it and nothing is delivered twice.
570        if crate::external_agents::live::deliver_message(&child.id, &message) {
571            child.add_message(bamboo_agent_core::Message::user(message.clone()));
572            port.save_child_session(&mut child).await?;
573            return Ok(json!({
574                "child_session_id": child.id,
575                "status": "message_delivered_live",
576                "auto_run": false,
577                "message": message,
578                "message_count": child.messages.len(),
579                "note": "Message delivered to the running actor in-band; it will be admitted at the next round boundary without canceling progress.",
580            }));
581        }
582
583        // Store the message in session runtime metadata so the running agent
584        // loop can merge it at the next turn boundary without canceling
585        // progress. Routed through the typed accessor (dual-writes the typed
586        // field + the legacy `pending_injected_messages` JSON string mirror).
587        let mut pending = child.pending_injected_messages().unwrap_or_default();
588        let queued = QueuedInjectedMessage {
589            content: message.clone(),
590            created_at: Some(chrono::Utc::now()),
591        };
592        pending.push(serde_json::to_value(&queued).unwrap_or(serde_json::Value::Null));
593        child.set_pending_injected_messages(pending);
594        port.save_child_session(&mut child).await?;
595
596        // Race guard: the `is_running` snapshot above may be stale — if the
597        // child finished between that check and this queue write, nothing
598        // would ever drain the pending message. Re-check and schedule a run
599        // so the message is processed instead of stranding.
600        if !port.is_child_running(&child.id).await {
601            port.enqueue_child_run(parent, &child).await?;
602            return Ok(json!({
603                "child_session_id": child.id,
604                "status": "queued",
605                "auto_run": true,
606                "message": message,
607                "message_count": child.messages.len(),
608                "note": "Child finished while the message was being queued; a new run was scheduled to process it.",
609            }));
610        }
611
612        return Ok(json!({
613            "child_session_id": child.id,
614            "status": "message_queued",
615            "auto_run": false,
616            "message": message,
617            "message_count": child.messages.len(),
618            "note": "Message queued for the child session. It will be picked up at the next turn boundary without canceling current progress.",
619        }));
620    }
621
622    child.add_message(bamboo_agent_core::Message::user(message.clone()));
623    child.set_last_run_status("pending");
624    child.clear_last_run_error();
625    port.save_child_session(&mut child).await?;
626
627    let should_auto_run = auto_run.unwrap_or(true);
628    if should_auto_run {
629        port.enqueue_child_run(parent, &child).await?;
630    }
631
632    Ok(json!({
633        "child_session_id": child.id,
634        "status": if should_auto_run { "queued" } else { "pending" },
635        "auto_run": should_auto_run,
636        "message": message,
637        "message_count": child.messages.len(),
638        "note": if should_auto_run {
639            "Follow-up message appended and child session queued."
640        } else {
641            "Follow-up message appended. Use action=run to execute the child session."
642        },
643    }))
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}