1use async_trait::async_trait;
2use serde::Deserialize;
3use serde_json::json;
4use std::sync::Arc;
5use uuid::Uuid;
6
7use crate::session_app::child_session::{self, ChildSessionPort, CreateChildInput};
8use crate::tools::child_session_adapter::{tool_error_from_child_session, ChildSessionAdapter};
9use bamboo_agent_core::tools::{Tool, ToolError, ToolExecutionContext, ToolResult};
10use bamboo_domain::subagent::SubagentProfileRegistry;
11use bamboo_domain::ReasoningEffort;
12
13#[derive(Debug, Deserialize)]
18#[serde(tag = "action", rename_all = "snake_case")]
19enum SubSessionArgs {
20 Create {
21 #[serde(default)]
22 title: Option<String>,
23 #[serde(default)]
24 description: String,
25 #[serde(default)]
26 responsibility: Option<String>,
27 prompt: String,
28 subagent_type: String,
29 #[serde(default)]
30 auto_run: Option<bool>,
31 #[serde(default)]
37 reasoning_effort: Option<ReasoningEffort>,
38 },
39 List,
40 Get {
41 child_session_id: String,
42 },
43 Update {
44 child_session_id: String,
45 #[serde(default)]
46 title: Option<String>,
47 #[serde(default)]
48 responsibility: Option<String>,
49 #[serde(default)]
50 prompt: Option<String>,
51 #[serde(default)]
52 subagent_type: Option<String>,
53 #[serde(default)]
54 reset_after_update: Option<bool>,
55 #[serde(default)]
56 auto_run: Option<bool>,
57 #[serde(default)]
61 reasoning_effort: Option<ReasoningEffort>,
62 },
63 Run {
64 child_session_id: String,
65 #[serde(default)]
66 reset_to_last_user: Option<bool>,
67 },
68 SendMessage {
69 child_session_id: String,
70 message: String,
71 #[serde(default)]
72 auto_run: Option<bool>,
73 #[serde(default)]
74 interrupt_running: Option<bool>,
75 },
76 Cancel {
77 child_session_id: String,
78 },
79 Delete {
80 child_session_id: String,
81 },
82 ListProfiles,
87}
88
89fn normalize_required_text(value: Option<String>, field_name: &str) -> Result<String, ToolError> {
94 let Some(value) = value else {
95 return Err(ToolError::InvalidArguments(format!(
96 "{field_name} must be non-empty"
97 )));
98 };
99 let trimmed = value.trim();
100 if trimmed.is_empty() {
101 return Err(ToolError::InvalidArguments(format!(
102 "{field_name} must be non-empty"
103 )));
104 }
105 Ok(trimmed.to_string())
106}
107
108fn normalize_title(title: Option<String>, legacy_description: String) -> Result<String, ToolError> {
109 let title = title.and_then(|value| {
110 let trimmed = value.trim();
111 if trimmed.is_empty() {
112 None
113 } else {
114 Some(trimmed.to_string())
115 }
116 });
117 let legacy_description = {
118 let trimmed = legacy_description.trim();
119 if trimmed.is_empty() {
120 None
121 } else {
122 Some(trimmed.to_string())
123 }
124 };
125 normalize_required_text(title.or(legacy_description), "title")
126}
127
128fn tool_result(value: serde_json::Value) -> Result<ToolResult, ToolError> {
129 Ok(ToolResult {
130 success: true,
131 result: value.to_string(),
132 display_preference: Some("Collapsible".to_string()),
133 })
134}
135
136pub struct SubSessionTool {
141 adapter: Arc<ChildSessionAdapter>,
142 profiles: Arc<SubagentProfileRegistry>,
145}
146
147impl SubSessionTool {
148 pub fn new(adapter: Arc<ChildSessionAdapter>, profiles: Arc<SubagentProfileRegistry>) -> Self {
149 Self { adapter, profiles }
150 }
151}
152
153#[async_trait]
154impl Tool for SubSessionTool {
155 fn name(&self) -> &str {
156 "SubSession"
157 }
158
159 fn description(&self) -> &str {
160 "Create, inspect, and manage child sessions for explicitly requested delegated, parallel, or sub-agent work. A child session runs independently under the current root session with its own conversation context, can use a specialized subagent profile, streams progress back to the parent via sub_session_* events, and can be reopened from the Sub-sessions panel. Use action=create for a new delegated task; use list/get to inspect existing children; use update/run/send_message/cancel/delete to manage existing children. Use only when the user explicitly asks for delegation/parallelism or when a side task would otherwise flood the main context. Do not use for simple one-step tasks. Child sessions cannot spawn nested child sessions. IMPORTANT: When a child fails or needs redirection, prefer send_message over creating a duplicate child. Use list before create to avoid spawning redundant children."
161 }
162
163 fn parameters_schema(&self) -> serde_json::Value {
164 json!({
165 "type": "object",
166 "properties": {
167 "action": {
168 "type": "string",
169 "enum": ["create", "list", "get", "update", "run", "send_message", "cancel", "delete", "list_profiles"],
170 "description": "Sub-session lifecycle operation. Use create to delegate a new independent child session; use list/get to inspect; use update/run/send_message/cancel/delete to manage existing child sessions. Use list_profiles to enumerate available subagent roles before deciding which subagent_type to pass to create."
171 },
172 "child_session_id": {
173 "type": "string",
174 "description": "Existing child session id. Required for get/update/run/send_message/cancel/delete."
175 },
176 "title": {
177 "type": "string",
178 "description": "Short title for a new or updated child session. Required for create. Displayed in the Sub-sessions panel."
179 },
180 "description": {
181 "type": "string",
182 "description": "Legacy alias of title; prefer title."
183 },
184 "responsibility": {
185 "type": "string",
186 "description": "Single explicit responsibility for the child session. Required for create. Keep this narrow and non-overlapping with other child sessions."
187 },
188 "prompt": {
189 "type": "string",
190 "description": "Detailed task instructions, context, constraints, and expected output for the child session. Required for create; optional for update."
191 },
192 "subagent_type": {
193 "type": "string",
194 "description": "Specialized child agent profile, e.g. general-purpose, researcher, coder, plan. Use plan/researcher for read-only exploration and coder/general-purpose for implementation when allowed."
195 },
196 "auto_run": {
197 "type": "boolean",
198 "description": "For create/send_message/update: whether to enqueue the child session immediately. Defaults to true for create/send_message and false for update."
199 },
200 "reset_after_update": {
201 "type": "boolean",
202 "description": "For update: whether to truncate messages after refreshed assignment. Defaults to true."
203 },
204 "reset_to_last_user": {
205 "type": "boolean",
206 "description": "For run: whether to truncate messages after the last user message before rerun. Defaults to true."
207 },
208 "message": {
209 "type": "string",
210 "description": "Follow-up instruction to append as a new user message for send_message. Required for send_message."
211 },
212 "interrupt_running": {
213 "type": "boolean",
214 "description": "For send_message/cancel: if true, cancel a currently running child session before appending or returning. Defaults to false for send_message. When false on a running child, the message is queued and will be picked up at the next turn boundary without canceling progress."
215 },
216 "reasoning_effort": {
217 "type": "string",
218 "enum": ["low", "medium", "high", "xhigh", "max"],
219 "description": "For create/update: reasoning effort level applied to the child session's own LLM calls. Use \"low\" for trivial fan-outs (e.g. simple lookups), \"medium\"/\"high\" for normal coding/analysis, \"xhigh\"/\"max\" for deep reasoning tasks. Omit to leave at provider default; the child does NOT inherit the parent's reasoning_effort."
220 }
221 },
222 "required": ["action"],
223 "additionalProperties": false
224 })
225 }
226
227 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
228 self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
229 .await
230 }
231
232 async fn execute_with_context(
233 &self,
234 args: serde_json::Value,
235 ctx: ToolExecutionContext<'_>,
236 ) -> Result<ToolResult, ToolError> {
237 let parent_session_id = ctx.session_id.ok_or_else(|| {
238 ToolError::Execution("SubSession requires a session_id in tool context".to_string())
239 })?;
240
241 let mut args = args;
245 if args.get("action").is_none() {
246 args["action"] = json!("create");
247 }
248
249 let parsed: SubSessionArgs = serde_json::from_value(args).map_err(|error| {
250 ToolError::InvalidArguments(format!("Invalid SubSession args: {error}"))
251 })?;
252
253 if let SubSessionArgs::ListProfiles = parsed {
258 return tool_result(self.list_profiles_payload());
259 }
260
261 let parent = self
262 .adapter
263 .as_ref()
264 .load_root_session(parent_session_id)
265 .await
266 .map_err(tool_error_from_child_session)?;
267
268 match parsed {
269 SubSessionArgs::Create {
270 title,
271 description,
272 responsibility,
273 prompt,
274 subagent_type,
275 auto_run,
276 reasoning_effort,
277 } => {
278 let title = normalize_title(title, description)?;
279 let responsibility = normalize_required_text(responsibility, "responsibility")?;
280 let prompt = normalize_required_text(Some(prompt), "prompt")?;
281 let subagent_type = normalize_required_text(Some(subagent_type), "subagent_type")?;
282
283 if parent.model.trim().is_empty() {
284 return Err(ToolError::Execution(
285 "parent session model is empty".to_string(),
286 ));
287 }
288
289 let child_id = Uuid::new_v4().to_string();
290 let model_ref_override = self.adapter.resolve_subagent_model(&subagent_type).await;
291 let model_override = model_ref_override
292 .as_ref()
293 .map(|model_ref| model_ref.model.clone());
294 let runtime_metadata = self.adapter.resolve_runtime_metadata(&subagent_type).await;
295 let system_prompt_override =
296 Some(self.adapter.resolve_subagent_prompt(&subagent_type));
297
298 let result = child_session::create_child_action(
299 self.adapter.as_ref(),
300 CreateChildInput {
301 parent_session: parent.clone(),
302 child_id: child_id.clone(),
303 title: title.clone(),
304 responsibility: responsibility.clone(),
305 assignment_prompt: prompt.clone(),
306 subagent_type: subagent_type.clone(),
307 model_override,
308 model_ref_override,
309 runtime_metadata,
310 system_prompt_override,
311 auto_run: auto_run.unwrap_or(true),
312 reasoning_effort,
313 },
314 )
315 .await
316 .map_err(tool_error_from_child_session)?;
317
318 let _ = self
320 .adapter
321 .session_store
322 .get_index_entry(&result.child_session_id)
323 .await;
324
325 ctx.emit_tool_token(format!(
326 "Spawned child session: {}",
327 result.child_session_id
328 ))
329 .await;
330
331 tool_result(json!({
332 "title": title.clone(),
333 "description": title,
334 "responsibility": responsibility,
335 "prompt": prompt,
336 "subagent_type": subagent_type,
337 "child_session_id": result.child_session_id,
338 "parent_session_id": parent_session_id,
339 "model": result.model,
340 "reasoning_effort": reasoning_effort.map(|effort| effort.as_str()),
341 "note": "Child session created. Typical execution time: 30-120 seconds. Use action=get to check progress. Wait at least 30 seconds before polling. If the child fails or needs correction, use send_message (not create) to retry in place."
342 }))
343 }
344 SubSessionArgs::List => {
345 let result =
346 child_session::list_children_action(self.adapter.as_ref(), &parent.id).await;
347 tool_result(result)
348 }
349 SubSessionArgs::Get { child_session_id } => {
350 let result = child_session::get_child_action(
351 self.adapter.as_ref(),
352 &parent.id,
353 child_session_id,
354 )
355 .await
356 .map_err(tool_error_from_child_session)?;
357 tool_result(result)
358 }
359 SubSessionArgs::Update {
360 child_session_id,
361 title,
362 responsibility,
363 prompt,
364 subagent_type,
365 reset_after_update,
366 auto_run,
367 reasoning_effort,
368 } => {
369 let result = child_session::update_child_action(
370 self.adapter.as_ref(),
371 &parent.id,
372 child_session_id.clone(),
373 title,
374 responsibility,
375 prompt,
376 subagent_type,
377 reset_after_update,
378 reasoning_effort,
379 )
380 .await
381 .map_err(tool_error_from_child_session)?;
382
383 if auto_run.unwrap_or(false) {
384 let child = self
385 .adapter
386 .load_child_for_parent(&parent.id, &child_session_id)
387 .await
388 .map_err(tool_error_from_child_session)?;
389 self.adapter
390 .enqueue_child_run(&parent, &child)
391 .await
392 .map_err(tool_error_from_child_session)?;
393 }
394
395 tool_result(result)
396 }
397 SubSessionArgs::Run {
398 child_session_id,
399 reset_to_last_user,
400 } => {
401 let result = child_session::run_child_action(
402 self.adapter.as_ref(),
403 &parent,
404 child_session_id,
405 reset_to_last_user,
406 )
407 .await
408 .map_err(tool_error_from_child_session)?;
409 tool_result(result)
410 }
411 SubSessionArgs::SendMessage {
412 child_session_id,
413 message,
414 auto_run,
415 interrupt_running,
416 } => {
417 let result = child_session::send_message_to_child_action(
418 self.adapter.as_ref(),
419 &parent,
420 child_session_id,
421 message,
422 auto_run,
423 interrupt_running,
424 )
425 .await
426 .map_err(tool_error_from_child_session)?;
427 tool_result(result)
428 }
429 SubSessionArgs::Cancel { child_session_id } => {
430 let result = child_session::cancel_child_action(
431 self.adapter.as_ref(),
432 &parent.id,
433 child_session_id,
434 )
435 .await
436 .map_err(tool_error_from_child_session)?;
437 tool_result(result)
438 }
439 SubSessionArgs::Delete { child_session_id } => {
440 let result = child_session::delete_child_action(
441 self.adapter.as_ref(),
442 &parent.id,
443 child_session_id,
444 )
445 .await
446 .map_err(tool_error_from_child_session)?;
447 tool_result(result)
448 }
449 SubSessionArgs::ListProfiles => tool_result(self.list_profiles_payload()),
452 }
453 }
454}
455
456impl SubSessionTool {
457 fn list_profiles_payload(&self) -> serde_json::Value {
484 let profiles: Vec<serde_json::Value> = self
488 .profiles
489 .iter()
490 .map(|p| {
491 json!({
492 "id": p.id,
493 "display_name": p.display_name,
494 "description": p.description,
495 "tools": p.tools,
496 "model_hint": p.model_hint,
497 "default_responsibility": p.default_responsibility,
498 "ui": p.ui,
499 })
500 })
501 .collect();
502 json!({
503 "profiles": profiles,
504 "fallback_id": self.profiles.fallback_id(),
505 "count": self.profiles.len(),
506 })
507 }
508}
509
510#[cfg(test)]
515mod tests {
516 use super::*;
517
518 use std::collections::HashMap;
519 use std::path::PathBuf;
520 use std::time::Duration;
521 use tokio::sync::{broadcast, RwLock};
522
523 use crate::app_state::{AgentRunner, AgentStatus};
524 use crate::spawn_scheduler::{SpawnContext, SpawnScheduler};
525 use bamboo_agent_core::storage::Storage;
526 use bamboo_agent_core::tools::{ToolCall, ToolExecutor, ToolSchema};
527 use bamboo_agent_core::{AgentEvent, Message, Role, Session};
528 use bamboo_engine::metrics::storage::SqliteMetricsStorage;
529 use bamboo_engine::MetricsCollector;
530 use bamboo_engine::SkillManager;
531 use bamboo_infrastructure::SessionStoreV2;
532 use bamboo_infrastructure::{LLMError, LLMProvider, LLMStream};
533
534 struct NoopProvider;
535
536 #[async_trait::async_trait]
537 impl LLMProvider for NoopProvider {
538 async fn chat_stream(
539 &self,
540 _messages: &[Message],
541 _tools: &[ToolSchema],
542 _max_output_tokens: Option<u32>,
543 _model: &str,
544 ) -> Result<LLMStream, LLMError> {
545 Err(LLMError::Api("noop".to_string()))
546 }
547 }
548
549 struct NoopToolExecutor;
550
551 #[async_trait::async_trait]
552 impl ToolExecutor for NoopToolExecutor {
553 async fn execute(&self, _call: &ToolCall) -> std::result::Result<ToolResult, ToolError> {
554 Err(ToolError::NotFound("noop".to_string()))
555 }
556
557 fn list_tools(&self) -> Vec<ToolSchema> {
558 Vec::new()
559 }
560 }
561
562 fn make_temp_dir(prefix: &str) -> PathBuf {
563 std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4()))
564 }
565
566 struct TestHarness {
567 tool: SubSessionTool,
568 storage: Arc<dyn Storage>,
569 agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
570 parent_session_id: String,
571 child_session_id: String,
572 parent_rx: broadcast::Receiver<AgentEvent>,
573 }
574
575 async fn build_test_harness() -> TestHarness {
576 build_test_harness_with_resolver(None).await
577 }
578
579 async fn build_test_harness_with_resolver(
580 subagent_model_resolver: crate::tools::OptionalSubagentModelResolver,
581 ) -> TestHarness {
582 let bamboo_home = make_temp_dir("bamboo-sub-session-test");
583 tokio::fs::create_dir_all(&bamboo_home).await.unwrap();
584
585 let session_store = Arc::new(SessionStoreV2::new(bamboo_home.clone()).await.unwrap());
586 let storage_dir = bamboo_home.join("storage");
587 tokio::fs::create_dir_all(&storage_dir).await.unwrap();
588 let jsonl = bamboo_infrastructure::JsonlStorage::new(&storage_dir);
589 jsonl.init().await.unwrap();
590 let storage: Arc<dyn Storage> = Arc::new(jsonl);
591
592 let metrics_storage = Arc::new(SqliteMetricsStorage::new(bamboo_home.join("metrics.db")));
593 let metrics_collector = MetricsCollector::spawn(metrics_storage, 7);
594
595 let sessions_cache = Arc::new(RwLock::new(HashMap::new()));
596 let agent_runners = Arc::new(RwLock::new(HashMap::new()));
597 let session_event_senders = Arc::new(RwLock::new(HashMap::<
598 String,
599 broadcast::Sender<AgentEvent>,
600 >::new()));
601
602 let parent_session_id = "root-session".to_string();
603 let child_session_id = "child-session".to_string();
604 let (parent_tx, parent_rx) = broadcast::channel(1000);
605 {
606 let mut senders = session_event_senders.write().await;
607 senders.insert(parent_session_id.clone(), parent_tx);
608 }
609
610 let mut parent = Session::new(parent_session_id.clone(), "gpt-5");
611 parent.title = "Root".to_string();
612 storage.save_session(&parent).await.unwrap();
613 session_store.save_session(&parent).await.unwrap();
614
615 let mut child = Session::new_child(
616 child_session_id.clone(),
617 parent_session_id.clone(),
618 "gpt-5",
619 "Child session",
620 );
621 child
622 .metadata
623 .insert("last_run_status".to_string(), "completed".to_string());
624 child.add_message(Message::system("child system"));
625 child.add_message(Message::user("initial assignment"));
626 child.add_message(Message::assistant("initial answer", None));
627 storage.save_session(&child).await.unwrap();
628 session_store.save_session(&child).await.unwrap();
629
630 let agent_runtime = Arc::new(
631 bamboo_engine::Agent::builder()
632 .storage(storage.clone())
633 .attachment_reader(session_store.clone())
634 .skill_manager(Arc::new(SkillManager::new()))
635 .metrics_collector(metrics_collector)
636 .config(Arc::new(RwLock::new(
637 bamboo_infrastructure::Config::default(),
638 )))
639 .provider(Arc::new(NoopProvider))
640 .default_tools(Arc::new(NoopToolExecutor))
641 .build()
642 .expect("test agent should be fully configured"),
643 );
644
645 let scheduler = Arc::new(SpawnScheduler::new(SpawnContext {
646 agent: agent_runtime,
647 tools: Arc::new(NoopToolExecutor),
648 sessions_cache: sessions_cache.clone(),
649 agent_runners: agent_runners.clone(),
650 session_event_senders: session_event_senders.clone(),
651 external_child_runner: None,
652 provider_router: None,
653 }));
654
655 let test_profiles = std::sync::Arc::new(
656 bamboo_domain::subagent::SubagentProfileRegistry::builder()
657 .extend(crate::subagent_profiles::builtin::builtin_profiles())
658 .build()
659 .expect("builtin subagent profiles must build"),
660 );
661 let adapter = Arc::new(ChildSessionAdapter {
662 session_store,
663 storage: storage.clone(),
664 scheduler,
665 sessions_cache,
666 agent_runners: agent_runners.clone(),
667 session_event_senders,
668 subagent_model_resolver,
669 config: Arc::new(RwLock::new(bamboo_infrastructure::Config::default())),
670 subagent_profiles: test_profiles.clone(),
671 });
672 let tool = SubSessionTool::new(adapter, test_profiles);
673
674 TestHarness {
675 tool,
676 storage,
677 agent_runners,
678 parent_session_id,
679 child_session_id,
680 parent_rx,
681 }
682 }
683
684 #[test]
689 fn normalize_title_accepts_legacy_description() {
690 let title = normalize_title(None, "Search refs".to_string()).unwrap();
691 assert_eq!(title, "Search refs");
692 }
693
694 #[test]
695 fn normalize_title_prefers_title_over_description() {
696 let title =
697 normalize_title(Some("Real title".to_string()), "Legacy desc".to_string()).unwrap();
698 assert_eq!(title, "Real title");
699 }
700
701 #[test]
702 fn normalize_title_rejects_both_empty() {
703 let err = normalize_title(None, "".to_string()).unwrap_err();
704 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("title")));
705 }
706
707 #[tokio::test]
712 async fn create_requires_session_id_in_tool_context() {
713 let harness = build_test_harness().await;
714
715 let err = harness
716 .tool
717 .execute_with_context(
718 json!({
719 "action": "create",
720 "title": "demo task",
721 "responsibility": "do something",
722 "prompt": "do something",
723 "subagent_type": "general-purpose"
724 }),
725 ToolExecutionContext::none("tool_call"),
726 )
727 .await
728 .unwrap_err();
729
730 match err {
731 ToolError::Execution(msg) => {
732 assert!(msg.contains("SubSession requires a session_id in tool context"));
733 }
734 other => panic!("unexpected error: {other:?}"),
735 }
736 }
737
738 #[tokio::test]
739 async fn create_emits_sub_session_started_event_after_queueing() {
740 let mut harness = build_test_harness().await;
741
742 let result = harness
743 .tool
744 .execute_with_context(
745 json!({
746 "action": "create",
747 "title": "Child A",
748 "responsibility": "Investigate one module",
749 "prompt": "Read module and summarize",
750 "subagent_type": "general-purpose"
751 }),
752 ToolExecutionContext {
753 session_id: Some(harness.parent_session_id.as_str()),
754 tool_call_id: "tool_call_1",
755 event_tx: None,
756 available_tool_schemas: None,
757 },
758 )
759 .await
760 .expect("SubSession should enqueue a child session");
761
762 let parsed_result: serde_json::Value =
763 serde_json::from_str(&result.result).expect("tool result should be JSON");
764 let child_session_id = parsed_result
765 .get("child_session_id")
766 .and_then(|v| v.as_str())
767 .expect("tool result should include child_session_id")
768 .to_string();
769
770 let started_event = tokio::time::timeout(Duration::from_secs(2), async {
771 loop {
772 match harness.parent_rx.recv().await {
773 Ok(AgentEvent::SubSessionStarted {
774 parent_session_id: pid,
775 child_session_id: cid,
776 ..
777 }) => break (pid, cid),
778 Ok(_) => continue,
779 Err(broadcast::error::RecvError::Lagged(_)) => continue,
780 Err(broadcast::error::RecvError::Closed) => {
781 panic!("parent stream closed before start event")
782 }
783 }
784 }
785 })
786 .await
787 .expect("should receive SubSessionStarted event quickly");
788
789 assert_eq!(started_event.0, harness.parent_session_id);
790 assert_eq!(started_event.1, child_session_id);
791 }
792
793 #[tokio::test]
794 async fn create_uses_async_subagent_model_resolver() {
795 let resolver: crate::tools::SubagentModelResolver = Arc::new(|subagent_type: String| {
796 Box::pin(async move {
797 assert_eq!(subagent_type, "coder");
798 Some(bamboo_domain::ProviderModelRef::new(
799 "openai",
800 "gpt-resolved-coder",
801 ))
802 })
803 });
804 let harness = build_test_harness_with_resolver(Some(resolver)).await;
805
806 let result = harness
807 .tool
808 .execute_with_context(
809 json!({
810 "action": "create",
811 "title": "Coder Child",
812 "responsibility": "Implement a focused change",
813 "prompt": "Patch one file",
814 "subagent_type": "coder",
815 "auto_run": false
816 }),
817 ToolExecutionContext {
818 session_id: Some(harness.parent_session_id.as_str()),
819 tool_call_id: "tool_call_async_resolver",
820 event_tx: None,
821 available_tool_schemas: None,
822 },
823 )
824 .await
825 .expect("SubSession should create a child using async model resolver");
826
827 let payload: serde_json::Value =
828 serde_json::from_str(&result.result).expect("tool result should be JSON");
829 assert_eq!(payload["model"], "gpt-resolved-coder");
830
831 let child_id = payload["child_session_id"]
832 .as_str()
833 .expect("child_session_id should be present");
834 let child = harness
835 .storage
836 .load_session(child_id)
837 .await
838 .unwrap()
839 .expect("child session should exist");
840 assert_eq!(child.model, "gpt-resolved-coder");
841 assert_eq!(
842 child.model_ref,
843 Some(bamboo_domain::ProviderModelRef::new(
844 "openai",
845 "gpt-resolved-coder",
846 ))
847 );
848 assert_eq!(
849 child.metadata.get("provider_name").map(String::as_str),
850 Some("openai")
851 );
852 }
853
854 #[tokio::test]
855 async fn backward_compat_legacy_subsession_call_without_action_defaults_to_create() {
856 let harness = build_test_harness().await;
857
858 let result = harness
859 .tool
860 .execute_with_context(
861 json!({
862 "title": "Legacy Child",
863 "responsibility": "Test backward compat",
864 "prompt": "Do something",
865 "subagent_type": "general-purpose"
866 }),
867 ToolExecutionContext {
868 session_id: Some(harness.parent_session_id.as_str()),
869 tool_call_id: "tool_call_legacy",
870 event_tx: None,
871 available_tool_schemas: None,
872 },
873 )
874 .await
875 .expect("legacy SubSession call without action should default to create");
876
877 assert!(result.success);
878 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
879 assert!(parsed.get("child_session_id").is_some());
880 }
881
882 #[tokio::test]
887 async fn send_message_appends_follow_up_without_replacing_history() {
888 let harness = build_test_harness().await;
889
890 let result = harness
891 .tool
892 .execute_with_context(
893 json!({
894 "action": "send_message",
895 "child_session_id": harness.child_session_id,
896 "message": "continue with the failing parser path",
897 "auto_run": false
898 }),
899 ToolExecutionContext {
900 session_id: Some(harness.parent_session_id.as_str()),
901 tool_call_id: "tool_call_send_message",
902 event_tx: None,
903 available_tool_schemas: None,
904 },
905 )
906 .await
907 .expect("send_message should succeed");
908
909 let payload: serde_json::Value =
910 serde_json::from_str(&result.result).expect("tool result should be JSON");
911 assert_eq!(payload["status"], "pending");
912
913 let child = harness
914 .storage
915 .load_session(&harness.child_session_id)
916 .await
917 .unwrap()
918 .expect("child session should exist");
919 assert_eq!(child.messages.len(), 4);
920 assert!(matches!(child.messages[2].role, Role::Assistant));
921 assert!(matches!(child.messages[3].role, Role::User));
922 assert_eq!(
923 child.messages[3].content,
924 "continue with the failing parser path"
925 );
926 assert_eq!(
927 child.metadata.get("last_run_status").map(String::as_str),
928 Some("pending")
929 );
930 }
931
932 #[tokio::test]
933 async fn send_message_queues_on_running_child_without_interrupt() {
934 let harness = build_test_harness().await;
935 {
936 let mut runners = harness.agent_runners.write().await;
937 let mut runner = AgentRunner::new();
938 runner.status = AgentStatus::Running;
939 runners.insert(harness.child_session_id.clone(), runner);
940 }
941
942 let result = harness
943 .tool
944 .execute_with_context(
945 json!({
946 "action": "send_message",
947 "child_session_id": harness.child_session_id,
948 "message": "continue"
949 }),
950 ToolExecutionContext {
951 session_id: Some(harness.parent_session_id.as_str()),
952 tool_call_id: "tool_call_running",
953 event_tx: None,
954 available_tool_schemas: None,
955 },
956 )
957 .await
958 .expect("send_message should queue message on running child");
959
960 assert!(result.success);
961 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
962 assert_eq!(payload["status"], "message_queued");
963 assert_eq!(payload["message"], "continue");
964
965 let child = harness
966 .storage
967 .load_session(&harness.child_session_id)
968 .await
969 .unwrap()
970 .expect("child session should exist");
971 assert_eq!(child.messages.len(), 3);
974 let pending_raw = child
975 .metadata
976 .get("pending_injected_messages")
977 .expect("pending_injected_messages should be set");
978 let pending: Vec<child_session::QueuedInjectedMessage> =
979 serde_json::from_str(pending_raw).expect("should parse queued messages");
980 assert_eq!(pending.len(), 1);
981 assert_eq!(pending[0].content, "continue");
982 }
983
984 #[tokio::test]
985 async fn send_message_can_interrupt_running_child() {
986 let harness = build_test_harness().await;
987 let cancel_token = {
988 let mut runners = harness.agent_runners.write().await;
989 let mut runner = AgentRunner::new();
990 runner.status = AgentStatus::Running;
991 let cancel_token = runner.cancel_token.clone();
992 runners.insert(harness.child_session_id.clone(), runner);
993 cancel_token
994 };
995
996 let runners_for_status = harness.agent_runners.clone();
997 let child_id_for_status = harness.child_session_id.clone();
998 let waiter = tokio::spawn(async move {
999 cancel_token.cancelled().await;
1000 let mut runners = runners_for_status.write().await;
1001 if let Some(runner) = runners.get_mut(&child_id_for_status) {
1002 runner.status = AgentStatus::Cancelled;
1003 }
1004 });
1005
1006 let result = harness
1007 .tool
1008 .execute_with_context(
1009 json!({
1010 "action": "send_message",
1011 "child_session_id": harness.child_session_id,
1012 "message": "continue from latest state",
1013 "auto_run": false,
1014 "interrupt_running": true
1015 }),
1016 ToolExecutionContext {
1017 session_id: Some(harness.parent_session_id.as_str()),
1018 tool_call_id: "tool_call_interrupt_running",
1019 event_tx: None,
1020 available_tool_schemas: None,
1021 },
1022 )
1023 .await
1024 .expect("send_message should interrupt running child");
1025
1026 waiter.await.expect("waiter task should finish");
1027
1028 let payload: serde_json::Value =
1029 serde_json::from_str(&result.result).expect("tool result should be JSON");
1030 assert_eq!(payload["status"], "pending");
1031 assert_eq!(payload["auto_run"], false);
1032
1033 let child = harness
1034 .storage
1035 .load_session(&harness.child_session_id)
1036 .await
1037 .unwrap()
1038 .expect("child session should exist");
1039 assert!(matches!(
1040 child.messages.last().map(|m| &m.role),
1041 Some(Role::User)
1042 ));
1043 assert_eq!(
1044 child.messages.last().map(|m| m.content.as_str()),
1045 Some("continue from latest state")
1046 );
1047 assert_eq!(
1048 child.metadata.get("last_run_status").map(String::as_str),
1049 Some("pending")
1050 );
1051 }
1052
1053 #[tokio::test]
1054 async fn send_message_can_queue_child_immediately() {
1055 let mut harness = build_test_harness().await;
1056
1057 let result = harness
1058 .tool
1059 .execute_with_context(
1060 json!({
1061 "action": "send_message",
1062 "child_session_id": harness.child_session_id,
1063 "message": "retry with a narrower scope"
1064 }),
1065 ToolExecutionContext {
1066 session_id: Some(harness.parent_session_id.as_str()),
1067 tool_call_id: "tool_call_queue",
1068 event_tx: None,
1069 available_tool_schemas: None,
1070 },
1071 )
1072 .await
1073 .expect("send_message should queue the child");
1074
1075 let payload: serde_json::Value =
1076 serde_json::from_str(&result.result).expect("tool result should be JSON");
1077 assert_eq!(payload["status"], "queued");
1078 assert_eq!(payload["auto_run"], true);
1079
1080 let started_event = tokio::time::timeout(Duration::from_secs(2), async {
1081 loop {
1082 match harness.parent_rx.recv().await {
1083 Ok(AgentEvent::SubSessionStarted {
1084 parent_session_id,
1085 child_session_id,
1086 ..
1087 }) => break (parent_session_id, child_session_id),
1088 Ok(_) => continue,
1089 Err(broadcast::error::RecvError::Lagged(_)) => continue,
1090 Err(broadcast::error::RecvError::Closed) => {
1091 panic!("parent stream closed before start event")
1092 }
1093 }
1094 }
1095 })
1096 .await
1097 .expect("should receive SubSessionStarted event");
1098
1099 assert_eq!(started_event.0, harness.parent_session_id);
1100 assert_eq!(started_event.1, harness.child_session_id);
1101 }
1102
1103 #[tokio::test]
1104 async fn cancel_stops_running_child() {
1105 let harness = build_test_harness().await;
1106 let cancel_token = {
1107 let mut runners = harness.agent_runners.write().await;
1108 let mut runner = AgentRunner::new();
1109 runner.status = AgentStatus::Running;
1110 let token = runner.cancel_token.clone();
1111 runners.insert(harness.child_session_id.clone(), runner);
1112 token
1113 };
1114
1115 let runners_for_wait = harness.agent_runners.clone();
1116 let child_id_for_wait = harness.child_session_id.clone();
1117 let waiter = tokio::spawn(async move {
1118 cancel_token.cancelled().await;
1119 let mut runners = runners_for_wait.write().await;
1120 if let Some(runner) = runners.get_mut(&child_id_for_wait) {
1121 runner.status = AgentStatus::Cancelled;
1122 }
1123 });
1124
1125 let result = harness
1126 .tool
1127 .execute_with_context(
1128 json!({
1129 "action": "cancel",
1130 "child_session_id": harness.child_session_id
1131 }),
1132 ToolExecutionContext {
1133 session_id: Some(harness.parent_session_id.as_str()),
1134 tool_call_id: "tool_call_cancel",
1135 event_tx: None,
1136 available_tool_schemas: None,
1137 },
1138 )
1139 .await
1140 .expect("cancel should succeed");
1141
1142 waiter.await.expect("waiter should finish");
1143
1144 let payload: serde_json::Value =
1145 serde_json::from_str(&result.result).expect("tool result should be JSON");
1146 assert_eq!(payload["status"], "cancelled");
1147 assert_eq!(payload["child_session_id"], harness.child_session_id);
1148 }
1149
1150 #[tokio::test]
1151 async fn list_returns_children() {
1152 let harness = build_test_harness().await;
1153
1154 let result = harness
1155 .tool
1156 .execute_with_context(
1157 json!({"action": "list"}),
1158 ToolExecutionContext {
1159 session_id: Some(harness.parent_session_id.as_str()),
1160 tool_call_id: "tool_call_list",
1161 event_tx: None,
1162 available_tool_schemas: None,
1163 },
1164 )
1165 .await
1166 .expect("list should succeed");
1167
1168 let payload: serde_json::Value =
1169 serde_json::from_str(&result.result).expect("tool result should be JSON");
1170 let children = payload["children"]
1171 .as_array()
1172 .expect("list result should have children array");
1173 assert_eq!(children.len(), 1);
1174 assert_eq!(children[0]["child_session_id"], harness.child_session_id);
1175 assert_eq!(payload["count"], 1);
1176 }
1177
1178 #[tokio::test]
1179 async fn get_returns_runner_diagnostics() {
1180 let harness = build_test_harness().await;
1181
1182 {
1184 let mut runners = harness.agent_runners.write().await;
1185 let mut runner = AgentRunner::new();
1186 runner.status = AgentStatus::Running;
1187 runner.last_tool_name = Some("Read".to_string());
1188 runner.last_tool_phase = Some("begin".to_string());
1189 runner.round_count = 3;
1190 runners.insert(harness.child_session_id.clone(), runner);
1191 }
1192
1193 let result = harness
1194 .tool
1195 .execute_with_context(
1196 json!({
1197 "action": "get",
1198 "child_session_id": harness.child_session_id
1199 }),
1200 ToolExecutionContext {
1201 session_id: Some(harness.parent_session_id.as_str()),
1202 tool_call_id: "tool_call_get_diagnostics",
1203 event_tx: None,
1204 available_tool_schemas: None,
1205 },
1206 )
1207 .await
1208 .expect("get should succeed");
1209
1210 let payload: serde_json::Value =
1211 serde_json::from_str(&result.result).expect("tool result should be JSON");
1212 assert_eq!(payload["child_session_id"], harness.child_session_id);
1213 assert_eq!(payload["is_running"], true);
1214 assert_eq!(payload["last_tool_name"], "Read");
1215 assert_eq!(payload["last_tool_phase"], "begin");
1216 assert_eq!(payload["round_count"], 3);
1217 assert!(payload["runner_started_at"].is_string());
1218 assert!(payload.get("guidance").is_some());
1219 }
1220
1221 #[tokio::test]
1222 async fn create_returns_duration_hint() {
1223 let harness = build_test_harness().await;
1224
1225 let result = harness
1226 .tool
1227 .execute_with_context(
1228 json!({
1229 "action": "create",
1230 "title": "Test Child",
1231 "responsibility": "Do something",
1232 "prompt": "Do something useful",
1233 "subagent_type": "general-purpose",
1234 "auto_run": false
1235 }),
1236 ToolExecutionContext {
1237 session_id: Some(harness.parent_session_id.as_str()),
1238 tool_call_id: "tool_call_create_hint",
1239 event_tx: None,
1240 available_tool_schemas: None,
1241 },
1242 )
1243 .await
1244 .expect("create should succeed");
1245
1246 let payload: serde_json::Value =
1247 serde_json::from_str(&result.result).expect("tool result should be JSON");
1248 let note = payload["note"].as_str().expect("note should be present");
1249 assert!(
1250 note.contains("30-120 seconds"),
1251 "note should contain estimated duration hint: {note}"
1252 );
1253 assert!(
1254 note.contains("send_message"),
1255 "note should mention send_message: {note}"
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn create_persists_explicit_reasoning_effort_to_child_session() {
1261 let harness = build_test_harness().await;
1262
1263 let result = harness
1264 .tool
1265 .execute_with_context(
1266 json!({
1267 "action": "create",
1268 "title": "Reasoning Child",
1269 "responsibility": "Investigate hard problem",
1270 "prompt": "Think carefully step by step",
1271 "subagent_type": "general-purpose",
1272 "auto_run": false,
1273 "reasoning_effort": "high"
1274 }),
1275 ToolExecutionContext {
1276 session_id: Some(harness.parent_session_id.as_str()),
1277 tool_call_id: "tool_call_create_with_effort",
1278 event_tx: None,
1279 available_tool_schemas: None,
1280 },
1281 )
1282 .await
1283 .expect("create should succeed");
1284
1285 let payload: serde_json::Value =
1286 serde_json::from_str(&result.result).expect("tool result should be JSON");
1287 assert_eq!(
1288 payload["reasoning_effort"].as_str(),
1289 Some("high"),
1290 "tool result should echo the resolved reasoning_effort"
1291 );
1292
1293 let child_id = payload["child_session_id"]
1294 .as_str()
1295 .expect("child_session_id present")
1296 .to_string();
1297 let child = harness
1298 .storage
1299 .load_session(&child_id)
1300 .await
1301 .expect("child should be persisted")
1302 .expect("child session should exist");
1303 assert_eq!(
1304 child.reasoning_effort,
1305 Some(bamboo_domain::ReasoningEffort::High),
1306 "child.reasoning_effort should reflect the explicit override"
1307 );
1308 }
1309
1310 #[tokio::test]
1311 async fn create_without_reasoning_effort_leaves_child_at_provider_default() {
1312 let harness = build_test_harness().await;
1313
1314 let result = harness
1315 .tool
1316 .execute_with_context(
1317 json!({
1318 "action": "create",
1319 "title": "Default Child",
1320 "responsibility": "Quick lookup",
1321 "prompt": "Read a file and summarise",
1322 "subagent_type": "general-purpose",
1323 "auto_run": false
1324 }),
1325 ToolExecutionContext {
1326 session_id: Some(harness.parent_session_id.as_str()),
1327 tool_call_id: "tool_call_create_default_effort",
1328 event_tx: None,
1329 available_tool_schemas: None,
1330 },
1331 )
1332 .await
1333 .expect("create should succeed");
1334
1335 let payload: serde_json::Value =
1336 serde_json::from_str(&result.result).expect("tool result should be JSON");
1337 assert!(
1338 payload["reasoning_effort"].is_null(),
1339 "tool result should report null reasoning_effort when omitted, got {:?}",
1340 payload["reasoning_effort"]
1341 );
1342
1343 let child_id = payload["child_session_id"]
1344 .as_str()
1345 .expect("child_session_id present")
1346 .to_string();
1347 let child = harness
1348 .storage
1349 .load_session(&child_id)
1350 .await
1351 .expect("child should be persisted")
1352 .expect("child session should exist");
1353 assert_eq!(
1354 child.reasoning_effort, None,
1355 "child.reasoning_effort should stay at None (provider default) when caller omits it; \
1356 children must NOT inherit the parent's reasoning_effort"
1357 );
1358 }
1359
1360 #[tokio::test]
1361 async fn update_can_change_reasoning_effort_on_existing_child() {
1362 let harness = build_test_harness().await;
1363
1364 let seeded = harness
1366 .storage
1367 .load_session(&harness.child_session_id)
1368 .await
1369 .expect("seeded child should load")
1370 .expect("seeded child exists");
1371 assert_eq!(seeded.reasoning_effort, None);
1372
1373 let _ = harness
1374 .tool
1375 .execute_with_context(
1376 json!({
1377 "action": "update",
1378 "child_session_id": harness.child_session_id,
1379 "reasoning_effort": "max"
1380 }),
1381 ToolExecutionContext {
1382 session_id: Some(harness.parent_session_id.as_str()),
1383 tool_call_id: "tool_call_update_effort",
1384 event_tx: None,
1385 available_tool_schemas: None,
1386 },
1387 )
1388 .await
1389 .expect("update should succeed");
1390
1391 let updated = harness
1392 .storage
1393 .load_session(&harness.child_session_id)
1394 .await
1395 .expect("updated child should load")
1396 .expect("child still exists");
1397 assert_eq!(
1398 updated.reasoning_effort,
1399 Some(bamboo_domain::ReasoningEffort::Max),
1400 "update should persist the new reasoning_effort"
1401 );
1402 }
1403
1404 #[tokio::test]
1405 async fn delete_removes_child() {
1406 let harness = build_test_harness().await;
1407
1408 let result = harness
1409 .tool
1410 .execute_with_context(
1411 json!({
1412 "action": "delete",
1413 "child_session_id": harness.child_session_id
1414 }),
1415 ToolExecutionContext {
1416 session_id: Some(harness.parent_session_id.as_str()),
1417 tool_call_id: "tool_call_delete",
1418 event_tx: None,
1419 available_tool_schemas: None,
1420 },
1421 )
1422 .await
1423 .expect("delete should succeed");
1424
1425 let payload: serde_json::Value =
1426 serde_json::from_str(&result.result).expect("tool result should be JSON");
1427 assert_eq!(payload["deleted"], true);
1428
1429 let child = harness
1430 .storage
1431 .load_session(&harness.child_session_id)
1432 .await
1433 .unwrap();
1434 assert!(child.is_none());
1435 }
1436
1437 #[tokio::test]
1442 async fn list_profiles_returns_builtin_catalog() {
1443 let harness = build_test_harness().await;
1444
1445 let result = harness
1446 .tool
1447 .execute_with_context(
1448 json!({"action": "list_profiles"}),
1449 ToolExecutionContext {
1450 session_id: Some(harness.parent_session_id.as_str()),
1451 tool_call_id: "tool_call_list_profiles",
1452 event_tx: None,
1453 available_tool_schemas: None,
1454 },
1455 )
1456 .await
1457 .expect("list_profiles should succeed");
1458
1459 let payload: serde_json::Value =
1460 serde_json::from_str(&result.result).expect("tool result should be JSON");
1461
1462 let profiles = payload["profiles"]
1464 .as_array()
1465 .expect("list_profiles must return a `profiles` array");
1466 assert!(
1467 profiles.len() >= 6,
1468 "expected at least 6 built-in profiles, got {}",
1469 profiles.len()
1470 );
1471 assert_eq!(payload["count"], profiles.len());
1472 assert_eq!(payload["fallback_id"], "general-purpose");
1473
1474 for entry in profiles {
1477 assert!(entry.get("id").and_then(|v| v.as_str()).is_some());
1478 assert!(entry.get("display_name").and_then(|v| v.as_str()).is_some());
1479 assert!(entry.get("tools").is_some());
1480 assert!(
1481 entry.get("system_prompt").is_none(),
1482 "system_prompt must NOT be returned by list_profiles",
1483 );
1484 }
1485
1486 let ids: Vec<&str> = profiles
1489 .iter()
1490 .map(|p| p["id"].as_str().unwrap_or(""))
1491 .collect();
1492 for required in [
1493 "general-purpose",
1494 "plan",
1495 "researcher",
1496 "coder",
1497 "reviewer",
1498 "tester",
1499 ] {
1500 assert!(
1501 ids.contains(&required),
1502 "built-in profile `{required}` missing from list_profiles output (got: {ids:?})"
1503 );
1504 }
1505 }
1506
1507 #[tokio::test]
1512 async fn list_profiles_does_not_load_parent_session() {
1513 let harness = build_test_harness().await;
1514
1515 let result = harness
1516 .tool
1517 .execute_with_context(
1518 json!({"action": "list_profiles"}),
1519 ToolExecutionContext {
1520 session_id: Some("non-existent-session-id"),
1521 tool_call_id: "tool_call_list_profiles_no_session",
1522 event_tx: None,
1523 available_tool_schemas: None,
1524 },
1525 )
1526 .await
1527 .expect("list_profiles should succeed even when the parent session id is unknown");
1528
1529 let payload: serde_json::Value =
1530 serde_json::from_str(&result.result).expect("tool result should be JSON");
1531 assert!(payload["profiles"].as_array().is_some());
1532 }
1533}