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            images: Vec::new(),
108        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde_json::json;
116
117    #[test]
118    fn exit_plan_mode_has_correct_name() {
119        let tool = ExitPlanModeTool::new();
120        assert_eq!(tool.name(), "ExitPlanMode");
121    }
122
123    #[test]
124    fn exit_plan_mode_has_description() {
125        let tool = ExitPlanModeTool::new();
126        assert!(!tool.description().is_empty());
127        assert!(tool.description().contains("plan"));
128    }
129
130    #[test]
131    fn exit_plan_mode_parameters_schema_has_required_fields() {
132        let tool = ExitPlanModeTool::new();
133        let schema = tool.parameters_schema();
134
135        assert_eq!(schema["type"], "object");
136        assert!(schema["properties"]["plan"].is_object());
137        assert_eq!(schema["properties"]["plan"]["type"], "string");
138        assert!(schema["required"]
139            .as_array()
140            .unwrap()
141            .contains(&json!("plan")));
142        assert_eq!(schema["additionalProperties"], false);
143    }
144
145    #[tokio::test]
146    async fn exit_plan_mode_accepts_valid_plan() {
147        let tool = ExitPlanModeTool::new();
148        let result = tool
149            .execute(json!({
150                "plan": "Implement feature X"
151            }))
152            .await
153            .unwrap();
154
155        assert!(result.success);
156
157        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
158        assert_eq!(payload["status"], "awaiting_user_input");
159        assert!(payload["question"].as_str().unwrap().contains("Plan ready"));
160        let options = payload["options"].as_array().unwrap();
161        assert_eq!(options.len(), 4);
162        assert!(options.contains(&json!("Approve (Default mode)")));
163        assert!(options.contains(&json!("Stay in plan mode")));
164        assert!(options.contains(&json!("Edit plan first")));
165        assert_eq!(payload["allow_custom"], false);
166        assert_eq!(payload["plan"], "Implement feature X");
167    }
168
169    #[tokio::test]
170    async fn exit_plan_mode_includes_plan_in_payload() {
171        let tool = ExitPlanModeTool::new();
172        let plan_text = "1. Read config\n2. Update database\n3. Deploy changes";
173        let result = tool
174            .execute(json!({
175                "plan": plan_text
176            }))
177            .await
178            .unwrap();
179
180        assert!(result.success);
181        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
182        assert_eq!(payload["plan"], plan_text);
183    }
184
185    #[tokio::test]
186    async fn exit_plan_mode_sets_display_preference_to_conclusion_with_options() {
187        let tool = ExitPlanModeTool::new();
188        let result = tool
189            .execute(json!({
190                "plan": "Test plan"
191            }))
192            .await
193            .unwrap();
194
195        assert_eq!(
196            result.display_preference,
197            Some("conclusion_with_options".to_string())
198        );
199    }
200
201    #[tokio::test]
202    async fn exit_plan_mode_rejects_missing_plan() {
203        let tool = ExitPlanModeTool::new();
204        let result = tool.execute(json!({})).await;
205
206        assert!(result.is_err());
207        let error = result.unwrap_err();
208        assert!(matches!(error, ToolError::InvalidArguments(_)));
209    }
210
211    #[tokio::test]
212    async fn exit_plan_mode_rejects_invalid_plan_type() {
213        let tool = ExitPlanModeTool::new();
214        let result = tool
215            .execute(json!({
216                "plan": 123
217            }))
218            .await;
219
220        assert!(result.is_err());
221        let error = result.unwrap_err();
222        if let ToolError::InvalidArguments(msg) = error {
223            assert!(msg.contains("Invalid ExitPlanMode args"));
224        } else {
225            panic!("Expected InvalidArguments error");
226        }
227    }
228
229    #[tokio::test]
230    async fn exit_plan_mode_rejects_null_plan() {
231        let tool = ExitPlanModeTool::new();
232        let result = tool
233            .execute(json!({
234                "plan": null
235            }))
236            .await;
237
238        assert!(result.is_err());
239    }
240
241    #[tokio::test]
242    async fn exit_plan_mode_accepts_empty_plan_string() {
243        // Empty string is technically valid
244        let tool = ExitPlanModeTool::new();
245        let result = tool
246            .execute(json!({
247                "plan": ""
248            }))
249            .await
250            .unwrap();
251
252        assert!(result.success);
253        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
254        assert_eq!(payload["plan"], "");
255    }
256
257    #[tokio::test]
258    async fn exit_plan_mode_accepts_multiline_plan() {
259        let tool = ExitPlanModeTool::new();
260        let multiline_plan = "Step 1: Setup\nStep 2: Execute\nStep 3: Cleanup";
261        let result = tool
262            .execute(json!({
263                "plan": multiline_plan
264            }))
265            .await
266            .unwrap();
267
268        assert!(result.success);
269        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
270        assert_eq!(payload["plan"], multiline_plan);
271    }
272
273    #[tokio::test]
274    async fn exit_plan_mode_accepts_markdown_plan() {
275        let tool = ExitPlanModeTool::new();
276        let markdown_plan = r#"# Implementation Plan
277
278## Phase 1
279- Task A
280- Task B
281
282## Phase 2
283- Task C
284"#;
285        let result = tool
286            .execute(json!({
287                "plan": markdown_plan
288            }))
289            .await
290            .unwrap();
291
292        assert!(result.success);
293        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
294        assert_eq!(payload["plan"], markdown_plan);
295    }
296
297    #[tokio::test]
298    async fn exit_plan_mode_accepts_unicode_plan() {
299        let tool = ExitPlanModeTool::new();
300        let unicode_plan = "实施计划 🎯\n1. 读取配置\n2. 更新数据库";
301        let result = tool
302            .execute(json!({
303                "plan": unicode_plan
304            }))
305            .await
306            .unwrap();
307
308        assert!(result.success);
309        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
310        assert_eq!(payload["plan"], unicode_plan);
311    }
312
313    #[tokio::test]
314    async fn exit_plan_mode_ignores_extra_fields() {
315        let tool = ExitPlanModeTool::new();
316        // serde_json with additionalProperties: false may not strictly reject extra fields
317        // during deserialization, so this test verifies the behavior
318        let result = tool
319            .execute(json!({
320                "plan": "Test plan",
321                "extra_field": "should be ignored"
322            }))
323            .await;
324
325        // Depending on serde configuration, this might succeed (ignoring extra fields)
326        // or fail (rejecting extra fields). The test documents the actual behavior.
327        if let Ok(tool_result) = result {
328            // If it succeeds, verify the plan was captured correctly
329            assert!(tool_result.success);
330            let payload: serde_json::Value = serde_json::from_str(&tool_result.result).unwrap();
331            assert_eq!(payload["plan"], "Test plan");
332        } else {
333            // If it fails, verify it's an InvalidArguments error
334            let error = result.unwrap_err();
335            assert!(matches!(error, ToolError::InvalidArguments(_)));
336        }
337    }
338
339    #[tokio::test]
340    async fn exit_plan_mode_payload_has_correct_structure() {
341        let tool = ExitPlanModeTool::new();
342        let result = tool
343            .execute(json!({
344                "plan": "Test"
345            }))
346            .await
347            .unwrap();
348
349        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
350
351        // Verify all expected fields are present
352        assert!(payload.is_object());
353        assert!(payload.get("status").is_some());
354        assert!(payload.get("question").is_some());
355        assert!(payload.get("options").is_some());
356        assert!(payload.get("allow_custom").is_some());
357        assert!(payload.get("plan").is_some());
358
359        // Verify types
360        assert!(payload["status"].is_string());
361        assert!(payload["question"].is_string());
362        assert!(payload["options"].is_array());
363        assert!(payload["allow_custom"].is_boolean());
364        assert!(payload["plan"].is_string());
365    }
366
367    #[tokio::test]
368    async fn exit_plan_mode_options_has_four_choices_by_default() {
369        let tool = ExitPlanModeTool::new();
370        let result = tool
371            .execute(json!({
372                "plan": "Test"
373            }))
374            .await
375            .unwrap();
376
377        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
378        let options = payload["options"].as_array().unwrap();
379
380        assert_eq!(options.len(), 4);
381        assert!(options.contains(&json!("Approve (Default mode)")));
382        assert!(options.contains(&json!("Approve (Accept edits mode)")));
383        assert!(options.contains(&json!("Stay in plan mode")));
384        assert!(options.contains(&json!("Edit plan first")));
385    }
386
387    #[tokio::test]
388    async fn exit_plan_mode_with_accept_edits_exit_mode() {
389        let tool = ExitPlanModeTool::new();
390        let result = tool
391            .execute(json!({
392                "plan": "Test",
393                "exit_mode": "accept_edits"
394            }))
395            .await
396            .unwrap();
397
398        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
399        let options = payload["options"].as_array().unwrap();
400        assert!(options.contains(&json!("Approve (Accept edits mode)")));
401        assert!(options[0] == "Approve (Accept edits mode)"); // First option
402    }
403
404    #[tokio::test]
405    async fn exit_plan_mode_empty_plan_changes_question() {
406        let tool = ExitPlanModeTool::new();
407        let result = tool
408            .execute(json!({
409                "plan": ""
410            }))
411            .await
412            .unwrap();
413
414        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
415        assert!(payload["question"]
416            .as_str()
417            .unwrap()
418            .contains("start implementation"));
419    }
420
421    #[test]
422    fn exit_plan_mode_default_impl() {
423        let tool = ExitPlanModeTool;
424        assert_eq!(tool.name(), "ExitPlanMode");
425    }
426
427    #[tokio::test]
428    async fn exit_plan_mode_long_plan() {
429        let tool = ExitPlanModeTool::new();
430        let long_plan = "Step\n".repeat(1000);
431        let result = tool
432            .execute(json!({
433                "plan": long_plan.clone()
434            }))
435            .await
436            .unwrap();
437
438        assert!(result.success);
439        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
440        assert_eq!(payload["plan"], long_plan);
441    }
442}