fix: merge 模式下按 content 匹配已有项,无需 agent 记住 UUID

- merge=true 时,无 id 的项先按 content 匹配已有项
- 匹配到→更新,匹配不到→新建(仍需 pending/in_progress)
- 新增 list_todos 命令处理器,支持前端自动拉取
- prompt 增加规则6:更新已有任务必须传 id(作为最佳实践提示)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
oudecheng 2026-06-12 15:59:43 +08:00
parent 750eed7326
commit c4d10c6413

View File

@ -272,7 +272,31 @@ impl Tool for TodoWriteTool {
updated_at: now,
});
} else {
// 传入 id 但旧状态中没有 → 按新 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;
}
}
if !is_valid_new_status(&new_status) {
validation_errors.push(format!(
"Item '{}': new items must start as 'pending' or 'in_progress', got '{}'",
@ -280,7 +304,6 @@ impl Tool for TodoWriteTool {
));
continue;
}
processed_items.push(TodoItem {
id: id.to_string(),
content,
@ -291,7 +314,31 @@ impl Tool for TodoWriteTool {
});
}
} else {
// 新 item无 id— 允许 pending 或 in_progress
// 新 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;
}
}
if !is_valid_new_status(&new_status) {
validation_errors.push(format!(
"Item '{}': new items must start as 'pending' or 'in_progress', got '{}'",
@ -299,7 +346,6 @@ impl Tool for TodoWriteTool {
));
continue;
}
processed_items.push(TodoItem {
id: uuid::Uuid::new_v4().to_string(),
content,
@ -1197,4 +1243,52 @@ mod tests {
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() {
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"}
]
}),
)
.await
.unwrap();
// merge: true — 直接传 content + completed不传 id按 content 匹配
let result = tool
.execute_with_context(
&context,
json!({
"merge": true,
"todos": [
{"content": "任务1", "status": "completed"},
{"content": "任务2", "status": "completed"},
{"content": "任务3", "status": "cancelled"}
]
}),
)
.await
.unwrap();
assert!(result.success);
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);
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);
}
}