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