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