1use super::Tool;
7use crate::{PawanError, Result};
8use async_trait::async_trait;
9use serde_json::{json, Value};
10use std::io::Write;
11use std::path::PathBuf;
12use std::process::Stdio;
13use tokio::io::AsyncReadExt;
14use tokio::process::Command;
15use tracing;
16
17use crate::subagent::SubagentHandle;
18
19pub struct SpawnAgentTool {
21 workspace_root: PathBuf,
22}
23
24impl SpawnAgentTool {
25 pub fn new(workspace_root: PathBuf) -> Self {
26 Self { workspace_root }
27 }
28
29 fn find_pawan_binary(&self) -> String {
31 for candidate in &[
33 self.workspace_root.join("target/release/pawan"),
34 self.workspace_root.join("target/debug/pawan"),
35 ] {
36 if candidate.exists() {
37 return candidate.to_string_lossy().to_string();
38 }
39 }
40 "pawan".to_string()
42 }
43}
44
45#[async_trait]
46impl Tool for SpawnAgentTool {
47 fn name(&self) -> &str {
48 "spawn_agent"
49 }
50
51 fn description(&self) -> &str {
52 "Spawn a sub-agent (pawan subprocess) to handle a task independently. \
53 Returns the agent's response as JSON. Use this for parallel or delegated tasks."
54 }
55
56 fn mutating(&self) -> bool {
57 true }
59
60 fn parameters_schema(&self) -> Value {
61 json!({
62 "type": "object",
63 "properties": {
64 "prompt": {
65 "type": "string",
66 "description": "The task/prompt for the sub-agent"
67 },
68 "model": {
69 "type": "string",
70 "description": "Model to use (optional, defaults to parent's model)"
71 },
72 "timeout": {
73 "type": "integer",
74 "description": "Timeout in seconds (default: 120)"
75 },
76 "workspace": {
77 "type": "string",
78 "description": "Workspace directory for the sub-agent (default: same as parent)"
79 },
80 "retries": {
81 "type": "integer",
82 "description": "Number of retry attempts on failure (default: 0, max: 2)"
83 }
84 },
85 "required": ["prompt"]
86 })
87 }
88
89 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
90 use thulp_core::{Parameter, ParameterType};
91 thulp_core::ToolDefinition::builder("spawn_agent")
92 .description(self.description())
93 .parameter(
94 Parameter::builder("prompt")
95 .param_type(ParameterType::String)
96 .required(true)
97 .description("The task/prompt for the sub-agent")
98 .build(),
99 )
100 .parameter(
101 Parameter::builder("model")
102 .param_type(ParameterType::String)
103 .required(false)
104 .description("Model to use (optional, defaults to parent's model)")
105 .build(),
106 )
107 .parameter(
108 Parameter::builder("timeout")
109 .param_type(ParameterType::Integer)
110 .required(false)
111 .description("Timeout in seconds (default: 120)")
112 .build(),
113 )
114 .parameter(
115 Parameter::builder("workspace")
116 .param_type(ParameterType::String)
117 .required(false)
118 .description("Workspace directory for the sub-agent (default: same as parent)")
119 .build(),
120 )
121 .parameter(
122 Parameter::builder("retries")
123 .param_type(ParameterType::Integer)
124 .required(false)
125 .description("Number of retry attempts on failure (default: 0, max: 2)")
126 .build(),
127 )
128 .build()
129 }
130
131 async fn execute(&self, args: Value) -> Result<Value> {
132 let prompt = args["prompt"]
133 .as_str()
134 .ok_or_else(|| PawanError::Tool("prompt is required for spawn_agent".into()))?;
135
136 let timeout = args["timeout"].as_u64().unwrap_or(120);
137 let model = args["model"].as_str();
138 let workspace = args["workspace"]
139 .as_str()
140 .map(PathBuf::from)
141 .unwrap_or_else(|| self.workspace_root.clone());
142 let max_retries = args["retries"].as_u64().unwrap_or(0).min(2) as usize;
143
144 let label: String = prompt
146 .lines()
147 .next()
148 .unwrap_or(prompt)
149 .chars()
150 .take(48)
151 .collect();
152 let progress = SubagentHandle::start(&label, "spawn_agent", None);
153
154 let agent_id = progress.id().to_string();
155 let status_path = format!("/tmp/pawan-agent-{}.status", agent_id);
156 let started_at = chrono::Utc::now().to_rfc3339();
157
158 let pawan_bin = self.find_pawan_binary();
159
160 for attempt in 0..=max_retries {
161 let mut cmd = Command::new(&pawan_bin);
162 cmd.arg("run")
163 .arg("-o")
164 .arg("json")
165 .arg("--timeout")
166 .arg(timeout.to_string())
167 .arg("-w")
168 .arg(workspace.to_string_lossy().to_string());
169
170 if let Some(m) = model {
171 cmd.arg("-m").arg(m);
172 }
173
174 cmd.arg(prompt);
175
176 cmd.stdout(Stdio::piped())
177 .stderr(Stdio::piped())
178 .stdin(Stdio::null());
179
180 if let Ok(mut f) = std::fs::File::create(&status_path) {
182 let _ = write!(
183 f,
184 r#"{{"state":"running","prompt":"{}","started_at":"{}","attempt":{}}}"#,
185 prompt
186 .chars()
187 .take(100)
188 .collect::<String>()
189 .replace('"', "'"),
190 started_at,
191 attempt + 1
192 );
193 }
194
195 let mut child = match cmd.spawn() {
196 Ok(c) => c,
197 Err(e) => {
198 progress.complete_err(format!("spawn failed: {e}"));
199 progress.dismiss();
200 return Err(PawanError::Tool(format!(
201 "Failed to spawn sub-agent: {e}. Binary: {pawan_bin}"
202 )));
203 }
204 };
205
206 let mut stdout = String::new();
207 let mut stderr = String::new();
208
209 if let Some(mut handle) = child.stdout.take() {
210 handle.read_to_string(&mut stdout).await.ok();
211 }
212 if let Some(mut handle) = child.stderr.take() {
213 handle.read_to_string(&mut stderr).await.ok();
214 }
215
216 let status = match child.wait().await {
217 Ok(s) => s,
218 Err(e) => {
219 progress.complete_err(format!("wait failed: {e}"));
220 progress.dismiss();
221 return Err(PawanError::Io(e));
222 }
223 };
224
225 let result = if let Ok(json_result) = serde_json::from_str::<Value>(&stdout) {
226 json_result
227 } else {
228 json!({
229 "content": stdout.trim(),
230 "raw_output": true
231 })
232 };
233
234 if status.success() || attempt == max_retries {
235 let duration_ms = chrono::Utc::now()
237 .signed_duration_since(
238 chrono::DateTime::parse_from_rfc3339(&started_at).unwrap_or_default(),
239 )
240 .num_milliseconds();
241 if let Ok(mut f) = std::fs::File::create(&status_path) {
242 let state = if status.success() { "done" } else { "failed" };
243 let _ = write!(
244 f,
245 r#"{{"state":"{}","exit_code":{},"duration_ms":{},"attempt":{}}}"#,
246 state,
247 status.code().unwrap_or(-1),
248 duration_ms,
249 attempt + 1
250 );
251 }
252
253 if status.success() {
254 progress.complete_ok();
255 } else {
256 progress.complete_err(format!(
257 "exit code {}",
258 status.code().unwrap_or(-1)
259 ));
260 }
261 let out = json!({
262 "success": status.success(),
263 "attempt": attempt + 1,
264 "total_attempts": attempt + 1,
265 "result": result,
266 "stderr": stderr.trim(),
267 "subagent_id": progress.id(),
268 "duration_ms": duration_ms,
269 });
270 progress.dismiss();
271 return Ok(out);
272 }
273 tracing::warn!(
276 attempt = attempt + 1,
277 "spawn_agent attempt failed, retrying"
278 );
279 }
280
281 progress.complete_err("all retry attempts exhausted");
282 progress.dismiss();
283 Err(PawanError::Tool(
284 "spawn_agent: all retry attempts exhausted".into(),
285 ))
286 }
287}
288
289pub struct SpawnAgentsTool {
291 workspace_root: PathBuf,
292}
293
294impl SpawnAgentsTool {
295 pub fn new(workspace_root: PathBuf) -> Self {
296 Self { workspace_root }
297 }
298}
299
300#[async_trait]
301impl Tool for SpawnAgentsTool {
302 fn name(&self) -> &str {
303 "spawn_agents"
304 }
305
306 fn description(&self) -> &str {
307 "Spawn multiple sub-agents in parallel. Each task runs concurrently and results are returned as an array."
308 }
309
310 fn parameters_schema(&self) -> Value {
311 json!({
312 "type": "object",
313 "properties": {
314 "tasks": {
315 "type": "array",
316 "items": {
317 "type": "object",
318 "properties": {
319 "prompt": {"type": "string"},
320 "model": {"type": "string"},
321 "timeout": {"type": "integer"},
322 "workspace": {"type": "string"}
323 },
324 "required": ["prompt"]
325 }
326 }
327 },
328 "required": ["tasks"]
329 })
330 }
331
332 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
333 use thulp_core::{Parameter, ParameterType};
334 thulp_core::ToolDefinition::builder("spawn_agents")
335 .description(self.description())
336 .parameter(Parameter::builder("tasks").param_type(ParameterType::Array).required(true)
337 .description("Array of task objects, each with prompt (required), model, timeout, workspace").build())
338 .build()
339 }
340
341 fn mutating(&self) -> bool {
342 true }
344
345 async fn execute(&self, args: Value) -> Result<Value> {
346 let tasks = args["tasks"]
347 .as_array()
348 .ok_or_else(|| PawanError::Tool("tasks array is required for spawn_agents".into()))?;
349
350 for (index, task) in tasks.iter().enumerate() {
351 if task.get("prompt").and_then(|p| p.as_str()).is_none() {
352 return Err(PawanError::Tool(format!(
353 "spawn_agents: task {index} is missing prompt"
354 )));
355 }
356 }
357
358 let single_tool = SpawnAgentTool::new(self.workspace_root.clone());
359
360 let futures: Vec<_> = tasks
361 .iter()
362 .enumerate()
363 .map(|(index, task)| {
364 let tool = &single_tool;
365 let task = task.clone();
366 async move {
367 let started = std::time::Instant::now();
368 let result = tool.execute(task).await;
369 (index, started.elapsed().as_millis() as u64, result)
370 }
371 })
372 .collect();
373
374 let results = futures::future::join_all(futures).await;
375
376 let mut succeeded = 0usize;
377 let mut failed = 0usize;
378 let mut output: Vec<Value> = Vec::with_capacity(results.len());
379
380 for (index, duration_ms, result) in results {
381 match result {
382 Ok(mut v) => {
383 if v.get("success").and_then(|s| s.as_bool()) == Some(true) {
384 succeeded += 1;
385 } else {
386 failed += 1;
387 }
388 if let Some(obj) = v.as_object_mut() {
389 obj.insert("index".into(), json!(index));
390 obj.insert("duration_ms".into(), json!(duration_ms));
391 }
392 output.push(v);
393 }
394 Err(e) => {
395 failed += 1;
396 output.push(json!({
397 "index": index,
398 "success": false,
399 "error": e.to_string(),
400 "duration_ms": duration_ms,
401 }));
402 }
403 }
404 }
405
406 let total = tasks.len();
407 Ok(json!({
408 "success": failed == 0,
409 "total_tasks": total,
410 "succeeded": succeeded,
411 "failed": failed,
412 "results": output,
413 }))
414 }
415}
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use tempfile::TempDir;
420 #[test]
421 fn test_spawn_agent_tool_name() {
422 let tmp = TempDir::new().unwrap();
423 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
424 assert_eq!(tool.name(), "spawn_agent");
425 }
426
427 #[test]
428 fn test_spawn_agents_tool_name() {
429 let tmp = TempDir::new().unwrap();
430 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
431 assert_eq!(tool.name(), "spawn_agents");
432 }
433
434 #[test]
435 fn test_spawn_agent_schema_has_prompt() {
436 let tmp = TempDir::new().unwrap();
437 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
438 let schema = tool.parameters_schema();
439 assert!(schema["properties"]["prompt"].is_object());
440 assert!(schema["required"]
441 .as_array()
442 .unwrap()
443 .iter()
444 .any(|v| v == "prompt"));
445 }
446
447 #[test]
448 fn test_find_pawan_binary_prefers_release_over_debug() {
449 let tmp = TempDir::new().unwrap();
450 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
452 std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
453 let release = tmp.path().join("target/release/pawan");
454 let debug = tmp.path().join("target/debug/pawan");
455 std::fs::write(&release, "#!/bin/sh\necho release").unwrap();
456 std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
457
458 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
459 let binary = tool.find_pawan_binary();
460 assert_eq!(
461 binary,
462 release.to_string_lossy().to_string(),
463 "release binary must win over debug"
464 );
465 }
466
467 #[test]
468 fn test_find_pawan_binary_falls_back_to_debug_when_no_release() {
469 let tmp = TempDir::new().unwrap();
470 std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
471 let debug = tmp.path().join("target/debug/pawan");
472 std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
473
474 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
475 let binary = tool.find_pawan_binary();
476 assert_eq!(binary, debug.to_string_lossy().to_string());
477 }
478
479 #[test]
480 fn test_find_pawan_binary_falls_through_to_path_when_nothing_in_workspace() {
481 let tmp = TempDir::new().unwrap();
482 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
484 let binary = tool.find_pawan_binary();
485 assert_eq!(binary, "pawan");
487 }
488
489 #[tokio::test]
490 async fn test_spawn_agent_missing_prompt_errors() {
491 let tmp = TempDir::new().unwrap();
492 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
493 let result = tool.execute(json!({ "model": "test-model" })).await;
495 assert!(result.is_err(), "missing prompt must error");
496 let err = format!("{}", result.unwrap_err());
497 assert!(
498 err.contains("prompt"),
499 "error message should mention prompt, got: {}",
500 err
501 );
502 }
503
504 #[test]
505 fn test_spawn_agents_schema_requires_tasks_array() {
506 let tmp = TempDir::new().unwrap();
507 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
508 let schema = tool.parameters_schema();
509 let required = schema["required"].as_array().unwrap();
510 assert!(
511 required.iter().any(|v| v == "tasks"),
512 "tasks must be required"
513 );
514 let tasks_type = schema["properties"]["tasks"]["type"].as_str();
516 assert_eq!(tasks_type, Some("array"));
517 }
518
519 #[tokio::test]
520 async fn test_spawn_agents_empty_tasks_succeeds_with_zero_results() {
521 let tmp = TempDir::new().unwrap();
522 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
523 let result = tool.execute(json!({ "tasks": [] })).await.unwrap();
524 assert_eq!(result["success"], true);
525 assert_eq!(result["total_tasks"], 0);
526 assert_eq!(result["results"].as_array().unwrap().len(), 0);
527 }
528
529 #[tokio::test]
530 async fn test_spawn_agents_missing_tasks_errors() {
531 let tmp = TempDir::new().unwrap();
532 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
533 let result = tool.execute(json!({})).await;
535 assert!(result.is_err());
536 let err = format!("{}", result.unwrap_err());
537 assert!(err.contains("tasks"));
538 }
539
540 #[tokio::test]
541 async fn test_spawn_agent_prompt_non_string_errors() {
542 let tmp = TempDir::new().unwrap();
547 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
548 let result = tool.execute(json!({ "prompt": 42 })).await;
549 assert!(result.is_err(), "non-string prompt must error");
550 let err = format!("{}", result.unwrap_err());
551 assert!(
552 err.contains("prompt"),
553 "error should mention 'prompt', got: {}",
554 err
555 );
556 }
557
558 #[tokio::test]
559 async fn test_spawn_agents_tasks_non_array_errors() {
560 let tmp = TempDir::new().unwrap();
563 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
564 let result = tool.execute(json!({ "tasks": "not an array" })).await;
565 assert!(result.is_err(), "non-array tasks must error");
566 let err = format!("{}", result.unwrap_err());
567 assert!(
568 err.contains("tasks"),
569 "error should mention 'tasks', got: {}",
570 err
571 );
572 }
573
574 #[test]
575 fn test_spawn_agent_schema_lists_all_optional_params() {
576 let tmp = TempDir::new().unwrap();
580 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
581 let schema = tool.parameters_schema();
582 let props = schema["properties"].as_object().unwrap();
583 for p in &["prompt", "model", "timeout", "workspace", "retries"] {
584 assert!(props.contains_key(*p), "schema missing '{}'", p);
585 }
586 let required = schema["required"].as_array().unwrap();
588 assert_eq!(required.len(), 1);
589 assert_eq!(required[0], "prompt");
590 }
591
592 #[test]
593 fn test_spawn_agents_schema_tasks_items_has_prompt_required() {
594 let tmp = TempDir::new().unwrap();
599 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
600 let schema = tool.parameters_schema();
601 let items_required = schema["properties"]["tasks"]["items"]["required"]
602 .as_array()
603 .expect("tasks.items.required should exist");
604 assert!(items_required.iter().any(|v| v == "prompt"));
605 }
606
607 #[test]
608 fn test_spawn_agent_thulp_definition_has_all_5_params() {
609 let tmp = TempDir::new().unwrap();
612 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
613 let def = tool.thulp_definition();
614 assert_eq!(def.name, "spawn_agent");
615 let param_names: Vec<&str> = def.parameters.iter().map(|p| p.name.as_str()).collect();
616 for p in &["prompt", "model", "timeout", "workspace", "retries"] {
617 assert!(param_names.contains(p), "thulp definition missing '{}'", p);
618 }
619 let required_count = def.parameters.iter().filter(|p| p.required).count();
621 assert_eq!(required_count, 1, "only prompt should be required");
622 }
623
624 #[test]
625 fn test_spawn_agents_thulp_definition_has_tasks_param() {
626 let tmp = TempDir::new().unwrap();
630 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
631 let def = tool.thulp_definition();
632 assert_eq!(def.name, "spawn_agents");
633 assert_eq!(def.parameters.len(), 1);
634 let tasks_param = &def.parameters[0];
635 assert_eq!(tasks_param.name, "tasks");
636 assert!(tasks_param.required);
637 }
638
639 #[test]
640 fn test_spawn_agent_mutating_returns_true() {
641 let tmp = TempDir::new().unwrap();
642 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
643 assert!(tool.mutating(), "spawn_agent can mutate state");
644 }
645
646 #[test]
647 fn test_spawn_agents_mutating_returns_true() {
648 let tmp = TempDir::new().unwrap();
649 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
650 assert!(tool.mutating(), "spawn_agents can mutate state");
651 }
652
653 #[test]
654 fn test_spawn_agent_description_non_empty() {
655 let tmp = TempDir::new().unwrap();
656 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
657 let desc = tool.description();
658 assert!(!desc.is_empty());
659 assert!(desc.contains("sub-agent"));
660 }
661
662 #[test]
663 fn test_spawn_agents_description_non_empty() {
664 let tmp = TempDir::new().unwrap();
665 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
666 let desc = tool.description();
667 assert!(!desc.is_empty());
668 assert!(desc.contains("parallel"));
669 }
670
671 #[tokio::test]
672 async fn test_spawn_agent_timeout_defaults_to_120() {
673 let tmp = TempDir::new().unwrap();
674 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
675 let binary = tmp.path().join("target/release/pawan");
676 std::fs::write(
677 &binary,
678 r"#!/bin/sh
679exit 0",
680 )
681 .unwrap();
682 #[cfg(unix)]
683 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
684 .unwrap();
685
686 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
687 let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
688 assert_eq!(result["success"], true);
689 }
690
691 #[tokio::test]
692 async fn test_spawn_agent_custom_timeout() {
693 let tmp = TempDir::new().unwrap();
694 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
695 let binary = tmp.path().join("target/release/pawan");
696 std::fs::write(
697 &binary,
698 r"#!/bin/sh
699exit 0",
700 )
701 .unwrap();
702 #[cfg(unix)]
703 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
704 .unwrap();
705
706 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
707 let result = tool
708 .execute(json!({"prompt": "test", "timeout": 60}))
709 .await
710 .unwrap();
711 assert_eq!(result["success"], true);
712 }
713
714 #[tokio::test]
715 async fn test_spawn_agent_custom_model() {
716 let tmp = TempDir::new().unwrap();
717 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
718 let binary = tmp.path().join("target/release/pawan");
719 std::fs::write(&binary, "#!/bin/sh\necho '{\"content\":\"test response\"}'").unwrap();
720 #[cfg(unix)]
721 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
722 .unwrap();
723
724 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
725 let result = tool
726 .execute(json!({"prompt": "test", "model": "gpt-4"}))
727 .await
728 .unwrap();
729 assert_eq!(result["success"], true);
730 assert_eq!(result["result"]["content"], "test response");
731 }
732
733 #[tokio::test]
734 async fn test_spawn_agent_retries_on_failure() {
735 let tmp = TempDir::new().unwrap();
736 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
737 let binary = tmp.path().join("target/release/pawan");
738 let counter_file = tmp.path().join("counter");
739 std::fs::write(&counter_file, "0").unwrap();
740 let script = format!(
741 "#!/bin/sh\ncount=$(cat {})\necho $((count + 1)) > {}\nif [ $count -eq 0 ]; then\n exit 1\nelse\n exit 0\nfi",
742 counter_file.display(), counter_file.display()
743 );
744 std::fs::write(&binary, script).unwrap();
745 #[cfg(unix)]
746 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
747 .unwrap();
748
749 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
750 let result = tool
751 .execute(json!({
752 "prompt": "test",
753 "retries": 1
754 }))
755 .await
756 .unwrap();
757 assert_eq!(result["success"], true);
758 assert_eq!(result["attempt"], 2);
759 assert_eq!(result["total_attempts"], 2);
760 }
761
762 #[tokio::test]
763 async fn test_spawn_agent_stderr_captured() {
764 let tmp = TempDir::new().unwrap();
765 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
766 let binary = tmp.path().join("target/release/pawan");
767 std::fs::write(
768 &binary,
769 r"#!/bin/sh
770echo 'error message' >&2
771exit 0",
772 )
773 .unwrap();
774 #[cfg(unix)]
775 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
776 .unwrap();
777
778 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
779 let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
780 assert_eq!(result["success"], true);
781 assert_eq!(result["stderr"], "error message");
782 }
783
784 #[serial_test::serial(pawan_session_tests)]
785 #[tokio::test]
786 async fn test_spawn_agents_single_task() {
787 let tmp = TempDir::new().unwrap();
788 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
789 let binary = tmp.path().join("target/release/pawan");
790 std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
791 #[cfg(unix)]
792 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
793 .unwrap();
794
795 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
796 let result = tool
797 .execute(json!({
798 "tasks": [
799 {"prompt": "task1"}
800 ]
801 }))
802 .await
803 .unwrap();
804 assert_eq!(result["success"], true);
805 assert_eq!(result["total_tasks"], 1);
806 assert_eq!(result["results"].as_array().unwrap().len(), 1);
807 assert_eq!(result["results"][0]["result"]["result"], "done");
808 }
809
810 #[serial_test::serial(pawan_session_tests)]
811 #[tokio::test]
812 async fn test_spawn_agents_multiple_tasks() {
813 let tmp = TempDir::new().unwrap();
814 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
815 let binary = tmp.path().join("target/release/pawan");
816 std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
817 #[cfg(unix)]
818 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
819 .unwrap();
820
821 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
822 let result = tool
823 .execute(json!({
824 "tasks": [
825 {"prompt": "task1"},
826 {"prompt": "task2"},
827 {"prompt": "task3"}
828 ]
829 }))
830 .await
831 .unwrap();
832 assert_eq!(result["success"], true);
833 assert_eq!(result["total_tasks"], 3);
834 assert_eq!(result["results"].as_array().unwrap().len(), 3);
835 }
836
837 #[tokio::test]
838 async fn test_spawn_agents_task_missing_prompt() {
839 let tmp = TempDir::new().unwrap();
840 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
841 let result = tool
842 .execute(json!({
843 "tasks": [{"model": "gpt-4"}]
844 }))
845 .await;
846 assert!(result.is_err());
848 let err = result.unwrap_err();
849 assert!(err.to_string().contains("missing prompt"));
850 }
851}