use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Pending,
InProgress,
Completed,
}
impl StepStatus {
#[allow(dead_code)]
#[must_use]
pub fn from_str(value: &str) -> Option<Self> {
match value.trim().to_lowercase().as_str() {
"pending" => Some(StepStatus::Pending),
"in_progress" | "inprogress" => Some(StepStatus::InProgress),
"completed" | "done" => Some(StepStatus::Completed),
_ => None,
}
}
#[allow(dead_code)]
#[must_use]
pub fn symbol(&self) -> &'static str {
match self {
StepStatus::Pending => "○",
StepStatus::InProgress => "◎",
StepStatus::Completed => "●",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanItemArg {
pub step: String,
pub status: StepStatus,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdatePlanArgs {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub objective: Option<String>,
#[serde(default)]
pub context_summary: Option<String>,
#[serde(default)]
pub explanation: Option<String>,
#[serde(default)]
pub sources_used: Vec<String>,
#[serde(default)]
pub critical_files: Vec<String>,
#[serde(default)]
pub constraints: Vec<String>,
#[serde(default)]
pub recommended_approach: Option<String>,
#[serde(default)]
pub verification_plan: Option<String>,
#[serde(default)]
pub risks_and_unknowns: Option<String>,
#[serde(default)]
pub handoff_packet: Option<String>,
#[serde(default)]
pub plan: Vec<PlanItemArg>,
}
#[derive(Debug, Clone)]
pub struct PlanStep {
pub text: String,
pub status: StepStatus,
pub started_at: Option<Instant>,
pub completed_at: Option<Instant>,
}
impl PlanStep {
pub fn new(text: String, status: StepStatus) -> Self {
Self {
text,
status,
started_at: None,
completed_at: None,
}
}
#[must_use]
pub fn elapsed(&self) -> Option<Duration> {
match (self.started_at, self.completed_at) {
(Some(start), Some(end)) => Some(end.duration_since(start)),
(Some(start), None) if self.status == StepStatus::InProgress => Some(start.elapsed()),
_ => None,
}
}
#[must_use]
pub fn elapsed_str(&self) -> String {
match self.elapsed() {
Some(d) => {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
None => String::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlanSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub objective: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources_used: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub critical_files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recommended_approach: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification_plan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub risks_and_unknowns: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handoff_packet: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<PlanItemArg>,
}
impl PlanSnapshot {
#[must_use]
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.objective.is_none()
&& self.context_summary.is_none()
&& self.explanation.is_none()
&& self.sources_used.is_empty()
&& self.critical_files.is_empty()
&& self.constraints.is_empty()
&& self.recommended_approach.is_none()
&& self.verification_plan.is_none()
&& self.risks_and_unknowns.is_none()
&& self.handoff_packet.is_none()
&& self.items.is_empty()
}
#[must_use]
pub fn from_tool_input(input: &serde_json::Value) -> Self {
let mut items = Vec::new();
if let Some(plan_items) = input.get("plan").and_then(|v| v.as_array()) {
for item in plan_items {
let step = item
.get("step")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if step.is_empty() {
continue;
}
let status = item
.get("status")
.and_then(|v| v.as_str())
.and_then(StepStatus::from_str)
.unwrap_or(StepStatus::Pending);
items.push(PlanItemArg {
step: step.to_string(),
status,
});
}
}
Self {
title: clean_optional(string_field(input, "title")),
objective: clean_optional(string_field(input, "objective")),
context_summary: clean_optional(string_field(input, "context_summary")),
explanation: clean_optional(string_field(input, "explanation")),
sources_used: clean_list(string_vec_field(input, "sources_used")),
critical_files: clean_list(string_vec_field(input, "critical_files")),
constraints: clean_list(string_vec_field(input, "constraints")),
recommended_approach: clean_optional(string_field(input, "recommended_approach")),
verification_plan: clean_optional(string_field(input, "verification_plan")),
risks_and_unknowns: clean_optional(string_field(input, "risks_and_unknowns")),
handoff_packet: clean_optional(string_field(input, "handoff_packet")),
items,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PlanState {
title: Option<String>,
objective: Option<String>,
context_summary: Option<String>,
explanation: Option<String>,
sources_used: Vec<String>,
critical_files: Vec<String>,
constraints: Vec<String>,
recommended_approach: Option<String>,
verification_plan: Option<String>,
risks_and_unknowns: Option<String>,
handoff_packet: Option<String>,
steps: Vec<PlanStep>,
}
impl PlanState {
#[must_use]
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
&& self.title.is_none()
&& self.objective.is_none()
&& self.context_summary.is_none()
&& self.explanation.is_none()
&& self.sources_used.is_empty()
&& self.critical_files.is_empty()
&& self.constraints.is_empty()
&& self.recommended_approach.is_none()
&& self.verification_plan.is_none()
&& self.risks_and_unknowns.is_none()
&& self.handoff_packet.is_none()
}
pub fn update(&mut self, args: UpdatePlanArgs) {
self.title = clean_optional(args.title);
self.objective = clean_optional(args.objective);
self.context_summary = clean_optional(args.context_summary);
self.explanation = clean_optional(args.explanation);
self.sources_used = clean_list(args.sources_used);
self.critical_files = clean_list(args.critical_files);
self.constraints = clean_list(args.constraints);
self.recommended_approach = clean_optional(args.recommended_approach);
self.verification_plan = clean_optional(args.verification_plan);
self.risks_and_unknowns = clean_optional(args.risks_and_unknowns);
self.handoff_packet = clean_optional(args.handoff_packet);
let now = Instant::now();
let mut new_steps = Vec::new();
let mut in_progress_seen = false;
for item in args.plan {
let step_text = item.step.trim();
if step_text.is_empty() {
continue;
}
let existing = self.steps.iter().find(|s| s.text == step_text);
let mut status = item.status;
if status == StepStatus::InProgress {
if in_progress_seen {
status = StepStatus::Pending;
} else {
in_progress_seen = true;
}
}
let step = if let Some(old) = existing {
let mut s = old.clone();
let old_status = s.status.clone();
s.status = status.clone();
if old_status == StepStatus::Pending && status == StepStatus::InProgress {
s.started_at = Some(now);
}
if old_status == StepStatus::InProgress && status == StepStatus::Completed {
s.completed_at = Some(now);
}
s
} else {
let mut s = PlanStep::new(step_text.to_string(), status.clone());
if status == StepStatus::InProgress {
s.started_at = Some(now);
}
s
};
new_steps.push(step);
}
self.steps = new_steps;
}
pub fn snapshot(&self) -> PlanSnapshot {
PlanSnapshot {
title: self.title.clone(),
objective: self.objective.clone(),
context_summary: self.context_summary.clone(),
explanation: self.explanation.clone(),
sources_used: self.sources_used.clone(),
critical_files: self.critical_files.clone(),
constraints: self.constraints.clone(),
recommended_approach: self.recommended_approach.clone(),
verification_plan: self.verification_plan.clone(),
risks_and_unknowns: self.risks_and_unknowns.clone(),
handoff_packet: self.handoff_packet.clone(),
items: self
.steps
.iter()
.map(|s| PlanItemArg {
step: s.text.clone(),
status: s.status.clone(),
})
.collect(),
}
}
pub fn explanation(&self) -> Option<&str> {
self.explanation.as_deref()
}
pub fn steps(&self) -> &[PlanStep] {
&self.steps
}
pub fn counts(&self) -> (usize, usize, usize) {
let mut pending = 0;
let mut in_progress = 0;
let mut completed = 0;
for s in &self.steps {
match s.status {
StepStatus::Pending => pending += 1,
StepStatus::InProgress => in_progress += 1,
StepStatus::Completed => completed += 1,
}
}
(pending, in_progress, completed)
}
pub fn progress_percent(&self) -> u8 {
if self.steps.is_empty() {
return 0;
}
let completed = self
.steps
.iter()
.filter(|s| s.status == StepStatus::Completed)
.count();
let percent = completed.saturating_mul(100) / self.steps.len();
u8::try_from(percent).unwrap_or(u8::MAX)
}
}
fn clean_optional(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn clean_list(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}
#[derive(Debug)]
#[allow(dead_code)]
pub enum PlanValidation {
Ok,
Warning(String),
Error(String),
}
#[allow(dead_code)]
pub fn validate_plan_update(current: &PlanState, update: &UpdatePlanArgs) -> PlanValidation {
let current_steps: std::collections::HashMap<_, _> = current
.steps()
.iter()
.map(|s| (s.text.clone(), &s.status))
.collect();
for item in &update.plan {
if let Some(old_status) = current_steps.get(&item.step) {
match (old_status, &item.status) {
(StepStatus::Completed, StepStatus::Pending) => {
return PlanValidation::Warning(format!(
"Step '{}' was completed but is now pending",
item.step
));
}
(StepStatus::Completed, StepStatus::InProgress) => {
return PlanValidation::Warning(format!(
"Step '{}' was completed but is now in progress",
item.step
));
}
_ => {}
}
}
}
PlanValidation::Ok
}
pub type SharedPlanState = Arc<Mutex<PlanState>>;
pub fn new_shared_plan_state() -> SharedPlanState {
Arc::new(Mutex::new(PlanState::default()))
}
pub struct UpdatePlanTool {
plan_state: SharedPlanState,
}
impl UpdatePlanTool {
pub fn new(plan_state: SharedPlanState) -> Self {
Self { plan_state }
}
}
#[async_trait]
impl ToolSpec for UpdatePlanTool {
fn name(&self) -> &'static str {
"update_plan"
}
fn description(&self) -> &'static str {
"Update optional high-level strategy metadata for complex initiatives. Use checklist_write for primary Work progress; update_plan should capture phase-level approach changes, not duplicate checklist items. Include sources, critical files, constraints, verification, risks, and handoff context when they help the user review or continue the plan. Each strategy step has a description and status (pending, in_progress, completed)."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Optional short title for the plan artifact"
},
"objective": {
"type": "string",
"description": "What the plan is trying to accomplish"
},
"context_summary": {
"type": "string",
"description": "Brief summary of the evidence and current state behind the plan"
},
"explanation": {
"type": "string",
"description": "Legacy-compatible high-level explanation of the plan or approach"
},
"sources_used": {
"type": "array",
"description": "Files, issues, PRs, commands, or other evidence used to ground the plan. Do not include secrets.",
"items": { "type": "string" }
},
"critical_files": {
"type": "array",
"description": "Repo paths or surfaces likely to be edited or verified. Do not include secrets.",
"items": { "type": "string" }
},
"constraints": {
"type": "array",
"description": "Hard requirements, user preferences, or boundaries the implementation must respect",
"items": { "type": "string" }
},
"recommended_approach": {
"type": "string",
"description": "Recommended implementation strategy and important trade-offs"
},
"verification_plan": {
"type": "string",
"description": "Tests, checks, or manual verification expected before the work is considered done"
},
"risks_and_unknowns": {
"type": "string",
"description": "Known risks, blockers, or unresolved questions"
},
"handoff_packet": {
"type": "string",
"description": "Concise continuation notes for another agent or a later session"
},
"plan": {
"type": "array",
"description": "List of plan steps",
"items": {
"type": "object",
"properties": {
"step": {
"type": "string",
"description": "Description of the step"
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
"description": "Step status"
}
},
"required": ["step", "status"]
}
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::WritesFiles]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
input: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let empty_plan = Vec::new();
let plan_items = match input.get("plan") {
Some(value) => value
.as_array()
.ok_or_else(|| ToolError::invalid_input("Invalid 'plan' array"))?,
None => &empty_plan,
};
let mut plan_args = Vec::new();
for item in plan_items {
let step = item
.get("step")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::invalid_input("Plan item missing 'step'"))?;
let status_str = item
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("pending");
let status = StepStatus::from_str(status_str).unwrap_or(StepStatus::Pending);
plan_args.push(PlanItemArg {
step: step.to_string(),
status,
});
}
let args = UpdatePlanArgs {
title: string_field(&input, "title"),
objective: string_field(&input, "objective"),
context_summary: string_field(&input, "context_summary"),
explanation: string_field(&input, "explanation"),
sources_used: string_vec_field(&input, "sources_used"),
critical_files: string_vec_field(&input, "critical_files"),
constraints: string_vec_field(&input, "constraints"),
recommended_approach: string_field(&input, "recommended_approach"),
verification_plan: string_field(&input, "verification_plan"),
risks_and_unknowns: string_field(&input, "risks_and_unknowns"),
handoff_packet: string_field(&input, "handoff_packet"),
plan: plan_args,
};
let mut state = self.plan_state.lock().await;
state.update(args);
let snapshot = state.snapshot();
let (pending, in_progress, completed) = state.counts();
let progress = state.progress_percent();
let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
Ok(ToolResult::success(format!(
"Plan updated: {pending} pending, {in_progress} in progress, {completed} completed ({progress}% done)\n{result}"
)))
}
}
fn string_field(input: &serde_json::Value, field: &str) -> Option<String> {
input
.get(field)
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
}
fn string_vec_field(input: &serde_json::Value, field: &str) -> Vec<String> {
input
.get(field)
.and_then(|v| v.as_array())
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(std::string::ToString::to_string))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::spec::{ToolContext, ToolSpec};
use serde_json::json;
#[test]
fn plan_state_treats_every_artifact_field_as_non_empty() {
let cases = vec![
UpdatePlanArgs {
title: Some("Title".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
objective: Some("Objective".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
context_summary: Some("Context".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
explanation: Some("Explanation".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
sources_used: vec!["gh issue view 2691".to_string()],
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()],
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
constraints: vec!["Preserve legacy payloads".to_string()],
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
recommended_approach: Some("Do the narrow slice".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
verification_plan: Some("Run focused tests".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
risks_and_unknowns: Some("Replay may drift".to_string()),
..UpdatePlanArgs::default()
},
UpdatePlanArgs {
handoff_packet: Some("Next agent should inspect rendering".to_string()),
..UpdatePlanArgs::default()
},
];
for args in cases {
let mut state = PlanState::default();
state.update(args);
assert!(
!state.is_empty(),
"artifact metadata must keep plan state visible"
);
}
}
#[test]
fn plan_state_snapshot_trims_blank_artifact_values() {
let mut state = PlanState::default();
state.update(UpdatePlanArgs {
title: Some(" Rich plan ".to_string()),
sources_used: vec![" ".to_string(), " gh issue view 2691 ".to_string()],
critical_files: vec![" crates/tui/src/tools/plan.rs ".to_string()],
constraints: vec!["".to_string(), " no secrets ".to_string()],
plan: vec![
PlanItemArg {
step: " ".to_string(),
status: StepStatus::Pending,
},
PlanItemArg {
step: " render sections ".to_string(),
status: StepStatus::InProgress,
},
],
..UpdatePlanArgs::default()
});
let snapshot = state.snapshot();
assert_eq!(snapshot.title.as_deref(), Some("Rich plan"));
assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]);
assert_eq!(
snapshot.critical_files,
vec!["crates/tui/src/tools/plan.rs"]
);
assert_eq!(snapshot.constraints, vec!["no secrets"]);
assert_eq!(snapshot.items.len(), 1);
assert_eq!(snapshot.items[0].step, "render sections");
assert_eq!(snapshot.items[0].status, StepStatus::InProgress);
}
#[test]
fn snapshot_serde_skips_empty_fields_and_deserializes_legacy() {
let snapshot = PlanSnapshot {
objective: Some("Ship PlanArtifact".to_string()),
items: vec![PlanItemArg {
step: "keep legacy replay working".to_string(),
status: StepStatus::Completed,
}],
..PlanSnapshot::default()
};
let value = serde_json::to_value(&snapshot).expect("serialize snapshot");
assert!(value.get("objective").is_some());
assert!(value.get("title").is_none());
assert!(value.get("sources_used").is_none());
assert!(value.get("constraints").is_none());
let legacy: PlanSnapshot = serde_json::from_value(json!({
"explanation": "Legacy explanation",
"items": [
{ "step": "legacy step", "status": "pending" }
]
}))
.expect("legacy snapshot should deserialize");
assert_eq!(legacy.explanation.as_deref(), Some("Legacy explanation"));
assert_eq!(legacy.items.len(), 1);
assert!(legacy.sources_used.is_empty());
}
#[tokio::test]
async fn legacy_update_plan_still_works() {
let state = new_shared_plan_state();
let tool = UpdatePlanTool::new(state.clone());
let context = ToolContext::new(std::env::temp_dir());
tool.execute(
json!({
"explanation": "Legacy shape",
"plan": [
{ "step": "inspect", "status": "completed" },
{ "step": "patch", "status": "in_progress" }
]
}),
&context,
)
.await
.expect("legacy update_plan should succeed");
let snapshot = state.lock().await.snapshot();
assert_eq!(snapshot.explanation.as_deref(), Some("Legacy shape"));
assert_eq!(snapshot.items.len(), 2);
assert_eq!(snapshot.items[0].status, StepStatus::Completed);
assert_eq!(snapshot.items[1].status, StepStatus::InProgress);
}
#[tokio::test]
async fn update_plan_tool_accepts_metadata_only_payload() {
let state = new_shared_plan_state();
let tool = UpdatePlanTool::new(state.clone());
let context = ToolContext::new(std::env::temp_dir());
let result = tool
.execute(
json!({
"objective": "Make Plan mode reviewable",
"sources_used": ["gh issue view 2691"],
"critical_files": ["crates/tui/src/tools/plan.rs"],
"verification_plan": "Run focused plan tests"
}),
&context,
)
.await
.expect("metadata-only update_plan should succeed");
assert!(result.content.contains("Make Plan mode reviewable"));
let snapshot = state.lock().await.snapshot();
assert!(!snapshot.is_empty());
assert!(snapshot.items.is_empty());
assert_eq!(
snapshot.critical_files,
vec!["crates/tui/src/tools/plan.rs"]
);
}
}