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
13pub 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
72pub 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}