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