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 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 for note in mermaid_domain::advisory_notes(&store, &[], &store) {
256 out.push('\n');
257 out.push_str(¬e);
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 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}