返回 CodeWhale
plan.rs
根目录 / crates / tui / src / tools / plan.rs
1 //! Plan tool implementation with step tracking and validation
2
3 use std::sync::Arc;
4 use std::time::Instant;
5 use tokio::sync::Mutex;
6
7 use async_trait::async_trait;
8 use serde::{Deserialize, Serialize};
9 use serde_json::json;
10
11 use crate::tools::spec::{
12 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
13 };
14
15 // === Types ===
16
17 /// Status of a plan step.
18 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19 #[serde(rename_all = "snake_case")]
20 pub enum StepStatus {
21 Pending,
22 InProgress,
23 Completed,
24 }
25
26 impl StepStatus {
27 #[allow(dead_code)]
28 #[must_use]
29 pub fn from_str(value: &str) -> Option<Self> {
30 match value.trim().to_lowercase().as_str() {
31 "pending" => Some(StepStatus::Pending),
32 "in_progress" | "inprogress" => Some(StepStatus::InProgress),
33 "completed" | "done" => Some(StepStatus::Completed),
34 _ => None,
35 }
36 }
37
38 #[allow(dead_code)]
39 #[must_use]
40 pub fn symbol(&self) -> &'static str {
41 match self {
42 StepStatus::Pending => "○",
43 StepStatus::InProgress => "◎",
44 StepStatus::Completed => "●",
45 }
46 }
47 }
48
49 /// Input representation for a plan item.
50 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51 pub struct PlanItemArg {
52 pub step: String,
53 pub status: StepStatus,
54 }
55
56 /// Update payload used by the plan tool.
57 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
58 pub struct UpdatePlanArgs {
59 #[serde(default)]
60 pub title: Option<String>,
61 #[serde(default)]
62 pub objective: Option<String>,
63 #[serde(default)]
64 pub context_summary: Option<String>,
65 #[serde(default)]
66 pub explanation: Option<String>,
67 #[serde(default)]
68 pub sources_used: Vec<String>,
69 #[serde(default)]
70 pub critical_files: Vec<String>,
71 #[serde(default)]
72 pub constraints: Vec<String>,
73 #[serde(default)]
74 pub recommended_approach: Option<String>,
75 #[serde(default)]
76 pub verification_plan: Option<String>,
77 #[serde(default)]
78 pub risks_and_unknowns: Option<String>,
79 #[serde(default)]
80 pub handoff_packet: Option<String>,
81 #[serde(default)]
82 pub plan: Vec<PlanItemArg>,
83 }
84
85 // === Plan State ===
86
87 /// A plan step with timing information
88 #[derive(Debug, Clone)]
89 pub struct PlanStep {
90 pub text: String,
91 pub status: StepStatus,
92 /// When the step was started (transitioned to `InProgress`)
93 pub started_at: Option<Instant>,
94 /// When the step was completed
95 pub completed_at: Option<Instant>,
96 }
97
98 impl PlanStep {
99 /// Create a new plan step.
100 pub fn new(text: String, status: StepStatus) -> Self {
101 Self {
102 text,
103 status,
104 started_at: None,
105 completed_at: None,
106 }
107 }
108 }
109
110 /// Serializable snapshot for display
111 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
112 pub struct PlanSnapshot {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub title: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub objective: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub context_summary: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub explanation: Option<String>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub sources_used: Vec<String>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub critical_files: Vec<String>,
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub constraints: Vec<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub recommended_approach: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub verification_plan: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub risks_and_unknowns: Option<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub handoff_packet: Option<String>,
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
136 pub items: Vec<PlanItemArg>,
137 }
138
139 impl PlanSnapshot {
140 #[must_use]
141 pub fn is_empty(&self) -> bool {
142 self.title.is_none()
143 && self.objective.is_none()
144 && self.context_summary.is_none()
145 && self.explanation.is_none()
146 && self.sources_used.is_empty()
147 && self.critical_files.is_empty()
148 && self.constraints.is_empty()
149 && self.recommended_approach.is_none()
150 && self.verification_plan.is_none()
151 && self.risks_and_unknowns.is_none()
152 && self.handoff_packet.is_none()
153 && self.items.is_empty()
154 }
155
156 /// Parse the user/model-facing `update_plan` payload into a displayable
157 /// snapshot. This is intentionally tolerant so saved transcript replay can
158 /// keep legacy and partially streamed payloads visible.
159 #[must_use]
160 pub fn from_tool_input(input: &serde_json::Value) -> Self {
161 let mut items = Vec::new();
162 if let Some(plan_items) = input.get("plan").and_then(|v| v.as_array()) {
163 for item in plan_items {
164 let step = item
165 .get("step")
166 .and_then(|v| v.as_str())
167 .map(str::trim)
168 .unwrap_or("");
169 if step.is_empty() {
170 continue;
171 }
172 let status = item
173 .get("status")
174 .and_then(|v| v.as_str())
175 .and_then(StepStatus::from_str)
176 .unwrap_or(StepStatus::Pending);
177 items.push(PlanItemArg {
178 step: step.to_string(),
179 status,
180 });
181 }
182 }
183
184 Self {
185 title: clean_optional(string_field(input, "title")),
186 objective: clean_optional(string_field(input, "objective")),
187 context_summary: clean_optional(string_field(input, "context_summary")),
188 explanation: clean_optional(string_field(input, "explanation")),
189 sources_used: clean_list(string_vec_field(input, "sources_used")),
190 critical_files: clean_list(string_vec_field(input, "critical_files")),
191 constraints: clean_list(string_vec_field(input, "constraints")),
192 recommended_approach: clean_optional(string_field(input, "recommended_approach")),
193 verification_plan: clean_optional(string_field(input, "verification_plan")),
194 risks_and_unknowns: clean_optional(string_field(input, "risks_and_unknowns")),
195 handoff_packet: clean_optional(string_field(input, "handoff_packet")),
196 items,
197 }
198 }
199 }
200
201 /// State tracking for the current plan
202 #[derive(Debug, Clone, Default)]
203 pub struct PlanState {
204 title: Option<String>,
205 objective: Option<String>,
206 context_summary: Option<String>,
207 explanation: Option<String>,
208 sources_used: Vec<String>,
209 critical_files: Vec<String>,
210 constraints: Vec<String>,
211 recommended_approach: Option<String>,
212 verification_plan: Option<String>,
213 risks_and_unknowns: Option<String>,
214 handoff_packet: Option<String>,
215 steps: Vec<PlanStep>,
216 }
217
218 impl PlanState {
219 pub fn update(&mut self, args: UpdatePlanArgs) {
220 self.title = clean_optional(args.title);
221 self.objective = clean_optional(args.objective);
222 self.context_summary = clean_optional(args.context_summary);
223 self.explanation = clean_optional(args.explanation);
224 self.sources_used = clean_list(args.sources_used);
225 self.critical_files = clean_list(args.critical_files);
226 self.constraints = clean_list(args.constraints);
227 self.recommended_approach = clean_optional(args.recommended_approach);
228 self.verification_plan = clean_optional(args.verification_plan);
229 self.risks_and_unknowns = clean_optional(args.risks_and_unknowns);
230 self.handoff_packet = clean_optional(args.handoff_packet);
231
232 let now = Instant::now();
233 let mut new_steps = Vec::new();
234 let mut in_progress_seen = false;
235
236 for item in args.plan {
237 let step_text = item.step.trim();
238 if step_text.is_empty() {
239 continue;
240 }
241 // Try to find existing step to preserve timing
242 let existing = self.steps.iter().find(|s| s.text == step_text);
243
244 let mut status = item.status;
245 // Enforce single in_progress
246 if status == StepStatus::InProgress {
247 if in_progress_seen {
248 status = StepStatus::Pending;
249 } else {
250 in_progress_seen = true;
251 }
252 }
253
254 let step = if let Some(old) = existing {
255 let mut s = old.clone();
256 let old_status = s.status.clone();
257 s.status = status.clone();
258
259 // Track timing transitions
260 if old_status == StepStatus::Pending && status == StepStatus::InProgress {
261 s.started_at = Some(now);
262 }
263 if old_status == StepStatus::InProgress && status == StepStatus::Completed {
264 s.completed_at = Some(now);
265 }
266
267 s
268 } else {
269 let mut s = PlanStep::new(step_text.to_string(), status.clone());
270 if status == StepStatus::InProgress {
271 s.started_at = Some(now);
272 }
273 s
274 };
275
276 new_steps.push(step);
277 }
278
279 self.steps = new_steps;
280 }
281
282 pub fn snapshot(&self) -> PlanSnapshot {
283 PlanSnapshot {
284 title: self.title.clone(),
285 objective: self.objective.clone(),
286 context_summary: self.context_summary.clone(),
287 explanation: self.explanation.clone(),
288 sources_used: self.sources_used.clone(),
289 critical_files: self.critical_files.clone(),
290 constraints: self.constraints.clone(),
291 recommended_approach: self.recommended_approach.clone(),
292 verification_plan: self.verification_plan.clone(),
293 risks_and_unknowns: self.risks_and_unknowns.clone(),
294 handoff_packet: self.handoff_packet.clone(),
295 items: self
296 .steps
297 .iter()
298 .map(|s| PlanItemArg {
299 step: s.text.clone(),
300 status: s.status.clone(),
301 })
302 .collect(),
303 }
304 }
305
306 /// Restore persisted plan data through the same normalization path used by
307 /// `update_plan`. Timing is intentionally session-local and starts fresh.
308 #[must_use]
309 pub fn from_snapshot(snapshot: &PlanSnapshot) -> Self {
310 let mut state = Self::default();
311 state.update(UpdatePlanArgs {
312 title: snapshot.title.clone(),
313 objective: snapshot.objective.clone(),
314 context_summary: snapshot.context_summary.clone(),
315 explanation: snapshot.explanation.clone(),
316 sources_used: snapshot.sources_used.clone(),
317 critical_files: snapshot.critical_files.clone(),
318 constraints: snapshot.constraints.clone(),
319 recommended_approach: snapshot.recommended_approach.clone(),
320 verification_plan: snapshot.verification_plan.clone(),
321 risks_and_unknowns: snapshot.risks_and_unknowns.clone(),
322 handoff_packet: snapshot.handoff_packet.clone(),
323 plan: snapshot.items.clone(),
324 });
325 state
326 }
327
328 #[allow(dead_code)] // retained for PlanState consumers / older tests
329 pub fn steps(&self) -> &[PlanStep] {
330 &self.steps
331 }
332
333 /// Get counts of steps by status
334 pub fn counts(&self) -> (usize, usize, usize) {
335 let mut pending = 0;
336 let mut in_progress = 0;
337 let mut completed = 0;
338 for s in &self.steps {
339 match s.status {
340 StepStatus::Pending => pending += 1,
341 StepStatus::InProgress => in_progress += 1,
342 StepStatus::Completed => completed += 1,
343 }
344 }
345 (pending, in_progress, completed)
346 }
347
348 /// Get progress as a percentage
349 pub fn progress_percent(&self) -> u8 {
350 if self.steps.is_empty() {
351 return 0;
352 }
353 let completed = self
354 .steps
355 .iter()
356 .filter(|s| s.status == StepStatus::Completed)
357 .count();
358 let percent = completed.saturating_mul(100) / self.steps.len();
359 u8::try_from(percent).unwrap_or(u8::MAX)
360 }
361 }
362
363 fn clean_optional(value: Option<String>) -> Option<String> {
364 value
365 .map(|s| s.trim().to_string())
366 .filter(|s| !s.is_empty())
367 }
368
369 fn clean_list(values: Vec<String>) -> Vec<String> {
370 values
371 .into_iter()
372 .map(|value| value.trim().to_string())
373 .filter(|value| !value.is_empty())
374 .collect()
375 }
376
377 // === UpdatePlanTool - ToolSpec implementation ===
378
379 /// Shared reference to `PlanState` for use across tools
380 pub type SharedPlanState = Arc<Mutex<PlanState>>;
381
382 /// Create a new shared `PlanState`
383 pub fn new_shared_plan_state() -> SharedPlanState {
384 Arc::new(Mutex::new(PlanState::default()))
385 }
386
387 /// Tool for updating the implementation plan
388 pub struct UpdatePlanTool {
389 plan_state: SharedPlanState,
390 }
391
392 impl UpdatePlanTool {
393 pub fn new(plan_state: SharedPlanState) -> Self {
394 Self { plan_state }
395 }
396 }
397
398 #[async_trait]
399 impl ToolSpec for UpdatePlanTool {
400 fn name(&self) -> &'static str {
401 "update_plan"
402 }
403
404 fn description(&self) -> &'static str {
405 "Legacy compatibility tool for loading older Plan artifacts. New work uses the canonical work_update list and a normal Plan-mode response."
406 }
407
408 fn model_visible(&self) -> bool {
409 // Older transcripts and sessions can still replay this tool, but new
410 // model turns get one progress model (`work_update`) instead of the
411 // retired Strategy/Plan surface.
412 false
413 }
414
415 fn input_schema(&self) -> serde_json::Value {
416 json!({
417 "type": "object",
418 "properties": {
419 "title": {
420 "type": "string",
421 "description": "Optional short title for the plan artifact"
422 },
423 "objective": {
424 "type": "string",
425 "description": "What the plan is trying to accomplish"
426 },
427 "context_summary": {
428 "type": "string",
429 "description": "Brief summary of the evidence and current state behind the plan"
430 },
431 "explanation": {
432 "type": "string",
433 "description": "Legacy-compatible high-level explanation of the plan or approach"
434 },
435 "sources_used": {
436 "type": "array",
437 "description": "Files, issues, PRs, commands, or other evidence used to ground the plan. Do not include secrets.",
438 "items": { "type": "string" }
439 },
440 "critical_files": {
441 "type": "array",
442 "description": "Repo paths or surfaces likely to be edited or verified. Do not include secrets.",
443 "items": { "type": "string" }
444 },
445 "constraints": {
446 "type": "array",
447 "description": "Hard requirements, user preferences, or boundaries the implementation must respect",
448 "items": { "type": "string" }
449 },
450 "recommended_approach": {
451 "type": "string",
452 "description": "Recommended implementation strategy and important trade-offs"
453 },
454 "verification_plan": {
455 "type": "string",
456 "description": "Tests, checks, or manual verification expected before the work is considered done"
457 },
458 "risks_and_unknowns": {
459 "type": "string",
460 "description": "Known risks, blockers, or unresolved questions"
461 },
462 "handoff_packet": {
463 "type": "string",
464 "description": "Concise continuation notes for another agent or a later session"
465 },
466 "plan": {
467 "type": "array",
468 "description": "Legacy replay field; new work must use work_update",
469 "deprecated": true,
470 "items": { "type": "object" }
471 }
472 }
473 })
474 }
475
476 fn capabilities(&self) -> Vec<ToolCapability> {
477 vec![ToolCapability::WritesFiles]
478 }
479
480 fn approval_requirement(&self) -> ApprovalRequirement {
481 ApprovalRequirement::Auto
482 }
483
484 async fn execute(
485 &self,
486 input: serde_json::Value,
487 context: &ToolContext,
488 ) -> Result<ToolResult, ToolError> {
489 let empty_plan = Vec::new();
490 let plan_items = match input.get("plan") {
491 Some(value) => value
492 .as_array()
493 .ok_or_else(|| ToolError::invalid_input("Invalid 'plan' array"))?,
494 None => &empty_plan,
495 };
496
497 let mut plan_args = Vec::new();
498 for item in plan_items {
499 let step = item
500 .get("step")
501 .and_then(|v| v.as_str())
502 .ok_or_else(|| ToolError::invalid_input("Plan item missing 'step'"))?;
503
504 let status_str = item
505 .get("status")
506 .and_then(|v| v.as_str())
507 .unwrap_or("pending");
508
509 let status = StepStatus::from_str(status_str).unwrap_or(StepStatus::Pending);
510
511 plan_args.push(PlanItemArg {
512 step: step.to_string(),
513 status,
514 });
515 }
516
517 let args = UpdatePlanArgs {
518 title: string_field(&input, "title"),
519 objective: string_field(&input, "objective"),
520 context_summary: string_field(&input, "context_summary"),
521 explanation: string_field(&input, "explanation"),
522 sources_used: string_vec_field(&input, "sources_used"),
523 critical_files: string_vec_field(&input, "critical_files"),
524 constraints: string_vec_field(&input, "constraints"),
525 recommended_approach: string_field(&input, "recommended_approach"),
526 verification_plan: string_field(&input, "verification_plan"),
527 risks_and_unknowns: string_field(&input, "risks_and_unknowns"),
528 handoff_packet: string_field(&input, "handoff_packet"),
529 plan: plan_args,
530 };
531
532 let mut next_state = PlanState::default();
533 next_state.update(args);
534 let desired = next_state.snapshot();
535 let snapshot = if let Some(work) = context.runtime.work.as_ref()
536 && work.matches_plan(&self.plan_state)
537 {
538 work.apply_plan_update(&context.state_namespace, self.name(), &desired)
539 .await
540 .map_err(ToolError::execution_failed)?
541 } else {
542 let mut state = self.plan_state.lock().await;
543 state.update(UpdatePlanArgs {
544 title: desired.title.clone(),
545 objective: desired.objective.clone(),
546 context_summary: desired.context_summary.clone(),
547 explanation: desired.explanation.clone(),
548 sources_used: desired.sources_used.clone(),
549 critical_files: desired.critical_files.clone(),
550 constraints: desired.constraints.clone(),
551 recommended_approach: desired.recommended_approach.clone(),
552 verification_plan: desired.verification_plan.clone(),
553 risks_and_unknowns: desired.risks_and_unknowns.clone(),
554 handoff_packet: desired.handoff_packet.clone(),
555 plan: desired.items.clone(),
556 });
557 state.snapshot()
558 };
559 let state = PlanState::from_snapshot(&snapshot);
560 let (pending, in_progress, completed) = state.counts();
561 let progress = state.progress_percent();
562
563 let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
564
565 Ok(ToolResult::success(format!(
566 "Plan updated: {pending} pending, {in_progress} in progress, {completed} completed ({progress}% done)\n{result}"
567 )))
568 }
569 }
570
571 fn string_field(input: &serde_json::Value, field: &str) -> Option<String> {
572 input
573 .get(field)
574 .and_then(|v| v.as_str())
575 .map(std::string::ToString::to_string)
576 }
577
578 fn string_vec_field(input: &serde_json::Value, field: &str) -> Vec<String> {
579 input
580 .get(field)
581 .and_then(|v| v.as_array())
582 .map(|values| {
583 values
584 .iter()
585 .filter_map(|value| value.as_str().map(std::string::ToString::to_string))
586 .collect()
587 })
588 .unwrap_or_default()
589 }
590
591 #[cfg(test)]
592 mod tests {
593 use super::*;
594 use crate::tools::spec::{ToolContext, ToolSpec};
595 use serde_json::json;
596
597 #[test]
598 fn update_plan_is_hidden_replay_compatibility() {
599 let tool = UpdatePlanTool::new(new_shared_plan_state());
600 let description = tool.description();
601
602 assert!(!tool.model_visible());
603 assert!(description.contains("Legacy compatibility"));
604 assert!(description.contains("canonical work_update list"));
605 }
606
607 #[tokio::test]
608 async fn update_plan_routes_through_attached_work_graph() {
609 let plan = new_shared_plan_state();
610 let todos = crate::tools::todo::new_shared_todo_list();
611 let work = crate::work_graph::new_shared_work_runtime(todos, plan.clone());
612 let tool = UpdatePlanTool::new(plan);
613 let mut context = ToolContext::new(std::env::temp_dir());
614 context.runtime.work = Some(work.clone());
615
616 tool.execute(
617 json!({
618 "objective": "Prove the real tool path",
619 "plan": [{"step": "Update graph", "status": "in_progress"}]
620 }),
621 &context,
622 )
623 .await
624 .expect("update_plan succeeds");
625
626 let state = work
627 .capture(Some(&context.state_namespace))
628 .expect("capture")
629 .expect("graph state");
630 assert_eq!(
631 state.plan.objective.as_deref(),
632 Some("Prove the real tool path")
633 );
634 assert_eq!(state.graph.compat.plan_order.len(), 1);
635 }
636
637 #[test]
638 fn plan_state_treats_every_artifact_field_as_non_empty() {
639 let cases = vec![
640 UpdatePlanArgs {
641 title: Some("Title".to_string()),
642 ..UpdatePlanArgs::default()
643 },
644 UpdatePlanArgs {
645 objective: Some("Objective".to_string()),
646 ..UpdatePlanArgs::default()
647 },
648 UpdatePlanArgs {
649 context_summary: Some("Context".to_string()),
650 ..UpdatePlanArgs::default()
651 },
652 UpdatePlanArgs {
653 explanation: Some("Explanation".to_string()),
654 ..UpdatePlanArgs::default()
655 },
656 UpdatePlanArgs {
657 sources_used: vec!["gh issue view 2691".to_string()],
658 ..UpdatePlanArgs::default()
659 },
660 UpdatePlanArgs {
661 critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()],
662 ..UpdatePlanArgs::default()
663 },
664 UpdatePlanArgs {
665 constraints: vec!["Preserve legacy payloads".to_string()],
666 ..UpdatePlanArgs::default()
667 },
668 UpdatePlanArgs {
669 recommended_approach: Some("Do the narrow slice".to_string()),
670 ..UpdatePlanArgs::default()
671 },
672 UpdatePlanArgs {
673 verification_plan: Some("Run focused tests".to_string()),
674 ..UpdatePlanArgs::default()
675 },
676 UpdatePlanArgs {
677 risks_and_unknowns: Some("Replay may drift".to_string()),
678 ..UpdatePlanArgs::default()
679 },
680 UpdatePlanArgs {
681 handoff_packet: Some("Next agent should inspect rendering".to_string()),
682 ..UpdatePlanArgs::default()
683 },
684 ];
685
686 for args in cases {
687 let mut state = PlanState::default();
688 state.update(args);
689 assert!(
690 !state.snapshot().is_empty(),
691 "artifact metadata must keep plan state visible"
692 );
693 }
694 }
695
696 #[test]
697 fn plan_state_snapshot_trims_blank_artifact_values() {
698 let mut state = PlanState::default();
699 state.update(UpdatePlanArgs {
700 title: Some(" Rich plan ".to_string()),
701 sources_used: vec![" ".to_string(), " gh issue view 2691 ".to_string()],
702 critical_files: vec![" crates/tui/src/tools/plan.rs ".to_string()],
703 constraints: vec!["".to_string(), " no secrets ".to_string()],
704 plan: vec![
705 PlanItemArg {
706 step: " ".to_string(),
707 status: StepStatus::Pending,
708 },
709 PlanItemArg {
710 step: " render sections ".to_string(),
711 status: StepStatus::InProgress,
712 },
713 ],
714 ..UpdatePlanArgs::default()
715 });
716
717 let snapshot = state.snapshot();
718 assert_eq!(snapshot.title.as_deref(), Some("Rich plan"));
719 assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]);
720 assert_eq!(
721 snapshot.critical_files,
722 vec!["crates/tui/src/tools/plan.rs"]
723 );
724 assert_eq!(snapshot.constraints, vec!["no secrets"]);
725 assert_eq!(snapshot.items.len(), 1);
726 assert_eq!(snapshot.items[0].step, "render sections");
727 assert_eq!(snapshot.items[0].status, StepStatus::InProgress);
728 }
729
730 #[test]
731 fn plan_state_restores_from_persisted_snapshot() {
732 let snapshot = PlanSnapshot {
733 objective: Some("Restore Work state".to_string()),
734 items: vec![
735 PlanItemArg {
736 step: "inspect".to_string(),
737 status: StepStatus::Completed,
738 },
739 PlanItemArg {
740 step: "verify".to_string(),
741 status: StepStatus::InProgress,
742 },
743 ],
744 ..PlanSnapshot::default()
745 };
746
747 let restored = PlanState::from_snapshot(&snapshot);
748 assert_eq!(restored.snapshot(), snapshot);
749 }
750
751 #[test]
752 fn snapshot_serde_skips_empty_fields_and_deserializes_legacy() {
753 let snapshot = PlanSnapshot {
754 objective: Some("Ship PlanArtifact".to_string()),
755 items: vec![PlanItemArg {
756 step: "keep legacy replay working".to_string(),
757 status: StepStatus::Completed,
758 }],
759 ..PlanSnapshot::default()
760 };
761
762 let value = serde_json::to_value(&snapshot).expect("serialize snapshot");
763 assert!(value.get("objective").is_some());
764 assert!(value.get("title").is_none());
765 assert!(value.get("sources_used").is_none());
766 assert!(value.get("constraints").is_none());
767
768 let legacy: PlanSnapshot = serde_json::from_value(json!({
769 "explanation": "Legacy explanation",
770 "items": [
771 { "step": "legacy step", "status": "pending" }
772 ]
773 }))
774 .expect("legacy snapshot should deserialize");
775 assert_eq!(legacy.explanation.as_deref(), Some("Legacy explanation"));
776 assert_eq!(legacy.items.len(), 1);
777 assert!(legacy.sources_used.is_empty());
778 }
779
780 #[tokio::test]
781 async fn legacy_update_plan_still_works() {
782 let state = new_shared_plan_state();
783 let tool = UpdatePlanTool::new(state.clone());
784 let context = ToolContext::new(std::env::temp_dir());
785
786 tool.execute(
787 json!({
788 "explanation": "Legacy shape",
789 "plan": [
790 { "step": "inspect", "status": "completed" },
791 { "step": "patch", "status": "in_progress" }
792 ]
793 }),
794 &context,
795 )
796 .await
797 .expect("legacy update_plan should succeed");
798
799 let snapshot = state.lock().await.snapshot();
800 assert_eq!(snapshot.explanation.as_deref(), Some("Legacy shape"));
801 assert_eq!(snapshot.items.len(), 2);
802 assert_eq!(snapshot.items[0].status, StepStatus::Completed);
803 assert_eq!(snapshot.items[1].status, StepStatus::InProgress);
804 }
805
806 #[tokio::test]
807 async fn update_plan_tool_accepts_metadata_only_payload() {
808 let state = new_shared_plan_state();
809 let tool = UpdatePlanTool::new(state.clone());
810 let context = ToolContext::new(std::env::temp_dir());
811
812 let result = tool
813 .execute(
814 json!({
815 "objective": "Make Plan mode reviewable",
816 "sources_used": ["gh issue view 2691"],
817 "critical_files": ["crates/tui/src/tools/plan.rs"],
818 "verification_plan": "Run focused plan tests"
819 }),
820 &context,
821 )
822 .await
823 .expect("metadata-only update_plan should succeed");
824
825 assert!(result.content.contains("Make Plan mode reviewable"));
826 let snapshot = state.lock().await.snapshot();
827 assert!(!snapshot.is_empty());
828 assert!(snapshot.items.is_empty());
829 assert_eq!(
830 snapshot.critical_files,
831 vec!["crates/tui/src/tools/plan.rs"]
832 );
833 }
834 }
835
835 lines RUST