Skip to main content

agent_engine/tools/subagent/
start.rs

1//! SubagentStartTool — dispatch a reactive subagent and return a handle_id immediately.
2//!
3//! Unlike the one-shot `subagent` tool, this tool returns *before* the subagent
4//! finishes. The caller gets a `handle_id` they can poll via `subagent_status`,
5//! steer via `subagent_steer`, or block on via `subagent_collect`.
6
7use serde_json::{json, Value};
8use std::sync::atomic::Ordering;
9use std::sync::{Arc, RwLock};
10use std::time::Duration;
11use tokio::sync::{mpsc, oneshot};
12
13use super::super::{resolve_agent_prompt, Tool, ToolContext, NEXT_SUBAGENT_ID};
14use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentState, SubagentStatus};
15use crate::{AgentEvent, LlmEvent, Result, RuntimeError, SessionEvent};
16
17pub struct SubagentStartTool;
18
19#[async_trait::async_trait]
20impl Tool for SubagentStartTool {
21    fn origin(&self) -> crate::tools::ToolOrigin {
22        crate::tools::ToolOrigin::Builtin
23    }
24
25    fn name(&self) -> &str {
26        "subagent_start"
27    }
28
29    fn description(&self) -> &str {
30        "Dispatch a reactive subagent and return immediately with a handle_id. \
31         The subagent runs in the background and is REACTIVE: when it finishes, a \
32         completion event is pushed into your queue and you are woken with its \
33         status — do NOT sleep, busy-wait, or poll in a loop waiting for it. After \
34         dispatching, end your turn or keep doing other work; on the completion \
35         event call subagent_collect once with reconciled=true. subagent_status is \
36         for an occasional progress peek, and subagent_steer injects guidance \
37         mid-run. Use this for parallel execution or when you want to continue \
38         working while the subagent runs. For simple sequential delegation that \
39         must block, use subagent instead. Provide either an agent name (resolves \
40         from ~/.synaps-cli/agents/<name>.md) or a system_prompt string directly; \
41         give an inline subagent a short name so it is recognisable in the \
42         subagent panel."
43    }
44
45    fn parameters(&self) -> Value {
46        json!({
47            "type": "object",
48            "properties": {
49                "agent": {
50                    "type": "string",
51                    "description": "Agent name — resolves to ~/.synaps-cli/agents/<name>.md. Mutually exclusive with system_prompt."
52                },
53                "system_prompt": {
54                    "type": "string",
55                    "description": "Inline system prompt for the subagent. Use when you don't have a named agent file."
56                },
57                "name": {
58                    "type": "string",
59                    "description": "Short display name for an inline (system_prompt) subagent, e.g. \"gif-recorder\". Shown in the subagent panel and in status/collect output. Letters, digits, - and _ only, max 32. Ignored when agent is set; defaults to \"inline\"."
60                },
61                "task": {
62                    "type": "string",
63                    "description": "The task/prompt to send to the subagent."
64                },
65                "model": {
66                    "type": "string",
67                    "description": "Omit to inherit the session foreground qualified identity. Explicit values must be one of subagent_models' listed exact choices."
68                },
69                "role": {
70                    "type": "string",
71                    "enum": ["planner", "implementer", "tester", "reviewer", "researcher", "debugger"],
72                    "description": "Typed orchestration role."
73                },
74                "write_policy": {
75                    "oneOf": [
76                        {"type": "object", "properties": {"mode": {"const": "read_only"}}, "required": ["mode"]},
77                        {"type": "object", "properties": {"mode": {"const": "isolated_worktree"}}, "required": ["mode"]},
78                        {"type": "object", "properties": {"mode": {"const": "non_overlapping_paths"}, "scopes": {"type": "array", "items": {"type": "string"}}}, "required": ["mode", "scopes"]}
79                    ],
80                    "description": "Declared write isolation and path scopes."
81                },
82                "timeout": {
83                    "type": "integer",
84                    "description": "Timeout in seconds (default: 300). Increase for long-running tasks."
85                }
86            },
87            "required": ["task"]
88        })
89    }
90
91    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
92        // ── Parse params ───────────────────────────────────────────────────────
93        let task = params["task"]
94            .as_str()
95            .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
96            .to_string();
97
98        // Treat blank / whitespace / control-char strings as absent.
99        // Some model providers serialize "unset" as "" rather than omitting the field,
100        // and we must not try to resolve "" (or " ", or "\u{0}") as an agent name —
101        // doing so produces a hard "Agent '' not found" error that the model then
102        // retries forever with sentinel values instead of falling back to system_prompt.
103        let is_blank = |s: &String| s.chars().all(|c| c.is_whitespace() || c.is_control());
104        let agent_name = params["agent"]
105            .as_str()
106            .map(|s| s.to_string())
107            .filter(|s| !is_blank(s));
108        let inline_prompt = params["system_prompt"]
109            .as_str()
110            .map(|s| s.to_string())
111            .filter(|s| !is_blank(s));
112        let requested_model = params["model"].as_str();
113        // Validate the registry before authorization, id allocation, or event emission;
114        // the same borrow is reused below when publishing the authorized handle.
115        let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
116            RuntimeError::Tool("subagent_start requires a subagent_registry in ToolContext".into())
117        })?;
118        let system_prompt = match (&agent_name, &inline_prompt) {
119            (Some(name), _) => resolve_agent_prompt(name).map_err(RuntimeError::Tool)?,
120            (None, Some(p)) => p.clone(),
121            (None, None) => {
122                return Err(RuntimeError::Tool(
123                    "Must provide either 'agent' (name) or 'system_prompt' (inline). Got neither."
124                        .to_string(),
125                ));
126            }
127        };
128        let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
129        let handle_id = format!("sa_{}", subagent_id);
130        let orchestration = ctx
131            .capabilities
132            .orchestration
133            .as_ref()
134            .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
135        orchestration
136            .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
137            .map_err(|reason| {
138                RuntimeError::Tool(format!("delegation tree budget denied: {reason:?}"))
139            })?;
140        let decision = orchestration
141            .resolve_and_authorize(&handle_id, requested_model)
142            .map_err(|error| {
143                orchestration
144                    .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
145                RuntimeError::Tool(error.to_string())
146            })?;
147        let model = decision.model.as_str().to_owned();
148        let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
149        let memory_backend = ctx.capabilities.memory_backend.clone();
150        let timeout_secs = params["timeout"]
151            .as_u64()
152            .unwrap_or(ctx.limits.subagent_timeout);
153
154        let label = super::subagent_label(agent_name.as_deref(), params["name"].as_str());
155        let task_preview: String = task.chars().take(80).collect();
156        let task_full = task.clone();
157
158        tracing::info!(
159            "subagent_start: dispatching '{}' (id={}) model={}",
160            label,
161            handle_id,
162            model
163        );
164
165        // ── Shared state ───────────────────────────────────────────────────────
166        let state = Arc::new(RwLock::new(SubagentState::new()));
167
168        // ── Channels ───────────────────────────────────────────────────────────
169        let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
170        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
171        let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
172
173        // ── Forward SubagentStart event to TUI ─────────────────────────────────
174        if let Some(ref tx) = ctx.channels.tx_events {
175            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
176                subagent_id,
177                agent_name: label.clone(),
178                task_preview: task_preview.clone(),
179            }));
180        }
181
182        // ── Clone state for the spawned thread ─────────────────────────────────
183        let state_t = Arc::clone(&state);
184        let task_full_a = task_full.clone();
185        let label_inner = label.clone();
186        let model_inner = model.clone();
187        let tx_events_inner = ctx.channels.tx_events.clone();
188        let start_time = std::time::Instant::now();
189        let parent_queue = ctx.capabilities.event_queue.clone();
190        let handle_id_inner = handle_id.clone();
191        let child_parent_id = handle_id.clone();
192
193        // ── Build and register handle BEFORE spawning ─────────────────────────
194        // This closes the publish-before-register race: finalize_subagent can
195        // push a completion event the instant the thread exits, but the registry
196        // must already contain the handle so the parent's collect succeeds.
197        let system_prompt_for_handle = system_prompt.clone();
198        let handle = SubagentHandle::new(
199            handle_id.clone(),
200            subagent_id,
201            label.clone(),
202            task_preview,
203            model.clone(),
204            system_prompt_for_handle,
205            timeout_secs,
206            Arc::clone(&state),
207            Some(steer_tx),
208            Some(shutdown_tx),
209            Some(result_rx),
210        )
211        .with_authorization(&decision);
212        {
213            let mut reg = registry.lock().unwrap();
214            reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
215        }
216
217        let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
218        let orchestration_for_worker = Arc::clone(orchestration);
219        let parent_for_worker = ctx.capabilities.delegation_parent.clone();
220        if let Err(error) = orchestration.mark_starting(&handle_id) {
221            orchestration.rollback(&handle_id);
222            orchestration
223                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
224            return Err(RuntimeError::Tool(error));
225        }
226
227        // ── Spawn subagent thread (mirrors subagent.rs) ────────────────────────
228        let thread_handle = std::thread::spawn(move || {
229            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
230            let state_for_finalizer = Arc::clone(&state_t);
231            let label_for_finalizer = label_inner.clone();
232            let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
233
234            let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
235                let rt = match tokio::runtime::Builder::new_current_thread()
236                    .enable_all()
237                    .build()
238                {
239                    Ok(rt) => rt,
240                    Err(_) => {
241                        state_t.write().unwrap().status =
242                            SubagentStatus::Failed("runtime initialization failed".into());
243                        return;
244                    }
245                };
246
247                // Clones for the async block — the outer closure still needs the originals.
248                let state_a = Arc::clone(&state_t);
249                let label_a = label_inner.clone();
250                let model_a = model_inner.clone();
251                let tx_events_a = tx_events_inner.clone();
252                let task_for_timeout = task_full_a.clone();
253                let task_for_complete = task_full_a;
254
255                let outcome: std::result::Result<SubagentResult, String> = rt.block_on(async move {
256                    use futures::StreamExt;
257
258                    // Host-built worker (shared client/creds/token cache, cached
259                    // registry) or the legacy fresh runtime — see `spawn_runtime`.
260                    let mut runtime = match super::spawn_runtime().await {
261                        Ok(r) => r,
262                        Err(_) => return Err("subagent runtime initialization failed".into()),
263                    };
264
265                    // Apply subagent spawn policy: worker role, 5m cache TTL, worker
266                    // turn budget. Subagents are short-lived one-shots — paying the 1h
267                    // write premium (~2× input price) on them is unrecoverable waste
268                    // (~$0.23 per 10-spawn fan-out). (#110)
269                    super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config(), memory_backend.as_ref());
270                    runtime.set_system_prompt(super::compose_system_prompt(system_prompt, runtime.memory_backend_is_axel()));
271                    runtime.set_model(model_a.clone());
272                    super::apply_codex_worker_reasoning(&mut runtime, codex_parent_plan.as_ref());
273                    runtime.install_worker_orchestration(Arc::clone(
274                        &orchestration_for_runtime,
275                    ));
276                    runtime.set_delegation_parent(Some(child_parent_id.clone()));
277
278                    let cancel = crate::CancellationToken::new();
279                    let cancel_inner = cancel.clone();
280                    tokio::spawn(async move {
281                        let _ = shutdown_rx.await;
282                        cancel_inner.cancel();
283                    });
284
285                    let mut stream = runtime.run_stream_with_messages(vec![std::sync::Arc::new(serde_json::json!({"role": "user", "content": task}))], cancel, Some(steer_rx), None, false).await;
286
287                    let mut tool_count = 0u32;
288                    let mut response_baseline = (0usize, 0usize, 0u32);
289                    let mut total_input_tokens = 0u64;
290                    let mut total_output_tokens = 0u64;
291                    let mut total_cache_read = 0u64;
292                    let mut total_cache_creation = 0u64;
293                    // TTL split: None only if no turn ever reported one; otherwise summed.
294                    let mut total_cache_5m: Option<u64> = None;
295                    let mut total_cache_1h: Option<u64> = None;
296
297                    let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
298                    tokio::pin!(timeout_fut);
299
300                    loop {
301                        tokio::select! {
302                            event = stream.next() => {
303                                let Some(event) = event else { break };
304                                match event {
305                                    crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
306                                        state_a.write().unwrap().note_progress("💭 thinking...", tool_count);
307                                        if let Some(ref tx) = tx_events_a {
308                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
309                                                subagent_id,
310                                                agent_name: label_a.clone(),
311                                                status: "💭 thinking...".to_string(),
312                                            }));
313                                        }
314                                    }
315                                    crate::StreamEvent::Llm(LlmEvent::ResponseStart) => {
316                                        let s = state_a.read().unwrap();
317                                        response_baseline = (s.partial_text.len(), s.tool_log.len(), tool_count);
318                                    }
319                                    crate::StreamEvent::Llm(LlmEvent::ResponseReset) => {
320                                        let mut s = state_a.write().unwrap();
321                                        s.partial_text.truncate(response_baseline.0);
322                                        s.tool_log.truncate(response_baseline.1);
323                                        tool_count = response_baseline.2;
324                                        s.tools = tool_count;
325                                    }
326                                    crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
327                                        state_a.write().unwrap().partial_text.push_str(&text);
328                                    }
329                                    crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
330                                        tool_count += 1;
331                                        state_a.write().unwrap().note_progress(&format!("⚙ {} (tool #{})", name, tool_count), tool_count);
332                                        if let Some(ref tx) = tx_events_a {
333                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
334                                                subagent_id,
335                                                agent_name: label_a.clone(),
336                                                status: format!("⚙ {} (tool #{})", name, tool_count),
337                                            }));
338                                        }
339                                    }
340                                    crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
341                                        let input_str = input.to_string();
342                                        let input_preview: String = input_str.chars().take(200).collect();
343                                        state_a.write().unwrap().tool_log
344                                            .push(format!("[tool_use]: {} — {}", tool_name, input_preview));
345                                        let detail = match tool_name.as_str() {
346                                            "bash" => {
347                                                let cmd = input["command"].as_str().unwrap_or("");
348                                                let preview: String = cmd.chars().take(60).collect();
349                                                format!("$ {}", preview)
350                                            }
351                                            "read"  => format!("reading {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
352                                            "write" => format!("writing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
353                                            "edit"  => format!("editing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
354                                            "grep"  => format!("grep /{}/", input["pattern"].as_str().unwrap_or("?").chars().take(30).collect::<String>()),
355                                            "find"  => format!("find {}", input["pattern"].as_str().unwrap_or("?")),
356                                            "ls"    => format!("ls {}", input["path"].as_str().unwrap_or(".").rsplit('/').next().unwrap_or(".")),
357                                            other   => {
358                                                if other.starts_with("ext__") {
359                                                    other.splitn(3, "__").last().unwrap_or(other).to_string()
360                                                } else {
361                                                    other.to_string()
362                                                }
363                                            }
364                                        };
365                                        state_a.write().unwrap().note_progress(&detail, tool_count);
366                                        if let Some(ref tx) = tx_events_a {
367                                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
368                                                subagent_id,
369                                                agent_name: label_a.clone(),
370                                                status: detail,
371                                            }));
372                                        }
373                                    }
374                                    crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
375                                        let preview: String = result.chars().take(300).collect();
376                                        state_a.write().unwrap().tool_log
377                                            .push(format!("[tool_result]: {}", preview));
378                                    }
379                                    crate::StreamEvent::Session(SessionEvent::Usage {
380                                        input_tokens, output_tokens,
381                                        cache_read_input_tokens, cache_creation_input_tokens,
382                                        cache_creation_5m, cache_creation_1h,
383                                        model: _,
384                                    }) => {
385                                        total_input_tokens    += input_tokens;
386                                        total_output_tokens   += output_tokens;
387                                        total_cache_read      += cache_read_input_tokens;
388                                        total_cache_creation  += cache_creation_input_tokens;
389                                        crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
390                                        crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
391                                    }
392                                    crate::StreamEvent::Session(SessionEvent::Error(e)) => return Err(format!("provider request failed [{}]", e.category_label())),
393                                    crate::StreamEvent::Session(SessionEvent::Done) => break,
394                                    _ => {}
395                                }
396                            }
397                            _ = &mut timeout_fut => {
398                                let (partial, log) = {
399                                    let mut s = state_a.write().unwrap();
400                                    s.status = SubagentStatus::TimedOut;
401                                    s.conversation_state = vec![
402                                        serde_json::json!({"role": "user", "content": task_for_timeout.clone()}),
403                                        serde_json::json!({"role": "assistant", "content": &s.partial_text}),
404                                    ];
405                                    (s.partial_text.clone(), s.tool_log.clone())
406                                };
407                                let mut text = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
408                                if !log.is_empty() {
409                                    text.push_str(&log.join("\n"));
410                                    text.push('\n');
411                                }
412                                if !partial.is_empty() {
413                                    text.push_str("\n[partial response]:\n");
414                                    text.push_str(&partial);
415                                }
416                                state_a.write().unwrap_or_else(|p| p.into_inner()).partial_text = text.clone();
417                                return Ok(SubagentResult {
418                                    text,
419                                    model: model_a.clone(),
420                                    input_tokens: total_input_tokens,
421                                    output_tokens: total_output_tokens,
422                                    cache_read: total_cache_read,
423                                    cache_creation: total_cache_creation,
424                                    cache_creation_5m: total_cache_5m,
425                                    cache_creation_1h: total_cache_1h,
426                                    tool_count,
427                                timed_out: true,
428                                });
429                            }
430                        }
431                    }
432
433                    Ok(SubagentResult {
434                        text: state_a.write().unwrap().partial_text.clone(),
435                        model: model_a.clone(),
436                        input_tokens: total_input_tokens,
437                        output_tokens: total_output_tokens,
438                        cache_read: total_cache_read,
439                        cache_creation: total_cache_creation,
440                        cache_creation_5m: total_cache_5m,
441                        cache_creation_1h: total_cache_1h,
442                        tool_count,
443                    timed_out: false,
444                    })
445                });
446
447                match outcome {
448                    Ok(sa_result) => {
449                        // Only overwrite Running → Completed (don't stomp TimedOut or a cancellation).
450                        {
451                            let mut s = state_t.write().unwrap();
452                            if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
453                                s.status = SubagentStatus::Completed;
454                                s.conversation_state = vec![
455                                    serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
456                                    serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
457                                ];
458                            }
459                        }
460                        let elapsed = start_time.elapsed().as_secs_f64();
461                        let preview: String = sa_result.text.chars().take(120).collect();
462                        if let Some(ref tx) = tx_events_inner {
463                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
464                                subagent_id,
465                                agent_name: label_inner.clone(),
466                                result_preview: preview,
467                                duration_secs: elapsed,
468                            }));
469                        }
470                        let _ = result_tx.send(sa_result);
471                    }
472                    Err(e) => {
473                        state_t.write().unwrap().status = SubagentStatus::Failed(e.clone());
474                        let elapsed = start_time.elapsed().as_secs_f64();
475                        if let Some(ref tx) = tx_events_inner {
476                            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
477                                subagent_id,
478                                agent_name: label_inner.clone(),
479                                result_preview: format!("ERROR: {}", e),
480                                duration_secs: elapsed,
481                            }));
482                        }
483                        // drop result_tx — collect() will surface the closed channel
484                    }
485                }
486            }));
487
488            if let Err(panic_info) = panic_result {
489                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
490                    s.to_string()
491                } else if let Some(s) = panic_info.downcast_ref::<String>() {
492                    s.clone()
493                } else {
494                    "unknown panic".to_string()
495                };
496                tracing::error!("Subagent thread panicked: {}", msg);
497                state_t.write().unwrap_or_else(|p| p.into_inner()).status =
498                    SubagentStatus::Failed(format!("panic: {}", msg));
499            }
500
501            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
502            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
503            super::finalize::finalize_subagent(
504                &state_for_finalizer,
505                parent_queue.as_ref(),
506                &handle_id_inner,
507                subagent_id,
508                &label_for_finalizer,
509                start_time,
510                None, // start.rs: not a resume
511            );
512            orchestration_for_worker
513                .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
514        });
515
516        // ── Attach the already-persisted handle to the started thread ──────────
517        {
518            let mut reg = registry.lock().unwrap();
519            if let Some(h) = reg.get_mut(&handle_id) {
520                h.set_thread_handle(thread_handle);
521            }
522        }
523        if let Err(error) = ctx
524            .capabilities
525            .orchestration
526            .as_ref()
527            .unwrap()
528            .mark_running(&handle_id)
529        {
530            let thread = {
531                let mut reg = registry.lock().unwrap();
532                if let Some(handle) = reg.get_mut(&handle_id) {
533                    handle.cancel();
534                }
535                reg.remove(&handle_id)
536            };
537            if let Some(handle) = thread {
538                let _ = handle.collect().await;
539            }
540            orchestration.rollback(&handle_id);
541            orchestration
542                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
543            return Err(RuntimeError::Tool(error));
544        }
545
546        Ok(json!({
547            "handle_id":  handle_id,
548            "agent_name": label,
549            "status":     "running"
550        })
551        .to_string())
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::tools::test_helpers::create_tool_context;
559    use crate::tools::{SubagentRegistry, Tool};
560    use serde_json::json;
561    use std::sync::{Arc, Mutex};
562
563    // V4: registry=None must return Err immediately, before any thread is spawned.
564    #[tokio::test]
565    async fn test_subagent_start_no_registry_returns_err() {
566        let tool = SubagentStartTool;
567        let ctx = create_tool_context(); // subagent_registry is None by default
568
569        let params = json!({
570            "system_prompt": "You are a test subagent.",
571            "task": "Say ok",
572        });
573
574        let result = tool.execute(params, ctx).await;
575        assert!(
576            result.is_err(),
577            "missing registry must return Err before spawn"
578        );
579        let msg = format!("{:?}", result.unwrap_err());
580        assert!(
581            msg.contains("subagent_registry"),
582            "error must mention subagent_registry: {msg}"
583        );
584    }
585
586    #[tokio::test]
587    async fn test_subagent_start_blank_agent_uses_system_prompt() {
588        let tool = SubagentStartTool;
589        let mut ctx = create_tool_context();
590        ctx.capabilities.subagent_registry = Some(Arc::new(Mutex::new(SubagentRegistry::new())));
591
592        let params = json!({
593            "agent": "",
594            "system_prompt": "You are a concise test subagent. Reply with only: ok",
595            "task": "Say ok",
596            "model": "anthropic/claude-sonnet-4-6",
597            "timeout": 1
598        });
599
600        let result = tool.execute(params, ctx).await;
601        assert!(
602            result.is_ok(),
603            "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
604        );
605        let body: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
606        assert_eq!(body["agent_name"], "inline");
607        assert!(body["handle_id"]
608            .as_str()
609            .unwrap_or_default()
610            .starts_with("sa_"));
611    }
612
613    #[tokio::test]
614    async fn test_subagent_start_inline_name_labels_the_handle() {
615        let tool = SubagentStartTool;
616        let mut ctx = create_tool_context();
617        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
618        ctx.capabilities.subagent_registry = Some(registry.clone());
619
620        let params = json!({
621            "system_prompt": "You are a concise test subagent. Reply with only: ok",
622            "name": "gif recorder",
623            "task": "Say ok",
624            "model": "anthropic/claude-sonnet-4-6",
625            "timeout": 1
626        });
627
628        let result = tool.execute(params, ctx).await.expect("dispatch");
629        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
630        assert_eq!(body["agent_name"], "gif-recorder");
631        let handle_id = body["handle_id"].as_str().unwrap();
632        let reg = registry.lock().unwrap();
633        assert_eq!(
634            reg.get(handle_id).expect("registered").agent_name,
635            "gif-recorder"
636        );
637    }
638}