spec_ai_core/tools/builtin/
prompt.rs

1use crate::tools::{Tool, ToolResult};
2use anyhow::{anyhow, Context, Result};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use std::io::IsTerminal;
7use std::time::Duration;
8use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader, Stdin};
9use tokio::time::timeout;
10
11/// Tool for interactively prompting the human user for additional input.
12pub struct PromptUserTool;
13
14impl PromptUserTool {
15    pub fn new() -> Self {
16        Self
17    }
18
19    fn supports_interactive() -> bool {
20        std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
21    }
22
23    fn default_required() -> bool {
24        true
25    }
26
27    async fn prompt_interactively(&self, args: &PromptUserArgs) -> Result<NormalizedResponse> {
28        if !Self::supports_interactive() {
29            return Err(anyhow!(
30                "Interactive prompting is unavailable (stdin/stdout not a TTY). Provide `prefilled_response` instead."
31            ));
32        }
33
34        self.print_prompt_header(args).await?;
35        let stdin = io::stdin();
36        let mut reader = BufReader::new(stdin);
37        match args.input_type {
38            PromptInputType::MultilineText => self.collect_multiline(&mut reader, args).await,
39            _ => self.collect_single_line(&mut reader, args).await,
40        }
41    }
42
43    async fn collect_single_line(
44        &self,
45        reader: &mut BufReader<Stdin>,
46        args: &PromptUserArgs,
47    ) -> Result<NormalizedResponse> {
48        let mut stdout = io::stdout();
49        loop {
50            stdout.write_all(b"> ").await?;
51            stdout.flush().await?;
52            let mut buffer = String::new();
53            let bytes = self
54                .read_line(reader, &mut buffer, args.timeout_seconds)
55                .await?;
56            if bytes == 0 {
57                return Err(anyhow!("User input closed before a value was provided"));
58            }
59            let trimmed = buffer.trim_end().to_string();
60
61            if trimmed.is_empty() {
62                if let Some(res) = self.empty_fallback(args)? {
63                    return Ok(res);
64                }
65                self.write_warning(
66                    "A response is required. Please provide a value or configure a default.",
67                )
68                .await?;
69                continue;
70            }
71
72            match self.parse_user_input(&trimmed, args) {
73                Ok(resp) => return Ok(resp),
74                Err(err) => {
75                    self.write_warning(&format!("{}", err)).await?;
76                }
77            }
78        }
79    }
80
81    async fn collect_multiline(
82        &self,
83        reader: &mut BufReader<Stdin>,
84        args: &PromptUserArgs,
85    ) -> Result<NormalizedResponse> {
86        let mut stdout = io::stdout();
87        stdout
88            .write_all(
89                b"Enter your response. Finish by typing '/end' on its own line or '/skip' to leave empty.\n",
90            )
91            .await?;
92        stdout.flush().await?;
93
94        loop {
95            let mut lines = Vec::new();
96            loop {
97                stdout.write_all(b"| ").await?;
98                stdout.flush().await?;
99                let mut buffer = String::new();
100                let bytes = self
101                    .read_line(reader, &mut buffer, args.timeout_seconds)
102                    .await?;
103                if bytes == 0 {
104                    return Err(anyhow!(
105                        "User input closed before multiline response finished"
106                    ));
107                }
108                let trimmed = buffer.trim_end();
109                if trimmed.eq_ignore_ascii_case("/end") {
110                    break;
111                }
112                if trimmed.eq_ignore_ascii_case("/skip") {
113                    if let Some(res) = self.empty_fallback(args)? {
114                        return Ok(res);
115                    }
116                    self.write_warning(
117                        "This prompt is required. Please provide content before finishing.",
118                    )
119                    .await?;
120                    continue;
121                }
122                lines.push(trimmed.to_string());
123            }
124
125            if lines.is_empty() {
126                if let Some(res) = self.empty_fallback(args)? {
127                    return Ok(res);
128                }
129                self.write_warning("A response is required. Please try again.")
130                    .await?;
131                continue;
132            }
133
134            let combined = lines.join("\n");
135            return self.normalize_text_value(combined, args, false, false);
136        }
137    }
138
139    async fn read_line(
140        &self,
141        reader: &mut BufReader<Stdin>,
142        buffer: &mut String,
143        timeout_secs: Option<u64>,
144    ) -> Result<usize> {
145        buffer.clear();
146        if let Some(secs) = timeout_secs {
147            match timeout(Duration::from_secs(secs), reader.read_line(buffer)).await {
148                Ok(res) => Ok(res.context("Failed to read user input")?),
149                Err(_) => Err(anyhow!(
150                    "Timed out waiting for user input after {} seconds",
151                    secs
152                )),
153            }
154        } else {
155            Ok(reader
156                .read_line(buffer)
157                .await
158                .context("Failed to read user input")?)
159        }
160    }
161
162    async fn write_warning(&self, message: &str) -> Result<()> {
163        let mut stdout = io::stdout();
164        stdout
165            .write_all(format!("⚠️  {}\n", message).as_bytes())
166            .await?;
167        stdout.flush().await?;
168        Ok(())
169    }
170
171    async fn print_prompt_header(&self, args: &PromptUserArgs) -> Result<()> {
172        let mut stdout = io::stdout();
173        let mut section = String::new();
174        section.push_str("\n🔸 User Input Required\n");
175        section.push_str(&format!("{}\n", args.prompt.trim()));
176        if let Some(instructions) = &args.instructions {
177            section.push_str(instructions.trim());
178            section.push('\n');
179        }
180        if let Some(placeholder) = &args.placeholder {
181            section.push_str(&format!("Hint: {}\n", placeholder));
182        }
183        if !args.options.is_empty() {
184            section.push_str("Options:\n");
185            for (idx, opt) in args.options.iter().enumerate() {
186                let label = opt.label.as_deref().unwrap_or("(option)");
187                let mut line = format!("  [{}] {}", idx + 1, label);
188                if let Some(code) = &opt.short_code {
189                    line.push_str(&format!(" (code: {})", code));
190                }
191                if let Some(desc) = &opt.description {
192                    line.push_str(&format!(" — {}", desc));
193                }
194                if let Some(preview) = value_preview(&opt.value) {
195                    line.push_str(&format!(" [value: {}]", preview));
196                }
197                line.push('\n');
198                section.push_str(&line);
199            }
200            if args.allow_freeform {
201                section.push_str("  (Custom values are allowed.)\n");
202            }
203        }
204        if let Some(validation) = &args.validation_hint {
205            section.push_str(&format!("Validation: {}\n", validation));
206        }
207
208        match args.input_type {
209            PromptInputType::MultilineText => {
210                section.push_str(
211                    "Enter text over multiple lines. Type '/end' on its own line when finished.\n",
212                );
213            }
214            PromptInputType::Boolean => {
215                section.push_str("Respond with yes/no, true/false, or y/n.\n");
216            }
217            PromptInputType::Number => {
218                section.push_str("Enter a numeric value.\n");
219            }
220            PromptInputType::SingleSelect | PromptInputType::MultiSelect => {
221                section.push_str(
222                    "Choose by number, label, or short code. Separate multiple choices with commas.\n",
223                );
224            }
225            PromptInputType::Json => {
226                section.push_str("Provide a JSON value (object, array, string, etc.).\n");
227            }
228            PromptInputType::Text => {}
229        }
230
231        stdout.write_all(section.as_bytes()).await?;
232        stdout.flush().await?;
233        Ok(())
234    }
235
236    fn empty_fallback(&self, args: &PromptUserArgs) -> Result<Option<NormalizedResponse>> {
237        if let Some(default_value) = &args.default_value {
238            let mut normalized = self.normalize_prefill(default_value.clone(), args)?;
239            normalized.used_default = true;
240            return Ok(Some(normalized));
241        }
242        if !args.required {
243            return Ok(Some(NormalizedResponse::empty()));
244        }
245        Ok(None)
246    }
247
248    fn parse_user_input(&self, raw: &str, args: &PromptUserArgs) -> Result<NormalizedResponse> {
249        match args.input_type {
250            PromptInputType::Text => self.normalize_text_value(raw.to_string(), args, false, false),
251            PromptInputType::MultilineText => {
252                self.normalize_text_value(raw.to_string(), args, false, false)
253            }
254            PromptInputType::Boolean => {
255                let value = parse_bool(raw)
256                    .ok_or_else(|| anyhow!("Could not interpret '{}' as yes/no", raw))?;
257                Ok(NormalizedResponse::from_bool(value))
258            }
259            PromptInputType::Number => {
260                let value: f64 = raw
261                    .parse()
262                    .map_err(|_| anyhow!("Could not interpret '{}' as a number", raw))?;
263                self.normalize_number_value(value, args, false, false)
264            }
265            PromptInputType::SingleSelect => self.resolve_single_selection(raw, args, false, false),
266            PromptInputType::MultiSelect => self.resolve_multi_selection(raw, args, false, false),
267            PromptInputType::Json => {
268                let value: Value = serde_json::from_str(raw)
269                    .map_err(|err| anyhow!("Invalid JSON input: {}", err))?;
270                Ok(NormalizedResponse::from_json(value))
271            }
272        }
273    }
274
275    fn normalize_prefill(&self, value: Value, args: &PromptUserArgs) -> Result<NormalizedResponse> {
276        match args.input_type {
277            PromptInputType::Text | PromptInputType::MultilineText => {
278                let text = value_to_owned_string(&value).ok_or_else(|| {
279                    anyhow!("prefilled_response must be a string for text prompts")
280                })?;
281                self.normalize_text_value(text, args, false, true)
282            }
283            PromptInputType::Boolean => {
284                let as_bool = match value {
285                    Value::Bool(b) => Some(b),
286                    Value::String(s) => parse_bool(&s),
287                    Value::Number(n) => {
288                        if let Some(i) = n.as_i64() {
289                            if i == 0 {
290                                Some(false)
291                            } else if i == 1 {
292                                Some(true)
293                            } else {
294                                None
295                            }
296                        } else {
297                            None
298                        }
299                    }
300                    _ => None,
301                }
302                .ok_or_else(|| anyhow!("prefilled_response must be boolean"))?;
303                Ok(NormalizedResponse::from_prefilled_bool(as_bool))
304            }
305            PromptInputType::Number => {
306                let numeric = match &value {
307                    Value::Number(num) => num.as_f64(),
308                    Value::String(s) => s.parse().ok(),
309                    _ => None,
310                }
311                .ok_or_else(|| anyhow!("prefilled_response must be numeric"))?;
312                self.normalize_number_value(numeric, args, false, true)
313            }
314            PromptInputType::SingleSelect => {
315                if value.is_null() && !args.required {
316                    return Ok(NormalizedResponse::empty());
317                }
318                self.match_prefilled_selection(value, args, false, true)
319            }
320            PromptInputType::MultiSelect => self.match_prefilled_multi(value, args, false, true),
321            PromptInputType::Json => Ok(NormalizedResponse::from_prefilled_json(value)),
322        }
323    }
324
325    fn normalize_text_value(
326        &self,
327        mut text: String,
328        args: &PromptUserArgs,
329        used_default: bool,
330        used_prefill: bool,
331    ) -> Result<NormalizedResponse> {
332        let len = text.chars().count();
333        if let Some(min) = args.min_length {
334            if len < min {
335                return Err(anyhow!("Response must be at least {} characters", min));
336            }
337        }
338        if let Some(max) = args.max_length {
339            if len > max {
340                text.truncate(max);
341            }
342        }
343        Ok(NormalizedResponse::from_string(
344            text,
345            used_default,
346            used_prefill,
347        ))
348    }
349
350    fn normalize_number_value(
351        &self,
352        value: f64,
353        args: &PromptUserArgs,
354        used_default: bool,
355        used_prefill: bool,
356    ) -> Result<NormalizedResponse> {
357        if let Some(min) = args.min_value {
358            if value < min {
359                return Err(anyhow!("Value must be >= {}", min));
360            }
361        }
362        if let Some(max) = args.max_value {
363            if value > max {
364                return Err(anyhow!("Value must be <= {}", max));
365            }
366        }
367        if let Some(step) = args.step {
368            if step > 0.0 {
369                let quotient = value / step;
370                let nearest = quotient.round();
371                if (quotient - nearest).abs() > 1e-6 {
372                    return Err(anyhow!("Value must be a multiple of {}", step));
373                }
374            }
375        }
376        Ok(NormalizedResponse::from_number(
377            value,
378            used_default,
379            used_prefill,
380        ))
381    }
382
383    fn resolve_single_selection(
384        &self,
385        raw: &str,
386        args: &PromptUserArgs,
387        used_default: bool,
388        used_prefill: bool,
389    ) -> Result<NormalizedResponse> {
390        if args.options.is_empty() && !args.allow_freeform {
391            return Err(anyhow!(
392                "No options provided. Set `allow_freeform` to true for free-text answers."
393            ));
394        }
395
396        if args.options.is_empty() {
397            return Ok(NormalizedResponse::from_string(
398                raw.to_string(),
399                used_default,
400                used_prefill,
401            ));
402        }
403
404        match match_option(raw, &args.options) {
405            Some((label, value)) => Ok(NormalizedResponse::from_selection(
406                value,
407                Some(label),
408                used_default,
409                used_prefill,
410            )),
411            None if args.allow_freeform => Ok(NormalizedResponse::from_string(
412                raw.to_string(),
413                used_default,
414                used_prefill,
415            )),
416            None => Err(anyhow!("'{}' did not match any available options", raw)),
417        }
418    }
419
420    fn resolve_multi_selection(
421        &self,
422        raw: &str,
423        args: &PromptUserArgs,
424        used_default: bool,
425        used_prefill: bool,
426    ) -> Result<NormalizedResponse> {
427        if args.options.is_empty() && !args.allow_freeform {
428            return Err(anyhow!(
429                "Multi-select prompts require options unless `allow_freeform` is true"
430            ));
431        }
432        let tokens: Vec<_> = raw
433            .split(',')
434            .map(|t| t.trim())
435            .filter(|t| !t.is_empty())
436            .collect();
437        if tokens.is_empty() {
438            return Err(anyhow!("Provide at least one selection"));
439        }
440
441        let mut values = Vec::new();
442        let mut labels = Vec::new();
443        for token in tokens {
444            if let Some((label, value)) = match_option(token, &args.options) {
445                values.push(value);
446                labels.push(label);
447            } else if args.allow_freeform {
448                values.push(Value::String(token.to_string()));
449            } else {
450                return Err(anyhow!("'{}' did not match any available options", token));
451            }
452        }
453        Ok(NormalizedResponse::from_multi_selection(
454            values,
455            labels,
456            used_default,
457            used_prefill,
458        ))
459    }
460
461    fn match_prefilled_selection(
462        &self,
463        value: Value,
464        args: &PromptUserArgs,
465        used_default: bool,
466        used_prefill: bool,
467    ) -> Result<NormalizedResponse> {
468        if args.options.is_empty() {
469            if args.allow_freeform {
470                return Ok(NormalizedResponse::from_json(value));
471            }
472            return Err(anyhow!(
473                "prefilled_response must correspond to a provided option"
474            ));
475        }
476
477        for opt in &args.options {
478            if opt.value == value {
479                let label = opt
480                    .label
481                    .clone()
482                    .or_else(|| value_to_owned_string(&opt.value));
483                return Ok(NormalizedResponse::from_selection(
484                    opt.value.clone(),
485                    label,
486                    used_default,
487                    used_prefill,
488                ));
489            }
490        }
491
492        if args.allow_freeform {
493            return Ok(NormalizedResponse::from_json(value));
494        }
495
496        Err(anyhow!("prefilled_response value did not match any option"))
497    }
498
499    fn match_prefilled_multi(
500        &self,
501        value: Value,
502        args: &PromptUserArgs,
503        used_default: bool,
504        used_prefill: bool,
505    ) -> Result<NormalizedResponse> {
506        let values = if let Value::Array(arr) = value {
507            arr
508        } else if let Some(s) = value_to_owned_string(&value) {
509            s.split(',')
510                .map(|t| Value::String(t.trim().to_string()))
511                .collect()
512        } else {
513            return Err(anyhow!(
514                "prefilled_response must be an array or comma-delimited string"
515            ));
516        };
517
518        if values.is_empty() {
519            return Err(anyhow!(
520                "prefilled_response must contain at least one entry"
521            ));
522        }
523
524        let mut resolved_values = Vec::new();
525        let mut labels = Vec::new();
526        for val in values {
527            if let Some(opt_label) =
528                args.options
529                    .iter()
530                    .find(|opt| opt.value == val)
531                    .and_then(|opt| {
532                        opt.label
533                            .clone()
534                            .or_else(|| value_to_owned_string(&opt.value))
535                    })
536            {
537                resolved_values.push(val.clone());
538                labels.push(opt_label);
539            } else if args.allow_freeform {
540                resolved_values.push(val.clone());
541            } else {
542                return Err(anyhow!(
543                    "prefilled_response contained a value not present in options"
544                ));
545            }
546        }
547
548        Ok(NormalizedResponse::from_multi_selection(
549            resolved_values,
550            labels,
551            used_default,
552            used_prefill,
553        ))
554    }
555}
556
557impl Default for PromptUserTool {
558    fn default() -> Self {
559        Self::new()
560    }
561}
562
563#[async_trait]
564impl Tool for PromptUserTool {
565    fn name(&self) -> &str {
566        "prompt_user"
567    }
568
569    fn description(&self) -> &str {
570        "Prompts the human user for structured input (text, boolean, number, selections, or JSON)."
571    }
572
573    fn parameters(&self) -> Value {
574        json!({
575            "type": "object",
576            "properties": {
577                "prompt": {"type": "string", "description": "Friendly prompt shown to the user."},
578                "input_type": {
579                    "type": "string",
580                    "description": "Type of input expected from the user.",
581                    "enum": [
582                        "text",
583                        "multiline_text",
584                        "boolean",
585                        "number",
586                        "single_select",
587                        "multi_select",
588                        "json"
589                    ],
590                    "default": "text"
591                },
592                "placeholder": {"type": "string"},
593                "instructions": {"type": "string", "description": "Extra instructions displayed before collecting input."},
594                "required": {"type": "boolean", "default": true},
595                "options": {
596                    "type": "array",
597                    "description": "List of allowed options for select-style prompts.",
598                    "items": {
599                        "type": "object",
600                        "properties": {
601                            "label": {"type": "string"},
602                            "description": {"type": "string"},
603                            "short_code": {"type": "string", "description": "Short alias like 'a', 'b', or 'high'."},
604                            "value": {"description": "JSON value returned when this option is chosen."}
605                        },
606                        "required": ["value"]
607                    }
608                },
609                "allow_freeform": {
610                    "type": "boolean",
611                    "description": "Allow responses outside the provided options for selection prompts.",
612                    "default": false
613                },
614                "default_value": {
615                    "description": "Default value used when the user skips the prompt."
616                },
617                "prefilled_response": {
618                    "description": "Provide a value here to bypass interactive prompting (useful for automated flows)."
619                },
620                "min_length": {"type": "integer", "minimum": 0},
621                "max_length": {"type": "integer", "minimum": 1},
622                "min_value": {"type": "number"},
623                "max_value": {"type": "number"},
624                "step": {
625                    "type": "number",
626                    "description": "Restrict numeric responses to multiples of this value."
627                },
628                "validation_hint": {"type": "string", "description": "Text displayed to the user describing validation requirements."},
629                "metadata": {"type": "object", "description": "Arbitrary metadata echoed back with the response."},
630                "timeout_seconds": {
631                    "type": "integer",
632                    "minimum": 1,
633                    "description": "Abort prompting if no input is received within this many seconds."
634                }
635            },
636            "required": ["prompt", "input_type"]
637        })
638    }
639
640    async fn execute(&self, args: Value) -> Result<ToolResult> {
641        let params: PromptUserArgs =
642            serde_json::from_value(args).context("Failed to parse prompt_user arguments")?;
643
644        let response = if let Some(prefill) = &params.prefilled_response {
645            match self.normalize_prefill(prefill.clone(), &params) {
646                Ok(mut resp) => {
647                    resp.used_prefill = true;
648                    resp
649                }
650                Err(err) => return Ok(ToolResult::failure(err.to_string())),
651            }
652        } else {
653            match self.prompt_interactively(&params).await {
654                Ok(resp) => resp,
655                Err(err) => return Ok(ToolResult::failure(err.to_string())),
656            }
657        };
658
659        let payload = PromptUserPayload {
660            prompt: params.prompt,
661            input_type: params.input_type.as_str().to_string(),
662            response: response.value,
663            display_value: response.display_value,
664            selections: response.selection_labels,
665            metadata: params.metadata,
666            used_default: response.used_default,
667            used_prefill: response.used_prefill,
668        };
669
670        let output = serde_json::to_string(&payload)?;
671        Ok(ToolResult::success(output))
672    }
673}
674
675#[derive(Debug, Clone, Deserialize)]
676#[serde(rename_all = "snake_case")]
677#[derive(Default)]
678enum PromptInputType {
679    #[default]
680    Text,
681    MultilineText,
682    Boolean,
683    Number,
684    SingleSelect,
685    MultiSelect,
686    Json,
687}
688
689impl PromptInputType {
690    fn as_str(&self) -> &'static str {
691        match self {
692            PromptInputType::Text => "text",
693            PromptInputType::MultilineText => "multiline_text",
694            PromptInputType::Boolean => "boolean",
695            PromptInputType::Number => "number",
696            PromptInputType::SingleSelect => "single_select",
697            PromptInputType::MultiSelect => "multi_select",
698            PromptInputType::Json => "json",
699        }
700    }
701}
702
703#[derive(Debug, Clone, Deserialize)]
704struct PromptOption {
705    #[serde(default)]
706    label: Option<String>,
707    #[serde(default)]
708    description: Option<String>,
709    #[serde(default)]
710    short_code: Option<String>,
711    value: Value,
712}
713
714#[derive(Debug, Deserialize)]
715struct PromptUserArgs {
716    prompt: String,
717    #[serde(default)]
718    input_type: PromptInputType,
719    #[serde(default)]
720    placeholder: Option<String>,
721    #[serde(default)]
722    instructions: Option<String>,
723    #[serde(default = "PromptUserTool::default_required")]
724    required: bool,
725    #[serde(default)]
726    options: Vec<PromptOption>,
727    #[serde(default)]
728    allow_freeform: bool,
729    #[serde(default)]
730    default_value: Option<Value>,
731    #[serde(default)]
732    prefilled_response: Option<Value>,
733    #[serde(default)]
734    min_length: Option<usize>,
735    #[serde(default)]
736    max_length: Option<usize>,
737    #[serde(default)]
738    min_value: Option<f64>,
739    #[serde(default)]
740    max_value: Option<f64>,
741    #[serde(default)]
742    step: Option<f64>,
743    #[serde(default)]
744    validation_hint: Option<String>,
745    #[serde(default)]
746    metadata: Option<Value>,
747    #[serde(default)]
748    timeout_seconds: Option<u64>,
749}
750
751#[derive(Debug, Serialize)]
752struct PromptUserPayload {
753    prompt: String,
754    input_type: String,
755    response: Value,
756    #[serde(skip_serializing_if = "Option::is_none")]
757    display_value: Option<String>,
758    #[serde(skip_serializing_if = "Option::is_none")]
759    selections: Option<Vec<String>>,
760    #[serde(skip_serializing_if = "Option::is_none")]
761    metadata: Option<Value>,
762    used_default: bool,
763    used_prefill: bool,
764}
765
766#[derive(Debug)]
767struct NormalizedResponse {
768    value: Value,
769    display_value: Option<String>,
770    selection_labels: Option<Vec<String>>,
771    used_default: bool,
772    used_prefill: bool,
773}
774
775impl NormalizedResponse {
776    fn empty() -> Self {
777        Self {
778            value: Value::Null,
779            display_value: None,
780            selection_labels: None,
781            used_default: false,
782            used_prefill: false,
783        }
784    }
785
786    fn from_string(value: String, used_default: bool, used_prefill: bool) -> Self {
787        Self {
788            display_value: Some(value.clone()),
789            value: Value::String(value),
790            selection_labels: None,
791            used_default,
792            used_prefill,
793        }
794    }
795
796    fn from_bool(value: bool) -> Self {
797        Self {
798            display_value: Some(value.to_string()),
799            value: Value::Bool(value),
800            selection_labels: None,
801            used_default: false,
802            used_prefill: false,
803        }
804    }
805
806    fn from_prefilled_bool(value: bool) -> Self {
807        Self {
808            display_value: Some(value.to_string()),
809            value: Value::Bool(value),
810            selection_labels: None,
811            used_default: false,
812            used_prefill: true,
813        }
814    }
815
816    fn from_number(value: f64, used_default: bool, used_prefill: bool) -> Self {
817        Self {
818            display_value: Some(value.to_string()),
819            value: json!(value),
820            selection_labels: None,
821            used_default,
822            used_prefill,
823        }
824    }
825
826    fn from_json(value: Value) -> Self {
827        Self {
828            selection_labels: None,
829            display_value: value_to_owned_string(&value),
830            value,
831            used_default: false,
832            used_prefill: false,
833        }
834    }
835
836    fn from_prefilled_json(value: Value) -> Self {
837        Self {
838            selection_labels: None,
839            display_value: value_to_owned_string(&value),
840            value,
841            used_default: false,
842            used_prefill: true,
843        }
844    }
845
846    fn from_selection(
847        value: Value,
848        label: Option<String>,
849        used_default: bool,
850        used_prefill: bool,
851    ) -> Self {
852        Self {
853            selection_labels: label.clone().map(|l| vec![l.clone()]),
854            display_value: label,
855            value,
856            used_default,
857            used_prefill,
858        }
859    }
860
861    fn from_multi_selection(
862        values: Vec<Value>,
863        labels: Vec<String>,
864        used_default: bool,
865        used_prefill: bool,
866    ) -> Self {
867        Self {
868            selection_labels: if labels.is_empty() {
869                None
870            } else {
871                Some(labels)
872            },
873            display_value: None,
874            value: Value::Array(values),
875            used_default,
876            used_prefill,
877        }
878    }
879}
880
881fn parse_bool(input: &str) -> Option<bool> {
882    match input.trim().to_lowercase().as_str() {
883        "y" | "yes" | "true" | "t" | "1" => Some(true),
884        "n" | "no" | "false" | "f" | "0" => Some(false),
885        _ => None,
886    }
887}
888
889fn match_option(token: &str, options: &[PromptOption]) -> Option<(String, Value)> {
890    if options.is_empty() {
891        return None;
892    }
893    let trimmed = token.trim();
894    let lower = trimmed.to_lowercase();
895    let numeric_choice = trimmed.parse::<usize>().ok();
896
897    for (idx, opt) in options.iter().enumerate() {
898        if let Some(choice) = numeric_choice {
899            if choice == idx + 1 {
900                let label = opt
901                    .label
902                    .clone()
903                    .or_else(|| value_to_owned_string(&opt.value))
904                    .unwrap_or_else(|| format!("Option {}", choice));
905                return Some((label, opt.value.clone()));
906            }
907        }
908
909        if let Some(label) = &opt.label {
910            if label.to_lowercase() == lower {
911                return Some((label.clone(), opt.value.clone()));
912            }
913        }
914
915        if let Some(code) = &opt.short_code {
916            if code.to_lowercase() == lower {
917                let label = opt.label.clone().unwrap_or_else(|| code.clone());
918                return Some((label, opt.value.clone()));
919            }
920        }
921
922        if let Some(value_repr) = value_to_owned_string(&opt.value) {
923            if value_repr.to_lowercase() == lower {
924                let label = opt.label.clone().unwrap_or(value_repr.clone());
925                return Some((label, opt.value.clone()));
926            }
927        }
928    }
929
930    None
931}
932
933fn value_to_owned_string(value: &Value) -> Option<String> {
934    match value {
935        Value::Null => None,
936        Value::Bool(b) => Some(b.to_string()),
937        Value::Number(n) => Some(n.to_string()),
938        Value::String(s) => Some(s.clone()),
939        Value::Array(_) | Value::Object(_) => Some(value.to_string()),
940    }
941}
942
943fn value_preview(value: &Value) -> Option<String> {
944    let repr = value_to_owned_string(value)?;
945    if repr.len() > 48 {
946        Some(format!("{}…", &repr[..45]))
947    } else {
948        Some(repr)
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    #[tokio::test]
957    async fn test_prompt_user_prefilled_text() {
958        let tool = PromptUserTool::new();
959        let args = json!({
960            "prompt": "Provide a status update",
961            "input_type": "text",
962            "prefilled_response": "All systems nominal"
963        });
964
965        let result = tool.execute(args).await.unwrap();
966        assert!(result.success);
967
968        let payload: Value = serde_json::from_str(&result.output).unwrap();
969        assert_eq!(payload["response"], "All systems nominal");
970        assert_eq!(payload["used_prefill"], true);
971    }
972
973    #[tokio::test]
974    async fn test_prompt_user_prefilled_select() {
975        let tool = PromptUserTool::new();
976        let args = json!({
977            "prompt": "Choose environment",
978            "input_type": "single_select",
979            "options": [
980                {"label": "Production", "short_code": "prod", "value": "prod"},
981                {"label": "Staging", "short_code": "stage", "value": "stage"}
982            ],
983            "prefilled_response": "stage"
984        });
985
986        let result = tool.execute(args).await.unwrap();
987        assert!(result.success, "error: {:?}", result.error);
988        let payload: Value = serde_json::from_str(&result.output).unwrap();
989        assert_eq!(payload["response"], "stage");
990        assert_eq!(payload["selections"].as_array().unwrap()[0], "Staging");
991    }
992
993    #[tokio::test]
994    async fn test_prompt_user_prefilled_multi_select() {
995        let tool = PromptUserTool::new();
996        let args = json!({
997            "prompt": "Select tags",
998            "input_type": "multi_select",
999            "options": [
1000                {"label": "Urgent", "short_code": "u", "value": "urgent"},
1001                {"label": "Follow-up", "short_code": "f", "value": "follow"}
1002            ],
1003            "prefilled_response": ["urgent", "follow"]
1004        });
1005
1006        let result = tool.execute(args).await.unwrap();
1007        assert!(result.success);
1008        let payload: Value = serde_json::from_str(&result.output).unwrap();
1009        assert_eq!(payload["response"].as_array().unwrap().len(), 2);
1010        assert_eq!(payload["used_prefill"], true);
1011    }
1012
1013    #[tokio::test]
1014    async fn test_prompt_user_missing_prefill_fails_when_noninteractive() {
1015        // Skip this test if running in an interactive terminal
1016        if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1017            eprintln!("Skipping test: running in interactive terminal");
1018            return;
1019        }
1020
1021        let tool = PromptUserTool::new();
1022        let args = json!({
1023            "prompt": "Need manual input",
1024            "input_type": "text"
1025        });
1026
1027        let result = tool.execute(args).await.unwrap();
1028        assert!(!result.success);
1029        assert!(result
1030            .error
1031            .unwrap_or_default()
1032            .contains("Interactive prompting is unavailable"));
1033    }
1034
1035    #[tokio::test]
1036    async fn test_prompt_user_invalid_prefill_option() {
1037        let tool = PromptUserTool::new();
1038        let args = json!({
1039            "prompt": "Pick a lane",
1040            "input_type": "single_select",
1041            "options": [
1042                {"label": "Blue", "value": "blue"}
1043            ],
1044            "prefilled_response": "red"
1045        });
1046
1047        let result = tool.execute(args).await.unwrap();
1048        assert!(!result.success);
1049    }
1050}