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