1use std::time::Instant;
20
21use async_trait::async_trait;
22
23use crate::domain::tasks::{TaskEdit, TaskItem, TaskOrigin, TaskSpec, TaskStatus, TaskStore};
24use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
25
26use super::super::ctx::ExecContext;
27use super::ToolExecutor;
28
29pub struct TaskCreateTool;
30pub struct TaskUpdateTool;
31pub struct TaskListTool;
32
33fn task_line(task: &TaskItem) -> String {
35 format!("#{} [{}] {}", task.id, task.status.as_str(), task.subject)
36}
37
38fn render_list(store: &TaskStore) -> String {
42 if store.is_empty() {
43 return "No tasks.".to_string();
44 }
45 let mut out = String::new();
46 for task in store.visible() {
47 out.push_str(&task_line(task));
48 out.push('\n');
49 if let Some(desc) = &task.description {
50 out.push_str(&format!(" {desc}\n"));
51 }
52 for entry in task.evidence.iter().rev().take(3).rev() {
55 out.push_str(&format!(
56 " evidence: {} {} ({})\n",
57 entry.tool, entry.target, entry.status
58 ));
59 }
60 }
61 out.push_str(&store.progress_string());
62 out
63}
64
65fn no_broker(secs: f64) -> ToolOutcome {
68 ToolOutcome::success(
69 "Task tracking is unavailable in this context; proceed without it.",
70 "tasks unavailable",
71 secs,
72 )
73}
74
75fn plan_mode_block(ctx: &crate::providers::ExecContext, secs: f64) -> Option<ToolOutcome> {
81 if ctx.plan_permissions.tasks == crate::app::PlanPermLevel::Allow {
84 return None;
85 }
86 ctx.plan_file.as_ref().map(|_| {
87 ToolOutcome::error(
88 "task tools are disabled in plan mode: the checklist is seeded from the \
89 approved plan. Put implementation steps in the plan file's Tasks section \
90 instead."
91 .to_string(),
92 secs,
93 )
94 })
95}
96
97fn metadata(action: &str, store: &TaskStore) -> ToolRunMetadata {
98 let (completed, total) = store.counts();
99 ToolRunMetadata {
100 detail: ToolMetadata::Tasks {
101 action: action.to_string(),
102 completed: completed as u32,
103 total: total as u32,
104 },
105 ..ToolRunMetadata::default()
106 }
107}
108
109fn parse_specs(args: &serde_json::Value) -> Result<Vec<TaskSpec>, String> {
110 let items = args
111 .get("tasks")
112 .and_then(|t| t.as_array())
113 .ok_or("task_create requires a `tasks` array")?;
114 if items.is_empty() {
115 return Err("`tasks` must not be empty".to_string());
116 }
117 items
118 .iter()
119 .enumerate()
120 .map(|(i, item)| {
121 let subject = item
122 .get("subject")
123 .and_then(|s| s.as_str())
124 .filter(|s| !s.trim().is_empty())
125 .ok_or_else(|| format!("tasks[{i}] is missing `subject`"))?;
126 let active_form = item
127 .get("active_form")
128 .and_then(|s| s.as_str())
129 .filter(|s| !s.trim().is_empty())
130 .ok_or_else(|| format!("tasks[{i}] is missing `active_form`"))?;
131 let status = item.get("status").and_then(|s| s.as_str());
132 let in_progress = match status {
133 None | Some("pending") => false,
134 Some("in_progress") => true,
135 Some(other) => {
136 return Err(format!(
137 "tasks[{i}]: initial status must be \"pending\" or \"in_progress\", got {other:?}"
138 ));
139 },
140 };
141 Ok(TaskSpec {
142 subject: subject.to_string(),
143 active_form: active_form.to_string(),
144 description: item
145 .get("description")
146 .and_then(|s| s.as_str())
147 .map(str::to_string),
148 in_progress,
149 })
150 })
151 .collect()
152}
153
154fn parse_edits(args: &serde_json::Value) -> Result<Vec<TaskEdit>, String> {
155 let items = args
156 .get("updates")
157 .and_then(|t| t.as_array())
158 .ok_or("task_update requires an `updates` array")?;
159 if items.is_empty() {
160 return Err("`updates` must not be empty".to_string());
161 }
162 items
163 .iter()
164 .enumerate()
165 .map(|(i, item)| {
166 let id = item
167 .get("id")
168 .and_then(|v| v.as_u64())
169 .ok_or_else(|| format!("updates[{i}] is missing `id`"))?;
170 let status = match item.get("status").and_then(|s| s.as_str()) {
171 None => None,
172 Some(s) => Some(
173 TaskStatus::parse(s)
174 .ok_or_else(|| format!("updates[{i}]: unknown status {s:?}"))?,
175 ),
176 };
177 Ok(TaskEdit {
178 id: id as u32,
179 status,
180 subject: item
181 .get("subject")
182 .and_then(|s| s.as_str())
183 .map(str::to_string),
184 active_form: item
185 .get("active_form")
186 .and_then(|s| s.as_str())
187 .map(str::to_string),
188 description: item
189 .get("description")
190 .and_then(|s| s.as_str())
191 .map(str::to_string),
192 })
193 })
194 .collect()
195}
196
197#[async_trait]
198impl ToolExecutor for TaskCreateTool {
199 fn name(&self) -> &'static str {
200 "task_create"
201 }
202
203 fn schema(&self) -> ToolDefinition {
204 ToolDefinition {
205 name: "task_create".to_string(),
206 description: "Create tasks on your session checklist, which the user sees live in \
207 the terminal. Use it at the START of multi-step work (3+ distinct steps): plan \
208 the whole job and create ALL initial tasks in ONE call, in execution order. \
209 Skip it entirely for trivial or single-step requests — a one-item checklist is \
210 noise. Each task needs a short imperative `subject` (\"Wire the broker\") and a \
211 present-tense `active_form` (\"Wiring the broker\") shown on the spinner while \
212 it runs. Mark at most one task `in_progress`. Add tasks later as you discover \
213 work; give an `explanation` when a mid-run addition reshapes the plan."
214 .to_string(),
215 input_schema: serde_json::json!({
216 "type": "object",
217 "properties": {
218 "tasks": {
219 "type": "array",
220 "description": "Tasks to add, in execution order. Create the full initial plan in one call.",
221 "items": {
222 "type": "object",
223 "properties": {
224 "subject": {
225 "type": "string",
226 "description": "Short imperative step, e.g. \"Add the config flag\". Meaningful and verifiable, not vague."
227 },
228 "active_form": {
229 "type": "string",
230 "description": "Present-tense form shown while running, e.g. \"Adding the config flag\"."
231 },
232 "description": {
233 "type": "string",
234 "description": "Optional detail: acceptance criteria, files involved, constraints."
235 },
236 "status": {
237 "type": "string",
238 "enum": ["pending", "in_progress"],
239 "description": "Initial status (default pending). At most one task in_progress across the whole list."
240 }
241 },
242 "required": ["subject", "active_form"]
243 }
244 },
245 "explanation": {
246 "type": "string",
247 "description": "One-line rationale, when this call reshapes an existing plan. Shown to the user."
248 }
249 },
250 "required": ["tasks"]
251 }),
252 }
253 }
254
255 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
256 let started = Instant::now();
257 let secs = || started.elapsed().as_secs_f64();
258 if let Some(blocked) = plan_mode_block(&ctx, secs()) {
259 return blocked;
260 }
261 let Some(broker) = ctx.tasks.clone() else {
262 return no_broker(secs());
263 };
264 let specs = match parse_specs(&args) {
265 Ok(s) => s,
266 Err(e) => return ToolOutcome::error(e, secs()),
267 };
268 let count = specs.len();
269 let (created, store) = broker.create(specs, TaskOrigin::Model).await;
270 let mut out = format!("Created {count} task(s):\n");
271 for task in &created {
272 out.push_str(&task_line(task));
273 out.push('\n');
274 }
275 out.push_str(&store.progress_string());
276 for note in crate::domain::advisory_notes(&store, &[], &store) {
279 out.push('\n');
280 out.push_str(¬e);
281 }
282 ToolOutcome::success(out, format!("created {count} task(s)"), secs())
283 .with_metadata(metadata("create", &store))
284 }
285}
286
287#[async_trait]
288impl ToolExecutor for TaskUpdateTool {
289 fn name(&self) -> &'static str {
290 "task_update"
291 }
292
293 fn schema(&self) -> ToolDefinition {
294 ToolDefinition {
295 name: "task_update".to_string(),
296 description: "Update checklist tasks by id (from task_create). Batch related \
297 transitions in one call — completing one task and starting the next is ONE \
298 call with two updates. Keep at most one task in_progress: set it \
299 in_progress BEFORE you start the work and completed IMMEDIATELY after it is \
300 done and verified — never batch-complete at the end, and never jump a task \
301 from pending straight to completed. Only mark completed when the work truly \
302 succeeded; if you hit a blocker, mark the stuck task blocked with an \
303 `explanation`, create a task for the blocker, and mark that one \
304 in_progress. When the plan changes shape (splitting, merging, dropping \
305 work), update or delete tasks and say why in `explanation` — do not let the \
306 checklist go stale while you work."
307 .to_string(),
308 input_schema: serde_json::json!({
309 "type": "object",
310 "properties": {
311 "updates": {
312 "type": "array",
313 "description": "Differential updates, applied in order. Only `id` is required; omitted fields stay unchanged.",
314 "items": {
315 "type": "object",
316 "properties": {
317 "id": {
318 "type": "integer",
319 "description": "Task id from task_create."
320 },
321 "status": {
322 "type": "string",
323 "enum": ["pending", "in_progress", "blocked", "completed", "deleted"],
324 "description": "New status. \"blocked\" marks a task stalled on something outside it (pair it with a new task for the blocker); \"deleted\" permanently removes the task from the list."
325 },
326 "subject": { "type": "string", "description": "Replacement subject." },
327 "active_form": { "type": "string", "description": "Replacement active form." },
328 "description": { "type": "string", "description": "Replacement description." }
329 },
330 "required": ["id"]
331 }
332 },
333 "explanation": {
334 "type": "string",
335 "description": "One-line rationale for scope pivots (deleting, reordering, or reshaping work). Shown to the user."
336 }
337 },
338 "required": ["updates"]
339 }),
340 }
341 }
342
343 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
344 let started = Instant::now();
345 let secs = || started.elapsed().as_secs_f64();
346 if let Some(blocked) = plan_mode_block(&ctx, secs()) {
347 return blocked;
348 }
349 let Some(broker) = ctx.tasks.clone() else {
350 return no_broker(secs());
351 };
352 let edits = match parse_edits(&args) {
353 Ok(e) => e,
354 Err(e) => return ToolOutcome::error(e, secs()),
355 };
356 let (report, store) = broker.update(edits.clone()).await;
357 if report.applied.is_empty() {
358 return ToolOutcome::error(
359 format!("No updates applied:\n{}", report.errors.join("\n")),
360 secs(),
361 );
362 }
363 let mut out = String::new();
364 for edit in &edits {
365 if !report.applied.contains(&edit.id) {
366 continue;
367 }
368 match edit.status {
369 Some(status) => {
370 out.push_str(&format!("#{} -> {}\n", edit.id, status.as_str()));
371 },
372 None => out.push_str(&format!("#{} updated\n", edit.id)),
373 }
374 }
375 for err in &report.errors {
376 out.push_str(&format!("error: {err}\n"));
377 }
378 out.push_str(&store.progress_string());
379 for note in &report.notes {
380 out.push('\n');
381 out.push_str(note);
382 }
383 ToolOutcome::success(out, store.progress_string(), secs())
384 .with_metadata(metadata("update", &store))
385 }
386}
387
388#[async_trait]
389impl ToolExecutor for TaskListTool {
390 fn name(&self) -> &'static str {
391 "task_list"
392 }
393
394 fn schema(&self) -> ToolDefinition {
395 ToolDefinition {
396 name: "task_list".to_string(),
397 description: "Read back the current session checklist: every task with its id, \
398 status, description, and recent evidence (the work recorded while it was in \
399 progress). Call it to re-anchor after a context compaction, or when unsure of \
400 a task id or the plan's current state. The user also sees this list live in \
401 the terminal, so you never need to repeat its contents in prose."
402 .to_string(),
403 input_schema: serde_json::json!({
404 "type": "object",
405 "properties": {}
406 }),
407 }
408 }
409
410 async fn execute(&self, _args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
411 let started = Instant::now();
412 let secs = || started.elapsed().as_secs_f64();
413 let Some(broker) = ctx.tasks.clone() else {
414 return no_broker(secs());
415 };
416 let store = broker.snapshot();
417 ToolOutcome::success(render_list(&store), store.progress_string(), secs())
418 .with_metadata(metadata("list", &store))
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::domain::{ToolCallId, TurnId};
426 use crate::providers::ctx::test_exec_context;
427 use crate::providers::tasks::TaskBroker;
428 use std::path::PathBuf;
429
430 fn ctx_with_broker() -> (
431 ExecContext,
432 TaskBroker,
433 tokio::sync::mpsc::Receiver<crate::domain::Msg>,
434 ) {
435 let (mut ctx, _progress) =
436 test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
437 let (tx, rx) = tokio::sync::mpsc::channel(32);
438 let broker = TaskBroker::new(tx);
439 ctx.tasks = Some(broker.clone());
440 (ctx, broker, rx)
441 }
442
443 fn create_args(n: usize) -> serde_json::Value {
444 let tasks: Vec<serde_json::Value> = (0..n)
445 .map(|i| {
446 serde_json::json!({
447 "subject": format!("task {i}"),
448 "active_form": format!("doing task {i}"),
449 "status": if i == 0 { "in_progress" } else { "pending" },
450 })
451 })
452 .collect();
453 serde_json::json!({ "tasks": tasks })
454 }
455
456 #[tokio::test]
457 async fn create_returns_ids_and_progress() {
458 let (ctx, _broker, _rx) = ctx_with_broker();
459 let outcome = TaskCreateTool.execute(create_args(3), ctx).await;
460 assert!(outcome.error.is_none(), "{:?}", outcome.error);
461 assert!(outcome.model_content.contains("#1 [in_progress] task 0"));
462 assert!(outcome.model_content.contains("#3 [pending] task 2"));
463 assert!(outcome.model_content.contains("Tasks 0/3"));
464 }
465
466 #[tokio::test]
467 async fn update_batches_and_appends_notes() {
468 let (ctx, broker, _rx) = ctx_with_broker();
469 let outcome = TaskCreateTool.execute(create_args(3), ctx).await;
470 assert!(outcome.error.is_none());
471
472 let (ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
473 let mut ctx2 = ctx2;
474 ctx2.tasks = Some(broker.clone());
475 let outcome = TaskUpdateTool
476 .execute(
477 serde_json::json!({ "updates": [
478 { "id": 1, "status": "completed" },
479 { "id": 2, "status": "in_progress" },
480 { "id": 3, "status": "in_progress" },
481 ]}),
482 ctx2,
483 )
484 .await;
485 assert!(outcome.error.is_none());
486 assert!(outcome.model_content.contains("#1 -> completed"));
487 assert!(outcome.model_content.contains("Tasks 1/3"));
488 assert!(
490 outcome
491 .model_content
492 .contains("at most one task in_progress")
493 );
494 assert_eq!(outcome.summary, "Tasks 1/3");
495 }
496
497 #[tokio::test]
498 async fn update_all_unknown_ids_is_an_error() {
499 let (ctx, _broker, _rx) = ctx_with_broker();
500 let outcome = TaskUpdateTool
501 .execute(
502 serde_json::json!({ "updates": [{ "id": 42, "status": "completed" }]}),
503 ctx,
504 )
505 .await;
506 assert!(outcome.error.is_some());
507 assert!(
508 outcome
509 .error
510 .as_deref()
511 .unwrap_or_default()
512 .contains("no such task")
513 );
514 }
515
516 #[tokio::test]
517 async fn list_renders_descriptions_and_evidence() {
518 let (ctx, broker, _rx) = ctx_with_broker();
519 let outcome = TaskCreateTool
520 .execute(
521 serde_json::json!({ "tasks": [{
522 "subject": "wire broker",
523 "active_form": "wiring broker",
524 "description": "through ExecContext",
525 "status": "in_progress",
526 }]}),
527 ctx,
528 )
529 .await;
530 assert!(outcome.error.is_none());
531 broker
532 .record_evidence(crate::domain::EvidenceEntry {
533 tool: "edit_file".into(),
534 target: "src/x.rs".into(),
535 status: "ok".into(),
536 })
537 .await;
538
539 let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
540 ctx2.tasks = Some(broker);
541 let outcome = TaskListTool.execute(serde_json::json!({}), ctx2).await;
542 assert!(
543 outcome
544 .model_content
545 .contains("#1 [in_progress] wire broker")
546 );
547 assert!(outcome.model_content.contains(" through ExecContext"));
548 assert!(
549 outcome
550 .model_content
551 .contains("evidence: edit_file src/x.rs (ok)")
552 );
553 }
554
555 #[tokio::test]
556 async fn missing_broker_degrades_gracefully() {
557 let (ctx, _p) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
558 let outcome = TaskCreateTool.execute(create_args(1), ctx).await;
559 assert!(outcome.error.is_none());
560 assert!(outcome.model_content.contains("unavailable"));
561 }
562
563 #[tokio::test]
564 async fn create_rejects_malformed_args() {
565 let (ctx, _broker, _rx) = ctx_with_broker();
566 let outcome = TaskCreateTool
567 .execute(serde_json::json!({ "tasks": [] }), ctx)
568 .await;
569 assert!(outcome.error.is_some());
570
571 let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
572 let (tx, _rx2) = tokio::sync::mpsc::channel(8);
573 ctx2.tasks = Some(TaskBroker::new(tx));
574 let outcome = TaskCreateTool
575 .execute(serde_json::json!({ "tasks": [{ "subject": "x" }] }), ctx2)
576 .await;
577 assert!(
578 outcome
579 .error
580 .as_deref()
581 .unwrap_or_default()
582 .contains("active_form")
583 );
584 }
585}