Skip to main content

mermaid_cli/providers/tool/
tasks.rs

1//! The task checklist tools: `task_create`, `task_update`, `task_list`.
2//!
3//! The model's planning surface for multi-step work. Granular and
4//! id-addressed (Claude Code's Task-tool shape) with batch arrays in both
5//! create and update (codex's one-call ergonomics): the initial plan lands in
6//! one `task_create`, and "complete #2 + start #3" is one `task_update`.
7//! Every mutation goes through the session's `TaskBroker` (single writer),
8//! which publishes a snapshot the TUI renders live.
9//!
10//! Planning mutates nothing outside the session, so like `ask_user_question`
11//! these tools are ungated (they never touch the policy gate) and run in
12//! every safety mode, including read_only.
13//!
14//! Discipline ("at most one in_progress", no pending->completed jumps) is
15//! soft-enforced: violations come back as advisory notes in the tool result,
16//! never rejections — a hard reject risks retry loops, while silence (codex)
17//! lets malformed checklists render unremarked.
18
19use 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
33/// Tool result line for one task: `#3 [in_progress] wire the broker`.
34fn task_line(task: &TaskItem) -> String {
35    format!("#{} [{}] {}", task.id, task.status.as_str(), task.subject)
36}
37
38/// The checklist as the model sees it from `task_list`: numbered lines plus
39/// the progress footer, with per-task evidence indented underneath (the
40/// model's own audit trail of what actually happened while each ran).
41fn 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        // Show the tail of the evidence ring — enough to re-anchor after a
53        // compaction without ballooning the result.
54        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
65/// Graceful degradation for contexts with no broker (bare test harnesses):
66/// the model is told to carry on rather than erroring into a retry loop.
67fn 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
75/// Plan mode firewalls the checklist WRITERS: implementation steps belong in
76/// the plan file's Tasks section, which seeds the checklist when the plan is
77/// approved. Without a hard error models conflate the two planning surfaces
78/// (Codex shipped the same runtime error for the same reason). `task_list`
79/// stays available — reading is harmless.
80fn plan_mode_block(ctx: &crate::providers::ExecContext, secs: f64) -> Option<ToolOutcome> {
81    // Only an explicit `allow` in the plan profile unblocks the writers —
82    // `auto`/`ask` collapse to deny (ungated tools have no approval path).
83    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        // Creation can violate single-in_progress too (e.g. adding an
277        // in_progress task while another is active) — same advisory path.
278        for note in crate::domain::advisory_notes(&store, &[], &store) {
279            out.push('\n');
280            out.push_str(&note);
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        // Two in_progress after the batch: the advisory note must land.
489        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}