返回 CodeWhale
tasks.rs
根目录 / crates / tui / src / tools / tasks.rs
1 //! Durable task, gate, and PR-attempt tools.
2
3 use std::path::{Path, PathBuf};
4 use std::process::Stdio;
5 use std::time::Instant;
6
7 use async_trait::async_trait;
8 use chrono::Utc;
9 use serde_json::{Value, json};
10 use tokio::process::Command;
11 use uuid::Uuid;
12
13 use crate::command_safety::{SafetyLevel, analyze_command};
14 use crate::dependencies::ExternalTool;
15 use crate::task_manager::{
16 NewTaskRequest, TaskArtifactRef, TaskAttemptRecord, TaskCancelDisposition, TaskGateRecord,
17 TaskRecord,
18 };
19 use crate::tools::shell::BashTool;
20 use crate::tools::spec::{
21 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
22 optional_bool, optional_bool_opt, optional_str, optional_u64, required_str,
23 };
24 use crate::work_graph::{
25 CancelOutcome, OperationIntent, OperationObservation, OperationOwnerSnapshot, OwnerState,
26 task_owner_snapshot,
27 };
28
29 const MAX_SUMMARY_CHARS: usize = 900;
30 const DEFAULT_GATE_TIMEOUT_MS: u64 = 120_000;
31 const MAX_GATE_TIMEOUT_MS: u64 = 600_000;
32
33 fn build_gate_command_parts(command: &str) -> (String, Vec<String>) {
34 (
35 "/bin/sh".to_string(),
36 vec!["-lc".to_string(), command.to_string()],
37 )
38 }
39
40 fn build_gate_command(command: &str, cwd: &Path) -> Command {
41 let (program, args) = build_gate_command_parts(command);
42 let mut cmd = Command::new(program);
43 cmd.args(args)
44 .current_dir(cwd)
45 .stdout(Stdio::piped())
46 .stderr(Stdio::piped());
47 cmd
48 }
49
50 /// Unified durable-task tool (piagent phase B).
51 ///
52 /// The model sees one tool, `tasks`, with an `action` parameter routing to
53 /// the per-action logic below. The per-action `task_*` / `pr_attempt_*`
54 /// execution aliases were removed in v0.9.3.
55 ///
56 /// `TaskShellStartTool` / `TaskShellWaitTool` stay separate: the registry
57 /// gates them behind `allow_shell` (see `with_runtime_task_shell_tools`),
58 /// which differs from every other action in this family.
59 pub struct TasksTool {
60 name: &'static str,
61 forced_action: Option<&'static str>,
62 read_only: bool,
63 }
64
65 pub struct TaskShellStartTool;
66 pub struct TaskShellWaitTool;
67
68 /// Actions the Plan-mode read-only surface exposes.
69 const READ_ACTIONS: &[&str] = &["list", "read", "pr_attempt_list", "pr_attempt_read"];
70 const ALL_ACTIONS: &[&str] = &[
71 "create",
72 "list",
73 "read",
74 "cancel",
75 "gate_run",
76 "pr_attempt_record",
77 "pr_attempt_list",
78 "pr_attempt_read",
79 "pr_attempt_preflight",
80 ];
81
82 impl TasksTool {
83 pub const fn new(name: &'static str) -> Self {
84 Self {
85 name,
86 forced_action: None,
87 read_only: false,
88 }
89 }
90
91 /// Plan-mode variant: only the read-only actions are advertised and routed.
92 pub const fn read_only(name: &'static str) -> Self {
93 Self {
94 name,
95 forced_action: None,
96 read_only: true,
97 }
98 }
99
100 #[cfg(test)]
101 pub const fn alias(name: &'static str, action: &'static str) -> Self {
102 Self {
103 name,
104 forced_action: Some(action),
105 read_only: false,
106 }
107 }
108
109 fn allowed_actions(&self) -> &'static [&'static str] {
110 if self.read_only {
111 READ_ACTIONS
112 } else {
113 ALL_ACTIONS
114 }
115 }
116
117 fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
118 let action = match self.forced_action {
119 Some(action) => action,
120 None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
121 ToolError::invalid_input(format!(
122 "tasks: missing `action` (one of: {})",
123 self.allowed_actions().join(", ")
124 ))
125 })?,
126 };
127 if self.allowed_actions().contains(&action) {
128 Ok(action)
129 } else {
130 Err(ToolError::invalid_input(format!(
131 "tasks: invalid action `{action}` (one of: {})",
132 self.allowed_actions().join(", ")
133 )))
134 }
135 }
136
137 fn action_is_read(action: &str) -> bool {
138 READ_ACTIONS.contains(&action)
139 }
140
141 /// Whether this action executes code (drives static capabilities and the
142 /// Plan-mode "no ExecutesCode tools" invariant).
143 fn action_executes_code(action: &str) -> bool {
144 action == "gate_run"
145 }
146
147 fn action_requires_approval(action: &str) -> bool {
148 !Self::action_is_read(action)
149 }
150 }
151
152 #[async_trait]
153 impl ToolSpec for TasksTool {
154 fn name(&self) -> &'static str {
155 self.name
156 }
157
158 fn model_visible(&self) -> bool {
159 self.forced_action.is_none()
160 }
161
162 fn description(&self) -> &'static str {
163 match self.forced_action {
164 Some("create") => {
165 "Create/enqueue a durable background task through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents."
166 }
167 Some("list") => {
168 "List recent durable tasks with status, linked thread/turn ids, and concise summaries."
169 }
170 Some("read") => {
171 "Read durable task detail including timeline, checklist, gate evidence, artifacts, and PR attempts."
172 }
173 Some("cancel") => {
174 "Cancel a queued or running durable task through TaskManager. Requires approval because it changes work state."
175 }
176 Some("gate_run") => {
177 "Run an approved verification gate command and return structured evidence. When inside a durable task, the gate result and log artifact are attached to that task."
178 }
179 Some("pr_attempt_record") => {
180 "Capture current git diff as a durable PR work attempt with patch artifact, changed files, and verification notes."
181 }
182 Some("pr_attempt_list") => "List PR attempts recorded on a durable task.",
183 Some("pr_attempt_read") => {
184 "Read one recorded PR attempt and its patch artifact reference."
185 }
186 Some("pr_attempt_preflight") => {
187 "Run `git apply --check` for a recorded attempt patch. This is a no-mutation preflight; actual apply remains explicit and approval-gated elsewhere."
188 }
189 _ if self.read_only => {
190 "Inspect durable tasks and their PR attempts. Actions: \"list\", \"read\", \"pr_attempt_list\", \"pr_attempt_read\"."
191 }
192 _ => {
193 "Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\", \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\". Use task_shell_start for long-running shell work."
194 }
195 }
196 }
197
198 fn input_schema(&self) -> Value {
199 if let Some(action) = self.forced_action {
200 return legacy_action_schema(action);
201 }
202 let actions: Vec<&str> = self.allowed_actions().to_vec();
203 let mut properties = serde_json::Map::new();
204 properties.insert(
205 "action".to_string(),
206 json!({
207 "type": "string",
208 "enum": actions,
209 "description": "Action to perform."
210 }),
211 );
212 if !self.read_only {
213 properties.insert(
214 "prompt".to_string(),
215 json!({ "type": "string", "description": "Work prompt for the durable task (action=create)." }),
216 );
217 properties.insert(
218 "model".to_string(),
219 json!({ "type": "string", "description": "(action=create)" }),
220 );
221 properties.insert(
222 "workspace".to_string(),
223 json!({ "type": "string", "description": "Workspace path; defaults to current workspace. (action=create)" }),
224 );
225 properties.insert(
226 "mode".to_string(),
227 json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "(action=create)" }),
228 );
229 properties.insert(
230 "allow_shell".to_string(),
231 json!({ "type": "boolean", "description": "(action=create)" }),
232 );
233 properties.insert(
234 "trust_mode".to_string(),
235 json!({ "type": "boolean", "description": "(action=create)" }),
236 );
237 properties.insert(
238 "auto_approve".to_string(),
239 json!({ "type": "boolean", "description": "(action=create)" }),
240 );
241 properties.insert(
242 "gate".to_string(),
243 json!({
244 "type": "string",
245 "enum": ["fmt", "check", "clippy", "test", "custom"],
246 "description": "Gate category. (action=gate_run)"
247 }),
248 );
249 properties.insert(
250 "command".to_string(),
251 json!({ "type": "string", "description": "Command to run. (action=gate_run)" }),
252 );
253 properties.insert(
254 "cwd".to_string(),
255 json!({ "type": "string", "description": "Optional working directory within the workspace. (action=gate_run)" }),
256 );
257 properties.insert(
258 "timeout_ms".to_string(),
259 json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "(action=gate_run)" }),
260 );
261 properties.insert(
262 "attempt_group_id".to_string(),
263 json!({ "type": "string", "description": "(action=pr_attempt_record)" }),
264 );
265 properties.insert(
266 "attempt_index".to_string(),
267 json!({ "type": "integer", "minimum": 1, "description": "(action=pr_attempt_record)" }),
268 );
269 properties.insert(
270 "attempt_count".to_string(),
271 json!({ "type": "integer", "minimum": 1, "description": "(action=pr_attempt_record)" }),
272 );
273 properties.insert(
274 "summary".to_string(),
275 json!({ "type": "string", "description": "Attempt summary (action=pr_attempt_record)." }),
276 );
277 properties.insert(
278 "verification".to_string(),
279 json!({ "type": "array", "items": { "type": "string" }, "description": "(action=pr_attempt_record)" }),
280 );
281 }
282 properties.insert(
283 "attempt_id".to_string(),
284 json!({ "type": "string", "description": "(action=pr_attempt_read/preflight)" }),
285 );
286 properties.insert(
287 "task_id".to_string(),
288 json!({ "type": "string", "description": "Full task id or unambiguous prefix (action=read/cancel); task id, defaults to active task (action=pr_attempt_*)." }),
289 );
290 properties.insert(
291 "limit".to_string(),
292 json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "(action=list)" }),
293 );
294 json!({
295 "type": "object",
296 "properties": properties,
297 "additionalProperties": false
298 })
299 }
300
301 fn capabilities(&self) -> Vec<ToolCapability> {
302 match self.forced_action {
303 Some(action) if Self::action_executes_code(action) => {
304 vec![
305 ToolCapability::ExecutesCode,
306 ToolCapability::RequiresApproval,
307 ]
308 }
309 Some(action) if Self::action_is_read(action) => vec![ToolCapability::ReadOnly],
310 Some(_) => vec![ToolCapability::RequiresApproval],
311 None if self.read_only => vec![ToolCapability::ReadOnly],
312 None => vec![
313 ToolCapability::ExecutesCode,
314 ToolCapability::RequiresApproval,
315 ],
316 }
317 }
318
319 fn approval_requirement(&self) -> ApprovalRequirement {
320 match self.forced_action {
321 Some(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required,
322 Some(_) => ApprovalRequirement::Auto,
323 None if self.read_only => ApprovalRequirement::Auto,
324 None => ApprovalRequirement::Required,
325 }
326 }
327
328 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
329 match self.resolve_action(input) {
330 Ok(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required,
331 Ok(_) => ApprovalRequirement::Auto,
332 Err(_) => self.approval_requirement(),
333 }
334 }
335
336 fn is_read_only_for(&self, input: &Value) -> bool {
337 match self.resolve_action(input) {
338 Ok(action) => Self::action_is_read(action),
339 Err(_) => self.is_read_only(),
340 }
341 }
342
343 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
344 match self.resolve_action(&input)? {
345 "create" => self.execute_create(&input, context).await,
346 "list" => self.execute_list(&input, context).await,
347 "read" => self.execute_read(&input, context).await,
348 "cancel" => self.execute_cancel(&input, context).await,
349 "gate_run" => self.execute_gate_run(&input, context).await,
350 "pr_attempt_record" => self.execute_pr_attempt_record(&input, context).await,
351 "pr_attempt_list" => self.execute_pr_attempt_list(&input, context).await,
352 "pr_attempt_read" => self.execute_pr_attempt_read(&input, context).await,
353 "pr_attempt_preflight" => self.execute_pr_attempt_preflight(&input, context).await,
354 action => Err(ToolError::invalid_input(format!(
355 "tasks: invalid action `{action}`"
356 ))),
357 }
358 }
359 }
360
361 /// The exact schema the legacy per-action tool exposed, kept so hidden alias
362 /// registrations report an identical contract to the pre-unification tools.
363 fn legacy_action_schema(action: &str) -> Value {
364 match action {
365 "create" => json!({
366 "type": "object",
367 "properties": {
368 "prompt": { "type": "string", "description": "Work prompt for the durable task." },
369 "model": { "type": "string" },
370 "workspace": { "type": "string", "description": "Workspace path; defaults to current workspace." },
371 "mode": { "type": "string", "enum": ["agent", "plan", "operate"] },
372 "allow_shell": { "type": "boolean" },
373 "trust_mode": { "type": "boolean" },
374 "auto_approve": { "type": "boolean" }
375 },
376 "required": ["prompt"],
377 "additionalProperties": false
378 }),
379 "list" => json!({
380 "type": "object",
381 "properties": {
382 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
383 },
384 "additionalProperties": false
385 }),
386 "read" | "cancel" => json!({
387 "type": "object",
388 "properties": {
389 "task_id": { "type": "string", "description": "Full task id or unambiguous prefix." }
390 },
391 "required": ["task_id"],
392 "additionalProperties": false
393 }),
394 "gate_run" => json!({
395 "type": "object",
396 "properties": {
397 "gate": {
398 "type": "string",
399 "enum": ["fmt", "check", "clippy", "test", "custom"],
400 "description": "Gate category."
401 },
402 "command": { "type": "string", "description": "Command to run." },
403 "cwd": { "type": "string", "description": "Optional working directory within the workspace." },
404 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 }
405 },
406 "required": ["gate", "command"],
407 "additionalProperties": false
408 }),
409 "pr_attempt_record" => json!({
410 "type": "object",
411 "properties": {
412 "task_id": { "type": "string", "description": "Task to attach to; defaults to active task." },
413 "attempt_group_id": { "type": "string" },
414 "attempt_index": { "type": "integer", "minimum": 1 },
415 "attempt_count": { "type": "integer", "minimum": 1 },
416 "summary": { "type": "string" },
417 "verification": { "type": "array", "items": { "type": "string" } }
418 },
419 "required": ["summary"],
420 "additionalProperties": false
421 }),
422 "pr_attempt_list" => task_id_schema(),
423 // pr_attempt_read / pr_attempt_preflight share the attempt-id schema.
424 _ => json!({
425 "type": "object",
426 "properties": {
427 "task_id": { "type": "string", "description": "Task id; defaults to active task." },
428 "attempt_id": { "type": "string" }
429 },
430 "required": ["attempt_id"],
431 "additionalProperties": false
432 }),
433 }
434 }
435
436 impl TasksTool {
437 async fn execute_create(
438 &self,
439 input: &Value,
440 context: &ToolContext,
441 ) -> Result<ToolResult, ToolError> {
442 let manager = context
443 .runtime
444 .task_manager
445 .as_ref()
446 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
447 let workspace = optional_str(input, "workspace")?
448 .map(PathBuf::from)
449 .unwrap_or_else(|| context.workspace.clone());
450 let prompt = required_str(input, "prompt")?.to_string();
451 let req = NewTaskRequest {
452 prompt: prompt.clone(),
453 model: optional_str(input, "model")?.map(ToString::to_string),
454 workspace: Some(workspace),
455 mode: optional_str(input, "mode")?.map(ToString::to_string),
456 // Authority declarations: read strictly. A malformed value that
457 // silently reads as "unset" is a restriction that evaporates.
458 allow_shell: optional_bool_opt(input, "allow_shell")?,
459 trust_mode: optional_bool_opt(input, "trust_mode")?,
460 auto_approve: optional_bool_opt(input, "auto_approve")?,
461 owner_session_id: Some(context.state_namespace.clone()),
462 };
463 let task_id = crate::task_manager::TaskManager::new_task_id();
464 if let Some(work) = context.runtime.work.as_ref() {
465 work.register_operation(
466 &context.state_namespace,
467 OperationIntent::new(
468 format!("task:{task_id}"),
469 prompt,
470 true,
471 "task_create",
472 &task_id,
473 ),
474 )
475 .map_err(ToolError::execution_failed)?;
476 }
477 let task = match manager.add_task_with_id(req, task_id.clone()).await {
478 Ok(task) => task,
479 Err(err) => {
480 if let Some(work) = context.runtime.work.as_ref() {
481 let _ = work.reconcile_operation(
482 &context.state_namespace,
483 OperationOwnerSnapshot::new(
484 format!("task:{task_id}"),
485 OwnerState::Failed,
486 1,
487 Utc::now().timestamp_millis(),
488 ),
489 );
490 }
491 return Err(ToolError::execution_failed(err.to_string()));
492 }
493 };
494 let lifecycle_warning = reconcile_task_record(context, &task).err().map(|err| {
495 tracing::warn!(task_id = %task.id, error = %err, "task was created but Work lifecycle reconciliation failed");
496 err.to_string()
497 });
498 task_result_with_lifecycle_warning("task_create", &task, lifecycle_warning.as_deref())
499 }
500
501 async fn execute_list(
502 &self,
503 input: &Value,
504 context: &ToolContext,
505 ) -> Result<ToolResult, ToolError> {
506 let manager = context
507 .runtime
508 .task_manager
509 .as_ref()
510 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
511 let limit = optional_u64(input, "limit", 20)?.clamp(1, 100) as usize;
512 let tasks = manager.list_tasks(Some(limit)).await;
513 ToolResult::json(&json!({
514 "summary": format!("{} durable task(s)", tasks.len()),
515 "tasks": tasks,
516 }))
517 .map_err(|e| ToolError::execution_failed(e.to_string()))
518 }
519
520 async fn execute_read(
521 &self,
522 input: &Value,
523 context: &ToolContext,
524 ) -> Result<ToolResult, ToolError> {
525 let manager = context
526 .runtime
527 .task_manager
528 .as_ref()
529 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
530 let task = manager
531 .get_task(required_str(input, "task_id")?)
532 .await
533 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
534 task_result("task_read", &task)
535 }
536
537 async fn execute_cancel(
538 &self,
539 input: &Value,
540 context: &ToolContext,
541 ) -> Result<ToolResult, ToolError> {
542 let manager = context
543 .runtime
544 .task_manager
545 .as_ref()
546 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
547 let cancellation = manager
548 .cancel_task(required_str(input, "task_id")?)
549 .await
550 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
551 let task = cancellation.task;
552 let cancel_outcome = match cancellation.disposition {
553 TaskCancelDisposition::Forced => CancelOutcome::Forced,
554 TaskCancelDisposition::Requested => CancelOutcome::Requested,
555 TaskCancelDisposition::AlreadyFinished => CancelOutcome::AlreadyFinished,
556 };
557 let mut lifecycle_warnings = Vec::new();
558 if let Some(work) = context.runtime.work.as_ref() {
559 let external = format!("task:{}", task.id);
560 if work.has_operation_binding(Some(&context.state_namespace), &external)
561 && let Err(err) = work.reconcile_observation(
562 &context.state_namespace,
563 &external,
564 OperationObservation::CancelUpdate {
565 outcome: cancel_outcome,
566 at: Utc::now().timestamp_millis(),
567 },
568 )
569 {
570 tracing::warn!(task_id = %task.id, error = %err, "task was cancelled but Work cancel reconciliation failed");
571 lifecycle_warnings.push(err);
572 }
573 }
574 if let Err(err) = reconcile_task_record(context, &task) {
575 tracing::warn!(task_id = %task.id, error = %err, "task cancellation succeeded but owner-state reconciliation failed");
576 lifecycle_warnings.push(err.to_string());
577 }
578 let lifecycle_warning =
579 (!lifecycle_warnings.is_empty()).then(|| lifecycle_warnings.join("; "));
580 task_result_with_lifecycle_warning("task_cancel", &task, lifecycle_warning.as_deref())
581 }
582
583 async fn execute_gate_run(
584 &self,
585 input: &Value,
586 context: &ToolContext,
587 ) -> Result<ToolResult, ToolError> {
588 let gate = required_str(input, "gate")?.to_string();
589 let command = required_str(input, "command")?.to_string();
590 let timeout_ms = optional_u64(input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)?
591 .clamp(1_000, MAX_GATE_TIMEOUT_MS);
592 let cwd = resolve_cwd(context, optional_str(input, "cwd")?)?;
593
594 let safety = analyze_command(&command);
595 if !context.auto_approve && matches!(safety.level, SafetyLevel::Dangerous) {
596 return Ok(ToolResult::error(format!(
597 "BLOCKED: gate command classified dangerous: {}",
598 safety.reasons.join("; ")
599 ))
600 .with_metadata(json!({
601 "safety_level": "dangerous",
602 "blocked": true,
603 "reasons": safety.reasons,
604 })));
605 }
606
607 let started = Instant::now();
608 let mut cmd = build_gate_command(&command, &cwd);
609 let output =
610 tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), cmd.output()).await;
611
612 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
613 let (exit_code, stdout, stderr, timed_out, spawn_error) = match output {
614 Ok(Ok(out)) => (
615 out.status.code(),
616 String::from_utf8_lossy(&out.stdout).to_string(),
617 String::from_utf8_lossy(&out.stderr).to_string(),
618 false,
619 None,
620 ),
621 Ok(Err(err)) => (
622 None,
623 String::new(),
624 String::new(),
625 false,
626 Some(err.to_string()),
627 ),
628 Err(_) => (None, String::new(), String::new(), true, None),
629 };
630
631 let full_log = format!(
632 "$ {command}\n\n[stdout]\n{stdout}\n\n[stderr]\n{stderr}\n{}",
633 spawn_error
634 .as_ref()
635 .map(|e| format!("\n[spawn_error]\n{e}\n"))
636 .unwrap_or_default()
637 );
638 let summary_source = if !stderr.trim().is_empty() {
639 stderr.as_str()
640 } else if !stdout.trim().is_empty() {
641 stdout.as_str()
642 } else {
643 spawn_error.as_deref().unwrap_or("(no output)")
644 };
645 let summary = summarize(summary_source, MAX_SUMMARY_CHARS);
646 let status = if timed_out {
647 "timeout"
648 } else if spawn_error.is_some() {
649 "failed"
650 } else if exit_code == Some(0) {
651 "passed"
652 } else {
653 "failed"
654 };
655 let classification = classify_gate_failure(&gate, status, timed_out, &stderr, &stdout);
656 let log_path = write_runtime_artifact(context, "gate", &full_log).await?;
657 let gate_record = TaskGateRecord {
658 id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
659 gate: gate.clone(),
660 command: command.clone(),
661 cwd: cwd.clone(),
662 exit_code,
663 status: status.to_string(),
664 classification,
665 duration_ms,
666 summary: summary.clone(),
667 log_path: log_path.clone(),
668 recorded_at: Utc::now(),
669 };
670
671 let content = json!({
672 "gate": gate_record,
673 "stdout_summary": summarize(&stdout, MAX_SUMMARY_CHARS),
674 "stderr_summary": summarize(&stderr, MAX_SUMMARY_CHARS),
675 });
676 let mut metadata = json!({
677 "command": command,
678 "cwd": cwd,
679 "exit_code": exit_code,
680 "duration_ms": duration_ms,
681 "timed_out": timed_out,
682 "task_updates": {
683 "gate": gate_record,
684 "artifacts": artifact_updates("gate_log", log_path.clone(), &summary)
685 }
686 });
687 if let Some(path) = log_path {
688 metadata["artifact_path"] = json!(path);
689 }
690 Ok(ToolResult::json(&content)
691 .map_err(|e| ToolError::execution_failed(e.to_string()))?
692 .with_metadata(metadata))
693 }
694
695 async fn execute_pr_attempt_record(
696 &self,
697 input: &Value,
698 context: &ToolContext,
699 ) -> Result<ToolResult, ToolError> {
700 let task_id = task_id_from_input_or_context(input, context)?;
701 let base_sha = git_output(&context.workspace, &["rev-parse", "HEAD"])
702 .await
703 .ok();
704 let head_sha = base_sha.clone();
705 let branch = git_output(&context.workspace, &["rev-parse", "--abbrev-ref", "HEAD"])
706 .await
707 .ok();
708 let diff = git_output(&context.workspace, &["diff", "--binary", "--no-color"]).await?;
709 if diff.trim().is_empty() {
710 return Ok(ToolResult::error(
711 "No working-tree diff to record as an attempt.",
712 ));
713 }
714 let changed_files = git_output(&context.workspace, &["diff", "--name-only"])
715 .await?
716 .lines()
717 .filter(|line| !line.trim().is_empty())
718 .map(ToString::to_string)
719 .collect::<Vec<_>>();
720 let patch_path = write_task_artifact_for(context, &task_id, "attempt_patch", &diff).await?;
721 let attempt = TaskAttemptRecord {
722 id: format!("attempt_{}", &Uuid::new_v4().to_string()[..8]),
723 attempt_group_id: optional_str(input, "attempt_group_id")?
724 .map(ToString::to_string)
725 .unwrap_or_else(|| format!("attempt_group_{}", &Uuid::new_v4().to_string()[..8])),
726 attempt_index: optional_u64(input, "attempt_index", 1)?.max(1) as u32,
727 attempt_count: optional_u64(input, "attempt_count", 1)?.max(1) as u32,
728 base_ref: branch.clone(),
729 base_sha,
730 head_ref: branch,
731 head_sha,
732 summary: required_str(input, "summary")?.to_string(),
733 changed_files,
734 patch_path: patch_path.clone(),
735 verification: input
736 .get("verification")
737 .and_then(Value::as_array)
738 .map(|items| {
739 items
740 .iter()
741 .filter_map(Value::as_str)
742 .map(ToString::to_string)
743 .collect()
744 })
745 .unwrap_or_default(),
746 selected: false,
747 recorded_at: Utc::now(),
748 };
749 let metadata = json!({
750 "task_id": task_id,
751 "task_updates": {
752 "attempt": attempt,
753 "artifacts": artifact_updates("attempt_patch", patch_path.clone(), "Captured git diff for PR attempt")
754 }
755 });
756 if context.runtime.active_task_id.as_deref() != Some(task_id.as_str())
757 && let Some(manager) = context.runtime.task_manager.as_ref()
758 {
759 manager
760 .record_tool_metadata(&task_id, &metadata)
761 .await
762 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
763 }
764 Ok(ToolResult::json(&metadata)
765 .map_err(|e| ToolError::execution_failed(e.to_string()))?
766 .with_metadata(metadata))
767 }
768
769 async fn execute_pr_attempt_list(
770 &self,
771 input: &Value,
772 context: &ToolContext,
773 ) -> Result<ToolResult, ToolError> {
774 let task = read_task_for_input(input, context).await?;
775 ToolResult::json(&json!({ "task_id": task.id, "attempts": task.attempts }))
776 .map_err(|e| ToolError::execution_failed(e.to_string()))
777 }
778
779 async fn execute_pr_attempt_read(
780 &self,
781 input: &Value,
782 context: &ToolContext,
783 ) -> Result<ToolResult, ToolError> {
784 let task = read_task_for_input(input, context).await?;
785 let attempt_id = required_str(input, "attempt_id")?;
786 let attempt = task
787 .attempts
788 .iter()
789 .find(|attempt| attempt.id == attempt_id)
790 .ok_or_else(|| ToolError::invalid_input(format!("Attempt not found: {attempt_id}")))?;
791 ToolResult::json(attempt).map_err(|e| ToolError::execution_failed(e.to_string()))
792 }
793
794 async fn execute_pr_attempt_preflight(
795 &self,
796 input: &Value,
797 context: &ToolContext,
798 ) -> Result<ToolResult, ToolError> {
799 let manager = context
800 .runtime
801 .task_manager
802 .as_ref()
803 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
804 let task = read_task_for_input(input, context).await?;
805 let attempt_id = required_str(input, "attempt_id")?;
806 let attempt = task
807 .attempts
808 .iter()
809 .find(|attempt| attempt.id == attempt_id)
810 .ok_or_else(|| ToolError::invalid_input(format!("Attempt not found: {attempt_id}")))?;
811 let patch_ref = attempt
812 .patch_path
813 .as_ref()
814 .ok_or_else(|| ToolError::invalid_input("Attempt has no patch artifact"))?;
815 let patch_path = manager.artifact_absolute_path(patch_ref);
816 let workspace = context.workspace.clone();
817 let out = tokio::task::spawn_blocking(move || {
818 crate::dependencies::Git::command()
819 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "git not found"))?
820 .args(["apply", "--check"])
821 .arg(&patch_path)
822 .current_dir(&workspace)
823 .output()
824 })
825 .await
826 .map_err(|join_err| {
827 // Surface the otherwise-discarded join error for debugging; the
828 // returned ToolError (and thus user-facing behavior) is unchanged.
829 tracing::debug!(error = %join_err, "git apply --check spawn_blocking task failed to join");
830 ToolError::execution_failed(format!("git apply --check panicked: {join_err}"))
831 })?
832 .map_err(|e| ToolError::execution_failed(format!("git apply --check failed: {e}")))?;
833 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
834 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
835 ToolResult::json(&json!({
836 "attempt_id": attempt_id,
837 "patch_path": patch_ref,
838 "would_apply": out.status.success(),
839 "exit_code": out.status.code(),
840 "stdout_summary": summarize(&stdout, MAX_SUMMARY_CHARS),
841 "stderr_summary": summarize(&stderr, MAX_SUMMARY_CHARS),
842 "mutated_worktree": false
843 }))
844 .map_err(|e| ToolError::execution_failed(e.to_string()))
845 }
846 }
847
848 #[async_trait]
849 impl ToolSpec for TaskShellStartTool {
850 fn name(&self) -> &'static str {
851 "task_shell_start"
852 }
853
854 fn description(&self) -> &'static str {
855 "Start a long-running shell command in the background and return a shell task_id immediately. Completion is delivered automatically as an internal runtime event and remains visible in the task/status surface; use task_shell_wait only for early output, explicit barriers, or gate evidence on the active durable task."
856 }
857
858 fn input_schema(&self) -> Value {
859 json!({
860 "type": "object",
861 "properties": {
862 "command": { "type": "string" },
863 "cwd": { "type": "string", "description": "Optional working directory within the workspace." },
864 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
865 "stdin": { "type": "string" },
866 "tty": { "type": "boolean" }
867 },
868 "required": ["command"],
869 "additionalProperties": false
870 })
871 }
872
873 fn capabilities(&self) -> Vec<ToolCapability> {
874 vec![
875 ToolCapability::ExecutesCode,
876 ToolCapability::RequiresApproval,
877 ]
878 }
879
880 fn approval_requirement(&self) -> ApprovalRequirement {
881 ApprovalRequirement::Required
882 }
883
884 fn starts_detached_for(&self, input: &Value) -> bool {
885 input.get("command").and_then(Value::as_str).is_some()
886 }
887
888 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
889 let mut shell_input = json!({
890 "command": required_str(&input, "command")?,
891 "background": true,
892 "timeout_ms": optional_u64(&input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)?
893 .clamp(1_000, MAX_GATE_TIMEOUT_MS),
894 });
895 if let Some(cwd) = optional_str(&input, "cwd")? {
896 let cwd = resolve_cwd(context, Some(cwd))?;
897 shell_input["cwd"] = json!(cwd);
898 }
899 if let Some(stdin) = optional_str(&input, "stdin")? {
900 shell_input["stdin"] = json!(stdin);
901 }
902 if optional_bool(&input, "tty", false)? {
903 shell_input["tty"] = json!(true);
904 }
905 let mut result = BashTool::new("Bash").execute(shell_input, context).await?;
906 if let Some(metadata) = result.metadata.as_mut() {
907 metadata["background"] = json!(true);
908 metadata["task_shell"] = json!(true);
909 }
910 Ok(result)
911 }
912 }
913
914 #[async_trait]
915 impl ToolSpec for TaskShellWaitTool {
916 fn name(&self) -> &'static str {
917 "task_shell_wait"
918 }
919
920 fn description(&self) -> &'static str {
921 "Poll a background shell task without blocking the agent indefinitely. Completion is delivered automatically; use this only for early output, explicit barriers, or gate evidence. If `gate` is supplied and the shell task has completed, records structured gate evidence on the active durable task."
922 }
923
924 fn input_schema(&self) -> Value {
925 json!({
926 "type": "object",
927 "properties": {
928 "task_id": { "type": "string", "description": "Background shell task id returned by task_shell_start or `Bash`." },
929 "wait": { "type": "boolean", "default": false },
930 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
931 "gate": { "type": "string", "enum": ["fmt", "check", "clippy", "test", "custom"] },
932 "command": { "type": "string", "description": "Original command, used when recording gate evidence." }
933 },
934 "required": ["task_id"],
935 "additionalProperties": false
936 })
937 }
938
939 fn capabilities(&self) -> Vec<ToolCapability> {
940 vec![ToolCapability::ReadOnly]
941 }
942
943 fn approval_requirement(&self) -> ApprovalRequirement {
944 ApprovalRequirement::Auto
945 }
946
947 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
948 let result = BashTool::alias("exec_shell_wait", "wait")
949 .execute(input.clone(), context)
950 .await?;
951 let Some(gate) = optional_str(&input, "gate")? else {
952 return Ok(result);
953 };
954 let status = result
955 .metadata
956 .as_ref()
957 .and_then(|m| m.get("status"))
958 .and_then(Value::as_str)
959 .unwrap_or("Running");
960 if status == "Running" {
961 return Ok(result);
962 }
963 let exit_code = result
964 .metadata
965 .as_ref()
966 .and_then(|m| m.get("exit_code"))
967 .and_then(Value::as_i64)
968 .and_then(|v| i32::try_from(v).ok());
969 let duration_ms = result
970 .metadata
971 .as_ref()
972 .and_then(|m| m.get("duration_ms"))
973 .and_then(Value::as_u64)
974 .unwrap_or_default();
975 let command = optional_str(&input, "command")?.unwrap_or("(background shell)");
976 let log_path = write_runtime_artifact(context, "background_gate", &result.content).await?;
977 let gate_status = if exit_code == Some(0) {
978 "passed"
979 } else if status == "TimedOut" {
980 "timeout"
981 } else {
982 "failed"
983 };
984 let gate_record = TaskGateRecord {
985 id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
986 gate: gate.to_string(),
987 command: command.to_string(),
988 cwd: context.workspace.clone(),
989 exit_code,
990 status: gate_status.to_string(),
991 classification: classify_gate_failure(
992 gate,
993 gate_status,
994 status == "TimedOut",
995 &result.content,
996 "",
997 ),
998 duration_ms,
999 summary: summarize(&result.content, MAX_SUMMARY_CHARS),
1000 log_path: log_path.clone(),
1001 recorded_at: Utc::now(),
1002 };
1003 let mut metadata = result.metadata.clone().unwrap_or_else(|| json!({}));
1004 metadata["background"] = json!(true);
1005 metadata["task_updates"] = json!({
1006 "gate": gate_record,
1007 "artifacts": artifact_updates("background_gate_log", log_path, "Background shell gate output")
1008 });
1009 Ok(result.with_metadata(metadata))
1010 }
1011 }
1012
1013 fn reconcile_task_record(context: &ToolContext, task: &TaskRecord) -> Result<(), ToolError> {
1014 let Some(work) = context.runtime.work.as_ref() else {
1015 return Ok(());
1016 };
1017 let external = format!("task:{}", task.id);
1018 if !work.has_operation_binding(Some(&context.state_namespace), &external) {
1019 return Ok(());
1020 }
1021 work.reconcile_operation(
1022 &context.state_namespace,
1023 task_owner_snapshot(
1024 &task.id,
1025 task.status,
1026 task.lifecycle_seq,
1027 task.created_at,
1028 task.started_at,
1029 task.ended_at,
1030 ),
1031 )
1032 .map(|_| ())
1033 .map_err(ToolError::execution_failed)
1034 }
1035
1036 fn task_result(label: &str, task: &TaskRecord) -> Result<ToolResult, ToolError> {
1037 task_result_with_lifecycle_warning(label, task, None)
1038 }
1039
1040 fn task_result_with_lifecycle_warning(
1041 label: &str,
1042 task: &TaskRecord,
1043 lifecycle_warning: Option<&str>,
1044 ) -> Result<ToolResult, ToolError> {
1045 ToolResult::json(&json!({
1046 "summary": format!("{label}: {} ({:?})", task.id, task.status),
1047 "task": task,
1048 "lifecycle_warning": lifecycle_warning,
1049 }))
1050 .map_err(|e| ToolError::execution_failed(e.to_string()))
1051 }
1052
1053 fn resolve_cwd(context: &ToolContext, raw: Option<&str>) -> Result<PathBuf, ToolError> {
1054 match raw {
1055 Some(path) => {
1056 let resolved = context.resolve_path(path)?;
1057 if resolved.is_dir() {
1058 Ok(resolved)
1059 } else {
1060 Err(ToolError::invalid_input(format!(
1061 "cwd must be a directory: {path}"
1062 )))
1063 }
1064 }
1065 None => Ok(context.workspace.clone()),
1066 }
1067 }
1068
1069 async fn write_runtime_artifact(
1070 context: &ToolContext,
1071 label: &str,
1072 content: &str,
1073 ) -> Result<Option<PathBuf>, ToolError> {
1074 let Some(task_id) = context.runtime.active_task_id.as_deref() else {
1075 return Ok(None);
1076 };
1077 let manager = context.runtime.task_manager.as_ref();
1078 if let Some(manager) = manager {
1079 return manager
1080 .write_task_artifact(task_id, label, content)
1081 .map(Some)
1082 .map_err(|e| ToolError::execution_failed(e.to_string()));
1083 }
1084 let Some(data_dir) = context.runtime.task_data_dir.as_ref() else {
1085 return Ok(None);
1086 };
1087 let artifact_dir = data_dir.join("artifacts").join(task_id);
1088 let filename = format!(
1089 "{}_{}.txt",
1090 Utc::now().format("%Y%m%dT%H%M%S%.3fZ"),
1091 sanitize_filename(label)
1092 );
1093 let absolute = artifact_dir.join(filename);
1094 let content_owned = content.to_owned();
1095 let abs = absolute.clone();
1096 tokio::task::spawn_blocking(move || {
1097 std::fs::create_dir_all(&artifact_dir)?;
1098 std::fs::write(&abs, content_owned)?;
1099 Ok::<(), std::io::Error>(())
1100 })
1101 .await
1102 .map_err(|e| {
1103 // Surface the otherwise-discarded join error for debugging; the
1104 // returned ToolError (and thus user-facing behavior) is unchanged.
1105 tracing::debug!(error = %e, "artifact write spawn_blocking task failed to join");
1106 ToolError::execution_failed(format!("artifact write task panicked: {e}"))
1107 })?
1108 .map_err(|e| ToolError::execution_failed(format!("write artifact: {e}")))?;
1109 Ok(Some(
1110 absolute
1111 .strip_prefix(data_dir)
1112 .map(PathBuf::from)
1113 .unwrap_or(absolute),
1114 ))
1115 }
1116
1117 async fn write_task_artifact_for(
1118 context: &ToolContext,
1119 task_id: &str,
1120 label: &str,
1121 content: &str,
1122 ) -> Result<Option<PathBuf>, ToolError> {
1123 if let Some(manager) = context.runtime.task_manager.as_ref() {
1124 return manager
1125 .write_task_artifact(task_id, label, content)
1126 .map(Some)
1127 .map_err(|e| ToolError::execution_failed(e.to_string()));
1128 }
1129 if context.runtime.active_task_id.as_deref() != Some(task_id) {
1130 return Ok(None);
1131 }
1132 write_runtime_artifact(context, label, content).await
1133 }
1134
1135 fn artifact_updates(label: &str, path: Option<PathBuf>, summary: &str) -> Value {
1136 match path {
1137 Some(path) => json!([TaskArtifactRef {
1138 label: label.to_string(),
1139 path,
1140 summary: summarize(summary, 240),
1141 created_at: Utc::now(),
1142 }]),
1143 None => json!([]),
1144 }
1145 }
1146
1147 async fn read_task_for_input(
1148 input: &Value,
1149 context: &ToolContext,
1150 ) -> Result<TaskRecord, ToolError> {
1151 let manager = context
1152 .runtime
1153 .task_manager
1154 .as_ref()
1155 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
1156 let task_id = task_id_from_input_or_context(input, context)?;
1157 manager
1158 .get_task(&task_id)
1159 .await
1160 .map_err(|e| ToolError::execution_failed(e.to_string()))
1161 }
1162
1163 fn task_id_from_input_or_context(
1164 input: &Value,
1165 context: &ToolContext,
1166 ) -> Result<String, ToolError> {
1167 optional_str(input, "task_id")?
1168 .map(ToString::to_string)
1169 .or_else(|| context.runtime.active_task_id.clone())
1170 .ok_or_else(|| {
1171 ToolError::invalid_input("task_id is required when no durable task is active")
1172 })
1173 }
1174
1175 fn task_id_schema() -> Value {
1176 json!({
1177 "type": "object",
1178 "properties": {
1179 "task_id": { "type": "string", "description": "Task id; defaults to active task." }
1180 },
1181 "additionalProperties": false
1182 })
1183 }
1184
1185 async fn git_output(workspace: &Path, args: &[&str]) -> Result<String, ToolError> {
1186 let args_owned: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
1187 let cwd = workspace.to_path_buf();
1188 let out = tokio::task::spawn_blocking(move || {
1189 let arg_refs: Vec<&str> = args_owned.iter().map(String::as_str).collect();
1190 crate::dependencies::Git::output(&arg_refs, &cwd)
1191 })
1192 .await
1193 .map_err(|e| {
1194 // Surface the otherwise-discarded join error for debugging; the
1195 // returned ToolError (and thus user-facing behavior) is unchanged.
1196 tracing::debug!(error = %e, "git spawn_blocking task failed to join");
1197 ToolError::execution_failed(format!("git task panicked: {e}"))
1198 })?
1199 .map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?;
1200 if !out.status.success() {
1201 return Err(ToolError::execution_failed(format!(
1202 "git {} failed: {}",
1203 args.join(" "),
1204 String::from_utf8_lossy(&out.stderr).trim()
1205 )));
1206 }
1207 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
1208 }
1209
1210 fn classify_gate_failure(
1211 gate: &str,
1212 status: &str,
1213 timed_out: bool,
1214 stderr: &str,
1215 stdout: &str,
1216 ) -> String {
1217 if timed_out {
1218 return "timeout".to_string();
1219 }
1220 if status == "passed" {
1221 return "passed".to_string();
1222 }
1223 let haystack = format!("{stderr}\n{stdout}").to_ascii_lowercase();
1224 if haystack.contains("address already in use") || haystack.contains("port") {
1225 "environment_port_binding".to_string()
1226 } else if gate == "clippy" || haystack.contains("warning:") {
1227 "lint_failure".to_string()
1228 } else if gate == "test" || haystack.contains("test result: failed") {
1229 "test_failure".to_string()
1230 } else if haystack.contains("error: could not compile")
1231 || haystack.contains("compilation failed")
1232 {
1233 "compile_error".to_string()
1234 } else {
1235 "environment_or_tooling_failure".to_string()
1236 }
1237 }
1238
1239 fn summarize(text: &str, limit: usize) -> String {
1240 let mut out = String::new();
1241 for (idx, ch) in text.chars().enumerate() {
1242 if idx >= limit.saturating_sub(3) {
1243 out.push_str("...");
1244 return out;
1245 }
1246 if ch.is_control() && ch != '\n' && ch != '\t' {
1247 continue;
1248 }
1249 out.push(ch);
1250 }
1251 if out.trim().is_empty() {
1252 "(no output)".to_string()
1253 } else {
1254 out
1255 }
1256 }
1257
1258 fn sanitize_filename(input: &str) -> String {
1259 let mut out = String::new();
1260 for ch in input.chars() {
1261 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
1262 out.push(ch);
1263 } else {
1264 out.push('_');
1265 }
1266 }
1267 if out.is_empty() {
1268 "artifact".to_string()
1269 } else {
1270 out
1271 }
1272 }
1273
1274 #[cfg(test)]
1275 mod tests {
1276 use super::*;
1277 use crate::tools::spec::ToolSpec;
1278
1279 #[test]
1280 fn durable_task_schema_requires_prompt() {
1281 let schema = TasksTool::alias("task_create", "create").input_schema();
1282 assert_eq!(schema["required"][0], "prompt");
1283 assert!(schema["properties"]["prompt"].is_object());
1284 }
1285
1286 #[test]
1287 fn create_mode_enum_advertises_operate_not_yolo() {
1288 let create = TasksTool::alias("task_create", "create").input_schema();
1289 assert_eq!(
1290 create["properties"]["mode"]["enum"],
1291 json!(["agent", "plan", "operate"])
1292 );
1293 let canonical = TasksTool::new("tasks").input_schema();
1294 assert_eq!(
1295 canonical["properties"]["mode"]["enum"],
1296 json!(["agent", "plan", "operate"])
1297 );
1298 }
1299
1300 #[test]
1301 fn gate_classifier_detects_timeout() {
1302 assert_eq!(
1303 classify_gate_failure("test", "timeout", true, "", ""),
1304 "timeout"
1305 );
1306 }
1307
1308 #[test]
1309 fn canonical_schema_lists_all_actions_and_union_fields() {
1310 let schema = TasksTool::new("tasks").input_schema();
1311 let actions = schema["properties"]["action"]["enum"]
1312 .as_array()
1313 .expect("action enum");
1314 for action in [
1315 "create",
1316 "list",
1317 "read",
1318 "cancel",
1319 "gate_run",
1320 "pr_attempt_record",
1321 "pr_attempt_list",
1322 "pr_attempt_read",
1323 "pr_attempt_preflight",
1324 ] {
1325 assert!(
1326 actions.iter().any(|value| value.as_str() == Some(action)),
1327 "canonical schema must offer action {action}"
1328 );
1329 }
1330 for field in [
1331 "prompt",
1332 "task_id",
1333 "gate",
1334 "command",
1335 "attempt_id",
1336 "limit",
1337 ] {
1338 assert!(
1339 schema["properties"][field].is_object(),
1340 "canonical schema must carry union field {field}"
1341 );
1342 }
1343 assert_eq!(schema["additionalProperties"], json!(false));
1344 }
1345
1346 #[test]
1347 fn read_only_variant_only_offers_read_actions() {
1348 let tool = TasksTool::read_only("tasks");
1349 let schema = tool.input_schema();
1350 assert_eq!(
1351 schema["properties"]["action"]["enum"],
1352 json!(["list", "read", "pr_attempt_list", "pr_attempt_read"])
1353 );
1354 assert!(!schema["properties"]["prompt"].is_object());
1355 assert!(!schema["properties"]["gate"].is_object());
1356 // pr_attempt_read is a read action: its id field must be advertised
1357 // on the read-only surface too.
1358 assert!(schema["properties"]["attempt_id"].is_object());
1359 assert!(schema["properties"]["task_id"].is_object());
1360 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
1361 assert!(tool.is_read_only());
1362 assert_eq!(tool.capabilities(), vec![ToolCapability::ReadOnly]);
1363 }
1364
1365 #[test]
1366 fn aliases_hide_from_model_and_force_action() {
1367 let create = TasksTool::alias("task_create", "create");
1368 assert!(!create.model_visible());
1369 assert_eq!(create.name(), "task_create");
1370 assert_eq!(create.approval_requirement(), ApprovalRequirement::Required);
1371
1372 let gate = TasksTool::alias("task_gate_run", "gate_run");
1373 assert_eq!(gate.approval_requirement(), ApprovalRequirement::Required);
1374 assert!(gate.capabilities().contains(&ToolCapability::ExecutesCode));
1375
1376 let list = TasksTool::alias("task_list", "list");
1377 assert_eq!(list.approval_requirement(), ApprovalRequirement::Auto);
1378 assert!(list.is_read_only_for(&json!({})));
1379
1380 let canonical = TasksTool::new("tasks");
1381 assert!(canonical.model_visible());
1382 assert_eq!(
1383 canonical.approval_requirement_for(&json!({"action": "list"})),
1384 ApprovalRequirement::Auto
1385 );
1386 assert_eq!(
1387 canonical.approval_requirement_for(&json!({"action": "cancel"})),
1388 ApprovalRequirement::Required
1389 );
1390 assert_eq!(
1391 canonical.approval_requirement_for(&json!({"action": "gate_run"})),
1392 ApprovalRequirement::Required
1393 );
1394 assert!(canonical.is_read_only_for(&json!({"action": "pr_attempt_read"})));
1395 assert!(!canonical.is_read_only_for(&json!({"action": "create"})));
1396 }
1397
1398 #[test]
1399 fn canonical_rejects_unknown_or_missing_action() {
1400 let tool = TasksTool::new("tasks");
1401 let err = tool
1402 .resolve_action(&json!({}))
1403 .expect_err("missing action must fail");
1404 assert!(err.to_string().contains("missing `action`"));
1405 let err = tool
1406 .resolve_action(&json!({"action": "explode"}))
1407 .expect_err("unknown action must fail");
1408 assert!(err.to_string().contains("invalid action"));
1409
1410 let read_only = TasksTool::read_only("tasks");
1411 let err = read_only
1412 .resolve_action(&json!({"action": "gate_run"}))
1413 .expect_err("read-only surface must reject exec actions");
1414 assert!(err.to_string().contains("invalid action"));
1415 }
1416
1417 #[test]
1418 fn background_shell_schema_is_explicit() {
1419 let schema = TaskShellStartTool.input_schema();
1420 assert_eq!(schema["required"][0], "command");
1421 assert_eq!(schema["properties"]["timeout_ms"]["maximum"], 600000);
1422
1423 let wait_schema = TaskShellWaitTool.input_schema();
1424 assert_eq!(wait_schema["required"][0], "task_id");
1425 assert!(wait_schema["properties"]["gate"].is_object());
1426 }
1427
1428 #[test]
1429 fn gate_command_uses_login_shell_invocation() {
1430 let (program, args) = build_gate_command_parts("echo hello");
1431 assert_eq!(program, "/bin/sh");
1432 assert_eq!(args, vec!["-lc".to_string(), "echo hello".to_string()]);
1433 }
1434 }
1435
1435 lines RUST