1use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use sgr_agent_core::agent_tool::{Tool, ToolError, ToolOutput, parse_args};
13use sgr_agent_core::context::AgentContext;
14use sgr_agent_core::schema::json_schema_for;
15
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18pub struct PlanStep {
19 pub step: String,
21 pub status: String,
23}
24
25impl PlanStep {
26 fn checkbox(&self) -> &str {
27 match self.status.as_str() {
28 "completed" => "[x]",
29 "in_progress" => "[~]",
30 _ => "[ ]",
31 }
32 }
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct PlanState {
39 pub steps: Vec<PlanStep>,
40 pub explanation: Option<String>,
41}
42
43impl PlanState {
44 pub fn summary(&self) -> String {
46 let done = self
47 .steps
48 .iter()
49 .filter(|s| s.status == "completed")
50 .count();
51 let total = self.steps.len();
52 let current = self.steps.iter().find(|s| s.status == "in_progress");
53 match current {
54 Some(s) => format!("{done}/{total} done — {}", s.step),
55 None if done == total && total > 0 => format!("{done}/{total} done"),
56 _ => format!("{done}/{total} steps"),
57 }
58 }
59
60 pub fn to_markdown(&self) -> String {
62 let mut md = String::from("# Plan\n\n");
63 if let Some(ref explanation) = self.explanation {
64 md.push_str(&format!("{explanation}\n\n"));
65 }
66 md.push_str("## Tasks\n\n");
67 for (i, step) in self.steps.iter().enumerate() {
68 md.push_str(&format!(
69 "- {} Task {}: {}\n",
70 step.checkbox(),
71 i + 1,
72 step.step
73 ));
74 }
75 md
76 }
77}
78
79#[derive(Deserialize, JsonSchema)]
80struct UpdatePlanArgs {
81 #[serde(default)]
83 explanation: Option<String>,
84 plan: Vec<PlanStep>,
86}
87
88pub struct UpdatePlanTool;
92
93#[async_trait::async_trait]
94impl Tool for UpdatePlanTool {
95 fn name(&self) -> &str {
96 "update_plan"
97 }
98 fn description(&self) -> &str {
99 "Update the task plan checklist. Provide steps with status (pending/in_progress/completed). \
100 At most one step should be in_progress at a time. Plan is saved to plan.md."
101 }
102 fn is_system(&self) -> bool {
103 true
104 }
105 fn parameters_schema(&self) -> Value {
106 json_schema_for::<UpdatePlanArgs>()
107 }
108
109 async fn execute(&self, args: Value, ctx: &mut AgentContext) -> Result<ToolOutput, ToolError> {
110 let a: UpdatePlanArgs = parse_args(&args)?;
111 let state = PlanState {
112 steps: a.plan,
113 explanation: a.explanation,
114 };
115
116 let plan_path = ctx.cwd.join("plan.md");
118 let md = state.to_markdown();
119 std::fs::write(&plan_path, &md)
120 .map_err(|e| ToolError::Execution(format!("write plan.md: {e}")))?;
121
122 let summary = state.summary();
124 ctx.insert(state);
125
126 Ok(ToolOutput::text(format!(
127 "Plan updated: {summary} (saved to plan.md)"
128 )))
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[tokio::test]
137 async fn test_update_plan() {
138 let tool = UpdatePlanTool;
139 let tmp = std::env::temp_dir().join("sgr_plan_test");
140 let _ = std::fs::create_dir_all(&tmp);
141 let mut ctx = AgentContext::new().with_cwd(&tmp);
142
143 let result = tool
144 .execute(
145 serde_json::json!({
146 "plan": [
147 {"step": "Read file", "status": "completed"},
148 {"step": "Fix bug", "status": "in_progress"},
149 {"step": "Run tests", "status": "pending"}
150 ]
151 }),
152 &mut ctx,
153 )
154 .await
155 .unwrap();
156
157 assert!(result.content.contains("1/3 done"));
158 assert!(result.content.contains("Fix bug"));
159 assert!(result.content.contains("plan.md"));
160
161 let state = ctx.get_typed::<PlanState>().unwrap();
163 assert_eq!(state.steps.len(), 3);
164
165 let md = std::fs::read_to_string(tmp.join("plan.md")).unwrap();
167 assert!(md.contains("[x] Task 1: Read file"));
168 assert!(md.contains("[~] Task 2: Fix bug"));
169 assert!(md.contains("[ ] Task 3: Run tests"));
170
171 let _ = std::fs::remove_dir_all(&tmp);
172 }
173
174 #[test]
175 fn plan_to_markdown() {
176 let state = PlanState {
177 steps: vec![
178 PlanStep {
179 step: "A".into(),
180 status: "completed".into(),
181 },
182 PlanStep {
183 step: "B".into(),
184 status: "in_progress".into(),
185 },
186 PlanStep {
187 step: "C".into(),
188 status: "pending".into(),
189 },
190 ],
191 explanation: Some("Fix the auth bug".into()),
192 };
193 let md = state.to_markdown();
194 assert!(md.contains("# Plan"));
195 assert!(md.contains("Fix the auth bug"));
196 assert!(md.contains("- [x] Task 1: A"));
197 assert!(md.contains("- [~] Task 2: B"));
198 assert!(md.contains("- [ ] Task 3: C"));
199 }
200
201 #[test]
202 fn plan_summary() {
203 let state = PlanState {
204 steps: vec![
205 PlanStep {
206 step: "A".into(),
207 status: "completed".into(),
208 },
209 PlanStep {
210 step: "B".into(),
211 status: "completed".into(),
212 },
213 PlanStep {
214 step: "C".into(),
215 status: "pending".into(),
216 },
217 ],
218 explanation: None,
219 };
220 assert_eq!(state.summary(), "2/3 steps");
221 }
222}