1use bamboo_domain::Session;
4use chrono::Utc;
5use serde_json::json;
6use std::{future::Future, pin::Pin};
7
8use super::helpers::{
9 append_subagent_delegation_contract, compute_status_guidance,
10 format_child_assignment_with_background, map_child_entry, metadata_text,
11 normalize_non_empty_optional, normalize_required_text, render_forked_parent_context,
12 replace_or_append_last_user_message, truncate_after_index, truncate_after_last_user,
13};
14use super::{
15 ChildSessionEntry, ChildSessionError, ChildSessionPort, CreateChildInput, CreateChildResult,
16};
17
18pub async fn create_child_action(
19 port: &dyn ChildSessionPort,
20 input: CreateChildInput,
21) -> Result<CreateChildResult, ChildSessionError> {
22 if input
23 .runtime_metadata
24 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
25 {
26 return Err(ChildSessionError::InvalidArguments(
27 "Ticket LocalPlan binding requires the trusted work-child entry point".into(),
28 ));
29 }
30 create_child_action_inner(port, input, None, None).await
31}
32
33pub async fn create_ticket_child_action(
35 port: &dyn ChildSessionPort,
36 mut input: CreateChildInput,
37 service: &bamboo_tickets::TicketService,
38 authority: &bamboo_tickets::Authority,
39 assignment_id: &str,
40) -> Result<CreateChildResult, ChildSessionError> {
41 if !matches!(authority.principal(), bamboo_tickets::Principal::Runtime) {
42 return Err(ChildSessionError::Execution(
43 "Ticket child creation requires trusted Runtime".into(),
44 ));
45 }
46 let packet = service
47 .child_context_packet(authority, assignment_id, 65536)
48 .map_err(|e| ChildSessionError::Execution(e.to_string()))?;
49 if input.parent_session.id != packet.binding.supervisor_session_id
50 || input.auto_run
51 || input.lifecycle.as_deref() == Some("resident")
52 || input.context_fork.unwrap_or(0) > 0
53 {
54 return Err(ChildSessionError::InvalidArguments(
55 "Ticket child requires fresh one-shot, exact Supervisor and explicit dispatch".into(),
56 ));
57 }
58 input.assignment_prompt =
59 serde_json::to_string(&packet).map_err(|e| ChildSessionError::Execution(e.to_string()))?;
60 input.runtime_metadata.insert(
61 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
62 assignment_id.into(),
63 );
64 let required_packet = bamboo_domain::ChildContextPacket {
65 version: 1,
66 objective: packet.contract.objective.clone(),
67 constraints: packet.contract.constraints.clone(),
68 acceptance: packet
69 .contract
70 .acceptance
71 .iter()
72 .cloned()
73 .chain(std::iter::once(packet.result_contract.clone()))
74 .collect(),
75 non_goals: vec![
76 "Do not change the Work contract, Supervisor TaskList or any sibling plan".into(),
77 ],
78 necessary_user_instructions: Vec::new(),
79 recorded_decisions: vec![serde_json::to_string(&packet)
80 .map_err(|e| ChildSessionError::Execution(e.to_string()))?],
81 source_user_message_ids: Vec::new(),
82 background_message_ids: Vec::new(),
83 };
84 input.runtime_metadata.insert(
85 bamboo_domain::CHILD_PACKET_INPUT_KEY.into(),
86 serde_json::to_string(&required_packet)
87 .map_err(|e| ChildSessionError::Execution(e.to_string()))?,
88 );
89 create_child_action_inner(port, input, Some(packet), None).await
90}
91
92pub(super) async fn create_profile_child_action(
93 port: &dyn ChildSessionPort,
94 input: CreateChildInput,
95 profile: super::named_profile::ResolvedChildProfile,
96) -> Result<CreateChildResult, ChildSessionError> {
97 if input
98 .runtime_metadata
99 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
100 {
101 return Err(ChildSessionError::InvalidArguments(
102 "invalid Workflow child input".into(),
103 ));
104 }
105 create_child_action_inner(port, input, None, Some(profile)).await
106}
107
108async fn create_child_action_inner(
109 port: &dyn ChildSessionPort,
110 mut input: CreateChildInput,
111 ticket_context: Option<bamboo_tickets::WorkContextPacket>,
112 pinned_profile: Option<super::named_profile::ResolvedChildProfile>,
113) -> Result<CreateChildResult, ChildSessionError> {
114 use crate::runner::refresh_prompt_snapshot;
115 use bamboo_agent_core::Message;
116
117 input.parent_session = super::validate_spawn_parent(port, &input.parent_session).await?;
118
119 let workflow_usage_requested = pinned_profile.is_some()
120 && input
121 .runtime_metadata
122 .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
123 .is_some_and(|v| v == "true");
124 let profile = match pinned_profile {
125 Some(profile) => Some(profile),
126 None => {
127 port.resolve_named_profile(&input.parent_session, &input.subagent_type)
128 .await?
129 }
130 };
131 if let Some(profile) = &profile {
132 if input.lifecycle.as_deref() == Some("resident")
133 || input.resident_name.is_some()
134 || input.context_fork.unwrap_or_default() > 0
135 {
136 return Err(ChildSessionError::InvalidArguments(
137 "named_profile_requires_fresh_local_child".into(),
138 ));
139 }
140 input.read_only |= profile.read_only()
141 || bamboo_domain::PermissionAuditSnapshot::from_metadata(
142 &input.parent_session.metadata,
143 )
144 .is_some_and(|audit| audit.resolution.effective == bamboo_domain::PermissionMode::Plan)
145 || input
146 .parent_session
147 .agent_runtime_state
148 .as_ref()
149 .is_some_and(|r| r.read_only || r.plan_mode.is_some());
150 if let Some(model) = profile.model().filter(|_| {
151 !input
152 .runtime_metadata
153 .contains_key(super::named_profile::PROFILE_EXPLICIT_MODEL_KEY)
154 }) {
155 input.model_override = Some(model.model.clone());
156 input.model_ref_override = Some(model.clone());
157 }
158 if !input
161 .runtime_metadata
162 .contains_key(bamboo_domain::CHILD_PACKET_INPUT_KEY)
163 {
164 let packet = bamboo_domain::ChildContextPacket {
165 version: 1,
166 objective: input.assignment_prompt.clone(),
167 constraints: Vec::new(),
168 acceptance: vec!["Complete the assigned task; report concrete evidence, verification and remaining blockers.".into()],
169 non_goals: Vec::new(),
170 necessary_user_instructions: Vec::new(),
171 recorded_decisions: Vec::new(),
172 source_user_message_ids: Vec::new(),
173 background_message_ids: Vec::new(),
174 };
175 input.runtime_metadata.insert(
176 bamboo_domain::CHILD_PACKET_INPUT_KEY.into(),
177 serde_json::to_string(&packet).map_err(|_| {
178 ChildSessionError::InvalidArguments("invalid_child_context_packet".into())
179 })?,
180 );
181 }
182 if let Some(raw) = input.parent_session.metadata.get("disabled_tools") {
183 let parent_denied: std::collections::BTreeSet<String> = serde_json::from_str(raw)
184 .map_err(|_| {
185 ChildSessionError::Execution("named_profile_parent_tools_invalid".into())
186 })?;
187 input
188 .disabled_tools
189 .get_or_insert_with(Default::default)
190 .extend(parent_denied);
191 }
192 }
193
194 let required_context = if let Some(raw) = input
197 .runtime_metadata
198 .get(bamboo_domain::CHILD_PACKET_INPUT_KEY)
199 {
200 if input.lifecycle.as_deref() == Some("resident")
201 || input.context_fork.unwrap_or_default() > 0
202 {
203 return Err(ChildSessionError::InvalidArguments(
204 "required_child_context_unsupported: fresh one-shot only".into(),
205 ));
206 }
207 if raw.len() > bamboo_domain::MAX_CHILD_PACKET_INPUT_BYTES {
208 return Err(ChildSessionError::InvalidArguments(
209 bamboo_domain::ChildContextPacketError::Budget.to_string(),
210 ));
211 }
212 let packet: bamboo_domain::ChildContextPacket =
213 serde_json::from_str(raw).map_err(|_| {
214 ChildSessionError::InvalidArguments(
215 bamboo_domain::ChildContextPacketError::Invalid.to_string(),
216 )
217 })?;
218 if [&input.title, &input.responsibility, &input.subagent_type]
219 .into_iter()
220 .any(|text| text.lines().any(|line| line.len() > 2048))
221 {
222 return Err(ChildSessionError::InvalidArguments(
223 bamboo_domain::ChildContextPacketError::Budget.to_string(),
224 ));
225 }
226 let parent = port.load_parent_session(&input.parent_session.id).await?;
227 if parent.created_at != input.parent_session.created_at {
228 return Err(ChildSessionError::InvalidArguments(
229 "invalid_child_context_packet: parent lifetime changed".into(),
230 ));
231 }
232 let resolved = packet
233 .resolve(&parent, &input.assignment_prompt)
234 .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?;
235 if resolved.required_input_bytes
236 + input.title.len()
237 + input.responsibility.len()
238 + input.subagent_type.len()
239 > bamboo_domain::MAX_CHILD_REQUIRED_BYTES
240 {
241 return Err(ChildSessionError::InvalidArguments(
242 bamboo_domain::ChildContextPacketError::Budget.to_string(),
243 ));
244 }
245 let assignment = format_child_assignment_with_background(
246 &input.title,
247 &input.responsibility,
248 &input.subagent_type,
249 &resolved.required_brief,
250 None,
251 );
252 let binding =
253 bamboo_domain::ChildContextBinding::new(&parent, &input.child_id, assignment, resolved)
254 .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?;
255 port.validate_required_child_context_route(&input.runtime_metadata, &input.subagent_type)
256 .await?;
257 Some(binding)
258 } else {
259 None
260 };
261
262 let inherited_project_id =
263 match crate::project_context::ProjectContextResolver::session_project_identity(
264 &input.parent_session,
265 ) {
266 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
267 Some(project_id)
268 }
269 crate::project_context::SessionProjectIdentity::Unassigned => None,
270 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
271 return Err(ChildSessionError::InvalidArguments(format!(
272 "parent session carries an invalid Project identity '{raw}': {message}"
273 )));
274 }
275 };
276 let final_workspace = port
277 .validate_child_workspace(inherited_project_id.as_ref(), &input.workspace)
278 .await?;
279
280 let mut child = Session::new_child_of(
285 input.child_id.clone(),
286 &input.parent_session,
287 input
288 .model_ref_override
289 .as_ref()
290 .map(|model_ref| model_ref.model.clone())
291 .or_else(|| input.model_override.clone())
292 .unwrap_or_else(|| input.parent_session.model.clone()),
293 input.title.clone(),
294 );
295 if let Some(project_id) = inherited_project_id {
298 child.set_project_id_meta(project_id.to_string());
299 }
300
301 if let Some(model_ref) = input.model_ref_override.clone() {
302 child.model_ref = Some(model_ref.clone());
303 child
304 .metadata
305 .insert("provider_name".to_string(), model_ref.provider);
306 } else if let Some(mut parent_model_ref) = input.parent_session.model_ref.clone() {
307 parent_model_ref.model = child.model.clone();
310 child.model_ref = Some(parent_model_ref.clone());
311 child.set_provider_name(parent_model_ref.provider);
312 } else if let Some(parent_provider) = input.parent_session.provider_name() {
313 child.set_provider_name(parent_provider);
314 }
315
316 if let Some(effort) = input.reasoning_effort {
320 child.reasoning_effort = Some(effort);
321 }
322
323 let inherited_permission_mode = input
327 .parent_session
328 .agent_runtime_state
329 .as_ref()
330 .map(|state| state.effective_permission_mode())
331 .unwrap_or_default();
332 let child_runtime = child
333 .agent_runtime_state
334 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
335 child_runtime.set_permission_mode(inherited_permission_mode);
336 child_runtime.read_only = input.read_only;
337 let parent_audit =
338 bamboo_domain::PermissionAuditSnapshot::from_metadata(&input.parent_session.metadata);
339 let parent_plan_active = input
340 .parent_session
341 .agent_runtime_state
342 .as_ref()
343 .is_some_and(|state| state.plan_mode.is_some());
344 let effective = if input.read_only || parent_plan_active {
345 bamboo_domain::PermissionMode::Plan
346 } else {
347 parent_audit
348 .as_ref()
349 .filter(|audit| {
350 audit.resolution.requested == inherited_permission_mode
351 && audit.resolution.is_consistent()
352 })
353 .map(|audit| audit.resolution.effective)
354 .unwrap_or_else(|| {
355 bamboo_domain::resolve_permission_mode(
356 inherited_permission_mode,
357 bamboo_domain::PermissionMode::Default,
358 )
359 .effective
360 })
361 };
362 let resolution = bamboo_domain::PermissionModeResolution {
363 requested: inherited_permission_mode,
364 effective,
365 };
366 bamboo_domain::record_permission_audit(
367 &mut child.metadata,
368 &bamboo_domain::PermissionAuditSeed::new(
369 parent_audit
370 .as_ref()
371 .map(|audit| audit.policy_revision)
372 .unwrap_or_default(),
373 resolution,
374 format!("child_activation:{}", resolution.effective.as_str()),
375 ),
376 Some(&Utc::now().to_rfc3339()),
377 )
378 .map_err(|error| ChildSessionError::Execution(error.to_string()))?;
379
380 if input
385 .parent_session
386 .agent_runtime_state
387 .as_ref()
388 .is_some_and(|state| state.no_human_approver)
389 {
390 child
391 .agent_runtime_state
392 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
393 .no_human_approver = true;
394 }
395
396 child
397 .metadata
398 .insert("spawned_by".to_string(), "SubAgent".to_string());
399 child.set_subagent_type(input.subagent_type.clone());
400 child
401 .metadata
402 .insert("responsibility".to_string(), input.responsibility.clone());
403 child.metadata.insert(
404 "assignment_prompt".to_string(),
405 input.assignment_prompt.clone(),
406 );
407 if input.lifecycle.as_deref() == Some("resident") {
412 child
413 .metadata
414 .insert("lifecycle".to_string(), "resident".to_string());
415 if let Some(name) = input.resident_name.clone().filter(|n| !n.trim().is_empty()) {
416 child.metadata.insert("resident_name".to_string(), name);
417 }
418 child.metadata.insert(
419 "resident_context".to_string(),
420 input
421 .resident_context
422 .clone()
423 .filter(|c| matches!(c.as_str(), "reset" | "accumulate"))
424 .unwrap_or_else(|| "reset".to_string()),
425 );
426 }
427 child.set_last_run_status("pending");
428 child
429 .advance_child_launch_generation()
430 .ok_or_else(|| ChildSessionError::Execution("child launch generation exhausted".into()))?;
431 if input.auto_run {
432 child.mark_child_auto_run_launch_intent();
433 }
434 child.clear_last_run_error();
435
436 for (key, value) in input.runtime_metadata {
438 if key != bamboo_domain::CHILD_PACKET_INPUT_KEY
439 && key != super::named_profile::PROFILE_EXPLICIT_MODEL_KEY
440 {
441 child.metadata.insert(key, value);
442 }
443 }
444
445 port.validate_child_model(&child).await?;
448
449 let stored_workspace = port.publish_child_workspace(
452 &child.id,
453 std::path::PathBuf::from(final_workspace),
454 input.workspace_source.as_str(),
455 );
456 child.workspace = Some(stored_workspace.to_string_lossy().to_string());
457 child.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
458 &stored_workspace,
459 ));
460 child.metadata.insert(
461 crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
462 input.workspace_source.as_str().to_string(),
463 );
464
465 let base_prompt = {
471 let global = crate::prompt_defaults::read_global_default_system_prompt_template();
472 if global.trim().is_empty() {
473 crate::context::DEFAULT_BASE_PROMPT.to_string()
474 } else {
475 global
476 }
477 };
478 let base_prompt = profile.as_ref().map_or(base_prompt.clone(), |profile| {
479 profile.append_prompt(&base_prompt)
480 });
481 let system_prompt = append_subagent_delegation_contract(&base_prompt);
482
483 child
484 .metadata
485 .insert("base_system_prompt".to_string(), system_prompt.clone());
486
487 let mut system_message = Message::system(&system_prompt);
488 if workflow_usage_requested {
489 system_message.metadata =
490 Some(json!({(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY): true}));
491 }
492 child.add_message(system_message);
493
494 if let Some(parent_budget) = input.parent_session.effective_token_budget() {
498 let mut child_budget = parent_budget.clone();
499 child_budget.compression_trigger_percent = 70;
500 child_budget.compression_target_percent = 35;
501 child.token_budget = Some(child_budget);
502 }
503
504 refresh_prompt_snapshot(&mut child);
505 let background = input
511 .context_fork
512 .and_then(|n| render_forked_parent_context(&input.parent_session, n));
513 let assignment = format_child_assignment_with_background(
514 &input.title,
515 &input.responsibility,
516 &input.subagent_type,
517 &input.assignment_prompt,
518 background.as_deref(),
519 );
520 if let Some(mut binding) = required_context {
521 binding
522 .bind_host_budget(&child)
523 .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?;
524 binding
525 .install(&mut child)
526 .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?;
527 child.add_message(binding.assignment_message());
528 for message in binding.background_messages() {
529 child.add_message(message);
530 }
531 } else {
532 child.add_message(Message::user(assignment));
533 }
534
535 if let Some(ticket_context) = ticket_context.as_ref() {
536 child.task_list = None;
539 if !child
540 .messages
541 .iter()
542 .any(|m| m.role == bamboo_domain::Role::System)
543 {
544 child.messages.insert(0, Message::system("Only this Work's own Task steps may be written through the Host. The Work contract and inputs are readonly."));
545 }
546 let system = child
547 .messages
548 .iter_mut()
549 .find(|m| m.role == bamboo_domain::Role::System)
550 .unwrap();
551 let metadata = system.metadata.get_or_insert_with(|| json!({}));
552 metadata
553 .as_object_mut()
554 .ok_or_else(|| ChildSessionError::Execution("invalid System metadata".into()))?
555 .insert(
556 crate::ticket_worker_plan::TICKET_PLAN_PACKET_KEY.into(),
557 json!(ticket_context),
558 );
559 child.metadata.insert(
560 "ticket.work_contract_ref.v1".into(),
561 serde_json::to_string(ticket_context)
562 .map_err(|e| ChildSessionError::Execution(e.to_string()))?,
563 );
564 } else if let Some(parent_task_list) = input.parent_session.task_list.clone() {
565 child.set_task_list(parent_task_list);
566 }
567
568 if let Some(ref disabled) = input.disabled_tools {
572 if !disabled.is_empty() {
573 child.metadata.insert(
574 "disabled_tools".to_string(),
575 serde_json::to_string(disabled).unwrap_or_default(),
576 );
577 }
578 }
579
580 if let Some(profile) = profile {
581 profile.bind(&mut child, &input.parent_session)?;
582 }
583
584 let model = child.model.clone();
585 port.save_child_session(&mut child).await?;
586 if input.auto_run {
587 port.enqueue_child_run(&input.parent_session, &child)
588 .await?;
589 }
590
591 Ok(CreateChildResult {
592 child_session_id: child.id,
593 model,
594 })
595}
596
597pub async fn list_children_action(
598 port: &dyn ChildSessionPort,
599 parent_id: &str,
600) -> serde_json::Value {
601 let children = port.list_children(parent_id).await;
602 json!({
603 "parent_session_id": parent_id,
604 "children": children.iter().map(map_child_entry).collect::<Vec<_>>(),
605 "count": children.len(),
606 })
607}
608
609#[derive(Debug, Clone, PartialEq, serde::Serialize)]
612pub struct SessionTreeNode {
613 pub session_id: String,
614 pub title: String,
615 #[serde(skip_serializing_if = "Option::is_none")]
616 pub last_run_status: Option<String>,
617 pub depth: u32,
618 pub children: Vec<SessionTreeNode>,
619}
620
621pub fn assemble_session_tree(
626 root_id: &str,
627 root_title: &str,
628 adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
629 max_depth: u32,
630) -> SessionTreeNode {
631 fn build(
632 id: &str,
633 title: &str,
634 status: Option<String>,
635 depth: u32,
636 max_depth: u32,
637 adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
638 visited: &mut std::collections::HashSet<String>,
639 ) -> SessionTreeNode {
640 let first_visit = visited.insert(id.to_string());
641 let mut children = Vec::new();
642 if first_visit && depth < max_depth {
643 if let Some(kids) = adjacency.get(id) {
644 for kid in kids {
645 children.push(build(
646 &kid.child_session_id,
647 &kid.title,
648 kid.last_run_status.clone(),
649 depth + 1,
650 max_depth,
651 adjacency,
652 visited,
653 ));
654 }
655 }
656 }
657 SessionTreeNode {
658 session_id: id.to_string(),
659 title: title.to_string(),
660 last_run_status: status,
661 depth,
662 children,
663 }
664 }
665 let mut visited = std::collections::HashSet::new();
666 build(
667 root_id,
668 root_title,
669 None,
670 0,
671 max_depth,
672 adjacency,
673 &mut visited,
674 )
675}
676
677pub async fn build_session_tree_action(
683 port: &dyn ChildSessionPort,
684 root_id: &str,
685 max_depth: u32,
686) -> SessionTreeNode {
687 use std::collections::{HashMap, HashSet, VecDeque};
688 const NODE_CAP: usize = 5000;
689
690 let root_title = port
691 .load_root_session(root_id)
692 .await
693 .map(|s| s.title)
694 .unwrap_or_default();
695
696 let mut adjacency: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
697 let mut visited: HashSet<String> = HashSet::new();
698 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
699 queue.push_back((root_id.to_string(), 0));
700
701 while let Some((id, depth)) = queue.pop_front() {
702 if depth >= max_depth || adjacency.len() >= NODE_CAP || !visited.insert(id.clone()) {
703 continue;
704 }
705 let kids = port.list_children(&id).await;
706 for kid in &kids {
707 queue.push_back((kid.child_session_id.clone(), depth + 1));
708 }
709 adjacency.insert(id, kids);
710 }
711
712 assemble_session_tree(root_id, &root_title, &adjacency, max_depth)
713}
714
715pub async fn get_child_action(
716 port: &dyn ChildSessionPort,
717 parent_id: &str,
718 child_session_id: String,
719) -> Result<serde_json::Value, ChildSessionError> {
720 let child = port
721 .load_child_for_inspection(parent_id, &child_session_id)
722 .await?;
723
724 let status = child.last_run_status();
725 let runner_info = port.get_child_runner_info(&child.id).await;
726 let mut truncated_fields = Vec::new();
727 let title = bounded_overview_field(Some(child.title.clone()), "title", &mut truncated_fields);
728 let model = bounded_overview_field(Some(child.model.clone()), "model", &mut truncated_fields);
729 let error = bounded_overview_field(
730 child.last_run_error(),
731 "last_run_error",
732 &mut truncated_fields,
733 );
734 let responsibility = bounded_overview_field(
735 metadata_text(&child, "responsibility"),
736 "responsibility",
737 &mut truncated_fields,
738 );
739 let subagent_type = bounded_overview_field(
740 metadata_text(&child, "subagent_type"),
741 "subagent_type",
742 &mut truncated_fields,
743 );
744 let prompt = bounded_overview_field(
745 metadata_text(&child, "assignment_prompt"),
746 "prompt",
747 &mut truncated_fields,
748 );
749 let latest_user_message = bounded_overview_field(
750 child
751 .messages
752 .iter()
753 .rfind(|message| matches!(message.role, bamboo_agent_core::Role::User))
754 .map(|message| message.content.clone()),
755 "latest_user_message",
756 &mut truncated_fields,
757 );
758 let guidance = bounded_overview_field(
759 Some(compute_status_guidance(
760 status.as_deref(),
761 runner_info.as_ref(),
762 child.has_pending_injected_messages(),
763 )),
764 "guidance",
765 &mut truncated_fields,
766 );
767
768 Ok(json!({
769 "child_session_id": child.id,
770 "title": title,
771 "model": model,
772 "pinned": child.pinned,
773 "message_count": child.messages.len(),
774 "is_running": port.is_child_running(&child.id).await,
775 "last_run_status": bounded_overview_field(status.clone(), "last_run_status", &mut truncated_fields),
776 "last_run_error": error,
777 "responsibility": responsibility,
778 "subagent_type": subagent_type,
779 "prompt": prompt,
780 "latest_user_message": latest_user_message,
781 "runtime_kind": bounded_overview_field(metadata_text(&child, "runtime.kind"), "runtime_kind", &mut truncated_fields),
782 "external_protocol": bounded_overview_field(metadata_text(&child, "external.protocol"), "external_protocol", &mut truncated_fields),
783 "external_agent_id": bounded_overview_field(metadata_text(&child, "external.agent_id"), "external_agent_id", &mut truncated_fields),
784 "a2a_context_id": bounded_overview_field(metadata_text(&child, "a2a.context_id"), "a2a_context_id", &mut truncated_fields),
785 "a2a_latest_task_id": bounded_overview_field(metadata_text(&child, "a2a.latest_task_id"), "a2a_latest_task_id", &mut truncated_fields),
786 "a2a_last_state": bounded_overview_field(metadata_text(&child, "a2a.last_state"), "a2a_last_state", &mut truncated_fields),
787 "runner_started_at": runner_info.as_ref().and_then(|r| r.started_at.map(|t| t.to_rfc3339())),
788 "runner_completed_at": runner_info.as_ref().and_then(|r| r.completed_at.map(|t| t.to_rfc3339())),
789 "last_tool_name": bounded_overview_field(runner_info.as_ref().and_then(|r| r.last_tool_name.clone()), "last_tool_name", &mut truncated_fields),
790 "last_tool_phase": bounded_overview_field(runner_info.as_ref().and_then(|r| r.last_tool_phase.clone()), "last_tool_phase", &mut truncated_fields),
791 "last_event_at": runner_info.as_ref().and_then(|r| r.last_event_at.map(|t| t.to_rfc3339())),
792 "round_count": runner_info.as_ref().map(|r| r.round_count).unwrap_or(0),
793 "has_pending_injected_messages": child.has_pending_injected_messages(),
794 "guidance": guidance,
795 "truncated_fields": truncated_fields,
796 "inspection_hint": "This overview contains metadata and bounded previews, not the child transcript. Use view=messages for paginated previews and view=result for bounded final-answer slices.",
797 }))
798}
799
800fn bounded_overview_field(
801 value: Option<String>,
802 field: &'static str,
803 truncated_fields: &mut Vec<&'static str>,
804) -> Option<String> {
805 const MAX_BYTES: usize = 2048;
806 value.map(|value| {
807 if value.len() <= MAX_BYTES {
808 return value;
809 }
810 let mut end = MAX_BYTES;
811 while !value.is_char_boundary(end) {
812 end -= 1;
813 }
814 truncated_fields.push(field);
815 value[..end].to_string()
816 })
817}
818
819#[allow(clippy::too_many_arguments)]
820pub async fn update_child_action(
821 port: &dyn ChildSessionPort,
822 parent_id: &str,
823 child_session_id: String,
824 title: Option<String>,
825 responsibility: Option<String>,
826 prompt: Option<String>,
827 subagent_type: Option<String>,
828 reset_after_update: Option<bool>,
829 model_ref_override: Option<bamboo_domain::ProviderModelRef>,
830 reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
831 auto_run: bool,
832) -> Result<serde_json::Value, ChildSessionError> {
833 update_child_action_with_background(
834 port,
835 parent_id,
836 child_session_id,
837 title,
838 responsibility,
839 prompt,
840 subagent_type,
841 reset_after_update,
842 model_ref_override,
843 reasoning_effort,
844 None,
845 auto_run,
846 )
847 .await
848}
849
850#[allow(clippy::too_many_arguments)]
854pub async fn update_child_action_with_background(
855 port: &dyn ChildSessionPort,
856 parent_id: &str,
857 child_session_id: String,
858 title: Option<String>,
859 responsibility: Option<String>,
860 prompt: Option<String>,
861 subagent_type: Option<String>,
862 reset_after_update: Option<bool>,
863 model_ref_override: Option<bamboo_domain::ProviderModelRef>,
864 reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
865 assignment_background: Option<String>,
866 auto_run: bool,
867) -> Result<serde_json::Value, ChildSessionError> {
868 let update = ChildSessionUpdate {
869 title: normalize_non_empty_optional(title, "title")?,
870 responsibility: normalize_non_empty_optional(responsibility, "responsibility")?,
871 prompt: normalize_non_empty_optional(prompt, "prompt")?,
872 subagent_type: normalize_non_empty_optional(subagent_type, "subagent_type")?,
873 reset_after_update,
874 model_ref_override,
875 reasoning_effort,
876 assignment_background,
877 auto_run,
878 };
879
880 if update.title.is_none()
881 && !update.refreshes_assignment()
882 && update.model_ref_override.is_none()
883 && update.reasoning_effort.is_none()
884 {
885 return Err(ChildSessionError::InvalidArguments(
886 "update requires at least one field: title/responsibility/prompt/subagent_type/model/reasoning_effort"
887 .to_string(),
888 ));
889 }
890
891 let (child, messages_removed) = port
892 .update_child_session(parent_id, &child_session_id, update)
893 .await?;
894
895 Ok(json!({
896 "child_session_id": child.id,
897 "title": child.title,
898 "model": child.model,
899 "model_ref": child.model_ref,
900 "reasoning_effort": child.reasoning_effort.map(|effort| effort.as_str()),
901 "messages_removed": messages_removed,
902 "last_run_status": metadata_text(&child, "last_run_status"),
903 "note": "Child session updated in place. Use action=run to execute the same child session.",
904 }))
905}
906
907#[derive(Clone)]
910pub struct ChildSessionUpdate {
911 pub title: Option<String>,
912 pub responsibility: Option<String>,
913 pub prompt: Option<String>,
914 pub subagent_type: Option<String>,
915 pub reset_after_update: Option<bool>,
916 pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
917 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
918 pub assignment_background: Option<String>,
919 pub auto_run: bool,
920}
921
922impl ChildSessionUpdate {
923 pub fn refreshes_assignment(&self) -> bool {
924 self.responsibility.is_some() || self.prompt.is_some() || self.subagent_type.is_some()
925 }
926
927 pub fn changes_execution(&self) -> bool {
928 self.refreshes_assignment()
929 || self.model_ref_override.is_some()
930 || self.reasoning_effort.is_some()
931 || self.auto_run
932 }
933}
934
935pub fn apply_child_session_update(
936 child: &mut Session,
937 update: ChildSessionUpdate,
938) -> Result<usize, ChildSessionError> {
939 let should_refresh_assignment = update.refreshes_assignment();
940 if super::named_profile::has_named_profile(child)
941 && (should_refresh_assignment
942 || update.assignment_background.is_some()
943 || update.model_ref_override.is_some()
944 || update.reasoning_effort.is_some())
945 {
946 return Err(ChildSessionError::InvalidArguments(
947 "named_profile_contract_is_frozen; create a new Child to select another profile or model".into()));
948 }
949 if (should_refresh_assignment || update.assignment_background.is_some())
950 && bamboo_domain::ChildContextBinding::from_session(child)
951 .map_err(|error| ChildSessionError::Execution(error.to_string()))?
952 .is_some()
953 {
954 return Err(ChildSessionError::InvalidArguments(
955 "required_child_context_unsupported: immutable assignment cannot be updated in place; create a new Child".into()));
956 }
957
958 if let Some(model_ref) = update.model_ref_override {
959 apply_model_ref_override(child, model_ref)?;
960 }
961
962 if let Some(effort) = update.reasoning_effort {
963 child.reasoning_effort = Some(effort);
964 }
965
966 if let Some(title) = update.title {
967 if child.title != title {
968 child.title = title;
969 child.title_generated = false;
970 child.title_version = child.title_version.saturating_add(1);
971 child.metadata_version = child.metadata_version.saturating_add(1);
972 }
973 }
974
975 let mut messages_removed = 0usize;
976
977 if should_refresh_assignment {
978 let effective_responsibility = normalize_required_text(
979 update
980 .responsibility
981 .or_else(|| metadata_text(child, "responsibility")),
982 "responsibility",
983 )?;
984 let effective_subagent_type = normalize_required_text(
985 update
986 .subagent_type
987 .or_else(|| metadata_text(child, "subagent_type")),
988 "subagent_type",
989 )?;
990 let effective_prompt = normalize_required_text(
991 update
992 .prompt
993 .or_else(|| metadata_text(child, "assignment_prompt")),
994 "prompt",
995 )?;
996
997 child.metadata.insert(
998 "responsibility".to_string(),
999 effective_responsibility.clone(),
1000 );
1001 child
1002 .metadata
1003 .insert("subagent_type".to_string(), effective_subagent_type.clone());
1004 child
1005 .metadata
1006 .insert("assignment_prompt".to_string(), effective_prompt.clone());
1007 child.set_last_run_status("pending");
1008 child.advance_child_launch_generation().ok_or_else(|| {
1009 ChildSessionError::Execution("child launch generation exhausted".into())
1010 })?;
1011 child.clear_last_run_error();
1012
1013 let assignment = format_child_assignment_with_background(
1014 &child.title,
1015 &effective_responsibility,
1016 &effective_subagent_type,
1017 &effective_prompt,
1018 update.assignment_background.as_deref(),
1019 );
1020 let user_index = replace_or_append_last_user_message(child, assignment);
1021
1022 if update.reset_after_update.unwrap_or(true) {
1023 messages_removed = truncate_after_index(child, user_index);
1024 }
1025 }
1026
1027 if update.auto_run {
1028 if !should_refresh_assignment {
1029 child.set_last_run_status("pending");
1030 child.advance_child_launch_generation().ok_or_else(|| {
1031 ChildSessionError::Execution("child launch generation exhausted".into())
1032 })?;
1033 child.clear_last_run_error();
1034 }
1035 child.mark_child_auto_run_launch_intent();
1036 }
1037
1038 child.updated_at = Utc::now();
1039 Ok(messages_removed)
1040}
1041
1042fn apply_model_ref_override(
1043 child: &mut Session,
1044 model_ref: bamboo_domain::ProviderModelRef,
1045) -> Result<(), ChildSessionError> {
1046 let provider = model_ref.provider.trim();
1047 let model = model_ref.model.trim();
1048 if provider.is_empty() || model.is_empty() {
1049 return Err(ChildSessionError::InvalidArguments(
1050 "model provider and id must both be non-empty".to_string(),
1051 ));
1052 }
1053 let model_ref = bamboo_domain::ProviderModelRef {
1054 provider: provider.to_string(),
1055 model: model.to_string(),
1056 reasoning_effort: model_ref.reasoning_effort,
1057 };
1058 child.model = model_ref.model.clone();
1059 child.set_provider_name(model_ref.provider.clone());
1060 child.model_ref = Some(model_ref);
1061 Ok(())
1062}
1063
1064pub async fn run_child_action(
1067 port: &dyn ChildSessionPort,
1068 parent: &Session,
1069 child_session_id: String,
1070 reset_to_last_user: Option<bool>,
1071) -> Result<serde_json::Value, ChildSessionError> {
1072 let mut child = port
1073 .load_child_for_parent(&parent.id, &child_session_id)
1074 .await?;
1075
1076 if port.is_child_running(&child.id).await {
1077 return Ok(json!({
1078 "child_session_id": child.id,
1079 "status": "already_running",
1080 "note": "Child session is already running.",
1081 }));
1082 }
1083
1084 port.validate_child_run_request(parent, &child, reset_to_last_user)
1085 .await?;
1086 port.validate_child_model(&child).await?;
1087 let mut messages_removed = 0usize;
1088 if reset_to_last_user.unwrap_or(true) {
1089 messages_removed = truncate_after_last_user(&mut child)?;
1090 }
1091
1092 child.set_last_run_status("pending");
1093 child
1094 .advance_child_launch_generation()
1095 .ok_or_else(|| ChildSessionError::Execution("child launch generation exhausted".into()))?;
1096 child.mark_child_auto_run_launch_intent();
1097 child.clear_last_run_error();
1098 child.updated_at = Utc::now();
1099 port.save_child_session(&mut child).await?;
1100
1101 Ok(json!({
1102 "child_session_id": child.id,
1103 "status": "queued",
1104 "messages_removed": messages_removed,
1105 "note": "Queued existing child session for retry in place.",
1106 }))
1107}
1108
1109#[allow(clippy::too_many_arguments)]
1110pub async fn send_message_to_child_action(
1111 port: &dyn ChildSessionPort,
1112 parent: &Session,
1113 child_session_id: String,
1114 message: String,
1115 auto_run: Option<bool>,
1116 interrupt_running: Option<bool>,
1117 idempotency_key: Option<&str>,
1118 wait_if_queued: bool,
1119) -> Result<serde_json::Value, ChildSessionError> {
1120 send_message_to_child_action_with_gate(
1121 port,
1122 parent,
1123 child_session_id,
1124 message,
1125 auto_run,
1126 interrupt_running,
1127 idempotency_key,
1128 wait_if_queued,
1129 None,
1130 )
1131 .await
1132}
1133
1134#[allow(clippy::too_many_arguments)]
1135pub async fn send_message_to_child_action_with_gate(
1136 port: &dyn ChildSessionPort,
1137 parent: &Session,
1138 child_session_id: String,
1139 message: String,
1140 auto_run: Option<bool>,
1141 interrupt_running: Option<bool>,
1142 idempotency_key: Option<&str>,
1143 wait_if_queued: bool,
1144 admission_gate: Option<&bamboo_domain::AdmissionGate>,
1145) -> Result<serde_json::Value, ChildSessionError> {
1146 let mut child = port
1147 .load_child_for_parent(&parent.id, &child_session_id)
1148 .await?;
1149
1150 if message.trim().is_empty() {
1154 return Err(ChildSessionError::InvalidArguments(
1155 "message must be non-empty".to_string(),
1156 ));
1157 }
1158
1159 port.validate_child_model(&child).await?;
1160
1161 let mut is_running = port.is_child_running(&child.id).await;
1162 let should_interrupt = interrupt_running.unwrap_or(false);
1163 let mut delivery_gate = admission_gate;
1164
1165 if is_running && should_interrupt {
1166 if let Some(gate) = admission_gate {
1170 match gate.commit(|| Ok::<(), ChildSessionError>(()))? {
1171 bamboo_domain::AdmissionCommit::Cancelled => {
1172 return Err(ChildSessionError::Execution(
1173 "SubAgent tool cancelled before child interruption".to_string(),
1174 ));
1175 }
1176 bamboo_domain::AdmissionCommit::Committed(())
1177 | bamboo_domain::AdmissionCommit::AlreadyCommitted => {}
1178 }
1179 }
1180 delivery_gate = None;
1184 port.cancel_child_run_and_wait(&child.id).await?;
1185 child = port
1186 .load_child_for_parent(&parent.id, &child_session_id)
1187 .await?;
1188 is_running = port.is_child_running(&child.id).await;
1192 }
1193
1194 let should_auto_run = auto_run.unwrap_or(true);
1195 if should_route_child_message_through_inbox(is_running, should_auto_run) {
1199 let armed_wait = wait_if_queued && !is_running;
1204 let had_wait = if armed_wait {
1205 port.load_parent_session(&parent.id)
1206 .await?
1207 .agent_runtime_state
1208 .as_ref()
1209 .and_then(|state| state.waiting_for_children.as_ref())
1210 .is_some_and(|wait| wait.child_session_ids.iter().any(|id| id == &child.id))
1211 } else {
1212 false
1213 };
1214 if armed_wait {
1215 if let Err(error) = port
1216 .register_parent_wait_for_child(&parent.id, &child.id, idempotency_key)
1217 .await
1218 {
1219 return Err(if had_wait {
1220 error
1221 } else {
1222 rollback_failed_wait_launch(port, &parent.id, &child.id, error).await
1223 });
1224 }
1225 }
1226 if delivery_gate.is_some_and(bamboo_domain::AdmissionGate::is_cancelled) {
1227 let error = ChildSessionError::Execution(
1228 "SubAgent tool cancelled before child delivery".to_string(),
1229 );
1230 return Err(if armed_wait && !had_wait {
1231 rollback_failed_wait_launch(port, &parent.id, &child.id, error).await
1232 } else {
1233 error
1234 });
1235 }
1236 let send = port.send_session_message_with_gate(
1237 &parent.id,
1238 &child.id,
1239 &message,
1240 idempotency_key,
1241 delivery_gate,
1242 );
1243 let delivery = match delivery_gate {
1248 Some(gate) => tokio::select! {
1249 biased;
1250 _ = gate.cancelled() => Err(ChildSessionError::Execution(
1251 "SubAgent tool cancelled before child delivery".to_string(),
1252 )),
1253 result = send => result,
1254 },
1255 None => send.await,
1256 };
1257 let delivery = match delivery {
1258 Ok(delivery) => delivery,
1259 Err(error) if armed_wait && !had_wait => {
1260 return Err(rollback_failed_wait_launch(port, &parent.id, &child.id, error).await);
1261 }
1262 Err(error) => return Err(error),
1263 };
1264 let (delivery, activation, status, note, activation_error) = match delivery {
1265 super::ChildSessionMessageDelivery::Activated(receipt) => {
1266 let (status, note) = match receipt.activation {
1267 bamboo_domain::SessionActivationDisposition::ActiveNotified => (
1268 "message_delivered_live",
1269 "Message durably delivered and the owning active loop was notified.",
1270 ),
1271 bamboo_domain::SessionActivationDisposition::ActivationReserved => (
1272 "queued",
1273 "Message durably delivered and exactly one session activation was reserved.",
1274 ),
1275 bamboo_domain::SessionActivationDisposition::ActivationCoalesced => (
1276 "message_queued",
1277 "Message durably delivered; activation coalesced with existing session work.",
1278 ),
1279 };
1280 (
1281 receipt.delivery,
1282 Some(receipt.activation),
1283 status,
1284 note,
1285 None,
1286 )
1287 }
1288 super::ChildSessionMessageDelivery::ActivationPending { delivery, error } => (
1289 delivery,
1290 None,
1291 "activation_pending",
1292 "Message is durable; activation is pending and will be retried from the inbox watermark.",
1293 Some(error),
1294 ),
1295 super::ChildSessionMessageDelivery::ActivationAuthorizationPending {
1296 delivery,
1297 error,
1298 } => (
1299 delivery,
1300 None,
1301 "activation_retry_required",
1302 "Message is durable, but activation authorization was not persisted. The same message ID must be retried; restart alone cannot wake this delivery.",
1303 Some(error),
1304 ),
1305 };
1306 if armed_wait && !had_wait && status != "queued" {
1307 port.rollback_parent_wait_for_child(&parent.id, &child.id)
1308 .await?;
1309 }
1310 return Ok(json!({
1311 "child_session_id": child.id,
1312 "status": status,
1313 "auto_run": !matches!(
1314 activation,
1315 Some(bamboo_domain::SessionActivationDisposition::ActiveNotified)
1316 ),
1317 "message": message,
1318 "message_id": delivery.id.to_string(),
1319 "inbox_generation": delivery.generation,
1320 "activation_error": activation_error,
1321 "message_count": child.messages.len(),
1322 "note": note,
1323 }));
1324 }
1325
1326 if delivery_gate.is_some_and(bamboo_domain::AdmissionGate::is_cancelled) {
1332 return Err(ChildSessionError::Execution(
1333 "SubAgent tool cancelled before child delivery".to_string(),
1334 ));
1335 }
1336 let child = port
1337 .append_draft_child_message(&parent.id, &child.id, &message)
1338 .await?;
1339
1340 Ok(json!({
1341 "child_session_id": child.id,
1342 "status": "pending",
1343 "auto_run": false,
1344 "message": message,
1345 "message_count": child.messages.len(),
1346 "note": "Follow-up message appended. Use action=run to execute the child session.",
1347 }))
1348}
1349
1350pub async fn rollback_failed_wait_launch(
1353 port: &dyn ChildSessionPort,
1354 parent_session_id: &str,
1355 child_session_id: &str,
1356 error: ChildSessionError,
1357) -> ChildSessionError {
1358 match port
1359 .rollback_parent_wait_for_child(parent_session_id, child_session_id)
1360 .await
1361 {
1362 Ok(()) => error,
1363 Err(rollback_error) => ChildSessionError::Execution(format!(
1364 "{error}; parent-wait rollback failed: {rollback_error}"
1365 )),
1366 }
1367}
1368
1369fn should_route_child_message_through_inbox(is_running: bool, should_auto_run: bool) -> bool {
1370 is_running || should_auto_run
1371}
1372
1373pub fn cancel_child_action<'a>(
1374 port: &'a dyn ChildSessionPort,
1375 parent_id: &'a str,
1376 child_session_id: String,
1377) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, ChildSessionError>> + Send + 'a>> {
1378 Box::pin(cancel_child_action_inner(port, parent_id, child_session_id))
1379}
1380
1381async fn cancel_child_action_inner(
1382 port: &dyn ChildSessionPort,
1383 parent_id: &str,
1384 child_session_id: String,
1385) -> Result<serde_json::Value, ChildSessionError> {
1386 let _ = port
1388 .load_child_for_parent(parent_id, &child_session_id)
1389 .await?;
1390 port.cancel_child_run_and_wait(&child_session_id).await?;
1391
1392 let mut child = port
1397 .load_child_for_parent(parent_id, &child_session_id)
1398 .await?;
1399 let latest_status = child.last_run_status().unwrap_or_default();
1400 if latest_status == "cancelled" {
1401 return Ok(json!({
1402 "child_session_id": child_session_id,
1403 "status": "cancelled",
1404 }));
1405 }
1406 if matches!(
1407 latest_status.as_str(),
1408 "completed" | "error" | "timeout" | "skipped"
1409 ) {
1410 return Ok(json!({
1411 "child_session_id": child_session_id,
1412 "status": latest_status,
1413 "note": "Child reached a natural terminal state while the cancel was in flight; its real outcome was kept.",
1414 }));
1415 }
1416 child.set_last_run_status("cancelled");
1417 child.set_last_run_error("Cancelled by parent");
1418 port.save_child_session(&mut child).await?;
1419 Ok(json!({
1420 "child_session_id": child_session_id,
1421 "status": "cancelled",
1422 }))
1423}
1424
1425pub async fn delete_child_action(
1426 port: &dyn ChildSessionPort,
1427 parent_id: &str,
1428 child_session_id: String,
1429) -> Result<serde_json::Value, ChildSessionError> {
1430 let child = port
1432 .load_child_for_parent(parent_id, &child_session_id)
1433 .await?;
1434 let result = port.delete_child_session(parent_id, &child.id).await?;
1435
1436 if !result.deleted {
1437 return Err(ChildSessionError::Execution(format!(
1438 "child session was not deleted: {}",
1439 child.id
1440 )));
1441 }
1442
1443 Ok(json!({
1444 "child_session_id": child.id,
1445 "deleted": true,
1446 "cancelled_running_child": result.cancelled_running_child,
1447 }))
1448}
1449
1450#[cfg(test)]
1451mod tree_tests {
1452 use super::super::ChildSessionEntry;
1453 use super::assemble_session_tree;
1454 use std::collections::HashMap;
1455
1456 fn entry(id: &str, title: &str) -> ChildSessionEntry {
1457 ChildSessionEntry {
1458 child_session_id: id.to_string(),
1459 title: title.to_string(),
1460 pinned: false,
1461 message_count: 0,
1462 updated_at: String::new(),
1463 last_run_status: Some("completed".to_string()),
1464 last_run_error: None,
1465 }
1466 }
1467
1468 #[test]
1469 fn assembles_multi_level_tree() {
1470 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
1471 adj.insert(
1472 "root".into(),
1473 vec![entry("c1", "child 1"), entry("c2", "child 2")],
1474 );
1475 adj.insert("c1".into(), vec![entry("g1", "grandchild")]);
1476
1477 let tree = assemble_session_tree("root", "Root", &adj, 8);
1478 assert_eq!(tree.session_id, "root");
1479 assert_eq!(tree.depth, 0);
1480 assert_eq!(tree.children.len(), 2);
1481 let c1 = tree.children.iter().find(|n| n.session_id == "c1").unwrap();
1482 assert_eq!(c1.depth, 1);
1483 assert_eq!(c1.children.len(), 1);
1484 assert_eq!(c1.children[0].session_id, "g1");
1485 assert_eq!(c1.children[0].depth, 2);
1486 let c2 = tree.children.iter().find(|n| n.session_id == "c2").unwrap();
1487 assert!(c2.children.is_empty());
1488 }
1489
1490 #[test]
1491 fn depth_cap_stops_descent() {
1492 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
1493 adj.insert("root".into(), vec![entry("c1", "c1")]);
1494 adj.insert("c1".into(), vec![entry("g1", "g1")]);
1495 let tree = assemble_session_tree("root", "Root", &adj, 1);
1496 assert_eq!(tree.children.len(), 1);
1497 assert!(
1498 tree.children[0].children.is_empty(),
1499 "depth cap stops expansion at depth 1"
1500 );
1501 }
1502
1503 #[test]
1504 fn cycle_is_broken_by_first_visit_guard() {
1505 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
1506 adj.insert("a".into(), vec![entry("b", "b")]);
1507 adj.insert("b".into(), vec![entry("a", "a")]); let tree = assemble_session_tree("a", "A", &adj, 100);
1509 assert_eq!(tree.children.len(), 1);
1510 let b = &tree.children[0];
1511 assert_eq!(b.session_id, "b");
1512 assert_eq!(b.children.len(), 1);
1513 let a2 = &b.children[0];
1514 assert_eq!(a2.session_id, "a");
1515 assert!(a2.children.is_empty(), "cycle must terminate as a leaf");
1516 }
1517}
1518
1519#[cfg(test)]
1520mod update_model_tests {
1521 use super::apply_model_ref_override;
1522
1523 #[test]
1524 fn model_override_updates_all_session_model_authority() {
1525 let mut child = bamboo_domain::Session::new("child", "old-model");
1526 child.model_ref = Some(bamboo_domain::ProviderModelRef::new(
1527 "old-provider",
1528 "old-model",
1529 ));
1530 child.set_provider_name("old-provider");
1531
1532 apply_model_ref_override(
1533 &mut child,
1534 bamboo_domain::ProviderModelRef::new(" new-provider ", " new-model "),
1535 )
1536 .expect("valid model override");
1537
1538 assert_eq!(child.model, "new-model");
1539 assert_eq!(child.provider_name().as_deref(), Some("new-provider"));
1540 assert_eq!(
1541 child
1542 .model_ref
1543 .as_ref()
1544 .map(|model_ref| model_ref.to_pair()),
1545 Some(("new-provider", "new-model"))
1546 );
1547 }
1548
1549 #[test]
1550 fn model_override_rejects_empty_provider_or_model() {
1551 let mut child = bamboo_domain::Session::new("child", "old-model");
1552 assert!(apply_model_ref_override(
1553 &mut child,
1554 bamboo_domain::ProviderModelRef::new("", "new-model")
1555 )
1556 .is_err());
1557 assert!(apply_model_ref_override(
1558 &mut child,
1559 bamboo_domain::ProviderModelRef::new("provider", " ")
1560 )
1561 .is_err());
1562 }
1563}
1564
1565#[cfg(test)]
1566mod send_message_tests {
1567 use super::should_route_child_message_through_inbox;
1568
1569 #[test]
1570 fn running_auto_run_interrupt_matrix_preserves_idle_draft_contract() {
1571 for (is_running, auto_run, interrupt_running, expected_inbox) in [
1572 (false, false, false, false),
1573 (false, false, true, false),
1574 (false, true, false, true),
1575 (false, true, true, true),
1576 (true, false, false, true),
1577 (true, false, true, false),
1578 (true, true, false, true),
1579 (true, true, true, true),
1580 ] {
1581 let effective_running = is_running && !interrupt_running;
1582 assert_eq!(
1583 should_route_child_message_through_inbox(effective_running, auto_run),
1584 expected_inbox,
1585 "is_running={is_running} auto_run={auto_run} interrupt_running={interrupt_running}"
1586 );
1587 }
1588 }
1589}