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', 'bypass_permissions', or 'auto'"
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 Some("auto") => vec![
85 "Approve (Auto mode)",
86 "Approve (Default mode)",
87 "Stay in plan mode",
88 "Edit plan first",
89 ],
90 _ => vec![
91 "Approve (Default mode)",
92 "Approve (Accept edits mode)",
93 "Stay in plan mode",
94 "Edit plan first",
95 ],
96 };
97
98 let question = if parsed.plan.trim().is_empty() {
99 "Plan ready. Exit plan mode and start implementation?"
100 } else {
101 "Plan ready. Review the plan below and approve to exit plan mode and start implementation."
102 };
103
104 let payload = json!({
105 "status": "awaiting_user_input",
106 "question": question,
107 "options": options,
108 "allow_custom": false,
109 "plan": parsed.plan,
110 "exit_mode": parsed.exit_mode,
111 });
112
113 Ok(ToolOutcome::Completed(ToolResult {
114 success: true,
115 result: payload.to_string(),
116 display_preference: Some("conclusion_with_options".to_string()),
117 images: Vec::new(),
118 }))
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use serde_json::json;
126
127 #[test]
128 fn exit_plan_mode_has_correct_name() {
129 let tool = ExitPlanModeTool::new();
130 assert_eq!(tool.name(), "ExitPlanMode");
131 }
132
133 #[test]
134 fn exit_plan_mode_has_description() {
135 let tool = ExitPlanModeTool::new();
136 assert!(!tool.description().is_empty());
137 assert!(tool.description().contains("plan"));
138 }
139
140 #[test]
141 fn exit_plan_mode_parameters_schema_has_required_fields() {
142 let tool = ExitPlanModeTool::new();
143 let schema = tool.parameters_schema();
144
145 assert_eq!(schema["type"], "object");
146 assert!(schema["properties"]["plan"].is_object());
147 assert_eq!(schema["properties"]["plan"]["type"], "string");
148 assert!(schema["required"]
149 .as_array()
150 .unwrap()
151 .contains(&json!("plan")));
152 assert_eq!(schema["additionalProperties"], false);
153 }
154
155 #[tokio::test]
156 async fn exit_plan_mode_accepts_valid_plan() {
157 let tool = ExitPlanModeTool::new();
158 let out = tool
159 .invoke(
160 json!({
161 "plan": "Implement feature X"
162 }),
163 ToolCtx::none("t"),
164 )
165 .await
166 .unwrap();
167 let ToolOutcome::Completed(result) = out else {
168 panic!("expected Completed")
169 };
170
171 assert!(result.success);
172
173 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
174 assert_eq!(payload["status"], "awaiting_user_input");
175 assert!(payload["question"].as_str().unwrap().contains("Plan ready"));
176 let options = payload["options"].as_array().unwrap();
177 assert_eq!(options.len(), 4);
178 assert!(options.contains(&json!("Approve (Default mode)")));
179 assert!(options.contains(&json!("Stay in plan mode")));
180 assert!(options.contains(&json!("Edit plan first")));
181 assert_eq!(payload["allow_custom"], false);
182 assert_eq!(payload["plan"], "Implement feature X");
183 }
184
185 #[tokio::test]
186 async fn exit_plan_mode_includes_plan_in_payload() {
187 let tool = ExitPlanModeTool::new();
188 let plan_text = "1. Read config\n2. Update database\n3. Deploy changes";
189 let out = tool
190 .invoke(
191 json!({
192 "plan": plan_text
193 }),
194 ToolCtx::none("t"),
195 )
196 .await
197 .unwrap();
198 let ToolOutcome::Completed(result) = out else {
199 panic!("expected Completed")
200 };
201
202 assert!(result.success);
203 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
204 assert_eq!(payload["plan"], plan_text);
205 }
206
207 #[tokio::test]
208 async fn exit_plan_mode_sets_display_preference_to_conclusion_with_options() {
209 let tool = ExitPlanModeTool::new();
210 let out = tool
211 .invoke(
212 json!({
213 "plan": "Test plan"
214 }),
215 ToolCtx::none("t"),
216 )
217 .await
218 .unwrap();
219 let ToolOutcome::Completed(result) = out else {
220 panic!("expected Completed")
221 };
222
223 assert_eq!(
224 result.display_preference,
225 Some("conclusion_with_options".to_string())
226 );
227 }
228
229 #[tokio::test]
230 async fn exit_plan_mode_rejects_missing_plan() {
231 let tool = ExitPlanModeTool::new();
232 let result = tool.invoke(json!({}), ToolCtx::none("t")).await;
233
234 assert!(result.is_err());
235 let error = result.unwrap_err();
236 assert!(matches!(error, ToolError::InvalidArguments(_)));
237 }
238
239 #[tokio::test]
240 async fn exit_plan_mode_rejects_invalid_plan_type() {
241 let tool = ExitPlanModeTool::new();
242 let result = tool
243 .invoke(
244 json!({
245 "plan": 123
246 }),
247 ToolCtx::none("t"),
248 )
249 .await;
250
251 assert!(result.is_err());
252 let error = result.unwrap_err();
253 if let ToolError::InvalidArguments(msg) = error {
254 assert!(msg.contains("Invalid ExitPlanMode args"));
255 } else {
256 panic!("Expected InvalidArguments error");
257 }
258 }
259
260 #[tokio::test]
261 async fn exit_plan_mode_rejects_null_plan() {
262 let tool = ExitPlanModeTool::new();
263 let result = tool
264 .invoke(
265 json!({
266 "plan": null
267 }),
268 ToolCtx::none("t"),
269 )
270 .await;
271
272 assert!(result.is_err());
273 }
274
275 #[tokio::test]
276 async fn exit_plan_mode_accepts_empty_plan_string() {
277 let tool = ExitPlanModeTool::new();
279 let out = tool
280 .invoke(
281 json!({
282 "plan": ""
283 }),
284 ToolCtx::none("t"),
285 )
286 .await
287 .unwrap();
288 let ToolOutcome::Completed(result) = out else {
289 panic!("expected Completed")
290 };
291
292 assert!(result.success);
293 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
294 assert_eq!(payload["plan"], "");
295 }
296
297 #[tokio::test]
298 async fn exit_plan_mode_accepts_multiline_plan() {
299 let tool = ExitPlanModeTool::new();
300 let multiline_plan = "Step 1: Setup\nStep 2: Execute\nStep 3: Cleanup";
301 let out = tool
302 .invoke(
303 json!({
304 "plan": multiline_plan
305 }),
306 ToolCtx::none("t"),
307 )
308 .await
309 .unwrap();
310 let ToolOutcome::Completed(result) = out else {
311 panic!("expected Completed")
312 };
313
314 assert!(result.success);
315 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
316 assert_eq!(payload["plan"], multiline_plan);
317 }
318
319 #[tokio::test]
320 async fn exit_plan_mode_accepts_markdown_plan() {
321 let tool = ExitPlanModeTool::new();
322 let markdown_plan = r#"# Implementation Plan
323
324## Phase 1
325- Task A
326- Task B
327
328## Phase 2
329- Task C
330"#;
331 let out = tool
332 .invoke(
333 json!({
334 "plan": markdown_plan
335 }),
336 ToolCtx::none("t"),
337 )
338 .await
339 .unwrap();
340 let ToolOutcome::Completed(result) = out else {
341 panic!("expected Completed")
342 };
343
344 assert!(result.success);
345 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
346 assert_eq!(payload["plan"], markdown_plan);
347 }
348
349 #[tokio::test]
350 async fn exit_plan_mode_accepts_unicode_plan() {
351 let tool = ExitPlanModeTool::new();
352 let unicode_plan = "实施计划 🎯\n1. 读取配置\n2. 更新数据库";
353 let out = tool
354 .invoke(
355 json!({
356 "plan": unicode_plan
357 }),
358 ToolCtx::none("t"),
359 )
360 .await
361 .unwrap();
362 let ToolOutcome::Completed(result) = out else {
363 panic!("expected Completed")
364 };
365
366 assert!(result.success);
367 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
368 assert_eq!(payload["plan"], unicode_plan);
369 }
370
371 #[tokio::test]
372 async fn exit_plan_mode_ignores_extra_fields() {
373 let tool = ExitPlanModeTool::new();
374 let result = tool
377 .invoke(
378 json!({
379 "plan": "Test plan",
380 "extra_field": "should be ignored"
381 }),
382 ToolCtx::none("t"),
383 )
384 .await;
385
386 if let Ok(out) = result {
389 let ToolOutcome::Completed(tool_result) = out else {
390 panic!("expected Completed")
391 };
392 assert!(tool_result.success);
394 let payload: serde_json::Value = serde_json::from_str(&tool_result.result).unwrap();
395 assert_eq!(payload["plan"], "Test plan");
396 } else {
397 let error = result.unwrap_err();
399 assert!(matches!(error, ToolError::InvalidArguments(_)));
400 }
401 }
402
403 #[tokio::test]
404 async fn exit_plan_mode_payload_has_correct_structure() {
405 let tool = ExitPlanModeTool::new();
406 let out = tool
407 .invoke(
408 json!({
409 "plan": "Test"
410 }),
411 ToolCtx::none("t"),
412 )
413 .await
414 .unwrap();
415 let ToolOutcome::Completed(result) = out else {
416 panic!("expected Completed")
417 };
418
419 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
420
421 assert!(payload.is_object());
423 assert!(payload.get("status").is_some());
424 assert!(payload.get("question").is_some());
425 assert!(payload.get("options").is_some());
426 assert!(payload.get("allow_custom").is_some());
427 assert!(payload.get("plan").is_some());
428
429 assert!(payload["status"].is_string());
431 assert!(payload["question"].is_string());
432 assert!(payload["options"].is_array());
433 assert!(payload["allow_custom"].is_boolean());
434 assert!(payload["plan"].is_string());
435 }
436
437 #[tokio::test]
438 async fn exit_plan_mode_options_has_four_choices_by_default() {
439 let tool = ExitPlanModeTool::new();
440 let out = tool
441 .invoke(
442 json!({
443 "plan": "Test"
444 }),
445 ToolCtx::none("t"),
446 )
447 .await
448 .unwrap();
449 let ToolOutcome::Completed(result) = out else {
450 panic!("expected Completed")
451 };
452
453 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
454 let options = payload["options"].as_array().unwrap();
455
456 assert_eq!(options.len(), 4);
457 assert!(options.contains(&json!("Approve (Default mode)")));
458 assert!(options.contains(&json!("Approve (Accept edits mode)")));
459 assert!(options.contains(&json!("Stay in plan mode")));
460 assert!(options.contains(&json!("Edit plan first")));
461 }
462
463 #[tokio::test]
464 async fn exit_plan_mode_with_accept_edits_exit_mode() {
465 let tool = ExitPlanModeTool::new();
466 let out = tool
467 .invoke(
468 json!({
469 "plan": "Test",
470 "exit_mode": "accept_edits"
471 }),
472 ToolCtx::none("t"),
473 )
474 .await
475 .unwrap();
476 let ToolOutcome::Completed(result) = out else {
477 panic!("expected Completed")
478 };
479
480 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
481 let options = payload["options"].as_array().unwrap();
482 assert!(options.contains(&json!("Approve (Accept edits mode)")));
483 assert!(options[0] == "Approve (Accept edits mode)"); }
485
486 #[tokio::test]
487 async fn exit_plan_mode_with_auto_exit_mode_surfaces_real_auto_choice() {
488 let tool = ExitPlanModeTool::new();
489 let out = tool
490 .invoke(
491 json!({
492 "plan": "Test",
493 "exit_mode": "auto"
494 }),
495 ToolCtx::none("t"),
496 )
497 .await
498 .unwrap();
499 let ToolOutcome::Completed(result) = out else {
500 panic!("expected Completed")
501 };
502 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
503 let options = payload["options"].as_array().unwrap();
504 assert_eq!(options[0], "Approve (Auto mode)");
505 assert!(options.contains(&json!("Approve (Default mode)")));
506 }
507
508 #[tokio::test]
509 async fn exit_plan_mode_empty_plan_changes_question() {
510 let tool = ExitPlanModeTool::new();
511 let out = tool
512 .invoke(
513 json!({
514 "plan": ""
515 }),
516 ToolCtx::none("t"),
517 )
518 .await
519 .unwrap();
520 let ToolOutcome::Completed(result) = out else {
521 panic!("expected Completed")
522 };
523
524 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
525 assert!(payload["question"]
526 .as_str()
527 .unwrap()
528 .contains("start implementation"));
529 }
530
531 #[test]
532 fn exit_plan_mode_default_impl() {
533 let tool = ExitPlanModeTool;
534 assert_eq!(tool.name(), "ExitPlanMode");
535 }
536
537 #[tokio::test]
538 async fn exit_plan_mode_long_plan() {
539 let tool = ExitPlanModeTool::new();
540 let long_plan = "Step\n".repeat(1000);
541 let out = tool
542 .invoke(
543 json!({
544 "plan": long_plan.clone()
545 }),
546 ToolCtx::none("t"),
547 )
548 .await
549 .unwrap();
550 let ToolOutcome::Completed(result) = out else {
551 panic!("expected Completed")
552 };
553
554 assert!(result.success);
555 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
556 assert_eq!(payload["plan"], long_plan);
557 }
558}