1use std::sync::{Arc, RwLock};
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12
13use crate::error::{Error, Result};
14use crate::event::{AgentEvent, EventSink};
15use crate::llm::ToolSpec;
16use crate::tools::{Tool, ToolSideEffect};
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "snake_case")]
25pub enum TodoStatus {
26 Pending,
27 InProgress,
28 Completed,
29 Cancelled,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct TodoItem {
35 pub content: String,
37 pub status: TodoStatus,
39 #[serde(skip_serializing_if = "Option::is_none")]
42 pub active_form: Option<String>,
43}
44
45pub struct TodoWriteTool {
57 todo_list: Arc<RwLock<Vec<TodoItem>>>,
58 event_sink: Arc<dyn EventSink>,
59}
60
61impl TodoWriteTool {
62 pub fn new(todo_list: Arc<RwLock<Vec<TodoItem>>>, event_sink: Arc<dyn EventSink>) -> Self {
69 Self {
70 todo_list,
71 event_sink,
72 }
73 }
74}
75
76#[async_trait]
77impl Tool for TodoWriteTool {
78 fn spec(&self) -> ToolSpec {
79 ToolSpec {
80 name: "TodoWrite".into(),
81 description: "Create and manage a structured task list for the current session. \
82 Use it proactively for tasks with 3 or more distinct steps; update status \
83 in real time as you work.\n\n\
84 When to use:\n\
85 - Create the list BEFORE starting work (capture requirements as todos)\n\
86 - Mark exactly ONE task as in_progress at a time\n\
87 - Mark completed IMMEDIATELY after finishing (not batched)\n\
88 - ONLY mark completed when fully done (tests passing, no partial work)\n\
89 - Clear the list (call with an empty array) when all tasks are done\n\n\
90 When NOT to use:\n\
91 - Single, straightforward tasks\n\
92 - Purely conversational responses\n\
93 - Tasks completable in less than 3 trivial steps\n\n\
94 Do not use this tool for a single trivial task — just do the task directly."
95 .into(),
96 parameters: json!({
97 "type": "object",
98 "required": ["todos"],
99 "properties": {
100 "todos": {
101 "type": "array",
102 "description": "Complete replacement for the task list. \
103 Pass an empty array to clear all tasks.",
104 "items": {
105 "type": "object",
106 "required": ["content", "status"],
107 "properties": {
108 "content": {
109 "type": "string",
110 "description": "Task description in imperative form, e.g. \"Run tests\"."
111 },
112 "status": {
113 "type": "string",
114 "enum": ["pending", "in_progress", "completed", "cancelled"],
115 "description": "Current status of this task."
116 },
117 "active_form": {
118 "type": "string",
119 "description": "Optional present-continuous label shown while in_progress, \
120 e.g. \"Running tests\"."
121 }
122 }
123 }
124 }
125 }
126 }),
127 }
128 }
129
130 fn is_deferred(&self) -> bool {
131 true
132 }
133
134 async fn execute(&self, arguments: Value) -> Result<String> {
135 let raw_todos = arguments.get("todos").ok_or_else(|| Error::BadToolArgs {
137 name: "TodoWrite".into(),
138 message: "missing required field `todos`".into(),
139 })?;
140
141 let items: Vec<TodoItem> =
142 serde_json::from_value(raw_todos.clone()).map_err(|e| Error::BadToolArgs {
143 name: "TodoWrite".into(),
144 message: format!("failed to parse `todos`: {e}"),
145 })?;
146
147 let in_progress_count = items
149 .iter()
150 .filter(|t| t.status == TodoStatus::InProgress)
151 .count();
152 if in_progress_count > 1 {
153 return Err(Error::BadToolArgs {
154 name: "TodoWrite".into(),
155 message: format!(
156 "at most one task may have status `in_progress`; found {in_progress_count}"
157 ),
158 });
159 }
160
161 let count = items.len();
163 let in_progress_label = items
164 .iter()
165 .find(|t| t.status == TodoStatus::InProgress)
166 .map(|t| t.active_form.clone().unwrap_or_else(|| t.content.clone()));
167 let remaining = items
168 .iter()
169 .filter(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
170 .count();
171
172 {
174 let mut list = self.todo_list.write().map_err(|_| Error::Tool {
175 name: "TodoWrite".into(),
176 call_id: None,
177 message: "todo list lock poisoned".into(),
178 })?;
179 *list = items.clone();
180 }
181
182 self.event_sink
184 .emit(AgentEvent::TodoUpdated {
185 todos: items.clone(),
186 })
187 .await;
188
189 let result = json!({
192 "updated": true,
193 "count": count,
194 "in_progress": in_progress_label,
195 "remaining": remaining,
196 "todos": items,
197 });
198 Ok(result.to_string())
199 }
200
201 fn side_effect_class(&self) -> ToolSideEffect {
202 ToolSideEffect::Mutating
204 }
205
206 fn is_readonly(&self) -> bool {
207 false
208 }
209}
210
211#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::event::NullSink;
219
220 fn make_tool() -> (TodoWriteTool, Arc<RwLock<Vec<TodoItem>>>) {
221 let list = Arc::new(RwLock::new(vec![]));
222 let tool = TodoWriteTool::new(list.clone(), Arc::new(NullSink));
223 (tool, list)
224 }
225
226 #[tokio::test]
227 async fn basic_write_and_read() {
228 let (tool, list) = make_tool();
229 let args = json!({
230 "todos": [
231 {"content": "Step 1", "status": "pending"},
232 {"content": "Step 2", "status": "in_progress"},
233 ]
234 });
235 let result = tool.execute(args).await.unwrap();
236 let parsed: Value = serde_json::from_str(&result).unwrap();
237 assert_eq!(parsed["count"], 2);
238 assert_eq!(parsed["remaining"], 2);
239 assert_eq!(parsed["in_progress"], "Step 2");
240 assert_eq!(parsed["updated"], true);
241
242 let stored = list.read().unwrap();
243 assert_eq!(stored.len(), 2);
244 assert_eq!(stored[0].content, "Step 1");
245 }
246
247 #[tokio::test]
248 async fn rejects_multiple_in_progress() {
249 let (tool, _) = make_tool();
250 let args = json!({
251 "todos": [
252 {"content": "A", "status": "in_progress"},
253 {"content": "B", "status": "in_progress"},
254 ]
255 });
256 let err = tool.execute(args).await.unwrap_err();
257 assert!(err.to_string().contains("at most one task"));
258 }
259
260 #[tokio::test]
261 async fn clears_list_with_empty_array() {
262 let (tool, list) = make_tool();
263 let _ = tool
265 .execute(json!({"todos": [{"content": "X", "status": "pending"}]}))
266 .await
267 .unwrap();
268 assert_eq!(list.read().unwrap().len(), 1);
269
270 let result = tool.execute(json!({"todos": []})).await.unwrap();
272 let parsed: Value = serde_json::from_str(&result).unwrap();
273 assert_eq!(parsed["count"], 0);
274 assert!(list.read().unwrap().is_empty());
275 }
276
277 #[tokio::test]
278 async fn active_form_used_when_present() {
279 let (tool, _) = make_tool();
280 let args = json!({
281 "todos": [
282 {
283 "content": "Run tests",
284 "status": "in_progress",
285 "active_form": "Running tests"
286 }
287 ]
288 });
289 let result = tool.execute(args).await.unwrap();
290 let parsed: Value = serde_json::from_str(&result).unwrap();
291 assert_eq!(parsed["in_progress"], "Running tests");
292 }
293
294 #[test]
295 fn side_effect_is_mutating() {
296 let (tool, _) = make_tool();
297 assert_eq!(tool.side_effect_class(), ToolSideEffect::Mutating);
298 assert!(!tool.is_readonly());
299 }
300
301 #[test]
302 fn is_deferred_returns_true() {
303 let (tool, _) = make_tool();
305 assert!(tool.is_deferred(), "TodoWriteTool must be a deferred tool");
306 }
307
308 #[tokio::test]
309 async fn remaining_excludes_completed_and_cancelled() {
310 let (tool, _) = make_tool();
313 let args = json!({
314 "todos": [
315 {"content": "A", "status": "pending"},
316 {"content": "B", "status": "in_progress"},
317 {"content": "C", "status": "completed"},
318 {"content": "D", "status": "cancelled"},
319 ]
320 });
321 let result = tool.execute(args).await.unwrap();
322 let parsed: Value = serde_json::from_str(&result).unwrap();
323 assert_eq!(parsed["count"], 4, "count must include all 4 todos");
324 assert_eq!(
325 parsed["remaining"], 2,
326 "remaining must count only pending+in_progress (not completed/cancelled)"
327 );
328 }
329
330 #[tokio::test]
331 async fn missing_todos_field_returns_error() {
332 let (tool, _) = make_tool();
334 let res = tool.execute(json!({})).await;
335 assert!(res.is_err(), "missing 'todos' field must return error");
336 }
337
338 #[tokio::test]
339 async fn in_progress_label_uses_active_form_when_present() {
340 let (tool, _) = make_tool();
342 let args = json!({
343 "todos": [
344 {"content": "Write tests", "active_form": "Writing tests...", "status": "in_progress"},
345 ]
346 });
347 let result = tool.execute(args).await.unwrap();
348 let parsed: Value = serde_json::from_str(&result).unwrap();
349 assert_eq!(
350 parsed["in_progress"], "Writing tests...",
351 "in_progress label must use active_form when set; got: {}",
352 parsed["in_progress"]
353 );
354 }
355
356 #[tokio::test]
357 async fn in_progress_label_falls_back_to_content() {
358 let (tool, _) = make_tool();
360 let args = json!({
361 "todos": [
362 {"content": "Run tests", "status": "in_progress"},
363 ]
365 });
366 let result = tool.execute(args).await.unwrap();
367 let parsed: Value = serde_json::from_str(&result).unwrap();
368 assert_eq!(
369 parsed["in_progress"], "Run tests",
370 "in_progress must fall back to content when active_form absent"
371 );
372 }
373}