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- 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#[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 workspace: String,
119 pub model_override: Option<String>,
122 pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
125 pub runtime_metadata: std::collections::HashMap<String, String>,
127 pub system_prompt_override: Option<String>,
134 pub auto_run: bool,
137 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
144}
145
146#[derive(Debug, Clone)]
148pub struct CreateChildResult {
149 pub child_session_id: String,
150 pub model: String,
151}
152
153#[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 async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo>;
181}
182
183pub 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
221pub 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
320pub 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
363pub 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 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 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 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#[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 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 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#[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 #[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 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}