1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5
6const DEFAULT_OPTIONS: [&str; 2] = ["OK", "Need changes"];
7const MAX_OPTIONS: usize = 6;
8const MAX_LIST_ITEMS: usize = 8;
9
10fn default_options() -> Vec<String> {
11 DEFAULT_OPTIONS.iter().map(|s| (*s).to_string()).collect()
12}
13
14fn default_allow_custom() -> bool {
15 true
16}
17
18fn normalize_text(value: &str) -> Option<String> {
19 let trimmed = value.trim();
20 if trimmed.is_empty() {
21 None
22 } else {
23 Some(trimmed.to_string())
24 }
25}
26
27fn normalize_optional_text(value: Option<String>) -> Option<String> {
28 value.and_then(|raw| normalize_text(&raw))
29}
30
31fn normalize_text_list(values: Vec<String>) -> Vec<String> {
32 values
33 .into_iter()
34 .filter_map(|value| normalize_text(&value))
35 .take(MAX_LIST_ITEMS)
36 .collect()
37}
38
39#[derive(Debug, Deserialize)]
40struct ConclusionWithOptionsMermaidArgs {
41 #[serde(default)]
42 title: Option<String>,
43 graph: String,
44}
45
46#[derive(Debug, Deserialize)]
47struct ConclusionWithOptionsConclusionArgs {
48 #[serde(default)]
49 title: Option<String>,
50 summary: String,
51 #[serde(default)]
52 key_points: Vec<String>,
53 #[serde(default)]
54 next_steps: Vec<String>,
55 #[serde(default)]
56 confidence: Option<String>,
57 mermaid: ConclusionWithOptionsMermaidArgs,
58}
59
60#[derive(Debug, Deserialize)]
61struct ConclusionWithOptionsArgs {
62 question: String,
63 #[serde(default)]
64 options: Vec<String>,
65 #[serde(default = "default_allow_custom")]
66 allow_custom: bool,
67 conclusion: ConclusionWithOptionsConclusionArgs,
68}
69
70pub struct ConclusionWithOptionsTool;
72
73impl ConclusionWithOptionsTool {
74 pub fn new() -> Self {
79 Self
80 }
81}
82
83impl Default for ConclusionWithOptionsTool {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89#[async_trait]
90impl Tool for ConclusionWithOptionsTool {
91 fn name(&self) -> &str {
92 "conclusion_with_options"
93 }
94
95 fn description(&self) -> &str {
96 "Ask the user a question with options and wait for the user to select or enter a custom answer. Use this as the final interaction step when wrapping up a task turn or when the user must choose next steps. The `conclusion` object is required and must include both a summary and a Mermaid graph."
97 }
98
99 fn parameters_schema(&self) -> serde_json::Value {
100 json!({
101 "type": "object",
102 "properties": {
103 "question": {
104 "type": "string",
105 "description": "The question to display to the user"
106 },
107 "conclusion": {
108 "type": "object",
109 "description": "Structured wrap-up context shown before the confirmation question.",
110 "properties": {
111 "title": {
112 "type": "string",
113 "description": "Optional title for the conclusion block."
114 },
115 "summary": {
116 "type": "string",
117 "description": "Main summary text shown to the user."
118 },
119 "key_points": {
120 "type": "array",
121 "description": "Optional short bullet points supporting the summary.",
122 "items": { "type": "string" }
123 },
124 "next_steps": {
125 "type": "array",
126 "description": "Optional follow-up actions.",
127 "items": { "type": "string" }
128 },
129 "confidence": {
130 "type": "string",
131 "description": "Optional confidence label, for example high/medium/low."
132 },
133 "mermaid": {
134 "type": "object",
135 "description": "Mermaid chart payload rendered in the UI.",
136 "properties": {
137 "title": {
138 "type": "string",
139 "description": "Optional Mermaid section title."
140 },
141 "graph": {
142 "type": "string",
143 "description": "Mermaid graph definition text."
144 }
145 },
146 "required": ["graph"],
147 "additionalProperties": false
148 }
149 },
150 "required": ["summary", "mermaid"],
151 "additionalProperties": false
152 },
153 "options": {
154 "type": "array",
155 "description": "Candidate answer options (optional). If omitted or invalid, defaults to [\"OK\", \"Need changes\"].",
156 "items": {
157 "type": "string"
158 }
159 },
160 "allow_custom": {
161 "type": "boolean",
162 "description": "Whether to allow user to enter a custom answer (instead of selecting from options), default true",
163 "default": true
164 }
165 },
166 "required": ["question", "conclusion"],
167 "additionalProperties": false
168 })
169 }
170
171 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
172 let parsed: ConclusionWithOptionsArgs = serde_json::from_value(args).map_err(|error| {
173 ToolError::InvalidArguments(format!("Invalid conclusion_with_options args: {error}"))
174 })?;
175 let question = normalize_text(&parsed.question).ok_or_else(|| {
176 ToolError::InvalidArguments("question must be a non-empty string".to_string())
177 })?;
178 let summary = normalize_text(&parsed.conclusion.summary).ok_or_else(|| {
179 ToolError::InvalidArguments("conclusion.summary must be a non-empty string".to_string())
180 })?;
181 let mermaid_graph = normalize_text(&parsed.conclusion.mermaid.graph).ok_or_else(|| {
182 ToolError::InvalidArguments(
183 "conclusion.mermaid.graph must be a non-empty string".to_string(),
184 )
185 })?;
186
187 let mut options = normalize_text_list(parsed.options);
188
189 if options.len() < 2 {
190 options = default_options();
191 } else if options.len() > MAX_OPTIONS {
192 options.truncate(MAX_OPTIONS);
193 }
194
195 let allow_custom = parsed.allow_custom;
196
197 let result_payload = json!({
199 "status": "awaiting_user_input",
200 "type": "conclusion_with_options",
201 "question": question,
202 "options": options,
203 "allow_custom": allow_custom,
204 "conclusion": {
205 "title": normalize_optional_text(parsed.conclusion.title).unwrap_or_else(|| "Conclusion".to_string()),
206 "summary": summary,
207 "key_points": normalize_text_list(parsed.conclusion.key_points),
208 "next_steps": normalize_text_list(parsed.conclusion.next_steps),
209 "confidence": normalize_optional_text(parsed.conclusion.confidence),
210 "mermaid": {
211 "title": normalize_optional_text(parsed.conclusion.mermaid.title),
212 "graph": mermaid_graph
213 }
214 }
215 });
216
217 Ok(ToolResult {
218 success: true,
219 result: result_payload.to_string(),
220 display_preference: Some("conclusion_with_options".to_string()),
221 })
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn minimal_conclusion() -> serde_json::Value {
230 json!({
231 "summary": "Core changes are done and ready for confirmation.",
232 "mermaid": {
233 "graph": "graph TD\nA[Done]-->B[Confirm]"
234 }
235 })
236 }
237
238 #[test]
239 fn test_conclusion_with_options_tool_name() {
240 let tool = ConclusionWithOptionsTool::new();
241 assert_eq!(tool.name(), "conclusion_with_options");
242 }
243
244 #[tokio::test]
245 async fn test_execute_valid_input() {
246 let tool = ConclusionWithOptionsTool::new();
247
248 let result = tool
249 .execute(json!({
250 "question": "Please select deployment environment",
251 "options": ["Development", "Testing", "Production"],
252 "conclusion": minimal_conclusion()
253 }))
254 .await
255 .expect("tool should execute successfully");
256
257 assert!(result.success);
258 assert_eq!(
259 result.display_preference,
260 Some("conclusion_with_options".to_string())
261 );
262
263 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
264 assert_eq!(parsed["status"], "awaiting_user_input");
265 assert_eq!(parsed["question"], "Please select deployment environment");
266 assert!(parsed["allow_custom"].as_bool().unwrap());
267 assert_eq!(
268 parsed["conclusion"]["summary"],
269 "Core changes are done and ready for confirmation."
270 );
271 assert_eq!(
272 parsed["conclusion"]["mermaid"]["graph"],
273 "graph TD\nA[Done]-->B[Confirm]"
274 );
275 }
276
277 #[tokio::test]
278 async fn test_execute_accepts_two_options() {
279 let tool = ConclusionWithOptionsTool::new();
280
281 let result = tool
282 .execute(json!({
283 "question": "Please confirm?",
284 "options": ["Yes", "No"],
285 "conclusion": minimal_conclusion()
286 }))
287 .await;
288
289 assert!(result.is_ok());
290 }
291
292 #[tokio::test]
293 async fn test_execute_with_too_few_options_uses_defaults() {
294 let tool = ConclusionWithOptionsTool::new();
295
296 let result = tool
297 .execute(json!({
298 "question": "Please select?",
299 "options": ["Only one option"],
300 "conclusion": minimal_conclusion()
301 }))
302 .await
303 .expect("tool should execute with fallback defaults");
304
305 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
306 assert_eq!(parsed["options"], json!(["OK", "Need changes"]));
307 }
308
309 #[tokio::test]
310 async fn test_execute_without_options_uses_defaults() {
311 let tool = ConclusionWithOptionsTool::new();
312
313 let result = tool
314 .execute(json!({
315 "question": "Any other requests before I finish?",
316 "conclusion": minimal_conclusion()
317 }))
318 .await
319 .expect("tool should execute without options");
320
321 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
322 assert_eq!(parsed["options"], json!(["OK", "Need changes"]));
323 }
324
325 #[tokio::test]
326 async fn test_execute_truncates_options_to_six_items() {
327 let tool = ConclusionWithOptionsTool::new();
328
329 let result = tool
330 .execute(json!({
331 "question": "Please pick one",
332 "options": ["1", "2", "3", "4", "5", "6", "7"],
333 "conclusion": minimal_conclusion()
334 }))
335 .await
336 .expect("tool should execute and truncate options");
337
338 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
339 assert_eq!(parsed["options"], json!(["1", "2", "3", "4", "5", "6"]));
340 }
341
342 #[tokio::test]
343 async fn test_execute_with_allow_custom_false() {
344 let tool = ConclusionWithOptionsTool::new();
345
346 let result = tool
347 .execute(json!({
348 "question": "Please confirm",
349 "options": ["Yes", "No", "Cancel"],
350 "allow_custom": false,
351 "conclusion": minimal_conclusion()
352 }))
353 .await
354 .expect("tool should execute");
355
356 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
357 assert!(!parsed["allow_custom"].as_bool().unwrap());
358 }
359
360 #[tokio::test]
361 async fn test_execute_rejects_missing_conclusion() {
362 let tool = ConclusionWithOptionsTool::new();
363
364 let result = tool
365 .execute(json!({
366 "question": "Please confirm"
367 }))
368 .await;
369
370 assert!(result.is_err());
371 let error = result.expect_err("expected invalid args");
372 if let ToolError::InvalidArguments(message) = error {
373 assert!(message.contains("conclusion"));
374 } else {
375 panic!("expected invalid arguments");
376 }
377 }
378
379 #[tokio::test]
380 async fn test_execute_rejects_empty_mermaid_graph() {
381 let tool = ConclusionWithOptionsTool::new();
382
383 let result = tool
384 .execute(json!({
385 "question": "Please confirm",
386 "conclusion": {
387 "summary": "Summary",
388 "mermaid": { "graph": " " }
389 }
390 }))
391 .await;
392
393 assert!(result.is_err());
394 let error = result.expect_err("expected invalid args");
395 if let ToolError::InvalidArguments(message) = error {
396 assert!(message.contains("conclusion.mermaid.graph"));
397 } else {
398 panic!("expected invalid arguments");
399 }
400 }
401}