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 account_feed_inbox: None,
706 }));
707
708 let test_profiles = std::sync::Arc::new(
709 bamboo_domain::subagent::SubagentProfileRegistry::builder()
710 .extend(crate::subagent_profiles::builtin::builtin_profiles())
711 .build()
712 .expect("builtin subagent profiles must build"),
713 );
714 let adapter = Arc::new(ChildSessionAdapter {
715 session_store,
716 storage: storage.clone(),
717 persistence: Arc::new(bamboo_infrastructure::LockedSessionStore::new(
718 storage.clone(),
719 )),
720 scheduler,
721 sessions_cache,
722 agent_runners: agent_runners.clone(),
723 session_event_senders,
724 subagent_model_resolver,
725 config: Arc::new(RwLock::new(bamboo_infrastructure::Config::default())),
726 subagent_profiles: test_profiles.clone(),
727 tool_names: Vec::new(),
728 });
729 let tool = SubAgentTool::new(adapter, test_profiles);
730
731 TestHarness {
732 tool,
733 storage,
734 agent_runners,
735 parent_session_id,
736 child_session_id,
737 parent_rx,
738 }
739 }
740
741 #[test]
746 fn normalize_title_accepts_legacy_description() {
747 let title = normalize_title(None, "Search refs".to_string()).unwrap();
748 assert_eq!(title, "Search refs");
749 }
750
751 #[test]
752 fn normalize_title_prefers_title_over_description() {
753 let title =
754 normalize_title(Some("Real title".to_string()), "Legacy desc".to_string()).unwrap();
755 assert_eq!(title, "Real title");
756 }
757
758 #[test]
759 fn normalize_title_rejects_both_empty() {
760 let err = normalize_title(None, "".to_string()).unwrap_err();
761 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("title")));
762 }
763
764 #[tokio::test]
769 async fn create_requires_session_id_in_tool_context() {
770 let harness = build_test_harness().await;
771
772 let err = harness
773 .tool
774 .execute_with_context(
775 json!({
776 "action": "create",
777 "title": "demo task",
778 "responsibility": "do something",
779 "prompt": "do something",
780 "subagent_type": "general-purpose",
781 "workspace": "/tmp/test-workspace"
782 }),
783 ToolExecutionContext::none("tool_call"),
784 )
785 .await
786 .unwrap_err();
787
788 match err {
789 ToolError::Execution(msg) => {
790 assert!(msg.contains("SubAgent requires a session_id in tool context"));
791 }
792 other => panic!("unexpected error: {other:?}"),
793 }
794 }
795
796 #[tokio::test]
797 async fn create_emits_sub_agent_started_event_after_queueing() {
798 let mut harness = build_test_harness().await;
799
800 let result = harness
801 .tool
802 .execute_with_context(
803 json!({
804 "action": "create",
805 "title": "Child A",
806 "responsibility": "Investigate one module",
807 "prompt": "Read module and summarize",
808 "subagent_type": "general-purpose",
809 "workspace": "/tmp/test-workspace"
810 }),
811 ToolExecutionContext {
812 session_id: Some(harness.parent_session_id.as_str()),
813 tool_call_id: "tool_call_1",
814 event_tx: None,
815 available_tool_schemas: None,
816 },
817 )
818 .await
819 .expect("SubAgent should enqueue a child session");
820
821 let parsed_result: serde_json::Value =
822 serde_json::from_str(&result.result).expect("tool result should be JSON");
823 let child_session_id = parsed_result
824 .get("child_session_id")
825 .and_then(|v| v.as_str())
826 .expect("tool result should include child_session_id")
827 .to_string();
828
829 let started_event = tokio::time::timeout(Duration::from_secs(2), async {
830 loop {
831 match harness.parent_rx.recv().await {
832 Ok(AgentEvent::SubAgentStarted {
833 parent_session_id: pid,
834 child_session_id: cid,
835 ..
836 }) => break (pid, cid),
837 Ok(_) => continue,
838 Err(broadcast::error::RecvError::Lagged(_)) => continue,
839 Err(broadcast::error::RecvError::Closed) => {
840 panic!("parent stream closed before start event")
841 }
842 }
843 }
844 })
845 .await
846 .expect("should receive SubAgentStarted event quickly");
847
848 assert_eq!(started_event.0, harness.parent_session_id);
849 assert_eq!(started_event.1, child_session_id);
850 }
851
852 #[tokio::test]
853 async fn create_uses_async_subagent_model_resolver() {
854 let resolver: crate::tools::SubagentModelResolver = Arc::new(|subagent_type: String| {
855 Box::pin(async move {
856 assert_eq!(subagent_type, "coder");
857 Some(bamboo_domain::ProviderModelRef::new(
858 "openai",
859 "gpt-resolved-coder",
860 ))
861 })
862 });
863 let harness = build_test_harness_with_resolver(Some(resolver)).await;
864
865 let result = harness
866 .tool
867 .execute_with_context(
868 json!({
869 "action": "create",
870 "title": "Coder Child",
871 "responsibility": "Implement a focused change",
872 "prompt": "Patch one file",
873 "subagent_type": "coder",
874 "workspace": "/tmp/test-workspace",
875 "auto_run": false
876 }),
877 ToolExecutionContext {
878 session_id: Some(harness.parent_session_id.as_str()),
879 tool_call_id: "tool_call_async_resolver",
880 event_tx: None,
881 available_tool_schemas: None,
882 },
883 )
884 .await
885 .expect("SubAgent should create a child using async model resolver");
886
887 let payload: serde_json::Value =
888 serde_json::from_str(&result.result).expect("tool result should be JSON");
889 assert_eq!(payload["model"], "gpt-resolved-coder");
890
891 let child_id = payload["child_session_id"]
892 .as_str()
893 .expect("child_session_id should be present");
894 let child = harness
895 .storage
896 .load_session(child_id)
897 .await
898 .unwrap()
899 .expect("child session should exist");
900 assert_eq!(child.model, "gpt-resolved-coder");
901 assert_eq!(
902 child.model_ref,
903 Some(bamboo_domain::ProviderModelRef::new(
904 "openai",
905 "gpt-resolved-coder",
906 ))
907 );
908 assert_eq!(
909 child.metadata.get("provider_name").map(String::as_str),
910 Some("openai")
911 );
912 }
913
914 #[tokio::test]
915 async fn backward_compat_legacy_subagent_call_without_action_defaults_to_create() {
916 let harness = build_test_harness().await;
917
918 let result = harness
919 .tool
920 .execute_with_context(
921 json!({
922 "title": "Legacy Child",
923 "responsibility": "Test backward compat",
924 "prompt": "Do something",
925 "subagent_type": "general-purpose",
926 "workspace": "/tmp/test-workspace"
927 }),
928 ToolExecutionContext {
929 session_id: Some(harness.parent_session_id.as_str()),
930 tool_call_id: "tool_call_legacy",
931 event_tx: None,
932 available_tool_schemas: None,
933 },
934 )
935 .await
936 .expect("legacy SubAgent call without action should default to create");
937
938 assert!(result.success);
939 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
940 assert!(parsed.get("child_session_id").is_some());
941 }
942
943 #[tokio::test]
948 async fn send_message_appends_follow_up_without_replacing_history() {
949 let harness = build_test_harness().await;
950
951 let result = harness
952 .tool
953 .execute_with_context(
954 json!({
955 "action": "send_message",
956 "child_session_id": harness.child_session_id,
957 "message": "continue with the failing parser path",
958 "auto_run": false
959 }),
960 ToolExecutionContext {
961 session_id: Some(harness.parent_session_id.as_str()),
962 tool_call_id: "tool_call_send_message",
963 event_tx: None,
964 available_tool_schemas: None,
965 },
966 )
967 .await
968 .expect("send_message should succeed");
969
970 let payload: serde_json::Value =
971 serde_json::from_str(&result.result).expect("tool result should be JSON");
972 assert_eq!(payload["status"], "pending");
973
974 let child = harness
975 .storage
976 .load_session(&harness.child_session_id)
977 .await
978 .unwrap()
979 .expect("child session should exist");
980 assert_eq!(child.messages.len(), 4);
981 assert!(matches!(child.messages[2].role, Role::Assistant));
982 assert!(matches!(child.messages[3].role, Role::User));
983 assert_eq!(
984 child.messages[3].content,
985 "continue with the failing parser path"
986 );
987 assert_eq!(
988 child.metadata.get("last_run_status").map(String::as_str),
989 Some("pending")
990 );
991 }
992
993 #[tokio::test]
994 async fn send_message_queues_on_running_child_without_interrupt() {
995 let harness = build_test_harness().await;
996 {
997 let mut runners = harness.agent_runners.write().await;
998 let mut runner = AgentRunner::new();
999 runner.status = AgentStatus::Running;
1000 runners.insert(harness.child_session_id.clone(), runner);
1001 }
1002
1003 let result = harness
1004 .tool
1005 .execute_with_context(
1006 json!({
1007 "action": "send_message",
1008 "child_session_id": harness.child_session_id,
1009 "message": "continue"
1010 }),
1011 ToolExecutionContext {
1012 session_id: Some(harness.parent_session_id.as_str()),
1013 tool_call_id: "tool_call_running",
1014 event_tx: None,
1015 available_tool_schemas: None,
1016 },
1017 )
1018 .await
1019 .expect("send_message should queue message on running child");
1020
1021 assert!(result.success);
1022 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
1023 assert_eq!(payload["status"], "message_queued");
1024 assert_eq!(payload["message"], "continue");
1025
1026 let child = harness
1027 .storage
1028 .load_session(&harness.child_session_id)
1029 .await
1030 .unwrap()
1031 .expect("child session should exist");
1032 assert_eq!(child.messages.len(), 3);
1035 let pending_raw = child
1036 .metadata
1037 .get("pending_injected_messages")
1038 .expect("pending_injected_messages should be set");
1039 let pending: Vec<child_session::QueuedInjectedMessage> =
1040 serde_json::from_str(pending_raw).expect("should parse queued messages");
1041 assert_eq!(pending.len(), 1);
1042 assert_eq!(pending[0].content, "continue");
1043 }
1044
1045 #[tokio::test]
1046 async fn send_message_can_interrupt_running_child() {
1047 let harness = build_test_harness().await;
1048 let cancel_token = {
1049 let mut runners = harness.agent_runners.write().await;
1050 let mut runner = AgentRunner::new();
1051 runner.status = AgentStatus::Running;
1052 let cancel_token = runner.cancel_token.clone();
1053 runners.insert(harness.child_session_id.clone(), runner);
1054 cancel_token
1055 };
1056
1057 let runners_for_status = harness.agent_runners.clone();
1058 let child_id_for_status = harness.child_session_id.clone();
1059 let waiter = tokio::spawn(async move {
1060 cancel_token.cancelled().await;
1061 let mut runners = runners_for_status.write().await;
1062 if let Some(runner) = runners.get_mut(&child_id_for_status) {
1063 runner.status = AgentStatus::Cancelled;
1064 }
1065 });
1066
1067 let result = harness
1068 .tool
1069 .execute_with_context(
1070 json!({
1071 "action": "send_message",
1072 "child_session_id": harness.child_session_id,
1073 "message": "continue from latest state",
1074 "auto_run": false,
1075 "interrupt_running": true
1076 }),
1077 ToolExecutionContext {
1078 session_id: Some(harness.parent_session_id.as_str()),
1079 tool_call_id: "tool_call_interrupt_running",
1080 event_tx: None,
1081 available_tool_schemas: None,
1082 },
1083 )
1084 .await
1085 .expect("send_message should interrupt running child");
1086
1087 waiter.await.expect("waiter task should finish");
1088
1089 let payload: serde_json::Value =
1090 serde_json::from_str(&result.result).expect("tool result should be JSON");
1091 assert_eq!(payload["status"], "pending");
1092 assert_eq!(payload["auto_run"], false);
1093
1094 let child = harness
1095 .storage
1096 .load_session(&harness.child_session_id)
1097 .await
1098 .unwrap()
1099 .expect("child session should exist");
1100 assert!(matches!(
1101 child.messages.last().map(|m| &m.role),
1102 Some(Role::User)
1103 ));
1104 assert_eq!(
1105 child.messages.last().map(|m| m.content.as_str()),
1106 Some("continue from latest state")
1107 );
1108 assert_eq!(
1109 child.metadata.get("last_run_status").map(String::as_str),
1110 Some("pending")
1111 );
1112 }
1113
1114 #[tokio::test]
1115 async fn send_message_can_queue_child_immediately() {
1116 let mut harness = build_test_harness().await;
1117
1118 let result = harness
1119 .tool
1120 .execute_with_context(
1121 json!({
1122 "action": "send_message",
1123 "child_session_id": harness.child_session_id,
1124 "message": "retry with a narrower scope"
1125 }),
1126 ToolExecutionContext {
1127 session_id: Some(harness.parent_session_id.as_str()),
1128 tool_call_id: "tool_call_queue",
1129 event_tx: None,
1130 available_tool_schemas: None,
1131 },
1132 )
1133 .await
1134 .expect("send_message should queue the child");
1135
1136 let payload: serde_json::Value =
1137 serde_json::from_str(&result.result).expect("tool result should be JSON");
1138 assert_eq!(payload["status"], "queued");
1139 assert_eq!(payload["auto_run"], true);
1140
1141 let started_event = tokio::time::timeout(Duration::from_secs(2), async {
1142 loop {
1143 match harness.parent_rx.recv().await {
1144 Ok(AgentEvent::SubAgentStarted {
1145 parent_session_id,
1146 child_session_id,
1147 ..
1148 }) => break (parent_session_id, child_session_id),
1149 Ok(_) => continue,
1150 Err(broadcast::error::RecvError::Lagged(_)) => continue,
1151 Err(broadcast::error::RecvError::Closed) => {
1152 panic!("parent stream closed before start event")
1153 }
1154 }
1155 }
1156 })
1157 .await
1158 .expect("should receive SubAgentStarted event");
1159
1160 assert_eq!(started_event.0, harness.parent_session_id);
1161 assert_eq!(started_event.1, harness.child_session_id);
1162 }
1163
1164 #[tokio::test]
1165 async fn cancel_stops_running_child() {
1166 let harness = build_test_harness().await;
1167 let cancel_token = {
1168 let mut runners = harness.agent_runners.write().await;
1169 let mut runner = AgentRunner::new();
1170 runner.status = AgentStatus::Running;
1171 let token = runner.cancel_token.clone();
1172 runners.insert(harness.child_session_id.clone(), runner);
1173 token
1174 };
1175
1176 let runners_for_wait = harness.agent_runners.clone();
1177 let child_id_for_wait = harness.child_session_id.clone();
1178 let waiter = tokio::spawn(async move {
1179 cancel_token.cancelled().await;
1180 let mut runners = runners_for_wait.write().await;
1181 if let Some(runner) = runners.get_mut(&child_id_for_wait) {
1182 runner.status = AgentStatus::Cancelled;
1183 }
1184 });
1185
1186 let result = harness
1187 .tool
1188 .execute_with_context(
1189 json!({
1190 "action": "cancel",
1191 "child_session_id": harness.child_session_id
1192 }),
1193 ToolExecutionContext {
1194 session_id: Some(harness.parent_session_id.as_str()),
1195 tool_call_id: "tool_call_cancel",
1196 event_tx: None,
1197 available_tool_schemas: None,
1198 },
1199 )
1200 .await
1201 .expect("cancel should succeed");
1202
1203 waiter.await.expect("waiter should finish");
1204
1205 let payload: serde_json::Value =
1206 serde_json::from_str(&result.result).expect("tool result should be JSON");
1207 assert_eq!(payload["status"], "cancelled");
1208 assert_eq!(payload["child_session_id"], harness.child_session_id);
1209 }
1210
1211 #[tokio::test]
1212 async fn list_returns_children() {
1213 let harness = build_test_harness().await;
1214
1215 let result = harness
1216 .tool
1217 .execute_with_context(
1218 json!({"action": "list"}),
1219 ToolExecutionContext {
1220 session_id: Some(harness.parent_session_id.as_str()),
1221 tool_call_id: "tool_call_list",
1222 event_tx: None,
1223 available_tool_schemas: None,
1224 },
1225 )
1226 .await
1227 .expect("list should succeed");
1228
1229 let payload: serde_json::Value =
1230 serde_json::from_str(&result.result).expect("tool result should be JSON");
1231 let children = payload["children"]
1232 .as_array()
1233 .expect("list result should have children array");
1234 assert_eq!(children.len(), 1);
1235 assert_eq!(children[0]["child_session_id"], harness.child_session_id);
1236 assert_eq!(payload["count"], 1);
1237 }
1238
1239 #[tokio::test]
1240 async fn get_returns_runner_diagnostics() {
1241 let harness = build_test_harness().await;
1242
1243 {
1245 let mut runners = harness.agent_runners.write().await;
1246 let mut runner = AgentRunner::new();
1247 runner.status = AgentStatus::Running;
1248 runner.last_tool_name = Some("Read".to_string());
1249 runner.last_tool_phase = Some("begin".to_string());
1250 runner.round_count = 3;
1251 runners.insert(harness.child_session_id.clone(), runner);
1252 }
1253
1254 let result = harness
1255 .tool
1256 .execute_with_context(
1257 json!({
1258 "action": "get",
1259 "child_session_id": harness.child_session_id
1260 }),
1261 ToolExecutionContext {
1262 session_id: Some(harness.parent_session_id.as_str()),
1263 tool_call_id: "tool_call_get_diagnostics",
1264 event_tx: None,
1265 available_tool_schemas: None,
1266 },
1267 )
1268 .await
1269 .expect("get should succeed");
1270
1271 let payload: serde_json::Value =
1272 serde_json::from_str(&result.result).expect("tool result should be JSON");
1273 assert_eq!(payload["child_session_id"], harness.child_session_id);
1274 assert_eq!(payload["is_running"], true);
1275 assert_eq!(payload["last_tool_name"], "Read");
1276 assert_eq!(payload["last_tool_phase"], "begin");
1277 assert_eq!(payload["round_count"], 3);
1278 assert!(payload["runner_started_at"].is_string());
1279 assert!(payload.get("guidance").is_some());
1280 }
1281
1282 #[tokio::test]
1283 async fn create_returns_duration_hint() {
1284 let harness = build_test_harness().await;
1285
1286 let result = harness
1287 .tool
1288 .execute_with_context(
1289 json!({
1290 "action": "create",
1291 "title": "Test Child",
1292 "responsibility": "Do something",
1293 "prompt": "Do something useful",
1294 "subagent_type": "general-purpose",
1295 "workspace": "/tmp/test-workspace",
1296 "auto_run": false
1297 }),
1298 ToolExecutionContext {
1299 session_id: Some(harness.parent_session_id.as_str()),
1300 tool_call_id: "tool_call_create_hint",
1301 event_tx: None,
1302 available_tool_schemas: None,
1303 },
1304 )
1305 .await
1306 .expect("create should succeed");
1307
1308 let payload: serde_json::Value =
1309 serde_json::from_str(&result.result).expect("tool result should be JSON");
1310 let note = payload["note"].as_str().expect("note should be present");
1311 assert!(
1312 note.contains("30-120 seconds"),
1313 "note should contain estimated duration hint: {note}"
1314 );
1315 assert!(
1316 note.contains("send_message"),
1317 "note should mention send_message: {note}"
1318 );
1319 }
1320
1321 #[tokio::test]
1322 async fn create_persists_explicit_reasoning_effort_to_child_session() {
1323 let harness = build_test_harness().await;
1324
1325 let result = harness
1326 .tool
1327 .execute_with_context(
1328 json!({
1329 "action": "create",
1330 "title": "Reasoning Child",
1331 "responsibility": "Investigate hard problem",
1332 "prompt": "Think carefully step by step",
1333 "subagent_type": "general-purpose",
1334 "workspace": "/tmp/test-workspace",
1335 "auto_run": false,
1336 "reasoning_effort": "high"
1337 }),
1338 ToolExecutionContext {
1339 session_id: Some(harness.parent_session_id.as_str()),
1340 tool_call_id: "tool_call_create_with_effort",
1341 event_tx: None,
1342 available_tool_schemas: None,
1343 },
1344 )
1345 .await
1346 .expect("create should succeed");
1347
1348 let payload: serde_json::Value =
1349 serde_json::from_str(&result.result).expect("tool result should be JSON");
1350 assert_eq!(
1351 payload["reasoning_effort"].as_str(),
1352 Some("high"),
1353 "tool result should echo the resolved reasoning_effort"
1354 );
1355
1356 let child_id = payload["child_session_id"]
1357 .as_str()
1358 .expect("child_session_id present")
1359 .to_string();
1360 let child = harness
1361 .storage
1362 .load_session(&child_id)
1363 .await
1364 .expect("child should be persisted")
1365 .expect("child session should exist");
1366 assert_eq!(
1367 child.reasoning_effort,
1368 Some(bamboo_domain::ReasoningEffort::High),
1369 "child.reasoning_effort should reflect the explicit override"
1370 );
1371 }
1372
1373 #[tokio::test]
1374 async fn create_without_reasoning_effort_leaves_child_at_provider_default() {
1375 let harness = build_test_harness().await;
1376
1377 let result = harness
1378 .tool
1379 .execute_with_context(
1380 json!({
1381 "action": "create",
1382 "title": "Default Child",
1383 "responsibility": "Quick lookup",
1384 "prompt": "Read a file and summarise",
1385 "subagent_type": "general-purpose",
1386 "workspace": "/tmp/test-workspace",
1387 "auto_run": false
1388 }),
1389 ToolExecutionContext {
1390 session_id: Some(harness.parent_session_id.as_str()),
1391 tool_call_id: "tool_call_create_default_effort",
1392 event_tx: None,
1393 available_tool_schemas: None,
1394 },
1395 )
1396 .await
1397 .expect("create should succeed");
1398
1399 let payload: serde_json::Value =
1400 serde_json::from_str(&result.result).expect("tool result should be JSON");
1401 assert!(
1402 payload["reasoning_effort"].is_null(),
1403 "tool result should report null reasoning_effort when omitted, got {:?}",
1404 payload["reasoning_effort"]
1405 );
1406
1407 let child_id = payload["child_session_id"]
1408 .as_str()
1409 .expect("child_session_id present")
1410 .to_string();
1411 let child = harness
1412 .storage
1413 .load_session(&child_id)
1414 .await
1415 .expect("child should be persisted")
1416 .expect("child session should exist");
1417 assert_eq!(
1418 child.reasoning_effort, None,
1419 "child.reasoning_effort should stay at None (provider default) when caller omits it; \
1420 children must NOT inherit the parent's reasoning_effort"
1421 );
1422 }
1423
1424 #[tokio::test]
1425 async fn update_can_change_reasoning_effort_on_existing_child() {
1426 let harness = build_test_harness().await;
1427
1428 let seeded = harness
1430 .storage
1431 .load_session(&harness.child_session_id)
1432 .await
1433 .expect("seeded child should load")
1434 .expect("seeded child exists");
1435 assert_eq!(seeded.reasoning_effort, None);
1436
1437 let _ = harness
1438 .tool
1439 .execute_with_context(
1440 json!({
1441 "action": "update",
1442 "child_session_id": harness.child_session_id,
1443 "reasoning_effort": "max"
1444 }),
1445 ToolExecutionContext {
1446 session_id: Some(harness.parent_session_id.as_str()),
1447 tool_call_id: "tool_call_update_effort",
1448 event_tx: None,
1449 available_tool_schemas: None,
1450 },
1451 )
1452 .await
1453 .expect("update should succeed");
1454
1455 let updated = harness
1456 .storage
1457 .load_session(&harness.child_session_id)
1458 .await
1459 .expect("updated child should load")
1460 .expect("child still exists");
1461 assert_eq!(
1462 updated.reasoning_effort,
1463 Some(bamboo_domain::ReasoningEffort::Max),
1464 "update should persist the new reasoning_effort"
1465 );
1466 }
1467
1468 #[tokio::test]
1469 async fn delete_removes_child() {
1470 let harness = build_test_harness().await;
1471
1472 let result = harness
1473 .tool
1474 .execute_with_context(
1475 json!({
1476 "action": "delete",
1477 "child_session_id": harness.child_session_id
1478 }),
1479 ToolExecutionContext {
1480 session_id: Some(harness.parent_session_id.as_str()),
1481 tool_call_id: "tool_call_delete",
1482 event_tx: None,
1483 available_tool_schemas: None,
1484 },
1485 )
1486 .await
1487 .expect("delete should succeed");
1488
1489 let payload: serde_json::Value =
1490 serde_json::from_str(&result.result).expect("tool result should be JSON");
1491 assert_eq!(payload["deleted"], true);
1492
1493 let child = harness
1494 .storage
1495 .load_session(&harness.child_session_id)
1496 .await
1497 .unwrap();
1498 assert!(child.is_none());
1499 }
1500
1501 #[tokio::test]
1506 async fn list_profiles_returns_builtin_catalog() {
1507 let harness = build_test_harness().await;
1508
1509 let result = harness
1510 .tool
1511 .execute_with_context(
1512 json!({"action": "list_profiles"}),
1513 ToolExecutionContext {
1514 session_id: Some(harness.parent_session_id.as_str()),
1515 tool_call_id: "tool_call_list_profiles",
1516 event_tx: None,
1517 available_tool_schemas: None,
1518 },
1519 )
1520 .await
1521 .expect("list_profiles should succeed");
1522
1523 let payload: serde_json::Value =
1524 serde_json::from_str(&result.result).expect("tool result should be JSON");
1525
1526 let profiles = payload["profiles"]
1528 .as_array()
1529 .expect("list_profiles must return a `profiles` array");
1530 assert!(
1531 profiles.len() >= 6,
1532 "expected at least 6 built-in profiles, got {}",
1533 profiles.len()
1534 );
1535 assert_eq!(payload["count"], profiles.len());
1536 assert_eq!(payload["fallback_id"], "general-purpose");
1537
1538 for entry in profiles {
1541 assert!(entry.get("id").and_then(|v| v.as_str()).is_some());
1542 assert!(entry.get("display_name").and_then(|v| v.as_str()).is_some());
1543 assert!(entry.get("tools").is_some());
1544 assert!(
1545 entry.get("system_prompt").is_none(),
1546 "system_prompt must NOT be returned by list_profiles",
1547 );
1548 }
1549
1550 let ids: Vec<&str> = profiles
1553 .iter()
1554 .map(|p| p["id"].as_str().unwrap_or(""))
1555 .collect();
1556 for required in [
1557 "general-purpose",
1558 "plan",
1559 "researcher",
1560 "coder",
1561 "reviewer",
1562 "tester",
1563 ] {
1564 assert!(
1565 ids.contains(&required),
1566 "built-in profile `{required}` missing from list_profiles output (got: {ids:?})"
1567 );
1568 }
1569 }
1570
1571 #[tokio::test]
1576 async fn list_profiles_does_not_load_parent_session() {
1577 let harness = build_test_harness().await;
1578
1579 let result = harness
1580 .tool
1581 .execute_with_context(
1582 json!({"action": "list_profiles"}),
1583 ToolExecutionContext {
1584 session_id: Some("non-existent-session-id"),
1585 tool_call_id: "tool_call_list_profiles_no_session",
1586 event_tx: None,
1587 available_tool_schemas: None,
1588 },
1589 )
1590 .await
1591 .expect("list_profiles should succeed even when the parent session id is unknown");
1592
1593 let payload: serde_json::Value =
1594 serde_json::from_str(&result.result).expect("tool result should be JSON");
1595 assert!(payload["profiles"].as_array().is_some());
1596 }
1597
1598 #[tokio::test]
1599 async fn create_requires_workspace() {
1600 let harness = build_test_harness().await;
1601
1602 let err = harness
1603 .tool
1604 .execute_with_context(
1605 json!({
1606 "action": "create",
1607 "title": "No Workspace Child",
1608 "responsibility": "Test workspace validation",
1609 "prompt": "Do something",
1610 "subagent_type": "general-purpose"
1611 }),
1612 ToolExecutionContext {
1613 session_id: Some(harness.parent_session_id.as_str()),
1614 tool_call_id: "tool_call_no_workspace",
1615 event_tx: None,
1616 available_tool_schemas: None,
1617 },
1618 )
1619 .await
1620 .unwrap_err();
1621
1622 match err {
1623 ToolError::InvalidArguments(msg) => {
1624 assert!(
1625 msg.contains("workspace"),
1626 "error should mention workspace: {msg}"
1627 );
1628 }
1629 other => panic!("expected InvalidArguments error, got: {other:?}"),
1630 }
1631 }
1632
1633 #[tokio::test]
1634 async fn create_sets_child_workspace() {
1635 let harness = build_test_harness().await;
1636
1637 let result = harness
1638 .tool
1639 .execute_with_context(
1640 json!({
1641 "action": "create",
1642 "title": "Workspace Child",
1643 "responsibility": "Test workspace propagation",
1644 "prompt": "Do something",
1645 "subagent_type": "general-purpose",
1646 "workspace": "/tmp/test-workspace",
1647 "auto_run": false
1648 }),
1649 ToolExecutionContext {
1650 session_id: Some(harness.parent_session_id.as_str()),
1651 tool_call_id: "tool_call_workspace",
1652 event_tx: None,
1653 available_tool_schemas: None,
1654 },
1655 )
1656 .await
1657 .expect("create should succeed with workspace");
1658
1659 let payload: serde_json::Value =
1660 serde_json::from_str(&result.result).expect("tool result should be JSON");
1661 let child_id = payload["child_session_id"]
1662 .as_str()
1663 .expect("child_session_id should be present")
1664 .to_string();
1665
1666 let child = harness
1667 .storage
1668 .load_session(&child_id)
1669 .await
1670 .expect("child should be persisted")
1671 .expect("child session should exist");
1672 assert_eq!(
1673 child.workspace,
1674 Some("/tmp/test-workspace".to_string()),
1675 "child workspace should be set from create args"
1676 );
1677 }
1678}