//! TodoWrite — new-architecture implementation. //! //! Reuses the core logic (`validate_no_duplicate_ids`, `apply_replace`, //! `apply_merge`, `summarize_todo_state`) from the old `implementations::todo` //! module. State is stored as `State` in Resources instead of //! `ToolState.todo_state`. use std::fmt::Write; use crate::types::output::{TodoWriteOutput, TodoWriteSuccess}; use crate::types::requirements::{Expr, ToolRequirement}; #[allow(unused_imports)] use crate::types::resources::{SharedResources, State}; use crate::types::tool::{ToolKind, ToolNamespace}; #[derive(thiserror::Error, Debug)] pub enum TodoError { #[error("Missing Todo content in mode: {0}")] MissingTodoContent(String), #[error("Missing Todo ID in mode: {0}")] MissingTodoID(String), #[error("Duplicate Todo ID in response: {0}")] DuplicateTodoID(String), } pub(crate) fn validate_no_duplicate_ids(updates: &[TodoUpdate]) -> Result<(), TodoError> { use std::collections::HashSet; let mut seen = HashSet::with_capacity(updates.len()); if let Some(dup) = updates.iter().map(|u| &u.id).find(|id| !seen.insert(*id)) { return Err(TodoError::DuplicateTodoID(dup.to_owned())); } Ok(()) } /// `merge=false`: the incoming list fully replaces the existing todo state. /// If `content` is omitted for an item, the `id` is used as a fallback. /// If `status` is omitted, it defaults to `Pending`. pub(crate) fn apply_replace( state: &mut TodoState, updates: &[TodoUpdate], ) -> Result<(), TodoError> { state.clear(); for u in updates { let content = if u.has_no_content() { u.id.clone() } else { u.content.clone().unwrap() }; let status = u.status.unwrap_or(TodoStatus::Pending); state.push( u.id.clone(), TodoItem { content, priority: TodoPriority::default(), status, meta: None, }, ); } Ok(()) } /// `merge=true`: updates are merged into the existing state. /// - **Existing items**: `content` is optional — if omitted the previous /// value is kept. This lets the model mark an item from `in_progress` → /// `completed` without echoing the content back. /// - **New items** (id not yet in state): if `content` is omitted the `id` /// is used as a fallback so the tool never errors on a merge call. This /// makes the tool resilient to state being lost between calls. pub(crate) fn apply_merge(state: &mut TodoState, updates: &[TodoUpdate]) -> Result<(), TodoError> { for u in updates { if state.update(&u.id, u.content.as_deref(), u.status) { // Existing item – partial update succeeded, content was optional. continue; } let content = if u.has_no_content() { u.id.clone() } else { u.content.clone().unwrap() }; let status = u.status.unwrap_or(TodoStatus::Pending); state.push( u.id.clone(), TodoItem { content, priority: TodoPriority::default(), status, meta: None, }, ); } Ok(()) } pub(crate) fn summarize_todo_state(state: &TodoState) -> String { if state.is_empty() { "No tasks currently tracked.".into() } else { let mut out = String::new(); for (id, t) in state.todo_items_with_ids() { writeln!(&mut out, "- {} {id}: {}", t.status.tag(), t.content).ok(); } out } } use indexmap::IndexMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub type TodoId = String; // diff from acp: default to medium #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum TodoPriority { High, #[default] Medium, Low, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TodoStatus { Pending, InProgress, Completed, Cancelled, } impl TodoStatus { pub const fn tag(&self) -> &str { match self { Self::Pending => "[pending]", Self::InProgress => "[in_progress]", Self::Completed => "[completed]", Self::Cancelled => "[cancelled]", } } } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct TodoItem { pub content: String, #[serde(default)] pub priority: TodoPriority, pub status: TodoStatus, #[serde(default, skip_serializing_if = "Option::is_none")] pub meta: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TodoState { todos: IndexMap, } crate::register_resource!("kigi", "Todo", TodoState); impl TodoState { pub fn push(&mut self, id: TodoId, todo: TodoItem) { self.todos.insert(id, todo); } pub fn clear(&mut self) { self.todos.clear(); } pub fn update( &mut self, id: &TodoId, content: Option<&str>, status: Option, ) -> bool { let Some(todo) = self.todos.get_mut(id) else { return false; }; if let Some(content) = content && !content.is_empty() { todo.content = content.into(); } if let Some(status) = status { todo.status = status; } true } pub fn todo_items(&self) -> impl Iterator + '_ { self.todos.values() } pub fn todo_items_with_ids(&self) -> impl Iterator + '_ { self.todos.iter() } pub fn is_empty(&self) -> bool { self.todos.is_empty() } pub fn has_id(&self, id: &str) -> bool { self.todos.contains_key(id) } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct TodoUpdate { #[schemars(description = "Unique identifier for the todo item")] pub id: String, #[schemars(description = "The description/content of the todo item")] pub content: Option, #[schemars( description = "The status of the todo item: pending, in_progress, completed, or cancelled" )] pub status: Option, } impl TodoUpdate { /// True when the update carries no meaningful content (None or empty string). fn has_no_content(&self) -> bool { self.content.as_deref().is_none_or(str::is_empty) } } const fn default_merge() -> bool { true } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] #[serde(rename_all = "camelCase")] pub struct TodoWriteInput { /// When true (the default), merge the provided todos into the existing /// list by id (partial updates are allowed — leave unchanged fields /// undefined). When explicitly set to false, the provided todos replace /// the existing list entirely. #[serde( default = "default_merge", deserialize_with = "crate::types::schema::deserialize_lenient_bool" )] #[schemars( description = "Optional. When true (default), merges the provided todos into the existing list by id — send only the items you are changing, and to flip status without changing content send just id + status. When false, the provided todos replace the existing list." )] pub merge: bool, #[schemars(description = "Array of todo items to write to the workspace")] pub todos: Vec, } /// New-architecture `TodoWrite` tool. /// /// State: `State` — persisted across calls via Resources serde. /// Params: `()` — no per-tool configuration. #[derive(Debug, Default)] pub struct TodoWriteTool; impl crate::types::tool_metadata::ToolMetadata for TodoWriteTool { fn kind(&self) -> ToolKind { ToolKind::Plan } fn tool_namespace(&self) -> ToolNamespace { ToolNamespace::Kigi } fn description_template(&self) -> &str { r#"Create and manage a structured task list. The user sees this list live — it is your primary way to show progress. Use for any task with 3+ steps. Skip for trivial single-step work."# } fn requires_expr(&self) -> Expr { Expr::True } } impl kigi_tool_runtime::Tool for TodoWriteTool { type Args = TodoWriteInput; type Output = TodoWriteOutput; fn id(&self) -> kigi_tool_protocol::ToolId { kigi_tool_protocol::ToolId::new("todo_write").expect("valid tool id") } fn description( &self, _ctx: &::kigi_tool_runtime::ListToolsContext, ) -> kigi_tool_types::ToolDescription { kigi_tool_types::ToolDescription::new( "todo_write", crate::types::tool_metadata::ToolMetadata::description_template(self), ) } fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities { kigi_tool_protocol::ToolCapabilities { is_read_only: true, tool_scope: Some(kigi_tool_protocol::ToolScope::Read), ..Default::default() } } #[tracing::instrument( name = "new_tool.todo_write", skip_all, fields(merge = %input.merge, todo_count = input.todos.len()) )] async fn run( &self, ctx: kigi_tool_runtime::ToolCallContext, input: TodoWriteInput, ) -> Result { use crate::types::tool_metadata::shared_resources; let resources = shared_resources(&ctx)?; // Validate IDs upfront — return an error-as-output variant so the // Python side can distinguish this from infra errors. if let Err(TodoError::DuplicateTodoID(id)) = validate_no_duplicate_ids(&input.todos) { return Ok(TodoWriteOutput::DuplicateId(format!( "Duplicate todo ID in request: \"{id}\". Each todo item must have a unique ID." ))); } let (summary_for_prompt, todos, state_snapshot); { let mut res = resources.lock().await; let todo_state = res.get_or_default::>(); // Auto-upgrade to merge when the model forgot `merge: true` but // clearly intended a partial update: state already has items and // every update targets an existing ID without providing content. let effective_merge = input.merge || (!todo_state.0.is_empty() && !input.todos.is_empty() && input .todos .iter() .all(|u| u.has_no_content() && todo_state.0.has_id(&u.id))); if effective_merge { apply_merge(&mut todo_state.0, &input.todos)?; } else { apply_replace(&mut todo_state.0, &input.todos)?; } summary_for_prompt = summarize_todo_state(&todo_state.0); todos = todo_state.0.todo_items().cloned().collect::>(); state_snapshot = todo_state.0.clone(); } Ok(TodoWriteOutput::TodosUpdated(TodoWriteSuccess { summary_for_prompt, todos, state: state_snapshot, })) } } #[cfg(test)] mod tests { use super::*; use crate::types::output::TodoWriteOutput; use crate::types::resources::Resources; use crate::types::tool_metadata::test_ctx; // -- Helpers -- fn make_update(id: &str, content: Option<&str>, status: Option) -> TodoUpdate { TodoUpdate { id: id.to_owned(), content: content.map(str::to_owned), status, } } /// Unwrap a `TodoWriteOutput` expecting the `TodosUpdated` variant. fn expect_success(output: TodoWriteOutput) -> TodoWriteSuccess { match output { TodoWriteOutput::TodosUpdated(s) => s, other => panic!("expected TodosUpdated, got {other:?}"), } } // -- Tests -- #[test] fn name_and_description() { use crate::types::tool_metadata::ToolMetadata; let tool = TodoWriteTool; assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "todo_write"); assert!(ToolMetadata::description_template(&tool).contains("task list")); } #[tokio::test] async fn replace_mode_creates_items() { let tool = TodoWriteTool; let resources = Resources::new(); let input = TodoWriteInput { merge: false, todos: vec![ make_update("1", Some("Task A"), Some(TodoStatus::Pending)), make_update("2", Some("Task B"), Some(TodoStatus::InProgress)), ], }; let shared = resources.into_shared(); let output = expect_success( kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input) .await .unwrap(), ); assert_eq!(output.todos.len(), 2); assert!(output.summary_for_prompt.contains("Task A")); assert!(output.summary_for_prompt.contains("Task B")); // State persists in Resources let res = shared.lock().await; let state = res.get::>().unwrap(); assert_eq!(state.0.todo_items().count(), 2); } #[tokio::test] async fn replace_clears_previous_state() { let tool = TodoWriteTool; let resources = Resources::new(); let shared = resources.into_shared(); // Seed initial state let input1 = TodoWriteInput { merge: false, todos: vec![make_update( "old", Some("Old task"), Some(TodoStatus::Completed), )], }; kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input1) .await .unwrap(); // Replace with new let input2 = TodoWriteInput { merge: false, todos: vec![make_update( "new", Some("New task"), Some(TodoStatus::Pending), )], }; let output = expect_success( kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input2) .await .unwrap(), ); assert_eq!(output.todos.len(), 1); assert!(output.summary_for_prompt.contains("New task")); assert!(!output.summary_for_prompt.contains("Old task")); } #[tokio::test] async fn merge_mode_updates_existing() { let tool = TodoWriteTool; let resources = Resources::new(); let shared = resources.into_shared(); // Create initial items let input1 = TodoWriteInput { merge: false, todos: vec![ make_update("1", Some("Build project"), Some(TodoStatus::InProgress)), make_update("2", Some("Run tests"), Some(TodoStatus::Pending)), ], }; kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input1) .await .unwrap(); // Merge: mark item 1 completed (no content), add item 3 let input2 = TodoWriteInput { merge: true, todos: vec![ make_update("1", None, Some(TodoStatus::Completed)), make_update("3", Some("Deploy"), Some(TodoStatus::Pending)), ], }; let output = expect_success( kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input2) .await .unwrap(), ); assert_eq!(output.todos.len(), 3); // Item 1 content preserved, status updated let item1 = output .todos .iter() .find(|t| t.content == "Build project") .unwrap(); assert_eq!(item1.status, TodoStatus::Completed); } #[tokio::test] async fn merge_with_lost_state_uses_id_fallback() { let tool = TodoWriteTool; let resources = Resources::new(); // Merge into empty state — should not error let input = TodoWriteInput { merge: true, todos: vec![make_update("explore", None, Some(TodoStatus::Completed))], }; let output = expect_success( kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input) .await .unwrap(), ); assert_eq!(output.todos.len(), 1); // Id used as fallback content assert_eq!(output.todos[0].content, "explore"); assert_eq!(output.todos[0].status, TodoStatus::Completed); } #[tokio::test] async fn duplicate_ids_rejected() { let tool = TodoWriteTool; let resources = Resources::new(); let input = TodoWriteInput { merge: false, todos: vec![ make_update("dup", Some("A"), Some(TodoStatus::Pending)), make_update("dup", Some("B"), Some(TodoStatus::Pending)), ], }; let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input) .await .unwrap(); assert!( matches!(result, TodoWriteOutput::DuplicateId(ref msg) if msg.contains("dup")), "expected DuplicateId variant, got {result:?}" ); } #[tokio::test] async fn empty_todos_shows_no_tasks_message() { let tool = TodoWriteTool; let resources = Resources::new(); let input = TodoWriteInput { merge: false, todos: vec![], }; let output = expect_success( kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input) .await .unwrap(), ); assert!(output.summary_for_prompt.contains("No tasks")); assert!(output.todos.is_empty()); } #[tokio::test] async fn state_output_includes_snapshot() { let tool = TodoWriteTool; let resources = Resources::new(); let input = TodoWriteInput { merge: false, todos: vec![make_update("1", Some("Task"), Some(TodoStatus::Pending))], }; let output = expect_success( kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input) .await .unwrap(), ); // state field should match what's in Resources assert!(!output.state.is_empty()); assert_eq!(output.state.todo_items().count(), 1); } #[tokio::test] async fn state_serialization_roundtrip() { let tool = TodoWriteTool; let mut resources = Resources::new(); resources.register_state::(); // Create some state let input = TodoWriteInput { merge: false, todos: vec![ make_update("1", Some("First"), Some(TodoStatus::Completed)), make_update("2", Some("Second"), Some(TodoStatus::InProgress)), ], }; let shared = resources.into_shared(); kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input) .await .unwrap(); // Serialize let res = shared.lock().await; let snapshot = res.serialize(); let state_map = snapshot.get("state").unwrap(); assert!( state_map.get("kigi.Todo").is_some(), "TodoState should serialize under 'kigi.Todo'" ); // Deserialize into fresh Resources let mut resources2 = Resources::new(); resources2.register_state::(); let data: std::collections::HashMap< String, std::collections::HashMap, > = serde_json::from_value(snapshot).unwrap(); resources2.load_from(data); // Verify state was restored let restored = resources2.get::>().unwrap(); assert_eq!(restored.0.todo_items().count(), 2); let items: Vec<_> = restored.0.todo_items().collect(); assert_eq!(items[0].content, "First"); assert_eq!(items[0].status, TodoStatus::Completed); assert_eq!(items[1].content, "Second"); assert_eq!(items[1].status, TodoStatus::InProgress); } fn seed_state(items: &[(&str, &str, TodoStatus)]) -> TodoState { let mut state = TodoState::default(); for (id, content, status) in items { state.push( id.to_string(), TodoItem { content: content.to_string(), priority: TodoPriority::default(), status: *status, meta: None, }, ); } state } fn get_item<'a>(state: &'a TodoState, id: &str) -> &'a TodoItem { state .todo_items_with_ids() .find(|(i, _)| *i == id) .map(|(_, item)| item) .unwrap_or_else(|| panic!("item {id} not found in state")) } // ── replace (merge=false) ──────────────────────────────────────── #[test] fn replace_without_content_falls_back_to_id() { let mut state = TodoState::default(); let updates = vec![make_update( "build_project", None, Some(TodoStatus::Pending), )]; apply_replace(&mut state, &updates).unwrap(); let item = get_item(&state, "build_project"); assert_eq!(item.content, "build_project"); // id used as fallback assert_eq!(item.status, TodoStatus::Pending); } #[test] fn replace_without_content_or_status_defaults() { let mut state = TodoState::default(); let updates = vec![make_update("task_1", None, None)]; apply_replace(&mut state, &updates).unwrap(); let item = get_item(&state, "task_1"); assert_eq!(item.content, "task_1"); assert_eq!(item.status, TodoStatus::Pending); } #[test] fn replace_with_content_succeeds() { let mut state = TodoState::default(); let updates = vec![ make_update("1", Some("Task A"), Some(TodoStatus::Pending)), make_update("2", Some("Task B"), Some(TodoStatus::InProgress)), ]; apply_replace(&mut state, &updates).unwrap(); assert_eq!(get_item(&state, "1").content, "Task A"); assert_eq!(get_item(&state, "1").status, TodoStatus::Pending); assert_eq!(get_item(&state, "2").content, "Task B"); assert_eq!(get_item(&state, "2").status, TodoStatus::InProgress); } #[test] fn replace_clears_previous_state_unit() { let mut state = seed_state(&[("old", "Old task", TodoStatus::Completed)]); let updates = vec![make_update( "new", Some("New task"), Some(TodoStatus::Pending), )]; apply_replace(&mut state, &updates).unwrap(); // Old item is gone. assert!(!state.todo_items_with_ids().any(|(id, _)| *id == "old")); assert_eq!(get_item(&state, "new").content, "New task"); } // ── merge (merge=true) ─────────────────────────────────────────── #[test] fn merge_existing_item_status_only() { // The core use-case: mark in_progress → completed without sending content. let mut state = seed_state(&[("1", "Build the project", TodoStatus::InProgress)]); let updates = vec![make_update("1", None, Some(TodoStatus::Completed))]; apply_merge(&mut state, &updates).unwrap(); let item = get_item(&state, "1"); assert_eq!(item.status, TodoStatus::Completed); assert_eq!(item.content, "Build the project"); // unchanged } #[test] fn merge_existing_item_content_and_status() { let mut state = seed_state(&[("1", "Old text", TodoStatus::Pending)]); let updates = vec![make_update( "1", Some("New text"), Some(TodoStatus::InProgress), )]; apply_merge(&mut state, &updates).unwrap(); let item = get_item(&state, "1"); assert_eq!(item.content, "New text"); assert_eq!(item.status, TodoStatus::InProgress); } #[test] fn merge_existing_item_no_fields_is_noop() { let mut state = seed_state(&[("1", "Keep me", TodoStatus::Pending)]); let updates = vec![make_update("1", None, None)]; apply_merge(&mut state, &updates).unwrap(); let item = get_item(&state, "1"); assert_eq!(item.content, "Keep me"); assert_eq!(item.status, TodoStatus::Pending); } #[test] fn merge_new_item_without_content_uses_id_fallback() { // When state is empty (e.g. lost between calls) and content is None, // the id is used as fallback content instead of erroring. let mut state = TodoState::default(); let updates = vec![make_update( "explore_codebase", None, Some(TodoStatus::Completed), )]; apply_merge(&mut state, &updates).unwrap(); let item = get_item(&state, "explore_codebase"); assert_eq!(item.content, "explore_codebase"); // id used as fallback assert_eq!(item.status, TodoStatus::Completed); } #[test] fn merge_new_item_without_content_or_status_defaults_to_pending() { let mut state = TodoState::default(); let updates = vec![make_update("task_1", None, None)]; apply_merge(&mut state, &updates).unwrap(); let item = get_item(&state, "task_1"); assert_eq!(item.content, "task_1"); assert_eq!(item.status, TodoStatus::Pending); } #[test] fn merge_new_item_with_content_succeeds() { let mut state = TodoState::default(); let updates = vec![make_update( "1", Some("Fresh task"), Some(TodoStatus::Pending), )]; apply_merge(&mut state, &updates).unwrap(); assert_eq!(get_item(&state, "1").content, "Fresh task"); } #[test] fn merge_mixed_existing_and_new() { let mut state = seed_state(&[("exist", "Existing task", TodoStatus::InProgress)]); let updates = vec![ // Update existing — content omitted, just flip status. make_update("exist", None, Some(TodoStatus::Completed)), // Brand-new item — content required. make_update("fresh", Some("New task"), Some(TodoStatus::Pending)), ]; apply_merge(&mut state, &updates).unwrap(); let existing = get_item(&state, "exist"); assert_eq!(existing.status, TodoStatus::Completed); assert_eq!(existing.content, "Existing task"); // preserved let fresh = get_item(&state, "fresh"); assert_eq!(fresh.content, "New task"); assert_eq!(fresh.status, TodoStatus::Pending); } // ── duplicate id validation ────────────────────────────────────── #[test] fn duplicate_ids_rejected_unit() { let updates = vec![ make_update("dup", Some("A"), Some(TodoStatus::Pending)), make_update("dup", Some("B"), Some(TodoStatus::Pending)), ]; let err = validate_no_duplicate_ids(&updates).unwrap_err(); assert!(matches!(err, TodoError::DuplicateTodoID(ref id) if id == "dup")); } #[test] fn unique_ids_accepted() { let updates = vec![ make_update("a", Some("A"), Some(TodoStatus::Pending)), make_update("b", Some("B"), Some(TodoStatus::Pending)), ]; validate_no_duplicate_ids(&updates).unwrap(); } // ── regression: missing merge=true auto-upgrade ──────────────────── #[tokio::test] async fn missing_merge_flag_auto_upgrades_when_status_only() { // Regression: status-only update without merge=true must not wipe content. let tool = TodoWriteTool; let resources = Resources::new(); let shared = resources.into_shared(); // Create todos with content let input1 = TodoWriteInput { merge: false, todos: vec![ make_update("1", Some("Explore codebase"), Some(TodoStatus::InProgress)), make_update("2", Some("Review tools"), Some(TodoStatus::Pending)), make_update("3", Some("Write tests"), Some(TodoStatus::Pending)), ], }; kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input1) .await .unwrap(); // Status-only update without merge=true let input2 = TodoWriteInput { merge: false, // model forgot merge: true todos: vec![ make_update("1", None, Some(TodoStatus::Completed)), make_update("2", None, Some(TodoStatus::Completed)), make_update("3", None, Some(TodoStatus::InProgress)), ], }; let output = expect_success( kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input2) .await .unwrap(), ); // Content must be preserved, not replaced with id fallback. assert_eq!(output.todos.len(), 3); assert_eq!(output.todos[0].content, "Explore codebase"); assert_eq!(output.todos[0].status, TodoStatus::Completed); assert_eq!(output.todos[1].content, "Review tools"); assert_eq!(output.todos[1].status, TodoStatus::Completed); assert_eq!(output.todos[2].content, "Write tests"); assert_eq!(output.todos[2].status, TodoStatus::InProgress); } // ── regression: merge with null content should never error ──────── #[test] fn merge_after_replace_status_update_with_null_content() { // Reproduces the exact scenario from the bug report: // 1. Replace creates 3 items // 2. Merge updates 2 items with content=null, status changed let mut state = TodoState::default(); // Step 1: replace (merge=false) let initial = vec![ make_update( "explore_codebase", Some("Explore django/db/backends/sqlite3/"), Some(TodoStatus::InProgress), ), make_update( "analyze_and_propose", Some("Analyze current SQLite min version"), Some(TodoStatus::Pending), ), make_update( "implementation", Some("Update version checks"), Some(TodoStatus::Pending), ), ]; apply_replace(&mut state, &initial).unwrap(); // Step 2: merge (merge=true) — content=null, just status changes let updates = vec![ make_update("explore_codebase", None, Some(TodoStatus::Completed)), make_update("analyze_and_propose", None, Some(TodoStatus::InProgress)), ]; apply_merge(&mut state, &updates).unwrap(); // Statuses flipped, content preserved from step 1. assert_eq!( get_item(&state, "explore_codebase").status, TodoStatus::Completed ); assert_eq!( get_item(&state, "explore_codebase").content, "Explore django/db/backends/sqlite3/" ); assert_eq!( get_item(&state, "analyze_and_propose").status, TodoStatus::InProgress ); assert_eq!( get_item(&state, "analyze_and_propose").content, "Analyze current SQLite min version" ); // Third item unchanged. assert_eq!( get_item(&state, "implementation").status, TodoStatus::Pending ); } // ── regression: empty-string content must not wipe existing content ── #[test] fn merge_existing_item_empty_string_content_preserves_original() { // Model sends content: "" instead of omitting it. Must not wipe. let mut state = seed_state(&[("1", "Build the project", TodoStatus::InProgress)]); let updates = vec![make_update("1", Some(""), Some(TodoStatus::Completed))]; apply_merge(&mut state, &updates).unwrap(); let item = get_item(&state, "1"); assert_eq!(item.status, TodoStatus::Completed); assert_eq!(item.content, "Build the project"); // unchanged } #[test] fn replace_empty_string_content_falls_back_to_id() { let mut state = TodoState::default(); let updates = vec![make_update("task_1", Some(""), Some(TodoStatus::Pending))]; apply_replace(&mut state, &updates).unwrap(); assert_eq!(get_item(&state, "task_1").content, "task_1"); } #[test] fn merge_new_item_empty_string_content_falls_back_to_id() { let mut state = TodoState::default(); let updates = vec![make_update("task_1", Some(""), Some(TodoStatus::Pending))]; apply_merge(&mut state, &updates).unwrap(); assert_eq!(get_item(&state, "task_1").content, "task_1"); } #[test] fn merge_with_null_content_and_lost_state() { // Same scenario but state was lost between calls (empty state). // The tool should still not error — falls back to id as content. let mut state = TodoState::default(); let updates = vec![ make_update("explore_codebase", None, Some(TodoStatus::Completed)), make_update("analyze_and_propose", None, Some(TodoStatus::InProgress)), ]; apply_merge(&mut state, &updates).unwrap(); assert_eq!( get_item(&state, "explore_codebase").content, "explore_codebase" ); assert_eq!( get_item(&state, "explore_codebase").status, TodoStatus::Completed ); assert_eq!( get_item(&state, "analyze_and_propose").content, "analyze_and_propose" ); assert_eq!( get_item(&state, "analyze_and_propose").status, TodoStatus::InProgress ); } }