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 ("exactly 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 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        // Two in_progress after the batch: the advisory note must land.
488        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}