Skip to main content

forge_agent/
user_interaction.rs

1//! `AskUserQuestion` sequential sequencing. Mirrors upstream's
2//! `agent-sdk/src/bridge/user_interaction.ts` (323 `LoC`) - focused on
3//! the multi-question loop + answer-payload assembly.
4//!
5//! The actual loop driver lives in `forge_sdk_worker::run_ask_user_question`
6//! because it needs the worker's `pending` map + `event_tx` + the
7//! oneshot machinery. This module owns the pure parsing helpers.
8
9use 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
17/// #273: CLI 2.1.156 marks the "best" choice in an
18/// `AskUserQuestion` option list with a literal ` (Recommended)`
19/// suffix on the option label (space + paren + capital R). The label
20/// itself reaches the UI verbatim; this only reports the marker's
21/// presence. Match is case-sensitive because the CLI emits the
22/// canonical form; lower-case `recommended` from the model's own
23/// prose is just label text.
24fn 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    /// #273: Set when the CLI 2.1.156 wire label carried a trailing
34    /// ` (Recommended)` suffix. `label` keeps the marker text; the
35    /// option is hoisted to the front of the list.
36    pub recommended: bool,
37}
38
39#[derive(Debug)]
40pub struct AskUserQuestionPrompt {
41    /// Trimmed text; what a view displays.
42    pub question: String,
43    /// The untrimmed wire text. The CLI looks answers and annotations
44    /// up by this exact string, so this is the only valid answers-map
45    /// key.
46    pub question_key: String,
47    pub header: String,
48    pub multi_select: bool,
49    pub options: Vec<AskUserQuestionOption>,
50}
51
52/// Mirrors upstream's `parseAskUserQuestionPrompts`. Walks the raw
53/// `input.questions` JSON array, dropping malformed entries and
54/// requiring at least 2 options per question (matches upstream's
55/// validity rule).
56pub 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        // #273: the caret always starts at index 0, so the marked
107        // option leads the list.
108        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
126/// Mirrors `askUserQuestionOptions(prompt)` - synthesises wire
127/// `option_id` slugs as `question_<index>` so a view can map back
128/// to the upstream label list when responding.
129fn 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
147/// Mirrors `buildQuestionRequest(promptToolCall, prompt, index, total)`.
148pub 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
182/// Mirrors `deriveAnnotation`. Joins option previews with a blank
183/// line separator when no caller-supplied annotation preview is
184/// present.
185pub 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
213/// Helper used by the worker's loop driver: build the final
214/// `updatedInput` payload after every question has been answered.
215/// Mirrors the tail of `requestAskUserQuestionAnswers` upstream.
216pub 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// ----------------------------------------------------------------
230// #273: CLI 2.1.156 Monitor typed tool_use input.
231//
232// Placement follows the AskUserQuestion convention above - the
233// agent-layer type sits with its parser. The renderer consumes it via
234// the standard `raw_input: Value` -> parse path.
235// ----------------------------------------------------------------
236
237/// `Monitor` tool's `tool_use.input` payload. The renderer consumes
238/// `description` for the chat one-liner + the MONITORS-section
239/// header; `command` is informational; `persistent` toggles the
240/// "(persistent)" suffix on the chat notice; `timeout_ms` is
241/// honoured by the CLI's task-lifecycle handler.
242#[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
250/// Parse a `Monitor` tool_use's `raw_input` into a `MonitorInput`.
251/// Returns `None` when `description` or `command` is missing or
252/// non-string - both are required for a meaningful render.
253pub 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    // Wire field is `timeout_ms` per plan; default to 0 (which the
262    // renderer reads as "no explicit timeout, persistent or
263    // CLI-default").
264    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    // ----------------------------------------------------------------
381    // #273: (Recommended) suffix detection.
382    // ----------------------------------------------------------------
383
384    #[test]
385    fn parse_keeps_recommended_marker_in_label() {
386        // The label is the CLI's own text and reaches the UI verbatim.
387        // A re-added strip takes `(Recommended)` off the screen and
388        // leaves the marker with no visible form.
389        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        // The caret always starts at index 0, so the marked option has
410        // to lead the list or the two signals point at different rows.
411        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        // With no marker there is nothing to hoist, and reordering
427        // anyway would put an option the model did not choose first.
428        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        // CLI emits the canonical ` (Recommended)` form only; a
440        // lowercase suffix in the model's own prose is not the marker.
441        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    // ----------------------------------------------------------------
459    // #273: Monitor + Workflow typed input parsers.
460    // ----------------------------------------------------------------
461
462    #[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}