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;
6use std::{future::Future, pin::Pin};
7
8use super::helpers::{
9    append_subagent_delegation_contract, compute_status_guidance,
10    format_child_assignment_with_background, map_child_entry, metadata_text,
11    normalize_non_empty_optional, normalize_required_text, render_forked_parent_context,
12    replace_or_append_last_user_message, truncate_after_index, truncate_after_last_user,
13};
14use super::{
15    ChildSessionEntry, ChildSessionError, ChildSessionPort, CreateChildInput, CreateChildResult,
16};
17
18pub async fn create_child_action(
19    port: &dyn ChildSessionPort,
20    input: CreateChildInput,
21) -> Result<CreateChildResult, ChildSessionError> {
22    create_unbound_child(port, input, false).await
23}
24
25/// Compact delegation: reuse the canonical creator with a shared-directory
26/// constraint. No new persistence, placement or lifecycle protocol is added.
27pub async fn create_shared_child_action(
28    port: &dyn ChildSessionPort,
29    input: CreateChildInput,
30) -> Result<CreateChildResult, ChildSessionError> {
31    create_unbound_child(port, input, true).await
32}
33
34async fn create_unbound_child(
35    port: &dyn ChildSessionPort,
36    input: CreateChildInput,
37    shared_workspace: bool,
38) -> Result<CreateChildResult, ChildSessionError> {
39    if input
40        .runtime_metadata
41        .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
42    {
43        return Err(ChildSessionError::InvalidArguments(
44            "Ticket LocalPlan binding requires the trusted work-child entry point".into(),
45        ));
46    }
47    create_child_action_inner(port, input, None, None, shared_workspace).await
48}
49
50/// Host-only fresh work assignment creation; no model tool deserializes this port.
51pub async fn create_ticket_child_action(
52    port: &dyn ChildSessionPort,
53    mut input: CreateChildInput,
54    service: &bamboo_tickets::TicketService,
55    authority: &bamboo_tickets::Authority,
56    assignment_id: &str,
57) -> Result<CreateChildResult, ChildSessionError> {
58    if !matches!(authority.principal(), bamboo_tickets::Principal::Runtime) {
59        return Err(ChildSessionError::Execution(
60            "Ticket child creation requires trusted Runtime".into(),
61        ));
62    }
63    let packet = service
64        .child_context_packet(authority, assignment_id, 65536)
65        .map_err(|e| ChildSessionError::Execution(e.to_string()))?;
66    if input.parent_session.id != packet.binding.supervisor_session_id
67        || input.auto_run
68        || input.lifecycle.as_deref() == Some("resident")
69        || input.context_fork.unwrap_or(0) > 0
70    {
71        return Err(ChildSessionError::InvalidArguments(
72            "Ticket child requires fresh one-shot, exact Supervisor and explicit dispatch".into(),
73        ));
74    }
75    input.assignment_prompt =
76        serde_json::to_string(&packet).map_err(|e| ChildSessionError::Execution(e.to_string()))?;
77    input.runtime_metadata.insert(
78        crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
79        assignment_id.into(),
80    );
81    let required_packet = bamboo_domain::ChildContextPacket {
82        version: 1,
83        objective: packet.contract.objective.clone(),
84        constraints: packet.contract.constraints.clone(),
85        acceptance: packet
86            .contract
87            .acceptance
88            .iter()
89            .cloned()
90            .chain(std::iter::once(packet.result_contract.clone()))
91            .collect(),
92        non_goals: vec![
93            "Do not change the Work contract, Supervisor TaskList or any sibling plan".into(),
94        ],
95        necessary_user_instructions: Vec::new(),
96        recorded_decisions: vec![serde_json::to_string(&packet)
97            .map_err(|e| ChildSessionError::Execution(e.to_string()))?],
98        source_user_message_ids: Vec::new(),
99        background_message_ids: Vec::new(),
100    };
101    input.runtime_metadata.insert(
102        bamboo_domain::CHILD_PACKET_INPUT_KEY.into(),
103        serde_json::to_string(&required_packet)
104            .map_err(|e| ChildSessionError::Execution(e.to_string()))?,
105    );
106    create_child_action_inner(port, input, Some(packet), None, false).await
107}
108
109pub(super) async fn create_profile_child_action(
110    port: &dyn ChildSessionPort,
111    input: CreateChildInput,
112    profile: super::named_profile::ResolvedChildProfile,
113) -> Result<CreateChildResult, ChildSessionError> {
114    if input
115        .runtime_metadata
116        .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
117    {
118        return Err(ChildSessionError::InvalidArguments(
119            "invalid Workflow child input".into(),
120        ));
121    }
122    create_child_action_inner(port, input, None, Some(profile), false).await
123}
124
125async fn create_child_action_inner(
126    port: &dyn ChildSessionPort,
127    mut input: CreateChildInput,
128    ticket_context: Option<bamboo_tickets::WorkContextPacket>,
129    pinned_profile: Option<super::named_profile::ResolvedChildProfile>,
130    shared_workspace: bool,
131) -> Result<CreateChildResult, ChildSessionError> {
132    use crate::runner::refresh_prompt_snapshot;
133    use bamboo_agent_core::Message;
134
135    input.parent_session = super::validate_spawn_parent(port, &input.parent_session).await?;
136    if shared_workspace {
137        // Recompute from this creator's own durable reload, not the tool's
138        // earlier observation. A parent change must not leave a stale pin.
139        (input.workspace, input.workspace_source) =
140            super::resolve_shared_child_workspace(port, &input.parent_session).await?;
141    }
142
143    let workflow_usage_requested = pinned_profile.is_some()
144        && input
145            .runtime_metadata
146            .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
147            .is_some_and(|v| v == "true");
148    let profile = match pinned_profile {
149        Some(profile) => Some(profile),
150        None => {
151            port.resolve_named_profile(&input.parent_session, &input.subagent_type)
152                .await?
153        }
154    };
155    if let Some(profile) = &profile {
156        if input.lifecycle.as_deref() == Some("resident")
157            || input.resident_name.is_some()
158            || input.context_fork.unwrap_or_default() > 0
159        {
160            return Err(ChildSessionError::InvalidArguments(
161                "named_profile_requires_fresh_local_child".into(),
162            ));
163        }
164        input.read_only |= profile.read_only()
165            || bamboo_domain::PermissionAuditSnapshot::from_metadata(
166                &input.parent_session.metadata,
167            )
168            .is_some_and(|audit| audit.resolution.effective == bamboo_domain::PermissionMode::Plan)
169            || input
170                .parent_session
171                .agent_runtime_state
172                .as_ref()
173                .is_some_and(|r| r.read_only || r.plan_mode.is_some());
174        if let Some(model) = profile.model().filter(|_| {
175            !input
176                .runtime_metadata
177                .contains_key(super::named_profile::PROFILE_EXPLICIT_MODEL_KEY)
178        }) {
179            input.model_override = Some(model.model.clone());
180            input.model_ref_override = Some(model.clone());
181        }
182        // The strict route protects the complete assignment in task_brief.
183        // Do not duplicate it as the default objective: both fields are rendered
184        // and counted against the required-context budget.
185        if !input
186            .runtime_metadata
187            .contains_key(bamboo_domain::CHILD_PACKET_INPUT_KEY)
188        {
189            let packet = bamboo_domain::ChildContextPacket {
190                version: 1,
191                objective: "Complete the task described in task_brief.".into(),
192                constraints: Vec::new(),
193                acceptance: vec!["Complete the assigned task; report concrete evidence, verification and remaining blockers.".into()],
194                non_goals: Vec::new(),
195                necessary_user_instructions: Vec::new(),
196                recorded_decisions: Vec::new(),
197                source_user_message_ids: Vec::new(),
198                background_message_ids: Vec::new(),
199            };
200            input.runtime_metadata.insert(
201                bamboo_domain::CHILD_PACKET_INPUT_KEY.into(),
202                serde_json::to_string(&packet).map_err(|_| {
203                    ChildSessionError::InvalidArguments("invalid_child_context_packet".into())
204                })?,
205            );
206        }
207        if let Some(raw) = input.parent_session.metadata.get("disabled_tools") {
208            let parent_denied: std::collections::BTreeSet<String> = serde_json::from_str(raw)
209                .map_err(|_| {
210                    ChildSessionError::Execution("named_profile_parent_tools_invalid".into())
211                })?;
212            input
213                .disabled_tools
214                .get_or_insert_with(Default::default)
215                .extend(parent_denied);
216        }
217    }
218
219    // Resolve only from the durable parent, before constructing or persisting a
220    // child. The observation is content identity, not task/permission CAS.
221    let required_context = if let Some(raw) = input
222        .runtime_metadata
223        .get(bamboo_domain::CHILD_PACKET_INPUT_KEY)
224    {
225        if input.lifecycle.as_deref() == Some("resident")
226            || input.context_fork.unwrap_or_default() > 0
227        {
228            return Err(ChildSessionError::InvalidArguments(
229                "required_child_context_unsupported: fresh one-shot only".into(),
230            ));
231        }
232        if raw.len() > bamboo_domain::MAX_CHILD_PACKET_INPUT_BYTES {
233            return Err(ChildSessionError::InvalidArguments(
234                bamboo_domain::ChildContextPacketError::Budget.to_string(),
235            ));
236        }
237        let packet: bamboo_domain::ChildContextPacket =
238            serde_json::from_str(raw).map_err(|_| {
239                ChildSessionError::InvalidArguments(
240                    bamboo_domain::ChildContextPacketError::Invalid.to_string(),
241                )
242            })?;
243        if [&input.title, &input.responsibility, &input.subagent_type]
244            .into_iter()
245            .any(|text| text.lines().any(|line| line.len() > 2048))
246        {
247            return Err(ChildSessionError::InvalidArguments(
248                bamboo_domain::ChildContextPacketError::Budget.to_string(),
249            ));
250        }
251        let parent = port.load_parent_session(&input.parent_session.id).await?;
252        if parent.created_at != input.parent_session.created_at {
253            return Err(ChildSessionError::InvalidArguments(
254                "invalid_child_context_packet: parent lifetime changed".into(),
255            ));
256        }
257        let resolved = packet
258            .resolve(&parent, &input.assignment_prompt)
259            .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?;
260        if resolved.required_input_bytes
261            + input.title.len()
262            + input.responsibility.len()
263            + input.subagent_type.len()
264            > bamboo_domain::MAX_CHILD_REQUIRED_BYTES
265        {
266            return Err(ChildSessionError::InvalidArguments(
267                bamboo_domain::ChildContextPacketError::Budget.to_string(),
268            ));
269        }
270        let assignment = format_child_assignment_with_background(
271            &input.title,
272            &input.responsibility,
273            &input.subagent_type,
274            &resolved.required_brief,
275            None,
276        );
277        let binding =
278            bamboo_domain::ChildContextBinding::new(&parent, &input.child_id, assignment, resolved)
279                .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?;
280        port.validate_required_child_context_route(&input.runtime_metadata, &input.subagent_type)
281            .await?;
282        Some(binding)
283    } else {
284        None
285    };
286
287    let inherited_project_id =
288        match crate::project_context::ProjectContextResolver::session_project_identity(
289            &input.parent_session,
290        ) {
291            crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
292                Some(project_id)
293            }
294            crate::project_context::SessionProjectIdentity::Unassigned => None,
295            crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
296                return Err(ChildSessionError::InvalidArguments(format!(
297                    "parent session carries an invalid Project identity '{raw}': {message}"
298                )));
299            }
300        };
301    let final_workspace = port
302        .validate_child_workspace(inherited_project_id.as_ref(), &input.workspace)
303        .await?;
304    if shared_workspace {
305        // Host policy can change across the async preparation above. Retain
306        // the second validation, but never accept its relocation as sharing.
307        super::shared_workspace::require_same_workspace(&input.workspace, &final_workspace)?;
308    }
309
310    // Use `new_child_of` so the child inherits the parent's tree root and a
311    // depth of parent+1. For a root parent this is identical to the old
312    // flat-tree behavior; for a child parent it enables nesting while keeping
313    // `root_session_id` constant across the whole tree (completion/SSE keying).
314    let mut child = Session::new_child_of(
315        input.child_id.clone(),
316        &input.parent_session,
317        input
318            .model_ref_override
319            .as_ref()
320            .map(|model_ref| model_ref.model.clone())
321            .or_else(|| input.model_override.clone())
322            .unwrap_or_else(|| input.parent_session.model.clone()),
323        input.title.clone(),
324    );
325    // Project is stable tree identity: every child/resident/guardian inherits
326    // it even when the requested workspace differs from its parent.
327    if let Some(project_id) = inherited_project_id {
328        child.set_project_id_meta(project_id.to_string());
329    }
330
331    if let Some(model_ref) = input.model_ref_override.clone() {
332        child.model_ref = Some(model_ref.clone());
333        child
334            .metadata
335            .insert("provider_name".to_string(), model_ref.provider);
336    } else if let Some(mut parent_model_ref) = input.parent_session.model_ref.clone() {
337        // A legacy bare override inherits provider identity, not the parent's
338        // model ID. Keep the structured ref aligned with the actual spawn model.
339        parent_model_ref.model = child.model.clone();
340        child.model_ref = Some(parent_model_ref.clone());
341        child.set_provider_name(parent_model_ref.provider);
342    } else if let Some(parent_provider) = input.parent_session.provider_name() {
343        child.set_provider_name(parent_provider);
344    }
345
346    // Apply explicit reasoning_effort override if the LLM passed one;
347    // otherwise leave at `None` (provider default). Per CreateChildInput
348    // contract, children do NOT inherit the parent's reasoning_effort.
349    if let Some(effort) = input.reasoning_effort {
350        child.reasoning_effort = Some(effort);
351    }
352
353    // Children inherit the parent's exact permission posture. Auto must remain
354    // distinct from legacy Bypass so forced confirmations do not reappear at a
355    // child boundary.
356    let inherited_permission_mode = input
357        .parent_session
358        .agent_runtime_state
359        .as_ref()
360        .map(|state| state.effective_permission_mode())
361        .unwrap_or_default();
362    let child_runtime = child
363        .agent_runtime_state
364        .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
365    child_runtime.set_permission_mode(inherited_permission_mode);
366    child_runtime.read_only = input.read_only;
367    let parent_audit =
368        bamboo_domain::PermissionAuditSnapshot::from_metadata(&input.parent_session.metadata);
369    let parent_plan_active = input
370        .parent_session
371        .agent_runtime_state
372        .as_ref()
373        .is_some_and(|state| state.plan_mode.is_some());
374    let effective = if input.read_only || parent_plan_active {
375        bamboo_domain::PermissionMode::Plan
376    } else {
377        parent_audit
378            .as_ref()
379            .filter(|audit| {
380                audit.resolution.requested == inherited_permission_mode
381                    && audit.resolution.is_consistent()
382            })
383            .map(|audit| audit.resolution.effective)
384            .unwrap_or_else(|| {
385                bamboo_domain::resolve_permission_mode(
386                    inherited_permission_mode,
387                    bamboo_domain::PermissionMode::Default,
388                )
389                .effective
390            })
391    };
392    let resolution = bamboo_domain::PermissionModeResolution {
393        requested: inherited_permission_mode,
394        effective,
395    };
396    bamboo_domain::record_permission_audit(
397        &mut child.metadata,
398        &bamboo_domain::PermissionAuditSeed::new(
399            parent_audit
400                .as_ref()
401                .map(|audit| audit.policy_revision)
402                .unwrap_or_default(),
403            resolution,
404            format!("child_activation:{}", resolution.effective.as_str()),
405        ),
406        Some(&Utc::now().to_rfc3339()),
407    )
408    .map_err(|error| ChildSessionError::Execution(error.to_string()))?;
409
410    // #73: children inherit "no interactive human approver" too — if the run has
411    // no human to answer approvals (headless / scheduled / deployed), neither do
412    // its sub-agents, so their gated actions must be model-reviewed locally
413    // rather than escalated to a human who will never answer (300s fail-deny).
414    if input
415        .parent_session
416        .agent_runtime_state
417        .as_ref()
418        .is_some_and(|state| state.no_human_approver)
419    {
420        child
421            .agent_runtime_state
422            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
423            .no_human_approver = true;
424    }
425
426    child
427        .metadata
428        .insert("spawned_by".to_string(), "SubAgent".to_string());
429    child.set_subagent_type(input.subagent_type.clone());
430    child
431        .metadata
432        .insert("responsibility".to_string(), input.responsibility.clone());
433    child.metadata.insert(
434        "assignment_prompt".to_string(),
435        input.assignment_prompt.clone(),
436    );
437    // Resident-agent tagging (plain metadata, like `responsibility` above). Only
438    // a resident carries these; their presence is how a later create reuses this
439    // session instead of minting a new one. Mirrored into the session index so
440    // the lookup + the frontend can read them without loading session.json.
441    if input.lifecycle.as_deref() == Some("resident") {
442        child
443            .metadata
444            .insert("lifecycle".to_string(), "resident".to_string());
445        if let Some(name) = input.resident_name.clone().filter(|n| !n.trim().is_empty()) {
446            child.metadata.insert("resident_name".to_string(), name);
447        }
448        child.metadata.insert(
449            "resident_context".to_string(),
450            input
451                .resident_context
452                .clone()
453                .filter(|c| matches!(c.as_str(), "reset" | "accumulate"))
454                .unwrap_or_else(|| "reset".to_string()),
455        );
456    }
457    child.set_last_run_status("pending");
458    child
459        .advance_child_launch_generation()
460        .ok_or_else(|| ChildSessionError::Execution("child launch generation exhausted".into()))?;
461    if input.auto_run {
462        child.mark_child_auto_run_launch_intent();
463    }
464    child.clear_last_run_error();
465
466    // Apply runtime metadata (e.g. external agent routing).
467    for (key, value) in input.runtime_metadata {
468        if key != bamboo_domain::CHILD_PACKET_INPUT_KEY
469            && key != super::named_profile::PROFILE_EXPLICIT_MODEL_KEY
470        {
471            child.metadata.insert(key, value);
472        }
473    }
474
475    // Profile selection and runtime metadata now contain the final model
476    // authority. Reject it before publishing workspace or persisting a child.
477    port.validate_child_model(&child).await?;
478
479    // Publish the path that already passed the host's confinement and Project
480    // ownership checks without applying process-global policy a second time.
481    let stored_workspace = port.publish_child_workspace(
482        &child.id,
483        std::path::PathBuf::from(final_workspace),
484        input.workspace_source.as_str(),
485    );
486    child.workspace = Some(stored_workspace.to_string_lossy().to_string());
487    child.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
488        &stored_workspace,
489    ));
490    child.metadata.insert(
491        crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
492        input.workspace_source.as_str().to_string(),
493    );
494
495    // Preserve the configured global custom template/fallback, then append the
496    // child-only contract idempotently. Deliberately do not inherit the parent
497    // session's per-session prompt: it can contain root-only private policy and
498    // has never been the child-base source. Runtime context enhancement is
499    // applied uniformly by the runner to the resulting child base.
500    let base_prompt = {
501        let global = crate::prompt_defaults::read_global_default_system_prompt_template();
502        if global.trim().is_empty() {
503            crate::context::DEFAULT_BASE_PROMPT.to_string()
504        } else {
505            global
506        }
507    };
508    let base_prompt = profile.as_ref().map_or(base_prompt.clone(), |profile| {
509        profile.append_prompt(&base_prompt)
510    });
511    let system_prompt = append_subagent_delegation_contract(&base_prompt);
512
513    child
514        .metadata
515        .insert("base_system_prompt".to_string(), system_prompt.clone());
516
517    let mut system_message = Message::system(&system_prompt);
518    if workflow_usage_requested {
519        system_message.metadata =
520            Some(json!({(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY): true}));
521    }
522    child.add_message(system_message);
523
524    // Child sessions get more aggressive compression: trigger at 70% instead
525    // of the default 85%, target 35% instead of 40%. This prevents long child
526    // tasks from exhausting the context window before the parent can intervene.
527    if let Some(parent_budget) = input.parent_session.effective_token_budget() {
528        let mut child_budget = parent_budget.clone();
529        child_budget.compression_trigger_percent = 70;
530        child_budget.compression_target_percent = 35;
531        child.token_budget = Some(child_budget);
532    }
533
534    refresh_prompt_snapshot(&mut child);
535    // Phase 3: optionally fork a slice of the parent's recent context into the
536    // assignment's separately labeled background section (model-controllable
537    // via the SubAgent tool's `fork_last_messages`). `None`/0 keeps the child
538    // on a clean fresh context. Policy and stop/report sections remain after
539    // the fork, so transcript recency never closes the assignment frame.
540    let background = input
541        .context_fork
542        .and_then(|n| render_forked_parent_context(&input.parent_session, n));
543    let assignment = format_child_assignment_with_background(
544        &input.title,
545        &input.responsibility,
546        &input.subagent_type,
547        &input.assignment_prompt,
548        background.as_deref(),
549    );
550    if let Some(mut binding) = required_context {
551        binding
552            .bind_host_budget(&child)
553            .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?;
554        binding
555            .install(&mut child)
556            .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?;
557        child.add_message(binding.assignment_message());
558        for message in binding.background_messages() {
559            child.add_message(message);
560        }
561    } else {
562        child.add_message(Message::user(assignment));
563    }
564
565    if let Some(ticket_context) = ticket_context.as_ref() {
566        // The new work path never inherits Root Tasks. Its authoritative empty
567        // LocalPlan was created atomically with Assignment + DispatchIntent.
568        child.task_list = None;
569        if !child
570            .messages
571            .iter()
572            .any(|m| m.role == bamboo_domain::Role::System)
573        {
574            child.messages.insert(0, Message::system("Only this Work's own Task steps may be written through the Host. The Work contract and inputs are readonly."));
575        }
576        let system = child
577            .messages
578            .iter_mut()
579            .find(|m| m.role == bamboo_domain::Role::System)
580            .unwrap();
581        let metadata = system.metadata.get_or_insert_with(|| json!({}));
582        metadata
583            .as_object_mut()
584            .ok_or_else(|| ChildSessionError::Execution("invalid System metadata".into()))?
585            .insert(
586                crate::ticket_worker_plan::TICKET_PLAN_PACKET_KEY.into(),
587                json!(ticket_context),
588            );
589        child.metadata.insert(
590            "ticket.work_contract_ref.v1".into(),
591            serde_json::to_string(ticket_context)
592                .map_err(|e| ChildSessionError::Execution(e.to_string()))?,
593        );
594    } else if let Some(parent_task_list) = input.parent_session.task_list.clone() {
595        child.set_task_list(parent_task_list);
596    }
597
598    // Persist any per-child tool denylist so the spawn path (enqueue_child_run
599    // → SpawnJob.disabled_tools) can trim the runtime-exposed toolset (e.g. a
600    // read-only Guardian reviewer). Most children add no child-specific denylist.
601    if let Some(ref disabled) = input.disabled_tools {
602        if !disabled.is_empty() {
603            child.metadata.insert(
604                "disabled_tools".to_string(),
605                serde_json::to_string(disabled).unwrap_or_default(),
606            );
607        }
608    }
609
610    if let Some(profile) = profile {
611        profile.bind(&mut child, &input.parent_session)?;
612    }
613
614    let model = child.model.clone();
615    port.save_child_session(&mut child).await?;
616    if input.auto_run {
617        port.enqueue_child_run(&input.parent_session, &child)
618            .await?;
619    }
620
621    Ok(CreateChildResult {
622        child_session_id: child.id,
623        model,
624    })
625}
626
627pub async fn list_children_action(
628    port: &dyn ChildSessionPort,
629    parent_id: &str,
630) -> serde_json::Value {
631    let children = port.list_children(parent_id).await;
632    json!({
633        "parent_session_id": parent_id,
634        "children": children.iter().map(map_child_entry).collect::<Vec<_>>(),
635        "count": children.len(),
636    })
637}
638
639/// A node in the materialized parent→child session graph (Phase 6: persistent
640/// multi-level nesting graph). `children` are the transitive descendants.
641#[derive(Debug, Clone, PartialEq, serde::Serialize)]
642pub struct SessionTreeNode {
643    pub session_id: String,
644    pub title: String,
645    #[serde(skip_serializing_if = "Option::is_none")]
646    pub last_run_status: Option<String>,
647    pub depth: u32,
648    pub children: Vec<SessionTreeNode>,
649}
650
651/// Assemble the transitive parent→child tree rooted at `root_id` from a
652/// pre-fetched adjacency map (pure — unit-testable without a port). Bounded by
653/// `max_depth`; a first-visit guard breaks cycles (a re-encountered session
654/// becomes a leaf rather than recursing forever).
655pub fn assemble_session_tree(
656    root_id: &str,
657    root_title: &str,
658    adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
659    max_depth: u32,
660) -> SessionTreeNode {
661    fn build(
662        id: &str,
663        title: &str,
664        status: Option<String>,
665        depth: u32,
666        max_depth: u32,
667        adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
668        visited: &mut std::collections::HashSet<String>,
669    ) -> SessionTreeNode {
670        let first_visit = visited.insert(id.to_string());
671        let mut children = Vec::new();
672        if first_visit && depth < max_depth {
673            if let Some(kids) = adjacency.get(id) {
674                for kid in kids {
675                    children.push(build(
676                        &kid.child_session_id,
677                        &kid.title,
678                        kid.last_run_status.clone(),
679                        depth + 1,
680                        max_depth,
681                        adjacency,
682                        visited,
683                    ));
684                }
685            }
686        }
687        SessionTreeNode {
688            session_id: id.to_string(),
689            title: title.to_string(),
690            last_run_status: status,
691            depth,
692            children,
693        }
694    }
695    let mut visited = std::collections::HashSet::new();
696    build(
697        root_id,
698        root_title,
699        None,
700        0,
701        max_depth,
702        adjacency,
703        &mut visited,
704    )
705}
706
707/// Materialize the full transitive parent→child session graph rooted at
708/// `root_id` from the persisted session index (Phase 6). BFS-fetches each
709/// level's children via [`ChildSessionPort::list_children`] (a first-visit guard
710/// and a hard node cap protect against cycles / runaway trees), then assembles the
711/// tree. The graph is derived from durable index state, so it survives restarts.
712pub async fn build_session_tree_action(
713    port: &dyn ChildSessionPort,
714    root_id: &str,
715    max_depth: u32,
716) -> SessionTreeNode {
717    use std::collections::{HashMap, HashSet, VecDeque};
718    const NODE_CAP: usize = 5000;
719
720    let root_title = port
721        .load_root_session(root_id)
722        .await
723        .map(|s| s.title)
724        .unwrap_or_default();
725
726    let mut adjacency: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
727    let mut visited: HashSet<String> = HashSet::new();
728    let mut queue: VecDeque<(String, u32)> = VecDeque::new();
729    queue.push_back((root_id.to_string(), 0));
730
731    while let Some((id, depth)) = queue.pop_front() {
732        if depth >= max_depth || adjacency.len() >= NODE_CAP || !visited.insert(id.clone()) {
733            continue;
734        }
735        let kids = port.list_children(&id).await;
736        for kid in &kids {
737            queue.push_back((kid.child_session_id.clone(), depth + 1));
738        }
739        adjacency.insert(id, kids);
740    }
741
742    assemble_session_tree(root_id, &root_title, &adjacency, max_depth)
743}
744
745pub async fn get_child_action(
746    port: &dyn ChildSessionPort,
747    parent_id: &str,
748    child_session_id: String,
749) -> Result<serde_json::Value, ChildSessionError> {
750    let child = port
751        .load_child_for_inspection(parent_id, &child_session_id)
752        .await?;
753
754    let status = child.last_run_status();
755    let runner_info = port.get_child_runner_info(&child.id).await;
756    let mut truncated_fields = Vec::new();
757    let title = bounded_overview_field(Some(child.title.clone()), "title", &mut truncated_fields);
758    let model = bounded_overview_field(Some(child.model.clone()), "model", &mut truncated_fields);
759    let error = bounded_overview_field(
760        child.last_run_error(),
761        "last_run_error",
762        &mut truncated_fields,
763    );
764    let responsibility = bounded_overview_field(
765        metadata_text(&child, "responsibility"),
766        "responsibility",
767        &mut truncated_fields,
768    );
769    let subagent_type = bounded_overview_field(
770        metadata_text(&child, "subagent_type"),
771        "subagent_type",
772        &mut truncated_fields,
773    );
774    let prompt = bounded_overview_field(
775        metadata_text(&child, "assignment_prompt"),
776        "prompt",
777        &mut truncated_fields,
778    );
779    let latest_user_message = bounded_overview_field(
780        child
781            .messages
782            .iter()
783            .rfind(|message| matches!(message.role, bamboo_agent_core::Role::User))
784            .map(|message| message.content.clone()),
785        "latest_user_message",
786        &mut truncated_fields,
787    );
788    let guidance = bounded_overview_field(
789        Some(compute_status_guidance(
790            status.as_deref(),
791            runner_info.as_ref(),
792            child.has_pending_injected_messages(),
793        )),
794        "guidance",
795        &mut truncated_fields,
796    );
797
798    Ok(json!({
799        "child_session_id": child.id,
800        "title": title,
801        "model": model,
802        "pinned": child.pinned,
803        "message_count": child.messages.len(),
804        "is_running": port.is_child_running(&child.id).await,
805        "last_run_status": bounded_overview_field(status.clone(), "last_run_status", &mut truncated_fields),
806        "last_run_error": error,
807        "responsibility": responsibility,
808        "subagent_type": subagent_type,
809        "prompt": prompt,
810        "latest_user_message": latest_user_message,
811        "runtime_kind": bounded_overview_field(metadata_text(&child, "runtime.kind"), "runtime_kind", &mut truncated_fields),
812        "external_protocol": bounded_overview_field(metadata_text(&child, "external.protocol"), "external_protocol", &mut truncated_fields),
813        "external_agent_id": bounded_overview_field(metadata_text(&child, "external.agent_id"), "external_agent_id", &mut truncated_fields),
814        "a2a_context_id": bounded_overview_field(metadata_text(&child, "a2a.context_id"), "a2a_context_id", &mut truncated_fields),
815        "a2a_latest_task_id": bounded_overview_field(metadata_text(&child, "a2a.latest_task_id"), "a2a_latest_task_id", &mut truncated_fields),
816        "a2a_last_state": bounded_overview_field(metadata_text(&child, "a2a.last_state"), "a2a_last_state", &mut truncated_fields),
817        "runner_started_at": runner_info.as_ref().and_then(|r| r.started_at.map(|t| t.to_rfc3339())),
818        "runner_completed_at": runner_info.as_ref().and_then(|r| r.completed_at.map(|t| t.to_rfc3339())),
819        "last_tool_name": bounded_overview_field(runner_info.as_ref().and_then(|r| r.last_tool_name.clone()), "last_tool_name", &mut truncated_fields),
820        "last_tool_phase": bounded_overview_field(runner_info.as_ref().and_then(|r| r.last_tool_phase.clone()), "last_tool_phase", &mut truncated_fields),
821        "last_event_at": runner_info.as_ref().and_then(|r| r.last_event_at.map(|t| t.to_rfc3339())),
822        "round_count": runner_info.as_ref().map(|r| r.round_count).unwrap_or(0),
823        "has_pending_injected_messages": child.has_pending_injected_messages(),
824        "guidance": guidance,
825        "truncated_fields": truncated_fields,
826        "inspection_hint": "This overview contains metadata and bounded previews, not the child transcript. Use view=messages for paginated previews and view=result for bounded final-answer slices.",
827    }))
828}
829
830fn bounded_overview_field(
831    value: Option<String>,
832    field: &'static str,
833    truncated_fields: &mut Vec<&'static str>,
834) -> Option<String> {
835    const MAX_BYTES: usize = 2048;
836    value.map(|value| {
837        if value.len() <= MAX_BYTES {
838            return value;
839        }
840        let mut end = MAX_BYTES;
841        while !value.is_char_boundary(end) {
842            end -= 1;
843        }
844        truncated_fields.push(field);
845        value[..end].to_string()
846    })
847}
848
849#[allow(clippy::too_many_arguments)]
850pub async fn update_child_action(
851    port: &dyn ChildSessionPort,
852    parent_id: &str,
853    child_session_id: String,
854    title: Option<String>,
855    responsibility: Option<String>,
856    prompt: Option<String>,
857    subagent_type: Option<String>,
858    reset_after_update: Option<bool>,
859    model_ref_override: Option<bamboo_domain::ProviderModelRef>,
860    reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
861    auto_run: bool,
862) -> Result<serde_json::Value, ChildSessionError> {
863    update_child_action_with_background(
864        port,
865        parent_id,
866        child_session_id,
867        title,
868        responsibility,
869        prompt,
870        subagent_type,
871        reset_after_update,
872        model_ref_override,
873        reasoning_effort,
874        None,
875        auto_run,
876    )
877    .await
878}
879
880/// Background-aware form used by resident reuse with `fork_last_messages`.
881/// The canonical raw assignment prompt remains in metadata; rendered fork text
882/// only belongs to this task frame, so later reuse cannot compound it.
883#[allow(clippy::too_many_arguments)]
884pub async fn update_child_action_with_background(
885    port: &dyn ChildSessionPort,
886    parent_id: &str,
887    child_session_id: String,
888    title: Option<String>,
889    responsibility: Option<String>,
890    prompt: Option<String>,
891    subagent_type: Option<String>,
892    reset_after_update: Option<bool>,
893    model_ref_override: Option<bamboo_domain::ProviderModelRef>,
894    reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
895    assignment_background: Option<String>,
896    auto_run: bool,
897) -> Result<serde_json::Value, ChildSessionError> {
898    let update = ChildSessionUpdate {
899        title: normalize_non_empty_optional(title, "title")?,
900        responsibility: normalize_non_empty_optional(responsibility, "responsibility")?,
901        prompt: normalize_non_empty_optional(prompt, "prompt")?,
902        subagent_type: normalize_non_empty_optional(subagent_type, "subagent_type")?,
903        reset_after_update,
904        model_ref_override,
905        reasoning_effort,
906        assignment_background,
907        auto_run,
908    };
909
910    if update.title.is_none()
911        && !update.refreshes_assignment()
912        && update.model_ref_override.is_none()
913        && update.reasoning_effort.is_none()
914    {
915        return Err(ChildSessionError::InvalidArguments(
916            "update requires at least one field: title/responsibility/prompt/subagent_type/model/reasoning_effort"
917                .to_string(),
918        ));
919    }
920
921    let (child, messages_removed) = port
922        .update_child_session(parent_id, &child_session_id, update)
923        .await?;
924
925    Ok(json!({
926        "child_session_id": child.id,
927        "title": child.title,
928        "model": child.model,
929        "model_ref": child.model_ref,
930        "reasoning_effort": child.reasoning_effort.map(|effort| effort.as_str()),
931        "messages_removed": messages_removed,
932        "last_run_status": metadata_text(&child, "last_run_status"),
933        "note": "Child session updated in place. Use action=run to execute the same child session.",
934    }))
935}
936
937/// The fields of one update request. The server applies this to the latest
938/// durable child while holding both the launch fence and session write lock.
939#[derive(Clone)]
940pub struct ChildSessionUpdate {
941    pub title: Option<String>,
942    pub responsibility: Option<String>,
943    pub prompt: Option<String>,
944    pub subagent_type: Option<String>,
945    pub reset_after_update: Option<bool>,
946    pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
947    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
948    pub assignment_background: Option<String>,
949    pub auto_run: bool,
950}
951
952impl ChildSessionUpdate {
953    pub fn refreshes_assignment(&self) -> bool {
954        self.responsibility.is_some() || self.prompt.is_some() || self.subagent_type.is_some()
955    }
956
957    pub fn changes_execution(&self) -> bool {
958        self.refreshes_assignment()
959            || self.model_ref_override.is_some()
960            || self.reasoning_effort.is_some()
961            || self.auto_run
962    }
963}
964
965pub fn apply_child_session_update(
966    child: &mut Session,
967    update: ChildSessionUpdate,
968) -> Result<usize, ChildSessionError> {
969    let should_refresh_assignment = update.refreshes_assignment();
970    if super::named_profile::has_named_profile(child)
971        && (should_refresh_assignment
972            || update.assignment_background.is_some()
973            || update.model_ref_override.is_some()
974            || update.reasoning_effort.is_some())
975    {
976        return Err(ChildSessionError::InvalidArguments(
977            "named_profile_contract_is_frozen; create a new Child to select another profile or model".into()));
978    }
979    if (should_refresh_assignment || update.assignment_background.is_some())
980        && bamboo_domain::ChildContextBinding::from_session(child)
981            .map_err(|error| ChildSessionError::Execution(error.to_string()))?
982            .is_some()
983    {
984        return Err(ChildSessionError::InvalidArguments(
985            "required_child_context_unsupported: immutable assignment cannot be updated in place; create a new Child".into()));
986    }
987
988    if let Some(model_ref) = update.model_ref_override {
989        apply_model_ref_override(child, model_ref)?;
990    }
991
992    if let Some(effort) = update.reasoning_effort {
993        child.reasoning_effort = Some(effort);
994    }
995
996    if let Some(title) = update.title {
997        if child.title != title {
998            child.title = title;
999            child.title_generated = false;
1000            child.title_version = child.title_version.saturating_add(1);
1001            child.metadata_version = child.metadata_version.saturating_add(1);
1002        }
1003    }
1004
1005    let mut messages_removed = 0usize;
1006
1007    if should_refresh_assignment {
1008        let effective_responsibility = normalize_required_text(
1009            update
1010                .responsibility
1011                .or_else(|| metadata_text(child, "responsibility")),
1012            "responsibility",
1013        )?;
1014        let effective_subagent_type = normalize_required_text(
1015            update
1016                .subagent_type
1017                .or_else(|| metadata_text(child, "subagent_type")),
1018            "subagent_type",
1019        )?;
1020        let effective_prompt = normalize_required_text(
1021            update
1022                .prompt
1023                .or_else(|| metadata_text(child, "assignment_prompt")),
1024            "prompt",
1025        )?;
1026
1027        child.metadata.insert(
1028            "responsibility".to_string(),
1029            effective_responsibility.clone(),
1030        );
1031        child
1032            .metadata
1033            .insert("subagent_type".to_string(), effective_subagent_type.clone());
1034        child
1035            .metadata
1036            .insert("assignment_prompt".to_string(), effective_prompt.clone());
1037        child.set_last_run_status("pending");
1038        child.advance_child_launch_generation().ok_or_else(|| {
1039            ChildSessionError::Execution("child launch generation exhausted".into())
1040        })?;
1041        child.clear_last_run_error();
1042
1043        let assignment = format_child_assignment_with_background(
1044            &child.title,
1045            &effective_responsibility,
1046            &effective_subagent_type,
1047            &effective_prompt,
1048            update.assignment_background.as_deref(),
1049        );
1050        let user_index = replace_or_append_last_user_message(child, assignment);
1051
1052        if update.reset_after_update.unwrap_or(true) {
1053            messages_removed = truncate_after_index(child, user_index);
1054        }
1055    }
1056
1057    if update.auto_run {
1058        if !should_refresh_assignment {
1059            child.set_last_run_status("pending");
1060            child.advance_child_launch_generation().ok_or_else(|| {
1061                ChildSessionError::Execution("child launch generation exhausted".into())
1062            })?;
1063            child.clear_last_run_error();
1064        }
1065        child.mark_child_auto_run_launch_intent();
1066    }
1067
1068    child.updated_at = Utc::now();
1069    Ok(messages_removed)
1070}
1071
1072fn apply_model_ref_override(
1073    child: &mut Session,
1074    model_ref: bamboo_domain::ProviderModelRef,
1075) -> Result<(), ChildSessionError> {
1076    let provider = model_ref.provider.trim();
1077    let model = model_ref.model.trim();
1078    if provider.is_empty() || model.is_empty() {
1079        return Err(ChildSessionError::InvalidArguments(
1080            "model provider and id must both be non-empty".to_string(),
1081        ));
1082    }
1083    let model_ref = bamboo_domain::ProviderModelRef {
1084        provider: provider.to_string(),
1085        model: model.to_string(),
1086        reasoning_effort: model_ref.reasoning_effort,
1087    };
1088    child.model = model_ref.model.clone();
1089    child.set_provider_name(model_ref.provider.clone());
1090    child.model_ref = Some(model_ref);
1091    Ok(())
1092}
1093
1094/// Prepare an existing child for a fresh run without making it runnable. The
1095/// caller must durably arm any synchronous parent wait before enqueueing it.
1096pub async fn run_child_action(
1097    port: &dyn ChildSessionPort,
1098    parent: &Session,
1099    child_session_id: String,
1100    reset_to_last_user: Option<bool>,
1101) -> Result<serde_json::Value, ChildSessionError> {
1102    let mut child = port
1103        .load_child_for_parent(&parent.id, &child_session_id)
1104        .await?;
1105
1106    if port.is_child_running(&child.id).await {
1107        return Ok(json!({
1108            "child_session_id": child.id,
1109            "status": "already_running",
1110            "note": "Child session is already running.",
1111        }));
1112    }
1113
1114    port.validate_child_run_request(parent, &child, reset_to_last_user)
1115        .await?;
1116    port.validate_child_model(&child).await?;
1117    let mut messages_removed = 0usize;
1118    if reset_to_last_user.unwrap_or(true) {
1119        messages_removed = truncate_after_last_user(&mut child)?;
1120    }
1121
1122    child.set_last_run_status("pending");
1123    child
1124        .advance_child_launch_generation()
1125        .ok_or_else(|| ChildSessionError::Execution("child launch generation exhausted".into()))?;
1126    child.mark_child_auto_run_launch_intent();
1127    child.clear_last_run_error();
1128    child.updated_at = Utc::now();
1129    port.save_child_session(&mut child).await?;
1130
1131    Ok(json!({
1132        "child_session_id": child.id,
1133        "status": "queued",
1134        "messages_removed": messages_removed,
1135        "note": "Queued existing child session for retry in place.",
1136    }))
1137}
1138
1139#[allow(clippy::too_many_arguments)]
1140pub async fn send_message_to_child_action(
1141    port: &dyn ChildSessionPort,
1142    parent: &Session,
1143    child_session_id: String,
1144    message: String,
1145    auto_run: Option<bool>,
1146    interrupt_running: Option<bool>,
1147    idempotency_key: Option<&str>,
1148    wait_if_queued: bool,
1149) -> Result<serde_json::Value, ChildSessionError> {
1150    send_message_to_child_action_with_gate(
1151        port,
1152        parent,
1153        child_session_id,
1154        message,
1155        auto_run,
1156        interrupt_running,
1157        idempotency_key,
1158        wait_if_queued,
1159        None,
1160    )
1161    .await
1162}
1163
1164#[allow(clippy::too_many_arguments)]
1165pub async fn send_message_to_child_action_with_gate(
1166    port: &dyn ChildSessionPort,
1167    parent: &Session,
1168    child_session_id: String,
1169    message: String,
1170    auto_run: Option<bool>,
1171    interrupt_running: Option<bool>,
1172    idempotency_key: Option<&str>,
1173    wait_if_queued: bool,
1174    admission_gate: Option<&bamboo_domain::AdmissionGate>,
1175) -> Result<serde_json::Value, ChildSessionError> {
1176    let mut child = port
1177        .load_child_for_parent(&parent.id, &child_session_id)
1178        .await?;
1179
1180    // Preserve the historical existence/ownership error priority above, then
1181    // validate before any runner query or interrupt. Ordinary corrective
1182    // content stays byte-for-byte raw through draft and SessionInbox paths.
1183    if message.trim().is_empty() {
1184        return Err(ChildSessionError::InvalidArguments(
1185            "message must be non-empty".to_string(),
1186        ));
1187    }
1188
1189    port.validate_child_model(&child).await?;
1190
1191    let mut is_running = port.is_child_running(&child.id).await;
1192    let should_interrupt = interrupt_running.unwrap_or(false);
1193    let mut delivery_gate = admission_gate;
1194
1195    if is_running && should_interrupt {
1196        // Interrupting an existing run is itself an irreversible part of this
1197        // delivery. Once it starts, the owner must finish preparing the new
1198        // message even if the caller disappears.
1199        if let Some(gate) = admission_gate {
1200            match gate.commit(|| Ok::<(), ChildSessionError>(()))? {
1201                bamboo_domain::AdmissionCommit::Cancelled => {
1202                    return Err(ChildSessionError::Execution(
1203                        "SubAgent tool cancelled before child interruption".to_string(),
1204                    ));
1205                }
1206                bamboo_domain::AdmissionCommit::Committed(())
1207                | bamboo_domain::AdmissionCommit::AlreadyCommitted => {}
1208            }
1209        }
1210        // The stop is the first irreversible effect, so this branch has
1211        // committed its owner before that call. A second Inbox gate would see
1212        // an already-committed operation rather than a message receipt.
1213        delivery_gate = None;
1214        port.cancel_child_run_and_wait(&child.id).await?;
1215        child = port
1216            .load_child_for_parent(&parent.id, &child_session_id)
1217            .await?;
1218        // Cancellation changes the routing decision. In particular,
1219        // auto_run=false means "leave a draft" after interrupting the old run,
1220        // not "wake a successor" based on the pre-cancel snapshot.
1221        is_running = port.is_child_running(&child.id).await;
1222    }
1223
1224    let should_auto_run = auto_run.unwrap_or(true);
1225    // `interrupt_running` is meaningful only for an actually-running child.
1226    // On an idle child, `auto_run=false` must retain the historical draft-only
1227    // behavior regardless of that otherwise-inert flag.
1228    if should_route_child_message_through_inbox(is_running, should_auto_run) {
1229        // An idle child's SessionInbox delivery can reserve and execute its
1230        // activation inside send_session_message. Arm the parent's durable wait
1231        // first, then remove it if delivery did not actually queue a new run.
1232        // Live messages intentionally retain their non-suspending semantics.
1233        let armed_wait = wait_if_queued && !is_running;
1234        let had_wait = if armed_wait {
1235            port.load_parent_session(&parent.id)
1236                .await?
1237                .agent_runtime_state
1238                .as_ref()
1239                .and_then(|state| state.waiting_for_children.as_ref())
1240                .is_some_and(|wait| wait.child_session_ids.iter().any(|id| id == &child.id))
1241        } else {
1242            false
1243        };
1244        if armed_wait {
1245            if let Err(error) = port
1246                .register_parent_wait_for_child(&parent.id, &child.id, idempotency_key)
1247                .await
1248            {
1249                return Err(if had_wait {
1250                    error
1251                } else {
1252                    rollback_failed_wait_launch(port, &parent.id, &child.id, error).await
1253                });
1254            }
1255        }
1256        if delivery_gate.is_some_and(bamboo_domain::AdmissionGate::is_cancelled) {
1257            let error = ChildSessionError::Execution(
1258                "SubAgent tool cancelled before child delivery".to_string(),
1259            );
1260            return Err(if armed_wait && !had_wait {
1261                rollback_failed_wait_launch(port, &parent.id, &child.id, error).await
1262            } else {
1263                error
1264            });
1265        }
1266        let send = port.send_session_message_with_gate(
1267            &parent.id,
1268            &child.id,
1269            &message,
1270            idempotency_key,
1271            delivery_gate,
1272        );
1273        // The detached owner must not hold a newly armed parent wait while a
1274        // pre-commit inbox lock or temp write remains blocked. The durable
1275        // rename runs synchronously under this same gate; after it commits,
1276        // cancellation cannot win this select and activation keeps its owner.
1277        let delivery = match delivery_gate {
1278            Some(gate) => tokio::select! {
1279                biased;
1280                _ = gate.cancelled() => Err(ChildSessionError::Execution(
1281                    "SubAgent tool cancelled before child delivery".to_string(),
1282                )),
1283                result = send => result,
1284            },
1285            None => send.await,
1286        };
1287        let delivery = match delivery {
1288            Ok(delivery) => delivery,
1289            Err(error) if armed_wait && !had_wait => {
1290                return Err(rollback_failed_wait_launch(port, &parent.id, &child.id, error).await);
1291            }
1292            Err(error) => return Err(error),
1293        };
1294        let (delivery, activation, status, note, activation_error) = match delivery {
1295            super::ChildSessionMessageDelivery::Activated(receipt) => {
1296                let (status, note) = match receipt.activation {
1297                    bamboo_domain::SessionActivationDisposition::ActiveNotified => (
1298                        "message_delivered_live",
1299                        "Message durably delivered and the owning active loop was notified.",
1300                    ),
1301                    bamboo_domain::SessionActivationDisposition::ActivationReserved => (
1302                        "queued",
1303                        "Message durably delivered and exactly one session activation was reserved.",
1304                    ),
1305                    bamboo_domain::SessionActivationDisposition::ActivationCoalesced => (
1306                        "message_queued",
1307                        "Message durably delivered; activation coalesced with existing session work.",
1308                    ),
1309                };
1310                (
1311                    receipt.delivery,
1312                    Some(receipt.activation),
1313                    status,
1314                    note,
1315                    None,
1316                )
1317            }
1318            super::ChildSessionMessageDelivery::ActivationPending { delivery, error } => (
1319                delivery,
1320                None,
1321                "activation_pending",
1322                "Message is durable; activation is pending and will be retried from the inbox watermark.",
1323                Some(error),
1324            ),
1325            super::ChildSessionMessageDelivery::ActivationAuthorizationPending {
1326                delivery,
1327                error,
1328            } => (
1329                delivery,
1330                None,
1331                "activation_retry_required",
1332                "Message is durable, but activation authorization was not persisted. The same message ID must be retried; restart alone cannot wake this delivery.",
1333                Some(error),
1334            ),
1335        };
1336        if armed_wait && !had_wait && status != "queued" {
1337            port.rollback_parent_wait_for_child(&parent.id, &child.id)
1338                .await?;
1339        }
1340        return Ok(json!({
1341            "child_session_id": child.id,
1342            "status": status,
1343            "auto_run": !matches!(
1344                activation,
1345                Some(bamboo_domain::SessionActivationDisposition::ActiveNotified)
1346            ),
1347            "message": message,
1348            "message_id": delivery.id.to_string(),
1349            "inbox_generation": delivery.generation,
1350            "activation_error": activation_error,
1351            "message_count": child.messages.len(),
1352            "note": note,
1353        }));
1354    }
1355
1356    // Explicit `auto_run=false` on an idle child retains its historical
1357    // draft-only behavior. All runnable/live delivery paths above converge on
1358    // SessionMessenger and never rewrite a snapshot to enqueue. The draft's
1359    // multi-file Session save still needs its own final-commit cancellation
1360    // fence (#1328); this fast check only avoids work already cancelled here.
1361    if delivery_gate.is_some_and(bamboo_domain::AdmissionGate::is_cancelled) {
1362        return Err(ChildSessionError::Execution(
1363            "SubAgent tool cancelled before child delivery".to_string(),
1364        ));
1365    }
1366    let child = port
1367        .append_draft_child_message(&parent.id, &child.id, &message)
1368        .await?;
1369
1370    Ok(json!({
1371        "child_session_id": child.id,
1372        "status": "pending",
1373        "auto_run": false,
1374        "message": message,
1375        "message_count": child.messages.len(),
1376        "note": "Follow-up message appended. Use action=run to execute the child session.",
1377    }))
1378}
1379
1380/// Undo only this child's wait entry when a launch fails after the wait was
1381/// armed. Keep the original failure visible even if compensation also fails.
1382pub async fn rollback_failed_wait_launch(
1383    port: &dyn ChildSessionPort,
1384    parent_session_id: &str,
1385    child_session_id: &str,
1386    error: ChildSessionError,
1387) -> ChildSessionError {
1388    match port
1389        .rollback_parent_wait_for_child(parent_session_id, child_session_id)
1390        .await
1391    {
1392        Ok(()) => error,
1393        Err(rollback_error) => ChildSessionError::Execution(format!(
1394            "{error}; parent-wait rollback failed: {rollback_error}"
1395        )),
1396    }
1397}
1398
1399fn should_route_child_message_through_inbox(is_running: bool, should_auto_run: bool) -> bool {
1400    is_running || should_auto_run
1401}
1402
1403pub fn cancel_child_action<'a>(
1404    port: &'a dyn ChildSessionPort,
1405    parent_id: &'a str,
1406    child_session_id: String,
1407) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, ChildSessionError>> + Send + 'a>> {
1408    Box::pin(cancel_child_action_inner(port, parent_id, child_session_id))
1409}
1410
1411async fn cancel_child_action_inner(
1412    port: &dyn ChildSessionPort,
1413    parent_id: &str,
1414    child_session_id: String,
1415) -> Result<serde_json::Value, ChildSessionError> {
1416    // Validate ownership before doing anything.
1417    let _ = port
1418        .load_child_for_parent(parent_id, &child_session_id)
1419        .await?;
1420    port.cancel_child_run_and_wait(&child_session_id).await?;
1421
1422    // RELOAD after the wait — writing the pre-wait snapshot would clobber
1423    // whatever the finishing run persisted (its terminal status AND any
1424    // messages it appended). And if the child completed naturally while the
1425    // cancel was in flight, keep that truth instead of mislabeling it.
1426    let mut child = port
1427        .load_child_for_parent(parent_id, &child_session_id)
1428        .await?;
1429    let latest_status = child.last_run_status().unwrap_or_default();
1430    if latest_status == "cancelled" {
1431        return Ok(json!({
1432            "child_session_id": child_session_id,
1433            "status": "cancelled",
1434        }));
1435    }
1436    if matches!(
1437        latest_status.as_str(),
1438        "completed" | "error" | "timeout" | "skipped"
1439    ) {
1440        return Ok(json!({
1441            "child_session_id": child_session_id,
1442            "status": latest_status,
1443            "note": "Child reached a natural terminal state while the cancel was in flight; its real outcome was kept.",
1444        }));
1445    }
1446    child.set_last_run_status("cancelled");
1447    child.set_last_run_error("Cancelled by parent");
1448    port.save_child_session(&mut child).await?;
1449    Ok(json!({
1450        "child_session_id": child_session_id,
1451        "status": "cancelled",
1452    }))
1453}
1454
1455pub async fn delete_child_action(
1456    port: &dyn ChildSessionPort,
1457    parent_id: &str,
1458    child_session_id: String,
1459) -> Result<serde_json::Value, ChildSessionError> {
1460    // Load child first to get its ID (port.delete_child_session handles cancellation + cleanup)
1461    let child = port
1462        .load_child_for_parent(parent_id, &child_session_id)
1463        .await?;
1464    let result = port.delete_child_session(parent_id, &child.id).await?;
1465
1466    if !result.deleted {
1467        return Err(ChildSessionError::Execution(format!(
1468            "child session was not deleted: {}",
1469            child.id
1470        )));
1471    }
1472
1473    Ok(json!({
1474        "child_session_id": child.id,
1475        "deleted": true,
1476        "cancelled_running_child": result.cancelled_running_child,
1477    }))
1478}
1479
1480#[cfg(test)]
1481mod tree_tests {
1482    use super::super::ChildSessionEntry;
1483    use super::assemble_session_tree;
1484    use std::collections::HashMap;
1485
1486    fn entry(id: &str, title: &str) -> ChildSessionEntry {
1487        ChildSessionEntry {
1488            child_session_id: id.to_string(),
1489            title: title.to_string(),
1490            pinned: false,
1491            message_count: 0,
1492            updated_at: String::new(),
1493            last_run_status: Some("completed".to_string()),
1494            last_run_error: None,
1495        }
1496    }
1497
1498    #[test]
1499    fn assembles_multi_level_tree() {
1500        let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
1501        adj.insert(
1502            "root".into(),
1503            vec![entry("c1", "child 1"), entry("c2", "child 2")],
1504        );
1505        adj.insert("c1".into(), vec![entry("g1", "grandchild")]);
1506
1507        let tree = assemble_session_tree("root", "Root", &adj, 8);
1508        assert_eq!(tree.session_id, "root");
1509        assert_eq!(tree.depth, 0);
1510        assert_eq!(tree.children.len(), 2);
1511        let c1 = tree.children.iter().find(|n| n.session_id == "c1").unwrap();
1512        assert_eq!(c1.depth, 1);
1513        assert_eq!(c1.children.len(), 1);
1514        assert_eq!(c1.children[0].session_id, "g1");
1515        assert_eq!(c1.children[0].depth, 2);
1516        let c2 = tree.children.iter().find(|n| n.session_id == "c2").unwrap();
1517        assert!(c2.children.is_empty());
1518    }
1519
1520    #[test]
1521    fn depth_cap_stops_descent() {
1522        let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
1523        adj.insert("root".into(), vec![entry("c1", "c1")]);
1524        adj.insert("c1".into(), vec![entry("g1", "g1")]);
1525        let tree = assemble_session_tree("root", "Root", &adj, 1);
1526        assert_eq!(tree.children.len(), 1);
1527        assert!(
1528            tree.children[0].children.is_empty(),
1529            "depth cap stops expansion at depth 1"
1530        );
1531    }
1532
1533    #[test]
1534    fn cycle_is_broken_by_first_visit_guard() {
1535        let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
1536        adj.insert("a".into(), vec![entry("b", "b")]);
1537        adj.insert("b".into(), vec![entry("a", "a")]); // cycle a → b → a
1538        let tree = assemble_session_tree("a", "A", &adj, 100);
1539        assert_eq!(tree.children.len(), 1);
1540        let b = &tree.children[0];
1541        assert_eq!(b.session_id, "b");
1542        assert_eq!(b.children.len(), 1);
1543        let a2 = &b.children[0];
1544        assert_eq!(a2.session_id, "a");
1545        assert!(a2.children.is_empty(), "cycle must terminate as a leaf");
1546    }
1547}
1548
1549#[cfg(test)]
1550mod update_model_tests {
1551    use super::apply_model_ref_override;
1552
1553    #[test]
1554    fn model_override_updates_all_session_model_authority() {
1555        let mut child = bamboo_domain::Session::new("child", "old-model");
1556        child.model_ref = Some(bamboo_domain::ProviderModelRef::new(
1557            "old-provider",
1558            "old-model",
1559        ));
1560        child.set_provider_name("old-provider");
1561
1562        apply_model_ref_override(
1563            &mut child,
1564            bamboo_domain::ProviderModelRef::new(" new-provider ", " new-model "),
1565        )
1566        .expect("valid model override");
1567
1568        assert_eq!(child.model, "new-model");
1569        assert_eq!(child.provider_name().as_deref(), Some("new-provider"));
1570        assert_eq!(
1571            child
1572                .model_ref
1573                .as_ref()
1574                .map(|model_ref| model_ref.to_pair()),
1575            Some(("new-provider", "new-model"))
1576        );
1577    }
1578
1579    #[test]
1580    fn model_override_rejects_empty_provider_or_model() {
1581        let mut child = bamboo_domain::Session::new("child", "old-model");
1582        assert!(apply_model_ref_override(
1583            &mut child,
1584            bamboo_domain::ProviderModelRef::new("", "new-model")
1585        )
1586        .is_err());
1587        assert!(apply_model_ref_override(
1588            &mut child,
1589            bamboo_domain::ProviderModelRef::new("provider", "  ")
1590        )
1591        .is_err());
1592    }
1593}
1594
1595#[cfg(test)]
1596mod send_message_tests {
1597    use super::should_route_child_message_through_inbox;
1598
1599    #[test]
1600    fn running_auto_run_interrupt_matrix_preserves_idle_draft_contract() {
1601        for (is_running, auto_run, interrupt_running, expected_inbox) in [
1602            (false, false, false, false),
1603            (false, false, true, false),
1604            (false, true, false, true),
1605            (false, true, true, true),
1606            (true, false, false, true),
1607            (true, false, true, false),
1608            (true, true, false, true),
1609            (true, true, true, true),
1610        ] {
1611            let effective_running = is_running && !interrupt_running;
1612            assert_eq!(
1613                should_route_child_message_through_inbox(effective_running, auto_run),
1614                expected_inbox,
1615                "is_running={is_running} auto_run={auto_run} interrupt_running={interrupt_running}"
1616            );
1617        }
1618    }
1619}