返回 CodeWhale
todo.rs
根目录 / crates / tui / src / tools / todo.rs
1 //! Todo list tool and supporting data structures.
2
3 use std::sync::Arc;
4 use tokio::sync::Mutex;
5
6 use async_trait::async_trait;
7 use serde::{Deserialize, Serialize};
8 use serde_json::json;
9
10 use crate::tools::spec::{
11 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
12 };
13
14 // === Types ===
15
16 /// Status for a todo item.
17 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18 #[serde(rename_all = "snake_case")]
19 pub enum TodoStatus {
20 Pending,
21 InProgress,
22 Completed,
23 #[serde(alias = "canceled")]
24 Cancelled,
25 }
26
27 impl TodoStatus {
28 #[allow(dead_code)]
29 pub fn as_str(self) -> &'static str {
30 match self {
31 TodoStatus::Pending => "pending",
32 TodoStatus::InProgress => "in_progress",
33 TodoStatus::Completed => "completed",
34 TodoStatus::Cancelled => "cancelled",
35 }
36 }
37
38 /// Parse a string into a todo status.
39 #[must_use]
40 pub fn from_str(value: &str) -> Option<Self> {
41 match value.trim().to_lowercase().as_str() {
42 "pending" => Some(TodoStatus::Pending),
43 "in_progress" | "inprogress" | "in-progress" | "in progress" => {
44 Some(TodoStatus::InProgress)
45 }
46 "completed" | "complete" | "done" => Some(TodoStatus::Completed),
47 "cancelled" | "canceled" => Some(TodoStatus::Cancelled),
48 _ => None,
49 }
50 }
51
52 /// Whether this item has reached a terminal outcome. Cancellation settles
53 /// work without misreporting it as successful completion.
54 #[must_use]
55 pub fn is_settled(self) -> bool {
56 matches!(self, TodoStatus::Completed | TodoStatus::Cancelled)
57 }
58 }
59
60 /// A single todo item.
61 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62 pub struct TodoItem {
63 pub id: u32,
64 pub content: String,
65 pub status: TodoStatus,
66 }
67
68 /// Snapshot of a todo list for display or serialization.
69 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
70 pub struct TodoListSnapshot {
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub items: Vec<TodoItem>,
73 #[serde(default)]
74 pub completion_pct: u8,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub in_progress_id: Option<u32>,
77 }
78
79 impl TodoListSnapshot {
80 #[must_use]
81 pub fn is_empty(&self) -> bool {
82 self.items.is_empty()
83 }
84 }
85
86 /// Mutable list of todo items with helper operations.
87 #[derive(Debug, Clone, Default)]
88 pub struct TodoList {
89 items: Vec<TodoItem>,
90 next_id: u32,
91 }
92
93 impl TodoList {
94 /// Create an empty todo list.
95 #[must_use]
96 pub fn new() -> Self {
97 Self {
98 items: Vec::new(),
99 next_id: 1,
100 }
101 }
102
103 /// Return a snapshot of the list with computed metrics.
104 #[must_use]
105 pub fn snapshot(&self) -> TodoListSnapshot {
106 TodoListSnapshot {
107 items: self.items.clone(),
108 completion_pct: self.completion_percentage(),
109 in_progress_id: self.in_progress_id(),
110 }
111 }
112
113 /// Rebuild a mutable list from a persisted snapshot.
114 ///
115 /// Derived snapshot fields are deliberately recomputed. IDs and the
116 /// single-in-progress invariant are validated before any live state is
117 /// replaced, so malformed session data cannot leave a half-restored list.
118 pub fn from_snapshot(snapshot: &TodoListSnapshot) -> Result<Self, String> {
119 let mut seen = std::collections::HashSet::with_capacity(snapshot.items.len());
120 let mut in_progress_count = 0usize;
121 let mut max_id = 0u32;
122 let mut items = Vec::with_capacity(snapshot.items.len());
123
124 for item in &snapshot.items {
125 if item.id == 0 {
126 return Err("To-do item IDs must be greater than zero".to_string());
127 }
128 if !seen.insert(item.id) {
129 return Err(format!("Duplicate To-do item ID {}", item.id));
130 }
131 if item.status == TodoStatus::InProgress {
132 in_progress_count += 1;
133 if in_progress_count > 1 {
134 return Err("Only one To-do item may be in progress".to_string());
135 }
136 }
137 max_id = max_id.max(item.id);
138 items.push(TodoItem {
139 id: item.id,
140 content: item.content.clone(),
141 status: item.status,
142 });
143 }
144
145 let next_id = if items.is_empty() {
146 1
147 } else {
148 max_id
149 .checked_add(1)
150 .ok_or_else(|| "To-do item IDs are exhausted".to_string())?
151 };
152 Ok(Self { items, next_id })
153 }
154
155 /// Add a new todo item.
156 pub fn add(&mut self, content: String, status: TodoStatus) -> TodoItem {
157 let status = match status {
158 TodoStatus::InProgress => {
159 self.set_single_in_progress(None);
160 TodoStatus::InProgress
161 }
162 other => other,
163 };
164
165 let item = TodoItem {
166 id: self.next_id,
167 content,
168 status,
169 };
170 self.next_id += 1;
171 self.items.push(item.clone());
172 item
173 }
174
175 /// Update an item's status by id.
176 #[cfg(test)]
177 pub fn update_status(&mut self, id: u32, status: TodoStatus) -> Option<TodoItem> {
178 let mut updated: Option<TodoItem> = None;
179 if status == TodoStatus::InProgress {
180 self.set_single_in_progress(Some(id));
181 }
182 for item in &mut self.items {
183 if item.id == id {
184 item.status = status;
185 updated = Some(item.clone());
186 break;
187 }
188 }
189 updated
190 }
191
192 /// Compute completion percentage for the list.
193 #[must_use]
194 pub fn completion_percentage(&self) -> u8 {
195 if self.items.is_empty() {
196 return 0;
197 }
198 let total = self.items.len();
199 let settled = self
200 .items
201 .iter()
202 .filter(|item| item.status.is_settled())
203 .count();
204 let percent = settled.saturating_mul(100);
205 let percent = (percent + total / 2) / total;
206 u8::try_from(percent).unwrap_or(u8::MAX)
207 }
208
209 /// Return the id of the in-progress item, if any.
210 #[must_use]
211 pub fn in_progress_id(&self) -> Option<u32> {
212 self.items
213 .iter()
214 .find(|item| item.status == TodoStatus::InProgress)
215 .map(|item| item.id)
216 }
217
218 /// Clear all todo items.
219 pub fn clear(&mut self) {
220 self.items.clear();
221 self.next_id = 1;
222 }
223
224 fn set_single_in_progress(&mut self, allow_id: Option<u32>) {
225 for item in &mut self.items {
226 if Some(item.id) != allow_id && item.status == TodoStatus::InProgress {
227 item.status = TodoStatus::Pending;
228 }
229 }
230 }
231 }
232
233 // === TodoWriteTool - ToolSpec implementation ===
234
235 /// Shared reference to a `TodoList` for use across tools
236 pub type SharedTodoList = Arc<Mutex<TodoList>>;
237
238 /// Create a new shared `TodoList`
239 pub fn new_shared_todo_list() -> SharedTodoList {
240 Arc::new(Mutex::new(TodoList::new()))
241 }
242
243 const CANONICAL_WORK_SURFACE: &str = "work";
244 const CANONICAL_PROGRESS_TOOL: &str = "work_update";
245 const DURABLE_WORK_OWNER: &str = "fleet_workflow_ledger";
246
247 /// Tool for writing and updating the todo list
248 pub struct TodoWriteTool {
249 todo_list: SharedTodoList,
250 }
251
252 impl TodoWriteTool {
253 /// Canonical model-facing progress surface (#4132).
254 pub fn work_update(todo_list: SharedTodoList) -> Self {
255 Self { todo_list }
256 }
257 }
258
259 #[async_trait]
260 impl ToolSpec for TodoWriteTool {
261 fn name(&self) -> &'static str {
262 CANONICAL_PROGRESS_TOOL
263 }
264
265 fn description(&self) -> &'static str {
266 "Replace the active thread/task To-do list (concrete current work items). This is the canonical progress surface the user watches, so keep it live while you work: mark an item in_progress before starting it (exactly one at a time), and call work_update again the moment an item finishes so it shows completed — never batch completions at the end. Durable tasks remain the real executable work object."
267 }
268
269 fn input_schema(&self) -> serde_json::Value {
270 json!({
271 "type": "object",
272 "properties": {
273 "todos": {
274 "type": "array",
275 "description": "The complete list of To-do items. This replaces the existing list.",
276 "items": {
277 "type": "object",
278 "properties": {
279 "content": {
280 "type": "string",
281 "description": "The task description"
282 },
283 "status": {
284 "type": "string",
285 "enum": ["pending", "in_progress", "completed", "cancelled"],
286 "description": "Task status"
287 }
288 },
289 "required": ["content", "status"]
290 }
291 }
292 },
293 "required": ["todos"]
294 })
295 }
296
297 fn capabilities(&self) -> Vec<ToolCapability> {
298 vec![ToolCapability::WritesFiles]
299 }
300
301 fn approval_requirement(&self) -> ApprovalRequirement {
302 ApprovalRequirement::Auto
303 }
304
305 async fn execute(
306 &self,
307 input: serde_json::Value,
308 context: &ToolContext,
309 ) -> Result<ToolResult, ToolError> {
310 let todos = input
311 .get("todos")
312 .and_then(|v| v.as_array())
313 .ok_or_else(|| ToolError::invalid_input("Missing or invalid 'todos' array"))?;
314
315 let mut list = TodoList::new();
316
317 for item in todos {
318 let content = item
319 .get("content")
320 .and_then(|v| v.as_str())
321 .ok_or_else(|| ToolError::invalid_input("Todo item missing 'content'"))?;
322
323 let status = match item.get("status").and_then(|v| v.as_str()) {
324 Some(raw) => TodoStatus::from_str(raw).ok_or_else(|| {
325 // #5123-class: unknown statuses used to silently coerce to
326 // pending on the canonical progress surface.
327 ToolError::invalid_input(format!(
328 "unknown todo status '{raw}'; expected pending, in_progress, \
329 completed, or cancelled"
330 ))
331 })?,
332 None => TodoStatus::Pending,
333 };
334
335 list.add(content.to_string(), status);
336 }
337
338 let snapshot = publish_todo_snapshot(
339 context,
340 &self.todo_list,
341 CANONICAL_PROGRESS_TOOL,
342 list.snapshot(),
343 )
344 .await?;
345 let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
346
347 Ok(ToolResult::success(format!(
348 "Todo list updated ({} items, {}% settled)\n{}",
349 snapshot.items.len(),
350 snapshot.completion_pct,
351 result
352 ))
353 .with_metadata(work_progress_metadata(&snapshot)))
354 }
355 }
356
357 async fn publish_todo_snapshot(
358 context: &ToolContext,
359 todo_list: &SharedTodoList,
360 tool_name: &str,
361 desired: TodoListSnapshot,
362 ) -> Result<TodoListSnapshot, ToolError> {
363 if let Some(work) = context.runtime.work.as_ref()
364 && work.matches_todos(todo_list)
365 {
366 return work
367 .apply_todo_update(&context.state_namespace, tool_name, &desired)
368 .await
369 .map_err(ToolError::execution_failed);
370 }
371 *todo_list.lock().await =
372 TodoList::from_snapshot(&desired).map_err(ToolError::execution_failed)?;
373 Ok(desired)
374 }
375
376 fn work_progress_metadata(snapshot: &TodoListSnapshot) -> serde_json::Value {
377 let items = snapshot
378 .items
379 .iter()
380 .map(|item| {
381 json!({
382 "id": item.id,
383 "content": item.content,
384 "status": item.status.as_str(),
385 })
386 })
387 .collect::<Vec<_>>();
388 json!({
389 "canonical_tool": CANONICAL_PROGRESS_TOOL,
390 "work_surface": {
391 "canonical": CANONICAL_WORK_SURFACE,
392 "model_visible": true,
393 "durable_owner": DURABLE_WORK_OWNER,
394 "progress_key": "task_updates.checklist"
395 },
396 "task_updates": {
397 "checklist": {
398 "items": items,
399 "completion_pct": snapshot.completion_pct,
400 "in_progress_id": snapshot.in_progress_id,
401 "updated_at": null
402 }
403 }
404 })
405 }
406
407 #[cfg(test)]
408 mod tests {
409 #[test]
410 fn work_update_description_teaches_live_upkeep() {
411 // 2026-07-23 user report: models wrote the list once and never
412 // updated it while working. The canonical tool description must
413 // carry the upkeep contract every provider sees.
414 let tool = super::TodoWriteTool::work_update(super::new_shared_todo_list());
415 let description = crate::tools::spec::ToolSpec::description(&tool);
416 for phrase in [
417 "keep it live while you work",
418 "in_progress before starting it (exactly one at a time)",
419 "the moment an item finishes",
420 "never batch completions",
421 ] {
422 assert!(
423 description.contains(phrase),
424 "work_update description missing {phrase:?}: {description}"
425 );
426 }
427 }
428
429 use super::*;
430
431 #[test]
432 fn cancelled_is_a_terminal_round_trippable_todo_state() {
433 assert_eq!(
434 TodoStatus::from_str("cancelled"),
435 Some(TodoStatus::Cancelled)
436 );
437 assert_eq!(
438 TodoStatus::from_str("canceled"),
439 Some(TodoStatus::Cancelled)
440 );
441
442 let mut list = TodoList::new();
443 list.add("abandoned approach".to_string(), TodoStatus::Cancelled);
444 let snapshot = list.snapshot();
445 assert_eq!(snapshot.completion_pct, 100);
446 assert_eq!(snapshot.in_progress_id, None);
447 assert_eq!(
448 serde_json::to_value(snapshot.items[0].status).expect("serialize"),
449 serde_json::json!("cancelled")
450 );
451
452 let schema = TodoWriteTool::work_update(new_shared_todo_list()).input_schema();
453 let statuses = &schema["properties"]["todos"]["items"]["properties"]["status"]["enum"];
454 assert!(statuses.as_array().is_some_and(|values| {
455 values
456 .iter()
457 .any(|value| value.as_str() == Some("cancelled"))
458 }));
459 }
460
461 #[test]
462 fn persisted_snapshot_restores_ids_status_and_recomputes_metrics() {
463 let snapshot = TodoListSnapshot {
464 items: vec![
465 TodoItem {
466 id: 4,
467 content: " inspect ".to_string(),
468 status: TodoStatus::Completed,
469 },
470 TodoItem {
471 id: 9,
472 content: "patch".to_string(),
473 status: TodoStatus::InProgress,
474 },
475 ],
476 completion_pct: 0,
477 in_progress_id: None,
478 };
479
480 let mut restored = TodoList::from_snapshot(&snapshot).expect("restore");
481 let restored_snapshot = restored.snapshot();
482 assert_eq!(restored_snapshot.items[0].id, 4);
483 assert_eq!(restored_snapshot.items[0].content, " inspect ");
484 assert_eq!(restored_snapshot.items[1].id, 9);
485 assert_eq!(restored_snapshot.completion_pct, 50);
486 assert_eq!(restored_snapshot.in_progress_id, Some(9));
487 assert_eq!(
488 restored.add("verify".to_string(), TodoStatus::Pending).id,
489 10
490 );
491 }
492
493 #[test]
494 fn malformed_persisted_snapshot_is_rejected_deterministically() {
495 let duplicate = TodoListSnapshot {
496 items: vec![
497 TodoItem {
498 id: 1,
499 content: "one".to_string(),
500 status: TodoStatus::InProgress,
501 },
502 TodoItem {
503 id: 1,
504 content: "two".to_string(),
505 status: TodoStatus::Pending,
506 },
507 ],
508 ..TodoListSnapshot::default()
509 };
510 assert_eq!(
511 TodoList::from_snapshot(&duplicate).unwrap_err(),
512 "Duplicate To-do item ID 1"
513 );
514
515 let multiple_active = TodoListSnapshot {
516 items: vec![
517 TodoItem {
518 id: 1,
519 content: "one".to_string(),
520 status: TodoStatus::InProgress,
521 },
522 TodoItem {
523 id: 2,
524 content: "two".to_string(),
525 status: TodoStatus::InProgress,
526 },
527 ],
528 ..TodoListSnapshot::default()
529 };
530 assert_eq!(
531 TodoList::from_snapshot(&multiple_active).unwrap_err(),
532 "Only one To-do item may be in progress"
533 );
534 }
535
536 #[tokio::test]
537 async fn work_update_rejects_unknown_status_instead_of_coercing_to_pending() {
538 // #5123-class: statuses like "blocked" / "in-progress" used to be
539 // recorded as pending with a success receipt on the canonical
540 // progress surface.
541 let tool = TodoWriteTool::work_update(new_shared_todo_list());
542 let context = ToolContext::new(std::env::temp_dir());
543 let err = tool
544 .execute(
545 json!({"todos": [{ "content": "x", "status": "blocked" }]}),
546 &context,
547 )
548 .await
549 .expect_err("unknown status must fail fast");
550 assert!(format!("{err}").contains("unknown todo status"), "{err}");
551
552 // Common near-misses resolve via the synonym table.
553 assert_eq!(
554 TodoStatus::from_str("complete"),
555 Some(TodoStatus::Completed)
556 );
557 assert_eq!(
558 TodoStatus::from_str("in-progress"),
559 Some(TodoStatus::InProgress)
560 );
561 assert_eq!(TodoStatus::from_str("blocked"), None);
562 }
563
564 #[tokio::test]
565 async fn work_update_returns_canonical_task_update_metadata() {
566 let tool = TodoWriteTool::work_update(new_shared_todo_list());
567 let context = ToolContext::new(std::env::temp_dir());
568 let result = tool
569 .execute(
570 json!({
571 "todos": [
572 { "content": "wire durable task tools", "status": "in_progress" },
573 { "content": "run gates", "status": "pending" }
574 ]
575 }),
576 &context,
577 )
578 .await
579 .expect("work_update succeeds");
580
581 assert!(tool.model_visible());
582 let metadata = result.metadata.expect("metadata");
583 assert_eq!(metadata["canonical_tool"], "work_update");
584 assert_eq!(metadata["work_surface"]["canonical"], "work");
585 assert_eq!(metadata["work_surface"]["model_visible"], true);
586 assert_eq!(
587 metadata["work_surface"]["durable_owner"],
588 "fleet_workflow_ledger"
589 );
590 assert_eq!(
591 metadata["work_surface"]["progress_key"],
592 "task_updates.checklist"
593 );
594 assert_eq!(
595 metadata["task_updates"]["checklist"]["in_progress_id"],
596 json!(1)
597 );
598 assert_eq!(
599 metadata["task_updates"]["checklist"]["items"][0]["content"],
600 "wire durable task tools"
601 );
602 }
603
604 #[tokio::test]
605 async fn work_update_routes_through_attached_graph() {
606 let todos = new_shared_todo_list();
607 let plan = crate::tools::plan::new_shared_plan_state();
608 let work = crate::work_graph::new_shared_work_runtime(todos.clone(), plan);
609 let mut context = ToolContext::new(std::env::temp_dir());
610 context.runtime.work = Some(work.clone());
611
612 TodoWriteTool::work_update(todos.clone())
613 .execute(
614 json!({"todos": [
615 {"content": "Graph-owned", "status": "completed"},
616 {"content": "Discarded branch", "status": "cancelled"}
617 ]}),
618 &context,
619 )
620 .await
621 .expect("second work_update");
622
623 let state = work
624 .capture(Some(&context.state_namespace))
625 .expect("capture")
626 .expect("graph state");
627 assert_eq!(state.todos.items[0].status, TodoStatus::Completed);
628 assert_eq!(state.todos.items[1].status, TodoStatus::Cancelled);
629 assert_eq!(state.todos.completion_pct, 100);
630 let node = state
631 .graph
632 .node(&state.graph.compat.todos[0].node)
633 .expect("projected node");
634 assert_eq!(node.state, crate::work_graph::NodeState::Completed);
635 let cancelled_node = state
636 .graph
637 .node(&state.graph.compat.todos[1].node)
638 .expect("cancelled projected node");
639 assert_eq!(
640 cancelled_node.state,
641 crate::work_graph::NodeState::Cancelled
642 );
643 assert!(todos.lock().await.snapshot().is_empty());
644 assert_eq!(work.publish_pending().await, Ok(true));
645 assert_eq!(
646 todos.lock().await.snapshot().items[0].status,
647 TodoStatus::Completed
648 );
649 assert_eq!(
650 todos.lock().await.snapshot().items[1].status,
651 TodoStatus::Cancelled
652 );
653 }
654 }
655
655 lines RUST