Skip to main content

koda_core/tools/
todo.rs

1//! TodoWrite tool — session-scoped task list.
2//!
3//! The model maintains the full todo list by rewriting it on every call.
4//! Items are persisted to session metadata (survives compaction) and injected
5//! into the system prompt each turn so the model always has its plan in view.
6//!
7//! ## Schema (matches Claude Code's TodoWrite)
8//!
9//! Each item has:
10//! - `content`  — what to do (non-empty string)
11//! - `status`   — `"pending"` | `"in_progress"` | `"completed"`
12//! - `priority` — `"high"` | `"medium"` | `"low"`
13
14use 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// ── Schema ─────────────────────────────────────────────────────────────────
22
23/// Completion state of a todo item.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum TodoStatus {
27    /// Not started.
28    Pending,
29    /// Currently being worked on (at most one task should be in this state).
30    InProgress,
31    /// Finished.
32    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    /// Checkbox-style marker — universally understood.
46    fn checkbox(&self) -> &'static str {
47        match self {
48            Self::Pending => "[ ]",
49            Self::InProgress => "[→]",
50            Self::Completed => "[x]",
51        }
52    }
53}
54
55/// Relative importance of a todo item.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum TodoPriority {
59    /// Must be done first.
60    High,
61    /// Normal importance.
62    Medium,
63    /// Nice-to-have.
64    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    /// Compact suffix shown after the task content (only for high priority).
78    fn suffix(&self) -> &'static str {
79        match self {
80            Self::High => " ⚡",
81            Self::Medium | Self::Low => "",
82        }
83    }
84}
85
86/// A single task in the session todo list.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct TodoItem {
89    /// Human-readable task description.
90    pub content: String,
91    /// Current completion state.
92    pub status: TodoStatus,
93    /// Relative importance.
94    pub priority: TodoPriority,
95}
96
97// ── Diff types ───────────────────────────────────────────
98
99/// Before/after pair for a todo whose `status` and/or `priority` changed
100/// while keeping the same `content` string.
101///
102/// Computed server-side by [`todo_write`] so every client (TUI / ACP /
103/// headless / future) gets the same animation primitives without
104/// having to maintain its own previous-list snapshot. Surfaces on
105/// [`crate::engine::EngineEvent::TodoUpdate`].
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct TodoChange {
108    /// State on the previously persisted list.
109    pub before: TodoItem,
110    /// State on the newly written list.
111    pub after: TodoItem,
112}
113
114/// Server-computed delta between the previously persisted todo list
115/// and the one the model just wrote.
116///
117/// **Matching key is `content`.** If the model renames a task the
118/// rename surfaces as one entry in `removed` plus one in `added`,
119/// which is the right semantic — a renamed task is conceptually a
120/// different task to the user even if the underlying intent is the
121/// same.
122///
123/// On the very first `TodoWrite` of a session, every item lands in
124/// `added`. On a clear (`todos: []`), every previously persisted
125/// item lands in `removed`.
126#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
127pub struct TodoDiff {
128    /// Items present on the new list whose `content` is not on the old list.
129    pub added: Vec<TodoItem>,
130    /// Items present on the old list whose `content` is not on the new list.
131    pub removed: Vec<TodoItem>,
132    /// Items present on both lists by `content` whose `status` or
133    /// `priority` changed.
134    pub changed: Vec<TodoChange>,
135}
136
137impl TodoDiff {
138    /// `true` when there are no additions, removals, or changes.
139    /// Used to suppress the `TodoUpdate` event on no-op writes — the
140    /// dedup-nudge path returns the "unchanged" message to the model
141    /// without surfacing a transition to clients.
142    pub fn is_empty(&self) -> bool {
143        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
144    }
145
146    /// Compute the diff between an old list and a new list.
147    ///
148    /// O(n*m) but n and m are bounded by typical todo-list size (low
149    /// dozens at the absolute outside) so a HashMap would be more
150    /// code than savings.
151    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        // Pass 1: walk new list. Each item is either added, changed, or
157        // unchanged-equal-to-old.
158        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(_) => { /* identical — no diff entry */ }
166            }
167        }
168
169        // Pass 2: walk old list. Anything whose content is missing from
170        // new is a removal.
171        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// ── Outcome ───────────────────────────────────────────────
186
187/// What [`todo_write`] returns to the dispatch layer.
188///
189/// The dispatch layer:
190/// 1. forwards `message` to the model as the tool result string;
191/// 2. when `diff.is_empty()` is `false`, emits
192///    [`crate::engine::EngineEvent::TodoUpdate`] with `items` and
193///    `diff` so every client sees the transition.
194///
195/// Splitting `message` (model-facing) from `items + diff` (client-
196/// facing) is the same separation Claude Code's `TodoWriteTool` uses
197/// (`mapToolResultToToolResultBlockParam` returns a plain string
198/// while the structured diff goes to the UI). It keeps the model's
199/// tool-result clean and the UI's render data rich.
200#[derive(Debug, Clone)]
201pub struct TodoWriteOutcome {
202    /// String returned to the model as the tool result.
203    pub message: String,
204    /// Full new list, after dedup short-circuit but always populated
205    /// (even on the unchanged path) so callers that want to mirror
206    /// the latest list don't have to re-read from the DB.
207    pub items: Vec<TodoItem>,
208    /// Server-computed diff against the previously persisted list.
209    /// `is_empty()` on the unchanged path; `added` is non-empty on
210    /// the first write of a session.
211    pub diff: TodoDiff,
212}
213
214// ── Tool definition ─────────────────────────────────────────────────────────
215
216/// Return the tool definition for the LLM.
217pub 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
260// ── Handler ───────────────────────────────────────────────
261
262/// Write the full todo list for this session.
263///
264/// Returns a [`TodoWriteOutcome`] with both the model-facing message
265/// and structured `items + diff` for the dispatch layer to surface
266/// via [`crate::engine::EngineEvent::TodoUpdate`].
267///
268/// **Validation** (rejected before any DB write):
269/// - `todos` must be an array.
270/// - Each item needs a non-empty `content`, a valid `status`, and a
271///   valid `priority`.
272/// - At most one item may have `status == InProgress`. Stolen from
273///   Gemini CLI (`packages/core/src/tools/write-todos.ts`); the
274///   only one of the four reference projects that enforces it
275///   server-side instead of via prompt discipline. Small,
276///   deterministic, removes one class of model failure mode.
277///
278/// **Content-aware dedup**: if the parsed list is byte-equal to
279/// what's already stored, we skip the write and return a short
280/// "unchanged" message. The returned `diff` is empty and the
281/// dispatch layer suppresses the `TodoUpdate` event — this prevents
282/// the model from burning tool calls (and triggering loop detection)
283/// by re-emitting the same plan, while also not spamming clients
284/// with no-op transitions.
285pub 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    // ── Single-in-progress invariant (#1077 Phase A) ────────────
326    // Reject before reading the previous list — this is a structural
327    // input error, not a state-dependent one.
328    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    // ── Load the previous list once (for both dedup and diff) ─────
341    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    // ── Content-aware dedup ─────────────────────────────
347    // Byte-equal previous list short-circuits the write AND the event
348    // emission. `TodoDiff::default()` (empty) signals "no transition".
349    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
374// ── Formatting ──────────────────────────────────────────────────────────────
375
376/// Format a single todo item: `[x] Task description`
377fn 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// ── Tests ───────────────────────────────────────────────────────────────────
399
400#[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        // (#1077 Phase B) Persistence verified through the public DB
429        // accessor instead of the deleted `get_todo_section` helper.
430        // Clients now mirror state from `EngineEvent::TodoUpdate`;
431        // the DB row stays as the source-of-truth for ACP
432        // reconnects but is no longer read into the system prompt.
433        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        // First write something
443        let args = json!({ "todos": [
444            {"content": "Task", "status": "pending", "priority": "low"}
445        ]});
446        todo_write(&db, &sid, &args).await.unwrap();
447        // Then clear it
448        let clear = json!({ "todos": [] });
449        let out = todo_write(&db, &sid, &clear).await.unwrap();
450        assert!(out.message.contains("cleared"));
451        // Persisted row is the empty list (not deleted) — same
452        // observable behaviour as before, just verified directly.
453        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        // High priority gets a suffix
506        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        // Medium/Low priority: no suffix
528        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        // First write — should persist and return full list
548        let out1 = todo_write(&db, &sid, &args).await.unwrap();
549        assert!(out1.message.contains("0/2 done"));
550
551        // Second write with identical content — should short-circuit
552        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        // Same content but status changed — should NOT short-circuit
580        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    // ── #1077 Phase A: validation ───────────────────────────
595
596    /// Two `in_progress` items must be rejected up front. Stolen
597    /// from Gemini CLI; the only one of the four reference projects
598    /// that enforces single-in-progress server-side. Without this,
599    /// the model can silently keep two tasks active and clients
600    /// render a contradictory checklist.
601    #[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        // Must reject BEFORE writing — the DB should still be empty.
615        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    /// Single `in_progress` is the happy path; many `pending`
623    /// alongside is fine.
624    #[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    /// Zero `in_progress` (all pending or all completed) is
639    /// permitted — the rule is at-most-one, not exactly-one.
640    #[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    // ── #1077 Phase A: diff computation ───────────────────────
654
655    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        // First write of a session: previous list is empty so every
666        // item must show up in `added` — nothing in `changed` or
667        // `removed`. This is what enables clients to do a one-shot
668        // "populate from empty" render on session start.
669        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        // Same content, status flipped — surfaces as a single
695        // `TodoChange`, not as removed+added. Clients can render
696        // an in-place state transition (animate the checkbox) vs. a
697        // wholesale list rewrite.
698        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        // Rename = different `content` string, so by design the diff
711        // surfaces it as removal + addition rather than a
712        // `TodoChange`. Documented behaviour on `TodoDiff` — if a
713        // future product decision wants rename detection, that's a
714        // schema change (need a stable id), not a diff-algorithm
715        // tweak. Lock this in with a test so the trade-off doesn't
716        // silently flip.
717        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        // An item identical on both sides must NOT appear in any
728        // bucket. This is what lets clients render "only what
729        // changed" without filtering noise.
730        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), // unchanged
736            item("B", TodoStatus::Completed, TodoPriority::Medium), // status flipped
737        ];
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        // Edge case: priority changed but status unchanged. The
748        // matching key is `content`; the change predicate is
749        // `before != after`, which uses derived `PartialEq` on the
750        // whole struct including priority. So priority bumps DO
751        // surface as `changed`. Important for clients that render
752        // priority badges — they need to know to re-render.
753        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    // ── #1077 Phase A: outcome shape ────────────────────────
770
771    /// `TodoWriteOutcome.items` must always carry the full list,
772    /// even on the dedup-nudge path. This is what lets clients
773    /// (e.g. ACP IDEs) mirror the latest state without re-reading
774    /// from the DB on every event.
775    #[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    /// First-ever write must produce a non-empty diff with the
791    /// initial items in `added`. This is the event the dispatch
792    /// layer surfaces as `EngineEvent::TodoUpdate`.
793    #[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}