Skip to main content

bamboo_server/session_app/
child_session.rs

1//! Child session management use cases.
2//!
3//! Provides the application-layer logic for managing child sessions within
4//! a root session. The server layer implements `ChildSessionPort` to supply
5//! the infrastructure operations (load, save, schedule, cancel).
6
7use async_trait::async_trait;
8use bamboo_domain::Session;
9use chrono::Utc;
10use serde_json::json;
11
12// ---------------------------------------------------------------------------
13// Error type
14// ---------------------------------------------------------------------------
15
16#[derive(Debug, thiserror::Error)]
17pub enum ChildSessionError {
18    #[error("session not found: {0}")]
19    NotFound(String),
20    #[error("session is not a root session: {0}")]
21    NotRootSession(String),
22    #[error("session is not a child session: {0}")]
23    NotChildSession(String),
24    #[error("child session {child_id} does not belong to parent {parent_id}")]
25    NotChildOfParent { child_id: String, parent_id: String },
26    #[error("{0}")]
27    InvalidArguments(String),
28    #[error("{0}")]
29    Execution(String),
30}
31
32// ---------------------------------------------------------------------------
33// Value types
34// ---------------------------------------------------------------------------
35
36/// Summary of a child session for listing.
37#[derive(Debug, Clone)]
38pub struct ChildSessionEntry {
39    pub child_session_id: String,
40    pub title: String,
41    pub pinned: bool,
42    pub message_count: usize,
43    pub updated_at: String,
44    pub last_run_status: Option<String>,
45    pub last_run_error: Option<String>,
46}
47
48/// Result of deleting a child session.
49#[derive(Debug, Clone)]
50pub struct DeleteChildResult {
51    pub deleted: bool,
52    pub cancelled_running_child: bool,
53}
54
55/// Diagnostic snapshot of a running child session runner.
56#[derive(Debug, Clone)]
57pub struct ChildRunnerInfo {
58    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
59    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
60    pub last_tool_name: Option<String>,
61    pub last_tool_phase: Option<String>,
62    pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
63    pub round_count: u32,
64}
65
66/// Default system prompt for child sessions.
67pub const CHILD_SYSTEM_PROMPT: &str = r#"You are a **Child Session**, delegated by a parent session.
68
69Requirements:
70- Focus only on the assigned task and avoid unrelated conversation.
71- You may use tools to complete the task.
72- Do not create or trigger any additional child sessions (no recursive spawn).
73- Keep output concise: provide the conclusion first, then only necessary evidence or steps.
74"#;
75
76/// System prompt for plan-mode exploration child sessions.
77pub const PLAN_AGENT_SYSTEM_PROMPT: &str = r#"You are a **Plan Agent**, a read-only exploration specialist delegated by a parent session.
78
79Your role is EXCLUSIVELY to explore the codebase and gather information to help design an implementation plan. You MUST NOT modify anything.
80
81=== CRITICAL: READ-ONLY MODE — NO FILE MODIFICATIONS ===
82
83You are FORBIDDEN from using these tools:
84- Write — do not create new files
85- Edit — do not modify existing files
86- NotebookEdit — do not edit notebooks
87- Bash — do not execute shell commands
88- BashOutput — do not execute shell commands
89- KillShell — do not manage processes
90- SubSession — do not spawn further child sessions
91
92You MAY use these read-only tools:
93- Read — read file contents
94- Glob — list files matching patterns
95- Grep — search code for patterns
96- GetFileInfo — get file metadata
97- WebFetch — fetch web content
98- WebSearch — search the web
99- MemoryNote — write observations to session memory
100
101Requirements:
102- Focus only on the assigned exploration task.
103- Provide clear, structured findings: what you discovered, where the relevant code is, and what it does.
104- Keep output concise but thorough — the parent session needs enough detail to design a plan.
105- If you cannot find something after reasonable searching, say so clearly.
106"#;
107
108/// Input for creating a child session.
109#[derive(Debug, Clone)]
110pub struct CreateChildInput {
111    pub parent_session: Session,
112    pub child_id: String,
113    pub title: String,
114    pub responsibility: String,
115    pub assignment_prompt: String,
116    pub subagent_type: String,
117    /// Optional model override resolved from subagent_type routing.
118    /// When `None`, the child inherits the parent session's model.
119    pub model_override: Option<String>,
120    /// Optional provider+model override resolved from subagent routing.
121    /// When present, this preserves cross-provider routing for child execution.
122    pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
123    /// Runtime metadata resolved from subagent routing (e.g. external agent config).
124    pub runtime_metadata: std::collections::HashMap<String, String>,
125    /// Optional system prompt override resolved from the
126    /// `SubagentProfileRegistry`. When `None`, the child falls back to
127    /// the legacy hard-coded prompts (`PLAN_AGENT_SYSTEM_PROMPT` for
128    /// `subagent_type == "plan"`, `CHILD_SYSTEM_PROMPT` otherwise) so
129    /// that callers that have not yet been wired up keep their pre-PR-3
130    /// behaviour byte-for-byte.
131    pub system_prompt_override: Option<String>,
132    /// Whether to immediately enqueue the child for execution.
133    /// Defaults to `true`.
134    pub auto_run: bool,
135    /// Optional reasoning effort to apply to the child's own LLM calls.
136    /// `None` (the default) leaves `Session::reasoning_effort` at `None`,
137    /// so the provider falls back to its default. The child does NOT
138    /// inherit the parent's reasoning_effort — fan-out children that
139    /// only need a quick lookup should not pay for `xhigh` reasoning
140    /// just because the orchestrator is running at `xhigh`.
141    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
142}
143
144/// Result of creating a child session.
145#[derive(Debug, Clone)]
146pub struct CreateChildResult {
147    pub child_session_id: String,
148    pub model: String,
149}
150
151// ---------------------------------------------------------------------------
152// Port trait
153// ---------------------------------------------------------------------------
154
155#[async_trait]
156pub trait ChildSessionPort: Send + Sync {
157    async fn load_root_session(&self, root_id: &str) -> Result<Session, ChildSessionError>;
158    async fn load_child_for_parent(
159        &self,
160        parent_id: &str,
161        child_id: &str,
162    ) -> Result<Session, ChildSessionError>;
163    async fn save_child_session(&self, child: &Session) -> Result<(), ChildSessionError>;
164    async fn is_child_running(&self, child_id: &str) -> bool;
165    async fn list_children(&self, parent_id: &str) -> Vec<ChildSessionEntry>;
166    async fn enqueue_child_run(
167        &self,
168        parent: &Session,
169        child: &Session,
170    ) -> Result<(), ChildSessionError>;
171    async fn cancel_child_run_and_wait(&self, child_id: &str) -> Result<(), ChildSessionError>;
172    async fn delete_child_session(
173        &self,
174        parent_id: &str,
175        child_id: &str,
176    ) -> Result<DeleteChildResult, ChildSessionError>;
177    /// Return live diagnostic info for a running child session, if available.
178    async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo>;
179}
180
181// ---------------------------------------------------------------------------
182// Pure helpers
183// ---------------------------------------------------------------------------
184
185pub fn normalize_non_empty_optional(
186    value: Option<String>,
187    field_name: &str,
188) -> Result<Option<String>, ChildSessionError> {
189    let Some(value) = value else {
190        return Ok(None);
191    };
192    let trimmed = value.trim();
193    if trimmed.is_empty() {
194        return Err(ChildSessionError::InvalidArguments(format!(
195            "{field_name} must be non-empty"
196        )));
197    }
198    Ok(Some(trimmed.to_string()))
199}
200
201pub fn normalize_required_text(
202    value: Option<String>,
203    field_name: &str,
204) -> Result<String, ChildSessionError> {
205    let Some(value) = value else {
206        return Err(ChildSessionError::InvalidArguments(format!(
207            "{field_name} must be non-empty"
208        )));
209    };
210    let trimmed = value.trim();
211    if trimmed.is_empty() {
212        return Err(ChildSessionError::InvalidArguments(format!(
213            "{field_name} must be non-empty"
214        )));
215    }
216    Ok(trimmed.to_string())
217}
218
219/// Resolve the system prompt for a child session.
220///
221/// - When `override_prompt` is `Some`, that value is used verbatim. Callers
222///   resolve this from the [`SubagentProfileRegistry`] before invoking
223///   `create_child_action`.
224/// - When `override_prompt` is `None`, falls back to the legacy hard-coded
225///   prompts: [`PLAN_AGENT_SYSTEM_PROMPT`] when `subagent_type == "plan"`
226///   (case-insensitive, surrounding whitespace ignored), and
227///   [`CHILD_SYSTEM_PROMPT`] otherwise. This keeps unwired call paths
228///   byte-for-byte equivalent to pre-PR-3 behaviour.
229pub fn resolve_system_prompt<'a>(
230    subagent_type: &str,
231    override_prompt: Option<&'a str>,
232) -> std::borrow::Cow<'a, str> {
233    if let Some(prompt) = override_prompt {
234        std::borrow::Cow::Borrowed(prompt)
235    } else if subagent_type.trim().eq_ignore_ascii_case("plan") {
236        std::borrow::Cow::Borrowed(PLAN_AGENT_SYSTEM_PROMPT)
237    } else {
238        std::borrow::Cow::Borrowed(CHILD_SYSTEM_PROMPT)
239    }
240}
241
242pub fn metadata_text(session: &Session, key: &str) -> Option<String> {
243    session
244        .metadata
245        .get(key)
246        .map(|value| value.trim())
247        .filter(|value| !value.is_empty())
248        .map(str::to_string)
249}
250
251pub fn format_child_assignment(
252    title: &str,
253    responsibility: &str,
254    subagent_type: &str,
255    prompt: &str,
256) -> String {
257    format!(
258        "Sub-session title: {}\nResponsibility: {}\nSubagent type: {}\n\nTask brief:\n{}",
259        title, responsibility, subagent_type, prompt
260    )
261}
262
263pub fn replace_or_append_last_user_message(session: &mut Session, content: String) -> usize {
264    use bamboo_agent_core::Role;
265
266    if let Some(index) = session
267        .messages
268        .iter()
269        .rposition(|message| matches!(message.role, Role::User))
270    {
271        session.messages[index].content = content;
272        return index;
273    }
274
275    session.add_message(bamboo_agent_core::Message::user(content));
276    session.messages.len().saturating_sub(1)
277}
278
279pub fn truncate_after_index(session: &mut Session, keep_last_index: usize) -> usize {
280    let keep_len = keep_last_index.saturating_add(1);
281    let removed = session.messages.len().saturating_sub(keep_len);
282    if removed > 0 {
283        session.messages.truncate(keep_len);
284        session.token_usage = None;
285        session.conversation_summary = None;
286    }
287    removed
288}
289
290pub fn truncate_after_last_user(session: &mut Session) -> Result<usize, ChildSessionError> {
291    use bamboo_agent_core::Role;
292
293    let Some(last_user_idx) = session
294        .messages
295        .iter()
296        .rposition(|message| matches!(message.role, Role::User))
297    else {
298        return Err(ChildSessionError::Execution(
299            "No user message found to retry from".to_string(),
300        ));
301    };
302
303    Ok(truncate_after_index(session, last_user_idx))
304}
305
306pub fn map_child_entry(entry: &ChildSessionEntry) -> serde_json::Value {
307    json!({
308        "child_session_id": entry.child_session_id,
309        "title": entry.title,
310        "pinned": entry.pinned,
311        "message_count": entry.message_count,
312        "updated_at": entry.updated_at,
313        "last_run_status": entry.last_run_status,
314        "last_run_error": entry.last_run_error,
315    })
316}
317
318/// Generate contextual guidance for the root LLM based on child status and runner info.
319pub fn compute_status_guidance(
320    status: Option<&str>,
321    runner_info: Option<&ChildRunnerInfo>,
322    has_pending_messages: bool,
323) -> String {
324    match status {
325        Some("running") => {
326            let mut parts = vec!["Child is active.".to_string()];
327            if let Some(info) = runner_info {
328                if let Some(ref tool_name) = info.last_tool_name {
329                    if info.last_tool_phase.as_deref() == Some("begin") {
330                        parts.push(format!("Currently executing tool: {tool_name}. Wait for completion."));
331                    } else {
332                        parts.push(format!("Last tool: {tool_name} ({}).", info.last_tool_phase.as_deref().unwrap_or("unknown")));
333                    }
334                }
335                if let Some(last_event) = info.last_event_at {
336                    let elapsed = chrono::Utc::now().signed_duration_since(last_event);
337                    let secs = elapsed.num_seconds();
338                    if secs < 30 {
339                        parts.push("Progress event received very recently. Do not create a replacement; wait 30-60s.".to_string());
340                    } else if secs > 120 {
341                        parts.push("No progress event for 120s. Consider send_message or cancel if stalled.".to_string());
342                    }
343                }
344            }
345            if has_pending_messages {
346                parts.push("A follow-up message is already queued and will be picked up at the next turn boundary.".to_string());
347            } else {
348                parts.push("Use send_message with interrupt_running=false to queue a follow-up, or interrupt_running=true to cancel and restart.".to_string());
349            }
350            parts.join(" ")
351        }
352        Some("error") => "Child failed. Use send_message with corrected instructions to retry in place, or create a new child only if the approach needs to change completely.".to_string(),
353        Some("completed") => "Child finished. Use get to read results, or send_message for follow-up work.".to_string(),
354        Some("pending") => "Child is waiting to run. Use action=run to start execution.".to_string(),
355        Some("cancelled") => "Child was cancelled. Use send_message to resume, or action=run to restart.".to_string(),
356        Some("skipped") => "Child had no pending message. Use send_message to add work, then action=run.".to_string(),
357        _ => "Use action=get to inspect progress, send_message to redirect, or create only if a new delegation is needed.".to_string(),
358    }
359}
360
361// ---------------------------------------------------------------------------
362// Action functions
363// ---------------------------------------------------------------------------
364
365pub async fn create_child_action(
366    port: &dyn ChildSessionPort,
367    input: CreateChildInput,
368) -> Result<CreateChildResult, ChildSessionError> {
369    use bamboo_agent_core::Message;
370    use bamboo_engine::runner::refresh_prompt_snapshot;
371
372    let mut child = Session::new_child(
373        input.child_id.clone(),
374        input.parent_session.id.clone(),
375        input
376            .model_ref_override
377            .as_ref()
378            .map(|model_ref| model_ref.model.clone())
379            .or_else(|| input.model_override.clone())
380            .unwrap_or_else(|| input.parent_session.model.clone()),
381        input.title.clone(),
382    );
383
384    if let Some(model_ref) = input.model_ref_override.clone() {
385        child.model_ref = Some(model_ref.clone());
386        child
387            .metadata
388            .insert("provider_name".to_string(), model_ref.provider);
389    } else if let Some(parent_model_ref) = input.parent_session.model_ref.clone() {
390        child.model_ref = Some(parent_model_ref.clone());
391        child
392            .metadata
393            .insert("provider_name".to_string(), parent_model_ref.provider);
394    } else if let Some(parent_provider) =
395        input.parent_session.metadata.get("provider_name").cloned()
396    {
397        child
398            .metadata
399            .insert("provider_name".to_string(), parent_provider);
400    }
401
402    // Apply explicit reasoning_effort override if the LLM passed one;
403    // otherwise leave at `None` (provider default). Per CreateChildInput
404    // contract, children do NOT inherit the parent's reasoning_effort.
405    if let Some(effort) = input.reasoning_effort {
406        child.reasoning_effort = Some(effort);
407    }
408
409    child
410        .metadata
411        .insert("spawned_by".to_string(), "SubSession".to_string());
412    child
413        .metadata
414        .insert("subagent_type".to_string(), input.subagent_type.clone());
415    child
416        .metadata
417        .insert("responsibility".to_string(), input.responsibility.clone());
418    child.metadata.insert(
419        "assignment_prompt".to_string(),
420        input.assignment_prompt.clone(),
421    );
422    child
423        .metadata
424        .insert("last_run_status".to_string(), "pending".to_string());
425    child.metadata.remove("last_run_error");
426
427    // Apply runtime metadata (e.g. external agent routing).
428    for (key, value) in input.runtime_metadata {
429        child.metadata.insert(key, value);
430    }
431
432    let system_prompt = resolve_system_prompt(
433        &input.subagent_type,
434        input.system_prompt_override.as_deref(),
435    );
436
437    child.metadata.insert(
438        "base_system_prompt".to_string(),
439        system_prompt.clone().into_owned(),
440    );
441
442    child.add_message(Message::system(system_prompt.as_ref()));
443
444    // Child sessions get more aggressive compression: trigger at 70% instead
445    // of the default 85%, target 35% instead of 40%. This prevents long child
446    // tasks from exhausting the context window before the parent can intervene.
447    if let Some(ref parent_budget) = input.parent_session.token_budget {
448        let mut child_budget = parent_budget.clone();
449        child_budget.compression_trigger_percent = 70;
450        child_budget.compression_target_percent = 35;
451        child.token_budget = Some(child_budget);
452    }
453
454    refresh_prompt_snapshot(&mut child);
455    let assignment = format_child_assignment(
456        &input.title,
457        &input.responsibility,
458        &input.subagent_type,
459        &input.assignment_prompt,
460    );
461    child.add_message(Message::user(assignment));
462
463    if let Some(parent_task_list) = input.parent_session.task_list.clone() {
464        child.set_task_list(parent_task_list);
465    }
466
467    let model = child.model.clone();
468    port.save_child_session(&child).await?;
469    if input.auto_run {
470        port.enqueue_child_run(&input.parent_session, &child)
471            .await?;
472    }
473
474    Ok(CreateChildResult {
475        child_session_id: child.id,
476        model,
477    })
478}
479
480pub async fn list_children_action(
481    port: &dyn ChildSessionPort,
482    parent_id: &str,
483) -> serde_json::Value {
484    let children = port.list_children(parent_id).await;
485    json!({
486        "parent_session_id": parent_id,
487        "children": children.iter().map(map_child_entry).collect::<Vec<_>>(),
488        "count": children.len(),
489    })
490}
491
492pub async fn get_child_action(
493    port: &dyn ChildSessionPort,
494    parent_id: &str,
495    child_session_id: String,
496) -> Result<serde_json::Value, ChildSessionError> {
497    let child = port
498        .load_child_for_parent(parent_id, &child_session_id)
499        .await?;
500
501    let status = metadata_text(&child, "last_run_status");
502    let runner_info = port.get_child_runner_info(&child.id).await;
503
504    Ok(json!({
505        "child_session_id": child.id,
506        "title": child.title,
507        "model": child.model,
508        "pinned": child.pinned,
509        "message_count": child.messages.len(),
510        "is_running": port.is_child_running(&child.id).await,
511        "last_run_status": status,
512        "last_run_error": metadata_text(&child, "last_run_error"),
513        "responsibility": metadata_text(&child, "responsibility"),
514        "subagent_type": metadata_text(&child, "subagent_type"),
515        "prompt": metadata_text(&child, "assignment_prompt"),
516        "latest_user_message": child
517            .messages
518            .iter()
519            .rposition(|message| matches!(message.role, bamboo_agent_core::Role::User))
520            .and_then(|idx| child.messages.get(idx))
521            .map(|message| message.content.clone()),
522        "runtime_kind": metadata_text(&child, "runtime.kind"),
523        "external_protocol": metadata_text(&child, "external.protocol"),
524        "external_agent_id": metadata_text(&child, "external.agent_id"),
525        "a2a_context_id": metadata_text(&child, "a2a.context_id"),
526        "a2a_latest_task_id": metadata_text(&child, "a2a.latest_task_id"),
527        "a2a_last_state": metadata_text(&child, "a2a.last_state"),
528        "runner_started_at": runner_info.as_ref().and_then(|r| r.started_at.map(|t| t.to_rfc3339())),
529        "runner_completed_at": runner_info.as_ref().and_then(|r| r.completed_at.map(|t| t.to_rfc3339())),
530        "last_tool_name": runner_info.as_ref().and_then(|r| r.last_tool_name.clone()),
531        "last_tool_phase": runner_info.as_ref().and_then(|r| r.last_tool_phase.clone()),
532        "last_event_at": runner_info.as_ref().and_then(|r| r.last_event_at.map(|t| t.to_rfc3339())),
533        "round_count": runner_info.as_ref().map(|r| r.round_count).unwrap_or(0),
534        "has_pending_injected_messages": child.metadata.contains_key("pending_injected_messages"),
535        "guidance": compute_status_guidance(status.as_deref(), runner_info.as_ref(), child.metadata.contains_key("pending_injected_messages")),
536    }))
537}
538
539#[allow(clippy::too_many_arguments)]
540pub async fn update_child_action(
541    port: &dyn ChildSessionPort,
542    parent_id: &str,
543    child_session_id: String,
544    title: Option<String>,
545    responsibility: Option<String>,
546    prompt: Option<String>,
547    subagent_type: Option<String>,
548    reset_after_update: Option<bool>,
549    reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
550) -> Result<serde_json::Value, ChildSessionError> {
551    let mut child = port
552        .load_child_for_parent(parent_id, &child_session_id)
553        .await?;
554
555    let title = normalize_non_empty_optional(title, "title")?;
556    let responsibility = normalize_non_empty_optional(responsibility, "responsibility")?;
557    let prompt = normalize_non_empty_optional(prompt, "prompt")?;
558    let subagent_type = normalize_non_empty_optional(subagent_type, "subagent_type")?;
559
560    let should_refresh_assignment =
561        responsibility.is_some() || prompt.is_some() || subagent_type.is_some();
562
563    if title.is_none() && !should_refresh_assignment && reasoning_effort.is_none() {
564        return Err(ChildSessionError::InvalidArguments(
565            "update requires at least one field: title/responsibility/prompt/subagent_type/reasoning_effort"
566                .to_string(),
567        ));
568    }
569
570    if let Some(effort) = reasoning_effort {
571        child.reasoning_effort = Some(effort);
572    }
573
574    if let Some(title) = title {
575        child.title = title;
576    }
577
578    let mut messages_removed = 0usize;
579
580    if should_refresh_assignment {
581        let effective_responsibility = normalize_required_text(
582            responsibility.or_else(|| metadata_text(&child, "responsibility")),
583            "responsibility",
584        )?;
585        let effective_subagent_type = normalize_required_text(
586            subagent_type.or_else(|| metadata_text(&child, "subagent_type")),
587            "subagent_type",
588        )?;
589        let effective_prompt = normalize_required_text(
590            prompt.or_else(|| metadata_text(&child, "assignment_prompt")),
591            "prompt",
592        )?;
593
594        child.metadata.insert(
595            "responsibility".to_string(),
596            effective_responsibility.clone(),
597        );
598        child
599            .metadata
600            .insert("subagent_type".to_string(), effective_subagent_type.clone());
601        child
602            .metadata
603            .insert("assignment_prompt".to_string(), effective_prompt.clone());
604        child
605            .metadata
606            .insert("last_run_status".to_string(), "pending".to_string());
607        child.metadata.remove("last_run_error");
608
609        let assignment = format_child_assignment(
610            &child.title,
611            &effective_responsibility,
612            &effective_subagent_type,
613            &effective_prompt,
614        );
615        let user_index = replace_or_append_last_user_message(&mut child, assignment);
616
617        if reset_after_update.unwrap_or(true) {
618            messages_removed = truncate_after_index(&mut child, user_index);
619        }
620    }
621
622    child.updated_at = Utc::now();
623    port.save_child_session(&child).await?;
624
625    Ok(json!({
626        "child_session_id": child.id,
627        "title": child.title,
628        "messages_removed": messages_removed,
629        "last_run_status": metadata_text(&child, "last_run_status"),
630        "note": "Child session updated in place. Use action=run to execute the same child session.",
631    }))
632}
633
634pub async fn run_child_action(
635    port: &dyn ChildSessionPort,
636    parent: &Session,
637    child_session_id: String,
638    reset_to_last_user: Option<bool>,
639) -> Result<serde_json::Value, ChildSessionError> {
640    let mut child = port
641        .load_child_for_parent(&parent.id, &child_session_id)
642        .await?;
643
644    if port.is_child_running(&child.id).await {
645        return Ok(json!({
646            "child_session_id": child.id,
647            "status": "already_running",
648            "note": "Child session is already running.",
649        }));
650    }
651
652    let mut messages_removed = 0usize;
653    if reset_to_last_user.unwrap_or(true) {
654        messages_removed = truncate_after_last_user(&mut child)?;
655    }
656
657    child
658        .metadata
659        .insert("last_run_status".to_string(), "pending".to_string());
660    child.metadata.remove("last_run_error");
661    child.updated_at = Utc::now();
662    port.save_child_session(&child).await?;
663
664    port.enqueue_child_run(parent, &child).await?;
665
666    Ok(json!({
667        "child_session_id": child.id,
668        "status": "queued",
669        "messages_removed": messages_removed,
670        "note": "Queued existing child session for retry in place.",
671    }))
672}
673
674/// A queued follow-up message stored in session metadata for later injection.
675#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
676pub struct QueuedInjectedMessage {
677    pub content: String,
678    #[serde(default)]
679    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
680}
681
682pub async fn send_message_to_child_action(
683    port: &dyn ChildSessionPort,
684    parent: &Session,
685    child_session_id: String,
686    message: String,
687    auto_run: Option<bool>,
688    interrupt_running: Option<bool>,
689) -> Result<serde_json::Value, ChildSessionError> {
690    let mut child = port
691        .load_child_for_parent(&parent.id, &child_session_id)
692        .await?;
693
694    let is_running = port.is_child_running(&child.id).await;
695    let should_interrupt = interrupt_running.unwrap_or(false);
696
697    if is_running && should_interrupt {
698        port.cancel_child_run_and_wait(&child.id).await?;
699        child = port
700            .load_child_for_parent(&parent.id, &child_session_id)
701            .await?;
702    }
703
704    let message = normalize_required_text(Some(message), "message")?;
705
706    if is_running && !should_interrupt {
707        // Store the message in session metadata so the running agent loop
708        // can merge it at the next turn boundary without canceling progress.
709        let mut pending: Vec<QueuedInjectedMessage> = child
710            .metadata
711            .get("pending_injected_messages")
712            .and_then(|raw| serde_json::from_str(raw).ok())
713            .unwrap_or_default();
714        pending.push(QueuedInjectedMessage {
715            content: message.clone(),
716            created_at: Some(chrono::Utc::now()),
717        });
718        child.metadata.insert(
719            "pending_injected_messages".to_string(),
720            serde_json::to_string(&pending).unwrap_or_default(),
721        );
722        port.save_child_session(&child).await?;
723        return Ok(json!({
724            "child_session_id": child.id,
725            "status": "message_queued",
726            "auto_run": false,
727            "message": message,
728            "message_count": child.messages.len(),
729            "note": "Message queued for the child session. It will be picked up at the next turn boundary without canceling current progress.",
730        }));
731    }
732
733    child.add_message(bamboo_agent_core::Message::user(message.clone()));
734    child
735        .metadata
736        .insert("last_run_status".to_string(), "pending".to_string());
737    child.metadata.remove("last_run_error");
738    port.save_child_session(&child).await?;
739
740    let should_auto_run = auto_run.unwrap_or(true);
741    if should_auto_run {
742        port.enqueue_child_run(parent, &child).await?;
743    }
744
745    Ok(json!({
746        "child_session_id": child.id,
747        "status": if should_auto_run { "queued" } else { "pending" },
748        "auto_run": should_auto_run,
749        "message": message,
750        "message_count": child.messages.len(),
751        "note": if should_auto_run {
752            "Follow-up message appended and child session queued."
753        } else {
754            "Follow-up message appended. Use action=run to execute the child session."
755        },
756    }))
757}
758
759pub async fn cancel_child_action(
760    port: &dyn ChildSessionPort,
761    parent_id: &str,
762    child_session_id: String,
763) -> Result<serde_json::Value, ChildSessionError> {
764    let mut child = port
765        .load_child_for_parent(parent_id, &child_session_id)
766        .await?;
767    port.cancel_child_run_and_wait(&child_session_id).await?;
768    child
769        .metadata
770        .insert("last_run_status".to_string(), "cancelled".to_string());
771    child.metadata.insert(
772        "last_run_error".to_string(),
773        "Cancelled by parent".to_string(),
774    );
775    port.save_child_session(&child).await?;
776    Ok(json!({
777        "child_session_id": child_session_id,
778        "status": "cancelled",
779    }))
780}
781
782pub async fn delete_child_action(
783    port: &dyn ChildSessionPort,
784    parent_id: &str,
785    child_session_id: String,
786) -> Result<serde_json::Value, ChildSessionError> {
787    // Load child first to get its ID (port.delete_child_session handles cancellation + cleanup)
788    let child = port
789        .load_child_for_parent(parent_id, &child_session_id)
790        .await?;
791    let result = port.delete_child_session(parent_id, &child.id).await?;
792
793    if !result.deleted {
794        return Err(ChildSessionError::Execution(format!(
795            "child session was not deleted: {}",
796            child.id
797        )));
798    }
799
800    Ok(json!({
801        "child_session_id": child.id,
802        "deleted": true,
803        "cancelled_running_child": result.cancelled_running_child,
804    }))
805}
806
807// ---------------------------------------------------------------------------
808// Tests
809// ---------------------------------------------------------------------------
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    #[test]
816    fn truncate_after_last_user_removes_assistant_tail() {
817        let mut session = Session::new_child("child", "root", "test-model", "Child");
818        session.add_message(bamboo_agent_core::Message::system("system"));
819        session.add_message(bamboo_agent_core::Message::user("task"));
820        session.add_message(bamboo_agent_core::Message::assistant("done", None));
821
822        let removed = truncate_after_last_user(&mut session).expect("truncate should work");
823
824        assert_eq!(removed, 1);
825        assert_eq!(session.messages.len(), 2);
826        assert!(matches!(
827            session.messages[1].role,
828            bamboo_agent_core::Role::User
829        ));
830    }
831
832    #[test]
833    fn replace_or_append_last_user_message_replaces_existing() {
834        let mut session = Session::new_child("child", "root", "test-model", "Child");
835        session.add_message(bamboo_agent_core::Message::user("old"));
836        session.add_message(bamboo_agent_core::Message::assistant("tail", None));
837
838        let idx = replace_or_append_last_user_message(&mut session, "new".to_string());
839
840        assert_eq!(idx, 0);
841        assert_eq!(session.messages[0].content, "new");
842        assert_eq!(session.messages.len(), 2);
843    }
844
845    #[test]
846    fn normalize_non_empty_optional_rejects_blank_strings() {
847        let err = normalize_non_empty_optional(Some("  ".to_string()), "prompt")
848            .expect_err("blank should be rejected");
849        assert!(matches!(err, ChildSessionError::InvalidArguments(msg) if msg.contains("prompt")));
850    }
851
852    #[test]
853    fn format_child_assignment_builds_expected_string() {
854        let result = format_child_assignment("Title", "Responsibility", "Type", "Task brief");
855        assert!(result.contains("Title"));
856        assert!(result.contains("Responsibility"));
857        assert!(result.contains("Type"));
858        assert!(result.contains("Task brief"));
859    }
860
861    // ----- resolve_system_prompt: PR-3 prompt routing contract --------------
862
863    #[test]
864    fn resolve_system_prompt_uses_override_verbatim() {
865        let custom = "You are a custom subagent.";
866        let prompt = resolve_system_prompt("anything", Some(custom));
867        assert_eq!(prompt.as_ref(), custom);
868    }
869
870    #[test]
871    fn resolve_system_prompt_uses_override_even_when_subagent_type_is_plan() {
872        // Override beats legacy plan routing: this is what lets the registry
873        // actually swap the plan agent's prompt.
874        let custom = "Plan override";
875        let prompt = resolve_system_prompt("plan", Some(custom));
876        assert_eq!(prompt.as_ref(), custom);
877    }
878
879    #[test]
880    fn resolve_system_prompt_falls_back_to_plan_for_plan_subagent_type() {
881        let prompt = resolve_system_prompt("plan", None);
882        assert_eq!(prompt.as_ref(), PLAN_AGENT_SYSTEM_PROMPT);
883    }
884
885    #[test]
886    fn resolve_system_prompt_plan_match_is_case_and_whitespace_insensitive() {
887        let prompt = resolve_system_prompt("  PLAN  ", None);
888        assert_eq!(prompt.as_ref(), PLAN_AGENT_SYSTEM_PROMPT);
889    }
890
891    #[test]
892    fn resolve_system_prompt_falls_back_to_general_for_unknown_subagent_type() {
893        let prompt = resolve_system_prompt("researcher", None);
894        assert_eq!(prompt.as_ref(), CHILD_SYSTEM_PROMPT);
895    }
896
897    #[test]
898    fn resolve_system_prompt_falls_back_to_general_for_empty_subagent_type() {
899        let prompt = resolve_system_prompt("", None);
900        assert_eq!(prompt.as_ref(), CHILD_SYSTEM_PROMPT);
901    }
902}