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