claude_agent/tools/
plan.rs

1//! Plan tool for structured planning workflow.
2
3use async_trait::async_trait;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use super::SchemaTool;
8use super::context::ExecutionContext;
9use crate::session::session_state::ToolState;
10use crate::types::ToolResult;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum PlanAction {
15    Start,
16    Complete,
17    Cancel,
18    Update,
19    Status,
20}
21
22#[derive(Debug, Deserialize, JsonSchema)]
23#[schemars(deny_unknown_fields)]
24pub struct PlanInput {
25    /// Action: "start", "complete", "cancel", "update", or "status"
26    pub action: PlanAction,
27    /// Plan name (optional, used with "start")
28    #[serde(default)]
29    pub name: Option<String>,
30    /// Plan content (optional, used with "update")
31    #[serde(default)]
32    pub content: Option<String>,
33}
34
35pub struct PlanTool {
36    state: ToolState,
37}
38
39impl PlanTool {
40    pub fn new(state: ToolState) -> Self {
41        Self { state }
42    }
43}
44
45#[async_trait]
46impl SchemaTool for PlanTool {
47    type Input = PlanInput;
48
49    const NAME: &'static str = "Plan";
50    const DESCRIPTION: &'static str = r#"Manage structured planning workflow for complex implementation tasks.
51
52## Actions
53
54- **start**: Begin plan mode for complex tasks (creates Draft plan)
55- **complete**: Finalize plan and proceed to implementation (approves plan)
56- **cancel**: Abort current plan
57- **update**: Update plan content while in plan mode
58- **status**: Check current plan state
59
60## When to Use Plan Mode
61
62Use `action: "start"` for implementation tasks unless they're simple:
63
641. **New Feature Implementation**: Adding meaningful new functionality
652. **Multiple Valid Approaches**: Task can be solved in several ways
663. **Code Modifications**: Changes affecting existing behavior
674. **Architectural Decisions**: Choosing between patterns or technologies
685. **Multi-File Changes**: Task touching more than 2-3 files
696. **Unclear Requirements**: Need exploration before understanding scope
70
71## When NOT to Use
72
73Skip for simple tasks:
74- Single-line fixes (typos, obvious bugs)
75- Adding a single function with clear requirements
76- Tasks with specific, detailed instructions
77- Pure research (use Task tool with Explore agent)
78
79## Workflow
80
811. Call with `action: "start"` to enter plan mode
822. Explore codebase using Glob, Grep, Read tools
833. Call with `action: "update"` to record your plan
844. Call with `action: "complete"` to finalize and proceed
85
86## Examples
87
88```json
89// Start planning
90{"action": "start", "name": "Add user authentication"}
91
92// Update plan content
93{"action": "update", "content": "1. Add JWT middleware\n2. Create auth routes"}
94
95// Complete and proceed
96{"action": "complete"}
97
98// Check status
99{"action": "status"}
100
101// Cancel if needed
102{"action": "cancel"}
103```
104
105## Integration
106
107- Use Plan for high-level approach and exploration
108- Use TodoWrite for granular task tracking during execution
109- Plan content persists across session compaction"#;
110
111    async fn handle(&self, input: PlanInput, _context: &ExecutionContext) -> ToolResult {
112        match input.action {
113            PlanAction::Start => self.start(input.name).await,
114            PlanAction::Complete => self.complete().await,
115            PlanAction::Cancel => self.cancel().await,
116            PlanAction::Update => self.update(input.content).await,
117            PlanAction::Status => self.status().await,
118        }
119    }
120}
121
122impl PlanTool {
123    async fn start(&self, name: Option<String>) -> ToolResult {
124        if self.state.is_in_plan_mode().await {
125            return ToolResult::error(
126                "Already in plan mode. Complete or cancel the current plan first.",
127            );
128        }
129
130        let plan = self.state.enter_plan_mode(name).await;
131        ToolResult::success(format!(
132            "Plan mode started.\n\
133            Plan ID: {}\n\
134            Status: {:?}\n\n\
135            Explore the codebase and design your approach.\n\
136            Use action: \"update\" to record your plan.\n\
137            Use action: \"complete\" when ready to proceed.",
138            plan.id, plan.status
139        ))
140    }
141
142    async fn complete(&self) -> ToolResult {
143        if !self.state.is_in_plan_mode().await {
144            return ToolResult::error("No active plan. Use action: \"start\" first.");
145        }
146
147        match self.state.exit_plan_mode().await {
148            Some(plan) => {
149                let content = if plan.content.is_empty() {
150                    "No plan content recorded.".to_string()
151                } else {
152                    plan.content.clone()
153                };
154
155                ToolResult::success(format!(
156                    "Plan completed.\n\
157                    Plan ID: {}\n\
158                    Name: {}\n\
159                    Status: {:?}\n\n\
160                    ## Content\n\n{}\n\n\
161                    Proceed with implementation.",
162                    plan.id,
163                    plan.name.as_deref().unwrap_or("Unnamed"),
164                    plan.status,
165                    content
166                ))
167            }
168            None => ToolResult::error("No active plan found."),
169        }
170    }
171
172    async fn cancel(&self) -> ToolResult {
173        if !self.state.is_in_plan_mode().await {
174            return ToolResult::error("No active plan to cancel.");
175        }
176
177        match self.state.cancel_plan().await {
178            Some(plan) => ToolResult::success(format!(
179                "Plan cancelled.\n\
180                Plan ID: {}\n\
181                Status: {:?}",
182                plan.id, plan.status
183            )),
184            None => ToolResult::error("No active plan found."),
185        }
186    }
187
188    async fn update(&self, content: Option<String>) -> ToolResult {
189        if !self.state.is_in_plan_mode().await {
190            return ToolResult::error("No active plan. Use action: \"start\" first.");
191        }
192
193        let content = match content {
194            Some(c) if !c.is_empty() => c,
195            _ => return ToolResult::error("Content is required for update action."),
196        };
197
198        self.state.update_plan_content(content.clone()).await;
199        ToolResult::success(format!(
200            "Plan content updated.\n\n## Content\n\n{}",
201            content
202        ))
203    }
204
205    async fn status(&self) -> ToolResult {
206        match self.state.current_plan().await {
207            Some(plan) => {
208                let content_preview = if plan.content.is_empty() {
209                    "No content recorded.".to_string()
210                } else if plan.content.len() > 500 {
211                    // Find valid UTF-8 char boundary at or before 500
212                    let mut end = 500;
213                    while !plan.content.is_char_boundary(end) && end > 0 {
214                        end -= 1;
215                    }
216                    format!("{}...", &plan.content[..end])
217                } else {
218                    plan.content.clone()
219                };
220
221                ToolResult::success(format!(
222                    "Plan Status\n\
223                    Plan ID: {}\n\
224                    Name: {}\n\
225                    Status: {:?}\n\
226                    Created: {}\n\n\
227                    ## Content Preview\n\n{}",
228                    plan.id,
229                    plan.name.as_deref().unwrap_or("Unnamed"),
230                    plan.status,
231                    plan.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
232                    content_preview
233                ))
234            }
235            None => ToolResult::success("No active plan."),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::session::SessionId;
244    use crate::tools::Tool;
245
246    fn test_context() -> ExecutionContext {
247        ExecutionContext::default()
248    }
249
250    #[tokio::test]
251    async fn test_plan_lifecycle() {
252        let tool_state = ToolState::new(SessionId::new());
253        let tool = PlanTool::new(tool_state);
254        let context = test_context();
255
256        // Start
257        let result = tool
258            .execute(
259                serde_json::json!({"action": "start", "name": "Test Plan"}),
260                &context,
261            )
262            .await;
263        assert!(!result.is_error());
264        assert!(result.text().contains("Plan mode started"));
265
266        // Update
267        let result = tool
268            .execute(
269                serde_json::json!({"action": "update", "content": "Step 1\nStep 2"}),
270                &context,
271            )
272            .await;
273        assert!(!result.is_error());
274        assert!(result.text().contains("Plan content updated"));
275
276        // Status
277        let result = tool
278            .execute(serde_json::json!({"action": "status"}), &context)
279            .await;
280        assert!(!result.is_error());
281        assert!(result.text().contains("Step 1"));
282
283        // Complete
284        let result = tool
285            .execute(serde_json::json!({"action": "complete"}), &context)
286            .await;
287        assert!(!result.is_error());
288        assert!(result.text().contains("Plan completed"));
289    }
290
291    #[tokio::test]
292    async fn test_plan_cancel() {
293        let tool_state = ToolState::new(SessionId::new());
294        let tool = PlanTool::new(tool_state);
295        let context = test_context();
296
297        // Start
298        let result = tool
299            .execute(serde_json::json!({"action": "start"}), &context)
300            .await;
301        assert!(!result.is_error());
302
303        // Cancel
304        let result = tool
305            .execute(serde_json::json!({"action": "cancel"}), &context)
306            .await;
307        assert!(!result.is_error());
308        assert!(result.text().contains("Plan cancelled"));
309
310        // Status after cancel
311        let result = tool
312            .execute(serde_json::json!({"action": "status"}), &context)
313            .await;
314        assert!(result.text().contains("No active plan"));
315    }
316
317    #[tokio::test]
318    async fn test_double_start_rejected() {
319        let tool_state = ToolState::new(SessionId::new());
320        let tool = PlanTool::new(tool_state);
321        let context = test_context();
322
323        let _ = tool
324            .execute(serde_json::json!({"action": "start"}), &context)
325            .await;
326
327        let result = tool
328            .execute(serde_json::json!({"action": "start"}), &context)
329            .await;
330        assert!(result.is_error());
331        assert!(result.text().contains("Already in plan mode"));
332    }
333
334    #[tokio::test]
335    async fn test_complete_without_start() {
336        let tool_state = ToolState::new(SessionId::new());
337        let tool = PlanTool::new(tool_state);
338        let context = test_context();
339
340        let result = tool
341            .execute(serde_json::json!({"action": "complete"}), &context)
342            .await;
343        assert!(result.is_error());
344        assert!(result.text().contains("No active plan"));
345    }
346
347    #[tokio::test]
348    async fn test_update_requires_content() {
349        let tool_state = ToolState::new(SessionId::new());
350        let tool = PlanTool::new(tool_state);
351        let context = test_context();
352
353        let _ = tool
354            .execute(serde_json::json!({"action": "start"}), &context)
355            .await;
356
357        let result = tool
358            .execute(serde_json::json!({"action": "update"}), &context)
359            .await;
360        assert!(result.is_error());
361        assert!(result.text().contains("Content is required"));
362    }
363}