Skip to main content

bamboo_tools/tools/
exit_plan_mode.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5
6#[derive(Debug, Deserialize)]
7struct ExitPlanModeArgs {
8    plan: String,
9    #[serde(default)]
10    exit_mode: Option<String>,
11}
12
13pub struct ExitPlanModeTool;
14
15impl ExitPlanModeTool {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl Default for ExitPlanModeTool {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27#[async_trait]
28impl Tool for ExitPlanModeTool {
29    fn name(&self) -> &str {
30        "ExitPlanMode"
31    }
32
33    fn description(&self) -> &str {
34        "Prompt the user to confirm exiting plan mode and moving to implementation"
35    }
36
37    fn parameters_schema(&self) -> serde_json::Value {
38        json!({
39            "type": "object",
40            "properties": {
41                "plan": {
42                    "type": "string",
43                    "description": "The plan to present to the user for approval"
44                },
45                "exit_mode": {
46                    "type": "string",
47                    "description": "Suggested permission mode after exiting plan mode: 'default', 'accept_edits', 'dont_ask', or 'bypass_permissions'"
48                }
49            },
50            "required": ["plan"],
51            "additionalProperties": false
52        })
53    }
54
55    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
56        let parsed: ExitPlanModeArgs = serde_json::from_value(args).map_err(|e| {
57            ToolError::InvalidArguments(format!("Invalid ExitPlanMode args: {}", e))
58        })?;
59
60        // Build options based on suggested exit_mode
61        let options = match parsed.exit_mode.as_deref() {
62            Some("accept_edits") => vec![
63                "Approve (Accept edits mode)",
64                "Approve (Default mode)",
65                "Stay in plan mode",
66                "Edit plan first",
67            ],
68            Some("dont_ask") => vec![
69                "Approve (Don't ask mode)",
70                "Approve (Default mode)",
71                "Stay in plan mode",
72                "Edit plan first",
73            ],
74            Some("bypass_permissions") => vec![
75                "Approve (Bypass permissions)",
76                "Approve (Default mode)",
77                "Stay in plan mode",
78                "Edit plan first",
79            ],
80            _ => vec![
81                "Approve (Default mode)",
82                "Approve (Accept edits mode)",
83                "Stay in plan mode",
84                "Edit plan first",
85            ],
86        };
87
88        let question = if parsed.plan.trim().is_empty() {
89            "Plan ready. Exit plan mode and start implementation?"
90        } else {
91            "Plan ready. Review the plan below and approve to exit plan mode and start implementation."
92        };
93
94        let payload = json!({
95            "status": "awaiting_user_input",
96            "question": question,
97            "options": options,
98            "allow_custom": false,
99            "plan": parsed.plan,
100            "exit_mode": parsed.exit_mode,
101        });
102
103        Ok(ToolResult {
104            success: true,
105            result: payload.to_string(),
106            display_preference: Some("conclusion_with_options".to_string()),
107        })
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use serde_json::json;
115
116    #[test]
117    fn exit_plan_mode_has_correct_name() {
118        let tool = ExitPlanModeTool::new();
119        assert_eq!(tool.name(), "ExitPlanMode");
120    }
121
122    #[test]
123    fn exit_plan_mode_has_description() {
124        let tool = ExitPlanModeTool::new();
125        assert!(!tool.description().is_empty());
126        assert!(tool.description().contains("plan"));
127    }
128
129    #[test]
130    fn exit_plan_mode_parameters_schema_has_required_fields() {
131        let tool = ExitPlanModeTool::new();
132        let schema = tool.parameters_schema();
133
134        assert_eq!(schema["type"], "object");
135        assert!(schema["properties"]["plan"].is_object());
136        assert_eq!(schema["properties"]["plan"]["type"], "string");
137        assert!(schema["required"]
138            .as_array()
139            .unwrap()
140            .contains(&json!("plan")));
141        assert_eq!(schema["additionalProperties"], false);
142    }
143
144    #[tokio::test]
145    async fn exit_plan_mode_accepts_valid_plan() {
146        let tool = ExitPlanModeTool::new();
147        let result = tool
148            .execute(json!({
149                "plan": "Implement feature X"
150            }))
151            .await
152            .unwrap();
153
154        assert!(result.success);
155
156        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
157        assert_eq!(payload["status"], "awaiting_user_input");
158        assert!(payload["question"].as_str().unwrap().contains("Plan ready"));
159        let options = payload["options"].as_array().unwrap();
160        assert_eq!(options.len(), 4);
161        assert!(options.contains(&json!("Approve (Default mode)")));
162        assert!(options.contains(&json!("Stay in plan mode")));
163        assert!(options.contains(&json!("Edit plan first")));
164        assert_eq!(payload["allow_custom"], false);
165        assert_eq!(payload["plan"], "Implement feature X");
166    }
167
168    #[tokio::test]
169    async fn exit_plan_mode_includes_plan_in_payload() {
170        let tool = ExitPlanModeTool::new();
171        let plan_text = "1. Read config\n2. Update database\n3. Deploy changes";
172        let result = tool
173            .execute(json!({
174                "plan": plan_text
175            }))
176            .await
177            .unwrap();
178
179        assert!(result.success);
180        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
181        assert_eq!(payload["plan"], plan_text);
182    }
183
184    #[tokio::test]
185    async fn exit_plan_mode_sets_display_preference_to_conclusion_with_options() {
186        let tool = ExitPlanModeTool::new();
187        let result = tool
188            .execute(json!({
189                "plan": "Test plan"
190            }))
191            .await
192            .unwrap();
193
194        assert_eq!(
195            result.display_preference,
196            Some("conclusion_with_options".to_string())
197        );
198    }
199
200    #[tokio::test]
201    async fn exit_plan_mode_rejects_missing_plan() {
202        let tool = ExitPlanModeTool::new();
203        let result = tool.execute(json!({})).await;
204
205        assert!(result.is_err());
206        let error = result.unwrap_err();
207        assert!(matches!(error, ToolError::InvalidArguments(_)));
208    }
209
210    #[tokio::test]
211    async fn exit_plan_mode_rejects_invalid_plan_type() {
212        let tool = ExitPlanModeTool::new();
213        let result = tool
214            .execute(json!({
215                "plan": 123
216            }))
217            .await;
218
219        assert!(result.is_err());
220        let error = result.unwrap_err();
221        if let ToolError::InvalidArguments(msg) = error {
222            assert!(msg.contains("Invalid ExitPlanMode args"));
223        } else {
224            panic!("Expected InvalidArguments error");
225        }
226    }
227
228    #[tokio::test]
229    async fn exit_plan_mode_rejects_null_plan() {
230        let tool = ExitPlanModeTool::new();
231        let result = tool
232            .execute(json!({
233                "plan": null
234            }))
235            .await;
236
237        assert!(result.is_err());
238    }
239
240    #[tokio::test]
241    async fn exit_plan_mode_accepts_empty_plan_string() {
242        // Empty string is technically valid
243        let tool = ExitPlanModeTool::new();
244        let result = tool
245            .execute(json!({
246                "plan": ""
247            }))
248            .await
249            .unwrap();
250
251        assert!(result.success);
252        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
253        assert_eq!(payload["plan"], "");
254    }
255
256    #[tokio::test]
257    async fn exit_plan_mode_accepts_multiline_plan() {
258        let tool = ExitPlanModeTool::new();
259        let multiline_plan = "Step 1: Setup\nStep 2: Execute\nStep 3: Cleanup";
260        let result = tool
261            .execute(json!({
262                "plan": multiline_plan
263            }))
264            .await
265            .unwrap();
266
267        assert!(result.success);
268        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
269        assert_eq!(payload["plan"], multiline_plan);
270    }
271
272    #[tokio::test]
273    async fn exit_plan_mode_accepts_markdown_plan() {
274        let tool = ExitPlanModeTool::new();
275        let markdown_plan = r#"# Implementation Plan
276
277## Phase 1
278- Task A
279- Task B
280
281## Phase 2
282- Task C
283"#;
284        let result = tool
285            .execute(json!({
286                "plan": markdown_plan
287            }))
288            .await
289            .unwrap();
290
291        assert!(result.success);
292        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
293        assert_eq!(payload["plan"], markdown_plan);
294    }
295
296    #[tokio::test]
297    async fn exit_plan_mode_accepts_unicode_plan() {
298        let tool = ExitPlanModeTool::new();
299        let unicode_plan = "实施计划 🎯\n1. 读取配置\n2. 更新数据库";
300        let result = tool
301            .execute(json!({
302                "plan": unicode_plan
303            }))
304            .await
305            .unwrap();
306
307        assert!(result.success);
308        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
309        assert_eq!(payload["plan"], unicode_plan);
310    }
311
312    #[tokio::test]
313    async fn exit_plan_mode_ignores_extra_fields() {
314        let tool = ExitPlanModeTool::new();
315        // serde_json with additionalProperties: false may not strictly reject extra fields
316        // during deserialization, so this test verifies the behavior
317        let result = tool
318            .execute(json!({
319                "plan": "Test plan",
320                "extra_field": "should be ignored"
321            }))
322            .await;
323
324        // Depending on serde configuration, this might succeed (ignoring extra fields)
325        // or fail (rejecting extra fields). The test documents the actual behavior.
326        if let Ok(tool_result) = result {
327            // If it succeeds, verify the plan was captured correctly
328            assert!(tool_result.success);
329            let payload: serde_json::Value = serde_json::from_str(&tool_result.result).unwrap();
330            assert_eq!(payload["plan"], "Test plan");
331        } else {
332            // If it fails, verify it's an InvalidArguments error
333            let error = result.unwrap_err();
334            assert!(matches!(error, ToolError::InvalidArguments(_)));
335        }
336    }
337
338    #[tokio::test]
339    async fn exit_plan_mode_payload_has_correct_structure() {
340        let tool = ExitPlanModeTool::new();
341        let result = tool
342            .execute(json!({
343                "plan": "Test"
344            }))
345            .await
346            .unwrap();
347
348        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
349
350        // Verify all expected fields are present
351        assert!(payload.is_object());
352        assert!(payload.get("status").is_some());
353        assert!(payload.get("question").is_some());
354        assert!(payload.get("options").is_some());
355        assert!(payload.get("allow_custom").is_some());
356        assert!(payload.get("plan").is_some());
357
358        // Verify types
359        assert!(payload["status"].is_string());
360        assert!(payload["question"].is_string());
361        assert!(payload["options"].is_array());
362        assert!(payload["allow_custom"].is_boolean());
363        assert!(payload["plan"].is_string());
364    }
365
366    #[tokio::test]
367    async fn exit_plan_mode_options_has_four_choices_by_default() {
368        let tool = ExitPlanModeTool::new();
369        let result = tool
370            .execute(json!({
371                "plan": "Test"
372            }))
373            .await
374            .unwrap();
375
376        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
377        let options = payload["options"].as_array().unwrap();
378
379        assert_eq!(options.len(), 4);
380        assert!(options.contains(&json!("Approve (Default mode)")));
381        assert!(options.contains(&json!("Approve (Accept edits mode)")));
382        assert!(options.contains(&json!("Stay in plan mode")));
383        assert!(options.contains(&json!("Edit plan first")));
384    }
385
386    #[tokio::test]
387    async fn exit_plan_mode_with_accept_edits_exit_mode() {
388        let tool = ExitPlanModeTool::new();
389        let result = tool
390            .execute(json!({
391                "plan": "Test",
392                "exit_mode": "accept_edits"
393            }))
394            .await
395            .unwrap();
396
397        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
398        let options = payload["options"].as_array().unwrap();
399        assert!(options.contains(&json!("Approve (Accept edits mode)")));
400        assert!(options[0] == "Approve (Accept edits mode)"); // First option
401    }
402
403    #[tokio::test]
404    async fn exit_plan_mode_empty_plan_changes_question() {
405        let tool = ExitPlanModeTool::new();
406        let result = tool
407            .execute(json!({
408                "plan": ""
409            }))
410            .await
411            .unwrap();
412
413        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
414        assert!(payload["question"]
415            .as_str()
416            .unwrap()
417            .contains("start implementation"));
418    }
419
420    #[test]
421    fn exit_plan_mode_default_impl() {
422        let tool = ExitPlanModeTool::default();
423        assert_eq!(tool.name(), "ExitPlanMode");
424    }
425
426    #[tokio::test]
427    async fn exit_plan_mode_long_plan() {
428        let tool = ExitPlanModeTool::new();
429        let long_plan = "Step\n".repeat(1000);
430        let result = tool
431            .execute(json!({
432                "plan": long_plan.clone()
433            }))
434            .await
435            .unwrap();
436
437        assert!(result.success);
438        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
439        assert_eq!(payload["plan"], long_plan);
440    }
441}