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 })
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use serde_json::json;
115
116 #[test]
117 fn exit_plan_mode_has_correct_name() {
118 let tool = ExitPlanModeTool::new();
119 assert_eq!(tool.name(), "ExitPlanMode");
120 }
121
122 #[test]
123 fn exit_plan_mode_has_description() {
124 let tool = ExitPlanModeTool::new();
125 assert!(!tool.description().is_empty());
126 assert!(tool.description().contains("plan"));
127 }
128
129 #[test]
130 fn exit_plan_mode_parameters_schema_has_required_fields() {
131 let tool = ExitPlanModeTool::new();
132 let schema = tool.parameters_schema();
133
134 assert_eq!(schema["type"], "object");
135 assert!(schema["properties"]["plan"].is_object());
136 assert_eq!(schema["properties"]["plan"]["type"], "string");
137 assert!(schema["required"]
138 .as_array()
139 .unwrap()
140 .contains(&json!("plan")));
141 assert_eq!(schema["additionalProperties"], false);
142 }
143
144 #[tokio::test]
145 async fn exit_plan_mode_accepts_valid_plan() {
146 let tool = ExitPlanModeTool::new();
147 let result = tool
148 .execute(json!({
149 "plan": "Implement feature X"
150 }))
151 .await
152 .unwrap();
153
154 assert!(result.success);
155
156 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
157 assert_eq!(payload["status"], "awaiting_user_input");
158 assert!(payload["question"].as_str().unwrap().contains("Plan ready"));
159 let options = payload["options"].as_array().unwrap();
160 assert_eq!(options.len(), 4);
161 assert!(options.contains(&json!("Approve (Default mode)")));
162 assert!(options.contains(&json!("Stay in plan mode")));
163 assert!(options.contains(&json!("Edit plan first")));
164 assert_eq!(payload["allow_custom"], false);
165 assert_eq!(payload["plan"], "Implement feature X");
166 }
167
168 #[tokio::test]
169 async fn exit_plan_mode_includes_plan_in_payload() {
170 let tool = ExitPlanModeTool::new();
171 let plan_text = "1. Read config\n2. Update database\n3. Deploy changes";
172 let result = tool
173 .execute(json!({
174 "plan": plan_text
175 }))
176 .await
177 .unwrap();
178
179 assert!(result.success);
180 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
181 assert_eq!(payload["plan"], plan_text);
182 }
183
184 #[tokio::test]
185 async fn exit_plan_mode_sets_display_preference_to_conclusion_with_options() {
186 let tool = ExitPlanModeTool::new();
187 let result = tool
188 .execute(json!({
189 "plan": "Test plan"
190 }))
191 .await
192 .unwrap();
193
194 assert_eq!(
195 result.display_preference,
196 Some("conclusion_with_options".to_string())
197 );
198 }
199
200 #[tokio::test]
201 async fn exit_plan_mode_rejects_missing_plan() {
202 let tool = ExitPlanModeTool::new();
203 let result = tool.execute(json!({})).await;
204
205 assert!(result.is_err());
206 let error = result.unwrap_err();
207 assert!(matches!(error, ToolError::InvalidArguments(_)));
208 }
209
210 #[tokio::test]
211 async fn exit_plan_mode_rejects_invalid_plan_type() {
212 let tool = ExitPlanModeTool::new();
213 let result = tool
214 .execute(json!({
215 "plan": 123
216 }))
217 .await;
218
219 assert!(result.is_err());
220 let error = result.unwrap_err();
221 if let ToolError::InvalidArguments(msg) = error {
222 assert!(msg.contains("Invalid ExitPlanMode args"));
223 } else {
224 panic!("Expected InvalidArguments error");
225 }
226 }
227
228 #[tokio::test]
229 async fn exit_plan_mode_rejects_null_plan() {
230 let tool = ExitPlanModeTool::new();
231 let result = tool
232 .execute(json!({
233 "plan": null
234 }))
235 .await;
236
237 assert!(result.is_err());
238 }
239
240 #[tokio::test]
241 async fn exit_plan_mode_accepts_empty_plan_string() {
242 let tool = ExitPlanModeTool::new();
244 let result = tool
245 .execute(json!({
246 "plan": ""
247 }))
248 .await
249 .unwrap();
250
251 assert!(result.success);
252 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
253 assert_eq!(payload["plan"], "");
254 }
255
256 #[tokio::test]
257 async fn exit_plan_mode_accepts_multiline_plan() {
258 let tool = ExitPlanModeTool::new();
259 let multiline_plan = "Step 1: Setup\nStep 2: Execute\nStep 3: Cleanup";
260 let result = tool
261 .execute(json!({
262 "plan": multiline_plan
263 }))
264 .await
265 .unwrap();
266
267 assert!(result.success);
268 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
269 assert_eq!(payload["plan"], multiline_plan);
270 }
271
272 #[tokio::test]
273 async fn exit_plan_mode_accepts_markdown_plan() {
274 let tool = ExitPlanModeTool::new();
275 let markdown_plan = r#"# Implementation Plan
276
277## Phase 1
278- Task A
279- Task B
280
281## Phase 2
282- Task C
283"#;
284 let result = tool
285 .execute(json!({
286 "plan": markdown_plan
287 }))
288 .await
289 .unwrap();
290
291 assert!(result.success);
292 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
293 assert_eq!(payload["plan"], markdown_plan);
294 }
295
296 #[tokio::test]
297 async fn exit_plan_mode_accepts_unicode_plan() {
298 let tool = ExitPlanModeTool::new();
299 let unicode_plan = "实施计划 🎯\n1. 读取配置\n2. 更新数据库";
300 let result = tool
301 .execute(json!({
302 "plan": unicode_plan
303 }))
304 .await
305 .unwrap();
306
307 assert!(result.success);
308 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
309 assert_eq!(payload["plan"], unicode_plan);
310 }
311
312 #[tokio::test]
313 async fn exit_plan_mode_ignores_extra_fields() {
314 let tool = ExitPlanModeTool::new();
315 let result = tool
318 .execute(json!({
319 "plan": "Test plan",
320 "extra_field": "should be ignored"
321 }))
322 .await;
323
324 if let Ok(tool_result) = result {
327 assert!(tool_result.success);
329 let payload: serde_json::Value = serde_json::from_str(&tool_result.result).unwrap();
330 assert_eq!(payload["plan"], "Test plan");
331 } else {
332 let error = result.unwrap_err();
334 assert!(matches!(error, ToolError::InvalidArguments(_)));
335 }
336 }
337
338 #[tokio::test]
339 async fn exit_plan_mode_payload_has_correct_structure() {
340 let tool = ExitPlanModeTool::new();
341 let result = tool
342 .execute(json!({
343 "plan": "Test"
344 }))
345 .await
346 .unwrap();
347
348 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
349
350 assert!(payload.is_object());
352 assert!(payload.get("status").is_some());
353 assert!(payload.get("question").is_some());
354 assert!(payload.get("options").is_some());
355 assert!(payload.get("allow_custom").is_some());
356 assert!(payload.get("plan").is_some());
357
358 assert!(payload["status"].is_string());
360 assert!(payload["question"].is_string());
361 assert!(payload["options"].is_array());
362 assert!(payload["allow_custom"].is_boolean());
363 assert!(payload["plan"].is_string());
364 }
365
366 #[tokio::test]
367 async fn exit_plan_mode_options_has_four_choices_by_default() {
368 let tool = ExitPlanModeTool::new();
369 let result = tool
370 .execute(json!({
371 "plan": "Test"
372 }))
373 .await
374 .unwrap();
375
376 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
377 let options = payload["options"].as_array().unwrap();
378
379 assert_eq!(options.len(), 4);
380 assert!(options.contains(&json!("Approve (Default mode)")));
381 assert!(options.contains(&json!("Approve (Accept edits mode)")));
382 assert!(options.contains(&json!("Stay in plan mode")));
383 assert!(options.contains(&json!("Edit plan first")));
384 }
385
386 #[tokio::test]
387 async fn exit_plan_mode_with_accept_edits_exit_mode() {
388 let tool = ExitPlanModeTool::new();
389 let result = tool
390 .execute(json!({
391 "plan": "Test",
392 "exit_mode": "accept_edits"
393 }))
394 .await
395 .unwrap();
396
397 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
398 let options = payload["options"].as_array().unwrap();
399 assert!(options.contains(&json!("Approve (Accept edits mode)")));
400 assert!(options[0] == "Approve (Accept edits mode)"); }
402
403 #[tokio::test]
404 async fn exit_plan_mode_empty_plan_changes_question() {
405 let tool = ExitPlanModeTool::new();
406 let result = tool
407 .execute(json!({
408 "plan": ""
409 }))
410 .await
411 .unwrap();
412
413 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
414 assert!(payload["question"]
415 .as_str()
416 .unwrap()
417 .contains("start implementation"));
418 }
419
420 #[test]
421 fn exit_plan_mode_default_impl() {
422 let tool = ExitPlanModeTool::default();
423 assert_eq!(tool.name(), "ExitPlanMode");
424 }
425
426 #[tokio::test]
427 async fn exit_plan_mode_long_plan() {
428 let tool = ExitPlanModeTool::new();
429 let long_plan = "Step\n".repeat(1000);
430 let result = tool
431 .execute(json!({
432 "plan": long_plan.clone()
433 }))
434 .await
435 .unwrap();
436
437 assert!(result.success);
438 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
439 assert_eq!(payload["plan"], long_plan);
440 }
441}