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