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