diff --git a/src/command/handlers/list_todos.rs b/src/command/handlers/list_todos.rs index 7b8d2aa..98f429f 100644 --- a/src/command/handlers/list_todos.rs +++ b/src/command/handlers/list_todos.rs @@ -76,9 +76,6 @@ impl CommandHandler for ListTodosCommandHandler { id: r.id, content: r.content, status: r.status, - priority: r.priority, - created_at: r.created_at, - updated_at: r.updated_at, }) .collect(); diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 570dd0d..cc15495 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -141,9 +141,15 @@ impl BusToolCallEmitter { let topic_id = self.metadata.get("topic_id").filter(|t| !t.is_empty()).cloned(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let records: Vec = todos_array .iter() - .filter_map(|item| { + .enumerate() + .filter_map(|(idx, item)| { Some(crate::storage::TodoRecord { id: item.get("id")?.as_str()?.to_string(), scope_key: scope_key.clone(), @@ -151,9 +157,9 @@ impl BusToolCallEmitter { topic_id: topic_id.clone(), content: item.get("content")?.as_str()?.to_string(), status: item.get("status")?.as_str()?.to_string(), - priority: item.get("priority")?.as_str()?.to_string(), - created_at: item.get("created_at")?.as_i64()?, - updated_at: item.get("updated_at")?.as_i64()?, + priority: "medium".to_string(), + created_at: now + idx as i64, + updated_at: now, }) }) .collect(); diff --git a/src/gateway/todo_prompt_provider.rs b/src/gateway/todo_prompt_provider.rs index 13d88a4..fe8857e 100644 --- a/src/gateway/todo_prompt_provider.rs +++ b/src/gateway/todo_prompt_provider.rs @@ -28,7 +28,7 @@ const TODO_WRITE_INSTRUCTIONS: &str = r#" ### merge 参数 - `merge: false`(默认):全量替换 — 只传入需要追踪的 todo,不在列表中的项将被移除 -- `merge: true`(推荐):增量更新 — 只传入需要添加或更新的项,未提及的项保持不变。**绝大多数情况应该使用 merge=true,这样你不需要记住所有 id** +- `merge: true`(推荐):增量更新 — 只传入需要添加或更新的项,未提及的项保持不变。**绝大多数情况应该使用 merge=true** ### 状态语义 - `pending` — 尚未开始 @@ -39,33 +39,34 @@ const TODO_WRITE_INSTRUCTIONS: &str = r#" ### 核心规则 1. 同一时间只能有一个任务处于 `in_progress` 状态 2. 必须先完成当前 `in_progress` 的任务,再开始下一个 -3. `completed` 和 `cancelled` 是终端状态,已完成的项不能被重新激活 -4. 不要先标记 completed 再去实际执行 — 先完成工作,再标记 -5. `content` 字段保持简洁、可执行 -6. **更新已有任务必须传 `id`**。新任务不传 id(工具会自动生成),更新状态时必须传 id。id 可以从之前 todo_write 返回的 `current_todos` 中获取 +3. `completed` 和 `cancelled` 的项可以重新激活(改回 `in_progress` 或 `pending`),用于任务返工或恢复 +4. `in_progress` 不能退回 `pending`,应直接标记为 `completed` 或 `cancelled` +5. 不要先标记 completed 再去实际执行 — 先完成工作,再标记 +6. `content` 字段保持简洁、可执行 +7. **每个任务都必须传 `id`**。新任务由你生成一个短随机字符串作为 id(如 `"r9Tg8Kq2"`),更新任务时使用相同的 id。id 可以从之前 todo_write 返回的 `current_todos` 中获取 ### 使用范例 -创建新任务(新任务必须 pending 或 in_progress,不传 id): +创建新任务(生成随机 id): ```json -{"merge": true, "todos": [{"content": "修复登录 bug", "status": "in_progress"}]} +{"merge": true, "todos": [{"id": "aB3kLm9x", "content": "修复登录 bug", "status": "in_progress"}]} ``` 追加新任务: ```json -{"merge": true, "todos": [{"content": "补充测试", "status": "pending"}]} +{"merge": true, "todos": [{"id": "pQ7nWy2z", "content": "补充测试", "status": "pending"}]} ``` -更新已有任务(**必须传 id**,从上次返回的 current_todos 中取得): +更新已有任务(使用创建时的 id): ```json -{"merge": true, "todos": [{"id": "abc-123", "content": "修复登录 bug", "status": "completed"}]} +{"merge": true, "todos": [{"id": "aB3kLm9x", "content": "修复登录 bug", "status": "completed"}]} ``` 同时更新多项: ```json {"merge": true, "todos": [ - {"id": "abc-123", "content": "修复登录 bug", "status": "completed"}, - {"content": "代码审查", "status": "in_progress"} + {"id": "aB3kLm9x", "content": "修复登录 bug", "status": "completed"}, + {"id": "pQ7nWy2z", "content": "补充测试", "status": "in_progress"} ]} ``` "#; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 79825b4..8835292 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -88,9 +88,6 @@ pub struct TodoItemSummary { pub id: String, pub content: String, pub status: String, - pub priority: String, - pub created_at: i64, - pub updated_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/tools/task/repository.rs b/src/tools/task/repository.rs index 20fe23f..de2e8cc 100644 --- a/src/tools/task/repository.rs +++ b/src/tools/task/repository.rs @@ -57,20 +57,20 @@ impl Default for InMemoryTaskRepository { #[async_trait] impl TaskRepository for InMemoryTaskRepository { async fn save_task_session(&self, session: &TaskSession) -> Result<(), StorageError> { - tracing::warn!( + tracing::debug!( task_id = %session.id, session_id = %session.session_id, state = ?session.state, - "REPO_SAVE: Saving task session" + "Saving task session" ); self.sessions .write() .unwrap() .insert(session.id.clone(), session.clone()); - tracing::warn!( + tracing::debug!( task_id = %session.id, total_tasks = self.sessions.read().unwrap().len(), - "REPO_SAVE: Task session saved, current repository size" + "Task session saved, current repository size" ); Ok(()) } @@ -79,11 +79,11 @@ impl TaskRepository for InMemoryTaskRepository { let sessions = self.sessions.read().unwrap(); let total = sessions.len(); let keys: Vec<&str> = sessions.keys().map(|k| k.as_str()).collect(); - tracing::warn!( + tracing::debug!( lookup_task_id = %task_id, total_tasks = total, all_keys = ?keys, - "REPO_LOOKUP: Looking up task session" + "Looking up task session" ); Ok(sessions.get(task_id).cloned()) } diff --git a/src/tools/todo_write.rs b/src/tools/todo_write.rs index e8d893e..2c776cd 100644 --- a/src/tools/todo_write.rs +++ b/src/tools/todo_write.rs @@ -37,37 +37,6 @@ impl TodoStatus { _ => None, } } - - /// 是否为终端状态(不可再变更) - fn is_terminal(&self) -> bool { - matches!(self, TodoStatus::Completed | TodoStatus::Cancelled) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum TodoPriority { - High, - Medium, - Low, -} - -impl TodoPriority { - fn as_str(&self) -> &'static str { - match self { - TodoPriority::High => "high", - TodoPriority::Medium => "medium", - TodoPriority::Low => "low", - } - } - - fn from_str(value: &str) -> Option { - match value { - "high" => Some(Self::High), - "medium" => Some(Self::Medium), - "low" => Some(Self::Low), - _ => None, - } - } } /// 内存中的 Todo 项 @@ -76,24 +45,12 @@ pub(crate) struct TodoItem { pub id: String, pub content: String, pub status: String, - pub priority: String, - pub created_at: i64, - pub updated_at: i64, -} - -/// 变更摘要 -#[derive(Debug, Clone, Serialize)] -struct ChangeSummary { - added: Vec, - updated: Vec, - removed: Vec, // ids of removed items } /// 工具完整返回 #[derive(Debug, Clone, Serialize)] struct TodoWriteOutput { current_todos: Vec, - changes: ChangeSummary, message: String, } @@ -121,10 +78,10 @@ impl Tool for TodoWriteTool { "Manage a structured task list for tracking work within the current conversation. \ Two modes: merge=false (default, full replacement — omitted items are removed); \ merge=true (incremental — only send the items you want to add/update, \ - previously completed items are preserved). \ + previously existing items are preserved). \ Use when you have 3+ distinct steps to track. \ Rules: only ONE in_progress at a time, complete work before marking completed, \ - completed/cancelled are terminal states." + every item requires an id (generate a short random string for new items)." } fn parameters_schema(&self) -> serde_json::Value { @@ -141,6 +98,10 @@ impl Tool for TodoWriteTool { "items": { "type": "object", "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the todo item. Generate a short random string (e.g. 'r9Tg8Kq2pLm7') for new items. Use the existing id to update." + }, "content": { "type": "string", "description": "Brief, actionable description of the task" @@ -149,14 +110,9 @@ impl Tool for TodoWriteTool { "type": "string", "enum": ["pending", "in_progress", "completed", "cancelled"], "description": "Current status: pending=not started, in_progress=working on (only ONE at a time), completed=done, cancelled=no longer needed" - }, - "priority": { - "type": "string", - "enum": ["high", "medium", "low"], - "description": "Task priority. Defaults to medium if not specified." } }, - "required": ["content", "status"] + "required": ["id", "content", "status"] } } }, @@ -198,10 +154,7 @@ impl Tool for TodoWriteTool { .and_then(|v| v.as_bool()) .unwrap_or(false); - // 3. 解析并校验每个输入项 - let now = current_timestamp(); - - // 读锁获取旧状态 + // 3. 读锁获取旧状态 let old_items = { let guard = self.state.read().await; guard.get(&scope_key).cloned().unwrap_or_default() @@ -210,10 +163,19 @@ impl Tool for TodoWriteTool { // 构建 id → TodoItem 的旧状态映射 let old_map: HashMap<&str, &TodoItem> = old_items.iter().map(|item| (item.id.as_str(), item)).collect(); + // 4. 解析并校验每个输入项 let mut processed_items: Vec = Vec::new(); let mut validation_errors: Vec = Vec::new(); for (idx, input) in todos_array.iter().enumerate() { + let id = match input.get("id").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => { + validation_errors.push(format!("Item {}: missing or empty 'id'", idx)); + continue; + } + }; + let content = match input.get("content").and_then(|v| v.as_str()) { Some(s) if !s.trim().is_empty() => s.trim().to_string(), _ => { @@ -238,18 +200,29 @@ impl Tool for TodoWriteTool { } }; - let priority_str = input - .get("priority") - .and_then(|v| v.as_str()) - .unwrap_or("medium"); + if let Some(old_item) = old_map.get(id.as_str()) { + // id 匹配旧项 → 更新,校验状态转换 + let old_status = match TodoStatus::from_str(&old_item.status) { + Some(s) => s, + None => { + validation_errors.push(format!("Item '{}': corrupted old status", content)); + continue; + } + }; - let priority = TodoPriority::from_str(priority_str).unwrap_or(TodoPriority::Medium); + if let Err(err) = validate_transition(&old_status, &new_status) { + validation_errors.push(format!("Item '{}': {}", content, err)); + continue; + } - let input_id = input.get("id").and_then(|v| v.as_str()); - - if let Some(id) = input_id { - if let Some(old_item) = old_map.get(id) { - // 已有 item — 校验状态转换 + processed_items.push(TodoItem { + id, + content, + status: new_status.as_str().to_string(), + }); + } else if merge_mode { + // merge 模式:id 不匹配,尝试 content fallback + if let Some(old_item) = old_items.iter().find(|oi| oi.content == content) { let old_status = match TodoStatus::from_str(&old_item.status) { Some(s) => s, None => { @@ -257,104 +230,29 @@ impl Tool for TodoWriteTool { continue; } }; - if let Err(err) = validate_transition(&old_status, &new_status) { validation_errors.push(format!("Item '{}': {}", content, err)); continue; } - processed_items.push(TodoItem { - id: id.to_string(), + id: old_item.id.clone(), content, status: new_status.as_str().to_string(), - priority: priority.as_str().to_string(), - created_at: old_item.created_at, - updated_at: now, }); } else { - // 传入 id 但旧状态中没有 → merge 模式按 content 匹配,否则按新 item - if merge_mode { - if let Some(old_item) = old_items.iter().find(|oi| oi.content == content) { - let old_status = match TodoStatus::from_str(&old_item.status) { - Some(s) => s, - None => { - validation_errors.push(format!("Item '{}': corrupted old status", content)); - continue; - } - }; - if let Err(err) = validate_transition(&old_status, &new_status) { - validation_errors.push(format!("Item '{}': {}", content, err)); - continue; - } - processed_items.push(TodoItem { - id: old_item.id.clone(), - content, - status: new_status.as_str().to_string(), - priority: priority.as_str().to_string(), - created_at: old_item.created_at, - updated_at: now, - }); - continue; - } - } - // merge 模式:接受任意状态;全量替换模式:必须是 pending 或 in_progress - if !merge_mode && !is_valid_new_status(&new_status) { - validation_errors.push(format!( - "Item '{}': new items must start as 'pending' or 'in_progress', got '{}'", - content, status_str - )); - continue; - } + // 全新项 processed_items.push(TodoItem { - id: id.to_string(), + id, content, status: new_status.as_str().to_string(), - priority: priority.as_str().to_string(), - created_at: now, - updated_at: now, }); } } else { - // 新 item(无 id)— merge 模式按 content 匹配,否则按新 item - if merge_mode { - if let Some(old_item) = old_items.iter().find(|oi| oi.content == content) { - let old_status = match TodoStatus::from_str(&old_item.status) { - Some(s) => s, - None => { - validation_errors.push(format!("Item '{}': corrupted old status", content)); - continue; - } - }; - if let Err(err) = validate_transition(&old_status, &new_status) { - validation_errors.push(format!("Item '{}': {}", content, err)); - continue; - } - processed_items.push(TodoItem { - id: old_item.id.clone(), - content, - status: new_status.as_str().to_string(), - priority: priority.as_str().to_string(), - created_at: old_item.created_at, - updated_at: now, - }); - continue; - } - } - // merge 模式:接受任意状态;全量替换模式:必须是 pending 或 in_progress - if !merge_mode && !is_valid_new_status(&new_status) { - validation_errors.push(format!( - "Item '{}': new items must start as 'pending' or 'in_progress', got '{}'", - content, status_str - )); - continue; - } + // 全量替换模式:id 不匹配 → 全新项 processed_items.push(TodoItem { - id: uuid::Uuid::new_v4().to_string(), + id, content, status: new_status.as_str().to_string(), - priority: priority.as_str().to_string(), - created_at: now, - updated_at: now, }); } } @@ -367,48 +265,23 @@ impl Tool for TodoWriteTool { }); } - // 4. 提前收集 processed ids(在 move 之前) + // 5. 合并模式:将旧列表中未被引用的项保留 let processed_ids: std::collections::HashSet<&str> = processed_items.iter().map(|item| item.id.as_str()).collect(); - let old_ids: std::collections::HashSet<&str> = old_items.iter().map(|item| item.id.as_str()).collect(); - // 5. 计算 diff(在 processed_items 被 move 之前) - let added: Vec = processed_items - .iter() - .filter(|item| !old_ids.contains(item.id.as_str())) - .cloned() - .collect(); - - let updated: Vec = processed_items - .iter() - .filter(|item| { - old_ids.contains(item.id.as_str()) - && old_map.get(item.id.as_str()).map_or(true, |old| { - old.status != item.status || old.content != item.content - }) - }) - .cloned() - .collect(); - - // 6. 合并模式:将旧列表中未被引用的项保留 let final_items: Vec = if merge_mode { - let mut merged = Vec::new(); - for item in &processed_items { - merged.push(item.clone()); - } + let mut merged = processed_items.clone(); for old in &old_items { if !processed_ids.contains(old.id.as_str()) { merged.push(old.clone()); } } - merged.sort_by_key(|item| item.created_at); merged } else { - // full replacement: processed_items 就是全部 processed_items }; - // 7. 全局约束:只有一个 in_progress + // 6. 全局约束:只有一个 in_progress let in_progress_count = final_items .iter() .filter(|item| item.status == "in_progress") @@ -420,23 +293,15 @@ impl Tool for TodoWriteTool { ))); } + // 7. 计算 removed 数量(仅全量替换模式) let final_ids: std::collections::HashSet<&str> = final_items.iter().map(|item| item.id.as_str()).collect(); - - // merge 模式下从不删除,只有全量替换模式才会删除 - let removed: Vec = if merge_mode { - Vec::new() + let removed_count = if merge_mode { + 0 } else { old_items .iter() .filter(|item| !final_ids.contains(item.id.as_str())) - .map(|item| item.id.clone()) - .collect() - }; - - let changes = ChangeSummary { - added, - updated, - removed, + .count() }; // 8. 更新内存状态 @@ -446,11 +311,10 @@ impl Tool for TodoWriteTool { } // 9. 生成友好消息 - let message = build_summary_message(&changes, merge_mode); + let message = build_message(final_items.len(), removed_count, merge_mode); let output = TodoWriteOutput { current_todos: final_items, - changes, message, }; @@ -471,76 +335,48 @@ pub(crate) fn scope_key_from_context(context: &ToolContext) -> Option { tid.or(sid).map(str::to_string) } -/// 新创建的 item 允许 pending 或 in_progress,不允许 completed/cancelled -fn is_valid_new_status(status: &TodoStatus) -> bool { - matches!(status, TodoStatus::Pending | TodoStatus::InProgress) -} - /// 校验状态转换合法性 fn validate_transition(old: &TodoStatus, new: &TodoStatus) -> Result<(), String> { - if old.is_terminal() { - return Err(format!( - "Cannot change status of a {} task (terminal state)", - old.as_str() - )); - } - match (old, new) { // pending → anything is allowed (TodoStatus::Pending, _) => Ok(()), - // in_progress → completed or cancelled + // in_progress → completed, cancelled, or same (TodoStatus::InProgress, TodoStatus::Completed) => Ok(()), (TodoStatus::InProgress, TodoStatus::Cancelled) => Ok(()), + (TodoStatus::InProgress, TodoStatus::InProgress) => Ok(()), (TodoStatus::InProgress, TodoStatus::Pending) => Err( "Cannot move an in_progress task back to pending. Use completed or cancelled.".to_string(), ), - (TodoStatus::InProgress, TodoStatus::InProgress) => Ok(()), // no change - // completed or cancelled → nothing (handled by is_terminal check above) - _ => Ok(()), + // completed → can reactivate to in_progress or pending + (TodoStatus::Completed, TodoStatus::InProgress) => Ok(()), + (TodoStatus::Completed, TodoStatus::Pending) => Ok(()), + (TodoStatus::Completed, TodoStatus::Completed) => Ok(()), + (TodoStatus::Completed, TodoStatus::Cancelled) => Err( + "Cannot cancel a completed task. Move it to pending first if needed.".to_string(), + ), + + // cancelled → can reactivate to pending or in_progress + (TodoStatus::Cancelled, TodoStatus::Pending) => Ok(()), + (TodoStatus::Cancelled, TodoStatus::InProgress) => Ok(()), + (TodoStatus::Cancelled, TodoStatus::Cancelled) => Ok(()), + (TodoStatus::Cancelled, TodoStatus::Completed) => Err( + "Cannot complete a cancelled task. Move it to pending first if needed.".to_string(), + ), } } -fn build_summary_message(changes: &ChangeSummary, merge_mode: bool) -> String { - let mut parts: Vec = Vec::new(); - - if !changes.added.is_empty() { - let names: Vec<&str> = changes.added.iter().map(|item| item.content.as_str()).collect(); - parts.push(format!("新增 {} 项: {}", changes.added.len(), names.join(", "))); - } - - if !changes.updated.is_empty() { - let names: Vec = changes - .updated - .iter() - .map(|item| format!("{}→{}", item.content, item.status)) - .collect(); - parts.push(format!("更新 {} 项: {}", changes.updated.len(), names.join(", "))); - } - - if !changes.removed.is_empty() { - parts.push(format!("移除 {} 项", changes.removed.len())); - } - - if parts.is_empty() { - if merge_mode { - "Todo list unchanged (merge mode — no items were added, updated, or removed).".to_string() - } else { - "Todo list unchanged.".to_string() - } +fn build_message(total: usize, removed: usize, merge_mode: bool) -> String { + if merge_mode { + format!("Todo list updated: {} items total", total) + } else if removed > 0 { + format!("Replaced todo list: {} items ({} removed)", total, removed) } else { - parts.join("; ") + format!("Todo list set: {} items", total) } } -fn current_timestamp() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64 -} - fn error_result(message: &str) -> ToolResult { ToolResult { success: false, @@ -583,9 +419,9 @@ mod tests { &context, json!({ "todos": [ - {"content": "设计数据库", "status": "pending", "priority": "high"}, - {"content": "实现 API", "status": "pending", "priority": "medium"}, - {"content": "写测试", "status": "pending", "priority": "low"} + {"id": "a1", "content": "设计数据库", "status": "pending"}, + {"id": "a2", "content": "实现 API", "status": "pending"}, + {"id": "a3", "content": "写测试", "status": "pending"} ] }), ) @@ -595,9 +431,8 @@ mod tests { assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); assert_eq!(output["current_todos"].as_array().unwrap().len(), 3); - assert_eq!(output["changes"]["added"].as_array().unwrap().len(), 3); - assert_eq!(output["changes"]["updated"].as_array().unwrap().len(), 0); - assert_eq!(output["changes"]["removed"].as_array().unwrap().len(), 0); + // 不应有 changes 字段 + assert!(output.get("changes").is_none()); } #[tokio::test] @@ -606,36 +441,27 @@ mod tests { let tool = TodoWriteTool::new(state.clone()); let context = test_context(); - // 先创建两个 pending 任务 let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务A", "status": "pending"}, - {"content": "任务B", "status": "pending"} + {"id": "b1", "content": "任务A", "status": "pending"}, + {"id": "b2", "content": "任务B", "status": "pending"} ] }), ) .await .unwrap(); - // 获取它们的 id - let guard = state.read().await; - let scope_key = scope_key_from_context(&context).unwrap(); - let items = guard.get(&scope_key).unwrap(); - let id_a = items[0].id.clone(); - let id_b = items[1].id.clone(); - drop(guard); - // 尝试将两个都设为 in_progress let result = tool .execute_with_context( &context, json!({ "todos": [ - {"id": id_a, "content": "任务A", "status": "in_progress"}, - {"id": id_b, "content": "任务B", "status": "in_progress"} + {"id": "b1", "content": "任务A", "status": "in_progress"}, + {"id": "b2", "content": "任务B", "status": "in_progress"} ] }), ) @@ -652,33 +478,38 @@ mod tests { let tool = TodoWriteTool::new(state.clone()); let context = test_context(); - // 先创建 todo 列表 let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务A", "status": "pending"} + {"id": "c1", "content": "任务A", "status": "pending"} ] }), ) .await .unwrap(); - // 获取创建后的 id - let guard = state.read().await; - let scope_key = scope_key_from_context(&context).unwrap(); - let items = guard.get(&scope_key).unwrap(); - let task_id = items[0].id.clone(); - drop(guard); + // pending → in_progress + let _ = tool + .execute_with_context( + &context, + json!({ + "todos": [ + {"id": "c1", "content": "任务A", "status": "in_progress"} + ] + }), + ) + .await + .unwrap(); - // 更新为 in_progress → completed + // in_progress → completed let result = tool .execute_with_context( &context, json!({ "todos": [ - {"id": task_id, "content": "任务A", "status": "completed"} + {"id": "c1", "content": "任务A", "status": "completed"} ] }), ) @@ -692,7 +523,7 @@ mod tests { } #[tokio::test] - async fn test_terminal_state_cannot_change() { + async fn test_completed_can_revert_to_in_progress() { let state = test_state(); let tool = TodoWriteTool::new(state.clone()); let context = test_context(); @@ -703,60 +534,128 @@ mod tests { &context, json!({ "todos": [ - {"content": "任务A", "status": "pending"} + {"id": "d1", "content": "任务A", "status": "pending"} ] }), ) .await .unwrap(); - let guard = state.read().await; - let scope_key = scope_key_from_context(&context).unwrap(); - let items = guard.get(&scope_key).unwrap(); - let task_id = items[0].id.clone(); - drop(guard); - - // 标记为 completed let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"id": task_id, "content": "任务A", "status": "completed"} + {"id": "d1", "content": "任务A", "status": "completed"} ] }), ) .await .unwrap(); - // 尝试从 completed 改回 pending + // completed → in_progress(返工) let result = tool .execute_with_context( &context, json!({ "todos": [ - {"id": task_id, "content": "任务A", "status": "pending"} + {"id": "d1", "content": "任务A", "status": "in_progress"} ] }), ) .await .unwrap(); - assert!(!result.success); - assert!(result.error.unwrap().contains("terminal state")); + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["current_todos"][0]["status"], "in_progress"); } #[tokio::test] - async fn test_new_item_cannot_be_completed_or_cancelled() { - let tool = TodoWriteTool::new(test_state()); + async fn test_cancelled_can_revert_to_pending() { + let state = test_state(); + let tool = TodoWriteTool::new(state.clone()); let context = test_context(); + let _ = tool + .execute_with_context( + &context, + json!({ + "todos": [ + {"id": "e1", "content": "任务A", "status": "pending"} + ] + }), + ) + .await + .unwrap(); + + let _ = tool + .execute_with_context( + &context, + json!({ + "todos": [ + {"id": "e1", "content": "任务A", "status": "cancelled"} + ] + }), + ) + .await + .unwrap(); + + // cancelled → pending(恢复) let result = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务A", "status": "completed"} + {"id": "e1", "content": "任务A", "status": "pending"} + ] + }), + ) + .await + .unwrap(); + + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["current_todos"][0]["status"], "pending"); + } + + #[tokio::test] + async fn test_in_progress_cannot_revert_to_pending() { + let state = test_state(); + let tool = TodoWriteTool::new(state.clone()); + let context = test_context(); + + let _ = tool + .execute_with_context( + &context, + json!({ + "todos": [ + {"id": "f1", "content": "任务A", "status": "pending"} + ] + }), + ) + .await + .unwrap(); + + let _ = tool + .execute_with_context( + &context, + json!({ + "todos": [ + {"id": "f1", "content": "任务A", "status": "in_progress"} + ] + }), + ) + .await + .unwrap(); + + // in_progress → pending(禁止) + let result = tool + .execute_with_context( + &context, + json!({ + "todos": [ + {"id": "f1", "content": "任务A", "status": "pending"} ] }), ) @@ -764,22 +663,28 @@ mod tests { .unwrap(); assert!(!result.success); - assert!(result.error.unwrap().contains("new items must start as")); + assert!(result.error.unwrap().contains("Cannot move an in_progress task back to pending")); + } - let result2 = tool + #[tokio::test] + async fn test_new_item_can_be_any_status() { + let tool = TodoWriteTool::new(test_state()); + let context = test_context(); + + // 新项直接 completed — 应该允许(id 必填后不再限制初始状态) + let result = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务B", "status": "cancelled"} + {"id": "g1", "content": "任务A", "status": "completed"} ] }), ) .await .unwrap(); - assert!(!result2.success); - assert!(result2.error.unwrap().contains("new items must start as")); + assert!(result.success); } #[tokio::test] @@ -787,15 +692,14 @@ mod tests { let tool = TodoWriteTool::new(test_state()); let context = test_context(); - // 直接创建第一个任务为 in_progress — 应该允许 let result = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "第一个任务", "status": "in_progress"}, - {"content": "第二个任务", "status": "pending"}, - {"content": "第三个任务", "status": "pending"} + {"id": "h1", "content": "第一个任务", "status": "in_progress"}, + {"id": "h2", "content": "第二个任务", "status": "pending"}, + {"id": "h3", "content": "第三个任务", "status": "pending"} ] }), ) @@ -815,14 +719,13 @@ mod tests { let tool = TodoWriteTool::new(state.clone()); let context = test_context(); - // 创建两个任务 let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务A", "status": "pending"}, - {"content": "任务B", "status": "pending"} + {"id": "i1", "content": "任务A", "status": "pending"}, + {"id": "i2", "content": "任务B", "status": "pending"} ] }), ) @@ -830,18 +733,12 @@ mod tests { .unwrap(); // 只传入一个任务(任务B 被移除) - let guard = state.read().await; - let scope_key = scope_key_from_context(&context).unwrap(); - let items = guard.get(&scope_key).unwrap(); - let task_a_id = items[0].id.clone(); - drop(guard); - let result = tool .execute_with_context( &context, json!({ "todos": [ - {"id": task_a_id, "content": "任务A", "status": "in_progress"} + {"id": "i1", "content": "任务A", "status": "in_progress"} ] }), ) @@ -851,7 +748,8 @@ mod tests { assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); assert_eq!(output["current_todos"].as_array().unwrap().len(), 1); - assert_eq!(output["changes"]["removed"].as_array().unwrap().len(), 1); + // message 应包含 removed 计数 + assert!(output["message"].as_str().unwrap().contains("1 removed")); } #[tokio::test] @@ -859,7 +757,6 @@ mod tests { let state = test_state(); let tool = TodoWriteTool::new(state.clone()); - // 在主会话中创建 todo let main_context = ToolContext { session_id: Some("cli:chat-1".to_string()), topic_id: None, @@ -871,14 +768,13 @@ mod tests { &main_context, json!({ "todos": [ - {"content": "主会话任务", "status": "pending"} + {"id": "j1", "content": "主会话任务", "status": "pending"} ] }), ) .await .unwrap(); - // 在 topic 中创建 todo let topic_context = ToolContext { session_id: Some("cli:chat-1".to_string()), topic_id: Some("topic-xyz".to_string()), @@ -890,14 +786,13 @@ mod tests { &topic_context, json!({ "todos": [ - {"content": "话题任务", "status": "pending"} + {"id": "j2", "content": "话题任务", "status": "pending"} ] }), ) .await .unwrap(); - // 验证隔离 let guard = state.read().await; let main_items = guard.get("cli:chat-1").unwrap(); let topic_items = guard.get("topic-xyz").unwrap(); @@ -955,7 +850,6 @@ mod tests { let state = test_state(); let tool = TodoWriteTool::new(state.clone()); - // 父代理 let parent_ctx = ToolContext { session_id: Some("cli:chat-1".to_string()), ..ToolContext::default() @@ -966,14 +860,13 @@ mod tests { &parent_ctx, json!({ "todos": [ - {"content": "父代理任务", "status": "pending"} + {"id": "k1", "content": "父代理任务", "status": "pending"} ] }), ) .await .unwrap(); - // 子代理(不同 session_id) let child_ctx = ToolContext { session_id: Some("sub:cli:chat-1:task:uuid-abc".to_string()), ..ToolContext::default() @@ -984,14 +877,13 @@ mod tests { &child_ctx, json!({ "todos": [ - {"content": "子代理任务", "status": "pending"} + {"id": "k2", "content": "子代理任务", "status": "pending"} ] }), ) .await .unwrap(); - // 验证隔离 let guard = state.read().await; let parent_items = guard.get("cli:chat-1").unwrap(); let child_items = guard.get("sub:cli:chat-1:task:uuid-abc").unwrap(); @@ -1002,61 +894,6 @@ mod tests { assert_eq!(child_items[0].content, "子代理任务"); } - #[tokio::test] - async fn test_in_progress_cannot_revert_to_pending() { - let state = test_state(); - let tool = TodoWriteTool::new(state.clone()); - let context = test_context(); - - // 先创建 pending 任务 - let _ = tool - .execute_with_context( - &context, - json!({ - "todos": [ - {"content": "任务A", "status": "pending"} - ] - }), - ) - .await - .unwrap(); - - let guard = state.read().await; - let scope_key = scope_key_from_context(&context).unwrap(); - let items = guard.get(&scope_key).unwrap(); - let task_id = items[0].id.clone(); - drop(guard); - - // 更新为 in_progress - let _ = tool - .execute_with_context( - &context, - json!({ - "todos": [ - {"id": task_id, "content": "任务A", "status": "in_progress"} - ] - }), - ) - .await - .unwrap(); - - // 尝试从 in_progress 退回 pending - let result = tool - .execute_with_context( - &context, - json!({ - "todos": [ - {"id": task_id, "content": "任务A", "status": "pending"} - ] - }), - ) - .await - .unwrap(); - - assert!(!result.success); - assert!(result.error.unwrap().contains("Cannot move an in_progress task back to pending")); - } - // ── merge 模式测试 ────────────────────────────────────── #[tokio::test] @@ -1065,28 +902,20 @@ mod tests { let tool = TodoWriteTool::new(state.clone()); let context = test_context(); - // 先创建三个 pending 任务 let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务A", "status": "pending"}, - {"content": "任务B", "status": "pending"}, - {"content": "任务C", "status": "pending"} + {"id": "m1", "content": "任务A", "status": "pending"}, + {"id": "m2", "content": "任务B", "status": "pending"}, + {"id": "m3", "content": "任务C", "status": "pending"} ] }), ) .await .unwrap(); - // 获取任务 A 的 id - let guard = state.read().await; - let scope_key = scope_key_from_context(&context).unwrap(); - let items = guard.get(&scope_key).unwrap(); - let id_a = items[0].id.clone(); - drop(guard); - // merge: true — 只传任务 A(改为 in_progress),B 和 C 应被保留 let result = tool .execute_with_context( @@ -1094,7 +923,7 @@ mod tests { json!({ "merge": true, "todos": [ - {"id": id_a, "content": "任务A", "status": "in_progress"} + {"id": "m1", "content": "任务A", "status": "in_progress"} ] }), ) @@ -1104,30 +933,23 @@ mod tests { assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); let todos = output["current_todos"].as_array().unwrap(); - // 3 项都应在 assert_eq!(todos.len(), 3); - // 已更新的一项状态是 in_progress - let task_a = todos.iter().find(|t| t["id"] == id_a).unwrap(); + let task_a = todos.iter().find(|t| t["id"] == "m1").unwrap(); assert_eq!(task_a["status"], "in_progress"); - // diff 中 updated 1, added 0, removed 0 - assert_eq!(output["changes"]["updated"].as_array().unwrap().len(), 1); - assert_eq!(output["changes"]["added"].as_array().unwrap().len(), 0); - assert_eq!(output["changes"]["removed"].as_array().unwrap().len(), 0); } #[tokio::test] - async fn test_merge_mode_add_new_item_without_id() { + async fn test_merge_mode_add_new_item() { let state = test_state(); let tool = TodoWriteTool::new(state.clone()); let context = test_context(); - // 先创建两个任务 let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "已有任务", "status": "pending"} + {"id": "n1", "content": "已有任务", "status": "pending"} ] }), ) @@ -1141,7 +963,7 @@ mod tests { json!({ "merge": true, "todos": [ - {"content": "新任务", "status": "pending"} + {"id": "n2", "content": "新任务", "status": "pending"} ] }), ) @@ -1151,12 +973,7 @@ mod tests { assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); let todos = output["current_todos"].as_array().unwrap(); - // 旧 + 新 = 2 assert_eq!(todos.len(), 2); - // diff: added 1, updated 0, removed 0 - assert_eq!(output["changes"]["added"].as_array().unwrap().len(), 1); - assert_eq!(output["changes"]["updated"].as_array().unwrap().len(), 0); - assert_eq!(output["changes"]["removed"].as_array().unwrap().len(), 0); } #[tokio::test] @@ -1165,14 +982,13 @@ mod tests { let tool = TodoWriteTool::new(state.clone()); let context = test_context(); - // 创建两个任务 let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务A", "status": "pending"}, - {"content": "任务B", "status": "pending"} + {"id": "o1", "content": "任务A", "status": "pending"}, + {"id": "o2", "content": "任务B", "status": "pending"} ] }), ) @@ -1194,10 +1010,7 @@ mod tests { assert!(result.success); let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); let todos = output["current_todos"].as_array().unwrap(); - // 2 项都在 assert_eq!(todos.len(), 2); - // removed 始终为空 - assert_eq!(output["changes"]["removed"].as_array().unwrap().len(), 0); } #[tokio::test] @@ -1206,33 +1019,26 @@ mod tests { let tool = TodoWriteTool::new(state.clone()); let context = test_context(); - // 创建两个任务 let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务A", "status": "pending"}, - {"content": "任务B", "status": "pending"} + {"id": "p1", "content": "任务A", "status": "pending"}, + {"id": "p2", "content": "任务B", "status": "pending"} ] }), ) .await .unwrap(); - let guard = state.read().await; - let scope_key = scope_key_from_context(&context).unwrap(); - let items = guard.get(&scope_key).unwrap(); - let id_a = items[0].id.clone(); - drop(guard); - - // merge 未设置(默认 false)— 只传一个,另一个被删 + // merge=false(默认)— 只传一个,另一个被删 let result = tool .execute_with_context( &context, json!({ "todos": [ - {"id": id_a, "content": "任务A", "status": "in_progress"} + {"id": "p1", "content": "任务A", "status": "in_progress"} ] }), ) @@ -1243,40 +1049,38 @@ mod tests { let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); let todos = output["current_todos"].as_array().unwrap(); assert_eq!(todos.len(), 1); - assert_eq!(output["changes"]["removed"].as_array().unwrap().len(), 1); } #[tokio::test] - async fn test_merge_match_by_content_without_id() { + async fn test_merge_match_by_content_fallback() { let state = test_state(); let tool = TodoWriteTool::new(state.clone()); let context = test_context(); - // 先创建 3 个 pending 任务 let _ = tool .execute_with_context( &context, json!({ "todos": [ - {"content": "任务1", "status": "pending"}, - {"content": "任务2", "status": "pending"}, - {"content": "任务3", "status": "pending"} + {"id": "q1", "content": "任务1", "status": "pending"}, + {"id": "q2", "content": "任务2", "status": "pending"}, + {"id": "q3", "content": "任务3", "status": "pending"} ] }), ) .await .unwrap(); - // merge: true — 直接传 content + completed,不传 id,按 content 匹配 + // merge: true — 传了不同的 id 但相同 content,应通过 content fallback 匹配 let result = tool .execute_with_context( &context, json!({ "merge": true, "todos": [ - {"content": "任务1", "status": "completed"}, - {"content": "任务2", "status": "completed"}, - {"content": "任务3", "status": "cancelled"} + {"id": "wrong-id-1", "content": "任务1", "status": "completed"}, + {"id": "wrong-id-2", "content": "任务2", "status": "completed"}, + {"id": "wrong-id-3", "content": "任务3", "status": "cancelled"} ] }), ) @@ -1287,10 +1091,31 @@ mod tests { let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); let todos = output["current_todos"].as_array().unwrap(); assert_eq!(todos.len(), 3); + // content fallback 匹配后应使用旧 id + assert_eq!(todos.iter().find(|t| t["content"] == "任务1").unwrap()["id"], "q1"); assert_eq!(todos.iter().find(|t| t["content"] == "任务1").unwrap()["status"], "completed"); assert_eq!(todos.iter().find(|t| t["content"] == "任务2").unwrap()["status"], "completed"); assert_eq!(todos.iter().find(|t| t["content"] == "任务3").unwrap()["status"], "cancelled"); - assert_eq!(output["changes"]["updated"].as_array().unwrap().len(), 3); - assert_eq!(output["changes"]["added"].as_array().unwrap().len(), 0); + } + + #[tokio::test] + async fn test_missing_id_validation_error() { + let tool = TodoWriteTool::new(test_state()); + let context = test_context(); + + let result = tool + .execute_with_context( + &context, + json!({ + "todos": [ + {"content": "缺少 id 的任务", "status": "pending"} + ] + }), + ) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.unwrap().contains("missing or empty 'id'")); } }