Skip to main content

microclaw_engine/tools/
todo.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use std::sync::Arc;
5use tracing::info;
6
7use crate::internal::storage::db::Database;
8use crate::internal::tool_runtime::todo_store::{format_todos, read_todos, write_todos, TodoItem};
9use microclaw_core::llm_types::ToolDefinition;
10
11use super::{auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolResult};
12
13// --- TodoReadTool ---
14
15pub struct TodoReadTool {
16    groups_dir: PathBuf,
17}
18
19impl TodoReadTool {
20    pub fn new(data_dir: &str) -> Self {
21        TodoReadTool {
22            groups_dir: PathBuf::from(data_dir).join("groups"),
23        }
24    }
25}
26
27fn todo_channel_from_input(input: &serde_json::Value) -> String {
28    auth_context_from_input(input)
29        .map(|a| a.caller_channel)
30        .filter(|s| !s.trim().is_empty())
31        .unwrap_or_else(|| "web".to_string())
32}
33
34#[async_trait]
35impl Tool for TodoReadTool {
36    fn name(&self) -> &str {
37        "todo_read"
38    }
39
40    fn definition(&self) -> ToolDefinition {
41        ToolDefinition {
42            name: "todo_read".into(),
43            description: "Read the current todo/plan list for this chat. Returns all tasks with their status (pending, in_progress, completed).".into(),
44            input_schema: schema_object(
45                json!({
46                    "chat_id": {
47                        "type": "integer",
48                        "description": "The chat ID"
49                    }
50                }),
51                &["chat_id"],
52            ),
53        }
54    }
55
56    async fn execute(&self, input: serde_json::Value) -> ToolResult {
57        let chat_id = match input.get("chat_id").and_then(|v| v.as_i64()) {
58            Some(id) => id,
59            None => return ToolResult::error("Missing 'chat_id' parameter".into()),
60        };
61        if let Err(e) = authorize_chat_access(&input, chat_id) {
62            return ToolResult::error(e);
63        }
64
65        let channel = todo_channel_from_input(&input);
66        info!("Reading todo list for chat {}", chat_id);
67        let todos = read_todos(&self.groups_dir, &channel, chat_id);
68        ToolResult::success(format_todos(&todos))
69    }
70}
71
72// --- TodoWriteTool ---
73
74pub struct TodoWriteTool {
75    groups_dir: PathBuf,
76    db: Option<Arc<Database>>,
77}
78
79impl TodoWriteTool {
80    pub fn new(data_dir: &str) -> Self {
81        TodoWriteTool {
82            groups_dir: PathBuf::from(data_dir).join("groups"),
83            db: None,
84        }
85    }
86
87    pub fn with_db(mut self, db: Arc<Database>) -> Self {
88        self.db = Some(db);
89        self
90    }
91}
92
93#[async_trait]
94impl Tool for TodoWriteTool {
95    fn name(&self) -> &str {
96        "todo_write"
97    }
98
99    fn definition(&self) -> ToolDefinition {
100        ToolDefinition {
101            name: "todo_write".into(),
102            description: "Write/update the todo list for this chat. Replaces the entire list. Use this to create a plan, update task statuses, or reorganize tasks. Each task has a 'task' (description) and 'status' (pending, in_progress, or completed).".into(),
103            input_schema: schema_object(
104                json!({
105                    "chat_id": {
106                        "type": "integer",
107                        "description": "The chat ID"
108                    },
109                    "todos": {
110                        "type": "array",
111                        "description": "The complete todo list",
112                        "items": {
113                            "type": "object",
114                            "properties": {
115                                "task": {
116                                    "type": "string",
117                                    "description": "Task description"
118                                },
119                                "status": {
120                                    "type": "string",
121                                    "enum": ["pending", "in_progress", "completed"],
122                                    "description": "Task status"
123                                }
124                            },
125                            "required": ["task", "status"]
126                        }
127                    }
128                }),
129                &["chat_id", "todos"],
130            ),
131        }
132    }
133
134    async fn execute(&self, input: serde_json::Value) -> ToolResult {
135        let chat_id = match input.get("chat_id").and_then(|v| v.as_i64()) {
136            Some(id) => id,
137            None => return ToolResult::error("Missing 'chat_id' parameter".into()),
138        };
139        if let Err(e) = authorize_chat_access(&input, chat_id) {
140            return ToolResult::error(e);
141        }
142
143        let todos_val = match input.get("todos") {
144            Some(v) => v,
145            None => return ToolResult::error("Missing 'todos' parameter".into()),
146        };
147
148        let todos: Vec<TodoItem> = match serde_json::from_value(todos_val.clone()) {
149            Ok(t) => t,
150            Err(e) => return ToolResult::error(format!("Invalid todos format: {e}")),
151        };
152
153        let channel = todo_channel_from_input(&input);
154        info!("Writing {} todo items for chat {}", todos.len(), chat_id);
155
156        match write_todos(&self.groups_dir, &channel, chat_id, &todos) {
157            Ok(()) => {
158                if let Some(db) = &self.db {
159                    let goal_id = format!("chat:{chat_id}:active-plan");
160                    let objective = todos
161                        .iter()
162                        .find(|todo| todo.status == "in_progress")
163                        .or_else(|| todos.iter().find(|todo| todo.status == "pending"))
164                        .or_else(|| todos.first())
165                        .map(|todo| todo.task.as_str())
166                        .unwrap_or("No active plan");
167                    let status = if todos.is_empty()
168                        || todos.iter().all(|todo| todo.status == "completed")
169                    {
170                        "completed"
171                    } else {
172                        "active"
173                    };
174                    let progress = serde_json::to_string(&todos).ok();
175                    if let Err(e) = db.upsert_goal_state(
176                        &goal_id,
177                        chat_id,
178                        objective,
179                        status,
180                        None,
181                        progress.as_deref(),
182                        None,
183                    ) {
184                        return ToolResult::error(format!(
185                            "Todo list was written, but durable goal-state synchronization failed: {e}"
186                        ));
187                    }
188                }
189                ToolResult::success(format!(
190                    "Todo list updated ({} tasks).\n\n{}",
191                    todos.len(),
192                    format_todos(&todos)
193                ))
194            }
195            Err(e) => ToolResult::error(format!("Failed to write todo list: {e}")),
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use serde_json::json;
204
205    fn test_dir() -> PathBuf {
206        std::env::temp_dir().join(format!("microclaw_todo_test_{}", uuid::Uuid::new_v4()))
207    }
208
209    fn cleanup(dir: &std::path::Path) {
210        let _ = std::fs::remove_dir_all(dir);
211    }
212
213    #[test]
214    fn test_todo_item_serde() {
215        let item = TodoItem {
216            task: "Do something".into(),
217            status: "pending".into(),
218        };
219        let json = serde_json::to_string(&item).unwrap();
220        let parsed: TodoItem = serde_json::from_str(&json).unwrap();
221        assert_eq!(parsed.task, "Do something");
222        assert_eq!(parsed.status, "pending");
223    }
224
225    #[test]
226    fn test_format_todos_empty() {
227        assert_eq!(format_todos(&[]), "No tasks in the todo list.");
228    }
229
230    #[test]
231    fn test_format_todos() {
232        let todos = vec![
233            TodoItem {
234                task: "Plan".into(),
235                status: "completed".into(),
236            },
237            TodoItem {
238                task: "Build".into(),
239                status: "in_progress".into(),
240            },
241            TodoItem {
242                task: "Test".into(),
243                status: "pending".into(),
244            },
245        ];
246        let formatted = format_todos(&todos);
247        assert!(formatted.contains("1. [x] Plan"));
248        assert!(formatted.contains("2. [~] Build"));
249        assert!(formatted.contains("3. [ ] Test"));
250    }
251
252    #[test]
253    fn test_read_todos_empty() {
254        let dir = test_dir();
255        let groups_dir = dir.join("groups");
256        let todos = read_todos(&groups_dir, "web", 123);
257        assert!(todos.is_empty());
258        cleanup(&dir);
259    }
260
261    #[test]
262    fn test_write_and_read_todos() {
263        let dir = test_dir();
264        let groups_dir = dir.join("groups");
265        let todos = vec![
266            TodoItem {
267                task: "Step 1".into(),
268                status: "pending".into(),
269            },
270            TodoItem {
271                task: "Step 2".into(),
272                status: "pending".into(),
273            },
274        ];
275        write_todos(&groups_dir, "web", 42, &todos).unwrap();
276        let loaded = read_todos(&groups_dir, "web", 42);
277        assert_eq!(loaded.len(), 2);
278        assert_eq!(loaded[0].task, "Step 1");
279        assert_eq!(loaded[1].task, "Step 2");
280        cleanup(&dir);
281    }
282
283    #[test]
284    fn test_todo_read_tool_name_and_definition() {
285        let dir = test_dir();
286        let tool = TodoReadTool::new(dir.to_str().unwrap());
287        assert_eq!(tool.name(), "todo_read");
288        let def = tool.definition();
289        assert_eq!(def.name, "todo_read");
290        assert!(def.input_schema["properties"]["chat_id"].is_object());
291        cleanup(&dir);
292    }
293
294    #[test]
295    fn test_todo_write_tool_name_and_definition() {
296        let dir = test_dir();
297        let tool = TodoWriteTool::new(dir.to_str().unwrap());
298        assert_eq!(tool.name(), "todo_write");
299        let def = tool.definition();
300        assert_eq!(def.name, "todo_write");
301        assert!(def.input_schema["properties"]["chat_id"].is_object());
302        assert!(def.input_schema["properties"]["todos"].is_object());
303        cleanup(&dir);
304    }
305
306    #[tokio::test]
307    async fn test_todo_read_empty() {
308        let dir = test_dir();
309        let tool = TodoReadTool::new(dir.to_str().unwrap());
310        let result = tool.execute(json!({"chat_id": 100})).await;
311        assert!(!result.is_error);
312        assert!(result.content.contains("No tasks"));
313        cleanup(&dir);
314    }
315
316    #[tokio::test]
317    async fn test_todo_read_missing_chat_id() {
318        let dir = test_dir();
319        let tool = TodoReadTool::new(dir.to_str().unwrap());
320        let result = tool.execute(json!({})).await;
321        assert!(result.is_error);
322        assert!(result.content.contains("Missing"));
323        cleanup(&dir);
324    }
325
326    #[tokio::test]
327    async fn test_todo_write_and_read() {
328        let dir = test_dir();
329        let write_tool = TodoWriteTool::new(dir.to_str().unwrap());
330        let read_tool = TodoReadTool::new(dir.to_str().unwrap());
331
332        let result = write_tool
333            .execute(json!({
334                "chat_id": 42,
335                "todos": [
336                    {"task": "Research", "status": "completed"},
337                    {"task": "Implement", "status": "in_progress"},
338                    {"task": "Test", "status": "pending"}
339                ]
340            }))
341            .await;
342        assert!(!result.is_error);
343        assert!(result.content.contains("3 tasks"));
344
345        let result = read_tool.execute(json!({"chat_id": 42})).await;
346        assert!(!result.is_error);
347        assert!(result.content.contains("[x] Research"));
348        assert!(result.content.contains("[~] Implement"));
349        assert!(result.content.contains("[ ] Test"));
350        cleanup(&dir);
351    }
352
353    #[tokio::test]
354    async fn test_todo_write_missing_params() {
355        let dir = test_dir();
356        let tool = TodoWriteTool::new(dir.to_str().unwrap());
357
358        let result = tool.execute(json!({})).await;
359        assert!(result.is_error);
360
361        let result = tool.execute(json!({"chat_id": 1})).await;
362        assert!(result.is_error);
363        cleanup(&dir);
364    }
365
366    #[tokio::test]
367    async fn test_todo_write_invalid_format() {
368        let dir = test_dir();
369        let tool = TodoWriteTool::new(dir.to_str().unwrap());
370        let result = tool
371            .execute(json!({
372                "chat_id": 1,
373                "todos": "not an array"
374            }))
375            .await;
376        assert!(result.is_error);
377        assert!(result.content.contains("Invalid todos format"));
378        cleanup(&dir);
379    }
380
381    #[tokio::test]
382    async fn test_todo_write_overwrites() {
383        let dir = test_dir();
384        let write_tool = TodoWriteTool::new(dir.to_str().unwrap());
385        let read_tool = TodoReadTool::new(dir.to_str().unwrap());
386
387        write_tool
388            .execute(json!({
389                "chat_id": 1,
390                "todos": [{"task": "Old task", "status": "pending"}]
391            }))
392            .await;
393
394        write_tool
395            .execute(json!({
396                "chat_id": 1,
397                "todos": [{"task": "New task", "status": "in_progress"}]
398            }))
399            .await;
400
401        let result = read_tool.execute(json!({"chat_id": 1})).await;
402        assert!(result.content.contains("New task"));
403        assert!(!result.content.contains("Old task"));
404        cleanup(&dir);
405    }
406
407    #[tokio::test]
408    async fn test_todo_read_permission_denied() {
409        let dir = test_dir();
410        let tool = TodoReadTool::new(dir.to_str().unwrap());
411        let result = tool
412            .execute(json!({
413                "chat_id": 200,
414                "__microclaw_auth": {
415                    "caller_chat_id": 100,
416                    "control_chat_ids": []
417                }
418            }))
419            .await;
420        assert!(result.is_error);
421        assert!(result.content.contains("Permission denied"));
422        cleanup(&dir);
423    }
424
425    #[tokio::test]
426    async fn test_todo_write_permission_denied() {
427        let dir = test_dir();
428        let tool = TodoWriteTool::new(dir.to_str().unwrap());
429        let result = tool
430            .execute(json!({
431                "chat_id": 200,
432                "todos": [{"task": "x", "status": "pending"}],
433                "__microclaw_auth": {
434                    "caller_chat_id": 100,
435                    "control_chat_ids": []
436                }
437            }))
438            .await;
439        assert!(result.is_error);
440        assert!(result.content.contains("Permission denied"));
441        cleanup(&dir);
442    }
443
444    #[tokio::test]
445    async fn test_todo_write_and_read_allowed_for_control_chat_cross_chat() {
446        let dir = test_dir();
447        let write_tool = TodoWriteTool::new(dir.to_str().unwrap());
448        let read_tool = TodoReadTool::new(dir.to_str().unwrap());
449        let result = write_tool
450            .execute(json!({
451                "chat_id": 200,
452                "todos": [{"task": "cross", "status": "pending"}],
453                "__microclaw_auth": {
454                    "caller_chat_id": 100,
455                    "control_chat_ids": [100]
456                }
457            }))
458            .await;
459        assert!(!result.is_error, "{}", result.content);
460        let result = read_tool
461            .execute(json!({
462                "chat_id": 200,
463                "__microclaw_auth": {
464                    "caller_chat_id": 100,
465                    "control_chat_ids": [100]
466                }
467            }))
468            .await;
469        assert!(!result.is_error, "{}", result.content);
470        assert!(result.content.contains("cross"));
471        cleanup(&dir);
472    }
473}