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!("exit code {}", status.code().unwrap_or(-1)));
257 }
258 let out = json!({
259 "success": status.success(),
260 "attempt": attempt + 1,
261 "total_attempts": attempt + 1,
262 "result": result,
263 "stderr": stderr.trim(),
264 "subagent_id": progress.id(),
265 "duration_ms": duration_ms,
266 });
267 progress.dismiss();
268 return Ok(out);
269 }
270 tracing::warn!(
273 attempt = attempt + 1,
274 "spawn_agent attempt failed, retrying"
275 );
276 }
277
278 progress.complete_err("all retry attempts exhausted");
279 progress.dismiss();
280 Err(PawanError::Tool(
281 "spawn_agent: all retry attempts exhausted".into(),
282 ))
283 }
284}
285
286pub struct SpawnAgentsTool {
288 workspace_root: PathBuf,
289}
290
291impl SpawnAgentsTool {
292 pub fn new(workspace_root: PathBuf) -> Self {
293 Self { workspace_root }
294 }
295}
296
297#[async_trait]
298impl Tool for SpawnAgentsTool {
299 fn name(&self) -> &str {
300 "spawn_agents"
301 }
302
303 fn description(&self) -> &str {
304 "Spawn multiple sub-agents in parallel. Each task runs concurrently and results are returned as an array."
305 }
306
307 fn parameters_schema(&self) -> Value {
308 json!({
309 "type": "object",
310 "properties": {
311 "tasks": {
312 "type": "array",
313 "items": {
314 "type": "object",
315 "properties": {
316 "prompt": {"type": "string"},
317 "model": {"type": "string"},
318 "timeout": {"type": "integer"},
319 "workspace": {"type": "string"}
320 },
321 "required": ["prompt"]
322 }
323 }
324 },
325 "required": ["tasks"]
326 })
327 }
328
329 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
330 use thulp_core::{Parameter, ParameterType};
331 thulp_core::ToolDefinition::builder("spawn_agents")
332 .description(self.description())
333 .parameter(Parameter::builder("tasks").param_type(ParameterType::Array).required(true)
334 .description("Array of task objects, each with prompt (required), model, timeout, workspace").build())
335 .build()
336 }
337
338 fn mutating(&self) -> bool {
339 true }
341
342 async fn execute(&self, args: Value) -> Result<Value> {
343 let tasks = args["tasks"]
344 .as_array()
345 .ok_or_else(|| PawanError::Tool("tasks array is required for spawn_agents".into()))?;
346
347 for (index, task) in tasks.iter().enumerate() {
348 if task.get("prompt").and_then(|p| p.as_str()).is_none() {
349 return Err(PawanError::Tool(format!(
350 "spawn_agents: task {index} is missing prompt"
351 )));
352 }
353 }
354
355 let single_tool = SpawnAgentTool::new(self.workspace_root.clone());
356
357 let futures: Vec<_> = tasks
358 .iter()
359 .enumerate()
360 .map(|(index, task)| {
361 let tool = &single_tool;
362 let task = task.clone();
363 async move {
364 let started = std::time::Instant::now();
365 let result = tool.execute(task).await;
366 (index, started.elapsed().as_millis() as u64, result)
367 }
368 })
369 .collect();
370
371 let results = futures::future::join_all(futures).await;
372
373 let mut succeeded = 0usize;
374 let mut failed = 0usize;
375 let mut output: Vec<Value> = Vec::with_capacity(results.len());
376
377 for (index, duration_ms, result) in results {
378 match result {
379 Ok(mut v) => {
380 if v.get("success").and_then(|s| s.as_bool()) == Some(true) {
381 succeeded += 1;
382 } else {
383 failed += 1;
384 }
385 if let Some(obj) = v.as_object_mut() {
386 obj.insert("index".into(), json!(index));
387 obj.insert("duration_ms".into(), json!(duration_ms));
388 }
389 output.push(v);
390 }
391 Err(e) => {
392 failed += 1;
393 output.push(json!({
394 "index": index,
395 "success": false,
396 "error": e.to_string(),
397 "duration_ms": duration_ms,
398 }));
399 }
400 }
401 }
402
403 let total = tasks.len();
404 Ok(json!({
405 "success": failed == 0,
406 "total_tasks": total,
407 "succeeded": succeeded,
408 "failed": failed,
409 "results": output,
410 }))
411 }
412}
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use tempfile::TempDir;
417 #[test]
418 fn test_spawn_agent_tool_name() {
419 let tmp = TempDir::new().unwrap();
420 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
421 assert_eq!(tool.name(), "spawn_agent");
422 }
423
424 #[test]
425 fn test_spawn_agents_tool_name() {
426 let tmp = TempDir::new().unwrap();
427 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
428 assert_eq!(tool.name(), "spawn_agents");
429 }
430
431 #[test]
432 fn test_spawn_agent_schema_has_prompt() {
433 let tmp = TempDir::new().unwrap();
434 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
435 let schema = tool.parameters_schema();
436 assert!(schema["properties"]["prompt"].is_object());
437 assert!(schema["required"]
438 .as_array()
439 .unwrap()
440 .iter()
441 .any(|v| v == "prompt"));
442 }
443
444 #[test]
445 fn test_find_pawan_binary_prefers_release_over_debug() {
446 let tmp = TempDir::new().unwrap();
447 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
449 std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
450 let release = tmp.path().join("target/release/pawan");
451 let debug = tmp.path().join("target/debug/pawan");
452 std::fs::write(&release, "#!/bin/sh\necho release").unwrap();
453 std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
454
455 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
456 let binary = tool.find_pawan_binary();
457 assert_eq!(
458 binary,
459 release.to_string_lossy().to_string(),
460 "release binary must win over debug"
461 );
462 }
463
464 #[test]
465 fn test_find_pawan_binary_falls_back_to_debug_when_no_release() {
466 let tmp = TempDir::new().unwrap();
467 std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
468 let debug = tmp.path().join("target/debug/pawan");
469 std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
470
471 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
472 let binary = tool.find_pawan_binary();
473 assert_eq!(binary, debug.to_string_lossy().to_string());
474 }
475
476 #[test]
477 fn test_find_pawan_binary_falls_through_to_path_when_nothing_in_workspace() {
478 let tmp = TempDir::new().unwrap();
479 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
481 let binary = tool.find_pawan_binary();
482 assert_eq!(binary, "pawan");
484 }
485
486 #[tokio::test]
487 async fn test_spawn_agent_missing_prompt_errors() {
488 let tmp = TempDir::new().unwrap();
489 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
490 let result = tool.execute(json!({ "model": "test-model" })).await;
492 assert!(result.is_err(), "missing prompt must error");
493 let err = format!("{}", result.unwrap_err());
494 assert!(
495 err.contains("prompt"),
496 "error message should mention prompt, got: {}",
497 err
498 );
499 }
500
501 #[test]
502 fn test_spawn_agents_schema_requires_tasks_array() {
503 let tmp = TempDir::new().unwrap();
504 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
505 let schema = tool.parameters_schema();
506 let required = schema["required"].as_array().unwrap();
507 assert!(
508 required.iter().any(|v| v == "tasks"),
509 "tasks must be required"
510 );
511 let tasks_type = schema["properties"]["tasks"]["type"].as_str();
513 assert_eq!(tasks_type, Some("array"));
514 }
515
516 #[tokio::test]
517 async fn test_spawn_agents_empty_tasks_succeeds_with_zero_results() {
518 let tmp = TempDir::new().unwrap();
519 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
520 let result = tool.execute(json!({ "tasks": [] })).await.unwrap();
521 assert_eq!(result["success"], true);
522 assert_eq!(result["total_tasks"], 0);
523 assert_eq!(result["results"].as_array().unwrap().len(), 0);
524 }
525
526 #[tokio::test]
527 async fn test_spawn_agents_missing_tasks_errors() {
528 let tmp = TempDir::new().unwrap();
529 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
530 let result = tool.execute(json!({})).await;
532 assert!(result.is_err());
533 let err = format!("{}", result.unwrap_err());
534 assert!(err.contains("tasks"));
535 }
536
537 #[tokio::test]
538 async fn test_spawn_agent_prompt_non_string_errors() {
539 let tmp = TempDir::new().unwrap();
544 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
545 let result = tool.execute(json!({ "prompt": 42 })).await;
546 assert!(result.is_err(), "non-string prompt must error");
547 let err = format!("{}", result.unwrap_err());
548 assert!(
549 err.contains("prompt"),
550 "error should mention 'prompt', got: {}",
551 err
552 );
553 }
554
555 #[tokio::test]
556 async fn test_spawn_agents_tasks_non_array_errors() {
557 let tmp = TempDir::new().unwrap();
560 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
561 let result = tool.execute(json!({ "tasks": "not an array" })).await;
562 assert!(result.is_err(), "non-array tasks must error");
563 let err = format!("{}", result.unwrap_err());
564 assert!(
565 err.contains("tasks"),
566 "error should mention 'tasks', got: {}",
567 err
568 );
569 }
570
571 #[test]
572 fn test_spawn_agent_schema_lists_all_optional_params() {
573 let tmp = TempDir::new().unwrap();
577 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
578 let schema = tool.parameters_schema();
579 let props = schema["properties"].as_object().unwrap();
580 for p in &["prompt", "model", "timeout", "workspace", "retries"] {
581 assert!(props.contains_key(*p), "schema missing '{}'", p);
582 }
583 let required = schema["required"].as_array().unwrap();
585 assert_eq!(required.len(), 1);
586 assert_eq!(required[0], "prompt");
587 }
588
589 #[test]
590 fn test_spawn_agents_schema_tasks_items_has_prompt_required() {
591 let tmp = TempDir::new().unwrap();
596 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
597 let schema = tool.parameters_schema();
598 let items_required = schema["properties"]["tasks"]["items"]["required"]
599 .as_array()
600 .expect("tasks.items.required should exist");
601 assert!(items_required.iter().any(|v| v == "prompt"));
602 }
603
604 #[test]
605 fn test_spawn_agent_thulp_definition_has_all_5_params() {
606 let tmp = TempDir::new().unwrap();
609 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
610 let def = tool.thulp_definition();
611 assert_eq!(def.name, "spawn_agent");
612 let param_names: Vec<&str> = def.parameters.iter().map(|p| p.name.as_str()).collect();
613 for p in &["prompt", "model", "timeout", "workspace", "retries"] {
614 assert!(param_names.contains(p), "thulp definition missing '{}'", p);
615 }
616 let required_count = def.parameters.iter().filter(|p| p.required).count();
618 assert_eq!(required_count, 1, "only prompt should be required");
619 }
620
621 #[test]
622 fn test_spawn_agents_thulp_definition_has_tasks_param() {
623 let tmp = TempDir::new().unwrap();
627 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
628 let def = tool.thulp_definition();
629 assert_eq!(def.name, "spawn_agents");
630 assert_eq!(def.parameters.len(), 1);
631 let tasks_param = &def.parameters[0];
632 assert_eq!(tasks_param.name, "tasks");
633 assert!(tasks_param.required);
634 }
635
636 #[test]
637 fn test_spawn_agent_mutating_returns_true() {
638 let tmp = TempDir::new().unwrap();
639 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
640 assert!(tool.mutating(), "spawn_agent can mutate state");
641 }
642
643 #[test]
644 fn test_spawn_agents_mutating_returns_true() {
645 let tmp = TempDir::new().unwrap();
646 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
647 assert!(tool.mutating(), "spawn_agents can mutate state");
648 }
649
650 #[test]
651 fn test_spawn_agent_description_non_empty() {
652 let tmp = TempDir::new().unwrap();
653 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
654 let desc = tool.description();
655 assert!(!desc.is_empty());
656 assert!(desc.contains("sub-agent"));
657 }
658
659 #[test]
660 fn test_spawn_agents_description_non_empty() {
661 let tmp = TempDir::new().unwrap();
662 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
663 let desc = tool.description();
664 assert!(!desc.is_empty());
665 assert!(desc.contains("parallel"));
666 }
667
668 #[tokio::test]
669 async fn test_spawn_agent_timeout_defaults_to_120() {
670 let tmp = TempDir::new().unwrap();
671 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
672 let binary = tmp.path().join("target/release/pawan");
673 std::fs::write(
674 &binary,
675 r"#!/bin/sh
676exit 0",
677 )
678 .unwrap();
679 #[cfg(unix)]
680 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
681 .unwrap();
682
683 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
684 let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
685 assert_eq!(result["success"], true);
686 }
687
688 #[tokio::test]
689 async fn test_spawn_agent_custom_timeout() {
690 let tmp = TempDir::new().unwrap();
691 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
692 let binary = tmp.path().join("target/release/pawan");
693 std::fs::write(
694 &binary,
695 r"#!/bin/sh
696exit 0",
697 )
698 .unwrap();
699 #[cfg(unix)]
700 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
701 .unwrap();
702
703 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
704 let result = tool
705 .execute(json!({"prompt": "test", "timeout": 60}))
706 .await
707 .unwrap();
708 assert_eq!(result["success"], true);
709 }
710
711 #[tokio::test]
712 async fn test_spawn_agent_custom_model() {
713 let tmp = TempDir::new().unwrap();
714 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
715 let binary = tmp.path().join("target/release/pawan");
716 std::fs::write(&binary, "#!/bin/sh\necho '{\"content\":\"test response\"}'").unwrap();
717 #[cfg(unix)]
718 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
719 .unwrap();
720
721 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
722 let result = tool
723 .execute(json!({"prompt": "test", "model": "gpt-4"}))
724 .await
725 .unwrap();
726 assert_eq!(result["success"], true);
727 assert_eq!(result["result"]["content"], "test response");
728 }
729
730 #[tokio::test]
731 async fn test_spawn_agent_retries_on_failure() {
732 let tmp = TempDir::new().unwrap();
733 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
734 let binary = tmp.path().join("target/release/pawan");
735 let counter_file = tmp.path().join("counter");
736 std::fs::write(&counter_file, "0").unwrap();
737 let script = format!(
738 "#!/bin/sh\ncount=$(cat {})\necho $((count + 1)) > {}\nif [ $count -eq 0 ]; then\n exit 1\nelse\n exit 0\nfi",
739 counter_file.display(), counter_file.display()
740 );
741 std::fs::write(&binary, script).unwrap();
742 #[cfg(unix)]
743 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
744 .unwrap();
745
746 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
747 let result = tool
748 .execute(json!({
749 "prompt": "test",
750 "retries": 1
751 }))
752 .await
753 .unwrap();
754 assert_eq!(result["success"], true);
755 assert_eq!(result["attempt"], 2);
756 assert_eq!(result["total_attempts"], 2);
757 }
758
759 #[tokio::test]
760 async fn test_spawn_agent_stderr_captured() {
761 let tmp = TempDir::new().unwrap();
762 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
763 let binary = tmp.path().join("target/release/pawan");
764 std::fs::write(
765 &binary,
766 r"#!/bin/sh
767echo 'error message' >&2
768exit 0",
769 )
770 .unwrap();
771 #[cfg(unix)]
772 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
773 .unwrap();
774
775 let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
776 let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
777 assert_eq!(result["success"], true);
778 assert_eq!(result["stderr"], "error message");
779 }
780
781 #[serial_test::serial(pawan_session_tests)]
782 #[tokio::test]
783 async fn test_spawn_agents_single_task() {
784 let tmp = TempDir::new().unwrap();
785 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
786 let binary = tmp.path().join("target/release/pawan");
787 std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
788 #[cfg(unix)]
789 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
790 .unwrap();
791
792 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
793 let result = tool
794 .execute(json!({
795 "tasks": [
796 {"prompt": "task1"}
797 ]
798 }))
799 .await
800 .unwrap();
801 assert_eq!(result["success"], true);
802 assert_eq!(result["total_tasks"], 1);
803 assert_eq!(result["results"].as_array().unwrap().len(), 1);
804 assert_eq!(result["results"][0]["result"]["result"], "done");
805 }
806
807 #[serial_test::serial(pawan_session_tests)]
808 #[tokio::test]
809 async fn test_spawn_agents_multiple_tasks() {
810 let tmp = TempDir::new().unwrap();
811 std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
812 let binary = tmp.path().join("target/release/pawan");
813 std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
814 #[cfg(unix)]
815 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755))
816 .unwrap();
817
818 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
819 let result = tool
820 .execute(json!({
821 "tasks": [
822 {"prompt": "task1"},
823 {"prompt": "task2"},
824 {"prompt": "task3"}
825 ]
826 }))
827 .await
828 .unwrap();
829 assert_eq!(result["success"], true);
830 assert_eq!(result["total_tasks"], 3);
831 assert_eq!(result["results"].as_array().unwrap().len(), 3);
832 }
833
834 #[tokio::test]
835 async fn test_spawn_agents_task_missing_prompt() {
836 let tmp = TempDir::new().unwrap();
837 let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
838 let result = tool
839 .execute(json!({
840 "tasks": [{"model": "gpt-4"}]
841 }))
842 .await;
843 assert!(result.is_err());
845 let err = result.unwrap_err();
846 assert!(err.to_string().contains("missing prompt"));
847 }
848}