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