1use async_trait::async_trait;
8use bamboo_domain::Session;
9use chrono::Utc;
10use serde_json::json;
11
12#[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#[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#[derive(Debug, Clone)]
50pub struct DeleteChildResult {
51 pub deleted: bool,
52 pub cancelled_running_child: bool,
53}
54
55#[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
66pub 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
76pub 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#[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 pub model_override: Option<String>,
120 pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
123 pub runtime_metadata: std::collections::HashMap<String, String>,
125 pub system_prompt_override: Option<String>,
132 pub auto_run: bool,
135 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
142}
143
144#[derive(Debug, Clone)]
146pub struct CreateChildResult {
147 pub child_session_id: String,
148 pub model: String,
149}
150
151#[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 async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo>;
179}
180
181pub 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
219pub 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
318pub 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
361pub 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 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 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 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#[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 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 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#[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 #[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 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}