Skip to main content

ai_agent/utils/task_list/
mod.rs

1// Source: ~/claudecode/openclaudecode/src/utils/tasks.ts
2//! Task management utilities (TodoV2 task list system).
3
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::{Mutex, OnceLock};
8
9/// Task statuses
10pub const TASK_STATUSES: [&str; 3] = ["pending", "in_progress", "completed"];
11
12/// Task status enum
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum TaskStatus {
16    Pending,
17    InProgress,
18    Completed,
19}
20
21impl std::fmt::Display for TaskStatus {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            TaskStatus::Pending => write!(f, "pending"),
25            TaskStatus::InProgress => write!(f, "in_progress"),
26            TaskStatus::Completed => write!(f, "completed"),
27        }
28    }
29}
30
31/// Task representation
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Task {
34    pub id: String,
35    pub subject: String,
36    pub description: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub active_form: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub owner: Option<String>,
41    pub status: TaskStatus,
42    #[serde(default)]
43    pub blocks: Vec<String>,
44    #[serde(default)]
45    pub blocked_by: Vec<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub metadata: Option<HashMap<String, serde_json::Value>>,
48}
49
50/// Check if TodoV2 is enabled
51pub fn is_todo_v2_enabled() -> bool {
52    // In non-interactive mode (e.g. SDK users who want Task tools over TodoWrite),
53    // tasks can be force-enabled via environment variable
54    let env_enabled = std::env::var("AI_CODE_ENABLE_TASKS")
55        .map(|v| v == "1" || v == "true" || v == "yes")
56        .unwrap_or(false);
57
58    if env_enabled {
59        return true;
60    }
61
62    // Default: enabled in interactive sessions
63    // For now, always return true (the agent runs in interactive mode)
64    true
65}
66
67/// Get the task list ID (directory identifier for this session's tasks)
68pub fn get_task_list_id() -> String {
69    // Use session ID or a unique identifier for the current session
70    std::env::var("AI_CODE_SESSION_ID").ok().unwrap_or_else(|| {
71        // Fallback: use a UUID-like identifier
72        uuid::Uuid::new_v4().to_string()
73    })
74}
75
76/// Get the tasks directory path
77fn get_tasks_dir(task_list_id: &str) -> PathBuf {
78    let config_dir = dirs::home_dir()
79        .map(|d| d.join(".ai").join("tasks"))
80        .unwrap_or_else(|| PathBuf::from("/tmp/.ai/tasks"));
81
82    config_dir.join(task_list_id)
83}
84
85/// In-memory task store for single-session use
86static TASK_STORE: OnceLock<Mutex<TaskStore>> = OnceLock::new();
87
88struct TaskStore {
89    tasks: HashMap<String, Task>,
90    high_water_mark: u64,
91}
92
93impl TaskStore {
94    fn new() -> Self {
95        Self {
96            tasks: HashMap::new(),
97            high_water_mark: 0,
98        }
99    }
100}
101
102fn get_store() -> &'static Mutex<TaskStore> {
103    TASK_STORE.get_or_init(|| Mutex::new(TaskStore::new()))
104}
105
106pub fn reset_task_store() {
107    let mut store = get_store().lock().unwrap();
108    store.tasks.clear();
109    store.high_water_mark = 0;
110}
111
112/// Generate the next task ID
113fn next_task_id() -> String {
114    let mut store = get_store().lock().unwrap();
115    store.high_water_mark += 1;
116    store.high_water_mark.to_string()
117}
118
119/// Create a new task
120pub async fn create_task(_task_list_id: &str, task: Task) -> Result<String, String> {
121    let id = next_task_id();
122    let mut new_task = task.clone();
123    new_task.id = id.clone();
124
125    let mut store = get_store().lock().unwrap();
126    store.tasks.insert(id.clone(), new_task);
127    Ok(id)
128}
129
130/// Get a task by ID
131pub async fn get_task(_task_list_id: &str, task_id: &str) -> Result<Option<Task>, String> {
132    let store = get_store().lock().unwrap();
133    Ok(store.tasks.get(task_id).cloned())
134}
135
136/// List all tasks
137pub async fn list_tasks(_task_list_id: &str) -> Result<Vec<Task>, String> {
138    let store = get_store().lock().unwrap();
139    Ok(store.tasks.values().cloned().collect())
140}
141
142/// Get all non-completed tasks from the in-memory store.
143pub fn get_unfinished_tasks() -> Vec<Task> {
144    let store = get_store().lock().unwrap();
145    store
146        .tasks
147        .values()
148        .filter(|t| t.status != TaskStatus::Completed)
149        .cloned()
150        .collect()
151}
152
153/// Update a task
154pub async fn update_task(
155    _task_list_id: &str,
156    task_id: &str,
157    updates: TaskUpdate,
158) -> Result<(), String> {
159    let mut store = get_store().lock().unwrap();
160    if let Some(task) = store.tasks.get_mut(task_id) {
161        if let Some(subject) = updates.subject {
162            task.subject = subject;
163        }
164        if let Some(description) = updates.description {
165            task.description = description;
166        }
167        if let Some(status) = updates.status {
168            task.status = status;
169        }
170        if let Some(owner) = updates.owner {
171            task.owner = Some(owner);
172        }
173        if let Some(active_form) = updates.active_form {
174            task.active_form = Some(active_form);
175        }
176        if let Some(blocks) = updates.blocks {
177            task.blocks = blocks;
178        }
179        if let Some(blocked_by) = updates.blocked_by {
180            task.blocked_by = blocked_by;
181        }
182        Ok(())
183    } else {
184        Err(format!("Task {} not found", task_id))
185    }
186}
187
188/// Delete a task
189pub async fn delete_task(_task_list_id: &str, task_id: &str) -> Result<(), String> {
190    let mut store = get_store().lock().unwrap();
191    if store.tasks.remove(task_id).is_some() {
192        Ok(())
193    } else {
194        Err(format!("Task {} not found", task_id))
195    }
196}
197
198/// Task update fields
199pub struct TaskUpdate {
200    pub subject: Option<String>,
201    pub description: Option<String>,
202    pub status: Option<TaskStatus>,
203    pub owner: Option<String>,
204    pub active_form: Option<String>,
205    pub blocks: Option<Vec<String>>,
206    pub blocked_by: Option<Vec<String>>,
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::tests::common::clear_all_test_state;
213
214    #[test]
215    fn test_is_todo_v2_enabled() {
216        clear_all_test_state();
217        assert!(is_todo_v2_enabled());
218    }
219
220    #[test]
221    fn test_task_status_display() {
222        clear_all_test_state();
223        assert_eq!(TaskStatus::Pending.to_string(), "pending");
224        assert_eq!(TaskStatus::InProgress.to_string(), "in_progress");
225        assert_eq!(TaskStatus::Completed.to_string(), "completed");
226    }
227
228    #[tokio::test]
229    async fn test_create_and_get_task() {
230        clear_all_test_state();
231        reset_task_store();
232        let task_list_id = get_task_list_id();
233        let task = Task {
234            id: String::new(),
235            subject: "Test task".to_string(),
236            description: "Test description".to_string(),
237            active_form: None,
238            owner: None,
239            status: TaskStatus::Pending,
240            blocks: vec![],
241            blocked_by: vec![],
242            metadata: None,
243        };
244        let id = create_task(&task_list_id, task).await.unwrap();
245        assert_eq!(id, "1");
246
247        let retrieved = get_task(&task_list_id, &id).await.unwrap().unwrap();
248        assert_eq!(retrieved.subject, "Test task");
249        assert_eq!(retrieved.status, TaskStatus::Pending);
250    }
251
252    #[tokio::test]
253    async fn test_list_tasks() {
254        clear_all_test_state();
255        reset_task_store();
256        let task_list_id = get_task_list_id();
257        // Create a task first so list_tasks has something to return
258        let task = Task {
259            id: String::new(),
260            subject: "Test task".to_string(),
261            description: "Test description".to_string(),
262            active_form: None,
263            owner: None,
264            status: TaskStatus::Pending,
265            blocks: vec![],
266            blocked_by: vec![],
267            metadata: None,
268        };
269        create_task(&task_list_id, task).await.unwrap();
270        let tasks = list_tasks(&task_list_id).await.unwrap();
271        assert!(!tasks.is_empty());
272    }
273
274    #[tokio::test]
275    async fn test_delete_task() {
276        clear_all_test_state();
277        reset_task_store();
278        let task_list_id = get_task_list_id();
279        let task = Task {
280            id: String::new(),
281            subject: "To delete".to_string(),
282            description: "Will be deleted".to_string(),
283            active_form: None,
284            owner: None,
285            status: TaskStatus::Pending,
286            blocks: vec![],
287            blocked_by: vec![],
288            metadata: None,
289        };
290        let id = create_task(&task_list_id, task).await.unwrap();
291        delete_task(&task_list_id, &id).await.unwrap();
292        let retrieved = get_task(&task_list_id, &id).await.unwrap();
293        assert!(retrieved.is_none());
294    }
295}