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