Skip to main content

vtcode_core/tools/handlers/
task_tracker.rs

1//! Task Tracker tool for structured task management during complex sessions.
2//!
3//! Based on NL2Repo-Bench findings: agents that leverage explicit planning
4//! tools achieve significantly better scores. This tool provides a first-class
5//! mechanism for the agent to create, update, and query a task checklist
6//! persisted to `.vtcode/tasks/`.
7//!
8//! ## Actions
9//!
10//! - `create`: Create a new task checklist with a title and list of items
11//! - `update`: Mark a specific task item as completed, in_progress, or pending
12//! - `list`: Show the current task checklist and its status
13//! - `add`: Add a new item to an existing checklist
14
15use super::planning_task_tracker::{PlanningTaskTrackerArgs, PlanningTaskTrackerTool};
16use super::planning_workflow::{PlanningWorkflowState, plan_file_for_tracker_file, tracker_file_for_plan_file};
17use std::str::FromStr;
18
19use crate::config::constants::tools;
20use crate::tools::error_helpers::deserialize_tool_args;
21use crate::tools::handlers::task_tracking::{
22    TASK_ITEMS_DESCRIPTION, TaskCounts, TaskItemInput, TaskMetadataRef, TaskStepMetadata, TaskTrackingStatus,
23    TaskTreeNode, TrackerArtifact, append_notes, append_notes_section, append_task_step_metadata, apply_metadata_input,
24    apply_metadata_line, compact_task_tree_view, is_bulk_sync_update, metadata_from_input, normalize_optional_text,
25    parse_marked_status_prefix, parse_status_prefix, parse_task_items, persist_tracker_artifacts,
26    task_files_property_schema, task_items_array_schema, task_status_property_schema, task_verify_property_schema,
27    tracker_schema_allof, validate_action_index_fields, validate_update_shape, write_tracker_artifact,
28};
29use crate::utils::file_utils::{ensure_dir_exists, read_file_with_context};
30use anyhow::{Context, Result, bail};
31use async_trait::async_trait;
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34use std::fmt::Write as _;
35use std::path::{Path, PathBuf};
36use vtcode_commons::workspace_relative_display;
37
38use crate::tools::traits::Tool;
39
40pub type TaskStatus = TaskTrackingStatus;
41
42/// A single task item
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct TaskItem {
45    pub index: usize,
46    pub description: String,
47    pub status: TaskStatus,
48    #[serde(default, flatten)]
49    pub metadata: TaskStepMetadata,
50}
51
52/// The full task checklist
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct TaskChecklist {
55    pub title: String,
56    pub items: Vec<TaskItem>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub notes: Option<String>,
59}
60
61impl TaskChecklist {
62    fn to_markdown(&self) -> String {
63        let mut md = format!("# {}\n\n", self.title);
64        for item in &self.items {
65            let _ = writeln!(md, "- {} {}", item.status.flat_checkbox(), item.description);
66            append_task_step_metadata(&mut md, "", &item.metadata);
67        }
68        append_notes_section(&mut md, self.notes.as_deref());
69        md
70    }
71
72    fn to_plan_markdown(&self) -> String {
73        let mut md = format!("# {}\n\n## Plan of Work\n\n", self.title);
74        for item in &self.items {
75            let trimmed = item.description.trim_start();
76            let indent = &item.description[..item.description.len() - trimmed.len()];
77            let _ = writeln!(md, "{}- {} {}", indent, item.status.plan_checkbox(), trimmed);
78            append_task_step_metadata(&mut md, indent, &item.metadata);
79        }
80        append_notes_section(&mut md, self.notes.as_deref());
81        md
82    }
83
84    fn summary(&self) -> Value {
85        let mut counts = TaskCounts::default();
86        for item in &self.items {
87            counts.add(&item.status);
88        }
89
90        json!({
91            "title": self.title,
92            "total": counts.total,
93            "completed": counts.completed,
94            "in_progress": counts.in_progress,
95            "pending": counts.pending,
96            "blocked": counts.blocked,
97            "progress_percent": counts.progress_percent(),
98            "items": self.items.iter().map(|item| {
99                json!({
100                    "index": item.index,
101                    "description": item.description,
102                    "status": item.status.to_string(),
103                    "files": item.metadata.files.clone(),
104                    "outcome": item.metadata.outcome.clone(),
105                    "verify": item.metadata.verify.clone(),
106                })
107            }).collect::<Vec<_>>()
108            ,
109            "notes": self.notes.clone(),
110        })
111    }
112
113    fn view(&self) -> Value {
114        let nodes = self
115            .items
116            .iter()
117            .map(|item| TaskTreeNode {
118                index_path: item.index.to_string(),
119                description: item.description.clone(),
120                status: item.status,
121                metadata: item.metadata.clone(),
122                children: Vec::new(),
123            })
124            .collect::<Vec<_>>();
125
126        json!({
127            "title": self.title,
128            "lines": compact_task_tree_view(&nodes),
129        })
130    }
131}
132
133fn parse_input_items(items: &[TaskItemInput], previous: &[TaskItem]) -> Result<Vec<TaskItem>> {
134    let previous_metadata: Vec<_> = previous
135        .iter()
136        .map(|item| TaskMetadataRef {
137            description: &item.description,
138            metadata: &item.metadata,
139        })
140        .collect();
141    Ok(parse_task_items(items, &previous_metadata)?
142        .into_iter()
143        .enumerate()
144        .map(|(idx, item)| TaskItem {
145            index: idx + 1,
146            description: item.description,
147            status: item.status,
148            metadata: item.metadata,
149        })
150        .collect())
151}
152
153fn parse_single_index_from_path(index_path: &str) -> Result<usize> {
154    let mut parts = index_path.trim().split('.');
155    let first = parts.next().context("index_path cannot be empty")?;
156    if parts.next().is_some() {
157        bail!(
158            "Hierarchical index_path '{index_path}' requires Planning workflow support. Use 'index' for standard task-tracker updates or switch to Planning workflow."
159        );
160    }
161    let parsed = first
162        .parse::<usize>()
163        .with_context(|| format!("Invalid index_path '{index_path}': expected integer"))?;
164    if parsed == 0 {
165        bail!("index_path must be >= 1");
166    }
167    Ok(parsed)
168}
169
170fn parse_plan_mirror_markdown(content: &str) -> Option<TaskChecklist> {
171    let mut title = String::new();
172    let mut items: Vec<TaskItem> = Vec::new();
173    let mut notes_lines = Vec::new();
174    let mut in_notes = false;
175    let mut in_verify_block = false;
176    let mut idx = 1usize;
177
178    for raw in content.lines() {
179        let trimmed = raw.trim();
180
181        if title.is_empty()
182            && let Some(rest) = trimmed.strip_prefix("# ")
183        {
184            title = rest.trim().to_string();
185            continue;
186        }
187
188        if trimmed == "## Notes" {
189            in_notes = true;
190            continue;
191        }
192
193        if let Some(header) = trimmed.strip_prefix("## ") {
194            let lowered = header.trim().to_ascii_lowercase();
195            in_notes = lowered == "notes";
196            continue;
197        }
198
199        if in_notes {
200            notes_lines.push(raw.to_string());
201            continue;
202        }
203
204        if let Some(last) = items.last_mut() {
205            let indent = raw.chars().take_while(|c| *c == ' ').count();
206            if indent >= 2 && apply_metadata_line(&mut last.metadata, raw, &mut in_verify_block) {
207                continue;
208            }
209            in_verify_block = false;
210        }
211
212        let Some(rest) = trimmed
213            .strip_prefix("- ")
214            .or_else(|| trimmed.strip_prefix("* "))
215            .or_else(|| trimmed.strip_prefix("+ "))
216        else {
217            continue;
218        };
219
220        if let Some((status, description)) = parse_marked_status_prefix(rest) {
221            let leading_spaces = raw.chars().take_while(|c| *c == ' ').count();
222            let description = format!("{}{}", " ".repeat(leading_spaces), description.trim());
223            items.push(TaskItem {
224                index: idx,
225                description,
226                status,
227                metadata: TaskStepMetadata::default(),
228            });
229            idx += 1;
230            in_verify_block = false;
231        }
232    }
233
234    if title.is_empty() && items.is_empty() {
235        return None;
236    }
237
238    let notes = if notes_lines.is_empty() {
239        None
240    } else {
241        Some(notes_lines.join("\n").trim().to_string())
242    };
243
244    Some(TaskChecklist { title, items, notes })
245}
246
247fn newer_source(
248    global_modified: Option<std::time::SystemTime>,
249    plan_modified: Option<std::time::SystemTime>,
250    planning_active: bool,
251) -> TrackerSource {
252    if planning_active {
253        return if plan_modified.is_some() {
254            TrackerSource::Plan
255        } else {
256            TrackerSource::Global
257        };
258    }
259
260    match (global_modified, plan_modified) {
261        (Some(global), Some(plan)) => {
262            if global > plan {
263                TrackerSource::Global
264            } else if plan > global {
265                TrackerSource::Plan
266            } else {
267                TrackerSource::Global
268            }
269        }
270        (Some(_), None) => TrackerSource::Global,
271        (None, Some(_)) => TrackerSource::Plan,
272        (None, None) => {
273            if planning_active {
274                TrackerSource::Plan
275            } else {
276                TrackerSource::Global
277            }
278        }
279    }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum TrackerSource {
284    Global,
285    Plan,
286}
287
288/// Arguments for the task_tracker tool
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct TaskTrackerArgs {
291    /// Action to perform: create, update, list, add
292    pub action: String,
293
294    /// Title for the checklist (required for `create`)
295    #[serde(default)]
296    pub title: Option<String>,
297
298    /// List of task descriptions (required for `create`)
299    #[serde(default)]
300    pub items: Option<Vec<TaskItemInput>>,
301
302    /// Index of item to update (required for `update`, 1-indexed)
303    #[serde(default)]
304    pub index: Option<usize>,
305
306    /// Hierarchical index path for update (Planning workflow, optional)
307    #[serde(default)]
308    pub index_path: Option<String>,
309
310    /// New status for the item (required for `update`)
311    #[serde(default)]
312    pub status: Option<String>,
313
314    /// Description for a new item (required for `add`)
315    #[serde(default)]
316    pub description: Option<String>,
317
318    /// Optional file paths associated with a step
319    #[serde(default)]
320    pub files: Option<Vec<String>>,
321
322    /// Optional expected outcome associated with a step
323    #[serde(default)]
324    pub outcome: Option<String>,
325
326    /// Optional verification command or commands associated with a step
327    #[serde(
328        default,
329        deserialize_with = "crate::tools::handlers::task_tracking::deserialize_optional_string_list"
330    )]
331    pub verify: Option<Vec<String>>,
332
333    /// Optional parent path for add in Planning workflow (example: "2")
334    #[serde(default)]
335    pub parent_index_path: Option<String>,
336
337    /// Optional notes to append
338    #[serde(default)]
339    pub notes: Option<String>,
340}
341
342/// Task Tracker tool state
343pub struct TaskTrackerTool {
344    workspace_root: PathBuf,
345    planning_workflow_state: PlanningWorkflowState,
346}
347
348fn standard_task_tracker_parameter_schema() -> Value {
349    json!({
350        "type": "object",
351        "properties": {
352            "action": {
353                "type": "string",
354                "enum": ["create", "update", "list", "add"],
355                "description": "Action to perform on the task checklist."
356            },
357            "title": {
358                "type": "string",
359                "description": "Title for the checklist (used with 'create')."
360            },
361            "items": task_items_array_schema(TASK_ITEMS_DESCRIPTION),
362            "index": {
363                "type": "integer",
364                "minimum": 0,
365                "description": "Action=update only: use a 1-based item index. index: 0 is reserved for standard checklist-level completion and is valid only with action: update and status: completed."
366            },
367            "index_path": {
368                "type": "string",
369                "pattern": "^[1-9][0-9]*$",
370                "description": "Action=update only: positive flat index path for compatibility (example: '2'). Hierarchical paths are accepted only by Planning workflow."
371            },
372            "status": task_status_property_schema("New status for the item (used with single-item 'update')."),
373            "description": {
374                "type": "string",
375                "description": "Description for a new item (used with 'add')."
376            },
377            "files": task_files_property_schema("Optional file paths associated with a single add/update item."),
378            "outcome": {
379                "type": "string",
380                "description": "Optional expected outcome associated with a single add/update item."
381            },
382            "verify": task_verify_property_schema("Optional verification command or commands associated with a single add/update item."),
383            "parent_index_path": {
384                "type": "string",
385                "description": "Optional parent path for add in Planning workflow (example: '2')."
386            },
387            "notes": {
388                "type": "string",
389                "description": "Optional notes to append to the checklist."
390            }
391        },
392        "required": ["action"],
393        "allOf": tracker_schema_allof(true)
394    })
395}
396
397pub(crate) fn task_tracker_description_for_workflow(planning_active: bool) -> &'static str {
398    if planning_active {
399        "Adaptive task tracking for planning. Persists hierarchical plan progress under .vtcode/plans/<plan>.tasks.md and mirrors updates to .vtcode/tasks/current_task.md. Actions: create, update, list, add. Calling action=create again replaces the plan task list and its progress; use action=update or action=add to change it. For action=update, planning item indices are positive 1-based flat or hierarchical index_path values; index: 0 is invalid. Use items only to replace the full checklist with descriptions and explicit statuses, not indexed updates or JSON-encoded strings. Minimal calls: {\"action\":\"list\"}; {\"action\":\"update\",\"index\":1,\"status\":\"in_progress\"}; {\"action\":\"update\",\"items\":[\"[x] Done\",\"[ ] Remaining\"]}. List uses only action; choose one of index, index_path, or items for update; omit unused fields."
400    } else {
401        "Track task progress through a single checklist API (action: create | update | list | add). Use action=create to start a multi-step plan; action=update as work progresses; action=list to review state. For action=update, item indices are 1-based; checklist-level completion alone may use index: 0 with status: completed. Planning workflow accepts only positive flat or hierarchical index paths. Use items only to replace the full checklist with descriptions and explicit statuses, not indexed updates or JSON-encoded strings. Minimal calls: {\"action\":\"list\"}; {\"action\":\"update\",\"index\":1,\"status\":\"in_progress\"}; {\"action\":\"update\",\"items\":[\"[x] Done\",\"[ ] Remaining\"]}. List uses only action; choose one of index, index_path, or items for update; omit unused fields. Calling action=create again replaces the checklist unless title and items match the active one (a no-op); use action=update or action=add to change it. Tracker state mirrors .vtcode/tasks/current_task.md and active plan sidecars when available."
402    }
403}
404
405pub(crate) fn task_tracker_parameter_schema_for_workflow(planning_active: bool) -> Value {
406    if planning_active {
407        super::planning_task_tracker::planning_task_tracker_parameter_schema()
408    } else {
409        standard_task_tracker_parameter_schema()
410    }
411}
412
413/// Model-facing correction for `task_tracker` shape failures.
414///
415/// The schema uses conditional (`if`/`then`/`not`) rules, so a bare
416/// "required fields" hint is not enough: `create` forbids `index`/`index_path`,
417/// `update` needs exactly one shape, and `add`/`list` have their own shapes.
418/// Without this, a `create` bundled with update fields fails with a generic
419/// root error and the model retries blindly.
420///
421/// Note: the schema only *forbids* `index`/`index_path` on
422/// `create`/`list`/`add`; other single-item fields (`status`, `description`,
423/// `files`, `outcome`, `verify`) are ignored by those actions, not rejected.
424/// The wording below matches that enforced contract: it forbids only what the
425/// schema forbids and recommends (not requires) the minimal shapes.
426pub const TASK_TRACKER_ARGUMENT_CORRECTION: &str = r#"Invalid task_tracker shape. List: {"action":"list"}. Individual update: {"action":"update","index":1,"status":"in_progress"} OR {"action":"update","index_path":"2.1","status":"completed"} (planning). Bulk replacement: {"action":"update","items":["[x] Done","[ ] Remaining"]}. For create, index/index_path are forbidden (use title/items); add uses description; list/add also forbid index/index_path. Never combine index with index_path or items; omit unused fields and retry once."#;
427
428/// Whether a `task_tracker` schema error is a shape error (conditional
429/// `if`/`then`/`not`, `anyOf`/`oneOf`, missing required, fallback) rather
430/// than a single-field value error (`enum`, `type`, `const`, ...). The shape
431/// correction above only helps the former; appending it to a bad-enum error
432/// would point the model at the wrong axis.
433pub(crate) fn is_task_tracker_shape_error(error_msg: &str) -> bool {
434    const MARKERS: [&str; 6] = [
435        "must not include",
436        "does not match any allowed shape",
437        "matches more than one allowed shape",
438        "is forbidden by schema",
439        "missing required property",
440        "failed validation (schema",
441    ];
442    MARKERS.iter().any(|marker| error_msg.contains(marker))
443}
444
445impl TaskTrackerTool {
446    pub fn new(workspace_root: PathBuf, planning_workflow_state: PlanningWorkflowState) -> Self {
447        Self { workspace_root, planning_workflow_state }
448    }
449
450    fn tasks_dir(&self) -> PathBuf {
451        self.workspace_root.join(".vtcode").join("tasks")
452    }
453
454    fn task_file(&self) -> PathBuf {
455        self.tasks_dir().join("current_task.md")
456    }
457
458    fn display_path(&self, path: &Path) -> String {
459        workspace_relative_display(&self.workspace_root, path)
460    }
461
462    async fn plan_task_file(&self) -> Option<PathBuf> {
463        let plan_file = self.planning_workflow_state.get_plan_file().await?;
464        tracker_file_for_plan_file(&plan_file)
465    }
466
467    async fn save_checklist(&self, checklist: &TaskChecklist) -> Result<()> {
468        let dir = self.tasks_dir();
469        ensure_dir_exists(&dir)
470            .await
471            .with_context(|| format!("Failed to create tasks directory: {}", dir.display()))?;
472        let md = checklist.to_markdown();
473        write_tracker_artifact(&self.task_file(), &md, "task checklist")
474            .await
475            .with_context(|| "Failed to write task checklist")?;
476        Ok(())
477    }
478
479    async fn save_plan_mirror_to_file(&self, tracker_file: &Path, checklist: &TaskChecklist) -> Result<()> {
480        persist_tracker_artifacts(Self::plan_mirror_artifacts(tracker_file, checklist).await?).await
481    }
482
483    async fn plan_mirror_artifacts(tracker_file: &Path, checklist: &TaskChecklist) -> Result<Vec<TrackerArtifact>> {
484        let markdown = checklist.to_plan_markdown();
485        let mut artifacts =
486            vec![TrackerArtifact::new(tracker_file.to_path_buf(), markdown.clone(), "plan task tracker file").await?];
487        if let Some(plan_file) = plan_file_for_tracker_file(tracker_file)
488            && let Some(artifact) = TrackerArtifact::embedded_plan(plan_file, &markdown).await?
489        {
490            artifacts.push(artifact);
491        }
492        Ok(artifacts)
493    }
494
495    async fn load_global_checklist(&self) -> Result<Option<TaskChecklist>> {
496        let file = self.task_file();
497        if !tokio::fs::try_exists(&file).await.unwrap_or(false) {
498            return Ok(None);
499        }
500        let content = read_file_with_context(&file, "task checklist").await?;
501
502        let mut title = String::new();
503        let mut items: Vec<TaskItem> = Vec::new();
504        let mut notes_lines = Vec::new();
505        let mut in_notes = false;
506        let mut in_verify_block = false;
507        let mut idx = 1;
508
509        for line in content.lines() {
510            let trimmed = line.trim();
511            if trimmed.starts_with("# ") && title.is_empty() {
512                title = trimmed.strip_prefix("# ").unwrap_or(trimmed).to_string();
513                continue;
514            }
515            if trimmed == "## Notes" {
516                in_notes = true;
517                continue;
518            }
519            if in_notes {
520                notes_lines.push(line.to_string());
521                continue;
522            }
523            if let Some(last) = items.last_mut() {
524                let indent = line.chars().take_while(|c| *c == ' ').count();
525                if indent >= 2 && apply_metadata_line(&mut last.metadata, line, &mut in_verify_block) {
526                    continue;
527                }
528                in_verify_block = false;
529            }
530            if let Some(rest) = trimmed.strip_prefix("- ")
531                && let Some((status, description)) = parse_marked_status_prefix(rest)
532            {
533                items.push(TaskItem {
534                    index: idx,
535                    description,
536                    status,
537                    metadata: TaskStepMetadata::default(),
538                });
539                idx += 1;
540                in_verify_block = false;
541            }
542        }
543
544        if title.is_empty() && items.is_empty() {
545            return Ok(None);
546        }
547
548        let notes = if notes_lines.is_empty() {
549            None
550        } else {
551            Some(notes_lines.join("\n").trim().to_string())
552        };
553
554        Ok(Some(TaskChecklist { title, items, notes }))
555    }
556
557    async fn load_plan_checklist_from(&self, tracker_file: &Path) -> Result<Option<TaskChecklist>> {
558        if !tokio::fs::try_exists(tracker_file).await.unwrap_or(false) {
559            return Ok(None);
560        }
561        let content = read_file_with_context(tracker_file, "plan task tracker file").await?;
562        Ok(parse_plan_mirror_markdown(&content))
563    }
564
565    async fn load_preferred_checklist(&self) -> Result<Option<TaskChecklist>> {
566        let task_file = self.task_file();
567        let plan_file = self.plan_task_file().await;
568
569        let global_exists = tokio::fs::try_exists(&task_file).await.unwrap_or(false);
570        let plan_exists = match plan_file.as_ref() {
571            Some(path) => tokio::fs::try_exists(path).await.unwrap_or(false),
572            None => false,
573        };
574
575        if !global_exists && !plan_exists {
576            return Ok(None);
577        }
578
579        let selected = if global_exists && plan_exists {
580            let global_modified = tokio::fs::metadata(&task_file).await.ok().and_then(|meta| meta.modified().ok());
581            let plan_modified = match &plan_file {
582                Some(path) => tokio::fs::metadata(path).await.ok().and_then(|meta| meta.modified().ok()),
583                None => None,
584            };
585            newer_source(global_modified, plan_modified, self.planning_workflow_state.is_active())
586        } else if plan_exists {
587            TrackerSource::Plan
588        } else {
589            TrackerSource::Global
590        };
591
592        let loaded = match selected {
593            TrackerSource::Global => self.load_global_checklist().await?,
594            TrackerSource::Plan => {
595                if let Some(path) = plan_file.as_ref() {
596                    self.load_plan_checklist_from(path).await?
597                } else {
598                    None
599                }
600            }
601        };
602
603        if let Some(checklist) = loaded.as_ref() {
604            match selected {
605                TrackerSource::Global => {
606                    if let Some(path) = plan_file.as_ref() {
607                        self.save_plan_mirror_to_file(path, checklist).await?;
608                    }
609                }
610                TrackerSource::Plan => {
611                    self.save_checklist(checklist).await?;
612                }
613            }
614        }
615
616        Ok(loaded)
617    }
618
619    async fn persist_edit_mode_snapshot(&self, checklist: &TaskChecklist) -> Result<()> {
620        let mut artifacts =
621            vec![TrackerArtifact::new(self.task_file(), checklist.to_markdown(), "task checklist").await?];
622        if let Some(tracker_file) = self.plan_task_file().await {
623            artifacts.extend(Self::plan_mirror_artifacts(&tracker_file, checklist).await?);
624        }
625        persist_tracker_artifacts(artifacts).await
626    }
627
628    async fn persist_and_build_view(&self, checklist: &TaskChecklist) -> Result<(Value, Value)> {
629        self.persist_edit_mode_snapshot(checklist).await?;
630        Ok((checklist.summary(), checklist.view()))
631    }
632
633    async fn persist_update(
634        &self,
635        previous: Option<&TaskChecklist>,
636        checklist: &TaskChecklist,
637        message: String,
638    ) -> Result<Value> {
639        let unchanged = previous == Some(checklist);
640        if !unchanged {
641            self.persist_edit_mode_snapshot(checklist).await?;
642        }
643        Ok(json!({
644            "status": if unchanged { "unchanged" } else { "updated" },
645            "message": if unchanged { "Checklist already matches requested state.".to_string() } else { message },
646            "checklist": checklist.summary(),
647            "view": checklist.view(),
648        }))
649    }
650
651    fn to_plan_args(args: &TaskTrackerArgs) -> PlanningTaskTrackerArgs {
652        PlanningTaskTrackerArgs {
653            action: args.action.clone(),
654            title: args.title.clone(),
655            items: args.items.clone(),
656            index: args.index,
657            index_path: args.index_path.clone(),
658            status: args.status.clone(),
659            description: args.description.clone(),
660            files: args.files.clone(),
661            outcome: args.outcome.clone(),
662            verify: args.verify.clone(),
663            parent_index_path: args.parent_index_path.clone(),
664            notes: args.notes.clone(),
665        }
666    }
667
668    async fn execute_in_planning_workflow(&self, args: &TaskTrackerArgs) -> Result<Value> {
669        let plan_tool = PlanningTaskTrackerTool::new(self.planning_workflow_state.clone());
670        let mapped = Self::to_plan_args(args);
671        plan_tool.execute(serde_json::to_value(mapped)?).await
672    }
673
674    /// Check if a `create` call is idempotent (same checklist already active).
675    ///
676    /// Returns `Some(unchanged_response)` when the existing checklist should be
677    /// preserved, or `None` when a new checklist must be created.
678    fn check_create_idempotency(
679        existing: &TaskChecklist,
680        title: &str,
681        items: &[TaskItem],
682        task_file_display: &str,
683    ) -> Option<Value> {
684        let same_structure = existing.title == title
685            && existing.items.len() == items.len()
686            && existing
687                .items
688                .iter()
689                .zip(items.iter())
690                .all(|(left, right)| left.description == right.description);
691        let requested_has_explicit_status = items.iter().any(|item| item.status != TaskStatus::Pending);
692        let requested_has_step_metadata = items.iter().any(|item| {
693            !item.metadata.files.is_empty() || item.metadata.outcome.is_some() || !item.metadata.verify.is_empty()
694        });
695
696        let unchanged = if same_structure && !requested_has_explicit_status && !requested_has_step_metadata {
697            Some("Checklist already active; preserved current progress.")
698        } else if existing.title == title
699            && existing.items.len() == items.len()
700            && existing.items.iter().zip(items.iter()).all(|(l, r)| l == r)
701        {
702            Some("Requested checklist already matches current tracker state.")
703        } else {
704            None
705        };
706
707        unchanged.map(|message| {
708            json!({
709                "status": "unchanged",
710                "message": message,
711                "task_file": task_file_display,
712                "checklist": existing.summary(),
713                "view": existing.view()
714            })
715        })
716    }
717
718    async fn handle_create(&self, args: &TaskTrackerArgs) -> Result<Value> {
719        let title = args.title.as_deref().unwrap_or("Task Checklist").to_string();
720        let item_descs = args.items.as_deref().unwrap_or(&[]);
721        if item_descs.is_empty() {
722            anyhow::bail!("At least one item is required for 'create'. Provide items: [\"step 1\", \"step 2\", ...]");
723        }
724
725        let items = parse_input_items(item_descs, &[])?;
726        if items.is_empty() {
727            anyhow::bail!("No valid task items were provided for create.");
728        }
729        let notes = append_notes(None, args.notes.as_deref());
730
731        let existing_checklist = self.load_preferred_checklist().await?;
732        let mut existing_item_count = 0usize;
733        if let Some(existing) = existing_checklist.as_ref() {
734            existing_item_count = existing.items.len();
735            if let Some(unchanged) =
736                Self::check_create_idempotency(existing, &title, &items, &self.display_path(&self.task_file()))
737            {
738                return Ok(unchanged);
739            }
740        }
741
742        let checklist = TaskChecklist { title, items, notes };
743
744        let (summary, view) = self.persist_and_build_view(&checklist).await?;
745
746        let (status, message) = if existing_item_count > 0 {
747            (
748                "replaced",
749                format!("Previous checklist replaced with new structure (was {existing_item_count} items).",),
750            )
751        } else {
752            ("created", "Task checklist created successfully.".to_string())
753        };
754
755        Ok(json!({
756            "status": status,
757            "message": message,
758            "task_file": self.display_path(&self.task_file()),
759            "checklist": summary,
760            "view": view
761        }))
762    }
763
764    async fn handle_update(&self, args: &TaskTrackerArgs) -> Result<Value> {
765        validate_update_shape(args.items.as_deref(), args.index, args.index_path.as_deref(), args.status.as_deref())?;
766        let is_bulk_update =
767            is_bulk_sync_update(args.items.as_deref(), args.index, args.index_path.as_deref(), args.status.as_deref());
768        let mut current = self.load_preferred_checklist().await?;
769        let previous = current.clone();
770        if is_bulk_update {
771            let input_items = args.items.as_deref().unwrap_or(&[]);
772            let items = parse_input_items(
773                input_items,
774                current.as_ref().map(|checklist| checklist.items.as_slice()).unwrap_or(&[]),
775            )?;
776            if items.is_empty() {
777                anyhow::bail!("No valid items provided for checklist sync.");
778            }
779
780            let title = args
781                .title
782                .clone()
783                .or_else(|| current.as_ref().map(|checklist| checklist.title.clone()))
784                .unwrap_or_else(|| "Task Checklist".to_string());
785
786            let checklist = current.get_or_insert(TaskChecklist {
787                title: title.clone(),
788                items: Vec::new(),
789                notes: None,
790            });
791
792            checklist.title = title;
793            checklist.items = items;
794            checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
795            let snapshot = checklist.clone();
796            return self
797                .persist_update(previous.as_ref(), &snapshot, "Checklist synchronized from provided items.".to_string())
798                .await;
799        }
800
801        let checklist = current.as_mut().context("No active checklist. Use action='create' first.")?;
802
803        let index = match (args.index, args.index_path.as_deref()) {
804            (Some(idx), _) => idx,
805            (None, Some(path)) => parse_single_index_from_path(path)?,
806            (None, None) => {
807                bail!(
808                    "'index' is required for 'update' (1-indexed), or provide 'index_path' for Planning workflow updates, or 'items' for bulk sync"
809                )
810            }
811        };
812
813        let status_str = args
814            .status
815            .as_deref()
816            .context("'status' is required for 'update' (pending|in_progress|completed|blocked), or provide 'items' for bulk sync")?;
817
818        let new_status = TaskStatus::from_str(status_str)?;
819
820        if index == 0 {
821            if new_status != TaskStatus::Completed {
822                bail!("index 0 is reserved for checklist-level completion; individual item indices are 1-indexed");
823            }
824
825            if let Some(outcome) = normalize_optional_text(args.outcome.as_deref()) {
826                let checklist_outcome = format!("Checklist outcome: {outcome}");
827                checklist.notes = append_notes(checklist.notes.take(), Some(checklist_outcome.as_str()));
828            }
829            checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
830
831            let snapshot = checklist.clone();
832            return self
833                .persist_update(
834                    previous.as_ref(),
835                    &snapshot,
836                    "Checklist-level completion acknowledged; checklist progress remains derived from item statuses."
837                        .to_string(),
838                )
839                .await;
840        }
841
842        let item_count = checklist.items.len();
843        let pos = checklist
844            .items
845            .iter()
846            .position(|i| i.index == index)
847            .with_context(|| format!("No item at index {index}. Valid range: 1-{item_count}"))?;
848
849        let old_status = checklist.items[pos].status.to_string();
850        checklist.items[pos].status = new_status;
851        let new_status_str = checklist.items[pos].status.to_string();
852        apply_metadata_input(
853            &mut checklist.items[pos].metadata,
854            args.files.as_deref(),
855            args.outcome.as_deref(),
856            args.verify.as_deref(),
857        );
858        checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
859
860        let snapshot = checklist.clone();
861        self.persist_update(
862            previous.as_ref(),
863            &snapshot,
864            format!("Item {} status changed: {} → {}", index, old_status, new_status_str),
865        )
866        .await
867    }
868
869    async fn handle_list(&self) -> Result<Value> {
870        match self.load_preferred_checklist().await? {
871            Some(checklist) => Ok(json!({
872                "status": "ok",
873                "checklist": checklist.summary(),
874                "view": checklist.view()
875            })),
876            None => Ok(json!({
877                "status": "empty",
878                "message": "No active checklist. Use action='create' to start one."
879            })),
880        }
881    }
882
883    async fn handle_add(&self, args: &TaskTrackerArgs) -> Result<Value> {
884        if let Some(parent_path) = args.parent_index_path.as_deref()
885            && !parent_path.trim().is_empty()
886        {
887            bail!(
888                "'parent_index_path' is only supported for hierarchical Planning workflow updates. Use Planning workflow or omit parent_index_path for standard task-tracker updates."
889            );
890        }
891
892        let mut checklist = self
893            .load_preferred_checklist()
894            .await?
895            .context("No active checklist. Use action='create' first.")?;
896
897        let desc = args.description.as_deref().context("'description' is required for 'add'")?;
898        let (status, parsed_description) = parse_status_prefix(desc);
899        let description = parsed_description.trim().to_string();
900        if description.is_empty() {
901            bail!("description cannot be empty");
902        }
903
904        let new_index = checklist.items.len() + 1;
905        checklist.items.push(TaskItem {
906            index: new_index,
907            description: description.clone(),
908            status,
909            metadata: metadata_from_input(args.files.as_deref(), args.outcome.as_deref(), args.verify.as_deref()),
910        });
911
912        checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
913        let (summary, view) = self.persist_and_build_view(&checklist).await?;
914
915        Ok(json!({
916            "status": "added",
917            "message": format!("Added item {}: {}", new_index, description),
918            "checklist": summary,
919            "view": view
920        }))
921    }
922}
923
924#[async_trait]
925impl Tool for TaskTrackerTool {
926    async fn execute(&self, args: Value) -> Result<Value> {
927        let args: TaskTrackerArgs = deserialize_tool_args(&args, "task_tracker")?;
928        validate_action_index_fields(&args.action, args.index, args.index_path.as_deref())?;
929
930        if self.planning_workflow_state.is_active() {
931            return self.execute_in_planning_workflow(&args).await;
932        }
933
934        match args.action.as_str() {
935            "create" => self.handle_create(&args).await,
936            "update" => self.handle_update(&args).await,
937            "list" => self.handle_list().await,
938            "add" => self.handle_add(&args).await,
939            other => Ok(json!({
940                "status": "error",
941                "message": format!("Unknown action '{}'. Use: create, update, list, add", other)
942            })),
943        }
944    }
945
946    fn name(&self) -> &str {
947        tools::TASK_TRACKER
948    }
949
950    fn description(&self) -> &str {
951        task_tracker_description_for_workflow(self.planning_workflow_state.is_active())
952    }
953
954    fn parameter_schema(&self) -> Option<Value> {
955        Some(task_tracker_parameter_schema_for_workflow(self.planning_workflow_state.is_active()))
956    }
957
958    fn is_mutating(&self) -> bool {
959        false // Writes tracker artifacts only (.vtcode/tasks and .vtcode/plans)
960    }
961
962    fn is_parallel_safe(&self) -> bool {
963        false // State management should be sequential
964    }
965}
966
967#[cfg(test)]
968mod update_regressions;
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use tempfile::TempDir;
974
975    fn setup_tool(temp: &TempDir) -> (PlanningWorkflowState, TaskTrackerTool) {
976        let state = PlanningWorkflowState::new(temp.path().to_path_buf());
977        let tool = TaskTrackerTool::new(temp.path().to_path_buf(), state.clone());
978        (state, tool)
979    }
980
981    #[tokio::test]
982    async fn test_create_checklist() {
983        let temp = TempDir::new().unwrap();
984        let (_state, tool) = setup_tool(&temp);
985
986        let result = tool
987            .execute(json!({
988                "action": "create",
989                "title": "Refactor Auth",
990                "items": ["Extract middleware", "Add tests", "Update docs"]
991            }))
992            .await
993            .unwrap();
994
995        assert_eq!(result["status"], "created");
996        assert_eq!(result["checklist"]["total"], 3);
997        assert_eq!(result["checklist"]["completed"], 0);
998        assert_eq!(result["view"]["title"], "Refactor Auth");
999        assert_eq!(result["task_file"], ".vtcode/tasks/current_task.md");
1000    }
1001
1002    #[tokio::test]
1003    async fn test_create_accepts_metadata_and_verify_string_forms() {
1004        let temp = TempDir::new().unwrap();
1005        let (_state, tool) = setup_tool(&temp);
1006
1007        let result = tool
1008            .execute(json!({
1009                "action": "create",
1010                "title": "Harness tracker",
1011                "items": [
1012                    {
1013                        "description": "Analyze current harness",
1014                        "files": ["docs/ARCHITECTURE.md"],
1015                        "outcome": "Document the harness map",
1016                        "verify": "cargo check"
1017                    },
1018                    {
1019                        "description": "Wire continuation",
1020                        "verify": ["cargo test -p vtcode-core continuation", "cargo check -p vtcode"]
1021                    }
1022                ]
1023            }))
1024            .await
1025            .unwrap();
1026
1027        assert_eq!(result["checklist"]["items"][0]["files"], json!(["docs/ARCHITECTURE.md"]));
1028        assert_eq!(result["checklist"]["items"][0]["outcome"], "Document the harness map");
1029        assert_eq!(result["checklist"]["items"][0]["verify"], json!(["cargo check"]));
1030        assert_eq!(
1031            result["checklist"]["items"][1]["verify"],
1032            json!(["cargo test -p vtcode-core continuation", "cargo check -p vtcode"])
1033        );
1034
1035        let persisted = std::fs::read_to_string(temp.path().join(".vtcode/tasks/current_task.md")).unwrap();
1036        assert!(persisted.contains("files: docs/ARCHITECTURE.md"));
1037        assert!(persisted.contains("outcome: Document the harness map"));
1038        assert!(persisted.contains("verify: cargo check"));
1039    }
1040
1041    #[tokio::test]
1042    async fn test_update_item() {
1043        let temp = TempDir::new().unwrap();
1044        let (_state, tool) = setup_tool(&temp);
1045
1046        tool.execute(json!({
1047            "action": "create",
1048            "title": "Test",
1049            "items": ["Step 1", "Step 2"]
1050        }))
1051        .await
1052        .unwrap();
1053
1054        let result = tool
1055            .execute(json!({
1056                "action": "update",
1057                "index": 1,
1058                "status": "completed"
1059            }))
1060            .await
1061            .unwrap();
1062
1063        assert_eq!(result["status"], "updated");
1064        assert_eq!(result["checklist"]["completed"], 1);
1065        assert_eq!(result["checklist"]["progress_percent"], 50);
1066    }
1067
1068    #[tokio::test]
1069    async fn test_update_index_zero_allows_checklist_completion_note() {
1070        let temp = TempDir::new().unwrap();
1071        let (_state, tool) = setup_tool(&temp);
1072
1073        tool.execute(json!({
1074            "action": "create",
1075            "title": "Test",
1076            "items": ["Step 1", "Step 2"]
1077        }))
1078        .await
1079        .unwrap();
1080
1081        let result = tool
1082            .execute(json!({
1083                "action": "update",
1084                "index": 0,
1085                "status": "completed",
1086                "outcome": "Reported summary to user"
1087            }))
1088            .await
1089            .unwrap();
1090
1091        assert_eq!(result["status"], "updated");
1092        assert_eq!(result["checklist"]["completed"], 0);
1093        assert_eq!(result["checklist"]["notes"], "Checklist outcome: Reported summary to user");
1094    }
1095
1096    #[test]
1097    fn task_tracker_schema_describes_action_aware_indices() {
1098        let (_state, tool) = setup_tool(&TempDir::new().unwrap());
1099        let schema = tool.parameter_schema().expect("task tracker schema");
1100
1101        assert_eq!(schema["properties"]["index"]["minimum"], 0);
1102        let items_description = schema["properties"]["items"]["description"].as_str().unwrap();
1103        assert!(items_description.contains("Full checklist replacement"));
1104        assert!(items_description.contains("never JSON-encoded strings"));
1105        assert_eq!(schema["properties"]["index_path"]["pattern"], "^[1-9][0-9]*$");
1106        let description = schema["properties"]["index"]["description"]
1107            .as_str()
1108            .expect("index description");
1109        assert!(description.contains("index: 0"));
1110        assert!(description.contains("action: update"));
1111        assert!(description.contains("completed"));
1112        assert_eq!(schema["allOf"][2]["then"]["properties"]["status"]["const"], "completed");
1113    }
1114
1115    #[test]
1116    fn standard_task_tracker_schema_rejects_zero_except_legacy_completion() {
1117        let schema = standard_task_tracker_parameter_schema();
1118        let valid_cases = [
1119            json!({"action": "update", "index": 0, "status": "completed"}),
1120            json!({"action": "update", "index": 1, "status": "completed"}),
1121            json!({"action": "update", "items": ["[x] Done"]}),
1122        ];
1123        let invalid_cases = [
1124            json!({"action": "list", "index": 0}),
1125            json!({"action": "list", "index": 1}),
1126            json!({"action": "list", "index_path": "1"}),
1127            json!({"action": "add", "index": 0, "description": "New item"}),
1128            json!({"action": "add", "index": 1, "description": "New item"}),
1129            json!({"action": "create", "index": 1, "items": ["New item"]}),
1130            json!({"action": "update", "index": 0, "status": "pending"}),
1131        ];
1132
1133        for args in valid_cases {
1134            assert!(jsonschema::validate(&schema, &args).is_ok(), "expected valid args: {args}");
1135        }
1136        for args in invalid_cases {
1137            assert!(jsonschema::validate(&schema, &args).is_err(), "expected invalid args: {args}");
1138        }
1139    }
1140
1141    #[test]
1142    fn standard_task_tracker_schema_rejects_mixed_bulk_and_single_updates() {
1143        let schema = standard_task_tracker_parameter_schema();
1144        let invalid_cases = [
1145            json!({"action": "update", "items": ["Done"], "index": 1, "status": "completed"}),
1146            json!({"action": "update", "items": ["Done"], "index_path": "1", "status": "completed"}),
1147            json!({"action": "update", "index": 1, "index_path": "1", "status": "completed"}),
1148            json!({"action": "update", "items": ["Done"], "status": "completed"}),
1149            json!({"action": "update", "items": ["Done"], "index": 0, "status": "completed"}),
1150        ];
1151
1152        for args in invalid_cases {
1153            assert!(jsonschema::validate(&schema, &args).is_err(), "expected invalid args: {args}");
1154        }
1155    }
1156
1157    #[test]
1158    fn tracker_schemas_share_update_shapes_but_diverge_on_index_zero() {
1159        let standard = standard_task_tracker_parameter_schema();
1160        let planning = crate::tools::handlers::planning_task_tracker::planning_task_tracker_parameter_schema();
1161
1162        // Shared shapes: both address modes validate on both schemas,
1163        // proving the normalized anyOf arm order is behavior-preserving.
1164        for args in [
1165            json!({"action": "update", "index": 1, "status": "completed"}),
1166            json!({"action": "update", "index_path": "1", "status": "completed"}),
1167            json!({"action": "update", "items": ["[x] Done"]}),
1168            json!({"action": "create", "title": "T", "items": ["New item"]}),
1169            json!({"action": "add", "description": "New item"}),
1170            json!({"action": "list"}),
1171        ] {
1172            assert!(jsonschema::validate(&standard, &args).is_ok(), "standard rejects shared shape: {args}");
1173            assert!(jsonschema::validate(&planning, &args).is_ok(), "planning rejects shared shape: {args}");
1174        }
1175
1176        // Shared rejections on both schemas.
1177        for args in [
1178            json!({"action": "list", "index": 1}),
1179            json!({"action": "create", "index": 1, "items": ["New item"]}),
1180            json!({"action": "update", "index": 1, "index_path": "1", "status": "completed"}),
1181        ] {
1182            assert!(jsonschema::validate(&standard, &args).is_err(), "standard accepts rejected shape: {args}");
1183            assert!(jsonschema::validate(&planning, &args).is_err(), "planning accepts rejected shape: {args}");
1184        }
1185
1186        // Divergent boundary: index 0 completes a standard checklist but is
1187        // invalid in planning (indices start at 1).
1188        let zero_completion = json!({"action": "update", "index": 0, "status": "completed"});
1189        assert!(jsonschema::validate(&standard, &zero_completion).is_ok());
1190        assert!(jsonschema::validate(&planning, &zero_completion).is_err());
1191    }
1192
1193    #[tokio::test]
1194    async fn bulk_update_rejects_encoded_commands_without_resetting_completed_steps() {
1195        let temp = TempDir::new().unwrap();
1196        let (_state, tool) = setup_tool(&temp);
1197        tool.execute(json!({
1198            "action": "create",
1199            "title": "Preserve progress",
1200            "items": [
1201                {"description": "Update introduction", "status": "completed", "files": ["README.md"], "outcome": "Clear overview", "verify": ["review headings"]},
1202                {"description": "Review links", "status": "blocked", "outcome": "Await network"}
1203            ]
1204        })).await.unwrap();
1205        let tracker_path = temp.path().join(".vtcode/tasks/current_task.md");
1206        let persisted_before = std::fs::read_to_string(&tracker_path).unwrap();
1207        let before = tool.execute(json!({"action": "list"})).await.unwrap();
1208
1209        for (action, encoded) in [
1210            ("update", r#"{"index_path":"1","status":"completed","outcome":"Done","verify":"review headings"}"#),
1211            ("update", r#"  [x] {"index_path":"1","status":"completed"}"#),
1212            ("create", r#"{"index":1,"status":"completed"}"#),
1213        ] {
1214            let error = tool
1215                .execute(json!({
1216                    "action": action,
1217                    "title": "Incorrect replacement",
1218                    "items": ["Replacement must not leak", encoded]
1219                }))
1220                .await
1221                .expect_err("encoded updates must not replace the tracker");
1222            assert!(error.to_string().contains("JSON-encoded task updates"));
1223            assert!(
1224                error
1225                    .to_string()
1226                    .contains(r#"{"action":"update","index_path":"1","status":"completed"}"#)
1227            );
1228            assert_eq!(tool.execute(json!({"action": "list"})).await.unwrap(), before);
1229            assert_eq!(std::fs::read_to_string(&tracker_path).unwrap(), persisted_before);
1230        }
1231
1232        tool.execute(json!({"action": "update", "index_path": "2", "status": "completed"}))
1233            .await
1234            .unwrap();
1235        let after = tool.execute(json!({"action": "list"})).await.unwrap();
1236        assert_eq!(after["checklist"]["completed"], 2);
1237        assert_eq!(after["checklist"]["items"][0]["description"], "Update introduction");
1238        assert_eq!(after["checklist"]["items"][0]["files"], json!(["README.md"]));
1239    }
1240
1241    #[tokio::test]
1242    async fn update_rejects_mixed_bulk_and_single_fields_before_mutation() {
1243        let temp = TempDir::new().unwrap();
1244        let (_state, tool) = setup_tool(&temp);
1245
1246        tool.execute(json!({
1247            "action": "create",
1248            "title": "Test",
1249            "items": ["Original"]
1250        }))
1251        .await
1252        .unwrap();
1253
1254        let error = tool
1255            .execute(json!({
1256                "action": "update",
1257                "items": ["Replacement"],
1258                "index": 1,
1259                "status": "completed"
1260            }))
1261            .await
1262            .expect_err("mixed update must fail closed");
1263        assert!(error.to_string().contains("cannot combine 'items'"));
1264
1265        let result = tool.execute(json!({"action": "list"})).await.unwrap();
1266        assert_eq!(result["checklist"]["items"][0]["description"], "Original");
1267        assert_eq!(result["checklist"]["items"][0]["status"], "pending");
1268    }
1269
1270    #[tokio::test]
1271    async fn update_rejects_both_index_forms_before_mutation() {
1272        let temp = TempDir::new().unwrap();
1273        let (_state, tool) = setup_tool(&temp);
1274
1275        tool.execute(json!({
1276            "action": "create",
1277            "title": "Test",
1278            "items": ["Original"]
1279        }))
1280        .await
1281        .unwrap();
1282
1283        let error = tool
1284            .execute(json!({
1285                "action": "update",
1286                "index": 1,
1287                "index_path": "1",
1288                "status": "completed"
1289            }))
1290            .await
1291            .expect_err("ambiguous index forms must fail closed");
1292        assert!(error.to_string().contains("cannot combine 'index' and 'index_path'"));
1293
1294        let result = tool.execute(json!({"action": "list"})).await.unwrap();
1295        assert_eq!(result["checklist"]["items"][0]["description"], "Original");
1296        assert_eq!(result["checklist"]["items"][0]["status"], "pending");
1297    }
1298
1299    #[tokio::test]
1300    async fn execute_rejects_indices_for_non_update_actions() {
1301        let temp = TempDir::new().unwrap();
1302        let (_state, tool) = setup_tool(&temp);
1303
1304        let invalid_inputs = [
1305            json!({"action": "list", "index": 1}),
1306            json!({"action": "create", "index_path": "1", "items": ["New item"]}),
1307            json!({"action": "add", "index": 1, "description": "New item"}),
1308            json!({"action": "unexpected", "index_path": "1"}),
1309        ];
1310
1311        for input in invalid_inputs {
1312            let error = tool.execute(input).await.expect_err("non-update index must fail closed");
1313            assert!(error.to_string().contains("cannot use 'index' or 'index_path'"), "unexpected error: {error}");
1314        }
1315    }
1316
1317    #[test]
1318    fn task_tracker_metadata_switches_with_planning_state() {
1319        let temp = TempDir::new().unwrap();
1320        let (state, tool) = setup_tool(&temp);
1321
1322        assert_eq!(tool.description(), task_tracker_description_for_workflow(false));
1323        assert_eq!(tool.parameter_schema().expect("standard schema")["properties"]["index"]["minimum"], 0);
1324
1325        state.enable();
1326
1327        assert_eq!(tool.description(), task_tracker_description_for_workflow(true));
1328        assert_eq!(tool.parameter_schema().expect("planning schema")["properties"]["index"]["minimum"], 1);
1329    }
1330
1331    #[tokio::test]
1332    async fn test_add_item() {
1333        let temp = TempDir::new().unwrap();
1334        let (_state, tool) = setup_tool(&temp);
1335
1336        tool.execute(json!({
1337            "action": "create",
1338            "title": "Test",
1339            "items": ["Step 1"]
1340        }))
1341        .await
1342        .unwrap();
1343
1344        let result = tool
1345            .execute(json!({
1346                "action": "add",
1347                "description": "Step 2"
1348            }))
1349            .await
1350            .unwrap();
1351
1352        assert_eq!(result["status"], "added");
1353        assert_eq!(result["checklist"]["total"], 2);
1354    }
1355
1356    #[tokio::test]
1357    async fn test_create_is_idempotent_for_same_structure() {
1358        let temp = TempDir::new().unwrap();
1359        let (_state, tool) = setup_tool(&temp);
1360
1361        tool.execute(json!({
1362            "action": "create",
1363            "title": "Clippy Warnings",
1364            "items": ["Fix A", "Fix B"]
1365        }))
1366        .await
1367        .unwrap();
1368
1369        tool.execute(json!({
1370            "action": "update",
1371            "index": 1,
1372            "status": "completed"
1373        }))
1374        .await
1375        .unwrap();
1376
1377        let duplicate = tool
1378            .execute(json!({
1379                "action": "create",
1380                "title": "Clippy Warnings",
1381                "items": ["Fix A", "Fix B"]
1382            }))
1383            .await
1384            .unwrap();
1385
1386        assert_eq!(duplicate["status"], "unchanged");
1387        assert_eq!(duplicate["checklist"]["completed"], 1);
1388    }
1389
1390    #[tokio::test]
1391    async fn test_create_with_new_structure_replaces_checklist() {
1392        let temp = TempDir::new().unwrap();
1393        let (_state, tool) = setup_tool(&temp);
1394
1395        tool.execute(json!({
1396            "action": "create",
1397            "title": "First",
1398            "items": ["Fix A", "Fix B"]
1399        }))
1400        .await
1401        .unwrap();
1402
1403        tool.execute(json!({
1404            "action": "update",
1405            "index": 1,
1406            "status": "completed"
1407        }))
1408        .await
1409        .unwrap();
1410
1411        let replaced = tool
1412            .execute(json!({
1413                "action": "create",
1414                "title": "Second",
1415                "items": ["Fix C"]
1416            }))
1417            .await
1418            .unwrap();
1419
1420        assert_eq!(replaced["status"], "replaced");
1421        assert_eq!(replaced["checklist"]["total"], 1);
1422        assert_eq!(replaced["checklist"]["completed"], 0);
1423    }
1424
1425    #[test]
1426    fn task_tracker_descriptions_state_that_repeat_create_replaces() {
1427        for planning_active in [false, true] {
1428            let description = task_tracker_description_for_workflow(planning_active);
1429            assert!(
1430                description.contains("Calling action=create again replaces"),
1431                "planning_active={planning_active}: {description}"
1432            );
1433            assert!(!description.contains("subsequent calls update"), "planning_active={planning_active}");
1434        }
1435        assert!(task_tracker_description_for_workflow(false).contains("no-op"));
1436    }
1437
1438    #[tokio::test]
1439    async fn test_update_supports_bulk_item_sync() {
1440        let temp = TempDir::new().unwrap();
1441        let (_state, tool) = setup_tool(&temp);
1442
1443        tool.execute(json!({
1444            "action": "create",
1445            "title": "Sync Test",
1446            "items": ["Step 1", "Step 2", "Step 3"]
1447        }))
1448        .await
1449        .unwrap();
1450
1451        let updated = tool
1452            .execute(json!({
1453                "action": "update",
1454                "items": ["[x] Step 1", "[~] Step 2", "[ ] Step 3"]
1455            }))
1456            .await
1457            .unwrap();
1458
1459        assert_eq!(updated["status"], "updated");
1460        assert_eq!(updated["checklist"]["completed"], 1);
1461        assert_eq!(updated["checklist"]["in_progress"], 1);
1462        assert_eq!(updated["checklist"]["pending"], 1);
1463    }
1464
1465    #[tokio::test]
1466    async fn test_list_empty() {
1467        let temp = TempDir::new().unwrap();
1468        let (_state, tool) = setup_tool(&temp);
1469
1470        let result = tool.execute(json!({"action": "list"})).await.unwrap();
1471        assert_eq!(result["status"], "empty");
1472    }
1473
1474    #[tokio::test]
1475    async fn test_persistence_across_loads() {
1476        let temp = TempDir::new().unwrap();
1477
1478        {
1479            let (_state, tool) = setup_tool(&temp);
1480            tool.execute(json!({
1481                "action": "create",
1482                "title": "Persist Test",
1483                "items": ["Alpha", "Beta"]
1484            }))
1485            .await
1486            .unwrap();
1487
1488            tool.execute(json!({
1489                "action": "update",
1490                "index": 1,
1491                "status": "completed"
1492            }))
1493            .await
1494            .unwrap();
1495        }
1496
1497        let (_state, tool2) = setup_tool(&temp);
1498        let result = tool2.execute(json!({"action": "list"})).await.unwrap();
1499
1500        assert_eq!(result["status"], "ok");
1501        assert_eq!(result["checklist"]["total"], 2);
1502        assert_eq!(result["checklist"]["completed"], 1);
1503    }
1504
1505    #[tokio::test]
1506    async fn test_planning_workflow_task_tracker_delegates_and_mirrors_global() {
1507        let temp = TempDir::new().unwrap();
1508        let (state, tool) = setup_tool(&temp);
1509
1510        let plans_dir = state.plans_dir();
1511        std::fs::create_dir_all(&plans_dir).unwrap();
1512        let plan_file = plans_dir.join("adaptive.md");
1513        std::fs::write(&plan_file, "# Adaptive\n").unwrap();
1514        state.set_plan_file(Some(plan_file)).await;
1515        state.enable();
1516
1517        let created = tool
1518            .execute(json!({
1519                "action": "create",
1520                "title": "Adaptive Plan",
1521                "items": ["Root task", "  Child task"]
1522            }))
1523            .await
1524            .unwrap();
1525
1526        assert_eq!(created["status"], "created");
1527        assert_eq!(created["checklist"]["total"], 2);
1528
1529        let task_file = temp.path().join(".vtcode/tasks/current_task.md");
1530        let persisted = std::fs::read_to_string(task_file).unwrap();
1531        assert!(persisted.contains("Root task"));
1532        assert!(persisted.contains("Child task"));
1533    }
1534
1535    #[tokio::test]
1536    async fn test_planning_workflow_adaptive_tool_accepts_hierarchical_index_path() {
1537        let temp = TempDir::new().unwrap();
1538        let (state, tool) = setup_tool(&temp);
1539
1540        let plans_dir = state.plans_dir();
1541        std::fs::create_dir_all(&plans_dir).unwrap();
1542        let plan_file = plans_dir.join("hierarchical.md");
1543        std::fs::write(&plan_file, "# Hierarchical\n").unwrap();
1544        state.set_plan_file(Some(plan_file)).await;
1545        state.enable();
1546
1547        tool.execute(json!({
1548            "action": "create",
1549            "items": ["Parent task", "  Child task"]
1550        }))
1551        .await
1552        .unwrap();
1553
1554        let updated = tool
1555            .execute(json!({
1556                "action": "update",
1557                "index_path": "1.1",
1558                "status": "completed"
1559            }))
1560            .await
1561            .unwrap();
1562
1563        assert_eq!(updated["status"], "updated");
1564        assert_eq!(updated["checklist"]["completed"], 1);
1565    }
1566
1567    #[tokio::test]
1568    async fn test_planning_workflow_adaptive_tool_rejects_index_zero() {
1569        let temp = TempDir::new().unwrap();
1570        let (state, tool) = setup_tool(&temp);
1571
1572        let plans_dir = state.plans_dir();
1573        std::fs::create_dir_all(&plans_dir).unwrap();
1574        let plan_file = plans_dir.join("reject-zero.md");
1575        std::fs::write(&plan_file, "# Reject zero\n").unwrap();
1576        state.set_plan_file(Some(plan_file)).await;
1577        state.enable();
1578
1579        tool.execute(json!({
1580            "action": "create",
1581            "items": ["Parent task"]
1582        }))
1583        .await
1584        .unwrap();
1585
1586        let error = tool
1587            .execute(json!({
1588                "action": "update",
1589                "index": 0,
1590                "status": "completed"
1591            }))
1592            .await
1593            .expect_err("planning index zero must be rejected");
1594
1595        assert!(error.to_string().contains("index_path components must be >= 1"));
1596    }
1597
1598    #[tokio::test]
1599    async fn test_planning_workflow_mirror_preserves_notes() {
1600        let temp = TempDir::new().unwrap();
1601        let (state, tool) = setup_tool(&temp);
1602
1603        let plans_dir = state.plans_dir();
1604        std::fs::create_dir_all(&plans_dir).unwrap();
1605        let plan_file = plans_dir.join("notes.md");
1606        std::fs::write(&plan_file, "# Notes\n").unwrap();
1607        state.set_plan_file(Some(plan_file)).await;
1608        state.enable();
1609
1610        tool.execute(json!({
1611            "action": "create",
1612            "items": ["Root task"],
1613            "notes": "Keep this note"
1614        }))
1615        .await
1616        .unwrap();
1617
1618        let task_file = temp.path().join(".vtcode/tasks/current_task.md");
1619        let persisted = std::fs::read_to_string(task_file).unwrap();
1620        assert!(persisted.contains("## Notes"));
1621        assert!(persisted.contains("Keep this note"));
1622    }
1623
1624    #[tokio::test]
1625    async fn test_edit_mode_prefers_newer_plan_mirror_when_present() {
1626        let temp = TempDir::new().unwrap();
1627        let (state, tool) = setup_tool(&temp);
1628
1629        let plans_dir = state.plans_dir();
1630        std::fs::create_dir_all(&plans_dir).unwrap();
1631        let plan_file = plans_dir.join("freshness.md");
1632        std::fs::write(&plan_file, "# Freshness\n").unwrap();
1633        state.set_plan_file(Some(plan_file.clone())).await;
1634
1635        let global_file = temp.path().join(".vtcode/tasks/current_task.md");
1636        std::fs::create_dir_all(global_file.parent().unwrap()).unwrap();
1637        std::fs::write(&global_file, "# Freshness\n\n- [ ] stale global\n").unwrap();
1638
1639        std::thread::sleep(std::time::Duration::from_millis(15));
1640
1641        let sidecar = plans_dir.join("freshness.tasks.md");
1642        std::fs::write(&sidecar, "# Freshness\n\n## Plan of Work\n\n- [x] newer plan\n").unwrap();
1643
1644        let listed = tool.execute(json!({"action": "list"})).await.unwrap();
1645        assert_eq!(listed["status"], "ok");
1646        assert_eq!(listed["checklist"]["completed"], 1);
1647        assert_eq!(listed["checklist"]["pending"], 0);
1648
1649        let global_synced = std::fs::read_to_string(global_file).unwrap();
1650        assert!(global_synced.contains("newer plan"));
1651    }
1652
1653    #[tokio::test]
1654    async fn test_planning_workflow_prefers_plan_sidecar_even_if_global_is_newer() {
1655        let temp = TempDir::new().unwrap();
1656        let (state, tool) = setup_tool(&temp);
1657
1658        let plans_dir = state.plans_dir();
1659        std::fs::create_dir_all(&plans_dir).unwrap();
1660        let plan_file = plans_dir.join("plan-primary.md");
1661        std::fs::write(&plan_file, "# Plan Primary\n").unwrap();
1662        state.set_plan_file(Some(plan_file.clone())).await;
1663        state.enable();
1664
1665        let global_file = temp.path().join(".vtcode/tasks/current_task.md");
1666        std::fs::create_dir_all(global_file.parent().unwrap()).unwrap();
1667        std::fs::write(&global_file, "# Plan Primary\n\n- [x] global newer\n").unwrap();
1668        std::thread::sleep(std::time::Duration::from_millis(15));
1669
1670        let sidecar = plans_dir.join("plan-primary.tasks.md");
1671        std::fs::write(&sidecar, "# Plan Primary\n\n## Plan of Work\n\n- [ ] plan source\n").unwrap();
1672        std::thread::sleep(std::time::Duration::from_millis(15));
1673        std::fs::write(&global_file, "# Plan Primary\n\n- [x] global newest\n").unwrap();
1674
1675        let listed = tool.execute(json!({"action": "list"})).await.unwrap();
1676        assert_eq!(listed["status"], "ok");
1677        assert_eq!(listed["checklist"]["pending"], 1);
1678        assert_eq!(listed["checklist"]["completed"], 0);
1679    }
1680}