Skip to main content

opendev_tools_impl/
write_todos.rs

1//! write_todos tool — replace the entire todo list.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6use opendev_runtime::{SubTodoItem, TodoManager, TodoStatus, parse_status, strip_markdown};
7use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
8
9/// Tool that replaces the entire todo list.
10#[derive(Debug)]
11pub struct WriteTodosTool {
12    manager: Arc<Mutex<TodoManager>>,
13}
14
15impl WriteTodosTool {
16    pub fn new(manager: Arc<Mutex<TodoManager>>) -> Self {
17        Self { manager }
18    }
19}
20
21#[async_trait::async_trait]
22impl BaseTool for WriteTodosTool {
23    fn name(&self) -> &str {
24        "write_todos"
25    }
26
27    fn description(&self) -> &str {
28        "Replace the entire todo list with new items. Each item can be a string \
29         or an object with content, status, and activeForm fields."
30    }
31
32    fn parameter_schema(&self) -> serde_json::Value {
33        serde_json::json!({
34            "type": "object",
35            "properties": {
36                "todos": {
37                    "type": "array",
38                    "maxItems": 10,
39                    "description": "List of parent todo items (max 10). Each can be a string or an object with 'content' (required), 'status' (optional), 'activeForm' (optional), and 'children' (optional array of sub-step strings, hidden in UI but shown in status output).",
40                    "items": {
41                        "oneOf": [
42                            { "type": "string", "minLength": 1 },
43                            {
44                                "type": "object",
45                                "properties": {
46                                    "content": { "type": "string", "minLength": 1 },
47                                    "status": { "type": "string" },
48                                    "activeForm": { "type": "string" },
49                                    "children": {
50                                        "type": "array",
51                                        "items": { "type": "string" },
52                                        "description": "Sub-steps for this todo. Hidden in the user's UI but included in status output so you can track sub-steps."
53                                    }
54                                },
55                                "required": ["content"]
56                            }
57                        ]
58                    }
59                }
60            },
61            "required": ["todos"]
62        })
63    }
64
65    async fn execute(
66        &self,
67        args: HashMap<String, serde_json::Value>,
68        _ctx: &ToolContext,
69    ) -> ToolResult {
70        let todos_val = match args.get("todos") {
71            Some(v) if v.is_array() => v.as_array().unwrap(),
72            _ => return ToolResult::fail("todos array is required"),
73        };
74
75        let mut items = Vec::new();
76        for item in todos_val {
77            if let Some(s) = item.as_str() {
78                let title = strip_markdown(s);
79                if title.trim().is_empty() {
80                    continue; // skip empty items
81                }
82                items.push((title, TodoStatus::Pending, String::new(), Vec::new()));
83            } else if let Some(obj) = item.as_object() {
84                let content = match obj.get("content").and_then(|v| v.as_str()) {
85                    Some(c) => {
86                        let stripped = strip_markdown(c);
87                        if stripped.trim().is_empty() {
88                            continue; // skip empty items
89                        }
90                        stripped
91                    }
92                    None => return ToolResult::fail("Each todo object requires a 'content' field"),
93                };
94                let status = obj
95                    .get("status")
96                    .and_then(|v| v.as_str())
97                    .and_then(parse_status)
98                    .unwrap_or(TodoStatus::Pending);
99                let active_form = obj
100                    .get("activeForm")
101                    .and_then(|v| v.as_str())
102                    .unwrap_or("")
103                    .to_string();
104                let children: Vec<SubTodoItem> = obj
105                    .get("children")
106                    .and_then(|v| v.as_array())
107                    .map(|arr| {
108                        arr.iter()
109                            .filter_map(|v| {
110                                v.as_str().map(|s| SubTodoItem {
111                                    title: strip_markdown(s),
112                                })
113                            })
114                            .collect()
115                    })
116                    .unwrap_or_default();
117                items.push((content, status, active_form, children));
118            } else {
119                return ToolResult::fail("Each todo must be a string or object");
120            }
121        }
122
123        const MAX_TODOS: usize = 10;
124        let was_truncated = items.len() > MAX_TODOS;
125        if was_truncated {
126            items.truncate(MAX_TODOS);
127        }
128
129        let mut mgr = match self.manager.lock() {
130            Ok(m) => m,
131            Err(e) => return ToolResult::fail(format!("Lock error: {e}")),
132        };
133
134        // Detect status-only updates: if the new titles match the existing
135        // titles, just update statuses instead of clearing and recreating.
136        // This avoids duplicate "Created N todos" display when the LLM
137        // calls write_todos again with the same list.
138        // Skip this optimization when any item has children (force full rewrite).
139        let has_children = items.iter().any(|(_, _, _, c)| !c.is_empty());
140        let existing_titles: Vec<String> = mgr.all().iter().map(|t| t.title.clone()).collect();
141        let new_titles: Vec<&str> = items.iter().map(|(t, _, _, _)| t.as_str()).collect();
142        let is_status_only = !has_children
143            && !existing_titles.is_empty()
144            && existing_titles.len() == new_titles.len()
145            && existing_titles
146                .iter()
147                .zip(new_titles.iter())
148                .all(|(a, b)| a == b);
149
150        if is_status_only {
151            // Collect (id, new_status) pairs first to avoid borrow conflict
152            let updates: Vec<(usize, TodoStatus)> = mgr
153                .all()
154                .iter()
155                .zip(items.iter())
156                .filter(|(todo, (_, status, _, _))| todo.status != *status)
157                .map(|(todo, (_, status, _, _))| (todo.id, *status))
158                .collect();
159            for (id, status) in &updates {
160                mgr.set_status(*id, *status);
161            }
162            return if updates.is_empty() {
163                ToolResult::ok("Todos unchanged. Now proceed with the next action.")
164            } else {
165                ToolResult::ok(format!(
166                    "Updated {} todo status(es). Now proceed with the next action.\n\n{}",
167                    updates.len(),
168                    mgr.format_status_sorted()
169                ))
170            };
171        }
172
173        mgr.write_todos(items);
174        let count = mgr.total();
175        let truncation_note = if was_truncated {
176            format!(" (truncated to {MAX_TODOS} — this is expected, do NOT call write_todos again)")
177        } else {
178            String::new()
179        };
180        ToolResult::ok(format!(
181            "Created {count} todo(s){truncation_note}. Now proceed with the next action.\n\n{}",
182            mgr.format_status_sorted()
183        ))
184    }
185}
186
187#[cfg(test)]
188#[path = "write_todos_tests.rs"]
189mod tests;