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(¶ms) 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(¶ms)?,
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(¶ms)),
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(¶ms);
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(¶ms)?,
412 mode: Some(ad_hoc_spawn_mode(¶ms)),
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(¶ms, "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}