1use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18use crate::llm::structured::{generate_blocking, StructuredMode, StructuredRequest};
19use crate::llm::LlmClient;
20use crate::mcp::manager::McpManager;
21use crate::orchestration::{AgentExecutor, AgentStepSpec, StepOutcome};
22use crate::subagent::AgentRegistry;
23use crate::tools::types::{Tool, ToolContext, ToolOutput};
24use anyhow::{Context, Result};
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27use std::path::PathBuf;
28use std::sync::Arc;
29use tokio::sync::broadcast;
30
31const TASK_OUTPUT_CONTEXT_LIMIT: usize = 4_000;
32const TASK_OUTPUT_CONTEXT_HEAD: usize = 3_000;
33const TASK_OUTPUT_CONTEXT_TAIL: usize = 800;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct TaskParams {
39 pub agent: String,
41 pub description: String,
43 pub prompt: String,
45 #[serde(default)]
47 pub background: bool,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub max_steps: Option<usize>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TaskResult {
56 pub output: String,
58 pub session_id: String,
60 pub agent: String,
62 pub success: bool,
64 pub task_id: String,
66}
67
68fn compact_task_output(output: &str) -> (String, bool) {
69 if output.len() <= TASK_OUTPUT_CONTEXT_LIMIT {
70 return (output.to_string(), false);
71 }
72
73 let head = crate::text::truncate_utf8(output, TASK_OUTPUT_CONTEXT_HEAD);
74 let tail_start = output
75 .char_indices()
76 .find_map(|(idx, _)| {
77 if output.len().saturating_sub(idx) <= TASK_OUTPUT_CONTEXT_TAIL {
78 Some(idx)
79 } else {
80 None
81 }
82 })
83 .unwrap_or(output.len());
84 let tail = &output[tail_start..];
85
86 (
87 format!(
88 "{}\n\n[{} bytes omitted from delegated task output]\n\n{}",
89 head,
90 output.len().saturating_sub(head.len() + tail.len()),
91 tail
92 ),
93 true,
94 )
95}
96
97fn synthesize_subagent_progress(
108 event: &AgentEvent,
109 task_id: &str,
110 session_id: &str,
111) -> Option<AgentEvent> {
112 match event {
113 AgentEvent::ToolEnd {
114 name,
115 output,
116 exit_code,
117 error_kind,
118 ..
119 } => {
120 let mut metadata = serde_json::json!({
121 "tool": name,
122 "exit_code": exit_code,
123 "output_bytes": output.len(),
124 });
125 if let Some(kind) = error_kind {
126 metadata["error_kind"] =
127 serde_json::to_value(kind).unwrap_or(serde_json::Value::Null);
128 }
129 Some(AgentEvent::SubagentProgress {
130 task_id: task_id.to_string(),
131 session_id: session_id.to_string(),
132 status: "tool_completed".to_string(),
133 metadata,
134 })
135 }
136 AgentEvent::TurnEnd { turn, usage } => Some(AgentEvent::SubagentProgress {
137 task_id: task_id.to_string(),
138 session_id: session_id.to_string(),
139 status: "turn_completed".to_string(),
140 metadata: serde_json::json!({
141 "turn": turn,
142 "total_tokens": usage.total_tokens,
143 "prompt_tokens": usage.prompt_tokens,
144 "completion_tokens": usage.completion_tokens,
145 }),
146 }),
147 _ => None,
148 }
149}
150
151fn task_artifact_id(result: &TaskResult) -> String {
152 format!("task-output:{}", result.task_id)
153}
154
155fn task_artifact_uri(result: &TaskResult) -> String {
156 format!(
157 "a3s://tasks/{}/runs/{}/output",
158 result.session_id, result.task_id
159 )
160}
161
162fn epoch_ms() -> u64 {
163 use std::time::{SystemTime, UNIX_EPOCH};
164 SystemTime::now()
165 .duration_since(UNIX_EPOCH)
166 .map(|duration| duration.as_millis() as u64)
167 .unwrap_or(0)
168}
169
170fn format_task_result_for_context(result: &TaskResult) -> (String, bool) {
171 let (output, truncated) = compact_task_output(&result.output);
172 let status = if result.success {
173 "completed"
174 } else {
175 "failed"
176 };
177 let artifact_id = task_artifact_id(result);
178 let artifact_uri = task_artifact_uri(result);
179 let mut formatted = format!(
180 "Task {status}: {}\nAgent: {}\nSession: {}\nTask ID: {}\nArtifact ID: {}\nArtifact URI: {}\n",
181 result.task_id, result.agent, result.session_id, result.task_id, artifact_id, artifact_uri
182 );
183 if truncated {
184 formatted.push_str(
185 "Output excerpt: truncated for parent context. Use the artifact URI or child run session/events if exact omitted content is needed.\n",
186 );
187 } else {
188 formatted.push_str("Output:\n");
189 }
190 formatted.push_str(&output);
191 (formatted, truncated)
192}
193
194pub struct TaskExecutor {
196 registry: Arc<AgentRegistry>,
198 llm_client: Arc<dyn LlmClient>,
200 workspace: String,
202 mcp_manager: Option<Arc<McpManager>>,
204 parent_context: Option<crate::child_run::ChildRunContext>,
206 max_parallel_tasks: usize,
207 subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
210}
211
212impl TaskExecutor {
213 pub fn new(
215 registry: Arc<AgentRegistry>,
216 llm_client: Arc<dyn LlmClient>,
217 workspace: String,
218 ) -> Self {
219 Self {
220 registry,
221 llm_client,
222 workspace,
223 mcp_manager: None,
224 parent_context: None,
225 max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
226 subagent_tracker: None,
227 }
228 }
229
230 pub fn with_mcp(
232 registry: Arc<AgentRegistry>,
233 llm_client: Arc<dyn LlmClient>,
234 workspace: String,
235 mcp_manager: Arc<McpManager>,
236 ) -> Self {
237 Self {
238 registry,
239 llm_client,
240 workspace,
241 mcp_manager: Some(mcp_manager),
242 parent_context: None,
243 max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
244 subagent_tracker: None,
245 }
246 }
247
248 pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
250 if let Some(max_parallel_tasks) = ctx.max_parallel_tasks {
251 self.max_parallel_tasks = max_parallel_tasks.max(1);
252 }
253 self.parent_context = Some(ctx);
254 self
255 }
256
257 pub fn with_max_parallel_tasks(mut self, max_parallel_tasks: usize) -> Self {
258 self.max_parallel_tasks = max_parallel_tasks.max(1);
259 self
260 }
261
262 pub fn with_subagent_tracker(
266 mut self,
267 tracker: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
268 ) -> Self {
269 self.subagent_tracker = Some(tracker);
270 self
271 }
272
273 pub async fn execute(
278 &self,
279 params: TaskParams,
280 event_tx: Option<broadcast::Sender<AgentEvent>>,
281 parent_session_id: Option<&str>,
282 ) -> Result<TaskResult> {
283 let task_id = format!("task-{}", uuid::Uuid::new_v4());
284 self.execute_with_task_id(task_id, params, event_tx, parent_session_id, true)
285 .await
286 }
287
288 pub async fn execute_with_task_id(
293 &self,
294 task_id: String,
295 params: TaskParams,
296 event_tx: Option<broadcast::Sender<AgentEvent>>,
297 parent_session_id: Option<&str>,
298 emit_start: bool,
299 ) -> Result<TaskResult> {
300 let session_id = format!("task-run-{}", task_id);
301 let started_ms = epoch_ms();
302
303 let agent = self
304 .registry
305 .get(¶ms.agent)
306 .context(format!("Unknown agent type: '{}'", params.agent))?;
307
308 if emit_start {
309 if let Some(ref tx) = event_tx {
310 let _ = tx.send(AgentEvent::SubagentStart {
311 task_id: task_id.clone(),
312 session_id: session_id.clone(),
313 parent_session_id: parent_session_id.unwrap_or_default().to_string(),
314 agent: params.agent.clone(),
315 description: params.description.clone(),
316 started_ms,
317 });
318 }
319 }
320
321 let child_executor = if let Some(ref parent_ctx) = self.parent_context {
324 if let Some(ref services) = parent_ctx.workspace_services {
325 crate::tools::ToolExecutor::new_with_workspace_services_and_artifact_limits(
326 self.workspace.clone(),
327 Arc::clone(services),
328 crate::tools::ArtifactStoreLimits::default(),
329 )
330 } else {
331 crate::tools::ToolExecutor::new(self.workspace.clone())
332 }
333 } else {
334 crate::tools::ToolExecutor::new(self.workspace.clone())
335 };
336
337 if let Some(ref mcp) = self.mcp_manager {
339 let all_tools = mcp.get_all_tools().await;
340 let mut by_server: std::collections::HashMap<
341 String,
342 Vec<crate::mcp::protocol::McpTool>,
343 > = std::collections::HashMap::new();
344 for (server, tool) in all_tools {
345 by_server.entry(server).or_default().push(tool);
346 }
347 for (server_name, tools) in by_server {
348 let wrappers =
349 crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
350 for wrapper in wrappers {
351 child_executor.register_dynamic_tool(wrapper);
352 }
353 }
354 }
355
356 let child_executor = Arc::new(child_executor);
357
358 let mut child_config = AgentConfig {
359 tools: child_executor.definitions(),
360 ..AgentConfig::default()
361 };
362 agent.apply_to(&mut child_config);
363 if let Some(ref parent_ctx) = self.parent_context {
364 parent_ctx.apply_to(&mut child_config);
365 }
366 if let Some(max_steps) = params.max_steps {
367 child_config.max_tool_rounds = max_steps;
368 }
369
370 let mut tool_context =
371 ToolContext::new(PathBuf::from(&self.workspace)).with_session_id(session_id.clone());
372 if let Some(ref parent_ctx) = self.parent_context {
373 if let Some(ref services) = parent_ctx.workspace_services {
374 tool_context = tool_context.with_workspace_services(Arc::clone(services));
375 }
376 }
377
378 let agent_loop = AgentLoop::new(
379 Arc::clone(&self.llm_client),
380 child_executor,
381 tool_context,
382 child_config,
383 );
384
385 let child_event_tx = if let Some(ref broadcast_tx) = event_tx {
390 let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
391 let broadcast_tx_clone = broadcast_tx.clone();
392 let progress_task_id = task_id.clone();
393 let progress_session_id = session_id.clone();
394
395 tokio::spawn(async move {
396 while let Some(event) = mpsc_rx.recv().await {
397 if let Some(progress) = synthesize_subagent_progress(
398 &event,
399 &progress_task_id,
400 &progress_session_id,
401 ) {
402 let _ = broadcast_tx_clone.send(progress);
403 }
404 let _ = broadcast_tx_clone.send(event);
405 }
406 });
407
408 Some(mpsc_tx)
409 } else {
410 None
411 };
412
413 let cancel_token = tokio_util::sync::CancellationToken::new();
416 if let Some(ref tracker) = self.subagent_tracker {
417 tracker
418 .register_canceller(&task_id, cancel_token.clone())
419 .await;
420 }
421
422 let (output, success) = match agent_loop
423 .execute_with_session(
424 &[],
425 ¶ms.prompt,
426 Some(&session_id),
427 child_event_tx,
428 Some(&cancel_token),
429 )
430 .await
431 {
432 Ok(result) => (result.text, true),
433 Err(e) if cancel_token.is_cancelled() => {
434 (format!("Task cancelled by caller: {}", e), false)
435 }
436 Err(e) => (format!("Task failed: {}", e), false),
437 };
438
439 if let Some(ref tracker) = self.subagent_tracker {
440 tracker.clear_canceller(&task_id).await;
441 }
442
443 if let Some(ref tx) = event_tx {
444 let _ = tx.send(AgentEvent::SubagentEnd {
445 task_id: task_id.clone(),
446 session_id: session_id.clone(),
447 agent: params.agent.clone(),
448 output: output.clone(),
449 success,
450 finished_ms: epoch_ms(),
451 });
452 }
453
454 Ok(TaskResult {
455 output,
456 session_id,
457 agent: params.agent,
458 success,
459 task_id,
460 })
461 }
462
463 pub fn execute_background(
471 self: Arc<Self>,
472 params: TaskParams,
473 event_tx: Option<broadcast::Sender<AgentEvent>>,
474 parent_session_id: Option<String>,
475 ) -> String {
476 let task_id = format!("task-{}", uuid::Uuid::new_v4());
477 let session_id = format!("task-run-{}", task_id);
478
479 if let Some(ref tx) = event_tx {
480 let _ = tx.send(AgentEvent::SubagentStart {
481 task_id: task_id.clone(),
482 session_id,
483 parent_session_id: parent_session_id.clone().unwrap_or_default(),
484 agent: params.agent.clone(),
485 description: params.description.clone(),
486 started_ms: epoch_ms(),
487 });
488 }
489
490 let task_id_for_spawn = task_id.clone();
491 let task_id_for_log = task_id.clone();
492 tokio::spawn(async move {
493 if let Err(e) = self
494 .execute_with_task_id(
495 task_id_for_spawn,
496 params,
497 event_tx,
498 parent_session_id.as_deref(),
499 false,
500 )
501 .await
502 {
503 tracing::error!("Background task {} failed: {}", task_id_for_log, e);
504 }
505 });
506
507 task_id
508 }
509
510 pub async fn execute_parallel(
518 self: &Arc<Self>,
519 tasks: Vec<TaskParams>,
520 event_tx: Option<broadcast::Sender<AgentEvent>>,
521 parent_session_id: Option<&str>,
522 ) -> Vec<TaskResult> {
523 let parent = parent_session_id.map(|s| s.to_string());
524 let specs = tasks
525 .into_iter()
526 .map(|params| AgentStepSpec {
527 task_id: format!("task-{}", uuid::Uuid::new_v4()),
528 agent: params.agent,
529 description: params.description,
530 prompt: params.prompt,
531 max_steps: params.max_steps,
532 parent_session_id: parent.clone(),
533 output_schema: None,
534 })
535 .collect();
536
537 let executor: Arc<dyn AgentExecutor> = Arc::<Self>::clone(self);
538 crate::orchestration::execute_steps_parallel(executor, specs, event_tx)
539 .await
540 .into_iter()
541 .map(TaskResult::from)
542 .collect()
543 }
544}
545
546impl From<TaskResult> for StepOutcome {
547 fn from(r: TaskResult) -> Self {
548 StepOutcome {
549 task_id: r.task_id,
550 session_id: r.session_id,
551 agent: r.agent,
552 output: r.output,
553 success: r.success,
554 structured: None,
555 }
556 }
557}
558
559impl From<StepOutcome> for TaskResult {
560 fn from(o: StepOutcome) -> Self {
561 TaskResult {
562 output: o.output,
563 session_id: o.session_id,
564 agent: o.agent,
565 success: o.success,
566 task_id: o.task_id,
567 }
568 }
569}
570
571#[async_trait]
575impl AgentExecutor for TaskExecutor {
576 async fn execute_step(
577 &self,
578 spec: AgentStepSpec,
579 event_tx: Option<broadcast::Sender<AgentEvent>>,
580 ) -> StepOutcome {
581 let agent = spec.agent.clone();
582 let task_id = spec.task_id.clone();
583 let output_schema = spec.output_schema.clone();
584 let params = TaskParams {
585 agent: spec.agent,
586 description: spec.description,
587 prompt: spec.prompt,
588 background: false,
589 max_steps: spec.max_steps,
590 };
591 let mut outcome: StepOutcome = match self
592 .execute_with_task_id(
593 task_id.clone(),
594 params,
595 event_tx,
596 spec.parent_session_id.as_deref(),
597 true,
598 )
599 .await
600 {
601 Ok(result) => result.into(),
602 Err(e) => return StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
603 };
604
605 if outcome.success {
610 if let Some(schema) = output_schema {
611 match self.coerce_to_schema(&outcome.output, schema).await {
612 Ok(object) => outcome.structured = Some(object),
613 Err(e) => {
614 outcome.success = false;
615 outcome.output =
616 format!("{}\n\n[structured output failed: {e}]", outcome.output);
617 }
618 }
619 }
620 }
621 outcome
622 }
623
624 fn concurrency_hint(&self) -> usize {
625 self.max_parallel_tasks
626 }
627}
628
629impl TaskExecutor {
630 async fn coerce_to_schema(
635 &self,
636 output: &str,
637 schema: serde_json::Value,
638 ) -> Result<serde_json::Value> {
639 let req = StructuredRequest {
640 prompt: format!(
641 "Convert the following task result into a single JSON object that conforms to \
642 the required schema. Use only information present in the result.\n\n\
643 --- TASK RESULT ---\n{output}"
644 ),
645 system: Some(
646 "You output exactly one JSON object matching the provided schema.".to_string(),
647 ),
648 schema,
649 schema_name: "step_output".to_string(),
650 schema_description: None,
651 mode: StructuredMode::Tool,
654 max_repair_attempts: 2,
655 };
656 let result = generate_blocking(&*self.llm_client, &req).await?;
657 Ok(result.object)
658 }
659}
660
661pub fn task_params_schema() -> serde_json::Value {
663 serde_json::json!({
664 "type": "object",
665 "additionalProperties": false,
666 "properties": {
667 "agent": {
668 "type": "string",
669 "description": "Required. Canonical agent type to use (for example: explore, general, plan, verification, review). Always provide this exact field name: 'agent'."
670 },
671 "description": {
672 "type": "string",
673 "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
674 },
675 "prompt": {
676 "type": "string",
677 "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
678 },
679 "background": {
680 "type": "boolean",
681 "description": "Optional. Run the task in the background. Default: false.",
682 "default": false
683 },
684 "max_steps": {
685 "type": "integer",
686 "description": "Optional. Maximum number of steps for this task."
687 }
688 },
689 "required": ["agent", "description", "prompt"],
690 "examples": [
691 {
692 "agent": "explore",
693 "description": "Find Rust files",
694 "prompt": "Search the workspace for Rust files and summarize the layout."
695 },
696 {
697 "agent": "general",
698 "description": "Investigate test failure",
699 "prompt": "Inspect the failing tests and explain the root cause.",
700 "max_steps": 6
701 }
702 ]
703 })
704}
705
706pub struct TaskTool {
709 executor: Arc<TaskExecutor>,
710}
711
712impl TaskTool {
713 pub fn new(executor: Arc<TaskExecutor>) -> Self {
715 Self { executor }
716 }
717}
718
719#[async_trait]
720impl Tool for TaskTool {
721 fn name(&self) -> &str {
722 "task"
723 }
724
725 fn description(&self) -> &str {
726 "Delegate a bounded task to a specialized child run. Built-in agents: explore (read-only codebase and web evidence search), general/general-purpose (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs and .a3s/agents are also available; .claude/agents is read for compatibility."
727 }
728
729 fn parameters(&self) -> serde_json::Value {
730 task_params_schema()
731 }
732
733 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
734 let params: TaskParams =
735 serde_json::from_value(args.clone()).context("Invalid task parameters")?;
736
737 if params.background {
738 let task_id = Arc::clone(&self.executor).execute_background(
739 params,
740 ctx.agent_event_tx.clone(),
741 ctx.session_id.clone(),
742 );
743 return Ok(ToolOutput::success(format!(
744 "Task started in background. Task ID: {}",
745 task_id
746 )));
747 }
748
749 let result = self
750 .executor
751 .execute(
752 params,
753 ctx.agent_event_tx.clone(),
754 ctx.session_id.as_deref(),
755 )
756 .await?;
757 let (content, truncated) = format_task_result_for_context(&result);
758 let metadata = serde_json::json!({
759 "task_id": result.task_id,
760 "session_id": result.session_id,
761 "agent": result.agent,
762 "success": result.success,
763 "output_bytes": result.output.len(),
764 "truncated_for_context": truncated,
765 "artifact_id": task_artifact_id(&result),
766 "artifact_uri": task_artifact_uri(&result),
767 });
768
769 if result.success {
770 Ok(ToolOutput::success(content).with_metadata(metadata))
771 } else {
772 Ok(ToolOutput::error(content).with_metadata(metadata))
773 }
774 }
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize)]
779#[serde(deny_unknown_fields)]
780pub struct ParallelTaskParams {
781 pub tasks: Vec<TaskParams>,
783}
784
785pub fn parallel_task_params_schema() -> serde_json::Value {
787 serde_json::json!({
788 "type": "object",
789 "additionalProperties": false,
790 "properties": {
791 "tasks": {
792 "type": "array",
793 "description": "List of tasks to execute in parallel. Each task runs as an independent delegated child run concurrently.",
794 "items": {
795 "type": "object",
796 "additionalProperties": false,
797 "properties": {
798 "agent": {
799 "type": "string",
800 "description": "Required. Canonical agent type for this task."
801 },
802 "description": {
803 "type": "string",
804 "description": "Required. Short task label for display and tracking."
805 },
806 "prompt": {
807 "type": "string",
808 "description": "Required. Detailed instruction for the delegated child run."
809 }
810 },
811 "required": ["agent", "description", "prompt"]
812 },
813 "minItems": 1
814 }
815 },
816 "required": ["tasks"],
817 "examples": [
818 {
819 "tasks": [
820 {
821 "agent": "explore",
822 "description": "Find Rust files",
823 "prompt": "List Rust files under src/."
824 },
825 {
826 "agent": "explore",
827 "description": "Find tests",
828 "prompt": "List test files and summarize their purpose."
829 }
830 ]
831 }
832 ]
833 })
834}
835
836pub struct ParallelTaskTool {
840 executor: Arc<TaskExecutor>,
841}
842
843impl ParallelTaskTool {
844 pub fn new(executor: Arc<TaskExecutor>) -> Self {
846 Self { executor }
847 }
848}
849
850#[async_trait]
851impl Tool for ParallelTaskTool {
852 fn name(&self) -> &str {
853 "parallel_task"
854 }
855
856 fn description(&self) -> &str {
857 "Fan out 2 or more INDEPENDENT subtasks as delegated child runs that execute concurrently; results are returned when all complete. Use this only when the work genuinely splits into branches that can be investigated or implemented separately (e.g. inspect several unrelated modules at once, or run review and verification in parallel). Do NOT use it for trivial, conversational, or single-step requests, or for steps that depend on one another — handle those directly. Built-in agents: explore (read-only codebase and web evidence search), general/general-purpose (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs and .a3s/agents are also available; .claude/agents is read for compatibility."
858 }
859
860 fn parameters(&self) -> serde_json::Value {
861 parallel_task_params_schema()
862 }
863
864 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
865 let params: ParallelTaskParams =
866 serde_json::from_value(args.clone()).context("Invalid parallel task parameters")?;
867
868 if params.tasks.is_empty() {
869 return Ok(ToolOutput::error("No tasks provided".to_string()));
870 }
871
872 let task_count = params.tasks.len();
873
874 let results = self
875 .executor
876 .execute_parallel(
877 params.tasks,
878 ctx.agent_event_tx.clone(),
879 ctx.session_id.as_deref(),
880 )
881 .await;
882
883 let mut output = format!("Executed {} tasks in parallel:\n\n", task_count);
885 let mut metadata_results = Vec::new();
886 for (i, result) in results.iter().enumerate() {
887 let status = if result.success { "[OK]" } else { "[ERR]" };
888 let (formatted, truncated) = format_task_result_for_context(result);
889 metadata_results.push(serde_json::json!({
890 "task_id": result.task_id,
891 "session_id": result.session_id,
892 "agent": result.agent,
893 "success": result.success,
894 "output": formatted.clone(),
895 "output_bytes": result.output.len(),
896 "truncated_for_context": truncated,
897 "artifact_id": task_artifact_id(result),
898 "artifact_uri": task_artifact_uri(result),
899 }));
900 output.push_str(&format!(
901 "--- Task {} ({}) {} ---\n{}\n\n",
902 i + 1,
903 result.agent,
904 status,
905 formatted
906 ));
907 }
908
909 let all_success = results.iter().all(|result| result.success);
910 let output = if all_success {
911 ToolOutput::success(output)
912 } else {
913 ToolOutput::error(output)
914 };
915
916 Ok(output.with_metadata(serde_json::json!({
917 "task_count": task_count,
918 "results": metadata_results,
919 })))
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 #[test]
928 fn test_task_params_deserialize() {
929 let json = r#"{
930 "agent": "explore",
931 "description": "Find auth code",
932 "prompt": "Search for authentication files"
933 }"#;
934
935 let params: TaskParams = serde_json::from_str(json).unwrap();
936 assert_eq!(params.agent, "explore");
937 assert_eq!(params.description, "Find auth code");
938 assert!(!params.background);
939 }
940
941 #[test]
942 fn test_task_params_with_background() {
943 let json = r#"{
944 "agent": "general",
945 "description": "Long task",
946 "prompt": "Do something complex",
947 "background": true
948 }"#;
949
950 let params: TaskParams = serde_json::from_str(json).unwrap();
951 assert!(params.background);
952 }
953
954 #[test]
955 fn test_task_params_with_max_steps() {
956 let json = r#"{
957 "agent": "plan",
958 "description": "Planning task",
959 "prompt": "Create a plan",
960 "max_steps": 10
961 }"#;
962
963 let params: TaskParams = serde_json::from_str(json).unwrap();
964 assert_eq!(params.agent, "plan");
965 assert_eq!(params.max_steps, Some(10));
966 assert!(!params.background);
967 }
968
969 #[test]
970 fn test_task_params_all_fields() {
971 let json = r#"{
972 "agent": "general",
973 "description": "Complex task",
974 "prompt": "Do everything",
975 "background": true,
976 "max_steps": 20
977 }"#;
978
979 let params: TaskParams = serde_json::from_str(json).unwrap();
980 assert_eq!(params.agent, "general");
981 assert_eq!(params.description, "Complex task");
982 assert_eq!(params.prompt, "Do everything");
983 assert!(params.background);
984 assert_eq!(params.max_steps, Some(20));
985 }
986
987 #[test]
988 fn test_task_params_missing_required_field() {
989 let json = r#"{
990 "agent": "explore",
991 "description": "Missing prompt"
992 }"#;
993
994 let result: Result<TaskParams, _> = serde_json::from_str(json);
995 assert!(result.is_err());
996 }
997
998 #[test]
999 fn test_task_params_serialize() {
1000 let params = TaskParams {
1001 agent: "explore".to_string(),
1002 description: "Test task".to_string(),
1003 prompt: "Test prompt".to_string(),
1004 background: false,
1005 max_steps: Some(5),
1006 };
1007
1008 let json = serde_json::to_string(¶ms).unwrap();
1009 assert!(json.contains("explore"));
1010 assert!(json.contains("Test task"));
1011 assert!(json.contains("Test prompt"));
1012 }
1013
1014 #[test]
1015 fn test_task_params_clone() {
1016 let params = TaskParams {
1017 agent: "explore".to_string(),
1018 description: "Test".to_string(),
1019 prompt: "Prompt".to_string(),
1020 background: true,
1021 max_steps: None,
1022 };
1023
1024 let cloned = params.clone();
1025 assert_eq!(params.agent, cloned.agent);
1026 assert_eq!(params.description, cloned.description);
1027 assert_eq!(params.background, cloned.background);
1028 }
1029
1030 #[test]
1031 fn test_task_result_serialize() {
1032 let result = TaskResult {
1033 output: "Found 5 files".to_string(),
1034 session_id: "session-123".to_string(),
1035 agent: "explore".to_string(),
1036 success: true,
1037 task_id: "task-456".to_string(),
1038 };
1039
1040 let json = serde_json::to_string(&result).unwrap();
1041 assert!(json.contains("Found 5 files"));
1042 assert!(json.contains("explore"));
1043 }
1044
1045 #[test]
1046 fn test_task_result_deserialize() {
1047 let json = r#"{
1048 "output": "Task completed",
1049 "session_id": "sess-789",
1050 "agent": "general",
1051 "success": false,
1052 "task_id": "task-123"
1053 }"#;
1054
1055 let result: TaskResult = serde_json::from_str(json).unwrap();
1056 assert_eq!(result.output, "Task completed");
1057 assert_eq!(result.session_id, "sess-789");
1058 assert_eq!(result.agent, "general");
1059 assert!(!result.success);
1060 assert_eq!(result.task_id, "task-123");
1061 }
1062
1063 #[test]
1064 fn test_task_result_clone() {
1065 let result = TaskResult {
1066 output: "Output".to_string(),
1067 session_id: "session-1".to_string(),
1068 agent: "explore".to_string(),
1069 success: true,
1070 task_id: "task-1".to_string(),
1071 };
1072
1073 let cloned = result.clone();
1074 assert_eq!(result.output, cloned.output);
1075 assert_eq!(result.success, cloned.success);
1076 }
1077
1078 #[test]
1079 fn test_compact_task_output_preserves_small_output() {
1080 let (output, truncated) = compact_task_output("short result");
1081 assert_eq!(output, "short result");
1082 assert!(!truncated);
1083 }
1084
1085 #[test]
1086 fn test_format_task_result_for_context_truncates_large_output() {
1087 let result = TaskResult {
1088 output: format!("{}TAIL", "x".repeat(TASK_OUTPUT_CONTEXT_LIMIT + 500)),
1089 session_id: "session-1".to_string(),
1090 agent: "explore".to_string(),
1091 success: true,
1092 task_id: "task-1".to_string(),
1093 };
1094
1095 let (formatted, truncated) = format_task_result_for_context(&result);
1096 assert!(truncated);
1097 assert!(formatted.contains("Output excerpt"));
1098 assert!(formatted.contains("bytes omitted"));
1099 assert!(formatted.contains("Artifact ID: task-output:task-1"));
1100 assert!(formatted.contains("Artifact URI: a3s://tasks/session-1/runs/task-1/output"));
1101 assert!(formatted.contains("TAIL"));
1102 assert!(formatted.len() < result.output.len());
1103 }
1104
1105 #[test]
1106 fn test_task_artifact_reference_is_stable() {
1107 let result = TaskResult {
1108 output: "done".to_string(),
1109 session_id: "session-1".to_string(),
1110 agent: "explore".to_string(),
1111 success: true,
1112 task_id: "task-1".to_string(),
1113 };
1114
1115 assert_eq!(task_artifact_id(&result), "task-output:task-1");
1116 assert_eq!(
1117 task_artifact_uri(&result),
1118 "a3s://tasks/session-1/runs/task-1/output"
1119 );
1120
1121 let (formatted, truncated) = format_task_result_for_context(&result);
1122 assert!(!truncated);
1123 assert!(formatted.contains("Artifact URI: a3s://tasks/session-1/runs/task-1/output"));
1124 }
1125
1126 #[test]
1127 fn test_task_params_schema() {
1128 let schema = task_params_schema();
1129 assert_eq!(schema["type"], "object");
1130 assert_eq!(schema["additionalProperties"], false);
1131 assert!(schema["properties"]["agent"].is_object());
1132 assert!(schema["properties"]["prompt"].is_object());
1133 }
1134
1135 #[test]
1136 fn test_task_params_schema_required_fields() {
1137 let schema = task_params_schema();
1138 let required = schema["required"].as_array().unwrap();
1139 assert!(required.contains(&serde_json::json!("agent")));
1140 assert!(required.contains(&serde_json::json!("description")));
1141 assert!(required.contains(&serde_json::json!("prompt")));
1142 }
1143
1144 #[test]
1145 fn test_task_params_schema_properties() {
1146 let schema = task_params_schema();
1147 let props = &schema["properties"];
1148
1149 assert_eq!(props["agent"]["type"], "string");
1150 assert_eq!(props["description"]["type"], "string");
1151 assert_eq!(props["prompt"]["type"], "string");
1152 assert_eq!(props["background"]["type"], "boolean");
1153 assert_eq!(props["background"]["default"], false);
1154 assert_eq!(props["max_steps"]["type"], "integer");
1155 }
1156
1157 #[test]
1158 fn test_task_params_schema_descriptions() {
1159 let schema = task_params_schema();
1160 let props = &schema["properties"];
1161
1162 assert!(props["agent"]["description"].is_string());
1163 assert!(props["description"]["description"].is_string());
1164 assert!(props["prompt"]["description"].is_string());
1165 assert!(props["background"]["description"].is_string());
1166 assert!(props["max_steps"]["description"].is_string());
1167 }
1168
1169 #[test]
1170 fn test_task_params_default_background() {
1171 let params = TaskParams {
1172 agent: "explore".to_string(),
1173 description: "Test".to_string(),
1174 prompt: "Test prompt".to_string(),
1175 background: false,
1176 max_steps: None,
1177 };
1178 assert!(!params.background);
1179 }
1180
1181 #[test]
1182 fn test_task_params_serialize_skip_none() {
1183 let params = TaskParams {
1184 agent: "explore".to_string(),
1185 description: "Test".to_string(),
1186 prompt: "Test prompt".to_string(),
1187 background: false,
1188 max_steps: None,
1189 };
1190 let json = serde_json::to_string(¶ms).unwrap();
1191 assert!(!json.contains("max_steps"));
1193 }
1194
1195 #[test]
1196 fn test_task_params_serialize_with_max_steps() {
1197 let params = TaskParams {
1198 agent: "explore".to_string(),
1199 description: "Test".to_string(),
1200 prompt: "Test prompt".to_string(),
1201 background: false,
1202 max_steps: Some(15),
1203 };
1204 let json = serde_json::to_string(¶ms).unwrap();
1205 assert!(json.contains("max_steps"));
1206 assert!(json.contains("15"));
1207 }
1208
1209 #[test]
1210 fn test_task_result_success_true() {
1211 let result = TaskResult {
1212 output: "Success".to_string(),
1213 session_id: "sess-1".to_string(),
1214 agent: "explore".to_string(),
1215 success: true,
1216 task_id: "task-1".to_string(),
1217 };
1218 assert!(result.success);
1219 }
1220
1221 #[test]
1222 fn test_task_result_success_false() {
1223 let result = TaskResult {
1224 output: "Failed".to_string(),
1225 session_id: "sess-1".to_string(),
1226 agent: "explore".to_string(),
1227 success: false,
1228 task_id: "task-1".to_string(),
1229 };
1230 assert!(!result.success);
1231 }
1232
1233 #[test]
1234 fn test_task_params_empty_strings() {
1235 let params = TaskParams {
1236 agent: "".to_string(),
1237 description: "".to_string(),
1238 prompt: "".to_string(),
1239 background: false,
1240 max_steps: None,
1241 };
1242 let json = serde_json::to_string(¶ms).unwrap();
1243 let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1244 assert_eq!(deserialized.agent, "");
1245 assert_eq!(deserialized.description, "");
1246 assert_eq!(deserialized.prompt, "");
1247 }
1248
1249 #[test]
1250 fn test_task_result_empty_output() {
1251 let result = TaskResult {
1252 output: "".to_string(),
1253 session_id: "sess-1".to_string(),
1254 agent: "explore".to_string(),
1255 success: true,
1256 task_id: "task-1".to_string(),
1257 };
1258 assert_eq!(result.output, "");
1259 }
1260
1261 #[test]
1262 fn test_task_params_debug_format() {
1263 let params = TaskParams {
1264 agent: "explore".to_string(),
1265 description: "Test".to_string(),
1266 prompt: "Test prompt".to_string(),
1267 background: false,
1268 max_steps: None,
1269 };
1270 let debug_str = format!("{:?}", params);
1271 assert!(debug_str.contains("explore"));
1272 assert!(debug_str.contains("Test"));
1273 }
1274
1275 #[test]
1276 fn test_task_result_debug_format() {
1277 let result = TaskResult {
1278 output: "Output".to_string(),
1279 session_id: "sess-1".to_string(),
1280 agent: "explore".to_string(),
1281 success: true,
1282 task_id: "task-1".to_string(),
1283 };
1284 let debug_str = format!("{:?}", result);
1285 assert!(debug_str.contains("Output"));
1286 assert!(debug_str.contains("explore"));
1287 }
1288
1289 #[test]
1290 fn test_task_params_roundtrip() {
1291 let original = TaskParams {
1292 agent: "general".to_string(),
1293 description: "Roundtrip test".to_string(),
1294 prompt: "Test roundtrip serialization".to_string(),
1295 background: true,
1296 max_steps: Some(42),
1297 };
1298 let json = serde_json::to_string(&original).unwrap();
1299 let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1300 assert_eq!(original.agent, deserialized.agent);
1301 assert_eq!(original.description, deserialized.description);
1302 assert_eq!(original.prompt, deserialized.prompt);
1303 assert_eq!(original.background, deserialized.background);
1304 assert_eq!(original.max_steps, deserialized.max_steps);
1305 }
1306
1307 #[test]
1308 fn test_task_result_roundtrip() {
1309 let original = TaskResult {
1310 output: "Roundtrip output".to_string(),
1311 session_id: "sess-roundtrip".to_string(),
1312 agent: "plan".to_string(),
1313 success: false,
1314 task_id: "task-roundtrip".to_string(),
1315 };
1316 let json = serde_json::to_string(&original).unwrap();
1317 let deserialized: TaskResult = serde_json::from_str(&json).unwrap();
1318 assert_eq!(original.output, deserialized.output);
1319 assert_eq!(original.session_id, deserialized.session_id);
1320 assert_eq!(original.agent, deserialized.agent);
1321 assert_eq!(original.success, deserialized.success);
1322 assert_eq!(original.task_id, deserialized.task_id);
1323 }
1324
1325 #[test]
1326 fn test_parallel_task_params_deserialize() {
1327 let json = r#"{
1328 "tasks": [
1329 { "agent": "explore", "description": "Find auth", "prompt": "Search auth files" },
1330 { "agent": "general", "description": "Fix bug", "prompt": "Fix the login bug" }
1331 ]
1332 }"#;
1333
1334 let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1335 assert_eq!(params.tasks.len(), 2);
1336 assert_eq!(params.tasks[0].agent, "explore");
1337 assert_eq!(params.tasks[1].agent, "general");
1338 }
1339
1340 #[test]
1341 fn test_parallel_task_params_single_task() {
1342 let json = r#"{
1343 "tasks": [
1344 { "agent": "plan", "description": "Plan work", "prompt": "Create a plan" }
1345 ]
1346 }"#;
1347
1348 let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1349 assert_eq!(params.tasks.len(), 1);
1350 }
1351
1352 #[test]
1353 fn test_parallel_task_params_empty_tasks() {
1354 let json = r#"{ "tasks": [] }"#;
1355 let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1356 assert!(params.tasks.is_empty());
1357 }
1358
1359 #[test]
1360 fn test_parallel_task_params_missing_tasks() {
1361 let json = r#"{}"#;
1362 let result: Result<ParallelTaskParams, _> = serde_json::from_str(json);
1363 assert!(result.is_err());
1364 }
1365
1366 #[test]
1367 fn test_parallel_task_params_serialize() {
1368 let params = ParallelTaskParams {
1369 tasks: vec![
1370 TaskParams {
1371 agent: "explore".to_string(),
1372 description: "Task 1".to_string(),
1373 prompt: "Prompt 1".to_string(),
1374 background: false,
1375 max_steps: None,
1376 },
1377 TaskParams {
1378 agent: "general".to_string(),
1379 description: "Task 2".to_string(),
1380 prompt: "Prompt 2".to_string(),
1381 background: false,
1382 max_steps: Some(10),
1383 },
1384 ],
1385 };
1386 let json = serde_json::to_string(¶ms).unwrap();
1387 assert!(json.contains("explore"));
1388 assert!(json.contains("general"));
1389 assert!(json.contains("Prompt 1"));
1390 assert!(json.contains("Prompt 2"));
1391 }
1392
1393 #[test]
1394 fn test_parallel_task_params_roundtrip() {
1395 let original = ParallelTaskParams {
1396 tasks: vec![
1397 TaskParams {
1398 agent: "explore".to_string(),
1399 description: "Explore".to_string(),
1400 prompt: "Find files".to_string(),
1401 background: false,
1402 max_steps: None,
1403 },
1404 TaskParams {
1405 agent: "plan".to_string(),
1406 description: "Plan".to_string(),
1407 prompt: "Make plan".to_string(),
1408 background: false,
1409 max_steps: Some(5),
1410 },
1411 ],
1412 };
1413 let json = serde_json::to_string(&original).unwrap();
1414 let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1415 assert_eq!(original.tasks.len(), deserialized.tasks.len());
1416 assert_eq!(original.tasks[0].agent, deserialized.tasks[0].agent);
1417 assert_eq!(original.tasks[1].agent, deserialized.tasks[1].agent);
1418 assert_eq!(original.tasks[1].max_steps, deserialized.tasks[1].max_steps);
1419 }
1420
1421 #[test]
1422 fn test_parallel_task_params_clone() {
1423 let params = ParallelTaskParams {
1424 tasks: vec![TaskParams {
1425 agent: "explore".to_string(),
1426 description: "Test".to_string(),
1427 prompt: "Prompt".to_string(),
1428 background: false,
1429 max_steps: None,
1430 }],
1431 };
1432 let cloned = params.clone();
1433 assert_eq!(params.tasks.len(), cloned.tasks.len());
1434 assert_eq!(params.tasks[0].agent, cloned.tasks[0].agent);
1435 }
1436
1437 #[test]
1438 fn test_parallel_task_params_schema() {
1439 let schema = parallel_task_params_schema();
1440 assert_eq!(schema["type"], "object");
1441 assert_eq!(schema["additionalProperties"], false);
1442 assert!(schema["properties"]["tasks"].is_object());
1443 assert_eq!(schema["properties"]["tasks"]["type"], "array");
1444 assert_eq!(schema["properties"]["tasks"]["minItems"], 1);
1445 }
1446
1447 #[test]
1448 fn test_parallel_task_params_schema_required() {
1449 let schema = parallel_task_params_schema();
1450 let required = schema["required"].as_array().unwrap();
1451 assert!(required.contains(&serde_json::json!("tasks")));
1452 }
1453
1454 #[test]
1455 fn test_parallel_task_params_schema_items() {
1456 let schema = parallel_task_params_schema();
1457 let items = &schema["properties"]["tasks"]["items"];
1458 assert_eq!(items["type"], "object");
1459 assert_eq!(items["additionalProperties"], false);
1460 let item_required = items["required"].as_array().unwrap();
1461 assert!(item_required.contains(&serde_json::json!("agent")));
1462 assert!(item_required.contains(&serde_json::json!("description")));
1463 assert!(item_required.contains(&serde_json::json!("prompt")));
1464 }
1465
1466 #[test]
1467 fn test_task_schema_examples_use_delegation_core() {
1468 let task = task_params_schema();
1469 let task_examples = task["examples"].as_array().unwrap();
1470 assert_eq!(task_examples[0]["agent"], "explore");
1471 assert!(task_examples[0].get("task").is_none());
1472
1473 let parallel = parallel_task_params_schema();
1474 let parallel_examples = parallel["examples"].as_array().unwrap();
1475 assert!(!parallel_examples[0]["tasks"].as_array().unwrap().is_empty());
1476 }
1477
1478 #[test]
1479 fn test_parallel_task_params_debug() {
1480 let params = ParallelTaskParams {
1481 tasks: vec![TaskParams {
1482 agent: "explore".to_string(),
1483 description: "Debug test".to_string(),
1484 prompt: "Test".to_string(),
1485 background: false,
1486 max_steps: None,
1487 }],
1488 };
1489 let debug_str = format!("{:?}", params);
1490 assert!(debug_str.contains("explore"));
1491 assert!(debug_str.contains("Debug test"));
1492 }
1493
1494 #[test]
1495 fn test_parallel_task_params_large_count() {
1496 let tasks: Vec<TaskParams> = (0..150)
1498 .map(|i| TaskParams {
1499 agent: "explore".to_string(),
1500 description: format!("Task {}", i),
1501 prompt: format!("Prompt for task {}", i),
1502 background: false,
1503 max_steps: Some(10),
1504 })
1505 .collect();
1506
1507 let params = ParallelTaskParams { tasks };
1508 let json = serde_json::to_string(¶ms).unwrap();
1509 let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1510 assert_eq!(deserialized.tasks.len(), 150);
1511 assert_eq!(deserialized.tasks[0].description, "Task 0");
1512 assert_eq!(deserialized.tasks[149].description, "Task 149");
1513 }
1514
1515 #[test]
1516 fn test_task_params_max_steps_zero() {
1517 let params = TaskParams {
1519 agent: "explore".to_string(),
1520 description: "Edge case".to_string(),
1521 prompt: "Zero steps".to_string(),
1522 background: false,
1523 max_steps: Some(0),
1524 };
1525 let json = serde_json::to_string(¶ms).unwrap();
1526 let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1527 assert_eq!(deserialized.max_steps, Some(0));
1528 }
1529
1530 #[test]
1531 fn test_parallel_task_params_all_background() {
1532 let tasks: Vec<TaskParams> = (0..5)
1533 .map(|i| TaskParams {
1534 agent: "general".to_string(),
1535 description: format!("BG task {}", i),
1536 prompt: "Run in background".to_string(),
1537 background: true,
1538 max_steps: None,
1539 })
1540 .collect();
1541 let params = ParallelTaskParams { tasks };
1542 for task in ¶ms.tasks {
1543 assert!(task.background);
1544 }
1545 }
1546
1547 #[test]
1548 fn test_task_params_rejects_permissive_field() {
1549 let json = r#"{
1550 "agent": "general",
1551 "description": "Legacy field rejection",
1552 "prompt": "Verify legacy fields are rejected",
1553 "permissive": true
1554 }"#;
1555
1556 let result: Result<TaskParams, _> = serde_json::from_str(json);
1557 assert!(result.is_err());
1558 }
1559
1560 #[test]
1561 fn test_task_params_schema_hides_permissive_field() {
1562 let schema = task_params_schema();
1563 let props = &schema["properties"];
1564
1565 assert!(props.get("permissive").is_none());
1566 }
1567
1568 use crate::agent::tests::MockLlmClient;
1573 use crate::llm::{ContentBlock, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition};
1574 use crate::permissions::PermissionPolicy;
1575 use crate::subagent::AgentRegistry;
1576 use std::sync::atomic::{AtomicUsize, Ordering};
1577 use std::time::Duration;
1578 use tokio::sync::{mpsc, Barrier};
1579
1580 fn text_response(text: impl Into<String>) -> LlmResponse {
1581 LlmResponse {
1582 message: Message {
1583 role: "assistant".to_string(),
1584 content: vec![ContentBlock::Text { text: text.into() }],
1585 reasoning_content: None,
1586 },
1587 usage: TokenUsage {
1588 prompt_tokens: 10,
1589 completion_tokens: 5,
1590 total_tokens: 15,
1591 cache_read_tokens: None,
1592 cache_write_tokens: None,
1593 },
1594 stop_reason: Some("end_turn".to_string()),
1595 token_logprobs: Vec::new(),
1596 meta: None,
1597 }
1598 }
1599
1600 fn pre_analysis_response(messages: &[Message]) -> LlmResponse {
1601 let prompt = last_text(messages);
1602 let response = serde_json::json!({
1603 "intent": "GeneralPurpose",
1604 "requires_planning": false,
1605 "goal": {
1606 "description": prompt,
1607 "success_criteria": []
1608 },
1609 "execution_plan": {
1610 "complexity": "Simple",
1611 "steps": [{
1612 "id": "step-1",
1613 "description": prompt,
1614 "tool": null,
1615 "dependencies": [],
1616 "success_criteria": "Complete the request"
1617 }],
1618 "required_tools": []
1619 },
1620 "optimized_input": prompt
1621 });
1622 text_response(response.to_string())
1623 }
1624
1625 fn last_text(messages: &[Message]) -> String {
1626 messages
1627 .last()
1628 .and_then(|message| {
1629 message.content.iter().find_map(|block| {
1630 if let ContentBlock::Text { text } = block {
1631 Some(text.clone())
1632 } else {
1633 None
1634 }
1635 })
1636 })
1637 .unwrap_or_default()
1638 }
1639
1640 struct SchemaCoercionClient;
1645
1646 #[async_trait::async_trait]
1647 impl LlmClient for SchemaCoercionClient {
1648 async fn complete(
1649 &self,
1650 messages: &[Message],
1651 system: Option<&str>,
1652 tools: &[ToolDefinition],
1653 ) -> Result<LlmResponse> {
1654 if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1655 return Ok(pre_analysis_response(messages));
1656 }
1657 if tools.iter().any(|t| t.name == "emit_step_output") {
1660 return Ok(MockLlmClient::tool_call_response(
1661 "coerce-1",
1662 "emit_step_output",
1663 serde_json::json!({ "verdict": "ok" }),
1664 ));
1665 }
1666 Ok(text_response("The verdict is ok."))
1667 }
1668
1669 async fn complete_streaming(
1670 &self,
1671 _messages: &[Message],
1672 _system: Option<&str>,
1673 _tools: &[ToolDefinition],
1674 _cancel_token: tokio_util::sync::CancellationToken,
1675 ) -> Result<mpsc::Receiver<StreamEvent>> {
1676 anyhow::bail!("streaming is not used by schema coercion tests")
1677 }
1678 }
1679
1680 fn verdict_schema() -> serde_json::Value {
1681 serde_json::json!({
1682 "type": "object",
1683 "properties": { "verdict": { "type": "string" } },
1684 "required": ["verdict"]
1685 })
1686 }
1687
1688 #[tokio::test]
1689 async fn execute_step_with_schema_coerces_structured_output() {
1690 let workspace = tempfile::tempdir().unwrap();
1691 let executor = TaskExecutor::new(
1692 Arc::new(AgentRegistry::new()),
1693 Arc::new(SchemaCoercionClient),
1694 workspace.path().to_string_lossy().to_string(),
1695 );
1696 let spec = AgentStepSpec::new("step-1", "general", "assess", "Assess the thing.")
1697 .with_output_schema(verdict_schema());
1698
1699 let outcome = executor.execute_step(spec, None).await;
1700
1701 assert!(outcome.success, "step should succeed: {}", outcome.output);
1702 assert_eq!(
1703 outcome.structured,
1704 Some(serde_json::json!({ "verdict": "ok" })),
1705 "a schema'd step returns the validated object in `structured`"
1706 );
1707 }
1708
1709 #[tokio::test]
1710 async fn execute_step_without_schema_has_no_structured_output() {
1711 let workspace = tempfile::tempdir().unwrap();
1712 let executor = TaskExecutor::new(
1713 Arc::new(AgentRegistry::new()),
1714 Arc::new(SchemaCoercionClient),
1715 workspace.path().to_string_lossy().to_string(),
1716 );
1717 let spec = AgentStepSpec::new("step-2", "general", "assess", "Assess the thing.");
1718
1719 let outcome = executor.execute_step(spec, None).await;
1720
1721 assert!(outcome.success, "step should succeed: {}", outcome.output);
1722 assert_eq!(
1723 outcome.structured, None,
1724 "no schema requested → no structured output, no coercion call"
1725 );
1726 }
1727
1728 struct SchemaFailClient;
1732
1733 #[async_trait::async_trait]
1734 impl LlmClient for SchemaFailClient {
1735 async fn complete(
1736 &self,
1737 messages: &[Message],
1738 system: Option<&str>,
1739 tools: &[ToolDefinition],
1740 ) -> Result<LlmResponse> {
1741 if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1742 return Ok(pre_analysis_response(messages));
1743 }
1744 if tools.iter().any(|t| t.name == "emit_step_output") {
1745 return Ok(MockLlmClient::tool_call_response(
1747 "coerce-fail",
1748 "emit_step_output",
1749 serde_json::json!({}),
1750 ));
1751 }
1752 Ok(text_response("some answer"))
1753 }
1754
1755 async fn complete_streaming(
1756 &self,
1757 _messages: &[Message],
1758 _system: Option<&str>,
1759 _tools: &[ToolDefinition],
1760 _cancel_token: tokio_util::sync::CancellationToken,
1761 ) -> Result<mpsc::Receiver<StreamEvent>> {
1762 anyhow::bail!("streaming unused")
1763 }
1764 }
1765
1766 #[tokio::test]
1767 async fn execute_step_with_schema_demotes_step_on_coercion_failure() {
1768 let workspace = tempfile::tempdir().unwrap();
1769 let executor = TaskExecutor::new(
1770 Arc::new(AgentRegistry::new()),
1771 Arc::new(SchemaFailClient),
1772 workspace.path().to_string_lossy().to_string(),
1773 );
1774 let spec = AgentStepSpec::new("step-x", "general", "assess", "Assess the thing.")
1775 .with_output_schema(verdict_schema());
1776
1777 let outcome = executor.execute_step(spec, None).await;
1778
1779 assert!(
1780 !outcome.success,
1781 "a step whose output can't satisfy the schema is demoted to failure"
1782 );
1783 assert_eq!(outcome.structured, None, "no validated object on failure");
1784 assert!(
1785 outcome.output.contains("[structured output failed"),
1786 "the demotion marker is appended: {}",
1787 outcome.output
1788 );
1789 }
1790
1791 #[tokio::test]
1792 async fn parallel_isolates_schema_coercion_failure_from_sibling() {
1793 let workspace = tempfile::tempdir().unwrap();
1794 let executor: Arc<dyn AgentExecutor> = Arc::new(TaskExecutor::new(
1795 Arc::new(AgentRegistry::new()),
1796 Arc::new(SchemaFailClient),
1797 workspace.path().to_string_lossy().to_string(),
1798 ));
1799 let specs = vec![
1802 AgentStepSpec::new("plain", "general", "d", "p"),
1803 AgentStepSpec::new("schemad", "general", "d", "p").with_output_schema(verdict_schema()),
1804 ];
1805 let out = crate::orchestration::execute_steps_parallel(executor, specs, None).await;
1806
1807 assert_eq!(out.len(), 2);
1808 assert_eq!(out[0].task_id, "plain");
1809 assert!(out[0].success, "no-schema sibling unaffected");
1810 assert_eq!(out[0].structured, None);
1811 assert_eq!(out[1].task_id, "schemad");
1812 assert!(!out[1].success, "schema-failing step surfaces as failure");
1813 assert_eq!(out[1].structured, None);
1814 assert!(out[1].output.contains("[structured output failed"));
1815 }
1816
1817 #[tokio::test]
1818 async fn failed_step_with_schema_skips_coercion() {
1819 let workspace = tempfile::tempdir().unwrap();
1820 let executor = TaskExecutor::new(
1821 Arc::new(AgentRegistry::new()),
1822 Arc::new(SchemaCoercionClient),
1823 workspace.path().to_string_lossy().to_string(),
1824 );
1825 let spec = AgentStepSpec::new("step-y", "no-such-agent", "d", "p")
1828 .with_output_schema(verdict_schema());
1829
1830 let outcome = executor.execute_step(spec, None).await;
1831
1832 assert!(!outcome.success);
1833 assert_eq!(outcome.structured, None);
1834 assert!(
1835 !outcome.output.contains("[structured output failed"),
1836 "coercion never ran — failure is the run error, not a coercion failure: {}",
1837 outcome.output
1838 );
1839 }
1840
1841 struct StaticLlmClient {
1842 text: String,
1843 }
1844
1845 impl StaticLlmClient {
1846 fn new(text: impl Into<String>) -> Self {
1847 Self { text: text.into() }
1848 }
1849 }
1850
1851 #[async_trait::async_trait]
1852 impl LlmClient for StaticLlmClient {
1853 async fn complete(
1854 &self,
1855 messages: &[Message],
1856 system: Option<&str>,
1857 _tools: &[ToolDefinition],
1858 ) -> Result<LlmResponse> {
1859 if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1860 return Ok(pre_analysis_response(messages));
1861 }
1862 Ok(text_response(self.text.clone()))
1863 }
1864
1865 async fn complete_streaming(
1866 &self,
1867 _messages: &[Message],
1868 _system: Option<&str>,
1869 _tools: &[ToolDefinition],
1870 _cancel_token: tokio_util::sync::CancellationToken,
1871 ) -> Result<mpsc::Receiver<StreamEvent>> {
1872 anyhow::bail!("streaming is not used by task executor tests")
1873 }
1874 }
1875
1876 struct ConcurrentLlmClient {
1877 barrier: Arc<Barrier>,
1878 active: AtomicUsize,
1879 max_active: AtomicUsize,
1880 }
1881
1882 impl ConcurrentLlmClient {
1883 fn new(task_count: usize) -> Self {
1884 Self {
1885 barrier: Arc::new(Barrier::new(task_count)),
1886 active: AtomicUsize::new(0),
1887 max_active: AtomicUsize::new(0),
1888 }
1889 }
1890
1891 fn max_active(&self) -> usize {
1892 self.max_active.load(Ordering::SeqCst)
1893 }
1894
1895 fn record_active(&self) {
1896 let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
1897 let mut observed = self.max_active.load(Ordering::SeqCst);
1898 while active > observed {
1899 match self.max_active.compare_exchange(
1900 observed,
1901 active,
1902 Ordering::SeqCst,
1903 Ordering::SeqCst,
1904 ) {
1905 Ok(_) => break,
1906 Err(next) => observed = next,
1907 }
1908 }
1909 }
1910 }
1911
1912 struct LimitedConcurrencyLlmClient {
1913 active: AtomicUsize,
1914 max_active: AtomicUsize,
1915 }
1916
1917 impl LimitedConcurrencyLlmClient {
1918 fn new() -> Self {
1919 Self {
1920 active: AtomicUsize::new(0),
1921 max_active: AtomicUsize::new(0),
1922 }
1923 }
1924
1925 fn max_active(&self) -> usize {
1926 self.max_active.load(Ordering::SeqCst)
1927 }
1928
1929 fn record_active(&self) {
1930 let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
1931 self.max_active.fetch_max(active, Ordering::SeqCst);
1932 }
1933 }
1934
1935 #[async_trait::async_trait]
1936 impl LlmClient for LimitedConcurrencyLlmClient {
1937 async fn complete(
1938 &self,
1939 messages: &[Message],
1940 system: Option<&str>,
1941 _tools: &[ToolDefinition],
1942 ) -> Result<LlmResponse> {
1943 if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1944 return Ok(pre_analysis_response(messages));
1945 }
1946
1947 let prompt = last_text(messages);
1948 self.record_active();
1949 tokio::time::sleep(Duration::from_millis(40)).await;
1950 self.active.fetch_sub(1, Ordering::SeqCst);
1951 Ok(text_response(format!("completed: {prompt}")))
1952 }
1953
1954 async fn complete_streaming(
1955 &self,
1956 _messages: &[Message],
1957 _system: Option<&str>,
1958 _tools: &[ToolDefinition],
1959 _cancel_token: tokio_util::sync::CancellationToken,
1960 ) -> Result<mpsc::Receiver<StreamEvent>> {
1961 anyhow::bail!("streaming is not used by task executor tests")
1962 }
1963 }
1964
1965 #[async_trait::async_trait]
1966 impl LlmClient for ConcurrentLlmClient {
1967 async fn complete(
1968 &self,
1969 messages: &[Message],
1970 system: Option<&str>,
1971 _tools: &[ToolDefinition],
1972 ) -> Result<LlmResponse> {
1973 if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1974 return Ok(pre_analysis_response(messages));
1975 }
1976
1977 let prompt = last_text(messages);
1978 self.record_active();
1979 self.barrier.wait().await;
1980 if prompt.contains("slow") {
1981 tokio::time::sleep(Duration::from_millis(120)).await;
1982 } else {
1983 tokio::time::sleep(Duration::from_millis(10)).await;
1984 }
1985 self.active.fetch_sub(1, Ordering::SeqCst);
1986 Ok(text_response(format!("completed: {prompt}")))
1987 }
1988
1989 async fn complete_streaming(
1990 &self,
1991 _messages: &[Message],
1992 _system: Option<&str>,
1993 _tools: &[ToolDefinition],
1994 _cancel_token: tokio_util::sync::CancellationToken,
1995 ) -> Result<mpsc::Receiver<StreamEvent>> {
1996 anyhow::bail!("streaming is not used by task executor tests")
1997 }
1998 }
1999
2000 fn test_registry_with_writer() -> Arc<AgentRegistry> {
2001 let registry = AgentRegistry::new();
2002 let spec = crate::subagent::WorkerAgentSpec::custom("writer", "Write files")
2003 .with_permissions(PermissionPolicy::new().allow("write(*)").allow("read(*)"))
2004 .with_prompt("Write files when asked.")
2005 .with_max_steps(3);
2006 registry.register(spec.into_agent_definition());
2007 Arc::new(registry)
2008 }
2009
2010 fn test_registry_with_text_worker() -> Arc<AgentRegistry> {
2011 let registry = AgentRegistry::new();
2012 let spec = crate::subagent::WorkerAgentSpec::custom("worker", "Text worker")
2013 .with_prompt("Return a concise result.")
2014 .with_max_steps(1);
2015 registry.register(spec.into_agent_definition());
2016 Arc::new(registry)
2017 }
2018
2019 #[tokio::test]
2020 async fn task_child_run_permission_allow() {
2021 let workspace = tempfile::tempdir().unwrap();
2022 let mock = Arc::new(MockLlmClient::new(vec![
2023 MockLlmClient::tool_call_response(
2024 "t1",
2025 "write",
2026 serde_json::json!({
2027 "file_path": workspace.path().join("out.txt").to_string_lossy(),
2028 "content": "WRITTEN"
2029 }),
2030 ),
2031 MockLlmClient::text_response("Done."),
2032 ]));
2033
2034 let executor = TaskExecutor::new(
2035 test_registry_with_writer(),
2036 mock,
2037 workspace.path().to_string_lossy().to_string(),
2038 );
2039
2040 let result = executor
2041 .execute(
2042 TaskParams {
2043 agent: "writer".to_string(),
2044 description: "Write file".to_string(),
2045 prompt: "Write out.txt".to_string(),
2046 background: false,
2047 max_steps: Some(3),
2048 },
2049 None,
2050 None,
2051 )
2052 .await
2053 .unwrap();
2054
2055 assert!(
2056 result.success,
2057 "child run should succeed: {}",
2058 result.output
2059 );
2060 assert!(
2061 !result.output.contains("Permission denied"),
2062 "no permission denial: {}",
2063 result.output
2064 );
2065 let content = std::fs::read_to_string(workspace.path().join("out.txt")).unwrap();
2066 assert_eq!(content, "WRITTEN");
2067 }
2068
2069 #[tokio::test]
2070 async fn task_child_run_permission_deny() {
2071 let workspace = tempfile::tempdir().unwrap();
2072 let registry = AgentRegistry::new();
2073 let spec = crate::subagent::WorkerAgentSpec::custom("restricted", "Restricted agent")
2074 .with_permissions(PermissionPolicy::new().allow("read(*)").deny("bash(*)"))
2075 .with_max_steps(3);
2076 registry.register(spec.into_agent_definition());
2077
2078 let mock = Arc::new(MockLlmClient::new(vec![
2079 MockLlmClient::tool_call_response(
2080 "t1",
2081 "bash",
2082 serde_json::json!({"command": "echo hello"}),
2083 ),
2084 MockLlmClient::text_response("Could not run bash."),
2085 ]));
2086
2087 let executor = TaskExecutor::new(
2088 Arc::new(registry),
2089 mock,
2090 workspace.path().to_string_lossy().to_string(),
2091 );
2092
2093 let result = executor
2094 .execute(
2095 TaskParams {
2096 agent: "restricted".to_string(),
2097 description: "Try bash".to_string(),
2098 prompt: "Run echo hello".to_string(),
2099 background: false,
2100 max_steps: Some(3),
2101 },
2102 None,
2103 None,
2104 )
2105 .await
2106 .unwrap();
2107
2108 assert!(result.success, "agent should complete: {}", result.output);
2111 }
2112
2113 #[tokio::test]
2114 async fn task_child_run_confirmation_auto_approve() {
2115 let workspace = tempfile::tempdir().unwrap();
2116 let registry = AgentRegistry::new();
2117 let spec = crate::subagent::WorkerAgentSpec::custom("reader-writer", "Read and write")
2120 .with_permissions(PermissionPolicy::new().allow("read(*)"))
2121 .with_max_steps(3);
2122 registry.register(spec.into_agent_definition());
2123
2124 let mock = Arc::new(MockLlmClient::new(vec![
2125 MockLlmClient::tool_call_response(
2126 "t1",
2127 "write",
2128 serde_json::json!({
2129 "file_path": workspace.path().join("auto.txt").to_string_lossy(),
2130 "content": "AUTO_APPROVED"
2131 }),
2132 ),
2133 MockLlmClient::text_response("Written."),
2134 ]));
2135
2136 let executor = TaskExecutor::new(
2137 Arc::new(registry),
2138 mock,
2139 workspace.path().to_string_lossy().to_string(),
2140 );
2141
2142 let result = executor
2143 .execute(
2144 TaskParams {
2145 agent: "reader-writer".to_string(),
2146 description: "Write via auto-approve".to_string(),
2147 prompt: "Write auto.txt".to_string(),
2148 background: false,
2149 max_steps: Some(3),
2150 },
2151 None,
2152 None,
2153 )
2154 .await
2155 .unwrap();
2156
2157 assert!(
2158 result.success,
2159 "Ask should be auto-approved: {}",
2160 result.output
2161 );
2162 assert!(
2163 !result.output.contains("MissingConfirmationManager"),
2164 "no MissingConfirmationManager: {}",
2165 result.output
2166 );
2167 }
2168
2169 #[tokio::test]
2170 async fn task_child_run_step_budget_enforced() {
2171 let workspace = tempfile::tempdir().unwrap();
2172 let mock = Arc::new(MockLlmClient::new(vec![
2173 MockLlmClient::tool_call_response(
2174 "t1",
2175 "read",
2176 serde_json::json!({"file_path": "/tmp/a.txt"}),
2177 ),
2178 MockLlmClient::tool_call_response(
2179 "t2",
2180 "read",
2181 serde_json::json!({"file_path": "/tmp/b.txt"}),
2182 ),
2183 MockLlmClient::tool_call_response(
2184 "t3",
2185 "read",
2186 serde_json::json!({"file_path": "/tmp/c.txt"}),
2187 ),
2188 MockLlmClient::text_response("Should not reach here."),
2189 ]));
2190
2191 let executor = TaskExecutor::new(
2192 test_registry_with_writer(),
2193 mock,
2194 workspace.path().to_string_lossy().to_string(),
2195 );
2196
2197 let result = executor
2198 .execute(
2199 TaskParams {
2200 agent: "writer".to_string(),
2201 description: "Exceed budget".to_string(),
2202 prompt: "Read many files".to_string(),
2203 background: false,
2204 max_steps: Some(2),
2205 },
2206 None,
2207 None,
2208 )
2209 .await
2210 .unwrap();
2211
2212 assert!(
2214 !result.success,
2215 "should fail when exceeding step budget: {}",
2216 result.output
2217 );
2218 assert!(
2219 result.output.contains("Max tool rounds") || result.output.contains("max tool rounds"),
2220 "error should mention tool rounds: {}",
2221 result.output
2222 );
2223 }
2224
2225 #[tokio::test]
2226 async fn parallel_task_executor_runs_children_concurrently_and_preserves_input_order() {
2227 let workspace = tempfile::tempdir().unwrap();
2228 let client = Arc::new(ConcurrentLlmClient::new(2));
2229 let executor = Arc::new(TaskExecutor::new(
2230 test_registry_with_text_worker(),
2231 client.clone(),
2232 workspace.path().to_string_lossy().to_string(),
2233 ));
2234
2235 let tasks = vec![
2236 TaskParams {
2237 agent: "worker".to_string(),
2238 description: "Slow task".to_string(),
2239 prompt: "slow branch".to_string(),
2240 background: false,
2241 max_steps: Some(1),
2242 },
2243 TaskParams {
2244 agent: "worker".to_string(),
2245 description: "Fast task".to_string(),
2246 prompt: "fast branch".to_string(),
2247 background: false,
2248 max_steps: Some(1),
2249 },
2250 ];
2251
2252 let results = tokio::time::timeout(
2253 Duration::from_secs(2),
2254 executor.execute_parallel(tasks, None, None),
2255 )
2256 .await
2257 .expect("parallel children should reach the barrier and complete");
2258
2259 assert_eq!(results.len(), 2);
2260 assert!(
2261 client.max_active() >= 2,
2262 "expected concurrent child execution, max_active={}",
2263 client.max_active()
2264 );
2265 assert!(results[0].success);
2266 assert!(results[0].output.contains("slow branch"));
2267 assert!(results[1].success);
2268 assert!(results[1].output.contains("fast branch"));
2269 }
2270
2271 #[tokio::test]
2272 async fn parallel_task_executor_respects_configured_concurrency_limit() {
2273 let workspace = tempfile::tempdir().unwrap();
2274 let client = Arc::new(LimitedConcurrencyLlmClient::new());
2275 let executor = Arc::new(
2276 TaskExecutor::new(
2277 test_registry_with_text_worker(),
2278 client.clone(),
2279 workspace.path().to_string_lossy().to_string(),
2280 )
2281 .with_max_parallel_tasks(2),
2282 );
2283
2284 let tasks = (0..5)
2285 .map(|idx| TaskParams {
2286 agent: "worker".to_string(),
2287 description: format!("Task {idx}"),
2288 prompt: format!("branch {idx}"),
2289 background: false,
2290 max_steps: Some(1),
2291 })
2292 .collect::<Vec<_>>();
2293
2294 let results = executor.execute_parallel(tasks, None, None).await;
2295
2296 assert_eq!(results.len(), 5);
2297 assert!(results.iter().all(|result| result.success));
2298 assert_eq!(client.max_active(), 2);
2299 }
2300
2301 #[tokio::test]
2302 async fn parallel_task_executor_isolates_unknown_agent_failure() {
2303 let workspace = tempfile::tempdir().unwrap();
2304 let executor = Arc::new(TaskExecutor::new(
2305 test_registry_with_text_worker(),
2306 Arc::new(StaticLlmClient::new("valid branch done")),
2307 workspace.path().to_string_lossy().to_string(),
2308 ));
2309
2310 let tasks = vec![
2311 TaskParams {
2312 agent: "missing-agent".to_string(),
2313 description: "Missing".to_string(),
2314 prompt: "should fail".to_string(),
2315 background: false,
2316 max_steps: Some(1),
2317 },
2318 TaskParams {
2319 agent: "worker".to_string(),
2320 description: "Valid".to_string(),
2321 prompt: "should succeed".to_string(),
2322 background: false,
2323 max_steps: Some(1),
2324 },
2325 ];
2326
2327 let results = executor.execute_parallel(tasks, None, None).await;
2328
2329 assert_eq!(results.len(), 2);
2330 assert!(!results[0].success);
2331 assert_eq!(results[0].agent, "missing-agent");
2332 assert!(results[0].output.contains("Unknown agent type"));
2333 assert!(results[1].success);
2334 assert_eq!(results[1].agent, "worker");
2335 assert!(results[1].output.contains("valid branch done"));
2336 }
2337
2338 #[tokio::test]
2339 async fn parallel_task_executor_emits_subagent_events_for_each_child() {
2340 let workspace = tempfile::tempdir().unwrap();
2341 let executor = Arc::new(TaskExecutor::new(
2342 test_registry_with_text_worker(),
2343 Arc::new(StaticLlmClient::new("done")),
2344 workspace.path().to_string_lossy().to_string(),
2345 ));
2346 let (tx, mut rx) = broadcast::channel(64);
2347
2348 let tasks = vec![
2349 TaskParams {
2350 agent: "worker".to_string(),
2351 description: "One".to_string(),
2352 prompt: "first".to_string(),
2353 background: false,
2354 max_steps: Some(1),
2355 },
2356 TaskParams {
2357 agent: "worker".to_string(),
2358 description: "Two".to_string(),
2359 prompt: "second".to_string(),
2360 background: false,
2361 max_steps: Some(1),
2362 },
2363 ];
2364
2365 let results = executor.execute_parallel(tasks, Some(tx), None).await;
2366 assert_eq!(results.len(), 2);
2367 tokio::time::sleep(Duration::from_millis(20)).await;
2368
2369 let mut starts = Vec::new();
2370 let mut ends = Vec::new();
2371 let mut progress_statuses: Vec<String> = Vec::new();
2372 while let Ok(event) = rx.try_recv() {
2373 match event {
2374 AgentEvent::SubagentStart { description, .. } => starts.push(description),
2375 AgentEvent::SubagentEnd { agent, success, .. } => ends.push((agent, success)),
2376 AgentEvent::SubagentProgress { status, .. } => progress_statuses.push(status),
2377 _ => {}
2378 }
2379 }
2380
2381 starts.sort();
2382 assert_eq!(starts, vec!["One".to_string(), "Two".to_string()]);
2383 assert_eq!(ends.len(), 2);
2384 assert!(ends
2385 .iter()
2386 .all(|(agent, success)| agent == "worker" && *success));
2387 assert!(
2390 progress_statuses
2391 .iter()
2392 .filter(|s| s == &"turn_completed")
2393 .count()
2394 >= 2,
2395 "expected at least two turn_completed progress events, got {:?}",
2396 progress_statuses
2397 );
2398 }
2399
2400 #[tokio::test]
2401 async fn parallel_task_tool_reports_error_when_any_child_fails() {
2402 let workspace = tempfile::tempdir().unwrap();
2403 let executor = Arc::new(TaskExecutor::new(
2404 test_registry_with_text_worker(),
2405 Arc::new(StaticLlmClient::new("valid branch done")),
2406 workspace.path().to_string_lossy().to_string(),
2407 ));
2408 let tool = ParallelTaskTool::new(executor);
2409 let ctx = ToolContext::new(workspace.path().to_path_buf());
2410
2411 let output = tool
2412 .execute(
2413 &serde_json::json!({
2414 "tasks": [
2415 {
2416 "agent": "missing-agent",
2417 "description": "Missing",
2418 "prompt": "should fail"
2419 },
2420 {
2421 "agent": "worker",
2422 "description": "Valid",
2423 "prompt": "should succeed"
2424 }
2425 ]
2426 }),
2427 &ctx,
2428 )
2429 .await
2430 .unwrap();
2431
2432 assert!(
2433 !output.success,
2434 "parallel_task should fail when any child result fails"
2435 );
2436 assert!(output.content.contains("[ERR]"));
2437 assert!(output.content.contains("[OK]"));
2438 let metadata = output.metadata.expect("metadata");
2439 assert_eq!(metadata["task_count"], 2);
2440 assert_eq!(metadata["results"][0]["success"], false);
2441 assert_eq!(metadata["results"][1]["success"], true);
2442 }
2443
2444 #[tokio::test]
2445 async fn parallel_task_both_inherit_permissions() {
2446 let workspace = tempfile::tempdir().unwrap();
2447 let mock = Arc::new(MockLlmClient::new(vec![
2448 MockLlmClient::tool_call_response(
2450 "t1",
2451 "write",
2452 serde_json::json!({
2453 "file_path": workspace.path().join("p1.txt").to_string_lossy(),
2454 "content": "P1"
2455 }),
2456 ),
2457 MockLlmClient::text_response("Done 1."),
2458 MockLlmClient::tool_call_response(
2460 "t2",
2461 "write",
2462 serde_json::json!({
2463 "file_path": workspace.path().join("p2.txt").to_string_lossy(),
2464 "content": "P2"
2465 }),
2466 ),
2467 MockLlmClient::text_response("Done 2."),
2468 ]));
2469
2470 let executor = Arc::new(TaskExecutor::new(
2471 test_registry_with_writer(),
2472 mock,
2473 workspace.path().to_string_lossy().to_string(),
2474 ));
2475
2476 let tasks = vec![
2477 TaskParams {
2478 agent: "writer".to_string(),
2479 description: "Write p1".to_string(),
2480 prompt: "Write p1.txt".to_string(),
2481 background: false,
2482 max_steps: Some(3),
2483 },
2484 TaskParams {
2485 agent: "writer".to_string(),
2486 description: "Write p2".to_string(),
2487 prompt: "Write p2.txt".to_string(),
2488 background: false,
2489 max_steps: Some(3),
2490 },
2491 ];
2492
2493 let results = executor.execute_parallel(tasks, None, None).await;
2494 assert_eq!(results.len(), 2);
2495
2496 for result in &results {
2497 assert!(
2498 result.success,
2499 "parallel child should succeed: {}",
2500 result.output
2501 );
2502 }
2503 }
2504
2505 #[test]
2506 fn synthesize_progress_emits_tool_completed_for_tool_end() {
2507 let event = AgentEvent::ToolEnd {
2508 id: "call-1".to_string(),
2509 name: "bash".to_string(),
2510 output: "hello".to_string(),
2511 exit_code: 0,
2512 metadata: None,
2513 error_kind: None,
2514 };
2515 let progress =
2516 synthesize_subagent_progress(&event, "task-1", "task-run-task-1").expect("some");
2517 match progress {
2518 AgentEvent::SubagentProgress {
2519 task_id,
2520 session_id,
2521 status,
2522 metadata,
2523 } => {
2524 assert_eq!(task_id, "task-1");
2525 assert_eq!(session_id, "task-run-task-1");
2526 assert_eq!(status, "tool_completed");
2527 assert_eq!(metadata["tool"], "bash");
2528 assert_eq!(metadata["exit_code"], 0);
2529 assert_eq!(metadata["output_bytes"], 5);
2530 assert!(metadata.get("error_kind").is_none());
2531 }
2532 other => panic!("expected SubagentProgress, got {:?}", other),
2533 }
2534 }
2535
2536 #[test]
2537 fn synthesize_progress_includes_error_kind_when_present() {
2538 let event = AgentEvent::ToolEnd {
2539 id: "call-2".to_string(),
2540 name: "edit".to_string(),
2541 output: "boom".to_string(),
2542 exit_code: 1,
2543 metadata: None,
2544 error_kind: Some(crate::tools::ToolErrorKind::NotFound {
2545 path: "missing.txt".to_string(),
2546 }),
2547 };
2548 let progress =
2549 synthesize_subagent_progress(&event, "task-x", "task-run-task-x").expect("some");
2550 if let AgentEvent::SubagentProgress { metadata, .. } = progress {
2551 assert!(
2552 metadata.get("error_kind").is_some(),
2553 "error_kind should propagate into metadata"
2554 );
2555 } else {
2556 panic!("expected SubagentProgress");
2557 }
2558 }
2559
2560 #[test]
2561 fn synthesize_progress_emits_turn_completed_for_turn_end() {
2562 let event = AgentEvent::TurnEnd {
2563 turn: 3,
2564 usage: crate::llm::TokenUsage {
2565 prompt_tokens: 100,
2566 completion_tokens: 25,
2567 total_tokens: 125,
2568 cache_read_tokens: None,
2569 cache_write_tokens: None,
2570 },
2571 };
2572 let progress =
2573 synthesize_subagent_progress(&event, "task-1", "task-run-task-1").expect("some");
2574 if let AgentEvent::SubagentProgress {
2575 status, metadata, ..
2576 } = progress
2577 {
2578 assert_eq!(status, "turn_completed");
2579 assert_eq!(metadata["turn"], 3);
2580 assert_eq!(metadata["total_tokens"], 125);
2581 assert_eq!(metadata["prompt_tokens"], 100);
2582 assert_eq!(metadata["completion_tokens"], 25);
2583 } else {
2584 panic!("expected SubagentProgress");
2585 }
2586 }
2587
2588 #[test]
2589 fn synthesize_progress_ignores_unrelated_events() {
2590 let ignored = [
2591 AgentEvent::TextDelta {
2592 text: "hi".to_string(),
2593 },
2594 AgentEvent::ToolStart {
2595 id: "x".to_string(),
2596 name: "bash".to_string(),
2597 },
2598 AgentEvent::TurnStart { turn: 1 },
2599 AgentEvent::SubagentStart {
2600 task_id: "nested".to_string(),
2601 session_id: "nested-run".to_string(),
2602 parent_session_id: "parent".to_string(),
2603 agent: "explore".to_string(),
2604 description: "nested".to_string(),
2605 started_ms: 0,
2606 },
2607 ];
2608 for event in &ignored {
2609 assert!(
2610 synthesize_subagent_progress(event, "task", "session").is_none(),
2611 "{:?} should not emit progress",
2612 event
2613 );
2614 }
2615 }
2616}