Skip to main content

pawan/tools/
agent.rs

1//! Sub-agent spawning tool
2//!
3//! Spawns a pawan subprocess to handle a task independently.
4//! This is the OMO replacement — enables multi-agent orchestration.
5
6use super::Tool;
7use crate::{PawanError, Result};
8use async_trait::async_trait;
9use serde_json::{json, Value};
10use std::path::PathBuf;
11use std::process::Stdio;
12use tokio::io::AsyncReadExt;
13use tokio::process::Command;
14use std::io::Write;
15use tracing;
16
17/// Tool for spawning a sub-agent (pawan subprocess)
18pub struct SpawnAgentTool {
19    workspace_root: PathBuf,
20}
21
22impl SpawnAgentTool {
23    pub fn new(workspace_root: PathBuf) -> Self {
24        Self { workspace_root }
25    }
26
27    /// Find the pawan binary — tries cargo target first, then PATH
28    fn find_pawan_binary(&self) -> String {
29        // Check for debug/release binary in workspace target
30        for candidate in &[
31            self.workspace_root.join("target/release/pawan"),
32            self.workspace_root.join("target/debug/pawan"),
33        ] {
34            if candidate.exists() {
35                return candidate.to_string_lossy().to_string();
36            }
37        }
38        // Fall back to PATH
39        "pawan".to_string()
40    }
41}
42
43#[async_trait]
44impl Tool for SpawnAgentTool {
45    fn name(&self) -> &str {
46        "spawn_agent"
47    }
48
49    fn description(&self) -> &str {
50        "Spawn a sub-agent (pawan subprocess) to handle a task independently. \
51         Returns the agent's response as JSON. Use this for parallel or delegated tasks."
52    }
53
54    fn mutating(&self) -> bool {
55        true // Spawning agents can mutate state
56    }
57
58    fn parameters_schema(&self) -> Value {
59        json!({
60            "type": "object",
61            "properties": {
62                "prompt": {
63                    "type": "string",
64                    "description": "The task/prompt for the sub-agent"
65                },
66                "model": {
67                    "type": "string",
68                    "description": "Model to use (optional, defaults to parent's model)"
69                },
70                "timeout": {
71                    "type": "integer",
72                    "description": "Timeout in seconds (default: 120)"
73                },
74                "workspace": {
75                    "type": "string",
76                    "description": "Workspace directory for the sub-agent (default: same as parent)"
77                },
78                "retries": {
79                    "type": "integer",
80                    "description": "Number of retry attempts on failure (default: 0, max: 2)"
81                }
82            },
83            "required": ["prompt"]
84        })
85    }
86
87    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
88        use thulp_core::{Parameter, ParameterType};
89        thulp_core::ToolDefinition::builder("spawn_agent")
90            .description(self.description())
91            .parameter(Parameter::builder("prompt").param_type(ParameterType::String).required(true)
92                .description("The task/prompt for the sub-agent").build())
93            .parameter(Parameter::builder("model").param_type(ParameterType::String).required(false)
94                .description("Model to use (optional, defaults to parent's model)").build())
95            .parameter(Parameter::builder("timeout").param_type(ParameterType::Integer).required(false)
96                .description("Timeout in seconds (default: 120)").build())
97            .parameter(Parameter::builder("workspace").param_type(ParameterType::String).required(false)
98                .description("Workspace directory for the sub-agent (default: same as parent)").build())
99            .parameter(Parameter::builder("retries").param_type(ParameterType::Integer).required(false)
100                .description("Number of retry attempts on failure (default: 0, max: 2)").build())
101            .build()
102    }
103
104    async fn execute(&self, args: Value) -> Result<Value> {
105        let prompt = args["prompt"]
106            .as_str()
107            .ok_or_else(|| PawanError::Tool("prompt is required for spawn_agent".into()))?;
108
109        let timeout = args["timeout"].as_u64().unwrap_or(120);
110        let model = args["model"].as_str();
111        let workspace = args["workspace"]
112            .as_str()
113            .map(PathBuf::from)
114            .unwrap_or_else(|| self.workspace_root.clone());
115        let max_retries = args["retries"].as_u64().unwrap_or(0).min(2) as usize;
116
117        // Generate unique agent ID for progress tracking
118        let agent_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
119        let status_path = format!("/tmp/pawan-agent-{}.status", agent_id);
120        let started_at = chrono::Utc::now().to_rfc3339();
121
122        let pawan_bin = self.find_pawan_binary();
123
124        for attempt in 0..=max_retries {
125            let mut cmd = Command::new(&pawan_bin);
126            cmd.arg("run")
127                .arg("-o")
128                .arg("json")
129                .arg("--timeout")
130                .arg(timeout.to_string())
131                .arg("-w")
132                .arg(workspace.to_string_lossy().to_string());
133
134            if let Some(m) = model {
135                cmd.arg("-m").arg(m);
136            }
137
138            cmd.arg(prompt);
139
140            cmd.stdout(Stdio::piped())
141                .stderr(Stdio::piped())
142                .stdin(Stdio::null());
143
144            // Write initial status
145            if let Ok(mut f) = std::fs::File::create(&status_path) {
146                let _ = write!(f, r#"{{"state":"running","prompt":"{}","started_at":"{}","attempt":{}}}"#,
147                    prompt.chars().take(100).collect::<String>().replace('"', "'"), started_at, attempt + 1);
148            }
149
150            let mut child = cmd.spawn().map_err(|e| {
151                PawanError::Tool(format!(
152                    "Failed to spawn sub-agent: {}. Binary: {}",
153                    e, pawan_bin
154                ))
155            })?;
156
157            let mut stdout = String::new();
158            let mut stderr = String::new();
159
160            if let Some(mut handle) = child.stdout.take() {
161                handle.read_to_string(&mut stdout).await.ok();
162            }
163            if let Some(mut handle) = child.stderr.take() {
164                handle.read_to_string(&mut stderr).await.ok();
165            }
166
167            let status = child.wait().await.map_err(PawanError::Io)?;
168
169            let result = if let Ok(json_result) = serde_json::from_str::<Value>(&stdout) {
170                json_result
171            } else {
172                json!({
173                    "content": stdout.trim(),
174                    "raw_output": true
175                })
176            };
177
178            if status.success() || attempt == max_retries {
179                // Update status file with completion
180                let duration_ms = chrono::Utc::now().signed_duration_since(chrono::DateTime::parse_from_rfc3339(&started_at).unwrap_or_default()).num_milliseconds();
181                if let Ok(mut f) = std::fs::File::create(&status_path) {
182                    let state = if status.success() { "done" } else { "failed" };
183                    let _ = write!(f, r#"{{"state":"{}","exit_code":{},"duration_ms":{},"attempt":{}}}"#,
184                        state, status.code().unwrap_or(-1), duration_ms, attempt + 1);
185                }
186
187                return Ok(json!({
188                    "success": status.success(),
189                    "attempt": attempt + 1,
190                    "total_attempts": attempt + 1,
191                    "result": result,
192                    "stderr": stderr.trim(),
193                }));
194            }
195            // Failed but retries remaining — continue loop
196            // Failed but retries remaining — continue loop
197            tracing::warn!(attempt = attempt + 1, "spawn_agent attempt failed, retrying");
198        }
199
200        // Should not reach here, but satisfy the compiler
201        Err(PawanError::Tool("spawn_agent: all retry attempts exhausted".into()))
202    }
203}
204
205/// Tool for spawning multiple sub-agents in parallel
206pub struct SpawnAgentsTool {
207    workspace_root: PathBuf,
208}
209
210impl SpawnAgentsTool {
211    pub fn new(workspace_root: PathBuf) -> Self {
212        Self { workspace_root }
213    }
214}
215
216#[async_trait]
217impl Tool for SpawnAgentsTool {
218    fn name(&self) -> &str {
219        "spawn_agents"
220    }
221
222    fn description(&self) -> &str {
223        "Spawn multiple sub-agents in parallel. Each task runs concurrently and results are returned as an array."
224    }
225
226    fn parameters_schema(&self) -> Value {
227        json!({
228            "type": "object",
229            "properties": {
230                "tasks": {
231                    "type": "array",
232                    "items": {
233                        "type": "object",
234                        "properties": {
235                            "prompt": {"type": "string"},
236                            "model": {"type": "string"},
237                            "timeout": {"type": "integer"},
238                            "workspace": {"type": "string"}
239                        },
240                        "required": ["prompt"]
241                    }
242                }
243            },
244            "required": ["tasks"]
245        })
246    }
247
248    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
249        use thulp_core::{Parameter, ParameterType};
250        thulp_core::ToolDefinition::builder("spawn_agents")
251            .description(self.description())
252            .parameter(Parameter::builder("tasks").param_type(ParameterType::Array).required(true)
253                .description("Array of task objects, each with prompt (required), model, timeout, workspace").build())
254            .build()
255    }
256
257    fn mutating(&self) -> bool {
258        true // Spawning agents can mutate state
259    }
260
261    async fn execute(&self, args: Value) -> Result<Value> {
262        let tasks = args["tasks"]
263            .as_array()
264            .ok_or_else(|| PawanError::Tool("tasks array is required for spawn_agents".into()))?;
265
266        let single_tool = SpawnAgentTool::new(self.workspace_root.clone());
267
268        let futures: Vec<_> = tasks
269            .iter()
270            .map(|task| single_tool.execute(task.clone()))
271            .collect();
272
273        let results = futures::future::join_all(futures).await;
274
275        let output: Vec<Value> = results
276            .into_iter()
277            .map(|r| match r {
278                Ok(v) => v,
279                Err(e) => json!({"success": false, "error": e.to_string()}),
280            })
281            .collect();
282
283        Ok(json!({
284            "success": true,
285            "results": output,
286            "total_tasks": tasks.len(),
287        }))
288    }
289}
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use tempfile::TempDir;
294    #[cfg(unix)]
295    use std::os::unix::fs::PermissionsExt;
296    #[test]
297    fn test_spawn_agent_tool_name() {
298        let tmp = TempDir::new().unwrap();
299        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
300        assert_eq!(tool.name(), "spawn_agent");
301    }
302
303    #[test]
304    fn test_spawn_agents_tool_name() {
305        let tmp = TempDir::new().unwrap();
306        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
307        assert_eq!(tool.name(), "spawn_agents");
308    }
309
310    #[test]
311    fn test_spawn_agent_schema_has_prompt() {
312        let tmp = TempDir::new().unwrap();
313        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
314        let schema = tool.parameters_schema();
315        assert!(schema["properties"]["prompt"].is_object());
316        assert!(schema["required"].as_array().unwrap().iter().any(|v| v == "prompt"));
317    }
318
319    #[test]
320    fn test_find_pawan_binary_prefers_release_over_debug() {
321        let tmp = TempDir::new().unwrap();
322        // Create both release and debug pawan binaries
323        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
324        std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
325        let release = tmp.path().join("target/release/pawan");
326        let debug = tmp.path().join("target/debug/pawan");
327        std::fs::write(&release, "#!/bin/sh\necho release").unwrap();
328        std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
329
330        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
331        let binary = tool.find_pawan_binary();
332        assert_eq!(
333            binary,
334            release.to_string_lossy().to_string(),
335            "release binary must win over debug"
336        );
337    }
338
339    #[test]
340    fn test_find_pawan_binary_falls_back_to_debug_when_no_release() {
341        let tmp = TempDir::new().unwrap();
342        std::fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
343        let debug = tmp.path().join("target/debug/pawan");
344        std::fs::write(&debug, "#!/bin/sh\necho debug").unwrap();
345
346        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
347        let binary = tool.find_pawan_binary();
348        assert_eq!(binary, debug.to_string_lossy().to_string());
349    }
350
351    #[test]
352    fn test_find_pawan_binary_falls_through_to_path_when_nothing_in_workspace() {
353        let tmp = TempDir::new().unwrap();
354        // No target/ at all
355        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
356        let binary = tool.find_pawan_binary();
357        // Falls back to bare "pawan" name (will be resolved via PATH at exec time)
358        assert_eq!(binary, "pawan");
359    }
360
361    #[tokio::test]
362    async fn test_spawn_agent_missing_prompt_errors() {
363        let tmp = TempDir::new().unwrap();
364        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
365        // No "prompt" field in args
366        let result = tool.execute(json!({ "model": "test-model" })).await;
367        assert!(result.is_err(), "missing prompt must error");
368        let err = format!("{}", result.unwrap_err());
369        assert!(
370            err.contains("prompt"),
371            "error message should mention prompt, got: {}",
372            err
373        );
374    }
375
376    #[test]
377    fn test_spawn_agents_schema_requires_tasks_array() {
378        let tmp = TempDir::new().unwrap();
379        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
380        let schema = tool.parameters_schema();
381        let required = schema["required"].as_array().unwrap();
382        assert!(required.iter().any(|v| v == "tasks"), "tasks must be required");
383        // tasks should be declared as an array type with an items.required = [prompt]
384        let tasks_type = schema["properties"]["tasks"]["type"].as_str();
385        assert_eq!(tasks_type, Some("array"));
386    }
387
388    #[tokio::test]
389    async fn test_spawn_agents_empty_tasks_succeeds_with_zero_results() {
390        let tmp = TempDir::new().unwrap();
391        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
392        let result = tool.execute(json!({ "tasks": [] })).await.unwrap();
393        assert_eq!(result["success"], true);
394        assert_eq!(result["total_tasks"], 0);
395        assert_eq!(result["results"].as_array().unwrap().len(), 0);
396    }
397
398    #[tokio::test]
399    async fn test_spawn_agents_missing_tasks_errors() {
400        let tmp = TempDir::new().unwrap();
401        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
402        // tasks field absent entirely
403        let result = tool.execute(json!({})).await;
404        assert!(result.is_err());
405        let err = format!("{}", result.unwrap_err());
406        assert!(err.contains("tasks"));
407    }
408
409    #[tokio::test]
410    async fn test_spawn_agent_prompt_non_string_errors() {
411        // Prompt present but as integer — `.as_str()` returns None so
412        // ok_or_else must fire. Guards against a refactor that silently
413        // coerces numeric prompts (which would then panic or send garbage
414        // to the pawan run subcommand).
415        let tmp = TempDir::new().unwrap();
416        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
417        let result = tool.execute(json!({ "prompt": 42 })).await;
418        assert!(result.is_err(), "non-string prompt must error");
419        let err = format!("{}", result.unwrap_err());
420        assert!(err.contains("prompt"), "error should mention 'prompt', got: {}", err);
421    }
422
423    #[tokio::test]
424    async fn test_spawn_agents_tasks_non_array_errors() {
425        // tasks present but as string — `.as_array()` returns None so
426        // ok_or_else must fire. Prevents silent coercion.
427        let tmp = TempDir::new().unwrap();
428        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
429        let result = tool.execute(json!({ "tasks": "not an array" })).await;
430        assert!(result.is_err(), "non-array tasks must error");
431        let err = format!("{}", result.unwrap_err());
432        assert!(err.contains("tasks"), "error should mention 'tasks', got: {}", err);
433    }
434
435    #[test]
436    fn test_spawn_agent_schema_lists_all_optional_params() {
437        // All 5 advertised parameters must be declared as schema properties.
438        // If someone adds a new one without updating the schema, consumers
439        // that introspect parameters_schema() will miss it.
440        let tmp = TempDir::new().unwrap();
441        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
442        let schema = tool.parameters_schema();
443        let props = schema["properties"].as_object().unwrap();
444        for p in &["prompt", "model", "timeout", "workspace", "retries"] {
445            assert!(props.contains_key(*p), "schema missing '{}'", p);
446        }
447        // Only prompt is required
448        let required = schema["required"].as_array().unwrap();
449        assert_eq!(required.len(), 1);
450        assert_eq!(required[0], "prompt");
451    }
452
453    #[test]
454    fn test_spawn_agents_schema_tasks_items_has_prompt_required() {
455        // The nested items schema inside tasks must mark prompt required —
456        // otherwise the array can hold task objects missing the prompt
457        // field, and each sub-execute() would error one-by-one instead of
458        // validating up front.
459        let tmp = TempDir::new().unwrap();
460        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
461        let schema = tool.parameters_schema();
462        let items_required = schema["properties"]["tasks"]["items"]["required"]
463            .as_array()
464            .expect("tasks.items.required should exist");
465        assert!(items_required.iter().any(|v| v == "prompt"));
466    }
467
468    #[test]
469    fn test_spawn_agent_thulp_definition_has_all_5_params() {
470        // thulp_definition() must mirror parameters_schema() — if they drift,
471        // thulp-registry callers will see a different API than MCP callers.
472        let tmp = TempDir::new().unwrap();
473        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
474        let def = tool.thulp_definition();
475        assert_eq!(def.name, "spawn_agent");
476        let param_names: Vec<&str> = def.parameters.iter().map(|p| p.name.as_str()).collect();
477        for p in &["prompt", "model", "timeout", "workspace", "retries"] {
478            assert!(param_names.contains(p), "thulp definition missing '{}'", p);
479        }
480        // Only prompt is required
481        let required_count = def.parameters.iter().filter(|p| p.required).count();
482        assert_eq!(required_count, 1, "only prompt should be required");
483    }
484
485    #[test]
486    fn test_spawn_agents_thulp_definition_has_tasks_param() {
487        // spawn_agents' thulp definition should declare exactly one required
488        // parameter: tasks (an array). If this drifts, parallel-agent callers
489        // get confused schemas.
490        let tmp = TempDir::new().unwrap();
491        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
492        let def = tool.thulp_definition();
493        assert_eq!(def.name, "spawn_agents");
494        assert_eq!(def.parameters.len(), 1);
495        let tasks_param = &def.parameters[0];
496        assert_eq!(tasks_param.name, "tasks");
497        assert!(tasks_param.required);
498    }
499
500    #[test]
501    fn test_spawn_agent_mutating_returns_true() {
502        let tmp = TempDir::new().unwrap();
503        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
504        assert!(tool.mutating(), "spawn_agent can mutate state");
505    }
506
507    #[test]
508    fn test_spawn_agents_mutating_returns_true() {
509        let tmp = TempDir::new().unwrap();
510        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
511        assert!(tool.mutating(), "spawn_agents can mutate state");
512    }
513
514    #[test]
515    fn test_spawn_agent_description_non_empty() {
516        let tmp = TempDir::new().unwrap();
517        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
518        let desc = tool.description();
519        assert!(!desc.is_empty());
520        assert!(desc.contains("sub-agent"));
521    }
522
523    #[test]
524    fn test_spawn_agents_description_non_empty() {
525        let tmp = TempDir::new().unwrap();
526        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
527        let desc = tool.description();
528        assert!(!desc.is_empty());
529        assert!(desc.contains("parallel"));
530    }
531
532    #[tokio::test]
533    async fn test_spawn_agent_timeout_defaults_to_120() {
534        let tmp = TempDir::new().unwrap();
535        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
536        let binary = tmp.path().join("target/release/pawan");
537        std::fs::write(&binary, r"#!/bin/sh
538exit 0").unwrap();
539        #[cfg(unix)]
540        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
541
542        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
543        let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
544        assert_eq!(result["success"], true);
545    }
546
547    #[tokio::test]
548    async fn test_spawn_agent_custom_timeout() {
549        let tmp = TempDir::new().unwrap();
550        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
551        let binary = tmp.path().join("target/release/pawan");
552        std::fs::write(&binary, r"#!/bin/sh
553exit 0").unwrap();
554        #[cfg(unix)]
555        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
556
557        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
558        let result = tool.execute(json!({"prompt": "test", "timeout": 60})).await.unwrap();
559        assert_eq!(result["success"], true);
560    }
561
562    #[tokio::test]
563    async fn test_spawn_agent_custom_model() {
564        let tmp = TempDir::new().unwrap();
565        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
566        let binary = tmp.path().join("target/release/pawan");
567        std::fs::write(&binary, "#!/bin/sh\necho '{\"content\":\"test response\"}'").unwrap();
568        #[cfg(unix)]
569        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
570
571        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
572        let result = tool.execute(json!({"prompt": "test", "model": "gpt-4"})).await.unwrap();
573        assert_eq!(result["success"], true);
574        assert_eq!(result["result"]["content"], "test response");
575    }
576
577
578    #[tokio::test]
579    async fn test_spawn_agent_retries_on_failure() {
580        let tmp = TempDir::new().unwrap();
581        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
582        let binary = tmp.path().join("target/release/pawan");
583        let counter_file = tmp.path().join("counter");
584        std::fs::write(&counter_file, "0").unwrap();
585        let script = format!(
586            "#!/bin/sh\ncount=$(cat {})\necho $((count + 1)) > {}\nif [ $count -eq 0 ]; then\n    exit 1\nelse\n    exit 0\nfi",
587            counter_file.display(), counter_file.display()
588        );
589        std::fs::write(&binary, script).unwrap();
590        #[cfg(unix)]
591        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
592
593        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
594        let result = tool.execute(json!({
595            "prompt": "test",
596            "retries": 1
597        })).await.unwrap();
598        assert_eq!(result["success"], true);
599        assert_eq!(result["attempt"], 2);
600        assert_eq!(result["total_attempts"], 2);
601    }
602
603
604
605    #[tokio::test]
606    async fn test_spawn_agent_stderr_captured() {
607        let tmp = TempDir::new().unwrap();
608        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
609        let binary = tmp.path().join("target/release/pawan");
610        std::fs::write(&binary, r"#!/bin/sh
611echo 'error message' >&2
612exit 0").unwrap();
613        #[cfg(unix)]
614        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
615
616        let tool = SpawnAgentTool::new(tmp.path().to_path_buf());
617        let result = tool.execute(json!({"prompt": "test"})).await.unwrap();
618        assert_eq!(result["success"], true);
619        assert_eq!(result["stderr"], "error message");
620    }
621
622    #[serial_test::serial(pawan_session_tests)]
623    #[tokio::test]
624    async fn test_spawn_agents_single_task() {
625        let tmp = TempDir::new().unwrap();
626        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
627        let binary = tmp.path().join("target/release/pawan");
628        std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
629        #[cfg(unix)]
630        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
631
632        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
633        let result = tool.execute(json!({
634            "tasks": [
635                {"prompt": "task1"}
636            ]
637        })).await.unwrap();
638        assert_eq!(result["success"], true);
639        assert_eq!(result["total_tasks"], 1);
640        assert_eq!(result["results"].as_array().unwrap().len(), 1);
641        assert_eq!(result["results"][0]["result"]["result"], "done");
642    }
643
644    #[serial_test::serial(pawan_session_tests)]
645    #[tokio::test]
646    async fn test_spawn_agents_multiple_tasks() {
647        let tmp = TempDir::new().unwrap();
648        std::fs::create_dir_all(tmp.path().join("target/release")).unwrap();
649        let binary = tmp.path().join("target/release/pawan");
650        std::fs::write(&binary, "#!/bin/sh\necho '{\"result\":\"done\"}'").unwrap();
651        #[cfg(unix)]
652        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
653
654        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
655        let result = tool.execute(json!({
656            "tasks": [
657                {"prompt": "task1"},
658                {"prompt": "task2"},
659                {"prompt": "task3"}
660            ]
661        })).await.unwrap();
662        assert_eq!(result["success"], true);
663        assert_eq!(result["total_tasks"], 3);
664        assert_eq!(result["results"].as_array().unwrap().len(), 3);
665    }
666
667    #[tokio::test]
668    async fn test_spawn_agents_task_missing_prompt() {
669        let tmp = TempDir::new().unwrap();
670        let tool = SpawnAgentsTool::new(tmp.path().to_path_buf());
671        let result = tool.execute(json!({
672            "tasks": [{"model": "gpt-4"}]
673        })).await.unwrap();
674        assert_eq!(result["success"], true);
675        assert_eq!(result["total_tasks"], 1);
676        assert_eq!(result["results"][0]["success"], false);
677        assert!(result["results"][0]["error"].as_str().unwrap().contains("prompt"));
678    }
679}