1use crate::db::Database;
15use crate::persistence::Persistence as _;
16use crate::providers::ToolDefinition;
17use anyhow::Result;
18use serde::{Deserialize, Serialize};
19use serde_json::{Value, json};
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum TodoStatus {
27 Pending,
29 InProgress,
31 Completed,
33}
34
35impl TodoStatus {
36 fn from_str(s: &str) -> Option<Self> {
37 match s {
38 "pending" => Some(Self::Pending),
39 "in_progress" => Some(Self::InProgress),
40 "completed" => Some(Self::Completed),
41 _ => None,
42 }
43 }
44
45 fn checkbox(&self) -> &'static str {
47 match self {
48 Self::Pending => "[ ]",
49 Self::InProgress => "[→]",
50 Self::Completed => "[x]",
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum TodoPriority {
59 High,
61 Medium,
63 Low,
65}
66
67impl TodoPriority {
68 fn from_str(s: &str) -> Option<Self> {
69 match s {
70 "high" => Some(Self::High),
71 "medium" => Some(Self::Medium),
72 "low" => Some(Self::Low),
73 _ => None,
74 }
75 }
76
77 fn suffix(&self) -> &'static str {
79 match self {
80 Self::High => " ⚡",
81 Self::Medium | Self::Low => "",
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct TodoItem {
89 pub content: String,
91 pub status: TodoStatus,
93 pub priority: TodoPriority,
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct TodoChange {
108 pub before: TodoItem,
110 pub after: TodoItem,
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
127pub struct TodoDiff {
128 pub added: Vec<TodoItem>,
130 pub removed: Vec<TodoItem>,
132 pub changed: Vec<TodoChange>,
135}
136
137impl TodoDiff {
138 pub fn is_empty(&self) -> bool {
143 self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
144 }
145
146 fn compute(old: &[TodoItem], new: &[TodoItem]) -> Self {
152 let mut added = Vec::new();
153 let mut removed = Vec::new();
154 let mut changed = Vec::new();
155
156 for n in new {
159 match old.iter().find(|o| o.content == n.content) {
160 None => added.push(n.clone()),
161 Some(o) if o != n => changed.push(TodoChange {
162 before: o.clone(),
163 after: n.clone(),
164 }),
165 Some(_) => { }
166 }
167 }
168
169 for o in old {
172 if !new.iter().any(|n| n.content == o.content) {
173 removed.push(o.clone());
174 }
175 }
176
177 Self {
178 added,
179 removed,
180 changed,
181 }
182 }
183}
184
185#[derive(Debug, Clone)]
201pub struct TodoWriteOutcome {
202 pub message: String,
204 pub items: Vec<TodoItem>,
208 pub diff: TodoDiff,
212}
213
214pub fn definitions() -> Vec<ToolDefinition> {
218 vec![ToolDefinition {
219 name: "TodoWrite".to_string(),
220 description: "Create and manage a structured task list for the current session. \
221 Rewrite the full list on every call — include all tasks, not just changed ones. \
222 Use proactively for: multi-step tasks (3+ steps), complex refactors, or when \
223 the user provides a list of things to do. Mark tasks `in_progress` BEFORE \
224 starting and `completed` immediately after finishing. Only one task should be \
225 `in_progress` at a time."
226 .to_string(),
227 parameters: json!({
228 "type": "object",
229 "properties": {
230 "todos": {
231 "type": "array",
232 "description": "The complete todo list (replaces any previous list)",
233 "items": {
234 "type": "object",
235 "properties": {
236 "content": {
237 "type": "string",
238 "description": "Actionable task description in imperative form"
239 },
240 "status": {
241 "type": "string",
242 "enum": ["pending", "in_progress", "completed"],
243 "description": "Current status of the task"
244 },
245 "priority": {
246 "type": "string",
247 "enum": ["high", "medium", "low"],
248 "description": "Task priority"
249 }
250 },
251 "required": ["content", "status", "priority"]
252 }
253 }
254 },
255 "required": ["todos"]
256 }),
257 }]
258}
259
260pub async fn todo_write(db: &Database, session_id: &str, args: &Value) -> Result<TodoWriteOutcome> {
286 let raw = args
287 .get("todos")
288 .and_then(|v| v.as_array())
289 .ok_or_else(|| anyhow::anyhow!("Missing 'todos' array"))?;
290
291 let mut todos: Vec<TodoItem> = Vec::with_capacity(raw.len());
292 for (i, item) in raw.iter().enumerate() {
293 let content = item
294 .get("content")
295 .and_then(|v| v.as_str())
296 .filter(|s| !s.trim().is_empty())
297 .ok_or_else(|| anyhow::anyhow!("todos[{i}]: 'content' must be a non-empty string"))?
298 .to_string();
299
300 let status_str = item
301 .get("status")
302 .and_then(|v| v.as_str())
303 .ok_or_else(|| anyhow::anyhow!("todos[{i}]: missing 'status'"))?;
304 let status = TodoStatus::from_str(status_str).ok_or_else(|| {
305 anyhow::anyhow!(
306 "todos[{i}]: invalid status '{status_str}' — use pending/in_progress/completed"
307 )
308 })?;
309
310 let priority_str = item
311 .get("priority")
312 .and_then(|v| v.as_str())
313 .ok_or_else(|| anyhow::anyhow!("todos[{i}]: missing 'priority'"))?;
314 let priority = TodoPriority::from_str(priority_str).ok_or_else(|| {
315 anyhow::anyhow!("todos[{i}]: invalid priority '{priority_str}' — use high/medium/low")
316 })?;
317
318 todos.push(TodoItem {
319 content,
320 status,
321 priority,
322 });
323 }
324
325 let in_progress = todos
329 .iter()
330 .filter(|t| t.status == TodoStatus::InProgress)
331 .count();
332 if in_progress > 1 {
333 anyhow::bail!(
334 "Invalid todo list: {in_progress} tasks marked 'in_progress'. \
335 Only one task may be 'in_progress' at a time — mark all but one as \
336 'pending' or 'completed' and call TodoWrite again."
337 );
338 }
339
340 let old: Vec<TodoItem> = match db.get_todo(session_id).await {
342 Ok(Some(raw)) => serde_json::from_str(&raw).unwrap_or_default(),
343 _ => Vec::new(),
344 };
345
346 if old == todos {
350 return Ok(TodoWriteOutcome {
351 message: format!(
352 "Todo list unchanged ({} task{}). \
353 Do not call TodoWrite again unless you are changing a task's status or content.",
354 todos.len(),
355 if todos.len() == 1 { "" } else { "s" }
356 ),
357 items: todos,
358 diff: TodoDiff::default(),
359 });
360 }
361
362 let diff = TodoDiff::compute(&old, &todos);
363
364 let json = serde_json::to_string(&todos)?;
365 db.set_todo(session_id, &json).await?;
366
367 Ok(TodoWriteOutcome {
368 message: format_todo_list(&todos),
369 items: todos,
370 diff,
371 })
372}
373
374fn format_item(t: &TodoItem) -> String {
378 format!("{} {}", t.status.checkbox(), t.content)
379}
380
381fn format_todo_list(todos: &[TodoItem]) -> String {
382 if todos.is_empty() {
383 return "Todo list cleared.".to_string();
384 }
385
386 let completed = todos
387 .iter()
388 .filter(|t| t.status == TodoStatus::Completed)
389 .count();
390
391 let mut out = format!("Todo list updated ({}/{} done):\n", completed, todos.len(),);
392 for t in todos {
393 out.push_str(&format!(" {}{}\n", format_item(t), t.priority.suffix()));
394 }
395 out
396}
397
398#[cfg(test)]
401mod tests {
402 use super::*;
403 use serde_json::json;
404 use tempfile::TempDir;
405
406 async fn test_db() -> (Database, TempDir, String) {
407 let dir = TempDir::new().unwrap();
408 let db = Database::open(&dir.path().join("test.db")).await.unwrap();
409 use crate::persistence::Persistence;
410 let sid = db.create_session("koda", dir.path()).await.unwrap();
411 (db, dir, sid)
412 }
413
414 #[tokio::test]
415 async fn write_and_read_back() {
416 let (db, _dir, sid) = test_db().await;
417 let args = json!({
418 "todos": [
419 {"content": "Add tests", "status": "pending", "priority": "high"},
420 {"content": "Write docs", "status": "in_progress", "priority": "medium"},
421 ]
422 });
423 let out = todo_write(&db, &sid, &args).await.unwrap();
424 assert!(out.message.contains("0/2 done"));
425 assert!(out.message.contains("[ ] Add tests"));
426 assert!(out.message.contains("[→] Write docs"));
427
428 use crate::persistence::Persistence;
434 let raw = db.get_todo(&sid).await.unwrap().expect("row persisted");
435 assert!(raw.contains("Add tests"));
436 assert!(raw.contains("Write docs"));
437 }
438
439 #[tokio::test]
440 async fn empty_list_clears_todos() {
441 let (db, _dir, sid) = test_db().await;
442 let args = json!({ "todos": [
444 {"content": "Task", "status": "pending", "priority": "low"}
445 ]});
446 todo_write(&db, &sid, &args).await.unwrap();
447 let clear = json!({ "todos": [] });
449 let out = todo_write(&db, &sid, &clear).await.unwrap();
450 assert!(out.message.contains("cleared"));
451 use crate::persistence::Persistence;
454 let raw = db.get_todo(&sid).await.unwrap().expect("row persisted");
455 assert_eq!(raw, "[]");
456 }
457
458 #[tokio::test]
459 async fn invalid_status_returns_error() {
460 let (db, _dir, sid) = test_db().await;
461 let args = json!({
462 "todos": [{"content": "Task", "status": "doing", "priority": "high"}]
463 });
464 let err = todo_write(&db, &sid, &args).await.unwrap_err();
465 assert!(err.to_string().contains("invalid status"));
466 }
467
468 #[tokio::test]
469 async fn invalid_priority_returns_error() {
470 let (db, _dir, sid) = test_db().await;
471 let args = json!({
472 "todos": [{"content": "Task", "status": "pending", "priority": "urgent"}]
473 });
474 let err = todo_write(&db, &sid, &args).await.unwrap_err();
475 assert!(err.to_string().contains("invalid priority"));
476 }
477
478 #[tokio::test]
479 async fn empty_content_returns_error() {
480 let (db, _dir, sid) = test_db().await;
481 let args = json!({
482 "todos": [{"content": " ", "status": "pending", "priority": "low"}]
483 });
484 let err = todo_write(&db, &sid, &args).await.unwrap_err();
485 assert!(err.to_string().contains("non-empty"));
486 }
487
488 #[tokio::test]
489 async fn missing_todos_field_returns_error() {
490 let (db, _dir, sid) = test_db().await;
491 let err = todo_write(&db, &sid, &json!({})).await.unwrap_err();
492 assert!(err.to_string().contains("todos"));
493 }
494
495 #[test]
496 fn format_single_task() {
497 let todos = vec![TodoItem {
498 content: "Ship it".into(),
499 status: TodoStatus::InProgress,
500 priority: TodoPriority::High,
501 }];
502 let out = format_todo_list(&todos);
503 assert!(out.contains("0/1 done"));
504 assert!(out.contains("[→] Ship it"));
505 assert!(out.contains("⚡"));
507 }
508
509 #[test]
510 fn format_completed_task() {
511 let todos = vec![
512 TodoItem {
513 content: "Done thing".into(),
514 status: TodoStatus::Completed,
515 priority: TodoPriority::Medium,
516 },
517 TodoItem {
518 content: "Todo thing".into(),
519 status: TodoStatus::Pending,
520 priority: TodoPriority::Low,
521 },
522 ];
523 let out = format_todo_list(&todos);
524 assert!(out.contains("1/2 done"));
525 assert!(out.contains("[x] Done thing"));
526 assert!(out.contains("[ ] Todo thing"));
527 assert!(!out.contains("⚡") || !out.contains("Done thing ⚡"));
529 }
530
531 #[test]
532 fn status_checkbox_coverage() {
533 assert_eq!(TodoStatus::Pending.checkbox(), "[ ]");
534 assert_eq!(TodoStatus::InProgress.checkbox(), "[→]");
535 assert_eq!(TodoStatus::Completed.checkbox(), "[x]");
536 }
537
538 #[tokio::test]
539 async fn dedup_skips_identical_write() {
540 let (db, _dir, sid) = test_db().await;
541 let args = json!({
542 "todos": [
543 {"content": "Task A", "status": "pending", "priority": "high"},
544 {"content": "Task B", "status": "in_progress", "priority": "medium"},
545 ]
546 });
547 let out1 = todo_write(&db, &sid, &args).await.unwrap();
549 assert!(out1.message.contains("0/2 done"));
550
551 let out2 = todo_write(&db, &sid, &args).await.unwrap();
553 assert!(
554 out2.message.contains("unchanged"),
555 "identical call should return 'unchanged', got: {}",
556 out2.message
557 );
558 assert!(
559 out2.message.contains("Do not call TodoWrite again"),
560 "should tell model to stop calling"
561 );
562 assert!(
563 out2.diff.is_empty(),
564 "unchanged write must yield an empty diff so the dispatch \
565 layer suppresses the TodoUpdate event"
566 );
567 }
568
569 #[tokio::test]
570 async fn dedup_allows_status_change() {
571 let (db, _dir, sid) = test_db().await;
572 let args1 = json!({
573 "todos": [
574 {"content": "Task A", "status": "pending", "priority": "high"},
575 ]
576 });
577 todo_write(&db, &sid, &args1).await.unwrap();
578
579 let args2 = json!({
581 "todos": [
582 {"content": "Task A", "status": "completed", "priority": "high"},
583 ]
584 });
585 let out = todo_write(&db, &sid, &args2).await.unwrap();
586 assert!(
587 out.message.contains("1/1 done"),
588 "status change should write normally, got: {}",
589 out.message
590 );
591 assert!(out.message.contains("[x] Task A"));
592 }
593
594 #[tokio::test]
602 async fn rejects_two_in_progress_items() {
603 let (db, _dir, sid) = test_db().await;
604 let args = json!({
605 "todos": [
606 {"content": "A", "status": "in_progress", "priority": "high"},
607 {"content": "B", "status": "in_progress", "priority": "medium"},
608 ]
609 });
610 let err = todo_write(&db, &sid, &args).await.unwrap_err();
611 let msg = err.to_string();
612 assert!(msg.contains("Only one task"), "got: {msg}");
613 assert!(msg.contains("in_progress"), "got: {msg}");
614 use crate::persistence::Persistence;
616 assert!(
617 db.get_todo(&sid).await.unwrap().is_none(),
618 "failed validation must not touch the DB"
619 );
620 }
621
622 #[tokio::test]
625 async fn accepts_single_in_progress() {
626 let (db, _dir, sid) = test_db().await;
627 let args = json!({
628 "todos": [
629 {"content": "A", "status": "in_progress", "priority": "high"},
630 {"content": "B", "status": "pending", "priority": "medium"},
631 {"content": "C", "status": "pending", "priority": "low"},
632 ]
633 });
634 let out = todo_write(&db, &sid, &args).await.unwrap();
635 assert!(out.message.contains("0/3 done"));
636 }
637
638 #[tokio::test]
641 async fn accepts_zero_in_progress() {
642 let (db, _dir, sid) = test_db().await;
643 let args = json!({
644 "todos": [
645 {"content": "A", "status": "pending", "priority": "high"},
646 {"content": "B", "status": "pending", "priority": "medium"},
647 ]
648 });
649 let out = todo_write(&db, &sid, &args).await.unwrap();
650 assert!(out.message.contains("0/2 done"));
651 }
652
653 fn item(content: &str, status: TodoStatus, priority: TodoPriority) -> TodoItem {
656 TodoItem {
657 content: content.into(),
658 status,
659 priority,
660 }
661 }
662
663 #[test]
664 fn diff_first_write_lands_everything_in_added() {
665 let new = vec![
670 item("A", TodoStatus::Pending, TodoPriority::High),
671 item("B", TodoStatus::InProgress, TodoPriority::Medium),
672 ];
673 let diff = TodoDiff::compute(&[], &new);
674 assert_eq!(diff.added.len(), 2);
675 assert!(diff.changed.is_empty());
676 assert!(diff.removed.is_empty());
677 assert!(!diff.is_empty());
678 }
679
680 #[test]
681 fn diff_clear_lands_everything_in_removed() {
682 let old = vec![
683 item("A", TodoStatus::Pending, TodoPriority::High),
684 item("B", TodoStatus::Completed, TodoPriority::Medium),
685 ];
686 let diff = TodoDiff::compute(&old, &[]);
687 assert!(diff.added.is_empty());
688 assert!(diff.changed.is_empty());
689 assert_eq!(diff.removed.len(), 2);
690 }
691
692 #[test]
693 fn diff_status_change_lands_in_changed() {
694 let old = vec![item("A", TodoStatus::Pending, TodoPriority::High)];
699 let new = vec![item("A", TodoStatus::InProgress, TodoPriority::High)];
700 let diff = TodoDiff::compute(&old, &new);
701 assert!(diff.added.is_empty());
702 assert!(diff.removed.is_empty());
703 assert_eq!(diff.changed.len(), 1);
704 assert_eq!(diff.changed[0].before.status, TodoStatus::Pending);
705 assert_eq!(diff.changed[0].after.status, TodoStatus::InProgress);
706 }
707
708 #[test]
709 fn diff_rename_lands_as_remove_plus_add() {
710 let old = vec![item("old name", TodoStatus::Pending, TodoPriority::High)];
718 let new = vec![item("new name", TodoStatus::Pending, TodoPriority::High)];
719 let diff = TodoDiff::compute(&old, &new);
720 assert_eq!(diff.added.len(), 1);
721 assert_eq!(diff.removed.len(), 1);
722 assert!(diff.changed.is_empty());
723 }
724
725 #[test]
726 fn diff_unchanged_item_does_not_surface() {
727 let old = vec![
731 item("A", TodoStatus::Pending, TodoPriority::High),
732 item("B", TodoStatus::InProgress, TodoPriority::Medium),
733 ];
734 let new = vec![
735 item("A", TodoStatus::Pending, TodoPriority::High), item("B", TodoStatus::Completed, TodoPriority::Medium), ];
738 let diff = TodoDiff::compute(&old, &new);
739 assert!(diff.added.is_empty());
740 assert!(diff.removed.is_empty());
741 assert_eq!(diff.changed.len(), 1);
742 assert_eq!(diff.changed[0].after.content, "B");
743 }
744
745 #[test]
746 fn diff_priority_only_change_lands_in_changed() {
747 let old = vec![item("A", TodoStatus::Pending, TodoPriority::Low)];
754 let new = vec![item("A", TodoStatus::Pending, TodoPriority::High)];
755 let diff = TodoDiff::compute(&old, &new);
756 assert_eq!(diff.changed.len(), 1);
757 assert_eq!(diff.changed[0].before.priority, TodoPriority::Low);
758 assert_eq!(diff.changed[0].after.priority, TodoPriority::High);
759 }
760
761 #[test]
762 fn diff_empty_when_lists_identical() {
763 let old = vec![item("A", TodoStatus::Pending, TodoPriority::High)];
764 let new = old.clone();
765 let diff = TodoDiff::compute(&old, &new);
766 assert!(diff.is_empty(), "identical lists must produce no diff");
767 }
768
769 #[tokio::test]
776 async fn outcome_items_populated_on_dedup_path() {
777 let (db, _dir, sid) = test_db().await;
778 let args = json!({
779 "todos": [
780 {"content": "A", "status": "pending", "priority": "high"},
781 ]
782 });
783 todo_write(&db, &sid, &args).await.unwrap();
784 let out2 = todo_write(&db, &sid, &args).await.unwrap();
785 assert!(out2.diff.is_empty(), "dedup must yield empty diff");
786 assert_eq!(out2.items.len(), 1, "dedup must still populate items");
787 assert_eq!(out2.items[0].content, "A");
788 }
789
790 #[tokio::test]
794 async fn outcome_first_write_yields_added_diff() {
795 let (db, _dir, sid) = test_db().await;
796 let args = json!({
797 "todos": [
798 {"content": "A", "status": "pending", "priority": "high"},
799 {"content": "B", "status": "in_progress", "priority": "medium"},
800 ]
801 });
802 let out = todo_write(&db, &sid, &args).await.unwrap();
803 assert!(!out.diff.is_empty());
804 assert_eq!(out.diff.added.len(), 2);
805 assert!(out.diff.removed.is_empty());
806 assert!(out.diff.changed.is_empty());
807 assert_eq!(out.items.len(), 2);
808 }
809}