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