1use serde_json::{Map, Value};
10
11use forge_primitives::{
12 QuestionAnnotation, QuestionOption, QuestionPrompt, QuestionRequest, ToolCall,
13};
14
15pub const ASK_USER_QUESTION_TOOL_NAME: &str = "AskUserQuestion";
16
17fn has_recommended_suffix(label: &str) -> bool {
25 label.ends_with(" (Recommended)")
26}
27
28#[derive(Debug)]
29pub struct AskUserQuestionOption {
30 pub label: String,
31 pub description: String,
32 pub preview: Option<String>,
33 pub recommended: bool,
37}
38
39#[derive(Debug)]
40pub struct AskUserQuestionPrompt {
41 pub question: String,
43 pub question_key: String,
47 pub header: String,
48 pub multi_select: bool,
49 pub options: Vec<AskUserQuestionOption>,
50}
51
52pub fn parse_ask_user_question_prompts(input: &Value) -> Vec<AskUserQuestionPrompt> {
57 let Some(questions) =
58 input.as_object().and_then(|r| r.get("questions")).and_then(Value::as_array)
59 else {
60 return Vec::new();
61 };
62 let mut prompts: Vec<AskUserQuestionPrompt> = Vec::new();
63 for raw in questions {
64 let Some(q) = raw.as_object() else { continue };
65 let question_key = q.get("question").and_then(Value::as_str).unwrap_or("").to_owned();
66 let question = question_key.trim().to_owned();
67 if question.is_empty() {
68 continue;
69 }
70 let header_raw = q.get("header").and_then(Value::as_str).unwrap_or("").trim().to_owned();
71 let header =
72 if header_raw.is_empty() { format!("Q{}", prompts.len() + 1) } else { header_raw };
73 let multi_select = q
74 .get("multiSelect")
75 .or_else(|| q.get("multi_select"))
76 .and_then(Value::as_bool)
77 .unwrap_or(false);
78 let mut options: Vec<AskUserQuestionOption> = Vec::new();
79 if let Some(opts) = q.get("options").and_then(Value::as_array) {
80 for raw_opt in opts {
81 let Some(opt) = raw_opt.as_object() else {
82 continue;
83 };
84 let raw_label =
85 opt.get("label").and_then(Value::as_str).unwrap_or("").trim().to_owned();
86 let description =
87 opt.get("description").and_then(Value::as_str).unwrap_or("").trim().to_owned();
88 let preview = opt
89 .get("preview")
90 .and_then(Value::as_str)
91 .map(str::trim)
92 .filter(|s| !s.is_empty())
93 .map(str::to_owned);
94 let recommended = has_recommended_suffix(&raw_label);
95 if raw_label.is_empty() {
96 continue;
97 }
98 options.push(AskUserQuestionOption {
99 label: raw_label,
100 description,
101 preview,
102 recommended,
103 });
104 }
105 }
106 if let Some(idx) = options.iter().position(|opt| opt.recommended) {
109 let marked = options.remove(idx);
110 options.insert(0, marked);
111 }
112 if options.len() < 2 {
113 continue;
114 }
115 prompts.push(AskUserQuestionPrompt {
116 question,
117 question_key,
118 header,
119 multi_select,
120 options,
121 });
122 }
123 prompts
124}
125
126fn ask_user_question_wire_options(prompt: &AskUserQuestionPrompt) -> Vec<QuestionOption> {
130 prompt
131 .options
132 .iter()
133 .enumerate()
134 .map(|(i, opt)| QuestionOption {
135 option_id: format!("question_{i}"),
136 label: opt.label.clone(),
137 description: if opt.description.is_empty() {
138 None
139 } else {
140 Some(opt.description.clone())
141 },
142 preview: opt.preview.clone(),
143 })
144 .collect()
145}
146
147pub fn build_question_request(
149 base_tool_call: &ToolCall,
150 prompt: &AskUserQuestionPrompt,
151 index: u64,
152 total: u64,
153) -> QuestionRequest {
154 let options = ask_user_question_wire_options(prompt);
155 let prompt_tool_call = ToolCall {
156 title: prompt.question.clone(),
157 raw_input: Some(serde_json::json!({
158 "prompt": {
159 "question": prompt.question,
160 "header": prompt.header,
161 "multi_select": prompt.multi_select,
162 "options": options,
163 },
164 "question_index": index,
165 "total_questions": total,
166 })),
167 ..base_tool_call.clone()
168 };
169 QuestionRequest {
170 tool_call: prompt_tool_call,
171 prompt: QuestionPrompt {
172 question: prompt.question.clone(),
173 header: prompt.header.clone(),
174 multi_select: prompt.multi_select,
175 options,
176 },
177 question_index: index,
178 total_questions: total,
179 }
180}
181
182pub fn derive_annotation(
186 selected: &[QuestionOption],
187 incoming: Option<&QuestionAnnotation>,
188) -> Option<QuestionAnnotation> {
189 let preview_raw = incoming.and_then(|a| a.preview.as_deref()).map_or("", str::trim);
190 let preview = if preview_raw.is_empty() {
191 let parts: Vec<&str> = selected
192 .iter()
193 .filter_map(|o| o.preview.as_deref().map(str::trim).filter(|s| !s.is_empty()))
194 .collect();
195 if parts.is_empty() { String::new() } else { parts.join("\n\n") }
196 } else {
197 preview_raw.to_owned()
198 };
199 let notes = incoming
200 .and_then(|a| a.notes.as_deref())
201 .map(str::trim)
202 .filter(|s| !s.is_empty())
203 .map(str::to_owned);
204 if preview.is_empty() && notes.is_none() {
205 return None;
206 }
207 Some(QuestionAnnotation {
208 preview: if preview.is_empty() { None } else { Some(preview) },
209 notes,
210 })
211}
212
213pub fn build_updated_input(
217 original_input: &Value,
218 answers: Map<String, Value>,
219 annotations: Map<String, Value>,
220) -> Value {
221 let mut merged = original_input.as_object().cloned().unwrap_or_default();
222 merged.insert("answers".to_owned(), Value::Object(answers));
223 if !annotations.is_empty() {
224 merged.insert("annotations".to_owned(), Value::Object(annotations));
225 }
226 Value::Object(merged)
227}
228
229#[derive(Debug, PartialEq, Eq)]
243pub struct MonitorInput {
244 pub description: String,
245 pub command: String,
246 pub persistent: bool,
247 pub timeout_ms: u64,
248}
249
250pub fn parse_monitor_input(input: &Value) -> Option<MonitorInput> {
254 let obj = input.as_object()?;
255 let description = obj.get("description").and_then(Value::as_str)?.trim().to_owned();
256 let command = obj.get("command").and_then(Value::as_str)?.trim().to_owned();
257 if description.is_empty() || command.is_empty() {
258 return None;
259 }
260 let persistent = obj.get("persistent").and_then(Value::as_bool).unwrap_or(false);
261 let timeout_ms = obj.get("timeout_ms").and_then(Value::as_u64).unwrap_or(0);
265 Some(MonitorInput { description, command, persistent, timeout_ms })
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use serde_json::json;
272
273 #[test]
274 fn parse_drops_invalid_entries() {
275 let input = json!({"questions": [
276 { "question": " ", "options": [{"label":"a"},{"label":"b"}] },
277 { "question": "Q1", "options": [{"label":"only"}] },
278 { "question": "Q2", "header": "H2", "multiSelect": true, "options": [
279 {"label":"L1","description":"d1"},
280 {"label":"L2","description":"","preview":"p2"},
281 ]},
282 "not_an_object",
283 ]});
284 let prompts = parse_ask_user_question_prompts(&input);
285 assert_eq!(prompts.len(), 1);
286 assert_eq!(prompts[0].question, "Q2");
287 assert_eq!(prompts[0].header, "H2");
288 assert!(prompts[0].multi_select);
289 assert_eq!(prompts[0].options.len(), 2);
290 assert_eq!(prompts[0].options[1].preview.as_deref(), Some("p2"));
291 }
292
293 #[test]
294 fn empty_questions_yields_empty_prompts() {
295 assert!(parse_ask_user_question_prompts(&json!({})).is_empty());
296 assert!(parse_ask_user_question_prompts(&json!({"questions": []})).is_empty());
297 }
298
299 #[test]
300 fn header_falls_back_to_q_n_when_blank() {
301 let input = json!({"questions": [{
302 "question": "first",
303 "options": [{"label":"a"},{"label":"b"}],
304 }, {
305 "question": "second",
306 "options": [{"label":"a"},{"label":"b"}],
307 }]});
308 let prompts = parse_ask_user_question_prompts(&input);
309 assert_eq!(prompts[0].header, "Q1");
310 assert_eq!(prompts[1].header, "Q2");
311 }
312
313 #[test]
314 fn build_request_carries_index_and_total() {
315 let prompt = AskUserQuestionPrompt {
316 question: "Q?".to_owned(),
317 question_key: "Q?".to_owned(),
318 header: "H".to_owned(),
319 multi_select: false,
320 options: vec![
321 AskUserQuestionOption {
322 label: "A".to_owned(),
323 description: String::new(),
324 preview: None,
325 recommended: false,
326 },
327 AskUserQuestionOption {
328 label: "B".to_owned(),
329 description: "desc".to_owned(),
330 preview: Some("p".to_owned()),
331 recommended: false,
332 },
333 ],
334 };
335 let base = ToolCall {
336 tool_call_id: "tu".to_owned(),
337 title: "AskUserQuestion".to_owned(),
338 kind: forge_primitives::ToolKind::Other,
339 status: forge_primitives::ToolCallStatus::Pending,
340 content: Vec::new(),
341 raw_input: None,
342 raw_output: None,
343 output_metadata: None,
344 task_metadata: None,
345 locations: Vec::new(),
346 meta: None,
347 };
348 let req = build_question_request(&base, &prompt, 1, 3);
349 assert_eq!(req.question_index, 1);
350 assert_eq!(req.total_questions, 3);
351 assert_eq!(req.prompt.question, "Q?");
352 assert_eq!(req.prompt.options.len(), 2);
353 assert_eq!(req.prompt.options[0].option_id, "question_0");
354 assert_eq!(req.prompt.options[1].option_id, "question_1");
355 assert_eq!(req.prompt.options[1].preview.as_deref(), Some("p"));
356 assert_eq!(req.tool_call.title, "Q?");
357 }
358
359 #[test]
360 fn derive_annotation_joins_previews_when_caller_omitted() {
361 let opts = vec![
362 QuestionOption {
363 option_id: "question_0".to_owned(),
364 label: "A".to_owned(),
365 description: None,
366 preview: Some("a-preview".to_owned()),
367 },
368 QuestionOption {
369 option_id: "question_1".to_owned(),
370 label: "B".to_owned(),
371 description: None,
372 preview: Some("b-preview".to_owned()),
373 },
374 ];
375 let derived = derive_annotation(&opts, None).unwrap();
376 assert_eq!(derived.preview.as_deref(), Some("a-preview\n\nb-preview"));
377 assert!(derived.notes.is_none());
378 }
379
380 #[test]
385 fn parse_keeps_recommended_marker_in_label() {
386 let input = json!({"questions": [{
390 "question": "Pick a rule shape",
391 "options": [
392 {"label": "Use deny rules (Recommended)"},
393 {"label": "Use allow rules"},
394 ],
395 }]});
396 let prompts = parse_ask_user_question_prompts(&input);
397 assert_eq!(prompts.len(), 1);
398 assert_eq!(prompts[0].options[0].label, "Use deny rules (Recommended)");
399 assert!(
400 prompts[0].options[0].recommended,
401 "the marker must still be detected, or the reorder has nothing to act on",
402 );
403 assert_eq!(prompts[0].options[1].label, "Use allow rules");
404 assert!(!prompts[0].options[1].recommended);
405 }
406
407 #[test]
408 fn parse_hoists_recommended_option_to_front() {
409 let input = json!({"questions": [{
412 "question": "Pick a rule shape",
413 "options": [
414 {"label": "First"},
415 {"label": "Second"},
416 {"label": "Third (Recommended)"},
417 ],
418 }]});
419 let prompts = parse_ask_user_question_prompts(&input);
420 let labels: Vec<&str> = prompts[0].options.iter().map(|o| o.label.as_str()).collect();
421 assert_eq!(labels, ["Third (Recommended)", "First", "Second"]);
422 }
423
424 #[test]
425 fn parse_preserves_cli_order_when_nothing_is_recommended() {
426 let input = json!({"questions": [{
429 "question": "Pick a rule shape",
430 "options": [{"label": "First"}, {"label": "Second"}, {"label": "Third"}],
431 }]});
432 let prompts = parse_ask_user_question_prompts(&input);
433 let labels: Vec<&str> = prompts[0].options.iter().map(|o| o.label.as_str()).collect();
434 assert_eq!(labels, ["First", "Second", "Third"]);
435 }
436
437 #[test]
438 fn parse_recommended_suffix_is_case_sensitive() {
439 let input = json!({"questions": [{
442 "question": "Q",
443 "options": [
444 {"label": "Plan with rationale (recommended)"},
445 {"label": "Quick reply"},
446 ],
447 }]});
448 let prompts = parse_ask_user_question_prompts(&input);
449 assert!(!prompts[0].options[0].recommended);
450 }
451
452 #[test]
453 fn derive_annotation_falls_through_when_nothing_to_say() {
454 let opts: Vec<QuestionOption> = Vec::new();
455 assert!(derive_annotation(&opts, None).is_none());
456 }
457
458 #[test]
463 fn parse_monitor_input_reads_required_and_optional_fields() {
464 let input = json!({
465 "description": "watch redis",
466 "command": "redis-cli monitor",
467 "persistent": true,
468 "timeout_ms": 300_000,
469 });
470 let parsed = parse_monitor_input(&input).expect("valid input");
471 assert_eq!(parsed.description, "watch redis");
472 assert_eq!(parsed.command, "redis-cli monitor");
473 assert!(parsed.persistent);
474 assert_eq!(parsed.timeout_ms, 300_000);
475 }
476
477 #[test]
478 fn parse_monitor_input_defaults_persistent_and_timeout() {
479 let input = json!({"description": "logs", "command": "tail -F app.log"});
480 let parsed = parse_monitor_input(&input).expect("valid input");
481 assert!(!parsed.persistent, "persistent defaults to false");
482 assert_eq!(parsed.timeout_ms, 0, "timeout_ms defaults to 0");
483 }
484
485 #[test]
486 fn parse_monitor_input_returns_none_when_required_fields_missing() {
487 for malformed in [
488 json!({}),
489 json!({"description": "x"}),
490 json!({"command": "y"}),
491 json!({"description": "", "command": "y"}),
492 json!({"description": "x", "command": ""}),
493 json!({"description": 42, "command": "y"}),
494 json!(["not", "an", "object"]),
495 ] {
496 assert!(parse_monitor_input(&malformed).is_none(), "expected None for {malformed:?}");
497 }
498 }
499}