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