Skip to main content

stakpak_shared/models/tools/
ask_user.rs

1//! Ask User tool types — single source of truth for MCP schema, CLI, and TUI.
2//!
3//! These types carry both `serde` and `schemars` annotations so they can be
4//! used directly in MCP tool definitions (schema generation) **and** for
5//! runtime (de)serialization in the TUI / CLI.
6
7use rmcp::schemars;
8use serde::{Deserialize, Serialize};
9
10// ---------------------------------------------------------------------------
11// Request (LLM → tool)
12// ---------------------------------------------------------------------------
13
14/// Request payload for the `ask_user` tool.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
16pub struct AskUserRequest {
17    #[schemars(
18        description = "List of questions to ask the user. Each question has a label, question text, and options."
19    )]
20    pub questions: Vec<AskUserQuestion>,
21}
22
23/// A single question presented to the user.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
25pub struct AskUserQuestion {
26    #[schemars(description = "Short unique label for tab display (max ~15 chars recommended)")]
27    pub label: String,
28    #[schemars(description = "Full question text to display")]
29    pub question: String,
30    #[schemars(description = "Predefined answer options")]
31    pub options: Vec<AskUserOption>,
32    /// Whether to allow custom text input (default: true)
33    #[serde(default = "default_true")]
34    #[schemars(description = "Whether to allow custom text input (default: true)")]
35    pub allow_custom: bool,
36    /// When true, user can select multiple options (checkbox list). Default: false (single-select).
37    #[serde(default)]
38    #[schemars(
39        description = "When true, user can select/deselect multiple options (checkbox list). Default: false (single-select radio behavior)."
40    )]
41    pub multi_select: bool,
42}
43
44/// A predefined answer option for a question.
45///
46/// The `value` field defaults to `label` when omitted by the LLM, since models
47/// frequently treat them as interchangeable. A custom `Deserialize` impl
48/// handles this fallback transparently.
49#[derive(Debug, Clone, Serialize, PartialEq, schemars::JsonSchema)]
50pub struct AskUserOption {
51    #[schemars(description = "Value to return to LLM when selected")]
52    pub value: String,
53    #[schemars(description = "Display label for the option")]
54    pub label: String,
55    /// Optional description shown below the label.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    #[schemars(description = "Optional description shown below the label")]
58    pub description: Option<String>,
59    /// Default selection state for multi_select questions. Ignored for single-select.
60    #[serde(default)]
61    #[schemars(
62        description = "Default selection state when multi_select is true. Pre-marks this option as selected. Ignored for single-select questions."
63    )]
64    pub selected: bool,
65}
66
67impl<'de> Deserialize<'de> for AskUserOption {
68    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69    where
70        D: serde::Deserializer<'de>,
71    {
72        #[derive(Deserialize)]
73        struct Raw {
74            value: Option<String>,
75            label: String,
76            #[serde(default)]
77            description: Option<String>,
78            #[serde(default)]
79            selected: bool,
80        }
81
82        let raw = Raw::deserialize(deserializer)?;
83        Ok(AskUserOption {
84            value: raw.value.unwrap_or_else(|| raw.label.clone()),
85            label: raw.label,
86            description: raw.description,
87            selected: raw.selected,
88        })
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Response (tool → LLM)
94// ---------------------------------------------------------------------------
95
96/// User's answer to a single question.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
98pub struct AskUserAnswer {
99    /// Question label this answers.
100    pub question_label: String,
101    /// Selected option value OR custom text (for single-select questions).
102    /// For multi-select questions this is a JSON array string of selected values.
103    pub answer: String,
104    /// Whether this was a custom answer (typed by user).
105    pub is_custom: bool,
106    /// Selected values for multi-select questions. Empty/absent for single-select.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub selected_values: Vec<String>,
109}
110
111/// Aggregated result of the `ask_user` tool.
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
113pub struct AskUserResult {
114    /// All answers provided by the user.
115    pub answers: Vec<AskUserAnswer>,
116    /// Whether the user completed all questions (false if cancelled).
117    pub completed: bool,
118    /// Reason for incompletion (if cancelled).
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub reason: Option<String>,
121}
122
123// ---------------------------------------------------------------------------
124// Helpers
125// ---------------------------------------------------------------------------
126
127fn default_true() -> bool {
128    true
129}
130
131// ---------------------------------------------------------------------------
132// Tests
133// ---------------------------------------------------------------------------
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_question_serialization() {
141        let question = AskUserQuestion {
142            label: "Environment".to_string(),
143            question: "Which environment should I deploy to?".to_string(),
144            options: vec![
145                AskUserOption {
146                    value: "dev".to_string(),
147                    label: "Development".to_string(),
148                    description: Some("For testing changes".to_string()),
149                    selected: false,
150                },
151                AskUserOption {
152                    value: "prod".to_string(),
153                    label: "Production".to_string(),
154                    description: None,
155                    selected: false,
156                },
157            ],
158            allow_custom: true,
159            multi_select: false,
160        };
161
162        let json = serde_json::to_string(&question).unwrap();
163        assert!(json.contains("\"label\":\"Environment\""));
164        assert!(json.contains("\"value\":\"dev\""));
165        assert!(json.contains("\"description\":\"For testing changes\""));
166        // description: None should be skipped
167        assert!(!json.contains("\"description\":null"));
168    }
169
170    #[test]
171    fn test_question_deserialization_with_defaults() {
172        let json = r#"{
173            "label": "Test",
174            "question": "Is this a test?",
175            "options": []
176        }"#;
177
178        let question: AskUserQuestion = serde_json::from_str(json).unwrap();
179        assert_eq!(question.label, "Test");
180        assert!(question.allow_custom, "allow_custom should default to true");
181    }
182
183    #[test]
184    fn test_question_deserialization_explicit_false() {
185        let json = r#"{
186            "label": "Test",
187            "question": "Is this a test?",
188            "options": [],
189            "allow_custom": false
190        }"#;
191
192        let question: AskUserQuestion = serde_json::from_str(json).unwrap();
193        assert!(!question.allow_custom);
194    }
195
196    #[test]
197    fn test_answer_serialization() {
198        let answer = AskUserAnswer {
199            question_label: "Environment".to_string(),
200            answer: "production".to_string(),
201            is_custom: false,
202            selected_values: vec![],
203        };
204
205        let json = serde_json::to_string(&answer).unwrap();
206        assert!(json.contains("\"question_label\":\"Environment\""));
207        assert!(json.contains("\"answer\":\"production\""));
208        assert!(json.contains("\"is_custom\":false"));
209    }
210
211    #[test]
212    fn test_answer_custom_input() {
213        let answer = AskUserAnswer {
214            question_label: "Feedback".to_string(),
215            answer: "User typed this custom response".to_string(),
216            is_custom: true,
217            selected_values: vec![],
218        };
219
220        let json = serde_json::to_string(&answer).unwrap();
221        assert!(json.contains("\"is_custom\":true"));
222        assert!(json.contains("User typed this custom response"));
223    }
224
225    #[test]
226    fn test_result_completed() {
227        let result = AskUserResult {
228            answers: vec![
229                AskUserAnswer {
230                    question_label: "q1".to_string(),
231                    answer: "a1".to_string(),
232                    is_custom: false,
233                    selected_values: vec![],
234                },
235                AskUserAnswer {
236                    question_label: "q2".to_string(),
237                    answer: "custom answer".to_string(),
238                    is_custom: true,
239                    selected_values: vec![],
240                },
241            ],
242            completed: true,
243            reason: None,
244        };
245
246        let json = serde_json::to_string(&result).unwrap();
247        assert!(json.contains("\"completed\":true"));
248        // reason: None should be skipped
249        assert!(!json.contains("\"reason\""));
250        assert!(json.contains("\"question_label\":\"q1\""));
251        assert!(json.contains("\"question_label\":\"q2\""));
252    }
253
254    #[test]
255    fn test_result_cancelled() {
256        let result = AskUserResult {
257            answers: vec![],
258            completed: false,
259            reason: Some("User cancelled the question prompt.".to_string()),
260        };
261
262        let json = serde_json::to_string(&result).unwrap();
263        assert!(json.contains("\"completed\":false"));
264        assert!(json.contains("\"reason\":\"User cancelled the question prompt.\""));
265        assert!(json.contains("\"answers\":[]"));
266    }
267
268    #[test]
269    fn test_result_deserialization() {
270        let json = r#"{
271            "answers": [
272                {"question_label": "env", "answer": "dev", "is_custom": false}
273            ],
274            "completed": true
275        }"#;
276
277        let result: AskUserResult = serde_json::from_str(json).unwrap();
278        assert!(result.completed);
279        assert!(result.reason.is_none());
280        assert_eq!(result.answers.len(), 1);
281        assert_eq!(result.answers[0].question_label, "env");
282        assert_eq!(result.answers[0].answer, "dev");
283        assert!(!result.answers[0].is_custom);
284    }
285
286    #[test]
287    fn test_option_without_description() {
288        let option = AskUserOption {
289            value: "yes".to_string(),
290            label: "Yes".to_string(),
291            description: None,
292            selected: false,
293        };
294
295        let json = serde_json::to_string(&option).unwrap();
296        // description should be omitted entirely when None
297        assert!(!json.contains("description"));
298        assert!(json.contains("\"value\":\"yes\""));
299        assert!(json.contains("\"label\":\"Yes\""));
300    }
301
302    #[test]
303    fn test_unicode_handling() {
304        let question = AskUserQuestion {
305            label: "言語".to_string(),
306            question: "どの言語を使用しますか?".to_string(),
307            options: vec![
308                AskUserOption {
309                    value: "ja".to_string(),
310                    label: "日本語".to_string(),
311                    description: Some("Japanese language".to_string()),
312                    selected: false,
313                },
314                AskUserOption {
315                    value: "emoji".to_string(),
316                    label: "🚀 Rocket".to_string(),
317                    description: Some("With emoji 🎉".to_string()),
318                    selected: false,
319                },
320            ],
321            allow_custom: true,
322            multi_select: false,
323        };
324
325        let json = serde_json::to_string(&question).unwrap();
326        let parsed: AskUserQuestion = serde_json::from_str(&json).unwrap();
327
328        assert_eq!(parsed.label, "言語");
329        assert_eq!(parsed.question, "どの言語を使用しますか?");
330        assert_eq!(parsed.options[0].label, "日本語");
331        assert_eq!(parsed.options[1].label, "🚀 Rocket");
332    }
333
334    #[test]
335    fn test_types_equality() {
336        let q1 = AskUserQuestion {
337            label: "Test".to_string(),
338            question: "Question?".to_string(),
339            options: vec![],
340            allow_custom: true,
341            multi_select: false,
342        };
343
344        let q2 = q1.clone();
345        assert_eq!(q1, q2);
346
347        let a1 = AskUserAnswer {
348            question_label: "Test".to_string(),
349            answer: "answer".to_string(),
350            is_custom: false,
351            selected_values: vec![],
352        };
353
354        let a2 = a1.clone();
355        assert_eq!(a1, a2);
356
357        let r1 = AskUserResult {
358            answers: vec![a1],
359            completed: true,
360            reason: None,
361        };
362
363        let r2 = r1.clone();
364        assert_eq!(r1, r2);
365    }
366
367    #[test]
368    fn test_request_round_trip() {
369        let request = AskUserRequest {
370            questions: vec![AskUserQuestion {
371                label: "Env".to_string(),
372                question: "Which env?".to_string(),
373                options: vec![AskUserOption {
374                    value: "dev".to_string(),
375                    label: "Dev".to_string(),
376                    description: None,
377                    selected: false,
378                }],
379                allow_custom: false,
380                multi_select: false,
381            }],
382        };
383
384        let json = serde_json::to_string(&request).unwrap();
385        let parsed: AskUserRequest = serde_json::from_str(&json).unwrap();
386        assert_eq!(request, parsed);
387    }
388
389    #[test]
390    fn test_multi_select_defaults() {
391        let json = r#"{
392            "label": "Scope",
393            "question": "Which repos?",
394            "options": [
395                {"value": "a", "label": "Repo A"},
396                {"value": "b", "label": "Repo B", "selected": true}
397            ]
398        }"#;
399
400        let question: AskUserQuestion = serde_json::from_str(json).unwrap();
401        assert!(
402            !question.multi_select,
403            "multi_select should default to false"
404        );
405        assert!(
406            !question.options[0].selected,
407            "selected should default to false"
408        );
409        assert!(
410            question.options[1].selected,
411            "selected should be true when set"
412        );
413    }
414
415    #[test]
416    fn test_multi_select_question_round_trip() {
417        let question = AskUserQuestion {
418            label: "Scope".to_string(),
419            question: "Which repos should I include?".to_string(),
420            options: vec![
421                AskUserOption {
422                    value: "repo:api".to_string(),
423                    label: "~/projects/api".to_string(),
424                    description: None,
425                    selected: true,
426                },
427                AskUserOption {
428                    value: "repo:web".to_string(),
429                    label: "~/projects/web".to_string(),
430                    description: None,
431                    selected: false,
432                },
433            ],
434            allow_custom: false,
435            multi_select: true,
436        };
437
438        let json = serde_json::to_string(&question).unwrap();
439        assert!(json.contains("\"multi_select\":true"));
440        assert!(json.contains("\"selected\":true"));
441
442        let parsed: AskUserQuestion = serde_json::from_str(&json).unwrap();
443        assert_eq!(question, parsed);
444    }
445
446    #[test]
447    fn test_multi_select_answer_with_selected_values() {
448        let answer = AskUserAnswer {
449            question_label: "Scope".to_string(),
450            answer: "[\"repo:api\",\"repo:web\"]".to_string(),
451            is_custom: false,
452            selected_values: vec!["repo:api".to_string(), "repo:web".to_string()],
453        };
454
455        let json = serde_json::to_string(&answer).unwrap();
456        assert!(json.contains("\"selected_values\""));
457        assert!(json.contains("repo:api"));
458        assert!(json.contains("repo:web"));
459
460        let parsed: AskUserAnswer = serde_json::from_str(&json).unwrap();
461        assert_eq!(parsed.selected_values.len(), 2);
462    }
463
464    #[test]
465    fn test_selected_values_omitted_when_empty() {
466        let answer = AskUserAnswer {
467            question_label: "Env".to_string(),
468            answer: "dev".to_string(),
469            is_custom: false,
470            selected_values: vec![],
471        };
472
473        let json = serde_json::to_string(&answer).unwrap();
474        assert!(
475            !json.contains("selected_values"),
476            "selected_values should be omitted when empty"
477        );
478    }
479
480    #[test]
481    fn test_answer_deserialization_without_selected_values() {
482        // Backward compatibility: old answers without selected_values should still parse
483        let json = r#"{"question_label": "env", "answer": "dev", "is_custom": false}"#;
484        let answer: AskUserAnswer = serde_json::from_str(json).unwrap();
485        assert!(answer.selected_values.is_empty());
486    }
487
488    #[test]
489    fn test_option_value_defaults_to_label_when_missing() {
490        let json = r#"{"label": "Already configured", "description": "AWS CLI is configured"}"#;
491        let option: AskUserOption = serde_json::from_str(json).unwrap();
492        assert_eq!(option.value, "Already configured");
493        assert_eq!(option.label, "Already configured");
494        assert_eq!(
495            option.description,
496            Some("AWS CLI is configured".to_string())
497        );
498        assert!(!option.selected);
499    }
500
501    #[test]
502    fn test_option_explicit_value_preserved() {
503        let json =
504            r#"{"value": "dev", "label": "Development", "description": "For testing changes"}"#;
505        let option: AskUserOption = serde_json::from_str(json).unwrap();
506        assert_eq!(option.value, "dev");
507        assert_eq!(option.label, "Development");
508    }
509
510    #[test]
511    fn test_option_value_null_defaults_to_label() {
512        let json = r#"{"value": null, "label": "Production"}"#;
513        let option: AskUserOption = serde_json::from_str(json).unwrap();
514        assert_eq!(option.value, "Production");
515        assert_eq!(option.label, "Production");
516    }
517
518    #[test]
519    fn test_real_llm_payload_without_value_fields() {
520        // Exact payload from a real LLM response that caused a stuck ask_user popup.
521        let json = r#"{"questions":[{"allow_custom": false, "label": "AWS Config", "options": [{"description": "AWS CLI is configured with credentials (aws configure already done)", "label": "Already configured"}, {"description": "I'll provide Access Key ID and Secret Access Key", "label": "Need to configure"}], "question": "Is your AWS CLI already configured with credentials?"}, {"allow_custom": false, "label": "SSH Key", "options": [{"description": "I have an EC2 key pair created in AWS", "label": "Have key pair"}, {"description": "Need to create a new key pair in AWS", "label": "Need to create"}], "question": "Do you have an SSH key pair in AWS EC2 for instance access?"}]}"#;
522
523        let request: AskUserRequest = serde_json::from_str(json).unwrap();
524        assert_eq!(request.questions.len(), 2);
525        assert_eq!(request.questions[0].label, "AWS Config");
526        assert_eq!(request.questions[0].options.len(), 2);
527        // value should default to label
528        assert_eq!(request.questions[0].options[0].value, "Already configured");
529        assert_eq!(request.questions[0].options[1].value, "Need to configure");
530        assert_eq!(request.questions[1].options[0].value, "Have key pair");
531    }
532
533    #[test]
534    fn test_option_roundtrip_with_explicit_value() {
535        let option = AskUserOption {
536            value: "custom_value".to_string(),
537            label: "Display Label".to_string(),
538            description: None,
539            selected: false,
540        };
541        let json = serde_json::to_string(&option).unwrap();
542        let parsed: AskUserOption = serde_json::from_str(&json).unwrap();
543        assert_eq!(parsed.value, "custom_value");
544        assert_eq!(parsed.label, "Display Label");
545    }
546}