Skip to main content

imp_core/tools/
imp.rs

1use async_trait::async_trait;
2use imp_llm::ThinkingLevel;
3use imp_llm::{AssistantMessage, ContentBlock};
4use serde_json::json;
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use super::{Tool, ToolContext, ToolOutput};
9use crate::config::AgentMode;
10use crate::error::{Error, Result};
11use crate::imp_session::{ImpSession, SessionChoice, SessionOptions};
12use crate::mana_worker::{self, WorkerRunOptions};
13
14pub struct ImpTool;
15
16const DEFAULT_AD_HOC_SPAWN_TIMEOUT_SECS: u64 = 300;
17const AD_HOC_SPAWN_CANCEL_GRACE_SECS: u64 = 5;
18const DEFAULT_UNIT_WORKER_SYSTEM_PROMPT: &str =
19    "You are a mana unit worker. Execute the assigned unit exactly, use tools if available, update mana with evidence, and stop.";
20
21#[async_trait]
22impl Tool for ImpTool {
23    fn name(&self) -> &str {
24        "spawn"
25    }
26
27    fn label(&self) -> &str {
28        "Spawn Worker"
29    }
30
31    fn description(&self) -> &str {
32        "Spawn another agent worker. Supports durable mana-unit worker runs and bounded ad hoc helper sessions."
33    }
34
35    fn parameters(&self) -> serde_json::Value {
36        json!({
37            "type": "object",
38            "properties": {
39                "action": {
40                    "type": "string",
41                    "enum": ["spawn", "delegate"],
42                    "description": "Preferred: spawn another imp worker. `delegate` remains accepted as a compatibility alias during migration."
43                },
44                "mode": {
45                    "type": "string",
46                    "enum": ["unit", "ad_hoc"],
47                    "description": "Worker mode. 'unit' runs a tracked mana unit; 'ad_hoc' runs a bounded transient helper session."
48                },
49                "unit_id": {
50                    "type": "string",
51                    "description": "Mana unit id to execute when mode='unit'"
52                },
53                "prompt": {
54                    "type": "string",
55                    "description": "Prompt to run when mode='ad_hoc'"
56                },
57                "mana_dir": {
58                    "type": "string",
59                    "description": "Optional explicit mana directory or project root"
60                },
61                "defer_verify": {
62                    "type": "boolean",
63                    "description": "Skip inline verify/close when true"
64                },
65                "model": { "type": "string" },
66                "provider": { "type": "string" },
67                "thinking": { "type": "string" },
68                "max_turns": { "type": "number" },
69                "max_tokens": { "type": "number" },
70                "system_prompt": { "type": "string" },
71                "timeout_secs": {
72                    "type": "number",
73                    "description": "Maximum wall-clock time for ad_hoc spawn before it is cancelled and returns an error. Defaults to 300 seconds."
74                },
75                "no_tools": { "type": "boolean" },
76                "idempotency_key": {
77                    "type": "string",
78                    "description": "Optional caller-supplied dedupe key"
79                }
80            },
81            "required": []
82        })
83    }
84
85    fn is_readonly(&self) -> bool {
86        false
87    }
88
89    async fn execute(
90        &self,
91        _call_id: &str,
92        params: serde_json::Value,
93        ctx: ToolContext,
94    ) -> Result<ToolOutput> {
95        if !matches!(ctx.mode, AgentMode::Full | AgentMode::Orchestrator) {
96            return Ok(ToolOutput::error(
97                "The spawn tool is only available in Full or Orchestrator mode.",
98            ));
99        }
100
101        let Some(request) = resolve_spawn_request(&params) else {
102            return Ok(ToolOutput::error(
103                "Invalid spawn request. Use mode='unit' with unit_id, or mode='ad_hoc' with prompt. The action field is optional and defaults to 'spawn'.",
104            ));
105        };
106
107        match request.mode {
108            SpawnMode::Unit => execute_unit_spawn(params, ctx).await,
109            SpawnMode::AdHoc => execute_ad_hoc_spawn(params, ctx).await,
110        }
111    }
112}
113
114struct SpawnRequest {
115    mode: SpawnMode,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119enum SpawnMode {
120    Unit,
121    AdHoc,
122}
123
124fn resolve_spawn_request(params: &serde_json::Value) -> Option<SpawnRequest> {
125    let action = optional_non_empty_string(params, "action").unwrap_or_else(|| "spawn".to_string());
126    if !matches!(action.as_str(), "spawn" | "delegate") {
127        return None;
128    }
129
130    let explicit_mode = optional_non_empty_string(params, "mode");
131    let mode = match explicit_mode.as_deref() {
132        Some("unit") => SpawnMode::Unit,
133        Some("ad_hoc") | Some("adhoc") | Some("ad-hoc") => SpawnMode::AdHoc,
134        Some(_) => return None,
135        None if optional_non_empty_string(params, "unit_id").is_some() => SpawnMode::Unit,
136        None if optional_non_empty_string(params, "prompt").is_some() => SpawnMode::AdHoc,
137        None => return None,
138    };
139
140    Some(SpawnRequest { mode })
141}
142
143fn build_spawn_details(
144    spawn_mode: &str,
145    durable: bool,
146    status: impl Into<String>,
147    success: bool,
148    summary: impl Into<String>,
149    model: serde_json::Value,
150    provider: serde_json::Value,
151    idempotency_key: Option<String>,
152    mode_details: serde_json::Value,
153) -> serde_json::Value {
154    json!({
155        "tool": "spawn",
156        "action": "spawn",
157        "spawn_mode": spawn_mode,
158        "delegation_mode": spawn_mode,
159        "durable": durable,
160        "status": status.into(),
161        "success": success,
162        "summary": summary.into(),
163        "model": model,
164        "provider": provider,
165        "idempotency_key": idempotency_key,
166        "mode_details": mode_details,
167    })
168}
169
170struct AdHocSpawnOutcome {
171    status: &'static str,
172    summary: String,
173    content: String,
174    success: bool,
175    final_text: Option<String>,
176}
177
178fn build_ad_hoc_spawn_outcome(final_text: Option<String>) -> AdHocSpawnOutcome {
179    match final_text.filter(|text| !text.trim().is_empty()) {
180        Some(text) => AdHocSpawnOutcome {
181            status: "completed",
182            summary: text.clone(),
183            content: text.clone(),
184            success: true,
185            final_text: Some(text),
186        },
187        None => AdHocSpawnOutcome {
188            status: "completed_no_output",
189            summary: "Transient helper worker completed with no final text.".to_string(),
190            content: "Transient helper worker completed with no final text.".to_string(),
191            success: true,
192            final_text: None,
193        },
194    }
195}
196
197fn unit_worker_status_is_error(status: mana_worker::WorkerStatus) -> bool {
198    matches!(
199        status,
200        mana_worker::WorkerStatus::Failed
201            | mana_worker::WorkerStatus::Blocked
202            | mana_worker::WorkerStatus::Cancelled
203    )
204}
205
206fn optional_non_empty_string(params: &serde_json::Value, key: &str) -> Option<String> {
207    params
208        .get(key)
209        .and_then(|v| v.as_str())
210        .map(str::trim)
211        .filter(|s| !s.is_empty())
212        .map(ToOwned::to_owned)
213}
214
215fn normalize_mana_dir_override(cwd: &Path, raw: &str) -> PathBuf {
216    let resolved = super::resolve_path(cwd, raw);
217    if resolved.file_name().and_then(|name| name.to_str()) == Some(".mana") {
218        resolved
219    } else {
220        let child = resolved.join(".mana");
221        if child.is_dir() {
222            child
223        } else {
224            resolved
225        }
226    }
227}
228
229fn unit_spawn_system_prompt(params: &serde_json::Value) -> String {
230    optional_non_empty_string(params, "system_prompt")
231        .unwrap_or_else(|| DEFAULT_UNIT_WORKER_SYSTEM_PROMPT.to_string())
232}
233
234fn ad_hoc_spawn_mode(params: &serde_json::Value) -> AgentMode {
235    if params
236        .get("no_tools")
237        .and_then(|v| v.as_bool())
238        .unwrap_or(false)
239    {
240        AgentMode::Reviewer
241    } else {
242        AgentMode::Worker
243    }
244}
245
246async fn execute_unit_spawn(params: serde_json::Value, ctx: ToolContext) -> Result<ToolOutput> {
247    let unit_id = params
248        .get("unit_id")
249        .and_then(|v| v.as_str())
250        .map(str::trim)
251        .filter(|s| !s.is_empty())
252        .ok_or_else(|| Error::Tool("Missing required parameter: unit_id".into()))?;
253
254    let mana_dir_override = params
255        .get("mana_dir")
256        .and_then(|v| v.as_str())
257        .map(|raw| normalize_mana_dir_override(&ctx.cwd, raw));
258
259    let assignment =
260        mana_worker::load_assignment_with_mana_dir(&ctx.cwd, unit_id, mana_dir_override.as_deref())
261            .map_err(|e| Error::Tool(e.to_string()))?;
262
263    let options = WorkerRunOptions {
264        cwd: ctx.cwd.clone(),
265        model_override: None,
266        model: params
267            .get("model")
268            .and_then(|v| v.as_str())
269            .map(ToOwned::to_owned)
270            .or_else(|| assignment.model.clone()),
271        provider: params
272            .get("provider")
273            .and_then(|v| v.as_str())
274            .map(ToOwned::to_owned),
275        api_key: None,
276        thinking: parse_optional_thinking(&params)?,
277        max_turns: params
278            .get("max_turns")
279            .and_then(|v| v.as_u64())
280            .map(|v| v as u32),
281        max_tokens: params
282            .get("max_tokens")
283            .and_then(|v| v.as_u64())
284            .map(|v| v as u32),
285        system_prompt: Some(unit_spawn_system_prompt(&params)),
286        no_tools: params
287            .get("no_tools")
288            .and_then(|v| v.as_bool())
289            .unwrap_or(false),
290        mana_dir_override,
291        defer_verify: params
292            .get("defer_verify")
293            .and_then(|v| v.as_bool())
294            .unwrap_or(false),
295        lua_loader: ctx.lua_tool_loader.clone(),
296    };
297
298    let idempotency_key = params
299        .get("idempotency_key")
300        .and_then(|v| v.as_str())
301        .map(ToOwned::to_owned);
302
303    let outcome = mana_worker::run_worker_assignment(assignment.clone(), options)
304        .await
305        .map_err(|e| Error::Tool(e.to_string()))?;
306
307    let status = format!("{:?}", outcome.result.status).to_lowercase();
308    let summary = outcome
309        .result
310        .summary
311        .clone()
312        .unwrap_or_else(|| format!("Spawned worker for unit {} finished.", assignment.id));
313
314    let content = outcome
315        .result
316        .summary
317        .clone()
318        .filter(|text| !text.trim().is_empty())
319        .unwrap_or_else(|| match outcome.result.status {
320            mana_worker::WorkerStatus::Completed => {
321                format!(
322                    "Spawned worker for unit {} completed successfully.",
323                    assignment.id
324                )
325            }
326            mana_worker::WorkerStatus::AwaitingVerify => {
327                format!(
328                    "Spawned worker for unit {} completed and is awaiting verify.",
329                    assignment.id
330                )
331            }
332            mana_worker::WorkerStatus::Failed => {
333                format!("Spawned worker for unit {} failed.", assignment.id)
334            }
335            mana_worker::WorkerStatus::Blocked => {
336                format!("Spawned worker for unit {} is blocked.", assignment.id)
337            }
338            mana_worker::WorkerStatus::Cancelled => {
339                format!("Spawned worker for unit {} was cancelled.", assignment.id)
340            }
341        });
342
343    let success = !unit_worker_status_is_error(outcome.result.status);
344
345    Ok(ToolOutput {
346        content: vec![ContentBlock::Text { text: content }],
347        details: build_spawn_details(
348            "unit",
349            true,
350            status,
351            success,
352            summary,
353            json!(outcome.result.model),
354            json!(params.get("provider").and_then(|v| v.as_str())),
355            idempotency_key,
356            json!({
357                "unit_id": assignment.id,
358                "verify_passed": outcome.verify_passed,
359                "verify_output": outcome.verify_output,
360                "verifier_result": outcome.verifier_result,
361                "closed_after_verify": outcome.closed_after_verify,
362                "prefilled_file_count": outcome.prefilled_files.len(),
363            }),
364        ),
365        is_error: !success,
366    })
367}
368
369fn ad_hoc_spawn_timeout_secs(params: &serde_json::Value) -> u64 {
370    params
371        .get("timeout_secs")
372        .and_then(|v| v.as_u64())
373        .filter(|secs| *secs > 0)
374        .unwrap_or(DEFAULT_AD_HOC_SPAWN_TIMEOUT_SECS)
375}
376
377fn ad_hoc_spawn_timeout_error(timeout_secs: u64) -> Error {
378    Error::Tool(format!(
379        "ad_hoc spawn timed out after {timeout_secs}s and was cancelled"
380    ))
381}
382
383async fn execute_ad_hoc_spawn(params: serde_json::Value, ctx: ToolContext) -> Result<ToolOutput> {
384    let timeout_secs = ad_hoc_spawn_timeout_secs(&params);
385    let timeout = Duration::from_secs(timeout_secs);
386    let cancel_grace = Duration::from_secs(AD_HOC_SPAWN_CANCEL_GRACE_SECS);
387    let prompt = params
388        .get("prompt")
389        .and_then(|v| v.as_str())
390        .map(str::trim)
391        .filter(|s| !s.is_empty())
392        .ok_or_else(|| Error::Tool("Missing required parameter: prompt".into()))?;
393
394    let idempotency_key = params
395        .get("idempotency_key")
396        .and_then(|v| v.as_str())
397        .map(ToOwned::to_owned);
398
399    let session_options = SessionOptions {
400        cwd: ctx.cwd.clone(),
401        model_override: None,
402        model: params
403            .get("model")
404            .and_then(|v| v.as_str())
405            .map(ToOwned::to_owned),
406        provider: params
407            .get("provider")
408            .and_then(|v| v.as_str())
409            .map(ToOwned::to_owned),
410        api_key: None,
411        thinking: parse_optional_thinking(&params)?,
412        mode: Some(ad_hoc_spawn_mode(&params)),
413        max_turns: params
414            .get("max_turns")
415            .and_then(|v| v.as_u64())
416            .map(|v| v as u32),
417        max_tokens: params
418            .get("max_tokens")
419            .and_then(|v| v.as_u64())
420            .map(|v| v as u32),
421        system_prompt: optional_non_empty_string(&params, "system_prompt"),
422        no_tools: params
423            .get("no_tools")
424            .and_then(|v| v.as_bool())
425            .unwrap_or(false),
426        session: SessionChoice::InMemory,
427        task: None,
428        facts: Vec::new(),
429        lua_loader: None,
430        ui: Some(ctx.ui.clone()),
431        auth_path: None,
432        context_prefill: Vec::new(),
433    };
434
435    let mut session = ImpSession::create(session_options)
436        .await
437        .map_err(|e| Error::Tool(e.to_string()))?;
438    session
439        .prompt(prompt)
440        .await
441        .map_err(|e| Error::Tool(e.to_string()))?;
442    match tokio::time::timeout(timeout, session.wait()).await {
443        Ok(result) => result.map_err(|e| Error::Tool(e.to_string()))?,
444        Err(_) => {
445            let _ = session.cancel().await;
446            if tokio::time::timeout(cancel_grace, session.wait())
447                .await
448                .is_err()
449            {
450                session.abort();
451            }
452            return Err(ad_hoc_spawn_timeout_error(timeout_secs));
453        }
454    }
455
456    let final_text = extract_final_assistant_text(&session);
457    let outcome = build_ad_hoc_spawn_outcome(final_text);
458
459    Ok(ToolOutput {
460        content: vec![ContentBlock::Text {
461            text: outcome.content,
462        }],
463        details: build_spawn_details(
464            "ad_hoc",
465            false,
466            outcome.status,
467            outcome.success,
468            outcome.summary,
469            json!(session.model().meta.id.clone()),
470            json!(session.model().meta.provider.clone()),
471            idempotency_key,
472            json!({
473                "final_text": outcome.final_text,
474                "timeout_secs": timeout_secs,
475            }),
476        ),
477        is_error: false,
478    })
479}
480
481fn extract_final_assistant_text_from_messages(messages: &[imp_llm::Message]) -> Option<String> {
482    messages.iter().rev().find_map(|message| match message {
483        imp_llm::Message::Assistant(AssistantMessage { content, .. }) => {
484            let text = content
485                .iter()
486                .filter_map(|block| match block {
487                    ContentBlock::Text { text } => Some(text.as_str()),
488                    _ => None,
489                })
490                .collect::<String>();
491            let trimmed = text.trim();
492            if trimmed.is_empty() {
493                None
494            } else {
495                Some(trimmed.to_string())
496            }
497        }
498        _ => None,
499    })
500}
501
502fn extract_final_assistant_text(session: &ImpSession) -> Option<String> {
503    extract_final_assistant_text_from_messages(&session.session_manager().get_active_messages())
504}
505
506fn parse_optional_thinking(params: &serde_json::Value) -> Result<Option<ThinkingLevel>> {
507    let Some(raw) = params.get("thinking").and_then(|v| v.as_str()) else {
508        return Ok(None);
509    };
510
511    let level = match raw.to_ascii_lowercase().as_str() {
512        "off" | "none" => ThinkingLevel::Off,
513        "low" => ThinkingLevel::Low,
514        "medium" | "med" => ThinkingLevel::Medium,
515        "high" => ThinkingLevel::High,
516        other => {
517            return Err(Error::Tool(format!(
518                "Invalid thinking level '{other}'. Expected off, low, medium, or high.",
519            )))
520        }
521    };
522
523    Ok(Some(level))
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use serde_json::json;
530    use std::sync::Arc;
531
532    fn test_ctx(mode: AgentMode) -> ToolContext {
533        let (update_tx, _update_rx) = tokio::sync::mpsc::channel(1);
534        let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1);
535        ToolContext {
536            cwd: std::env::temp_dir(),
537            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
538            update_tx,
539            command_tx,
540            ui: Arc::new(crate::ui::NullInterface),
541            file_cache: Arc::new(super::super::FileCache::new()),
542            checkpoint_state: Arc::new(super::super::CheckpointState::new()),
543            file_tracker: Arc::new(std::sync::Mutex::new(super::super::FileTracker::new())),
544            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
545            lua_tool_loader: None,
546            mode,
547            read_max_lines: 0,
548            turn_mana_review: Arc::new(std::sync::Mutex::new(
549                crate::mana_review::TurnManaReviewAccumulator::default(),
550            )),
551            config: Arc::new(crate::config::Config::default()),
552        }
553    }
554
555    #[test]
556    fn schema_is_plain_object_without_top_level_all_of() {
557        let schema = ImpTool.parameters();
558        assert_eq!(schema.get("type").and_then(|v| v.as_str()), Some("object"));
559        assert!(schema.get("allOf").is_none());
560        assert_eq!(
561            schema
562                .get("required")
563                .and_then(|v| v.as_array())
564                .map(Vec::len),
565            Some(0)
566        );
567        assert_eq!(
568            schema["properties"]["prompt"]["type"].as_str(),
569            Some("string")
570        );
571        assert_eq!(
572            schema["properties"]["timeout_secs"]["type"].as_str(),
573            Some("number")
574        );
575    }
576
577    #[test]
578    fn spawn_defaults_action_and_infers_mode_from_payload_harden_spawn() {
579        assert_eq!(
580            resolve_spawn_request(&json!({"unit_id": "299"})).map(|request| request.mode),
581            Some(SpawnMode::Unit)
582        );
583        assert_eq!(
584            resolve_spawn_request(&json!({"prompt": "inspect this"})).map(|request| request.mode),
585            Some(SpawnMode::AdHoc)
586        );
587        assert_eq!(
588            resolve_spawn_request(
589                &json!({"action": "delegate", "mode": "ad-hoc", "prompt": "inspect this"})
590            )
591            .map(|request| request.mode),
592            Some(SpawnMode::AdHoc)
593        );
594        assert!(
595            resolve_spawn_request(&json!({"action": "run", "prompt": "inspect this"})).is_none()
596        );
597    }
598
599    #[test]
600    fn spawn_rejects_ambiguous_empty_payload_harden_spawn() {
601        assert!(resolve_spawn_request(&json!({})).is_none());
602        assert!(resolve_spawn_request(&json!({"prompt": "   "})).is_none());
603        assert!(resolve_spawn_request(&json!({"mode": "review", "prompt": "x"})).is_none());
604    }
605
606    #[test]
607    fn ad_hoc_spawn_timeout_defaults_when_missing_or_invalid() {
608        assert_eq!(
609            ad_hoc_spawn_timeout_secs(&json!({})),
610            DEFAULT_AD_HOC_SPAWN_TIMEOUT_SECS
611        );
612        assert_eq!(
613            ad_hoc_spawn_timeout_secs(&json!({"timeout_secs": 0})),
614            DEFAULT_AD_HOC_SPAWN_TIMEOUT_SECS
615        );
616        assert_eq!(ad_hoc_spawn_timeout_secs(&json!({"timeout_secs": 12})), 12);
617    }
618
619    #[test]
620    fn normalize_mana_dir_override_accepts_project_root_or_mana_dir() {
621        let temp = tempfile::tempdir().unwrap();
622        let project_root = temp.path().join("project");
623        let mana_dir = project_root.join(".mana");
624        std::fs::create_dir_all(&mana_dir).unwrap();
625
626        assert_eq!(
627            normalize_mana_dir_override(temp.path(), project_root.to_str().unwrap()),
628            mana_dir
629        );
630        assert_eq!(
631            normalize_mana_dir_override(temp.path(), mana_dir.to_str().unwrap()),
632            mana_dir
633        );
634    }
635
636    #[test]
637    fn unit_spawn_system_prompt_defaults_when_missing_or_blank() {
638        assert_eq!(
639            unit_spawn_system_prompt(&json!({})),
640            DEFAULT_UNIT_WORKER_SYSTEM_PROMPT
641        );
642        assert_eq!(
643            unit_spawn_system_prompt(&json!({"system_prompt": "   "})),
644            DEFAULT_UNIT_WORKER_SYSTEM_PROMPT
645        );
646        assert_eq!(
647            unit_spawn_system_prompt(&json!({"system_prompt": " custom worker "})),
648            "custom worker"
649        );
650    }
651
652    #[test]
653    fn ad_hoc_spawn_uses_worker_mode_unless_no_tools_requested() {
654        assert_eq!(ad_hoc_spawn_mode(&json!({})), AgentMode::Worker);
655        assert_eq!(
656            ad_hoc_spawn_mode(&json!({"no_tools": false})),
657            AgentMode::Worker
658        );
659        assert_eq!(
660            ad_hoc_spawn_mode(&json!({"no_tools": true})),
661            AgentMode::Reviewer
662        );
663    }
664
665    #[test]
666    fn optional_non_empty_string_trims_and_filters_blank_values() {
667        assert_eq!(optional_non_empty_string(&json!({}), "value"), None);
668        assert_eq!(
669            optional_non_empty_string(&json!({"value": "   "}), "value"),
670            None
671        );
672        assert_eq!(
673            optional_non_empty_string(&json!({"value": " hello "}), "value"),
674            Some("hello".to_string())
675        );
676    }
677
678    #[tokio::test]
679    async fn spawn_infers_unit_mode_when_mode_omitted_harden_spawn() {
680        let tool = ImpTool;
681        let result = tool
682            .execute(
683                "call-1",
684                json!({"unit_id": "missing-unit-for-validation"}),
685                test_ctx(AgentMode::Orchestrator),
686            )
687            .await;
688        match result {
689            Ok(_) => panic!("expected inferred unit mode to reach unit_id loading and fail there"),
690            Err(err) => assert!(!err.to_string().contains("Invalid spawn request")),
691        }
692    }
693
694    #[tokio::test]
695    async fn spawn_returns_non_panicking_help_for_invalid_payload_harden_spawn() {
696        let tool = ImpTool;
697        let out = tool
698            .execute("call-1", json!({}), test_ctx(AgentMode::Orchestrator))
699            .await
700            .unwrap();
701
702        assert!(out.is_error);
703        let text = out.text_content().unwrap_or_default();
704        assert!(text.contains("mode='unit'"));
705        assert!(text.contains("mode='ad_hoc'"));
706    }
707
708    #[tokio::test]
709    async fn unit_mode_requires_unit_id_at_runtime() {
710        let tool = ImpTool;
711        let result = tool
712            .execute(
713                "call-1",
714                json!({"action": "spawn", "mode": "unit"}),
715                test_ctx(AgentMode::Orchestrator),
716            )
717            .await;
718        match result {
719            Ok(_) => panic!("expected missing unit_id to return an error"),
720            Err(err) => assert!(err.to_string().contains("unit_id")),
721        }
722    }
723
724    #[tokio::test]
725    async fn ad_hoc_mode_requires_prompt_at_runtime() {
726        let tool = ImpTool;
727        let result = tool
728            .execute(
729                "call-1",
730                json!({"action": "spawn", "mode": "ad_hoc"}),
731                test_ctx(AgentMode::Orchestrator),
732            )
733            .await;
734        match result {
735            Ok(_) => panic!("expected missing prompt to return an error"),
736            Err(err) => assert!(err.to_string().contains("prompt")),
737        }
738    }
739
740    #[tokio::test]
741    async fn blocked_modes_fail_clearly() {
742        let tool = ImpTool;
743        let out = tool
744            .execute(
745                "call-1",
746                json!({"action": "spawn", "mode": "unit", "unit_id": "123"}),
747                test_ctx(AgentMode::Worker),
748            )
749            .await
750            .unwrap();
751        assert!(out.is_error);
752        let text = out.text_content().unwrap_or_default();
753        assert!(text.contains("Full or Orchestrator"));
754    }
755
756    #[tokio::test]
757    async fn delegate_action_remains_accepted_as_compatibility_alias() {
758        let tool = ImpTool;
759        let result = tool
760            .execute(
761                "call-1",
762                json!({"action": "delegate", "mode": "unit"}),
763                test_ctx(AgentMode::Orchestrator),
764            )
765            .await;
766        match result {
767            Ok(_) => panic!("expected missing unit_id to return an error"),
768            Err(err) => assert!(err.to_string().contains("unit_id")),
769        }
770    }
771
772    #[test]
773    fn build_spawn_details_keeps_shared_fields_and_groups_mode_specific_data() {
774        let details = build_spawn_details(
775            "ad_hoc",
776            false,
777            "completed",
778            true,
779            "summary",
780            json!("model-x"),
781            json!("provider-y"),
782            Some("idem-1".to_string()),
783            json!({"final_text": "hello"}),
784        );
785
786        assert_eq!(
787            details.get("spawn_mode").and_then(|v| v.as_str()),
788            Some("ad_hoc")
789        );
790        assert_eq!(
791            details.get("delegation_mode").and_then(|v| v.as_str()),
792            Some("ad_hoc")
793        );
794        assert_eq!(
795            details.get("status").and_then(|v| v.as_str()),
796            Some("completed")
797        );
798        assert_eq!(details.get("success").and_then(|v| v.as_bool()), Some(true));
799        assert_eq!(
800            details
801                .get("mode_details")
802                .and_then(|v| v.get("final_text"))
803                .and_then(|v| v.as_str()),
804            Some("hello")
805        );
806    }
807
808    #[test]
809    fn build_ad_hoc_spawn_outcome_uses_final_text_when_present() {
810        let outcome = build_ad_hoc_spawn_outcome(Some("transient result".to_string()));
811
812        assert_eq!(outcome.status, "completed");
813        assert!(outcome.success);
814        assert_eq!(outcome.summary, "transient result");
815        assert_eq!(outcome.content, "transient result");
816        assert_eq!(outcome.final_text.as_deref(), Some("transient result"));
817    }
818
819    #[test]
820    fn build_ad_hoc_spawn_outcome_distinguishes_missing_final_text() {
821        let outcome = build_ad_hoc_spawn_outcome(None);
822
823        assert_eq!(outcome.status, "completed_no_output");
824        assert!(outcome.success);
825        assert!(outcome.summary.contains("no final text"));
826        assert!(outcome.content.contains("no final text"));
827        assert!(outcome.final_text.is_none());
828    }
829
830    #[test]
831    fn unit_worker_status_is_error_for_failed_blocked_and_cancelled_only() {
832        assert!(!unit_worker_status_is_error(
833            mana_worker::WorkerStatus::Completed
834        ));
835        assert!(!unit_worker_status_is_error(
836            mana_worker::WorkerStatus::AwaitingVerify
837        ));
838        assert!(unit_worker_status_is_error(
839            mana_worker::WorkerStatus::Failed
840        ));
841        assert!(unit_worker_status_is_error(
842            mana_worker::WorkerStatus::Blocked
843        ));
844        assert!(unit_worker_status_is_error(
845            mana_worker::WorkerStatus::Cancelled
846        ));
847    }
848
849    #[test]
850    fn extract_final_assistant_text_returns_last_non_empty_assistant_text() {
851        let messages = vec![
852            imp_llm::Message::Assistant(AssistantMessage {
853                content: vec![ContentBlock::Text {
854                    text: "first".to_string(),
855                }],
856                stop_reason: imp_llm::StopReason::EndTurn,
857                usage: None,
858                timestamp: 0,
859            }),
860            imp_llm::Message::Assistant(AssistantMessage {
861                content: vec![ContentBlock::Text {
862                    text: "   ".to_string(),
863                }],
864                stop_reason: imp_llm::StopReason::EndTurn,
865                usage: None,
866                timestamp: 0,
867            }),
868            imp_llm::Message::Assistant(AssistantMessage {
869                content: vec![ContentBlock::Text {
870                    text: "transient".to_string(),
871                }],
872                stop_reason: imp_llm::StopReason::EndTurn,
873                usage: None,
874                timestamp: 0,
875            }),
876        ];
877
878        let text = extract_final_assistant_text_from_messages(&messages);
879
880        assert_eq!(text.as_deref(), Some("transient"));
881    }
882}