1use super::planning_workflow::{PlanningWorkflowState, tracker_file_for_plan_file};
7use crate::config::constants::tools;
8use crate::tools::error_helpers::deserialize_tool_args;
9use crate::tools::handlers::task_tracking::{
10 TASK_ITEMS_DESCRIPTION, TaskCounts, TaskItemInput, TaskMetadataRef, TaskStepMetadata, TaskTrackingStatus,
11 TaskTreeNode, TrackerArtifact, append_notes, append_notes_section, append_task_step_metadata, apply_metadata_input,
12 apply_metadata_line, compact_task_tree_view, is_bulk_sync_update, metadata_from_input, parse_marked_status_prefix,
13 parse_status_prefix, parse_task_items, persist_tracker_artifacts, task_files_property_schema,
14 task_items_array_schema, task_status_property_schema, task_verify_property_schema, tracker_schema_allof,
15 validate_action_index_fields, validate_update_shape,
16};
17use crate::tools::traits::Tool;
18use crate::utils::file_utils::read_file_with_context;
19use anyhow::{Context, Result, bail};
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22use serde_json::{Value, json};
23use std::fmt::Write as _;
24use std::path::{Path, PathBuf};
25use std::str::FromStr;
26use vtcode_commons::workspace_relative_display;
27
28type PlanTaskStatus = TaskTrackingStatus;
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31struct PlanTaskNode {
32 description: String,
33 status: PlanTaskStatus,
34 #[serde(default, flatten)]
35 metadata: TaskStepMetadata,
36 children: Vec<PlanTaskNode>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40struct PlanTaskDocument {
41 title: String,
42 items: Vec<PlanTaskNode>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 notes: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PlanningTaskTrackerArgs {
49 pub action: String,
51
52 #[serde(default)]
54 pub title: Option<String>,
55
56 #[serde(default)]
58 pub items: Option<Vec<TaskItemInput>>,
59
60 #[serde(default)]
62 pub index_path: Option<String>,
63
64 #[serde(default)]
66 pub index: Option<usize>,
67
68 #[serde(default)]
70 pub status: Option<String>,
71
72 #[serde(default)]
74 pub description: Option<String>,
75
76 #[serde(default)]
78 pub files: Option<Vec<String>>,
79
80 #[serde(default)]
82 pub outcome: Option<String>,
83
84 #[serde(
86 default,
87 deserialize_with = "crate::tools::handlers::task_tracking::deserialize_optional_string_list"
88 )]
89 pub verify: Option<Vec<String>>,
90
91 #[serde(default)]
93 pub parent_index_path: Option<String>,
94
95 #[serde(default)]
97 pub notes: Option<String>,
98}
99
100#[derive(Debug, Clone)]
101struct FlatTaskLine {
102 level: usize,
103 status: PlanTaskStatus,
104 description: String,
105 metadata: TaskStepMetadata,
106}
107
108impl PlanTaskDocument {
109 fn to_markdown(&self) -> String {
110 let mut out = format!("# {}\n\n## Plan of Work\n\n", self.title);
111 write_markdown_nodes(&self.items, 0, &mut out);
112 append_notes_section(&mut out, self.notes.as_deref());
113 out
114 }
115
116 fn summary_json(&self) -> Value {
117 let mut counts = TaskCounts::default();
118 count_nodes(&self.items, &mut counts);
119
120 json!({
121 "title": self.title,
122 "total": counts.total,
123 "completed": counts.completed,
124 "in_progress": counts.in_progress,
125 "pending": counts.pending,
126 "blocked": counts.blocked,
127 "progress_percent": counts.progress_percent(),
128 "items": flatten_items_json(&self.items),
129 "notes": self.notes.clone(),
130 })
131 }
132
133 fn view_json(&self) -> Value {
134 json!({
135 "title": self.title,
136 "lines": compact_task_tree_view(&compact_view_nodes(&self.items, "")),
137 })
138 }
139}
140
141fn count_nodes(nodes: &[PlanTaskNode], counts: &mut TaskCounts) {
142 for node in nodes {
143 counts.add(&node.status);
144 count_nodes(&node.children, counts);
145 }
146}
147
148fn write_markdown_nodes(nodes: &[PlanTaskNode], level: usize, out: &mut String) {
149 let indent = " ".repeat(level);
150 for node in nodes {
151 out.push_str(&indent);
152 out.push_str("- ");
153 out.push_str(node.status.plan_checkbox());
154 out.push(' ');
155 out.push_str(&node.description);
156 out.push('\n');
157 append_task_step_metadata(out, &indent, &node.metadata);
158 write_markdown_nodes(&node.children, level + 1, out);
159 }
160}
161
162fn flatten_items_json(nodes: &[PlanTaskNode]) -> Vec<Value> {
163 let mut items = Vec::new();
164 flatten_items_json_inner(nodes, "", 0, &mut items);
165 items
166}
167
168fn flatten_for_global_items(nodes: &[PlanTaskNode], level: usize, out: &mut Vec<FlatTaskLine>) {
169 for node in nodes {
170 out.push(FlatTaskLine {
171 level,
172 status: node.status,
173 description: node.description.clone(),
174 metadata: node.metadata.clone(),
175 });
176 flatten_for_global_items(&node.children, level + 1, out);
177 }
178}
179
180fn flatten_items_json_inner(nodes: &[PlanTaskNode], index_prefix: &str, level: usize, out: &mut Vec<Value>) {
181 for (idx, node) in nodes.iter().enumerate() {
182 let index_path = if index_prefix.is_empty() {
183 format!("{}", idx + 1)
184 } else {
185 format!("{index_prefix}.{}", idx + 1)
186 };
187 out.push(json!({
188 "index_path": index_path,
189 "description": node.description,
190 "status": node.status.as_str(),
191 "level": level,
192 "files": node.metadata.files.clone(),
193 "outcome": node.metadata.outcome.clone(),
194 "verify": node.metadata.verify.clone(),
195 }));
196 flatten_items_json_inner(&node.children, &index_path, level + 1, out);
197 }
198}
199
200fn compact_view_nodes(nodes: &[PlanTaskNode], index_prefix: &str) -> Vec<TaskTreeNode> {
201 nodes
202 .iter()
203 .enumerate()
204 .map(|(index, node)| {
205 let index_path = if index_prefix.is_empty() {
206 format!("{}", index + 1)
207 } else {
208 format!("{index_prefix}.{}", index + 1)
209 };
210 TaskTreeNode {
211 index_path: index_path.clone(),
212 description: node.description.clone(),
213 status: node.status,
214 metadata: node.metadata.clone(),
215 children: compact_view_nodes(&node.children, &index_path),
216 }
217 })
218 .collect()
219}
220
221fn parse_task_line(line: &str) -> Option<FlatTaskLine> {
222 let indent_spaces = line.chars().take_while(|c| *c == ' ').count();
223 let level = indent_spaces / 2;
224 let trimmed = line.trim_start();
225 let rest = trimmed
226 .strip_prefix("- ")
227 .or_else(|| trimmed.strip_prefix("* "))
228 .or_else(|| trimmed.strip_prefix("+ "))?;
229
230 let (status, description) = parse_marked_status_prefix(rest)?;
231 if description.trim().is_empty() {
232 return None;
233 }
234 Some(FlatTaskLine {
235 level,
236 status,
237 description: description.trim().to_string(),
238 metadata: TaskStepMetadata::default(),
239 })
240}
241
242fn build_tree_from_flat(lines: &[FlatTaskLine]) -> Vec<PlanTaskNode> {
243 let mut roots = Vec::<PlanTaskNode>::new();
244 let mut current_path = Vec::<usize>::new();
245 let mut previous_level = 0usize;
246
247 for line in lines {
248 let mut level = line.level;
249 if level > previous_level + 1 {
250 level = previous_level + 1;
251 }
252 while current_path.len() > level {
253 current_path.pop();
254 }
255 if level > current_path.len() {
256 level = current_path.len();
257 }
258
259 let node = PlanTaskNode {
260 description: line.description.clone(),
261 status: line.status,
262 metadata: line.metadata.clone(),
263 children: Vec::new(),
264 };
265
266 if level == 0 || current_path.is_empty() {
267 roots.push(node);
268 current_path.clear();
269 current_path.push(roots.len() - 1);
270 previous_level = 0;
271 continue;
272 }
273
274 if let Some(parent) = get_node_mut_by_indices(&mut roots, ¤t_path) {
275 parent.children.push(node);
276 let child_idx = parent.children.len() - 1;
277 current_path.push(child_idx);
278 } else {
279 roots.push(node);
280 current_path.clear();
281 current_path.push(roots.len() - 1);
282 }
283
284 previous_level = level;
285 }
286
287 roots
288}
289
290fn get_node_mut_by_indices<'a>(nodes: &'a mut [PlanTaskNode], path: &[usize]) -> Option<&'a mut PlanTaskNode> {
291 let (&head, tail) = path.split_first()?;
292 let node = nodes.get_mut(head)?;
293 if tail.is_empty() {
294 Some(node)
295 } else {
296 get_node_mut_by_indices(node.children.as_mut_slice(), tail)
297 }
298}
299
300fn get_node_mut_by_index_path<'a>(nodes: &'a mut [PlanTaskNode], path: &[usize]) -> Option<&'a mut PlanTaskNode> {
301 let (&head, tail) = path.split_first()?;
302 let idx = head.checked_sub(1)?;
303 let node = nodes.get_mut(idx)?;
304 if tail.is_empty() {
305 Some(node)
306 } else {
307 get_node_mut_by_index_path(node.children.as_mut_slice(), tail)
308 }
309}
310
311fn parse_index_path(value: &str) -> Result<Vec<usize>> {
312 let trimmed = value.trim();
313 if trimmed.is_empty() {
314 bail!("index_path cannot be empty");
315 }
316
317 trimmed
318 .split('.')
319 .map(|token| {
320 let parsed = token
321 .parse::<usize>()
322 .with_context(|| format!("Invalid index component '{token}'"))?;
323 if parsed == 0 {
324 bail!("index_path components must be >= 1");
325 }
326 Ok(parsed)
327 })
328 .collect()
329}
330
331fn parse_document_from_markdown(content: &str) -> Option<PlanTaskDocument> {
332 let mut title = String::new();
333 let mut in_plan_section = false;
334 let mut in_notes = false;
335 let mut notes_lines = Vec::new();
336 let mut task_lines = Vec::<FlatTaskLine>::new();
337 let mut in_verify_block = false;
338
339 for raw in content.lines() {
340 let trimmed = raw.trim();
341
342 if title.is_empty()
343 && let Some(rest) = trimmed.strip_prefix("# ")
344 {
345 title = rest.trim().to_string();
346 continue;
347 }
348
349 if let Some(header) = trimmed.strip_prefix("## ") {
350 let lowered = header.trim().to_ascii_lowercase();
351 in_plan_section = matches!(lowered.as_str(), "plan of work" | "concrete steps" | "updated plan")
352 || lowered.starts_with("phase ");
353 in_notes = lowered == "notes";
354 continue;
355 }
356
357 if in_notes {
358 notes_lines.push(raw.to_string());
359 continue;
360 }
361
362 if in_plan_section {
363 if let Some(line) = parse_task_line(raw) {
364 task_lines.push(line);
365 in_verify_block = false;
366 continue;
367 }
368
369 if let Some(last) = task_lines.last_mut() {
370 let leading_spaces = raw.chars().take_while(|c| *c == ' ').count();
371 let min_indent = (last.level + 1) * 2;
372 if leading_spaces >= min_indent && apply_metadata_line(&mut last.metadata, raw, &mut in_verify_block) {
373 continue;
374 }
375 }
376 in_verify_block = false;
377 }
378 }
379
380 if title.is_empty() && task_lines.is_empty() {
381 return None;
382 }
383
384 let notes = if notes_lines.is_empty() {
385 None
386 } else {
387 Some(notes_lines.join("\n").trim().to_string())
388 };
389 let items = build_tree_from_flat(&task_lines);
390
391 Some(PlanTaskDocument { title, items, notes })
392}
393
394fn collect_task_metadata<'a>(nodes: &'a [PlanTaskNode], metadata: &mut Vec<TaskMetadataRef<'a>>) {
395 for node in nodes {
396 metadata.push(TaskMetadataRef {
397 description: &node.description,
398 metadata: &node.metadata,
399 });
400 collect_task_metadata(&node.children, metadata);
401 }
402}
403
404fn build_flat_task_lines(items: &[TaskItemInput], previous: &[PlanTaskNode]) -> Result<Vec<FlatTaskLine>> {
405 let mut previous_metadata = Vec::new();
406 collect_task_metadata(previous, &mut previous_metadata);
407 Ok(parse_task_items(items, &previous_metadata)?
408 .into_iter()
409 .map(|item| FlatTaskLine {
410 level: item.level,
411 status: item.status,
412 description: item.description,
413 metadata: item.metadata,
414 })
415 .collect())
416}
417
418pub struct PlanningTaskTrackerTool {
419 state: PlanningWorkflowState,
420}
421
422pub(crate) fn planning_task_tracker_parameter_schema() -> Value {
423 json!({
424 "type": "object",
425 "properties": {
426 "action": {
427 "type": "string",
428 "enum": ["create", "update", "list", "add"],
429 "description": "Action to perform on the plan-scoped tracker."
430 },
431 "title": {
432 "type": "string",
433 "description": "Title for tracker document (used with create)."
434 },
435 "items": task_items_array_schema(&format!("{TASK_ITEMS_DESCRIPTION} Leading 2-space indentation in description indicates nesting.")),
436 "index_path": {
437 "type": "string",
438 "pattern": "^[1-9][0-9]*(\\.[1-9][0-9]*)*$",
439 "description": "Action=update only: positive flat or hierarchical item path (examples: '2' or '2.1'). Zero is not a valid planning index."
440 },
441 "index": {
442 "type": "integer",
443 "minimum": 1,
444 "description": "Action=update only: positive top-level item index compatibility fallback. Planning index 0 is invalid; use items for bulk updates."
445 },
446 "status": task_status_property_schema("New status for update."),
447 "description": {
448 "type": "string",
449 "description": "Task description for add. Optional prefix like '[x] ' or '[~] ' is supported."
450 },
451 "files": task_files_property_schema("Optional file paths associated with a single add/update item."),
452 "outcome": {
453 "type": "string",
454 "description": "Optional expected outcome associated with a single add/update item."
455 },
456 "verify": task_verify_property_schema("Optional verification command or commands associated with a single add/update item."),
457 "parent_index_path": {
458 "type": "string",
459 "description": "Optional parent path for add (example: '2'). If omitted, adds top-level task."
460 },
461 "notes": {
462 "type": "string",
463 "description": "Optional notes to append."
464 }
465 },
466 "required": ["action"],
467 "allOf": tracker_schema_allof(false)
468 })
469}
470
471impl PlanningTaskTrackerTool {
472 pub fn new(state: PlanningWorkflowState) -> Self {
473 Self { state }
474 }
475
476 fn display_path(&self, path: &Path) -> String {
477 self.state
478 .workspace_root()
479 .map(|workspace| workspace_relative_display(&workspace, path))
480 .unwrap_or_else(|| path.to_string_lossy().into_owned())
481 }
482
483 fn tracker_file_for_plan(plan_file: &Path) -> Result<PathBuf> {
484 tracker_file_for_plan_file(plan_file).context("Active plan file is missing a valid file stem")
485 }
486
487 async fn active_plan_file(&self) -> Result<PathBuf> {
488 if !self.state.is_active() {
489 bail!("task_tracker planning storage is only available while planning");
490 }
491 self.state
492 .get_plan_file()
493 .await
494 .context("No active plan file. Call start_planning first.")
495 }
496
497 async fn tracker_file(&self) -> Result<PathBuf> {
498 let plan_file = self.active_plan_file().await?;
499 Self::tracker_file_for_plan(&plan_file)
500 }
501
502 async fn load_document(&self) -> Result<Option<PlanTaskDocument>> {
503 let tracker_file = self.tracker_file().await?;
504 if !tokio::fs::try_exists(&tracker_file).await.unwrap_or(false) {
505 return Ok(None);
506 }
507 let content = read_file_with_context(&tracker_file, "plan task tracker file").await?;
508 Ok(parse_document_from_markdown(&content))
509 }
510
511 fn global_task_file(&self) -> Option<PathBuf> {
512 self.state
513 .workspace_root()
514 .map(|workspace| workspace.join(".vtcode").join("tasks").join("current_task.md"))
515 }
516
517 fn global_task_markdown(document: &PlanTaskDocument) -> String {
518 let mut lines = Vec::new();
519 flatten_for_global_items(&document.items, 0, &mut lines);
520
521 let mut markdown = format!("# {}\n\n", document.title);
522 for line in lines {
523 let _ =
524 writeln!(markdown, "- {} {}{}", line.status.flat_checkbox(), " ".repeat(line.level), line.description);
525 append_task_step_metadata(&mut markdown, "", &line.metadata);
526 }
527 append_notes_section(&mut markdown, document.notes.as_deref());
528
529 markdown
530 }
531
532 fn success_payload(
533 &self,
534 status: &str,
535 message: String,
536 tracker_file: &Path,
537 document: &PlanTaskDocument,
538 ) -> Value {
539 json!({
540 "status": status,
541 "message": message,
542 "tracker_file": self.display_path(tracker_file),
543 "checklist": document.summary_json(),
544 "view": document.view_json(),
545 })
546 }
547
548 async fn persist_document_and_payload(
549 &self,
550 status: &str,
551 message: String,
552 document: &PlanTaskDocument,
553 ) -> Result<Value> {
554 let tracker_file = self.tracker_file().await?;
555 let markdown = document.to_markdown();
556 let mut artifacts =
557 vec![TrackerArtifact::new(tracker_file.clone(), markdown.clone(), "plan task tracker file").await?];
558 if let Some(task_file) = self.global_task_file() {
559 artifacts
560 .push(TrackerArtifact::new(task_file, Self::global_task_markdown(document), "task checklist").await?);
561 }
562 if let Some(plan_file) = self.state.get_plan_file().await
563 && let Some(artifact) = TrackerArtifact::embedded_plan(plan_file, &markdown).await?
564 {
565 artifacts.push(artifact);
566 }
567 persist_tracker_artifacts(artifacts).await?;
568 Ok(self.success_payload(status, message, &tracker_file, document))
569 }
570
571 async fn handle_create(&self, args: &PlanningTaskTrackerArgs) -> Result<Value> {
572 let items = args.items.as_deref().unwrap_or(&[]);
573 if items.is_empty() {
574 bail!("At least one item is required for 'create'. Provide items: [\"step 1\", \"step 2\", ...]");
575 }
576
577 let flat_lines = build_flat_task_lines(items, &[])?;
578 if flat_lines.is_empty() {
579 bail!("No valid task items were provided for create");
580 }
581
582 let mut document = PlanTaskDocument {
583 title: args.title.clone().unwrap_or_else(|| "Updated Plan".to_string()),
584 items: build_tree_from_flat(&flat_lines),
585 notes: None,
586 };
587 document.notes = append_notes(document.notes.take(), args.notes.as_deref());
588
589 self.persist_document_and_payload("created", "Plan task tracker created successfully.".to_string(), &document)
590 .await
591 }
592
593 async fn handle_update(&self, args: &PlanningTaskTrackerArgs) -> Result<Value> {
594 validate_update_shape(args.items.as_deref(), args.index, args.index_path.as_deref(), args.status.as_deref())?;
595 let is_bulk_update =
596 is_bulk_sync_update(args.items.as_deref(), args.index, args.index_path.as_deref(), args.status.as_deref());
597 let mut document = self
598 .load_document()
599 .await?
600 .context("No active plan tracker. Use action='create' first.")?;
601 let previous = document.clone();
602
603 if is_bulk_update {
604 let input_items = args.items.as_deref().unwrap_or(&[]);
605 let flat_lines = build_flat_task_lines(input_items, &document.items)?;
606 if flat_lines.is_empty() {
607 bail!("No valid items provided for checklist sync");
608 }
609 if let Some(title) = args.title.as_deref() {
610 document.title = title.to_string();
611 }
612 document.items = build_tree_from_flat(&flat_lines);
613 document.notes = append_notes(document.notes.take(), args.notes.as_deref());
614
615 return self
616 .persist_update(&previous, &document, "Checklist synchronized from provided items.".to_string())
617 .await;
618 }
619
620 let index_path = args
621 .index_path
622 .clone()
623 .or_else(|| args.index.map(|value| value.to_string()))
624 .context(
625 "'index_path' is required for 'update' (example: \"2.1\"), or provide 'index' for top-level compatibility",
626 )?;
627 let path = parse_index_path(&index_path)?;
628 let status_str = args
629 .status
630 .as_deref()
631 .context("'status' is required for 'update' (pending|in_progress|completed|blocked)")?;
632 let new_status = PlanTaskStatus::from_str(status_str)?;
633
634 let (old_status, new_status_str) = {
635 let node = get_node_mut_by_index_path(document.items.as_mut_slice(), &path)
636 .with_context(|| format!("No item at index_path '{index_path}'"))?;
637 let old_status = node.status.as_str().to_string();
638 node.status = new_status;
639 apply_metadata_input(
640 &mut node.metadata,
641 args.files.as_deref(),
642 args.outcome.as_deref(),
643 args.verify.as_deref(),
644 );
645 (old_status, node.status.as_str().to_string())
646 };
647
648 document.notes = append_notes(document.notes.take(), args.notes.as_deref());
649
650 self.persist_update(
651 &previous,
652 &document,
653 format!("Item {index_path} status changed: {old_status} -> {new_status_str}"),
654 )
655 .await
656 }
657
658 async fn persist_update(
659 &self,
660 previous: &PlanTaskDocument,
661 document: &PlanTaskDocument,
662 message: String,
663 ) -> Result<Value> {
664 let (status, message) = if previous == document {
665 ("unchanged", "Checklist already matches requested state.".to_string())
666 } else {
667 ("updated", message)
668 };
669 self.persist_document_and_payload(status, message, document).await
670 }
671
672 async fn handle_list(&self) -> Result<Value> {
673 let tracker_file = self.tracker_file().await?;
674 match self.load_document().await? {
675 Some(document) => {
676 Ok(self.success_payload("ok", "Plan task tracker loaded.".to_string(), &tracker_file, &document))
677 }
678 None => Ok(json!({
679 "status": "empty",
680 "message": "No active plan tracker. Use action='create' to start one.",
681 "tracker_file": self.display_path(&tracker_file),
682 })),
683 }
684 }
685
686 async fn handle_add(&self, args: &PlanningTaskTrackerArgs) -> Result<Value> {
687 let mut document = self
688 .load_document()
689 .await?
690 .context("No active plan tracker. Use action='create' first.")?;
691
692 let description = args.description.as_deref().context("'description' is required for 'add'")?;
693 let (status, parsed_description) = parse_status_prefix(description);
694 let node = PlanTaskNode {
695 description: parsed_description.trim().to_string(),
696 status,
697 metadata: metadata_from_input(args.files.as_deref(), args.outcome.as_deref(), args.verify.as_deref()),
698 children: Vec::new(),
699 };
700 if node.description.is_empty() {
701 bail!("description cannot be empty");
702 }
703
704 if let Some(parent_path_str) = args.parent_index_path.as_deref() {
705 let parent_path = parse_index_path(parent_path_str)?;
706 let parent = get_node_mut_by_index_path(document.items.as_mut_slice(), &parent_path)
707 .with_context(|| format!("No parent item at parent_index_path '{parent_path_str}'"))?;
708 parent.children.push(node);
709 } else {
710 document.items.push(node);
711 }
712
713 document.notes = append_notes(document.notes.take(), args.notes.as_deref());
714
715 self.persist_document_and_payload("added", "Plan task added successfully.".to_string(), &document)
716 .await
717 }
718}
719
720#[async_trait]
721impl Tool for PlanningTaskTrackerTool {
722 async fn execute(&self, args: Value) -> Result<Value> {
723 let args: PlanningTaskTrackerArgs = deserialize_tool_args(&args, "task_tracker")?;
724 validate_action_index_fields(&args.action, args.index, args.index_path.as_deref())?;
725
726 match args.action.as_str() {
727 "create" => self.handle_create(&args).await,
728 "update" => self.handle_update(&args).await,
729 "list" => self.handle_list().await,
730 "add" => self.handle_add(&args).await,
731 other => Ok(json!({
732 "status": "error",
733 "message": format!("Unknown action '{}'. Use: create, update, list, add", other),
734 })),
735 }
736 }
737
738 fn name(&self) -> &str {
739 tools::TASK_TRACKER
740 }
741
742 fn description(&self) -> &str {
743 super::task_tracker::task_tracker_description_for_workflow(true)
744 }
745
746 fn parameter_schema(&self) -> Option<Value> {
747 Some(planning_task_tracker_parameter_schema())
748 }
749
750 fn is_mutating(&self) -> bool {
751 false
752 }
753
754 fn is_parallel_safe(&self) -> bool {
755 false
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762 use tempfile::TempDir;
763
764 async fn setup_planning_workflow() -> (TempDir, PlanningWorkflowState, PlanningTaskTrackerTool) {
765 let temp_dir = TempDir::new().expect("temp dir");
766 let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
767 let plans_dir = state.plans_dir();
768 std::fs::create_dir_all(&plans_dir).expect("create plans dir");
769 let plan_file = plans_dir.join("test-plan.md");
770 std::fs::write(&plan_file, "# Test Plan\n").expect("write plan");
771 state.set_plan_file(Some(plan_file)).await;
772 state.enable();
773
774 let tool = PlanningTaskTrackerTool::new(state.clone());
775 (temp_dir, state, tool)
776 }
777
778 #[tokio::test]
779 async fn create_and_list_tracker_with_hierarchy() {
780 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
781
782 let created = tool
783 .execute(json!({
784 "action": "create",
785 "title": "Updated Plan",
786 "items": [
787 "Add config cap",
788 " Use cap in guard logic",
789 "[~] Expose setting in template"
790 ]
791 }))
792 .await
793 .expect("create tracker");
794
795 assert_eq!(created["status"], "created");
796 assert_eq!(created["checklist"]["total"], 3);
797 assert_eq!(created["checklist"]["in_progress"], 1);
798 assert_eq!(created["view"]["title"], "Updated Plan");
799 assert_eq!(created["tracker_file"], ".vtcode/plans/test-plan.tasks.md");
800
801 let lines = created["view"]["lines"].as_array().expect("view lines array");
802 assert!(!lines.is_empty());
803 let first = lines[0]["display"].as_str().unwrap_or_default();
804 assert!(first.contains('└') || first.contains('├'));
805 }
806
807 #[tokio::test]
808 async fn create_accepts_metadata_and_verify_string_forms() {
809 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
810
811 let created = tool
812 .execute(json!({
813 "action": "create",
814 "title": "Harness plan",
815 "items": [
816 {
817 "description": "Analyze",
818 "files": ["docs/ARCHITECTURE.md"],
819 "outcome": "Map the harness",
820 "verify": "cargo check"
821 },
822 {
823 "description": "Implement",
824 "verify": ["cargo test -p vtcode-core task_tracker", "cargo check -p vtcode"]
825 }
826 ]
827 }))
828 .await
829 .expect("create tracker");
830
831 assert_eq!(created["checklist"]["items"][0]["files"], json!(["docs/ARCHITECTURE.md"]));
832 assert_eq!(created["checklist"]["items"][0]["outcome"], "Map the harness");
833 assert_eq!(created["checklist"]["items"][0]["verify"], json!(["cargo check"]));
834 assert_eq!(
835 created["checklist"]["items"][1]["verify"],
836 json!(["cargo test -p vtcode-core task_tracker", "cargo check -p vtcode"])
837 );
838 }
839
840 #[tokio::test]
841 async fn add_and_update_nested_item() {
842 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
843
844 tool.execute(json!({
845 "action": "create",
846 "items": ["Parent task"]
847 }))
848 .await
849 .expect("create tracker");
850
851 tool.execute(json!({
852 "action": "add",
853 "parent_index_path": "1",
854 "description": "Child task"
855 }))
856 .await
857 .expect("add nested task");
858
859 let updated = tool
860 .execute(json!({
861 "action": "update",
862 "index_path": "1.1",
863 "status": "completed"
864 }))
865 .await
866 .expect("update nested task");
867
868 assert_eq!(updated["status"], "updated");
869 assert_eq!(updated["checklist"]["completed"], 1);
870 }
871
872 #[tokio::test]
873 async fn persistence_across_instances() {
874 let (_temp_dir, state, tool) = setup_planning_workflow().await;
875
876 tool.execute(json!({
877 "action": "create",
878 "items": ["Persisted step"]
879 }))
880 .await
881 .expect("create tracker");
882
883 tool.execute(json!({
884 "action": "update",
885 "index_path": "1",
886 "status": "completed"
887 }))
888 .await
889 .expect("update tracker");
890
891 let tool2 = PlanningTaskTrackerTool::new(state);
892 let listed = tool2.execute(json!({"action": "list"})).await.expect("list tracker");
893
894 assert_eq!(listed["status"], "ok");
895 assert_eq!(listed["checklist"]["completed"], 1);
896 }
897
898 #[tokio::test]
899 async fn update_supports_bulk_item_sync_and_global_mirror() {
900 let (temp_dir, _state, tool) = setup_planning_workflow().await;
901
902 tool.execute(json!({
903 "action": "create",
904 "items": ["Step 1", "Step 2"]
905 }))
906 .await
907 .expect("create tracker");
908
909 let updated = tool
910 .execute(json!({
911 "action": "update",
912 "items": ["[x] Step 1", "[~] Step 2", "[ ] Step 3"]
913 }))
914 .await
915 .expect("bulk update");
916
917 assert_eq!(updated["status"], "updated");
918 assert_eq!(updated["checklist"]["completed"], 1);
919 assert_eq!(updated["checklist"]["in_progress"], 1);
920 assert_eq!(updated["checklist"]["pending"], 1);
921
922 let mirrored = temp_dir.path().join(".vtcode").join("tasks").join("current_task.md");
923 let mirrored_content = std::fs::read_to_string(mirrored).expect("read mirrored checklist");
924 assert!(mirrored_content.contains("Step 3"));
925 }
926
927 #[tokio::test]
928 async fn update_accepts_flat_index_fallback() {
929 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
930
931 tool.execute(json!({
932 "action": "create",
933 "items": ["Parent task"]
934 }))
935 .await
936 .expect("create tracker");
937
938 let updated = tool
939 .execute(json!({
940 "action": "update",
941 "index": 1,
942 "status": "completed"
943 }))
944 .await
945 .expect("flat-index update");
946
947 assert_eq!(updated["status"], "updated");
948 assert_eq!(updated["checklist"]["completed"], 1);
949 }
950
951 #[tokio::test]
952 async fn update_rejects_zero_flat_index_even_for_checklist_completion() {
953 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
954
955 tool.execute(json!({
956 "action": "create",
957 "items": ["Parent task"]
958 }))
959 .await
960 .expect("create tracker");
961
962 let error = tool
963 .execute(json!({
964 "action": "update",
965 "index": 0,
966 "status": "completed"
967 }))
968 .await
969 .expect_err("planning index zero must be rejected");
970
971 assert!(error.to_string().contains("index_path components must be >= 1"));
972 }
973
974 #[tokio::test]
975 async fn planning_task_tracker_schema_requires_positive_indices() {
976 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
977 let schema = tool.parameter_schema().expect("planning task tracker schema");
978
979 assert_eq!(schema["properties"]["index"]["minimum"], 1);
980 let items_description = schema["properties"]["items"]["description"].as_str().unwrap();
981 assert!(items_description.contains("Full checklist replacement"));
982 assert!(items_description.contains("never JSON-encoded strings"));
983 assert!(items_description.contains("2-space indentation"));
984 assert_eq!(schema["properties"]["index_path"]["pattern"], "^[1-9][0-9]*(\\.[1-9][0-9]*)*$");
985 assert!(
986 schema["properties"]["index_path"]["description"]
987 .as_str()
988 .expect("index path description")
989 .contains("positive")
990 );
991 }
992
993 #[tokio::test]
994 async fn planning_task_tracker_schema_rejects_mixed_bulk_and_single_updates() {
995 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
996 let schema = tool.parameter_schema().expect("planning task tracker schema");
997 let invalid_cases = [
998 json!({"action": "list", "index": 1}),
999 json!({"action": "list", "index_path": "1"}),
1000 json!({"action": "create", "index": 1, "items": ["New item"]}),
1001 json!({"action": "add", "index_path": "1", "description": "New item"}),
1002 json!({"action": "update", "items": ["Done"], "index": 1, "status": "completed"}),
1003 json!({"action": "update", "items": ["Done"], "index_path": "1.1", "status": "completed"}),
1004 json!({"action": "update", "index": 1, "index_path": "1.1", "status": "completed"}),
1005 json!({"action": "update", "items": ["Done"], "status": "completed"}),
1006 ];
1007
1008 for args in invalid_cases {
1009 assert!(jsonschema::validate(&schema, &args).is_err(), "expected invalid args: {args}");
1010 }
1011 }
1012
1013 #[tokio::test]
1014 async fn bulk_update_rejects_encoded_commands_without_resetting_completed_steps() {
1015 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
1016 tool.execute(json!({
1017 "action": "create",
1018 "title": "Preserve progress",
1019 "items": [
1020 {"description": "Update introduction", "status": "completed", "files": ["README.md"], "outcome": "Clear overview", "verify": ["review headings"]},
1021 {"description": "Review links", "status": "blocked", "outcome": "Await network"}
1022 ]
1023 })).await.unwrap();
1024 let tracker_path = _temp_dir.path().join(".vtcode/tasks/current_task.md");
1025 let persisted_before = std::fs::read_to_string(&tracker_path).unwrap();
1026 let before = tool.execute(json!({"action": "list"})).await.unwrap();
1027
1028 for (action, encoded) in [
1029 ("update", r#"{"index_path":"1","status":"completed","outcome":"Done","verify":"review headings"}"#),
1030 ("update", r#" [x] {"index_path":"1","status":"completed"}"#),
1031 ("create", r#"{"index":1,"status":"completed"}"#),
1032 ] {
1033 let error = tool
1034 .execute(json!({
1035 "action": action,
1036 "title": "Incorrect replacement",
1037 "items": ["Replacement must not leak", encoded]
1038 }))
1039 .await
1040 .expect_err("encoded updates must not replace the tracker");
1041 assert!(error.to_string().contains("JSON-encoded task updates"));
1042 assert!(
1043 error
1044 .to_string()
1045 .contains(r#"{"action":"update","index_path":"1","status":"completed"}"#)
1046 );
1047 assert_eq!(tool.execute(json!({"action": "list"})).await.unwrap(), before);
1048 assert_eq!(std::fs::read_to_string(&tracker_path).unwrap(), persisted_before);
1049 }
1050
1051 tool.execute(json!({"action": "update", "index_path": "2", "status": "completed"}))
1052 .await
1053 .unwrap();
1054 let after = tool.execute(json!({"action": "list"})).await.unwrap();
1055 assert_eq!(after["checklist"]["completed"], 2);
1056 assert_eq!(after["checklist"]["items"][0]["description"], "Update introduction");
1057 assert_eq!(after["checklist"]["items"][0]["files"], json!(["README.md"]));
1058 }
1059
1060 #[tokio::test]
1061 async fn planning_update_rejects_mixed_bulk_and_single_fields_before_mutation() {
1062 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
1063
1064 tool.execute(json!({
1065 "action": "create",
1066 "items": ["Original"]
1067 }))
1068 .await
1069 .expect("create tracker");
1070
1071 let error = tool
1072 .execute(json!({
1073 "action": "update",
1074 "items": ["Replacement"],
1075 "index_path": "1",
1076 "status": "completed"
1077 }))
1078 .await
1079 .expect_err("mixed update must fail closed");
1080 assert!(error.to_string().contains("cannot combine 'items'"));
1081
1082 let result = tool.execute(json!({"action": "list"})).await.unwrap();
1083 assert_eq!(result["checklist"]["items"][0]["description"], "Original");
1084 assert_eq!(result["checklist"]["items"][0]["status"], "pending");
1085 }
1086
1087 #[tokio::test]
1088 async fn planning_update_rejects_both_index_forms_before_mutation() {
1089 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
1090
1091 tool.execute(json!({
1092 "action": "create",
1093 "items": ["Original"]
1094 }))
1095 .await
1096 .expect("create tracker");
1097
1098 let error = tool
1099 .execute(json!({
1100 "action": "update",
1101 "index": 1,
1102 "index_path": "1",
1103 "status": "completed"
1104 }))
1105 .await
1106 .expect_err("ambiguous index forms must fail closed");
1107 assert!(error.to_string().contains("cannot combine 'index' and 'index_path'"));
1108
1109 let result = tool.execute(json!({"action": "list"})).await.unwrap();
1110 assert_eq!(result["checklist"]["items"][0]["description"], "Original");
1111 assert_eq!(result["checklist"]["items"][0]["status"], "pending");
1112 }
1113
1114 #[tokio::test]
1115 async fn execute_rejects_indices_for_non_update_actions() {
1116 let (_temp_dir, _state, tool) = setup_planning_workflow().await;
1117
1118 let invalid_inputs = [
1119 json!({"action": "list", "index": 1}),
1120 json!({"action": "create", "index_path": "1", "items": ["New item"]}),
1121 json!({"action": "add", "index": 1, "description": "New item"}),
1122 json!({"action": "unexpected", "index_path": "1"}),
1123 ];
1124
1125 for input in invalid_inputs {
1126 let error = tool.execute(input).await.expect_err("non-update index must fail closed");
1127 assert!(error.to_string().contains("cannot use 'index' or 'index_path'"), "unexpected error: {error}");
1128 }
1129 }
1130
1131 #[tokio::test]
1132 async fn rejects_when_planning_workflow_is_inactive() {
1133 let temp_dir = TempDir::new().expect("temp dir");
1134 let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1135 let tool = PlanningTaskTrackerTool::new(state);
1136
1137 let err = tool
1138 .execute(json!({"action": "list"}))
1139 .await
1140 .expect_err("should fail outside planning workflow");
1141
1142 assert!(err.to_string().contains("only available while planning"));
1143 }
1144}