Skip to main content

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