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 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 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 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 if let Ok(out) = result {
383 let ToolOutcome::Completed(tool_result) = out else {
384 panic!("expected Completed")
385 };
386 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 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 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 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)"); }
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}