1use bamboo_domain::Session;
4use chrono::Utc;
5use serde_json::json;
6
7use super::helpers::{
8 compute_status_guidance, format_child_assignment, map_child_entry, metadata_text,
9 normalize_non_empty_optional, normalize_required_text, render_forked_parent_context,
10 replace_or_append_last_user_message, truncate_after_index, truncate_after_last_user,
11};
12use super::DELEGATION_NOTE;
13use super::{
14 ChildSessionEntry, ChildSessionError, ChildSessionPort, CreateChildInput, CreateChildResult,
15};
16
17pub async fn create_child_action(
18 port: &dyn ChildSessionPort,
19 input: CreateChildInput,
20) -> Result<CreateChildResult, ChildSessionError> {
21 use crate::runner::refresh_prompt_snapshot;
22 use bamboo_agent_core::Message;
23
24 let inherited_project_id =
25 match crate::project_context::ProjectContextResolver::session_project_identity(
26 &input.parent_session,
27 ) {
28 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
29 Some(project_id)
30 }
31 crate::project_context::SessionProjectIdentity::Unassigned => None,
32 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
33 return Err(ChildSessionError::InvalidArguments(format!(
34 "parent session carries an invalid Project identity '{raw}': {message}"
35 )));
36 }
37 };
38 let final_workspace = port
39 .validate_child_workspace(inherited_project_id.as_ref(), &input.workspace)
40 .await?;
41
42 let mut child = Session::new_child_of(
47 input.child_id.clone(),
48 &input.parent_session,
49 input
50 .model_ref_override
51 .as_ref()
52 .map(|model_ref| model_ref.model.clone())
53 .or_else(|| input.model_override.clone())
54 .unwrap_or_else(|| input.parent_session.model.clone()),
55 input.title.clone(),
56 );
57 if let Some(project_id) = inherited_project_id {
60 child.set_project_id_meta(project_id.to_string());
61 }
62
63 if let Some(model_ref) = input.model_ref_override.clone() {
64 child.model_ref = Some(model_ref.clone());
65 child
66 .metadata
67 .insert("provider_name".to_string(), model_ref.provider);
68 } else if let Some(parent_model_ref) = input.parent_session.model_ref.clone() {
69 child.model_ref = Some(parent_model_ref.clone());
70 child.set_provider_name(parent_model_ref.provider);
71 } else if let Some(parent_provider) = input.parent_session.provider_name() {
72 child.set_provider_name(parent_provider);
73 }
74
75 if let Some(effort) = input.reasoning_effort {
79 child.reasoning_effort = Some(effort);
80 }
81
82 let inherited_permission_mode = input
86 .parent_session
87 .agent_runtime_state
88 .as_ref()
89 .map(|state| state.effective_permission_mode())
90 .unwrap_or_default();
91 child
92 .agent_runtime_state
93 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
94 .set_permission_mode(inherited_permission_mode);
95 let parent_audit =
96 bamboo_domain::PermissionAuditSnapshot::from_metadata(&input.parent_session.metadata);
97 let parent_plan_active = input
98 .parent_session
99 .agent_runtime_state
100 .as_ref()
101 .is_some_and(|state| state.plan_mode.is_some());
102 let effective = if parent_plan_active {
103 bamboo_domain::PermissionMode::Plan
104 } else {
105 parent_audit
106 .as_ref()
107 .filter(|audit| {
108 audit.resolution.requested == inherited_permission_mode
109 && audit.resolution.is_consistent()
110 })
111 .map(|audit| audit.resolution.effective)
112 .unwrap_or_else(|| {
113 bamboo_domain::resolve_permission_mode(
114 inherited_permission_mode,
115 bamboo_domain::PermissionMode::Default,
116 )
117 .effective
118 })
119 };
120 let resolution = bamboo_domain::PermissionModeResolution {
121 requested: inherited_permission_mode,
122 effective,
123 };
124 bamboo_domain::record_permission_audit(
125 &mut child.metadata,
126 &bamboo_domain::PermissionAuditSeed::new(
127 parent_audit
128 .as_ref()
129 .map(|audit| audit.policy_revision)
130 .unwrap_or_default(),
131 resolution,
132 format!("child_activation:{}", resolution.effective.as_str()),
133 ),
134 Some(&Utc::now().to_rfc3339()),
135 )
136 .map_err(|error| ChildSessionError::Execution(error.to_string()))?;
137
138 if input
143 .parent_session
144 .agent_runtime_state
145 .as_ref()
146 .is_some_and(|state| state.no_human_approver)
147 {
148 child
149 .agent_runtime_state
150 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
151 .no_human_approver = true;
152 }
153
154 let stored_workspace = port.publish_child_workspace(
160 &child.id,
161 std::path::PathBuf::from(final_workspace),
162 input.workspace_source.as_str(),
163 );
164 child.workspace = Some(stored_workspace.to_string_lossy().to_string());
165 child.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
166 &stored_workspace,
167 ));
168 child.metadata.insert(
169 crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
170 input.workspace_source.as_str().to_string(),
171 );
172
173 child
174 .metadata
175 .insert("spawned_by".to_string(), "SubAgent".to_string());
176 child.set_subagent_type(input.subagent_type.clone());
177 child
178 .metadata
179 .insert("responsibility".to_string(), input.responsibility.clone());
180 child.metadata.insert(
181 "assignment_prompt".to_string(),
182 input.assignment_prompt.clone(),
183 );
184 if input.lifecycle.as_deref() == Some("resident") {
189 child
190 .metadata
191 .insert("lifecycle".to_string(), "resident".to_string());
192 if let Some(name) = input.resident_name.clone().filter(|n| !n.trim().is_empty()) {
193 child.metadata.insert("resident_name".to_string(), name);
194 }
195 child.metadata.insert(
196 "resident_context".to_string(),
197 input
198 .resident_context
199 .clone()
200 .filter(|c| matches!(c.as_str(), "reset" | "accumulate"))
201 .unwrap_or_else(|| "reset".to_string()),
202 );
203 }
204 child.set_last_run_status("pending");
205 child.clear_last_run_error();
206
207 for (key, value) in input.runtime_metadata {
209 child.metadata.insert(key, value);
210 }
211
212 let base_prompt = {
219 let global = crate::prompt_defaults::read_global_default_system_prompt_template();
220 if global.trim().is_empty() {
221 crate::context::DEFAULT_BASE_PROMPT.to_string()
222 } else {
223 global
224 }
225 };
226 let system_prompt = format!("{base_prompt}\n\n{DELEGATION_NOTE}");
227
228 child
229 .metadata
230 .insert("base_system_prompt".to_string(), system_prompt.clone());
231
232 child.add_message(Message::system(&system_prompt));
233
234 if let Some(parent_budget) = input.parent_session.effective_token_budget() {
238 let mut child_budget = parent_budget.clone();
239 child_budget.compression_trigger_percent = 70;
240 child_budget.compression_target_percent = 35;
241 child.token_budget = Some(child_budget);
242 }
243
244 refresh_prompt_snapshot(&mut child);
245 let assignment = format_child_assignment(
246 &input.title,
247 &input.responsibility,
248 &input.subagent_type,
249 &input.assignment_prompt,
250 );
251 let assignment = match input
255 .context_fork
256 .and_then(|n| render_forked_parent_context(&input.parent_session, n))
257 {
258 Some(forked) => format!("{forked}\n\n{assignment}"),
259 None => assignment,
260 };
261 child.add_message(Message::user(assignment));
262
263 if let Some(parent_task_list) = input.parent_session.task_list.clone() {
264 child.set_task_list(parent_task_list);
265 }
266
267 if let Some(ref disabled) = input.disabled_tools {
271 if !disabled.is_empty() {
272 child.metadata.insert(
273 "disabled_tools".to_string(),
274 serde_json::to_string(disabled).unwrap_or_default(),
275 );
276 }
277 }
278
279 let model = child.model.clone();
280 port.save_child_session(&mut child).await?;
281 if input.auto_run {
282 port.enqueue_child_run(&input.parent_session, &child)
283 .await?;
284 }
285
286 Ok(CreateChildResult {
287 child_session_id: child.id,
288 model,
289 })
290}
291
292pub async fn list_children_action(
293 port: &dyn ChildSessionPort,
294 parent_id: &str,
295) -> serde_json::Value {
296 let children = port.list_children(parent_id).await;
297 json!({
298 "parent_session_id": parent_id,
299 "children": children.iter().map(map_child_entry).collect::<Vec<_>>(),
300 "count": children.len(),
301 })
302}
303
304#[derive(Debug, Clone, PartialEq, serde::Serialize)]
307pub struct SessionTreeNode {
308 pub session_id: String,
309 pub title: String,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub last_run_status: Option<String>,
312 pub depth: u32,
313 pub children: Vec<SessionTreeNode>,
314}
315
316pub fn assemble_session_tree(
321 root_id: &str,
322 root_title: &str,
323 adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
324 max_depth: u32,
325) -> SessionTreeNode {
326 fn build(
327 id: &str,
328 title: &str,
329 status: Option<String>,
330 depth: u32,
331 max_depth: u32,
332 adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
333 visited: &mut std::collections::HashSet<String>,
334 ) -> SessionTreeNode {
335 let first_visit = visited.insert(id.to_string());
336 let mut children = Vec::new();
337 if first_visit && depth < max_depth {
338 if let Some(kids) = adjacency.get(id) {
339 for kid in kids {
340 children.push(build(
341 &kid.child_session_id,
342 &kid.title,
343 kid.last_run_status.clone(),
344 depth + 1,
345 max_depth,
346 adjacency,
347 visited,
348 ));
349 }
350 }
351 }
352 SessionTreeNode {
353 session_id: id.to_string(),
354 title: title.to_string(),
355 last_run_status: status,
356 depth,
357 children,
358 }
359 }
360 let mut visited = std::collections::HashSet::new();
361 build(
362 root_id,
363 root_title,
364 None,
365 0,
366 max_depth,
367 adjacency,
368 &mut visited,
369 )
370}
371
372pub async fn build_session_tree_action(
378 port: &dyn ChildSessionPort,
379 root_id: &str,
380 max_depth: u32,
381) -> SessionTreeNode {
382 use std::collections::{HashMap, HashSet, VecDeque};
383 const NODE_CAP: usize = 5000;
384
385 let root_title = port
386 .load_root_session(root_id)
387 .await
388 .map(|s| s.title)
389 .unwrap_or_default();
390
391 let mut adjacency: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
392 let mut visited: HashSet<String> = HashSet::new();
393 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
394 queue.push_back((root_id.to_string(), 0));
395
396 while let Some((id, depth)) = queue.pop_front() {
397 if depth >= max_depth || adjacency.len() >= NODE_CAP || !visited.insert(id.clone()) {
398 continue;
399 }
400 let kids = port.list_children(&id).await;
401 for kid in &kids {
402 queue.push_back((kid.child_session_id.clone(), depth + 1));
403 }
404 adjacency.insert(id, kids);
405 }
406
407 assemble_session_tree(root_id, &root_title, &adjacency, max_depth)
408}
409
410pub async fn get_child_action(
411 port: &dyn ChildSessionPort,
412 parent_id: &str,
413 child_session_id: String,
414) -> Result<serde_json::Value, ChildSessionError> {
415 let child = port
416 .load_child_for_parent(parent_id, &child_session_id)
417 .await?;
418
419 let status = metadata_text(&child, "last_run_status");
420 let runner_info = port.get_child_runner_info(&child.id).await;
421
422 Ok(json!({
423 "child_session_id": child.id,
424 "title": child.title,
425 "model": child.model,
426 "pinned": child.pinned,
427 "message_count": child.messages.len(),
428 "is_running": port.is_child_running(&child.id).await,
429 "last_run_status": status,
430 "last_run_error": metadata_text(&child, "last_run_error"),
431 "responsibility": metadata_text(&child, "responsibility"),
432 "subagent_type": metadata_text(&child, "subagent_type"),
433 "prompt": metadata_text(&child, "assignment_prompt"),
434 "latest_user_message": child
435 .messages
436 .iter()
437 .rposition(|message| matches!(message.role, bamboo_agent_core::Role::User))
438 .and_then(|idx| child.messages.get(idx))
439 .map(|message| message.content.clone()),
440 "runtime_kind": metadata_text(&child, "runtime.kind"),
441 "external_protocol": metadata_text(&child, "external.protocol"),
442 "external_agent_id": metadata_text(&child, "external.agent_id"),
443 "a2a_context_id": metadata_text(&child, "a2a.context_id"),
444 "a2a_latest_task_id": metadata_text(&child, "a2a.latest_task_id"),
445 "a2a_last_state": metadata_text(&child, "a2a.last_state"),
446 "runner_started_at": runner_info.as_ref().and_then(|r| r.started_at.map(|t| t.to_rfc3339())),
447 "runner_completed_at": runner_info.as_ref().and_then(|r| r.completed_at.map(|t| t.to_rfc3339())),
448 "last_tool_name": runner_info.as_ref().and_then(|r| r.last_tool_name.clone()),
449 "last_tool_phase": runner_info.as_ref().and_then(|r| r.last_tool_phase.clone()),
450 "last_event_at": runner_info.as_ref().and_then(|r| r.last_event_at.map(|t| t.to_rfc3339())),
451 "round_count": runner_info.as_ref().map(|r| r.round_count).unwrap_or(0),
452 "has_pending_injected_messages": child.has_pending_injected_messages(),
453 "guidance": compute_status_guidance(status.as_deref(), runner_info.as_ref(), child.has_pending_injected_messages()),
454 }))
455}
456
457#[allow(clippy::too_many_arguments)]
458pub async fn update_child_action(
459 port: &dyn ChildSessionPort,
460 parent_id: &str,
461 child_session_id: String,
462 title: Option<String>,
463 responsibility: Option<String>,
464 prompt: Option<String>,
465 subagent_type: Option<String>,
466 reset_after_update: Option<bool>,
467 reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
468) -> Result<serde_json::Value, ChildSessionError> {
469 let mut child = port
470 .load_child_for_parent(parent_id, &child_session_id)
471 .await?;
472
473 let title = normalize_non_empty_optional(title, "title")?;
474 let responsibility = normalize_non_empty_optional(responsibility, "responsibility")?;
475 let prompt = normalize_non_empty_optional(prompt, "prompt")?;
476 let subagent_type = normalize_non_empty_optional(subagent_type, "subagent_type")?;
477
478 let should_refresh_assignment =
479 responsibility.is_some() || prompt.is_some() || subagent_type.is_some();
480
481 if title.is_none() && !should_refresh_assignment && reasoning_effort.is_none() {
482 return Err(ChildSessionError::InvalidArguments(
483 "update requires at least one field: title/responsibility/prompt/subagent_type/reasoning_effort"
484 .to_string(),
485 ));
486 }
487
488 if let Some(effort) = reasoning_effort {
489 child.reasoning_effort = Some(effort);
490 }
491
492 if let Some(title) = title {
493 child.title = title;
494 }
495
496 let mut messages_removed = 0usize;
497
498 if should_refresh_assignment {
499 let effective_responsibility = normalize_required_text(
500 responsibility.or_else(|| metadata_text(&child, "responsibility")),
501 "responsibility",
502 )?;
503 let effective_subagent_type = normalize_required_text(
504 subagent_type.or_else(|| metadata_text(&child, "subagent_type")),
505 "subagent_type",
506 )?;
507 let effective_prompt = normalize_required_text(
508 prompt.or_else(|| metadata_text(&child, "assignment_prompt")),
509 "prompt",
510 )?;
511
512 child.metadata.insert(
513 "responsibility".to_string(),
514 effective_responsibility.clone(),
515 );
516 child
517 .metadata
518 .insert("subagent_type".to_string(), effective_subagent_type.clone());
519 child
520 .metadata
521 .insert("assignment_prompt".to_string(), effective_prompt.clone());
522 child.set_last_run_status("pending");
523 child.clear_last_run_error();
524
525 let assignment = format_child_assignment(
526 &child.title,
527 &effective_responsibility,
528 &effective_subagent_type,
529 &effective_prompt,
530 );
531 let user_index = replace_or_append_last_user_message(&mut child, assignment);
532
533 if reset_after_update.unwrap_or(true) {
534 messages_removed = truncate_after_index(&mut child, user_index);
535 }
536 }
537
538 child.updated_at = Utc::now();
539 port.save_child_session(&mut child).await?;
540
541 Ok(json!({
542 "child_session_id": child.id,
543 "title": child.title,
544 "messages_removed": messages_removed,
545 "last_run_status": metadata_text(&child, "last_run_status"),
546 "note": "Child session updated in place. Use action=run to execute the same child session.",
547 }))
548}
549
550pub async fn run_child_action(
551 port: &dyn ChildSessionPort,
552 parent: &Session,
553 child_session_id: String,
554 reset_to_last_user: Option<bool>,
555) -> Result<serde_json::Value, ChildSessionError> {
556 let mut child = port
557 .load_child_for_parent(&parent.id, &child_session_id)
558 .await?;
559
560 if port.is_child_running(&child.id).await {
561 return Ok(json!({
562 "child_session_id": child.id,
563 "status": "already_running",
564 "note": "Child session is already running.",
565 }));
566 }
567
568 let mut messages_removed = 0usize;
569 if reset_to_last_user.unwrap_or(true) {
570 messages_removed = truncate_after_last_user(&mut child)?;
571 }
572
573 child.set_last_run_status("pending");
574 child.clear_last_run_error();
575 child.updated_at = Utc::now();
576 port.save_child_session(&mut child).await?;
577
578 port.enqueue_child_run(parent, &child).await?;
579
580 Ok(json!({
581 "child_session_id": child.id,
582 "status": "queued",
583 "messages_removed": messages_removed,
584 "note": "Queued existing child session for retry in place.",
585 }))
586}
587
588pub async fn send_message_to_child_action(
589 port: &dyn ChildSessionPort,
590 parent: &Session,
591 child_session_id: String,
592 message: String,
593 auto_run: Option<bool>,
594 interrupt_running: Option<bool>,
595 idempotency_key: Option<&str>,
596) -> Result<serde_json::Value, ChildSessionError> {
597 let mut child = port
598 .load_child_for_parent(&parent.id, &child_session_id)
599 .await?;
600
601 let mut is_running = port.is_child_running(&child.id).await;
602 let should_interrupt = interrupt_running.unwrap_or(false);
603
604 if is_running && should_interrupt {
605 port.cancel_child_run_and_wait(&child.id).await?;
606 child = port
607 .load_child_for_parent(&parent.id, &child_session_id)
608 .await?;
609 is_running = port.is_child_running(&child.id).await;
613 }
614
615 let message = normalize_required_text(Some(message), "message")?;
616
617 let should_auto_run = auto_run.unwrap_or(true);
618 if should_route_child_message_through_inbox(is_running, should_auto_run) {
622 let delivery = port
623 .send_session_message(&parent.id, &child.id, &message, idempotency_key)
624 .await?;
625 let (delivery, activation, status, note, activation_error) = match delivery {
626 super::ChildSessionMessageDelivery::Activated(receipt) => {
627 let (status, note) = match receipt.activation {
628 bamboo_domain::SessionActivationDisposition::ActiveNotified => (
629 "message_delivered_live",
630 "Message durably delivered and the owning active loop was notified.",
631 ),
632 bamboo_domain::SessionActivationDisposition::ActivationReserved => (
633 "queued",
634 "Message durably delivered and exactly one session activation was reserved.",
635 ),
636 bamboo_domain::SessionActivationDisposition::ActivationCoalesced => (
637 "message_queued",
638 "Message durably delivered; activation coalesced with existing session work.",
639 ),
640 };
641 (
642 receipt.delivery,
643 Some(receipt.activation),
644 status,
645 note,
646 None,
647 )
648 }
649 super::ChildSessionMessageDelivery::ActivationPending { delivery, error } => (
650 delivery,
651 None,
652 "activation_pending",
653 "Message is durable; activation is pending and will be retried from the inbox watermark.",
654 Some(error),
655 ),
656 };
657 return Ok(json!({
658 "child_session_id": child.id,
659 "status": status,
660 "auto_run": !matches!(
661 activation,
662 Some(bamboo_domain::SessionActivationDisposition::ActiveNotified)
663 ),
664 "message": message,
665 "message_id": delivery.id.to_string(),
666 "inbox_generation": delivery.generation,
667 "activation_error": activation_error,
668 "message_count": child.messages.len(),
669 "note": note,
670 }));
671 }
672
673 child.add_message(bamboo_agent_core::Message::user(message.clone()));
677 child.set_last_run_status("pending");
678 child.clear_last_run_error();
679 port.save_child_session(&mut child).await?;
680
681 Ok(json!({
682 "child_session_id": child.id,
683 "status": "pending",
684 "auto_run": false,
685 "message": message,
686 "message_count": child.messages.len(),
687 "note": "Follow-up message appended. Use action=run to execute the child session.",
688 }))
689}
690
691fn should_route_child_message_through_inbox(is_running: bool, should_auto_run: bool) -> bool {
692 is_running || should_auto_run
693}
694
695pub async fn cancel_child_action(
696 port: &dyn ChildSessionPort,
697 parent_id: &str,
698 child_session_id: String,
699) -> Result<serde_json::Value, ChildSessionError> {
700 let _ = port
702 .load_child_for_parent(parent_id, &child_session_id)
703 .await?;
704 port.cancel_child_run_and_wait(&child_session_id).await?;
705
706 let mut child = port
711 .load_child_for_parent(parent_id, &child_session_id)
712 .await?;
713 let latest_status = child.last_run_status().unwrap_or_default();
714 if matches!(latest_status.as_str(), "completed" | "error") {
715 return Ok(json!({
716 "child_session_id": child_session_id,
717 "status": latest_status,
718 "note": "Child reached a natural terminal state while the cancel was in flight; its real outcome was kept.",
719 }));
720 }
721 child.set_last_run_status("cancelled");
722 child.set_last_run_error("Cancelled by parent");
723 port.save_child_session(&mut child).await?;
724 Ok(json!({
725 "child_session_id": child_session_id,
726 "status": "cancelled",
727 }))
728}
729
730pub async fn delete_child_action(
731 port: &dyn ChildSessionPort,
732 parent_id: &str,
733 child_session_id: String,
734) -> Result<serde_json::Value, ChildSessionError> {
735 let child = port
737 .load_child_for_parent(parent_id, &child_session_id)
738 .await?;
739 let result = port.delete_child_session(parent_id, &child.id).await?;
740
741 if !result.deleted {
742 return Err(ChildSessionError::Execution(format!(
743 "child session was not deleted: {}",
744 child.id
745 )));
746 }
747
748 Ok(json!({
749 "child_session_id": child.id,
750 "deleted": true,
751 "cancelled_running_child": result.cancelled_running_child,
752 }))
753}
754
755#[cfg(test)]
756mod tree_tests {
757 use super::super::ChildSessionEntry;
758 use super::assemble_session_tree;
759 use std::collections::HashMap;
760
761 fn entry(id: &str, title: &str) -> ChildSessionEntry {
762 ChildSessionEntry {
763 child_session_id: id.to_string(),
764 title: title.to_string(),
765 pinned: false,
766 message_count: 0,
767 updated_at: String::new(),
768 last_run_status: Some("completed".to_string()),
769 last_run_error: None,
770 }
771 }
772
773 #[test]
774 fn assembles_multi_level_tree() {
775 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
776 adj.insert(
777 "root".into(),
778 vec![entry("c1", "child 1"), entry("c2", "child 2")],
779 );
780 adj.insert("c1".into(), vec![entry("g1", "grandchild")]);
781
782 let tree = assemble_session_tree("root", "Root", &adj, 8);
783 assert_eq!(tree.session_id, "root");
784 assert_eq!(tree.depth, 0);
785 assert_eq!(tree.children.len(), 2);
786 let c1 = tree.children.iter().find(|n| n.session_id == "c1").unwrap();
787 assert_eq!(c1.depth, 1);
788 assert_eq!(c1.children.len(), 1);
789 assert_eq!(c1.children[0].session_id, "g1");
790 assert_eq!(c1.children[0].depth, 2);
791 let c2 = tree.children.iter().find(|n| n.session_id == "c2").unwrap();
792 assert!(c2.children.is_empty());
793 }
794
795 #[test]
796 fn depth_cap_stops_descent() {
797 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
798 adj.insert("root".into(), vec![entry("c1", "c1")]);
799 adj.insert("c1".into(), vec![entry("g1", "g1")]);
800 let tree = assemble_session_tree("root", "Root", &adj, 1);
801 assert_eq!(tree.children.len(), 1);
802 assert!(
803 tree.children[0].children.is_empty(),
804 "depth cap stops expansion at depth 1"
805 );
806 }
807
808 #[test]
809 fn cycle_is_broken_by_first_visit_guard() {
810 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
811 adj.insert("a".into(), vec![entry("b", "b")]);
812 adj.insert("b".into(), vec![entry("a", "a")]); let tree = assemble_session_tree("a", "A", &adj, 100);
814 assert_eq!(tree.children.len(), 1);
815 let b = &tree.children[0];
816 assert_eq!(b.session_id, "b");
817 assert_eq!(b.children.len(), 1);
818 let a2 = &b.children[0];
819 assert_eq!(a2.session_id, "a");
820 assert!(a2.children.is_empty(), "cycle must terminate as a leaf");
821 }
822}
823
824#[cfg(test)]
825mod send_message_tests {
826 use super::should_route_child_message_through_inbox;
827
828 #[test]
829 fn running_auto_run_interrupt_matrix_preserves_idle_draft_contract() {
830 for (is_running, auto_run, interrupt_running, expected_inbox) in [
831 (false, false, false, false),
832 (false, false, true, false),
833 (false, true, false, true),
834 (false, true, true, true),
835 (true, false, false, true),
836 (true, false, true, false),
837 (true, true, false, true),
838 (true, true, true, true),
839 ] {
840 let effective_running = is_running && !interrupt_running;
841 assert_eq!(
842 should_route_child_message_through_inbox(effective_running, auto_run),
843 expected_inbox,
844 "is_running={is_running} auto_run={auto_run} interrupt_running={interrupt_running}"
845 );
846 }
847 }
848}