Skip to main content

bamboo_tools/tools/
exit_plan_mode.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, 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', 'bypass_permissions', or 'auto'"
48                }
49            },
50            "required": ["plan"],
51            "additionalProperties": false
52        })
53    }
54
55    async fn invoke(
56        &self,
57        args: serde_json::Value,
58        _ctx: ToolCtx,
59    ) -> Result<ToolOutcome, ToolError> {
60        let parsed: ExitPlanModeArgs = serde_json::from_value(args).map_err(|e| {
61            ToolError::InvalidArguments(format!("Invalid ExitPlanMode args: {}", e))
62        })?;
63
64        // Build options based on suggested exit_mode
65        let options = match parsed.exit_mode.as_deref() {
66            Some("accept_edits") => vec![
67                "Approve (Accept edits mode)",
68                "Approve (Default mode)",
69                "Stay in plan mode",
70                "Edit plan first",
71            ],
72            Some("dont_ask") => vec![
73                "Approve (Don't ask mode)",
74                "Approve (Default mode)",
75                "Stay in plan mode",
76                "Edit plan first",
77            ],
78            Some("bypass_permissions") => vec![
79                "Approve (Bypass permissions)",
80                "Approve (Default mode)",
81                "Stay in plan mode",
82                "Edit plan first",
83            ],
84            Some("auto") => vec![
85                "Approve (Auto mode)",
86                "Approve (Default mode)",
87                "Stay in plan mode",
88                "Edit plan first",
89            ],
90            _ => vec![
91                "Approve (Default mode)",
92                "Approve (Accept edits mode)",
93                "Stay in plan mode",
94                "Edit plan first",
95            ],
96        };
97
98        let question = if parsed.plan.trim().is_empty() {
99            "Plan ready. Exit plan mode and start implementation?"
100        } else {
101            "Plan ready. Review the plan below and approve to exit plan mode and start implementation."
102        };
103
104        let payload = json!({
105            "status": "awaiting_user_input",
106            "question": question,
107            "options": options,
108            "allow_custom": false,
109            "plan": parsed.plan,
110            "exit_mode": parsed.exit_mode,
111        });
112
113        Ok(ToolOutcome::Completed(ToolResult {
114            success: true,
115            result: payload.to_string(),
116            display_preference: Some("conclusion_with_options".to_string()),
117            images: Vec::new(),
118        }))
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use serde_json::json;
126
127    #[test]
128    fn exit_plan_mode_has_correct_name() {
129        let tool = ExitPlanModeTool::new();
130        assert_eq!(tool.name(), "ExitPlanMode");
131    }
132
133    #[test]
134    fn exit_plan_mode_has_description() {
135        let tool = ExitPlanModeTool::new();
136        assert!(!tool.description().is_empty());
137        assert!(tool.description().contains("plan"));
138    }
139
140    #[test]
141    fn exit_plan_mode_parameters_schema_has_required_fields() {
142        let tool = ExitPlanModeTool::new();
143        let schema = tool.parameters_schema();
144
145        assert_eq!(schema["type"], "object");
146        assert!(schema["properties"]["plan"].is_object());
147        assert_eq!(schema["properties"]["plan"]["type"], "string");
148        assert!(schema["required"]
149            .as_array()
150            .unwrap()
151            .contains(&json!("plan")));
152        assert_eq!(schema["additionalProperties"], false);
153    }
154
155    #[tokio::test]
156    async fn exit_plan_mode_accepts_valid_plan() {
157        let tool = ExitPlanModeTool::new();
158        let out = tool
159            .invoke(
160                json!({
161                    "plan": "Implement feature X"
162                }),
163                ToolCtx::none("t"),
164            )
165            .await
166            .unwrap();
167        let ToolOutcome::Completed(result) = out else {
168            panic!("expected Completed")
169        };
170
171        assert!(result.success);
172
173        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
174        assert_eq!(payload["status"], "awaiting_user_input");
175        assert!(payload["question"].as_str().unwrap().contains("Plan ready"));
176        let options = payload["options"].as_array().unwrap();
177        assert_eq!(options.len(), 4);
178        assert!(options.contains(&json!("Approve (Default mode)")));
179        assert!(options.contains(&json!("Stay in plan mode")));
180        assert!(options.contains(&json!("Edit plan first")));
181        assert_eq!(payload["allow_custom"], false);
182        assert_eq!(payload["plan"], "Implement feature X");
183    }
184
185    #[tokio::test]
186    async fn exit_plan_mode_includes_plan_in_payload() {
187        let tool = ExitPlanModeTool::new();
188        let plan_text = "1. Read config\n2. Update database\n3. Deploy changes";
189        let out = tool
190            .invoke(
191                json!({
192                    "plan": plan_text
193                }),
194                ToolCtx::none("t"),
195            )
196            .await
197            .unwrap();
198        let ToolOutcome::Completed(result) = out else {
199            panic!("expected Completed")
200        };
201
202        assert!(result.success);
203        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
204        assert_eq!(payload["plan"], plan_text);
205    }
206
207    #[tokio::test]
208    async fn exit_plan_mode_sets_display_preference_to_conclusion_with_options() {
209        let tool = ExitPlanModeTool::new();
210        let out = tool
211            .invoke(
212                json!({
213                    "plan": "Test plan"
214                }),
215                ToolCtx::none("t"),
216            )
217            .await
218            .unwrap();
219        let ToolOutcome::Completed(result) = out else {
220            panic!("expected Completed")
221        };
222
223        assert_eq!(
224            result.display_preference,
225            Some("conclusion_with_options".to_string())
226        );
227    }
228
229    #[tokio::test]
230    async fn exit_plan_mode_rejects_missing_plan() {
231        let tool = ExitPlanModeTool::new();
232        let result = tool.invoke(json!({}), ToolCtx::none("t")).await;
233
234        assert!(result.is_err());
235        let error = result.unwrap_err();
236        assert!(matches!(error, ToolError::InvalidArguments(_)));
237    }
238
239    #[tokio::test]
240    async fn exit_plan_mode_rejects_invalid_plan_type() {
241        let tool = ExitPlanModeTool::new();
242        let result = tool
243            .invoke(
244                json!({
245                    "plan": 123
246                }),
247                ToolCtx::none("t"),
248            )
249            .await;
250
251        assert!(result.is_err());
252        let error = result.unwrap_err();
253        if let ToolError::InvalidArguments(msg) = error {
254            assert!(msg.contains("Invalid ExitPlanMode args"));
255        } else {
256            panic!("Expected InvalidArguments error");
257        }
258    }
259
260    #[tokio::test]
261    async fn exit_plan_mode_rejects_null_plan() {
262        let tool = ExitPlanModeTool::new();
263        let result = tool
264            .invoke(
265                json!({
266                    "plan": null
267                }),
268                ToolCtx::none("t"),
269            )
270            .await;
271
272        assert!(result.is_err());
273    }
274
275    #[tokio::test]
276    async fn exit_plan_mode_accepts_empty_plan_string() {
277        // Empty string is technically valid
278        let tool = ExitPlanModeTool::new();
279        let out = tool
280            .invoke(
281                json!({
282                    "plan": ""
283                }),
284                ToolCtx::none("t"),
285            )
286            .await
287            .unwrap();
288        let ToolOutcome::Completed(result) = out else {
289            panic!("expected Completed")
290        };
291
292        assert!(result.success);
293        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
294        assert_eq!(payload["plan"], "");
295    }
296
297    #[tokio::test]
298    async fn exit_plan_mode_accepts_multiline_plan() {
299        let tool = ExitPlanModeTool::new();
300        let multiline_plan = "Step 1: Setup\nStep 2: Execute\nStep 3: Cleanup";
301        let out = tool
302            .invoke(
303                json!({
304                    "plan": multiline_plan
305                }),
306                ToolCtx::none("t"),
307            )
308            .await
309            .unwrap();
310        let ToolOutcome::Completed(result) = out else {
311            panic!("expected Completed")
312        };
313
314        assert!(result.success);
315        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
316        assert_eq!(payload["plan"], multiline_plan);
317    }
318
319    #[tokio::test]
320    async fn exit_plan_mode_accepts_markdown_plan() {
321        let tool = ExitPlanModeTool::new();
322        let markdown_plan = r#"# Implementation Plan
323
324## Phase 1
325- Task A
326- Task B
327
328## Phase 2
329- Task C
330"#;
331        let out = tool
332            .invoke(
333                json!({
334                    "plan": markdown_plan
335                }),
336                ToolCtx::none("t"),
337            )
338            .await
339            .unwrap();
340        let ToolOutcome::Completed(result) = out else {
341            panic!("expected Completed")
342        };
343
344        assert!(result.success);
345        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
346        assert_eq!(payload["plan"], markdown_plan);
347    }
348
349    #[tokio::test]
350    async fn exit_plan_mode_accepts_unicode_plan() {
351        let tool = ExitPlanModeTool::new();
352        let unicode_plan = "实施计划 🎯\n1. 读取配置\n2. 更新数据库";
353        let out = tool
354            .invoke(
355                json!({
356                    "plan": unicode_plan
357                }),
358                ToolCtx::none("t"),
359            )
360            .await
361            .unwrap();
362        let ToolOutcome::Completed(result) = out else {
363            panic!("expected Completed")
364        };
365
366        assert!(result.success);
367        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
368        assert_eq!(payload["plan"], unicode_plan);
369    }
370
371    #[tokio::test]
372    async fn exit_plan_mode_ignores_extra_fields() {
373        let tool = ExitPlanModeTool::new();
374        // serde_json with additionalProperties: false may not strictly reject extra fields
375        // during deserialization, so this test verifies the behavior
376        let result = tool
377            .invoke(
378                json!({
379                    "plan": "Test plan",
380                    "extra_field": "should be ignored"
381                }),
382                ToolCtx::none("t"),
383            )
384            .await;
385
386        // Depending on serde configuration, this might succeed (ignoring extra fields)
387        // or fail (rejecting extra fields). The test documents the actual behavior.
388        if let Ok(out) = result {
389            let ToolOutcome::Completed(tool_result) = out else {
390                panic!("expected Completed")
391            };
392            // If it succeeds, verify the plan was captured correctly
393            assert!(tool_result.success);
394            let payload: serde_json::Value = serde_json::from_str(&tool_result.result).unwrap();
395            assert_eq!(payload["plan"], "Test plan");
396        } else {
397            // If it fails, verify it's an InvalidArguments error
398            let error = result.unwrap_err();
399            assert!(matches!(error, ToolError::InvalidArguments(_)));
400        }
401    }
402
403    #[tokio::test]
404    async fn exit_plan_mode_payload_has_correct_structure() {
405        let tool = ExitPlanModeTool::new();
406        let out = tool
407            .invoke(
408                json!({
409                    "plan": "Test"
410                }),
411                ToolCtx::none("t"),
412            )
413            .await
414            .unwrap();
415        let ToolOutcome::Completed(result) = out else {
416            panic!("expected Completed")
417        };
418
419        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
420
421        // Verify all expected fields are present
422        assert!(payload.is_object());
423        assert!(payload.get("status").is_some());
424        assert!(payload.get("question").is_some());
425        assert!(payload.get("options").is_some());
426        assert!(payload.get("allow_custom").is_some());
427        assert!(payload.get("plan").is_some());
428
429        // Verify types
430        assert!(payload["status"].is_string());
431        assert!(payload["question"].is_string());
432        assert!(payload["options"].is_array());
433        assert!(payload["allow_custom"].is_boolean());
434        assert!(payload["plan"].is_string());
435    }
436
437    #[tokio::test]
438    async fn exit_plan_mode_options_has_four_choices_by_default() {
439        let tool = ExitPlanModeTool::new();
440        let out = tool
441            .invoke(
442                json!({
443                    "plan": "Test"
444                }),
445                ToolCtx::none("t"),
446            )
447            .await
448            .unwrap();
449        let ToolOutcome::Completed(result) = out else {
450            panic!("expected Completed")
451        };
452
453        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
454        let options = payload["options"].as_array().unwrap();
455
456        assert_eq!(options.len(), 4);
457        assert!(options.contains(&json!("Approve (Default mode)")));
458        assert!(options.contains(&json!("Approve (Accept edits mode)")));
459        assert!(options.contains(&json!("Stay in plan mode")));
460        assert!(options.contains(&json!("Edit plan first")));
461    }
462
463    #[tokio::test]
464    async fn exit_plan_mode_with_accept_edits_exit_mode() {
465        let tool = ExitPlanModeTool::new();
466        let out = tool
467            .invoke(
468                json!({
469                    "plan": "Test",
470                    "exit_mode": "accept_edits"
471                }),
472                ToolCtx::none("t"),
473            )
474            .await
475            .unwrap();
476        let ToolOutcome::Completed(result) = out else {
477            panic!("expected Completed")
478        };
479
480        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
481        let options = payload["options"].as_array().unwrap();
482        assert!(options.contains(&json!("Approve (Accept edits mode)")));
483        assert!(options[0] == "Approve (Accept edits mode)"); // First option
484    }
485
486    #[tokio::test]
487    async fn exit_plan_mode_with_auto_exit_mode_surfaces_real_auto_choice() {
488        let tool = ExitPlanModeTool::new();
489        let out = tool
490            .invoke(
491                json!({
492                    "plan": "Test",
493                    "exit_mode": "auto"
494                }),
495                ToolCtx::none("t"),
496            )
497            .await
498            .unwrap();
499        let ToolOutcome::Completed(result) = out else {
500            panic!("expected Completed")
501        };
502        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
503        let options = payload["options"].as_array().unwrap();
504        assert_eq!(options[0], "Approve (Auto mode)");
505        assert!(options.contains(&json!("Approve (Default mode)")));
506    }
507
508    #[tokio::test]
509    async fn exit_plan_mode_empty_plan_changes_question() {
510        let tool = ExitPlanModeTool::new();
511        let out = tool
512            .invoke(
513                json!({
514                    "plan": ""
515                }),
516                ToolCtx::none("t"),
517            )
518            .await
519            .unwrap();
520        let ToolOutcome::Completed(result) = out else {
521            panic!("expected Completed")
522        };
523
524        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
525        assert!(payload["question"]
526            .as_str()
527            .unwrap()
528            .contains("start implementation"));
529    }
530
531    #[test]
532    fn exit_plan_mode_default_impl() {
533        let tool = ExitPlanModeTool;
534        assert_eq!(tool.name(), "ExitPlanMode");
535    }
536
537    #[tokio::test]
538    async fn exit_plan_mode_long_plan() {
539        let tool = ExitPlanModeTool::new();
540        let long_plan = "Step\n".repeat(1000);
541        let out = tool
542            .invoke(
543                json!({
544                    "plan": long_plan.clone()
545                }),
546                ToolCtx::none("t"),
547            )
548            .await
549            .unwrap();
550        let ToolOutcome::Completed(result) = out else {
551            panic!("expected Completed")
552        };
553
554        assert!(result.success);
555        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
556        assert_eq!(payload["plan"], long_plan);
557    }
558}