| 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 |