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