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