Skip to main content

bamboo_server/tools/
sub_agent.rs

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// ---------------------------------------------------------------------------
14// Args enum
15// ---------------------------------------------------------------------------
16
17#[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        /// Optional reasoning effort for the child session. When omitted,
33        /// the child stays at `None` so the provider's default applies
34        /// (it does NOT inherit the parent's reasoning_effort). The LLM
35        /// should pass an explicit value (e.g. `"low"` for cheap fan-outs,
36        /// `"high"`/`"max"` for hard reasoning) when it has a preference.
37        #[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        /// Optional reasoning effort to apply to the existing child session.
59        /// `Some(level)` overrides the current value; `None` (the default)
60        /// leaves it unchanged.
61        #[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    /// Enumerate the available subagent profiles (built-ins plus any
84    /// user/project overrides). Read-only; does not touch any session.
85    /// Useful both for the LLM (to discover roles before calling
86    /// `create`) and for the frontend (to populate a role dropdown).
87    ListProfiles,
88}
89
90// ---------------------------------------------------------------------------
91// Normalization helpers (ported from legacy SpawnSessionTool)
92// ---------------------------------------------------------------------------
93
94fn 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
154// ---------------------------------------------------------------------------
155// Tool struct
156// ---------------------------------------------------------------------------
157
158pub struct SubAgentTool {
159    adapter: Arc<ChildSessionAdapter>,
160    /// Registry consulted by `action=list_profiles`. Held as `Arc` so the
161    /// tool stays cheap to clone and share across executors.
162    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        // Backward compatibility: legacy SubAgent calls did not include an
264        // "action" field and always meant "create". If action is missing,
265        // default to "create" before deserializing the tagged enum.
266        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        // `list_profiles` is read-only and operates purely on the
276        // in-memory profile registry, so we short-circuit before doing
277        // any session lookup. This also lets the LLM call `list_profiles`
278        // safely from any context (root or otherwise).
279        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                // Ensure index entry is visible immediately (best-effort).
345                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            // Already short-circuited above; kept here so the match stays
497            // exhaustive without a wildcard.
498            SubAgentArgs::ListProfiles => tool_result(self.list_profiles_payload()),
499        }
500    }
501}
502
503impl SubAgentTool {
504    /// Build the JSON payload returned by `action=list_profiles`.
505    ///
506    /// Shape (kept stable as a public contract for the frontend and for
507    /// the LLM):
508    ///
509    /// ```jsonc
510    /// {
511    ///   "profiles": [
512    ///     {
513    ///       "id": "researcher",
514    ///       "display_name": "Researcher",
515    ///       "description": "...",
516    ///       "tools": { "mode": "allowlist", "allow": ["Read", "Grep"] },
517    ///       "model_hint": null,
518    ///       "default_responsibility": null,
519    ///       "ui": { "icon": "🔎", "color": "blue" }
520    ///       // NOTE: `system_prompt` is intentionally omitted from the
521    ///       // listing — it can be lengthy and is not needed for UI/LLM
522    ///       // selection. Use `action=get` on a child to inspect the
523    ///       // resolved prompt of an active session.
524    ///     }
525    ///   ],
526    ///   "fallback_id": "general-purpose",
527    ///   "count": 6
528    /// }
529    /// ```
530    fn list_profiles_payload(&self) -> serde_json::Value {
531        // Project each profile into a UI-friendly shape that excludes the
532        // (potentially large) `system_prompt`. This keeps the payload
533        // small for both the LLM context window and the frontend list.
534        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// ---------------------------------------------------------------------------
558// Tests
559// ---------------------------------------------------------------------------
560
561#[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    // -----------------------------------------------------------------------
742    // Normalization tests
743    // -----------------------------------------------------------------------
744
745    #[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    // -----------------------------------------------------------------------
765    // Create action tests
766    // -----------------------------------------------------------------------
767
768    #[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    // -----------------------------------------------------------------------
944    // Management action tests for the unified SubAgent tool
945    // -----------------------------------------------------------------------
946
947    #[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        // Message is NOT appended to messages array while child is running;
1033        // it is stored in metadata for the agent loop to merge at turn boundaries.
1034        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        // Set up a running runner with diagnostic fields populated.
1244        {
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        // Pre-condition: the seeded child has reasoning_effort = None.
1429        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    /// `action=list_profiles` returns every built-in profile (without
1502    /// the `system_prompt` body), reports the registry's fallback id,
1503    /// and uses the registry's stable insertion order. The shape of
1504    /// this payload is a public contract — UI / LLM rely on it.
1505    #[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        // Top-level shape.
1527        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        // Required fields per profile, and explicit guarantee that we
1539        // do NOT leak `system_prompt` (could be very large).
1540        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        // Built-in catalogue must include the documented baseline ids
1551        // so the LLM can rely on them being present.
1552        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    /// `list_profiles` is read-only and must not require a real,
1572    /// loadable parent session. We pass a non-existent session_id and
1573    /// expect success (registry is consulted directly, no session
1574    /// lookup is performed).
1575    #[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}