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