Skip to main content

a3s_code_core/tools/
task.rs

1//! Task Tool for Spawning Subagents
2//!
3//! The Task tool allows the main agent to delegate specialized tasks to
4//! focused child agents (subagents). Each subagent runs in an isolated
5//! child session with restricted permissions.
6//!
7//! ## Usage
8//!
9//! ```json
10//! {
11//!   "agent": "explore",
12//!   "description": "Find authentication code",
13//!   "prompt": "Search for files related to user authentication..."
14//! }
15//! ```
16
17use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18use crate::llm::LlmClient;
19use crate::subagent::AgentRegistry;
20use crate::tools::types::{Tool, ToolContext, ToolOutput};
21use anyhow::{Context, Result};
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24use std::path::PathBuf;
25use std::sync::Arc;
26use tokio::sync::broadcast;
27use tokio::task::JoinSet;
28
29/// Task tool parameters
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TaskParams {
32    /// Agent type to use (explore, general, plan, etc.)
33    pub agent: String,
34    /// Short description of the task (for display)
35    pub description: String,
36    /// Detailed prompt for the agent
37    pub prompt: String,
38    /// Optional: run in background (default: false)
39    #[serde(default)]
40    pub background: bool,
41    /// Optional: maximum steps for this task
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub max_steps: Option<usize>,
44}
45
46/// Task tool result
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TaskResult {
49    /// Task output from the subagent
50    pub output: String,
51    /// Child session ID
52    pub session_id: String,
53    /// Agent type used
54    pub agent: String,
55    /// Whether the task succeeded
56    pub success: bool,
57    /// Task ID for tracking
58    pub task_id: String,
59}
60
61/// Task executor for running subagent tasks
62pub struct TaskExecutor {
63    /// Agent registry for looking up agent definitions
64    registry: Arc<AgentRegistry>,
65    /// LLM client used to power child agent loops
66    llm_client: Arc<dyn LlmClient>,
67    /// Workspace path shared with child agents
68    workspace: String,
69}
70
71impl TaskExecutor {
72    /// Create a new task executor
73    pub fn new(
74        registry: Arc<AgentRegistry>,
75        llm_client: Arc<dyn LlmClient>,
76        workspace: String,
77    ) -> Self {
78        Self {
79            registry,
80            llm_client,
81            workspace,
82        }
83    }
84
85    /// Execute a task by spawning an isolated child AgentLoop.
86    pub async fn execute(
87        &self,
88        params: TaskParams,
89        event_tx: Option<broadcast::Sender<AgentEvent>>,
90    ) -> Result<TaskResult> {
91        let task_id = format!("task-{}", uuid::Uuid::new_v4());
92        let session_id = format!("subagent-{}", task_id);
93
94        let agent = self
95            .registry
96            .get(&params.agent)
97            .context(format!("Unknown agent type: '{}'", params.agent))?;
98
99        if let Some(ref tx) = event_tx {
100            let _ = tx.send(AgentEvent::SubagentStart {
101                task_id: task_id.clone(),
102                session_id: session_id.clone(),
103                parent_session_id: String::new(),
104                agent: params.agent.clone(),
105                description: params.description.clone(),
106            });
107        }
108
109        // Build a child ToolExecutor. Task tools are intentionally omitted
110        // here to prevent unlimited subagent nesting.
111        let mut child_executor = crate::tools::ToolExecutor::new(self.workspace.clone());
112        if !agent.permissions.allow.is_empty() || !agent.permissions.deny.is_empty() {
113            child_executor.set_guard_policy(Arc::new(agent.permissions.clone())
114                as Arc<dyn crate::permissions::PermissionChecker>);
115        }
116        let child_executor = Arc::new(child_executor);
117
118        // Inject the agent system prompt via the extra slot.
119        let mut prompt_slots = crate::prompts::SystemPromptSlots::default();
120        if let Some(ref p) = agent.prompt {
121            prompt_slots.extra = Some(p.clone());
122        }
123
124        let child_config = AgentConfig {
125            prompt_slots,
126            tools: child_executor.definitions(),
127            max_tool_rounds: params
128                .max_steps
129                .unwrap_or_else(|| agent.max_steps.unwrap_or(20)),
130            ..AgentConfig::default()
131        };
132
133        let tool_context =
134            ToolContext::new(PathBuf::from(&self.workspace)).with_session_id(session_id.clone());
135
136        let agent_loop = AgentLoop::new(
137            Arc::clone(&self.llm_client),
138            child_executor,
139            tool_context,
140            child_config,
141        );
142
143        let (output, success) = match agent_loop.execute(&[], &params.prompt, None).await {
144            Ok(result) => (result.text, true),
145            Err(e) => (format!("Task failed: {}", e), false),
146        };
147
148        if let Some(ref tx) = event_tx {
149            let _ = tx.send(AgentEvent::SubagentEnd {
150                task_id: task_id.clone(),
151                session_id: session_id.clone(),
152                agent: params.agent.clone(),
153                output: output.clone(),
154                success,
155            });
156        }
157
158        Ok(TaskResult {
159            output,
160            session_id,
161            agent: params.agent,
162            success,
163            task_id,
164        })
165    }
166
167    /// Execute a task in the background.
168    ///
169    /// Returns immediately with the task ID. Use events to track progress.
170    pub fn execute_background(
171        self: Arc<Self>,
172        params: TaskParams,
173        event_tx: Option<broadcast::Sender<AgentEvent>>,
174    ) -> String {
175        let task_id = format!("task-{}", uuid::Uuid::new_v4());
176        let task_id_clone = task_id.clone();
177
178        tokio::spawn(async move {
179            if let Err(e) = self.execute(params, event_tx).await {
180                tracing::error!("Background task {} failed: {}", task_id_clone, e);
181            }
182        });
183
184        task_id
185    }
186
187    /// Execute multiple tasks in parallel.
188    ///
189    /// Spawns all tasks concurrently and waits for all to complete.
190    /// Returns results in the same order as the input tasks.
191    pub async fn execute_parallel(
192        self: &Arc<Self>,
193        tasks: Vec<TaskParams>,
194        event_tx: Option<broadcast::Sender<AgentEvent>>,
195    ) -> Vec<TaskResult> {
196        let mut join_set: JoinSet<(usize, TaskResult)> = JoinSet::new();
197
198        for (idx, params) in tasks.into_iter().enumerate() {
199            let executor = Arc::clone(self);
200            let tx = event_tx.clone();
201
202            join_set.spawn(async move {
203                let result = match executor.execute(params.clone(), tx).await {
204                    Ok(result) => result,
205                    Err(e) => TaskResult {
206                        output: format!("Task failed: {}", e),
207                        session_id: String::new(),
208                        agent: params.agent,
209                        success: false,
210                        task_id: format!("task-{}", uuid::Uuid::new_v4()),
211                    },
212                };
213                (idx, result)
214            });
215        }
216
217        let mut indexed_results = Vec::new();
218        while let Some(result) = join_set.join_next().await {
219            match result {
220                Ok((idx, task_result)) => indexed_results.push((idx, task_result)),
221                Err(e) => {
222                    tracing::error!("Parallel task panicked: {}", e);
223                    indexed_results.push((
224                        usize::MAX,
225                        TaskResult {
226                            output: format!("Task panicked: {}", e),
227                            session_id: String::new(),
228                            agent: "unknown".to_string(),
229                            success: false,
230                            task_id: format!("task-{}", uuid::Uuid::new_v4()),
231                        },
232                    ));
233                }
234            }
235        }
236
237        indexed_results.sort_by_key(|(idx, _)| *idx);
238        indexed_results.into_iter().map(|(_, r)| r).collect()
239    }
240}
241
242/// Get the JSON schema for TaskParams
243pub fn task_params_schema() -> serde_json::Value {
244    serde_json::json!({
245        "type": "object",
246        "properties": {
247            "agent": {
248                "type": "string",
249                "description": "Agent type to use (explore, general, plan, etc.)"
250            },
251            "description": {
252                "type": "string",
253                "description": "Short description of the task (for display)"
254            },
255            "prompt": {
256                "type": "string",
257                "description": "Detailed prompt for the agent"
258            },
259            "background": {
260                "type": "boolean",
261                "description": "Run in background (default: false)",
262                "default": false
263            },
264            "max_steps": {
265                "type": "integer",
266                "description": "Maximum steps for this task"
267            }
268        },
269        "required": ["agent", "description", "prompt"]
270    })
271}
272
273/// TaskTool wraps TaskExecutor as a Tool for registration in ToolExecutor.
274/// This allows the LLM to delegate tasks to subagents via the standard tool interface.
275pub struct TaskTool {
276    executor: Arc<TaskExecutor>,
277}
278
279impl TaskTool {
280    /// Create a new TaskTool
281    pub fn new(executor: Arc<TaskExecutor>) -> Self {
282        Self { executor }
283    }
284}
285
286#[async_trait]
287impl Tool for TaskTool {
288    fn name(&self) -> &str {
289        "task"
290    }
291
292    fn description(&self) -> &str {
293        "Delegate a task to a specialized subagent. Built-in agents: explore (read-only codebase search), general (full access multi-step), plan (read-only planning). Custom agents from agent_dirs are also available."
294    }
295
296    fn parameters(&self) -> serde_json::Value {
297        task_params_schema()
298    }
299
300    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
301        let params: TaskParams =
302            serde_json::from_value(args.clone()).context("Invalid task parameters")?;
303
304        if params.background {
305            let task_id =
306                Arc::clone(&self.executor).execute_background(params, ctx.agent_event_tx.clone());
307            return Ok(ToolOutput::success(format!(
308                "Task started in background. Task ID: {}",
309                task_id
310            )));
311        }
312
313        let result = self
314            .executor
315            .execute(params, ctx.agent_event_tx.clone())
316            .await?;
317
318        if result.success {
319            Ok(ToolOutput::success(result.output))
320        } else {
321            Ok(ToolOutput::error(result.output))
322        }
323    }
324}
325
326/// Parameters for parallel task execution
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct ParallelTaskParams {
329    /// List of tasks to execute concurrently
330    pub tasks: Vec<TaskParams>,
331}
332
333/// Get the JSON schema for ParallelTaskParams
334pub fn parallel_task_params_schema() -> serde_json::Value {
335    serde_json::json!({
336        "type": "object",
337        "properties": {
338            "tasks": {
339                "type": "array",
340                "description": "List of tasks to execute in parallel. Each task runs as an independent subagent concurrently.",
341                "items": {
342                    "type": "object",
343                    "properties": {
344                        "agent": {
345                            "type": "string",
346                            "description": "Agent type to use (explore, general, plan, etc.)"
347                        },
348                        "description": {
349                            "type": "string",
350                            "description": "Short description of the task (for display)"
351                        },
352                        "prompt": {
353                            "type": "string",
354                            "description": "Detailed prompt for the agent"
355                        }
356                    },
357                    "required": ["agent", "description", "prompt"]
358                },
359                "minItems": 1
360            }
361        },
362        "required": ["tasks"]
363    })
364}
365
366/// ParallelTaskTool allows the LLM to fan-out multiple subagent tasks concurrently.
367///
368/// All tasks execute in parallel and the tool returns when all complete.
369pub struct ParallelTaskTool {
370    executor: Arc<TaskExecutor>,
371}
372
373impl ParallelTaskTool {
374    /// Create a new ParallelTaskTool
375    pub fn new(executor: Arc<TaskExecutor>) -> Self {
376        Self { executor }
377    }
378}
379
380#[async_trait]
381impl Tool for ParallelTaskTool {
382    fn name(&self) -> &str {
383        "parallel_task"
384    }
385
386    fn description(&self) -> &str {
387        "Execute multiple subagent tasks in parallel. All tasks run concurrently and results are returned when all complete. Built-in agents: explore (read-only codebase search), general (full access multi-step), plan (read-only planning). Custom agents from agent_dirs are also available."
388    }
389
390    fn parameters(&self) -> serde_json::Value {
391        parallel_task_params_schema()
392    }
393
394    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
395        let params: ParallelTaskParams =
396            serde_json::from_value(args.clone()).context("Invalid parallel task parameters")?;
397
398        if params.tasks.is_empty() {
399            return Ok(ToolOutput::error("No tasks provided".to_string()));
400        }
401
402        let task_count = params.tasks.len();
403
404        let results = self
405            .executor
406            .execute_parallel(params.tasks, ctx.agent_event_tx.clone())
407            .await;
408
409        // Format results
410        let mut output = format!("Executed {} tasks in parallel:\n\n", task_count);
411        for (i, result) in results.iter().enumerate() {
412            let status = if result.success { "[OK]" } else { "[ERR]" };
413            output.push_str(&format!(
414                "--- Task {} ({}) {} ---\n{}\n\n",
415                i + 1,
416                result.agent,
417                status,
418                result.output
419            ));
420        }
421
422        Ok(ToolOutput::success(output))
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_task_params_deserialize() {
432        let json = r#"{
433            "agent": "explore",
434            "description": "Find auth code",
435            "prompt": "Search for authentication files"
436        }"#;
437
438        let params: TaskParams = serde_json::from_str(json).unwrap();
439        assert_eq!(params.agent, "explore");
440        assert_eq!(params.description, "Find auth code");
441        assert!(!params.background);
442    }
443
444    #[test]
445    fn test_task_params_with_background() {
446        let json = r#"{
447            "agent": "general",
448            "description": "Long task",
449            "prompt": "Do something complex",
450            "background": true
451        }"#;
452
453        let params: TaskParams = serde_json::from_str(json).unwrap();
454        assert!(params.background);
455    }
456
457    #[test]
458    fn test_task_params_with_max_steps() {
459        let json = r#"{
460            "agent": "plan",
461            "description": "Planning task",
462            "prompt": "Create a plan",
463            "max_steps": 10
464        }"#;
465
466        let params: TaskParams = serde_json::from_str(json).unwrap();
467        assert_eq!(params.agent, "plan");
468        assert_eq!(params.max_steps, Some(10));
469        assert!(!params.background);
470    }
471
472    #[test]
473    fn test_task_params_all_fields() {
474        let json = r#"{
475            "agent": "general",
476            "description": "Complex task",
477            "prompt": "Do everything",
478            "background": true,
479            "max_steps": 20
480        }"#;
481
482        let params: TaskParams = serde_json::from_str(json).unwrap();
483        assert_eq!(params.agent, "general");
484        assert_eq!(params.description, "Complex task");
485        assert_eq!(params.prompt, "Do everything");
486        assert!(params.background);
487        assert_eq!(params.max_steps, Some(20));
488    }
489
490    #[test]
491    fn test_task_params_missing_required_field() {
492        let json = r#"{
493            "agent": "explore",
494            "description": "Missing prompt"
495        }"#;
496
497        let result: Result<TaskParams, _> = serde_json::from_str(json);
498        assert!(result.is_err());
499    }
500
501    #[test]
502    fn test_task_params_serialize() {
503        let params = TaskParams {
504            agent: "explore".to_string(),
505            description: "Test task".to_string(),
506            prompt: "Test prompt".to_string(),
507            background: false,
508            max_steps: Some(5),
509        };
510
511        let json = serde_json::to_string(&params).unwrap();
512        assert!(json.contains("explore"));
513        assert!(json.contains("Test task"));
514        assert!(json.contains("Test prompt"));
515    }
516
517    #[test]
518    fn test_task_params_clone() {
519        let params = TaskParams {
520            agent: "explore".to_string(),
521            description: "Test".to_string(),
522            prompt: "Prompt".to_string(),
523            background: true,
524            max_steps: None,
525        };
526
527        let cloned = params.clone();
528        assert_eq!(params.agent, cloned.agent);
529        assert_eq!(params.description, cloned.description);
530        assert_eq!(params.background, cloned.background);
531    }
532
533    #[test]
534    fn test_task_result_serialize() {
535        let result = TaskResult {
536            output: "Found 5 files".to_string(),
537            session_id: "session-123".to_string(),
538            agent: "explore".to_string(),
539            success: true,
540            task_id: "task-456".to_string(),
541        };
542
543        let json = serde_json::to_string(&result).unwrap();
544        assert!(json.contains("Found 5 files"));
545        assert!(json.contains("explore"));
546    }
547
548    #[test]
549    fn test_task_result_deserialize() {
550        let json = r#"{
551            "output": "Task completed",
552            "session_id": "sess-789",
553            "agent": "general",
554            "success": false,
555            "task_id": "task-123"
556        }"#;
557
558        let result: TaskResult = serde_json::from_str(json).unwrap();
559        assert_eq!(result.output, "Task completed");
560        assert_eq!(result.session_id, "sess-789");
561        assert_eq!(result.agent, "general");
562        assert!(!result.success);
563        assert_eq!(result.task_id, "task-123");
564    }
565
566    #[test]
567    fn test_task_result_clone() {
568        let result = TaskResult {
569            output: "Output".to_string(),
570            session_id: "session-1".to_string(),
571            agent: "explore".to_string(),
572            success: true,
573            task_id: "task-1".to_string(),
574        };
575
576        let cloned = result.clone();
577        assert_eq!(result.output, cloned.output);
578        assert_eq!(result.success, cloned.success);
579    }
580
581    #[test]
582    fn test_task_params_schema() {
583        let schema = task_params_schema();
584        assert_eq!(schema["type"], "object");
585        assert!(schema["properties"]["agent"].is_object());
586        assert!(schema["properties"]["prompt"].is_object());
587    }
588
589    #[test]
590    fn test_task_params_schema_required_fields() {
591        let schema = task_params_schema();
592        let required = schema["required"].as_array().unwrap();
593        assert!(required.contains(&serde_json::json!("agent")));
594        assert!(required.contains(&serde_json::json!("description")));
595        assert!(required.contains(&serde_json::json!("prompt")));
596    }
597
598    #[test]
599    fn test_task_params_schema_properties() {
600        let schema = task_params_schema();
601        let props = &schema["properties"];
602
603        assert_eq!(props["agent"]["type"], "string");
604        assert_eq!(props["description"]["type"], "string");
605        assert_eq!(props["prompt"]["type"], "string");
606        assert_eq!(props["background"]["type"], "boolean");
607        assert_eq!(props["background"]["default"], false);
608        assert_eq!(props["max_steps"]["type"], "integer");
609    }
610
611    #[test]
612    fn test_task_params_schema_descriptions() {
613        let schema = task_params_schema();
614        let props = &schema["properties"];
615
616        assert!(props["agent"]["description"].is_string());
617        assert!(props["description"]["description"].is_string());
618        assert!(props["prompt"]["description"].is_string());
619        assert!(props["background"]["description"].is_string());
620        assert!(props["max_steps"]["description"].is_string());
621    }
622
623    #[test]
624    fn test_task_params_default_background() {
625        let params = TaskParams {
626            agent: "explore".to_string(),
627            description: "Test".to_string(),
628            prompt: "Test prompt".to_string(),
629            background: false,
630            max_steps: None,
631        };
632        assert!(!params.background);
633    }
634
635    #[test]
636    fn test_task_params_serialize_skip_none() {
637        let params = TaskParams {
638            agent: "explore".to_string(),
639            description: "Test".to_string(),
640            prompt: "Test prompt".to_string(),
641            background: false,
642            max_steps: None,
643        };
644        let json = serde_json::to_string(&params).unwrap();
645        // max_steps should not appear when None
646        assert!(!json.contains("max_steps"));
647    }
648
649    #[test]
650    fn test_task_params_serialize_with_max_steps() {
651        let params = TaskParams {
652            agent: "explore".to_string(),
653            description: "Test".to_string(),
654            prompt: "Test prompt".to_string(),
655            background: false,
656            max_steps: Some(15),
657        };
658        let json = serde_json::to_string(&params).unwrap();
659        assert!(json.contains("max_steps"));
660        assert!(json.contains("15"));
661    }
662
663    #[test]
664    fn test_task_result_success_true() {
665        let result = TaskResult {
666            output: "Success".to_string(),
667            session_id: "sess-1".to_string(),
668            agent: "explore".to_string(),
669            success: true,
670            task_id: "task-1".to_string(),
671        };
672        assert!(result.success);
673    }
674
675    #[test]
676    fn test_task_result_success_false() {
677        let result = TaskResult {
678            output: "Failed".to_string(),
679            session_id: "sess-1".to_string(),
680            agent: "explore".to_string(),
681            success: false,
682            task_id: "task-1".to_string(),
683        };
684        assert!(!result.success);
685    }
686
687    #[test]
688    fn test_task_params_empty_strings() {
689        let params = TaskParams {
690            agent: "".to_string(),
691            description: "".to_string(),
692            prompt: "".to_string(),
693            background: false,
694            max_steps: None,
695        };
696        let json = serde_json::to_string(&params).unwrap();
697        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
698        assert_eq!(deserialized.agent, "");
699        assert_eq!(deserialized.description, "");
700        assert_eq!(deserialized.prompt, "");
701    }
702
703    #[test]
704    fn test_task_result_empty_output() {
705        let result = TaskResult {
706            output: "".to_string(),
707            session_id: "sess-1".to_string(),
708            agent: "explore".to_string(),
709            success: true,
710            task_id: "task-1".to_string(),
711        };
712        assert_eq!(result.output, "");
713    }
714
715    #[test]
716    fn test_task_params_debug_format() {
717        let params = TaskParams {
718            agent: "explore".to_string(),
719            description: "Test".to_string(),
720            prompt: "Test prompt".to_string(),
721            background: false,
722            max_steps: None,
723        };
724        let debug_str = format!("{:?}", params);
725        assert!(debug_str.contains("explore"));
726        assert!(debug_str.contains("Test"));
727    }
728
729    #[test]
730    fn test_task_result_debug_format() {
731        let result = TaskResult {
732            output: "Output".to_string(),
733            session_id: "sess-1".to_string(),
734            agent: "explore".to_string(),
735            success: true,
736            task_id: "task-1".to_string(),
737        };
738        let debug_str = format!("{:?}", result);
739        assert!(debug_str.contains("Output"));
740        assert!(debug_str.contains("explore"));
741    }
742
743    #[test]
744    fn test_task_params_roundtrip() {
745        let original = TaskParams {
746            agent: "general".to_string(),
747            description: "Roundtrip test".to_string(),
748            prompt: "Test roundtrip serialization".to_string(),
749            background: true,
750            max_steps: Some(42),
751        };
752        let json = serde_json::to_string(&original).unwrap();
753        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
754        assert_eq!(original.agent, deserialized.agent);
755        assert_eq!(original.description, deserialized.description);
756        assert_eq!(original.prompt, deserialized.prompt);
757        assert_eq!(original.background, deserialized.background);
758        assert_eq!(original.max_steps, deserialized.max_steps);
759    }
760
761    #[test]
762    fn test_task_result_roundtrip() {
763        let original = TaskResult {
764            output: "Roundtrip output".to_string(),
765            session_id: "sess-roundtrip".to_string(),
766            agent: "plan".to_string(),
767            success: false,
768            task_id: "task-roundtrip".to_string(),
769        };
770        let json = serde_json::to_string(&original).unwrap();
771        let deserialized: TaskResult = serde_json::from_str(&json).unwrap();
772        assert_eq!(original.output, deserialized.output);
773        assert_eq!(original.session_id, deserialized.session_id);
774        assert_eq!(original.agent, deserialized.agent);
775        assert_eq!(original.success, deserialized.success);
776        assert_eq!(original.task_id, deserialized.task_id);
777    }
778
779    #[test]
780    fn test_parallel_task_params_deserialize() {
781        let json = r#"{
782            "tasks": [
783                { "agent": "explore", "description": "Find auth", "prompt": "Search auth files" },
784                { "agent": "general", "description": "Fix bug", "prompt": "Fix the login bug" }
785            ]
786        }"#;
787
788        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
789        assert_eq!(params.tasks.len(), 2);
790        assert_eq!(params.tasks[0].agent, "explore");
791        assert_eq!(params.tasks[1].agent, "general");
792    }
793
794    #[test]
795    fn test_parallel_task_params_single_task() {
796        let json = r#"{
797            "tasks": [
798                { "agent": "plan", "description": "Plan work", "prompt": "Create a plan" }
799            ]
800        }"#;
801
802        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
803        assert_eq!(params.tasks.len(), 1);
804    }
805
806    #[test]
807    fn test_parallel_task_params_empty_tasks() {
808        let json = r#"{ "tasks": [] }"#;
809        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
810        assert!(params.tasks.is_empty());
811    }
812
813    #[test]
814    fn test_parallel_task_params_missing_tasks() {
815        let json = r#"{}"#;
816        let result: Result<ParallelTaskParams, _> = serde_json::from_str(json);
817        assert!(result.is_err());
818    }
819
820    #[test]
821    fn test_parallel_task_params_serialize() {
822        let params = ParallelTaskParams {
823            tasks: vec![
824                TaskParams {
825                    agent: "explore".to_string(),
826                    description: "Task 1".to_string(),
827                    prompt: "Prompt 1".to_string(),
828                    background: false,
829                    max_steps: None,
830                },
831                TaskParams {
832                    agent: "general".to_string(),
833                    description: "Task 2".to_string(),
834                    prompt: "Prompt 2".to_string(),
835                    background: false,
836                    max_steps: Some(10),
837                },
838            ],
839        };
840        let json = serde_json::to_string(&params).unwrap();
841        assert!(json.contains("explore"));
842        assert!(json.contains("general"));
843        assert!(json.contains("Prompt 1"));
844        assert!(json.contains("Prompt 2"));
845    }
846
847    #[test]
848    fn test_parallel_task_params_roundtrip() {
849        let original = ParallelTaskParams {
850            tasks: vec![
851                TaskParams {
852                    agent: "explore".to_string(),
853                    description: "Explore".to_string(),
854                    prompt: "Find files".to_string(),
855                    background: false,
856                    max_steps: None,
857                },
858                TaskParams {
859                    agent: "plan".to_string(),
860                    description: "Plan".to_string(),
861                    prompt: "Make plan".to_string(),
862                    background: false,
863                    max_steps: Some(5),
864                },
865            ],
866        };
867        let json = serde_json::to_string(&original).unwrap();
868        let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
869        assert_eq!(original.tasks.len(), deserialized.tasks.len());
870        assert_eq!(original.tasks[0].agent, deserialized.tasks[0].agent);
871        assert_eq!(original.tasks[1].agent, deserialized.tasks[1].agent);
872        assert_eq!(original.tasks[1].max_steps, deserialized.tasks[1].max_steps);
873    }
874
875    #[test]
876    fn test_parallel_task_params_clone() {
877        let params = ParallelTaskParams {
878            tasks: vec![TaskParams {
879                agent: "explore".to_string(),
880                description: "Test".to_string(),
881                prompt: "Prompt".to_string(),
882                background: false,
883                max_steps: None,
884            }],
885        };
886        let cloned = params.clone();
887        assert_eq!(params.tasks.len(), cloned.tasks.len());
888        assert_eq!(params.tasks[0].agent, cloned.tasks[0].agent);
889    }
890
891    #[test]
892    fn test_parallel_task_params_schema() {
893        let schema = parallel_task_params_schema();
894        assert_eq!(schema["type"], "object");
895        assert!(schema["properties"]["tasks"].is_object());
896        assert_eq!(schema["properties"]["tasks"]["type"], "array");
897        assert_eq!(schema["properties"]["tasks"]["minItems"], 1);
898    }
899
900    #[test]
901    fn test_parallel_task_params_schema_required() {
902        let schema = parallel_task_params_schema();
903        let required = schema["required"].as_array().unwrap();
904        assert!(required.contains(&serde_json::json!("tasks")));
905    }
906
907    #[test]
908    fn test_parallel_task_params_schema_items() {
909        let schema = parallel_task_params_schema();
910        let items = &schema["properties"]["tasks"]["items"];
911        assert_eq!(items["type"], "object");
912        let item_required = items["required"].as_array().unwrap();
913        assert!(item_required.contains(&serde_json::json!("agent")));
914        assert!(item_required.contains(&serde_json::json!("description")));
915        assert!(item_required.contains(&serde_json::json!("prompt")));
916    }
917
918    #[test]
919    fn test_parallel_task_params_debug() {
920        let params = ParallelTaskParams {
921            tasks: vec![TaskParams {
922                agent: "explore".to_string(),
923                description: "Debug test".to_string(),
924                prompt: "Test".to_string(),
925                background: false,
926                max_steps: None,
927            }],
928        };
929        let debug_str = format!("{:?}", params);
930        assert!(debug_str.contains("explore"));
931        assert!(debug_str.contains("Debug test"));
932    }
933
934    #[test]
935    fn test_parallel_task_params_large_count() {
936        // Validate that ParallelTaskParams can hold 150 tasks without truncation
937        let tasks: Vec<TaskParams> = (0..150)
938            .map(|i| TaskParams {
939                agent: "explore".to_string(),
940                description: format!("Task {}", i),
941                prompt: format!("Prompt for task {}", i),
942                background: false,
943                max_steps: Some(10),
944            })
945            .collect();
946
947        let params = ParallelTaskParams { tasks };
948        let json = serde_json::to_string(&params).unwrap();
949        let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
950        assert_eq!(deserialized.tasks.len(), 150);
951        assert_eq!(deserialized.tasks[0].description, "Task 0");
952        assert_eq!(deserialized.tasks[149].description, "Task 149");
953    }
954
955    #[test]
956    fn test_task_params_max_steps_zero() {
957        // max_steps = 0 is a valid edge case (callers decide enforcement)
958        let params = TaskParams {
959            agent: "explore".to_string(),
960            description: "Edge case".to_string(),
961            prompt: "Zero steps".to_string(),
962            background: false,
963            max_steps: Some(0),
964        };
965        let json = serde_json::to_string(&params).unwrap();
966        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
967        assert_eq!(deserialized.max_steps, Some(0));
968    }
969
970    #[test]
971    fn test_parallel_task_params_all_background() {
972        let tasks: Vec<TaskParams> = (0..5)
973            .map(|i| TaskParams {
974                agent: "general".to_string(),
975                description: format!("BG task {}", i),
976                prompt: "Run in background".to_string(),
977                background: true,
978                max_steps: None,
979            })
980            .collect();
981        let params = ParallelTaskParams { tasks };
982        for task in &params.tasks {
983            assert!(task.background);
984        }
985    }
986}