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