bamboo_tools/tools/
exit_plan_mode.rs1use 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 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 let result = tool
279 .execute(json!({
280 "plan": "Test plan",
281 "extra_field": "should be ignored"
282 }))
283 .await;
284
285 if let Ok(tool_result) = result {
288 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 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 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 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}