Skip to main content

a3s_code_core/tools/
task.rs

1//! Task tools for delegated child runs.
2//!
3//! The Task tool allows the main agent to delegate specialized work to focused
4//! child runs. Each child run gets bounded context and the permissions declared
5//! by its agent definition.
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::mcp::manager::McpManager;
20use crate::subagent::AgentRegistry;
21use crate::tools::types::{Tool, ToolContext, ToolOutput};
22use anyhow::{Context, Result};
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25use std::path::PathBuf;
26use std::sync::Arc;
27use tokio::sync::broadcast;
28use tokio::task::JoinSet;
29
30const TASK_OUTPUT_CONTEXT_LIMIT: usize = 4_000;
31const TASK_OUTPUT_CONTEXT_HEAD: usize = 3_000;
32const TASK_OUTPUT_CONTEXT_TAIL: usize = 800;
33
34/// Task tool parameters
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct TaskParams {
38    /// Agent type to use (explore, general, plan, verification, review, etc.)
39    pub agent: String,
40    /// Short description of the task (for display)
41    pub description: String,
42    /// Detailed prompt for the agent
43    pub prompt: String,
44    /// Optional: run in background (default: false)
45    #[serde(default)]
46    pub background: bool,
47    /// Optional: maximum steps for this task
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub max_steps: Option<usize>,
50}
51
52/// Task tool result
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TaskResult {
55    /// Task output from the delegated child run.
56    pub output: String,
57    /// Child session ID
58    pub session_id: String,
59    /// Agent type used
60    pub agent: String,
61    /// Whether the task succeeded
62    pub success: bool,
63    /// Task ID for tracking
64    pub task_id: String,
65}
66
67fn compact_task_output(output: &str) -> (String, bool) {
68    if output.len() <= TASK_OUTPUT_CONTEXT_LIMIT {
69        return (output.to_string(), false);
70    }
71
72    let head = crate::text::truncate_utf8(output, TASK_OUTPUT_CONTEXT_HEAD);
73    let tail_start = output
74        .char_indices()
75        .find_map(|(idx, _)| {
76            if output.len().saturating_sub(idx) <= TASK_OUTPUT_CONTEXT_TAIL {
77                Some(idx)
78            } else {
79                None
80            }
81        })
82        .unwrap_or(output.len());
83    let tail = &output[tail_start..];
84
85    (
86        format!(
87            "{}\n\n[{} bytes omitted from delegated task output]\n\n{}",
88            head,
89            output.len().saturating_sub(head.len() + tail.len()),
90            tail
91        ),
92        true,
93    )
94}
95
96fn task_artifact_id(result: &TaskResult) -> String {
97    format!("task-output:{}", result.task_id)
98}
99
100fn task_artifact_uri(result: &TaskResult) -> String {
101    format!(
102        "a3s://tasks/{}/runs/{}/output",
103        result.session_id, result.task_id
104    )
105}
106
107fn format_task_result_for_context(result: &TaskResult) -> (String, bool) {
108    let (output, truncated) = compact_task_output(&result.output);
109    let status = if result.success {
110        "completed"
111    } else {
112        "failed"
113    };
114    let artifact_id = task_artifact_id(result);
115    let artifact_uri = task_artifact_uri(result);
116    let mut formatted = format!(
117        "Task {status}: {}\nAgent: {}\nSession: {}\nTask ID: {}\nArtifact ID: {}\nArtifact URI: {}\n",
118        result.task_id, result.agent, result.session_id, result.task_id, artifact_id, artifact_uri
119    );
120    if truncated {
121        formatted.push_str(
122            "Output excerpt: truncated for parent context. Use the artifact URI or child run session/events if exact omitted content is needed.\n",
123        );
124    } else {
125        formatted.push_str("Output:\n");
126    }
127    formatted.push_str(&output);
128    (formatted, truncated)
129}
130
131/// Task executor for delegated child runs.
132pub struct TaskExecutor {
133    /// Agent registry for looking up agent definitions
134    registry: Arc<AgentRegistry>,
135    /// LLM client used to power child agent loops
136    llm_client: Arc<dyn LlmClient>,
137    /// Workspace path shared with child agents
138    workspace: String,
139    /// Optional MCP manager for registering MCP tools in child sessions
140    mcp_manager: Option<Arc<McpManager>>,
141    /// Parent capabilities to inherit into child runs.
142    parent_context: Option<crate::child_run::ChildRunContext>,
143}
144
145impl TaskExecutor {
146    /// Create a new task executor
147    pub fn new(
148        registry: Arc<AgentRegistry>,
149        llm_client: Arc<dyn LlmClient>,
150        workspace: String,
151    ) -> Self {
152        Self {
153            registry,
154            llm_client,
155            workspace,
156            mcp_manager: None,
157            parent_context: None,
158        }
159    }
160
161    /// Create a new task executor with MCP manager for tool inheritance
162    pub fn with_mcp(
163        registry: Arc<AgentRegistry>,
164        llm_client: Arc<dyn LlmClient>,
165        workspace: String,
166        mcp_manager: Arc<McpManager>,
167    ) -> Self {
168        Self {
169            registry,
170            llm_client,
171            workspace,
172            mcp_manager: Some(mcp_manager),
173            parent_context: None,
174        }
175    }
176
177    /// Set parent session capabilities to inherit into child runs.
178    pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
179        self.parent_context = Some(ctx);
180        self
181    }
182
183    /// Execute a task by spawning an isolated child AgentLoop.
184    pub async fn execute(
185        &self,
186        params: TaskParams,
187        event_tx: Option<broadcast::Sender<AgentEvent>>,
188    ) -> Result<TaskResult> {
189        let task_id = format!("task-{}", uuid::Uuid::new_v4());
190        let session_id = format!("task-run-{}", task_id);
191
192        let agent = self
193            .registry
194            .get(&params.agent)
195            .context(format!("Unknown agent type: '{}'", params.agent))?;
196
197        if let Some(ref tx) = event_tx {
198            let _ = tx.send(AgentEvent::SubagentStart {
199                task_id: task_id.clone(),
200                session_id: session_id.clone(),
201                parent_session_id: String::new(),
202                agent: params.agent.clone(),
203                description: params.description.clone(),
204            });
205        }
206
207        // Build a child ToolExecutor. Task tools are intentionally omitted
208        // here to prevent unlimited delegation nesting.
209        let child_executor = crate::tools::ToolExecutor::new(self.workspace.clone());
210
211        // Register MCP tools so child agents can access MCP servers.
212        if let Some(ref mcp) = self.mcp_manager {
213            let all_tools = mcp.get_all_tools().await;
214            let mut by_server: std::collections::HashMap<
215                String,
216                Vec<crate::mcp::protocol::McpTool>,
217            > = std::collections::HashMap::new();
218            for (server, tool) in all_tools {
219                by_server.entry(server).or_default().push(tool);
220            }
221            for (server_name, tools) in by_server {
222                let wrappers =
223                    crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
224                for wrapper in wrappers {
225                    child_executor.register_dynamic_tool(wrapper);
226                }
227            }
228        }
229
230        let child_executor = Arc::new(child_executor);
231
232        let mut child_config = AgentConfig {
233            tools: child_executor.definitions(),
234            ..AgentConfig::default()
235        };
236        agent.apply_to(&mut child_config);
237        if let Some(ref parent_ctx) = self.parent_context {
238            parent_ctx.apply_to(&mut child_config);
239        }
240        if let Some(max_steps) = params.max_steps {
241            child_config.max_tool_rounds = max_steps;
242        }
243
244        let tool_context =
245            ToolContext::new(PathBuf::from(&self.workspace)).with_session_id(session_id.clone());
246
247        let agent_loop = AgentLoop::new(
248            Arc::clone(&self.llm_client),
249            child_executor,
250            tool_context,
251            child_config,
252        );
253
254        // Create an mpsc channel for the child agent and forward events to broadcast
255        let child_event_tx = if let Some(ref broadcast_tx) = event_tx {
256            let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
257            let broadcast_tx_clone = broadcast_tx.clone();
258
259            // Spawn a task to forward events from mpsc to broadcast
260            tokio::spawn(async move {
261                while let Some(event) = mpsc_rx.recv().await {
262                    let _ = broadcast_tx_clone.send(event);
263                }
264            });
265
266            Some(mpsc_tx)
267        } else {
268            None
269        };
270
271        let (output, success) = match agent_loop
272            .execute(&[], &params.prompt, child_event_tx)
273            .await
274        {
275            Ok(result) => (result.text, true),
276            Err(e) => (format!("Task failed: {}", e), false),
277        };
278
279        if let Some(ref tx) = event_tx {
280            let _ = tx.send(AgentEvent::SubagentEnd {
281                task_id: task_id.clone(),
282                session_id: session_id.clone(),
283                agent: params.agent.clone(),
284                output: output.clone(),
285                success,
286            });
287        }
288
289        Ok(TaskResult {
290            output,
291            session_id,
292            agent: params.agent,
293            success,
294            task_id,
295        })
296    }
297
298    /// Execute a task in the background.
299    ///
300    /// Returns immediately with the task ID. Use events to track progress.
301    pub fn execute_background(
302        self: Arc<Self>,
303        params: TaskParams,
304        event_tx: Option<broadcast::Sender<AgentEvent>>,
305    ) -> String {
306        let task_id = format!("task-{}", uuid::Uuid::new_v4());
307        let task_id_clone = task_id.clone();
308
309        tokio::spawn(async move {
310            if let Err(e) = self.execute(params, event_tx).await {
311                tracing::error!("Background task {} failed: {}", task_id_clone, e);
312            }
313        });
314
315        task_id
316    }
317
318    /// Execute multiple tasks in parallel.
319    ///
320    /// Spawns all tasks concurrently and waits for all to complete.
321    /// Returns results in the same order as the input tasks.
322    pub async fn execute_parallel(
323        self: &Arc<Self>,
324        tasks: Vec<TaskParams>,
325        event_tx: Option<broadcast::Sender<AgentEvent>>,
326    ) -> Vec<TaskResult> {
327        let mut join_set: JoinSet<(usize, TaskResult)> = JoinSet::new();
328
329        for (idx, params) in tasks.into_iter().enumerate() {
330            let executor = Arc::clone(self);
331            let tx = event_tx.clone();
332
333            join_set.spawn(async move {
334                let result = match executor.execute(params.clone(), tx).await {
335                    Ok(result) => result,
336                    Err(e) => TaskResult {
337                        output: format!("Task failed: {}", e),
338                        session_id: String::new(),
339                        agent: params.agent,
340                        success: false,
341                        task_id: format!("task-{}", uuid::Uuid::new_v4()),
342                    },
343                };
344                (idx, result)
345            });
346        }
347
348        let mut indexed_results = Vec::new();
349        while let Some(result) = join_set.join_next().await {
350            match result {
351                Ok((idx, task_result)) => indexed_results.push((idx, task_result)),
352                Err(e) => {
353                    tracing::error!("Parallel task panicked: {}", e);
354                    indexed_results.push((
355                        usize::MAX,
356                        TaskResult {
357                            output: format!("Task panicked: {}", e),
358                            session_id: String::new(),
359                            agent: "unknown".to_string(),
360                            success: false,
361                            task_id: format!("task-{}", uuid::Uuid::new_v4()),
362                        },
363                    ));
364                }
365            }
366        }
367
368        indexed_results.sort_by_key(|(idx, _)| *idx);
369        indexed_results.into_iter().map(|(_, r)| r).collect()
370    }
371}
372
373/// Get the JSON schema for TaskParams
374pub fn task_params_schema() -> serde_json::Value {
375    serde_json::json!({
376        "type": "object",
377        "additionalProperties": false,
378        "properties": {
379            "agent": {
380                "type": "string",
381                "description": "Required. Canonical agent type to use (for example: explore, general, plan, verification, review). Always provide this exact field name: 'agent'."
382            },
383            "description": {
384                "type": "string",
385                "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
386            },
387            "prompt": {
388                "type": "string",
389                "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
390            },
391            "background": {
392                "type": "boolean",
393                "description": "Optional. Run the task in the background. Default: false.",
394                "default": false
395            },
396            "max_steps": {
397                "type": "integer",
398                "description": "Optional. Maximum number of steps for this task."
399            }
400        },
401        "required": ["agent", "description", "prompt"],
402        "examples": [
403            {
404                "agent": "explore",
405                "description": "Find Rust files",
406                "prompt": "Search the workspace for Rust files and summarize the layout."
407            },
408            {
409                "agent": "general",
410                "description": "Investigate test failure",
411                "prompt": "Inspect the failing tests and explain the root cause.",
412                "max_steps": 6
413            }
414        ]
415    })
416}
417
418/// TaskTool wraps TaskExecutor as a Tool for registration in ToolExecutor.
419/// This allows the LLM to delegate tasks through the standard tool interface.
420pub struct TaskTool {
421    executor: Arc<TaskExecutor>,
422}
423
424impl TaskTool {
425    /// Create a new TaskTool
426    pub fn new(executor: Arc<TaskExecutor>) -> Self {
427        Self { executor }
428    }
429}
430
431#[async_trait]
432impl Tool for TaskTool {
433    fn name(&self) -> &str {
434        "task"
435    }
436
437    fn description(&self) -> &str {
438        "Delegate a bounded task to a specialized child run. Built-in agents: explore (read-only codebase search), general (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs are also available."
439    }
440
441    fn parameters(&self) -> serde_json::Value {
442        task_params_schema()
443    }
444
445    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
446        let params: TaskParams =
447            serde_json::from_value(args.clone()).context("Invalid task parameters")?;
448
449        if params.background {
450            let task_id =
451                Arc::clone(&self.executor).execute_background(params, ctx.agent_event_tx.clone());
452            return Ok(ToolOutput::success(format!(
453                "Task started in background. Task ID: {}",
454                task_id
455            )));
456        }
457
458        let result = self
459            .executor
460            .execute(params, ctx.agent_event_tx.clone())
461            .await?;
462        let (content, truncated) = format_task_result_for_context(&result);
463        let metadata = serde_json::json!({
464            "task_id": result.task_id,
465            "session_id": result.session_id,
466            "agent": result.agent,
467            "success": result.success,
468            "output_bytes": result.output.len(),
469            "truncated_for_context": truncated,
470            "artifact_id": task_artifact_id(&result),
471            "artifact_uri": task_artifact_uri(&result),
472        });
473
474        if result.success {
475            Ok(ToolOutput::success(content).with_metadata(metadata))
476        } else {
477            Ok(ToolOutput::error(content).with_metadata(metadata))
478        }
479    }
480}
481
482/// Parameters for parallel task execution
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(deny_unknown_fields)]
485pub struct ParallelTaskParams {
486    /// List of tasks to execute concurrently
487    pub tasks: Vec<TaskParams>,
488}
489
490/// Get the JSON schema for ParallelTaskParams
491pub fn parallel_task_params_schema() -> serde_json::Value {
492    serde_json::json!({
493        "type": "object",
494        "additionalProperties": false,
495        "properties": {
496            "tasks": {
497                "type": "array",
498                "description": "List of tasks to execute in parallel. Each task runs as an independent delegated child run concurrently.",
499                "items": {
500                    "type": "object",
501                    "additionalProperties": false,
502                    "properties": {
503                        "agent": {
504                            "type": "string",
505                            "description": "Required. Canonical agent type for this task."
506                        },
507                        "description": {
508                            "type": "string",
509                            "description": "Required. Short task label for display and tracking."
510                        },
511                        "prompt": {
512                            "type": "string",
513                            "description": "Required. Detailed instruction for the delegated child run."
514                        }
515                    },
516                    "required": ["agent", "description", "prompt"]
517                },
518                "minItems": 1
519            }
520        },
521        "required": ["tasks"],
522        "examples": [
523            {
524                "tasks": [
525                    {
526                        "agent": "explore",
527                        "description": "Find Rust files",
528                        "prompt": "List Rust files under src/."
529                    },
530                    {
531                        "agent": "explore",
532                        "description": "Find tests",
533                        "prompt": "List test files and summarize their purpose."
534                    }
535                ]
536            }
537        ]
538    })
539}
540
541/// ParallelTaskTool allows the LLM to fan out multiple delegated tasks concurrently.
542///
543/// All tasks execute in parallel and the tool returns when all complete.
544pub struct ParallelTaskTool {
545    executor: Arc<TaskExecutor>,
546}
547
548impl ParallelTaskTool {
549    /// Create a new ParallelTaskTool
550    pub fn new(executor: Arc<TaskExecutor>) -> Self {
551        Self { executor }
552    }
553}
554
555#[async_trait]
556impl Tool for ParallelTaskTool {
557    fn name(&self) -> &str {
558        "parallel_task"
559    }
560
561    fn description(&self) -> &str {
562        "Execute multiple delegated child runs 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), verification (adversarial validation), review (code review). Custom agents from agent_dirs are also available."
563    }
564
565    fn parameters(&self) -> serde_json::Value {
566        parallel_task_params_schema()
567    }
568
569    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
570        let params: ParallelTaskParams =
571            serde_json::from_value(args.clone()).context("Invalid parallel task parameters")?;
572
573        if params.tasks.is_empty() {
574            return Ok(ToolOutput::error("No tasks provided".to_string()));
575        }
576
577        let task_count = params.tasks.len();
578
579        let results = self
580            .executor
581            .execute_parallel(params.tasks, ctx.agent_event_tx.clone())
582            .await;
583
584        // Format results with compact per-task excerpts for parent context.
585        let mut output = format!("Executed {} tasks in parallel:\n\n", task_count);
586        let mut metadata_results = Vec::new();
587        for (i, result) in results.iter().enumerate() {
588            let status = if result.success { "[OK]" } else { "[ERR]" };
589            let (formatted, truncated) = format_task_result_for_context(result);
590            metadata_results.push(serde_json::json!({
591                "task_id": result.task_id,
592                "session_id": result.session_id,
593                "agent": result.agent,
594                "success": result.success,
595                "output_bytes": result.output.len(),
596                "truncated_for_context": truncated,
597                "artifact_id": task_artifact_id(result),
598                "artifact_uri": task_artifact_uri(result),
599            }));
600            output.push_str(&format!(
601                "--- Task {} ({}) {} ---\n{}\n\n",
602                i + 1,
603                result.agent,
604                status,
605                formatted
606            ));
607        }
608
609        Ok(
610            ToolOutput::success(output).with_metadata(serde_json::json!({
611                "task_count": task_count,
612                "results": metadata_results,
613            })),
614        )
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_task_params_deserialize() {
624        let json = r#"{
625            "agent": "explore",
626            "description": "Find auth code",
627            "prompt": "Search for authentication files"
628        }"#;
629
630        let params: TaskParams = serde_json::from_str(json).unwrap();
631        assert_eq!(params.agent, "explore");
632        assert_eq!(params.description, "Find auth code");
633        assert!(!params.background);
634    }
635
636    #[test]
637    fn test_task_params_with_background() {
638        let json = r#"{
639            "agent": "general",
640            "description": "Long task",
641            "prompt": "Do something complex",
642            "background": true
643        }"#;
644
645        let params: TaskParams = serde_json::from_str(json).unwrap();
646        assert!(params.background);
647    }
648
649    #[test]
650    fn test_task_params_with_max_steps() {
651        let json = r#"{
652            "agent": "plan",
653            "description": "Planning task",
654            "prompt": "Create a plan",
655            "max_steps": 10
656        }"#;
657
658        let params: TaskParams = serde_json::from_str(json).unwrap();
659        assert_eq!(params.agent, "plan");
660        assert_eq!(params.max_steps, Some(10));
661        assert!(!params.background);
662    }
663
664    #[test]
665    fn test_task_params_all_fields() {
666        let json = r#"{
667            "agent": "general",
668            "description": "Complex task",
669            "prompt": "Do everything",
670            "background": true,
671            "max_steps": 20
672        }"#;
673
674        let params: TaskParams = serde_json::from_str(json).unwrap();
675        assert_eq!(params.agent, "general");
676        assert_eq!(params.description, "Complex task");
677        assert_eq!(params.prompt, "Do everything");
678        assert!(params.background);
679        assert_eq!(params.max_steps, Some(20));
680    }
681
682    #[test]
683    fn test_task_params_missing_required_field() {
684        let json = r#"{
685            "agent": "explore",
686            "description": "Missing prompt"
687        }"#;
688
689        let result: Result<TaskParams, _> = serde_json::from_str(json);
690        assert!(result.is_err());
691    }
692
693    #[test]
694    fn test_task_params_serialize() {
695        let params = TaskParams {
696            agent: "explore".to_string(),
697            description: "Test task".to_string(),
698            prompt: "Test prompt".to_string(),
699            background: false,
700            max_steps: Some(5),
701        };
702
703        let json = serde_json::to_string(&params).unwrap();
704        assert!(json.contains("explore"));
705        assert!(json.contains("Test task"));
706        assert!(json.contains("Test prompt"));
707    }
708
709    #[test]
710    fn test_task_params_clone() {
711        let params = TaskParams {
712            agent: "explore".to_string(),
713            description: "Test".to_string(),
714            prompt: "Prompt".to_string(),
715            background: true,
716            max_steps: None,
717        };
718
719        let cloned = params.clone();
720        assert_eq!(params.agent, cloned.agent);
721        assert_eq!(params.description, cloned.description);
722        assert_eq!(params.background, cloned.background);
723    }
724
725    #[test]
726    fn test_task_result_serialize() {
727        let result = TaskResult {
728            output: "Found 5 files".to_string(),
729            session_id: "session-123".to_string(),
730            agent: "explore".to_string(),
731            success: true,
732            task_id: "task-456".to_string(),
733        };
734
735        let json = serde_json::to_string(&result).unwrap();
736        assert!(json.contains("Found 5 files"));
737        assert!(json.contains("explore"));
738    }
739
740    #[test]
741    fn test_task_result_deserialize() {
742        let json = r#"{
743            "output": "Task completed",
744            "session_id": "sess-789",
745            "agent": "general",
746            "success": false,
747            "task_id": "task-123"
748        }"#;
749
750        let result: TaskResult = serde_json::from_str(json).unwrap();
751        assert_eq!(result.output, "Task completed");
752        assert_eq!(result.session_id, "sess-789");
753        assert_eq!(result.agent, "general");
754        assert!(!result.success);
755        assert_eq!(result.task_id, "task-123");
756    }
757
758    #[test]
759    fn test_task_result_clone() {
760        let result = TaskResult {
761            output: "Output".to_string(),
762            session_id: "session-1".to_string(),
763            agent: "explore".to_string(),
764            success: true,
765            task_id: "task-1".to_string(),
766        };
767
768        let cloned = result.clone();
769        assert_eq!(result.output, cloned.output);
770        assert_eq!(result.success, cloned.success);
771    }
772
773    #[test]
774    fn test_compact_task_output_preserves_small_output() {
775        let (output, truncated) = compact_task_output("short result");
776        assert_eq!(output, "short result");
777        assert!(!truncated);
778    }
779
780    #[test]
781    fn test_format_task_result_for_context_truncates_large_output() {
782        let result = TaskResult {
783            output: format!("{}TAIL", "x".repeat(TASK_OUTPUT_CONTEXT_LIMIT + 500)),
784            session_id: "session-1".to_string(),
785            agent: "explore".to_string(),
786            success: true,
787            task_id: "task-1".to_string(),
788        };
789
790        let (formatted, truncated) = format_task_result_for_context(&result);
791        assert!(truncated);
792        assert!(formatted.contains("Output excerpt"));
793        assert!(formatted.contains("bytes omitted"));
794        assert!(formatted.contains("Artifact ID: task-output:task-1"));
795        assert!(formatted.contains("Artifact URI: a3s://tasks/session-1/runs/task-1/output"));
796        assert!(formatted.contains("TAIL"));
797        assert!(formatted.len() < result.output.len());
798    }
799
800    #[test]
801    fn test_task_artifact_reference_is_stable() {
802        let result = TaskResult {
803            output: "done".to_string(),
804            session_id: "session-1".to_string(),
805            agent: "explore".to_string(),
806            success: true,
807            task_id: "task-1".to_string(),
808        };
809
810        assert_eq!(task_artifact_id(&result), "task-output:task-1");
811        assert_eq!(
812            task_artifact_uri(&result),
813            "a3s://tasks/session-1/runs/task-1/output"
814        );
815
816        let (formatted, truncated) = format_task_result_for_context(&result);
817        assert!(!truncated);
818        assert!(formatted.contains("Artifact URI: a3s://tasks/session-1/runs/task-1/output"));
819    }
820
821    #[test]
822    fn test_task_params_schema() {
823        let schema = task_params_schema();
824        assert_eq!(schema["type"], "object");
825        assert_eq!(schema["additionalProperties"], false);
826        assert!(schema["properties"]["agent"].is_object());
827        assert!(schema["properties"]["prompt"].is_object());
828    }
829
830    #[test]
831    fn test_task_params_schema_required_fields() {
832        let schema = task_params_schema();
833        let required = schema["required"].as_array().unwrap();
834        assert!(required.contains(&serde_json::json!("agent")));
835        assert!(required.contains(&serde_json::json!("description")));
836        assert!(required.contains(&serde_json::json!("prompt")));
837    }
838
839    #[test]
840    fn test_task_params_schema_properties() {
841        let schema = task_params_schema();
842        let props = &schema["properties"];
843
844        assert_eq!(props["agent"]["type"], "string");
845        assert_eq!(props["description"]["type"], "string");
846        assert_eq!(props["prompt"]["type"], "string");
847        assert_eq!(props["background"]["type"], "boolean");
848        assert_eq!(props["background"]["default"], false);
849        assert_eq!(props["max_steps"]["type"], "integer");
850    }
851
852    #[test]
853    fn test_task_params_schema_descriptions() {
854        let schema = task_params_schema();
855        let props = &schema["properties"];
856
857        assert!(props["agent"]["description"].is_string());
858        assert!(props["description"]["description"].is_string());
859        assert!(props["prompt"]["description"].is_string());
860        assert!(props["background"]["description"].is_string());
861        assert!(props["max_steps"]["description"].is_string());
862    }
863
864    #[test]
865    fn test_task_params_default_background() {
866        let params = TaskParams {
867            agent: "explore".to_string(),
868            description: "Test".to_string(),
869            prompt: "Test prompt".to_string(),
870            background: false,
871            max_steps: None,
872        };
873        assert!(!params.background);
874    }
875
876    #[test]
877    fn test_task_params_serialize_skip_none() {
878        let params = TaskParams {
879            agent: "explore".to_string(),
880            description: "Test".to_string(),
881            prompt: "Test prompt".to_string(),
882            background: false,
883            max_steps: None,
884        };
885        let json = serde_json::to_string(&params).unwrap();
886        // max_steps should not appear when None
887        assert!(!json.contains("max_steps"));
888    }
889
890    #[test]
891    fn test_task_params_serialize_with_max_steps() {
892        let params = TaskParams {
893            agent: "explore".to_string(),
894            description: "Test".to_string(),
895            prompt: "Test prompt".to_string(),
896            background: false,
897            max_steps: Some(15),
898        };
899        let json = serde_json::to_string(&params).unwrap();
900        assert!(json.contains("max_steps"));
901        assert!(json.contains("15"));
902    }
903
904    #[test]
905    fn test_task_result_success_true() {
906        let result = TaskResult {
907            output: "Success".to_string(),
908            session_id: "sess-1".to_string(),
909            agent: "explore".to_string(),
910            success: true,
911            task_id: "task-1".to_string(),
912        };
913        assert!(result.success);
914    }
915
916    #[test]
917    fn test_task_result_success_false() {
918        let result = TaskResult {
919            output: "Failed".to_string(),
920            session_id: "sess-1".to_string(),
921            agent: "explore".to_string(),
922            success: false,
923            task_id: "task-1".to_string(),
924        };
925        assert!(!result.success);
926    }
927
928    #[test]
929    fn test_task_params_empty_strings() {
930        let params = TaskParams {
931            agent: "".to_string(),
932            description: "".to_string(),
933            prompt: "".to_string(),
934            background: false,
935            max_steps: None,
936        };
937        let json = serde_json::to_string(&params).unwrap();
938        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
939        assert_eq!(deserialized.agent, "");
940        assert_eq!(deserialized.description, "");
941        assert_eq!(deserialized.prompt, "");
942    }
943
944    #[test]
945    fn test_task_result_empty_output() {
946        let result = TaskResult {
947            output: "".to_string(),
948            session_id: "sess-1".to_string(),
949            agent: "explore".to_string(),
950            success: true,
951            task_id: "task-1".to_string(),
952        };
953        assert_eq!(result.output, "");
954    }
955
956    #[test]
957    fn test_task_params_debug_format() {
958        let params = TaskParams {
959            agent: "explore".to_string(),
960            description: "Test".to_string(),
961            prompt: "Test prompt".to_string(),
962            background: false,
963            max_steps: None,
964        };
965        let debug_str = format!("{:?}", params);
966        assert!(debug_str.contains("explore"));
967        assert!(debug_str.contains("Test"));
968    }
969
970    #[test]
971    fn test_task_result_debug_format() {
972        let result = TaskResult {
973            output: "Output".to_string(),
974            session_id: "sess-1".to_string(),
975            agent: "explore".to_string(),
976            success: true,
977            task_id: "task-1".to_string(),
978        };
979        let debug_str = format!("{:?}", result);
980        assert!(debug_str.contains("Output"));
981        assert!(debug_str.contains("explore"));
982    }
983
984    #[test]
985    fn test_task_params_roundtrip() {
986        let original = TaskParams {
987            agent: "general".to_string(),
988            description: "Roundtrip test".to_string(),
989            prompt: "Test roundtrip serialization".to_string(),
990            background: true,
991            max_steps: Some(42),
992        };
993        let json = serde_json::to_string(&original).unwrap();
994        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
995        assert_eq!(original.agent, deserialized.agent);
996        assert_eq!(original.description, deserialized.description);
997        assert_eq!(original.prompt, deserialized.prompt);
998        assert_eq!(original.background, deserialized.background);
999        assert_eq!(original.max_steps, deserialized.max_steps);
1000    }
1001
1002    #[test]
1003    fn test_task_result_roundtrip() {
1004        let original = TaskResult {
1005            output: "Roundtrip output".to_string(),
1006            session_id: "sess-roundtrip".to_string(),
1007            agent: "plan".to_string(),
1008            success: false,
1009            task_id: "task-roundtrip".to_string(),
1010        };
1011        let json = serde_json::to_string(&original).unwrap();
1012        let deserialized: TaskResult = serde_json::from_str(&json).unwrap();
1013        assert_eq!(original.output, deserialized.output);
1014        assert_eq!(original.session_id, deserialized.session_id);
1015        assert_eq!(original.agent, deserialized.agent);
1016        assert_eq!(original.success, deserialized.success);
1017        assert_eq!(original.task_id, deserialized.task_id);
1018    }
1019
1020    #[test]
1021    fn test_parallel_task_params_deserialize() {
1022        let json = r#"{
1023            "tasks": [
1024                { "agent": "explore", "description": "Find auth", "prompt": "Search auth files" },
1025                { "agent": "general", "description": "Fix bug", "prompt": "Fix the login bug" }
1026            ]
1027        }"#;
1028
1029        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1030        assert_eq!(params.tasks.len(), 2);
1031        assert_eq!(params.tasks[0].agent, "explore");
1032        assert_eq!(params.tasks[1].agent, "general");
1033    }
1034
1035    #[test]
1036    fn test_parallel_task_params_single_task() {
1037        let json = r#"{
1038            "tasks": [
1039                { "agent": "plan", "description": "Plan work", "prompt": "Create a plan" }
1040            ]
1041        }"#;
1042
1043        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1044        assert_eq!(params.tasks.len(), 1);
1045    }
1046
1047    #[test]
1048    fn test_parallel_task_params_empty_tasks() {
1049        let json = r#"{ "tasks": [] }"#;
1050        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1051        assert!(params.tasks.is_empty());
1052    }
1053
1054    #[test]
1055    fn test_parallel_task_params_missing_tasks() {
1056        let json = r#"{}"#;
1057        let result: Result<ParallelTaskParams, _> = serde_json::from_str(json);
1058        assert!(result.is_err());
1059    }
1060
1061    #[test]
1062    fn test_parallel_task_params_serialize() {
1063        let params = ParallelTaskParams {
1064            tasks: vec![
1065                TaskParams {
1066                    agent: "explore".to_string(),
1067                    description: "Task 1".to_string(),
1068                    prompt: "Prompt 1".to_string(),
1069                    background: false,
1070                    max_steps: None,
1071                },
1072                TaskParams {
1073                    agent: "general".to_string(),
1074                    description: "Task 2".to_string(),
1075                    prompt: "Prompt 2".to_string(),
1076                    background: false,
1077                    max_steps: Some(10),
1078                },
1079            ],
1080        };
1081        let json = serde_json::to_string(&params).unwrap();
1082        assert!(json.contains("explore"));
1083        assert!(json.contains("general"));
1084        assert!(json.contains("Prompt 1"));
1085        assert!(json.contains("Prompt 2"));
1086    }
1087
1088    #[test]
1089    fn test_parallel_task_params_roundtrip() {
1090        let original = ParallelTaskParams {
1091            tasks: vec![
1092                TaskParams {
1093                    agent: "explore".to_string(),
1094                    description: "Explore".to_string(),
1095                    prompt: "Find files".to_string(),
1096                    background: false,
1097                    max_steps: None,
1098                },
1099                TaskParams {
1100                    agent: "plan".to_string(),
1101                    description: "Plan".to_string(),
1102                    prompt: "Make plan".to_string(),
1103                    background: false,
1104                    max_steps: Some(5),
1105                },
1106            ],
1107        };
1108        let json = serde_json::to_string(&original).unwrap();
1109        let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1110        assert_eq!(original.tasks.len(), deserialized.tasks.len());
1111        assert_eq!(original.tasks[0].agent, deserialized.tasks[0].agent);
1112        assert_eq!(original.tasks[1].agent, deserialized.tasks[1].agent);
1113        assert_eq!(original.tasks[1].max_steps, deserialized.tasks[1].max_steps);
1114    }
1115
1116    #[test]
1117    fn test_parallel_task_params_clone() {
1118        let params = ParallelTaskParams {
1119            tasks: vec![TaskParams {
1120                agent: "explore".to_string(),
1121                description: "Test".to_string(),
1122                prompt: "Prompt".to_string(),
1123                background: false,
1124                max_steps: None,
1125            }],
1126        };
1127        let cloned = params.clone();
1128        assert_eq!(params.tasks.len(), cloned.tasks.len());
1129        assert_eq!(params.tasks[0].agent, cloned.tasks[0].agent);
1130    }
1131
1132    #[test]
1133    fn test_parallel_task_params_schema() {
1134        let schema = parallel_task_params_schema();
1135        assert_eq!(schema["type"], "object");
1136        assert_eq!(schema["additionalProperties"], false);
1137        assert!(schema["properties"]["tasks"].is_object());
1138        assert_eq!(schema["properties"]["tasks"]["type"], "array");
1139        assert_eq!(schema["properties"]["tasks"]["minItems"], 1);
1140    }
1141
1142    #[test]
1143    fn test_parallel_task_params_schema_required() {
1144        let schema = parallel_task_params_schema();
1145        let required = schema["required"].as_array().unwrap();
1146        assert!(required.contains(&serde_json::json!("tasks")));
1147    }
1148
1149    #[test]
1150    fn test_parallel_task_params_schema_items() {
1151        let schema = parallel_task_params_schema();
1152        let items = &schema["properties"]["tasks"]["items"];
1153        assert_eq!(items["type"], "object");
1154        assert_eq!(items["additionalProperties"], false);
1155        let item_required = items["required"].as_array().unwrap();
1156        assert!(item_required.contains(&serde_json::json!("agent")));
1157        assert!(item_required.contains(&serde_json::json!("description")));
1158        assert!(item_required.contains(&serde_json::json!("prompt")));
1159    }
1160
1161    #[test]
1162    fn test_task_schema_examples_use_delegation_core() {
1163        let task = task_params_schema();
1164        let task_examples = task["examples"].as_array().unwrap();
1165        assert_eq!(task_examples[0]["agent"], "explore");
1166        assert!(task_examples[0].get("task").is_none());
1167
1168        let parallel = parallel_task_params_schema();
1169        let parallel_examples = parallel["examples"].as_array().unwrap();
1170        assert!(!parallel_examples[0]["tasks"].as_array().unwrap().is_empty());
1171    }
1172
1173    #[test]
1174    fn test_parallel_task_params_debug() {
1175        let params = ParallelTaskParams {
1176            tasks: vec![TaskParams {
1177                agent: "explore".to_string(),
1178                description: "Debug test".to_string(),
1179                prompt: "Test".to_string(),
1180                background: false,
1181                max_steps: None,
1182            }],
1183        };
1184        let debug_str = format!("{:?}", params);
1185        assert!(debug_str.contains("explore"));
1186        assert!(debug_str.contains("Debug test"));
1187    }
1188
1189    #[test]
1190    fn test_parallel_task_params_large_count() {
1191        // Validate that ParallelTaskParams can hold 150 tasks without truncation
1192        let tasks: Vec<TaskParams> = (0..150)
1193            .map(|i| TaskParams {
1194                agent: "explore".to_string(),
1195                description: format!("Task {}", i),
1196                prompt: format!("Prompt for task {}", i),
1197                background: false,
1198                max_steps: Some(10),
1199            })
1200            .collect();
1201
1202        let params = ParallelTaskParams { tasks };
1203        let json = serde_json::to_string(&params).unwrap();
1204        let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1205        assert_eq!(deserialized.tasks.len(), 150);
1206        assert_eq!(deserialized.tasks[0].description, "Task 0");
1207        assert_eq!(deserialized.tasks[149].description, "Task 149");
1208    }
1209
1210    #[test]
1211    fn test_task_params_max_steps_zero() {
1212        // max_steps = 0 is a valid edge case (callers decide enforcement)
1213        let params = TaskParams {
1214            agent: "explore".to_string(),
1215            description: "Edge case".to_string(),
1216            prompt: "Zero steps".to_string(),
1217            background: false,
1218            max_steps: Some(0),
1219        };
1220        let json = serde_json::to_string(&params).unwrap();
1221        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1222        assert_eq!(deserialized.max_steps, Some(0));
1223    }
1224
1225    #[test]
1226    fn test_parallel_task_params_all_background() {
1227        let tasks: Vec<TaskParams> = (0..5)
1228            .map(|i| TaskParams {
1229                agent: "general".to_string(),
1230                description: format!("BG task {}", i),
1231                prompt: "Run in background".to_string(),
1232                background: true,
1233                max_steps: None,
1234            })
1235            .collect();
1236        let params = ParallelTaskParams { tasks };
1237        for task in &params.tasks {
1238            assert!(task.background);
1239        }
1240    }
1241
1242    #[test]
1243    fn test_task_params_rejects_permissive_field() {
1244        let json = r#"{
1245            "agent": "general",
1246            "description": "Legacy field rejection",
1247            "prompt": "Verify legacy fields are rejected",
1248            "permissive": true
1249        }"#;
1250
1251        let result: Result<TaskParams, _> = serde_json::from_str(json);
1252        assert!(result.is_err());
1253    }
1254
1255    #[test]
1256    fn test_task_params_schema_hides_permissive_field() {
1257        let schema = task_params_schema();
1258        let props = &schema["properties"];
1259
1260        assert!(props.get("permissive").is_none());
1261    }
1262
1263    // ========================================================================
1264    // Contract tests — verify task delegation with MockLlmClient (no network)
1265    // ========================================================================
1266
1267    use crate::agent::tests::MockLlmClient;
1268    use crate::permissions::PermissionPolicy;
1269    use crate::subagent::AgentRegistry;
1270
1271    fn test_registry_with_writer() -> Arc<AgentRegistry> {
1272        let registry = AgentRegistry::new();
1273        let spec = crate::subagent::WorkerAgentSpec::custom("writer", "Write files")
1274            .with_permissions(PermissionPolicy::new().allow("write(*)").allow("read(*)"))
1275            .with_prompt("Write files when asked.")
1276            .with_max_steps(3);
1277        registry.register(spec.into_agent_definition());
1278        Arc::new(registry)
1279    }
1280
1281    #[tokio::test]
1282    async fn task_child_run_permission_allow() {
1283        let workspace = tempfile::tempdir().unwrap();
1284        let mock = Arc::new(MockLlmClient::new(vec![
1285            MockLlmClient::tool_call_response(
1286                "t1",
1287                "write",
1288                serde_json::json!({
1289                    "file_path": workspace.path().join("out.txt").to_string_lossy(),
1290                    "content": "WRITTEN"
1291                }),
1292            ),
1293            MockLlmClient::text_response("Done."),
1294        ]));
1295
1296        let executor = TaskExecutor::new(
1297            test_registry_with_writer(),
1298            mock,
1299            workspace.path().to_string_lossy().to_string(),
1300        );
1301
1302        let result = executor
1303            .execute(
1304                TaskParams {
1305                    agent: "writer".to_string(),
1306                    description: "Write file".to_string(),
1307                    prompt: "Write out.txt".to_string(),
1308                    background: false,
1309                    max_steps: Some(3),
1310                },
1311                None,
1312            )
1313            .await
1314            .unwrap();
1315
1316        assert!(
1317            result.success,
1318            "child run should succeed: {}",
1319            result.output
1320        );
1321        assert!(
1322            !result.output.contains("Permission denied"),
1323            "no permission denial: {}",
1324            result.output
1325        );
1326        let content = std::fs::read_to_string(workspace.path().join("out.txt")).unwrap();
1327        assert_eq!(content, "WRITTEN");
1328    }
1329
1330    #[tokio::test]
1331    async fn task_child_run_permission_deny() {
1332        let workspace = tempfile::tempdir().unwrap();
1333        let registry = AgentRegistry::new();
1334        let spec = crate::subagent::WorkerAgentSpec::custom("restricted", "Restricted agent")
1335            .with_permissions(PermissionPolicy::new().allow("read(*)").deny("bash(*)"))
1336            .with_max_steps(3);
1337        registry.register(spec.into_agent_definition());
1338
1339        let mock = Arc::new(MockLlmClient::new(vec![
1340            MockLlmClient::tool_call_response(
1341                "t1",
1342                "bash",
1343                serde_json::json!({"command": "echo hello"}),
1344            ),
1345            MockLlmClient::text_response("Could not run bash."),
1346        ]));
1347
1348        let executor = TaskExecutor::new(
1349            Arc::new(registry),
1350            mock,
1351            workspace.path().to_string_lossy().to_string(),
1352        );
1353
1354        let result = executor
1355            .execute(
1356                TaskParams {
1357                    agent: "restricted".to_string(),
1358                    description: "Try bash".to_string(),
1359                    prompt: "Run echo hello".to_string(),
1360                    background: false,
1361                    max_steps: Some(3),
1362                },
1363                None,
1364            )
1365            .await
1366            .unwrap();
1367
1368        // The agent completes (LLM responds after denial), but bash was denied.
1369        // The denial is sent as a tool result to the LLM, which then responds.
1370        assert!(result.success, "agent should complete: {}", result.output);
1371    }
1372
1373    #[tokio::test]
1374    async fn task_child_run_confirmation_auto_approve() {
1375        let workspace = tempfile::tempdir().unwrap();
1376        let registry = AgentRegistry::new();
1377        // Agent with allow("read(*)") — write is not in allow list, so it returns Ask.
1378        // With AutoApproveConfirmation (default for agents with permissions), Ask → approve.
1379        let spec = crate::subagent::WorkerAgentSpec::custom("reader-writer", "Read and write")
1380            .with_permissions(PermissionPolicy::new().allow("read(*)"))
1381            .with_max_steps(3);
1382        registry.register(spec.into_agent_definition());
1383
1384        let mock = Arc::new(MockLlmClient::new(vec![
1385            MockLlmClient::tool_call_response(
1386                "t1",
1387                "write",
1388                serde_json::json!({
1389                    "file_path": workspace.path().join("auto.txt").to_string_lossy(),
1390                    "content": "AUTO_APPROVED"
1391                }),
1392            ),
1393            MockLlmClient::text_response("Written."),
1394        ]));
1395
1396        let executor = TaskExecutor::new(
1397            Arc::new(registry),
1398            mock,
1399            workspace.path().to_string_lossy().to_string(),
1400        );
1401
1402        let result = executor
1403            .execute(
1404                TaskParams {
1405                    agent: "reader-writer".to_string(),
1406                    description: "Write via auto-approve".to_string(),
1407                    prompt: "Write auto.txt".to_string(),
1408                    background: false,
1409                    max_steps: Some(3),
1410                },
1411                None,
1412            )
1413            .await
1414            .unwrap();
1415
1416        assert!(
1417            result.success,
1418            "Ask should be auto-approved: {}",
1419            result.output
1420        );
1421        assert!(
1422            !result.output.contains("MissingConfirmationManager"),
1423            "no MissingConfirmationManager: {}",
1424            result.output
1425        );
1426    }
1427
1428    #[tokio::test]
1429    async fn task_child_run_step_budget_enforced() {
1430        let workspace = tempfile::tempdir().unwrap();
1431        let mock = Arc::new(MockLlmClient::new(vec![
1432            MockLlmClient::tool_call_response(
1433                "t1",
1434                "read",
1435                serde_json::json!({"file_path": "/tmp/a.txt"}),
1436            ),
1437            MockLlmClient::tool_call_response(
1438                "t2",
1439                "read",
1440                serde_json::json!({"file_path": "/tmp/b.txt"}),
1441            ),
1442            MockLlmClient::tool_call_response(
1443                "t3",
1444                "read",
1445                serde_json::json!({"file_path": "/tmp/c.txt"}),
1446            ),
1447            MockLlmClient::text_response("Should not reach here."),
1448        ]));
1449
1450        let executor = TaskExecutor::new(
1451            test_registry_with_writer(),
1452            mock,
1453            workspace.path().to_string_lossy().to_string(),
1454        );
1455
1456        let result = executor
1457            .execute(
1458                TaskParams {
1459                    agent: "writer".to_string(),
1460                    description: "Exceed budget".to_string(),
1461                    prompt: "Read many files".to_string(),
1462                    background: false,
1463                    max_steps: Some(2),
1464                },
1465                None,
1466            )
1467            .await
1468            .unwrap();
1469
1470        // The agent should fail after exceeding 2 tool rounds
1471        assert!(
1472            !result.success,
1473            "should fail when exceeding step budget: {}",
1474            result.output
1475        );
1476        assert!(
1477            result.output.contains("Max tool rounds") || result.output.contains("max tool rounds"),
1478            "error should mention tool rounds: {}",
1479            result.output
1480        );
1481    }
1482
1483    #[tokio::test]
1484    async fn parallel_task_both_inherit_permissions() {
1485        let workspace = tempfile::tempdir().unwrap();
1486        let mock = Arc::new(MockLlmClient::new(vec![
1487            // Task 1 responses
1488            MockLlmClient::tool_call_response(
1489                "t1",
1490                "write",
1491                serde_json::json!({
1492                    "file_path": workspace.path().join("p1.txt").to_string_lossy(),
1493                    "content": "P1"
1494                }),
1495            ),
1496            MockLlmClient::text_response("Done 1."),
1497            // Task 2 responses
1498            MockLlmClient::tool_call_response(
1499                "t2",
1500                "write",
1501                serde_json::json!({
1502                    "file_path": workspace.path().join("p2.txt").to_string_lossy(),
1503                    "content": "P2"
1504                }),
1505            ),
1506            MockLlmClient::text_response("Done 2."),
1507        ]));
1508
1509        let executor = Arc::new(TaskExecutor::new(
1510            test_registry_with_writer(),
1511            mock,
1512            workspace.path().to_string_lossy().to_string(),
1513        ));
1514
1515        let tasks = vec![
1516            TaskParams {
1517                agent: "writer".to_string(),
1518                description: "Write p1".to_string(),
1519                prompt: "Write p1.txt".to_string(),
1520                background: false,
1521                max_steps: Some(3),
1522            },
1523            TaskParams {
1524                agent: "writer".to_string(),
1525                description: "Write p2".to_string(),
1526                prompt: "Write p2.txt".to_string(),
1527                background: false,
1528                max_steps: Some(3),
1529            },
1530        ];
1531
1532        let results = executor.execute_parallel(tasks, None).await;
1533        assert_eq!(results.len(), 2);
1534
1535        for result in &results {
1536            assert!(
1537                result.success,
1538                "parallel child should succeed: {}",
1539                result.output
1540            );
1541        }
1542    }
1543}