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 #[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 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 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 let result = tool
319 .execute(json!({
320 "plan": "Test plan",
321 "extra_field": "should be ignored"
322 }))
323 .await;
324
325 if let Ok(tool_result) = result {
328 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 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 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 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)"); }
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}