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 exactly one task in_progress at all times: 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, leave it in_progress and create a task for \
303 the blocker. When the plan changes shape (splitting, merging, dropping work), \
304 update or delete tasks and say why in `explanation` — do not let the \
305 checklist go stale while you work."
306 .to_string(),
307 input_schema: serde_json::json!({
308 "type": "object",
309 "properties": {
310 "updates": {
311 "type": "array",
312 "description": "Differential updates, applied in order. Only `id` is required; omitted fields stay unchanged.",
313 "items": {
314 "type": "object",
315 "properties": {
316 "id": {
317 "type": "integer",
318 "description": "Task id from task_create."
319 },
320 "status": {
321 "type": "string",
322 "enum": ["pending", "in_progress", "completed", "deleted"],
323 "description": "New status. \"deleted\" permanently removes the task from the list."
324 },
325 "subject": { "type": "string", "description": "Replacement subject." },
326 "active_form": { "type": "string", "description": "Replacement active form." },
327 "description": { "type": "string", "description": "Replacement description." }
328 },
329 "required": ["id"]
330 }
331 },
332 "explanation": {
333 "type": "string",
334 "description": "One-line rationale for scope pivots (deleting, reordering, or reshaping work). Shown to the user."
335 }
336 },
337 "required": ["updates"]
338 }),
339 }
340 }
341
342 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
343 let started = Instant::now();
344 let secs = || started.elapsed().as_secs_f64();
345 if let Some(blocked) = plan_mode_block(&ctx, secs()) {
346 return blocked;
347 }
348 let Some(broker) = ctx.tasks.clone() else {
349 return no_broker(secs());
350 };
351 let edits = match parse_edits(&args) {
352 Ok(e) => e,
353 Err(e) => return ToolOutcome::error(e, secs()),
354 };
355 let (report, store) = broker.update(edits.clone()).await;
356 if report.applied.is_empty() {
357 return ToolOutcome::error(
358 format!("No updates applied:\n{}", report.errors.join("\n")),
359 secs(),
360 );
361 }
362 let mut out = String::new();
363 for edit in &edits {
364 if !report.applied.contains(&edit.id) {
365 continue;
366 }
367 match edit.status {
368 Some(status) => {
369 out.push_str(&format!("#{} -> {}\n", edit.id, status.as_str()));
370 },
371 None => out.push_str(&format!("#{} updated\n", edit.id)),
372 }
373 }
374 for err in &report.errors {
375 out.push_str(&format!("error: {err}\n"));
376 }
377 out.push_str(&store.progress_string());
378 for note in &report.notes {
379 out.push('\n');
380 out.push_str(note);
381 }
382 ToolOutcome::success(out, store.progress_string(), secs())
383 .with_metadata(metadata("update", &store))
384 }
385}
386
387#[async_trait]
388impl ToolExecutor for TaskListTool {
389 fn name(&self) -> &'static str {
390 "task_list"
391 }
392
393 fn schema(&self) -> ToolDefinition {
394 ToolDefinition {
395 name: "task_list".to_string(),
396 description: "Read back the current session checklist: every task with its id, \
397 status, description, and recent evidence (the work recorded while it was in \
398 progress). Call it to re-anchor after a context compaction, or when unsure of \
399 a task id or the plan's current state. The user also sees this list live in \
400 the terminal, so you never need to repeat its contents in prose."
401 .to_string(),
402 input_schema: serde_json::json!({
403 "type": "object",
404 "properties": {}
405 }),
406 }
407 }
408
409 async fn execute(&self, _args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
410 let started = Instant::now();
411 let secs = || started.elapsed().as_secs_f64();
412 let Some(broker) = ctx.tasks.clone() else {
413 return no_broker(secs());
414 };
415 let store = broker.snapshot();
416 ToolOutcome::success(render_list(&store), store.progress_string(), secs())
417 .with_metadata(metadata("list", &store))
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use crate::domain::{ToolCallId, TurnId};
425 use crate::providers::ctx::test_exec_context;
426 use crate::providers::tasks::TaskBroker;
427 use std::path::PathBuf;
428
429 fn ctx_with_broker() -> (
430 ExecContext,
431 TaskBroker,
432 tokio::sync::mpsc::Receiver<crate::domain::Msg>,
433 ) {
434 let (mut ctx, _progress) =
435 test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
436 let (tx, rx) = tokio::sync::mpsc::channel(32);
437 let broker = TaskBroker::new(tx);
438 ctx.tasks = Some(broker.clone());
439 (ctx, broker, rx)
440 }
441
442 fn create_args(n: usize) -> serde_json::Value {
443 let tasks: Vec<serde_json::Value> = (0..n)
444 .map(|i| {
445 serde_json::json!({
446 "subject": format!("task {i}"),
447 "active_form": format!("doing task {i}"),
448 "status": if i == 0 { "in_progress" } else { "pending" },
449 })
450 })
451 .collect();
452 serde_json::json!({ "tasks": tasks })
453 }
454
455 #[tokio::test]
456 async fn create_returns_ids_and_progress() {
457 let (ctx, _broker, _rx) = ctx_with_broker();
458 let outcome = TaskCreateTool.execute(create_args(3), ctx).await;
459 assert!(outcome.error.is_none(), "{:?}", outcome.error);
460 assert!(outcome.model_content.contains("#1 [in_progress] task 0"));
461 assert!(outcome.model_content.contains("#3 [pending] task 2"));
462 assert!(outcome.model_content.contains("Tasks 0/3"));
463 }
464
465 #[tokio::test]
466 async fn update_batches_and_appends_notes() {
467 let (ctx, broker, _rx) = ctx_with_broker();
468 let outcome = TaskCreateTool.execute(create_args(3), ctx).await;
469 assert!(outcome.error.is_none());
470
471 let (ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
472 let mut ctx2 = ctx2;
473 ctx2.tasks = Some(broker.clone());
474 let outcome = TaskUpdateTool
475 .execute(
476 serde_json::json!({ "updates": [
477 { "id": 1, "status": "completed" },
478 { "id": 2, "status": "in_progress" },
479 { "id": 3, "status": "in_progress" },
480 ]}),
481 ctx2,
482 )
483 .await;
484 assert!(outcome.error.is_none());
485 assert!(outcome.model_content.contains("#1 -> completed"));
486 assert!(outcome.model_content.contains("Tasks 1/3"));
487 assert!(
489 outcome
490 .model_content
491 .contains("exactly one task in_progress")
492 );
493 assert_eq!(outcome.summary, "Tasks 1/3");
494 }
495
496 #[tokio::test]
497 async fn update_all_unknown_ids_is_an_error() {
498 let (ctx, _broker, _rx) = ctx_with_broker();
499 let outcome = TaskUpdateTool
500 .execute(
501 serde_json::json!({ "updates": [{ "id": 42, "status": "completed" }]}),
502 ctx,
503 )
504 .await;
505 assert!(outcome.error.is_some());
506 assert!(
507 outcome
508 .error
509 .as_deref()
510 .unwrap_or_default()
511 .contains("no such task")
512 );
513 }
514
515 #[tokio::test]
516 async fn list_renders_descriptions_and_evidence() {
517 let (ctx, broker, _rx) = ctx_with_broker();
518 let outcome = TaskCreateTool
519 .execute(
520 serde_json::json!({ "tasks": [{
521 "subject": "wire broker",
522 "active_form": "wiring broker",
523 "description": "through ExecContext",
524 "status": "in_progress",
525 }]}),
526 ctx,
527 )
528 .await;
529 assert!(outcome.error.is_none());
530 broker
531 .record_evidence(crate::domain::EvidenceEntry {
532 tool: "edit_file".into(),
533 target: "src/x.rs".into(),
534 status: "ok".into(),
535 })
536 .await;
537
538 let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
539 ctx2.tasks = Some(broker);
540 let outcome = TaskListTool.execute(serde_json::json!({}), ctx2).await;
541 assert!(
542 outcome
543 .model_content
544 .contains("#1 [in_progress] wire broker")
545 );
546 assert!(outcome.model_content.contains(" through ExecContext"));
547 assert!(
548 outcome
549 .model_content
550 .contains("evidence: edit_file src/x.rs (ok)")
551 );
552 }
553
554 #[tokio::test]
555 async fn missing_broker_degrades_gracefully() {
556 let (ctx, _p) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
557 let outcome = TaskCreateTool.execute(create_args(1), ctx).await;
558 assert!(outcome.error.is_none());
559 assert!(outcome.model_content.contains("unavailable"));
560 }
561
562 #[tokio::test]
563 async fn create_rejects_malformed_args() {
564 let (ctx, _broker, _rx) = ctx_with_broker();
565 let outcome = TaskCreateTool
566 .execute(serde_json::json!({ "tasks": [] }), ctx)
567 .await;
568 assert!(outcome.error.is_some());
569
570 let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
571 let (tx, _rx2) = tokio::sync::mpsc::channel(8);
572 ctx2.tasks = Some(TaskBroker::new(tx));
573 let outcome = TaskCreateTool
574 .execute(serde_json::json!({ "tasks": [{ "subject": "x" }] }), ctx2)
575 .await;
576 assert!(
577 outcome
578 .error
579 .as_deref()
580 .unwrap_or_default()
581 .contains("active_form")
582 );
583 }
584}