| 1 | //! Tool and types for requesting user input via the TUI. |
| 2 | |
| 3 | use super::spec::{ |
| 4 | ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, |
| 5 | }; |
| 6 | use async_trait::async_trait; |
| 7 | use serde::{Deserialize, Serialize}; |
| 8 | use serde_json::{Value, json}; |
| 9 | |
| 10 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 11 | pub struct UserInputOption { |
| 12 | pub label: String, |
| 13 | pub description: String, |
| 14 | } |
| 15 | |
| 16 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 17 | pub struct UserInputQuestion { |
| 18 | pub header: String, |
| 19 | pub id: String, |
| 20 | pub question: String, |
| 21 | pub options: Vec<UserInputOption>, |
| 22 | /// When `true`, the modal offers a free-text "Other" response in addition |
| 23 | /// to the fixed options. Defaults to `false` for backwards compatibility |
| 24 | /// (older payloads omitting the field get the previous behavior). |
| 25 | #[serde(default)] |
| 26 | pub allow_free_text: bool, |
| 27 | /// When `true`, the user may select more than one option before confirming. |
| 28 | #[serde(default)] |
| 29 | pub multi_select: bool, |
| 30 | } |
| 31 | |
| 32 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 33 | pub struct UserInputRequest { |
| 34 | pub questions: Vec<UserInputQuestion>, |
| 35 | } |
| 36 | |
| 37 | impl UserInputRequest { |
| 38 | pub fn from_value(value: &Value) -> Result<Self, ToolError> { |
| 39 | let request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| { |
| 40 | ToolError::invalid_input(format!("Invalid request_user_input payload: {e}")) |
| 41 | })?; |
| 42 | request.validate()?; |
| 43 | Ok(request) |
| 44 | } |
| 45 | |
| 46 | pub fn validate(&self) -> Result<(), ToolError> { |
| 47 | if self.questions.is_empty() { |
| 48 | return Err(ToolError::invalid_input( |
| 49 | "request_user_input.questions must be non-empty", |
| 50 | )); |
| 51 | } |
| 52 | if self.questions.len() > 3 { |
| 53 | return Err(ToolError::invalid_input( |
| 54 | "request_user_input.questions must contain 1 to 3 items", |
| 55 | )); |
| 56 | } |
| 57 | for q in &self.questions { |
| 58 | if q.header.trim().is_empty() { |
| 59 | return Err(ToolError::invalid_input( |
| 60 | "request_user_input.questions.header cannot be empty", |
| 61 | )); |
| 62 | } |
| 63 | if q.id.trim().is_empty() { |
| 64 | return Err(ToolError::invalid_input( |
| 65 | "request_user_input.questions.id cannot be empty", |
| 66 | )); |
| 67 | } |
| 68 | if q.question.trim().is_empty() { |
| 69 | return Err(ToolError::invalid_input( |
| 70 | "request_user_input.questions.question cannot be empty", |
| 71 | )); |
| 72 | } |
| 73 | if q.options.len() < 2 || q.options.len() > 4 { |
| 74 | return Err(ToolError::invalid_input( |
| 75 | "request_user_input.questions.options must contain 2 to 4 items", |
| 76 | )); |
| 77 | } |
| 78 | for opt in &q.options { |
| 79 | if opt.label.trim().is_empty() { |
| 80 | return Err(ToolError::invalid_input( |
| 81 | "request_user_input option label cannot be empty", |
| 82 | )); |
| 83 | } |
| 84 | if opt.description.trim().is_empty() { |
| 85 | return Err(ToolError::invalid_input( |
| 86 | "request_user_input option description cannot be empty", |
| 87 | )); |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | Ok(()) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 96 | pub struct UserInputAnswer { |
| 97 | pub id: String, |
| 98 | pub label: String, |
| 99 | pub value: String, |
| 100 | } |
| 101 | |
| 102 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 103 | pub struct UserInputResponse { |
| 104 | pub answers: Vec<UserInputAnswer>, |
| 105 | } |
| 106 | |
| 107 | pub struct RequestUserInputTool; |
| 108 | |
| 109 | #[async_trait] |
| 110 | impl ToolSpec for RequestUserInputTool { |
| 111 | fn name(&self) -> &'static str { |
| 112 | "request_user_input" |
| 113 | } |
| 114 | |
| 115 | fn description(&self) -> &'static str { |
| 116 | "Ask the user 1-3 short questions and return their selections." |
| 117 | } |
| 118 | |
| 119 | fn input_schema(&self) -> Value { |
| 120 | json!({ |
| 121 | "type": "object", |
| 122 | "properties": { |
| 123 | "questions": { |
| 124 | "type": "array", |
| 125 | "items": { |
| 126 | "type": "object", |
| 127 | "properties": { |
| 128 | "header": { "type": "string" }, |
| 129 | "id": { "type": "string" }, |
| 130 | "question": { "type": "string" }, |
| 131 | "options": { |
| 132 | "type": "array", |
| 133 | "items": { |
| 134 | "type": "object", |
| 135 | "properties": { |
| 136 | "label": { "type": "string" }, |
| 137 | "description": { "type": "string" } |
| 138 | }, |
| 139 | "required": ["label", "description"] |
| 140 | }, |
| 141 | "minItems": 2, |
| 142 | "maxItems": 4 |
| 143 | }, |
| 144 | "allow_free_text": { |
| 145 | "type": "boolean", |
| 146 | "description": "When true, also offer a free-text 'Other' response. Defaults to false.", |
| 147 | "default": false |
| 148 | }, |
| 149 | "multi_select": { |
| 150 | "type": "boolean", |
| 151 | "description": "When true, allow selecting more than one option. Defaults to false.", |
| 152 | "default": false |
| 153 | } |
| 154 | }, |
| 155 | "required": ["header", "id", "question", "options"] |
| 156 | }, |
| 157 | "minItems": 1, |
| 158 | "maxItems": 3 |
| 159 | } |
| 160 | }, |
| 161 | "required": ["questions"] |
| 162 | }) |
| 163 | } |
| 164 | |
| 165 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 166 | vec![ToolCapability::ReadOnly] |
| 167 | } |
| 168 | |
| 169 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 170 | ApprovalRequirement::Auto |
| 171 | } |
| 172 | |
| 173 | async fn execute( |
| 174 | &self, |
| 175 | _input: Value, |
| 176 | _context: &ToolContext, |
| 177 | ) -> Result<ToolResult, ToolError> { |
| 178 | Err(ToolError::execution_failed( |
| 179 | "request_user_input must be handled by the engine", |
| 180 | )) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | #[cfg(test)] |
| 185 | mod tests { |
| 186 | use super::*; |
| 187 | |
| 188 | #[test] |
| 189 | fn validates_request_shape() { |
| 190 | let request = UserInputRequest { |
| 191 | questions: vec![UserInputQuestion { |
| 192 | header: "Pick".to_string(), |
| 193 | id: "choice".to_string(), |
| 194 | question: "Which option?".to_string(), |
| 195 | options: vec![ |
| 196 | UserInputOption { |
| 197 | label: "A".to_string(), |
| 198 | description: "Option A".to_string(), |
| 199 | }, |
| 200 | UserInputOption { |
| 201 | label: "B".to_string(), |
| 202 | description: "Option B".to_string(), |
| 203 | }, |
| 204 | ], |
| 205 | allow_free_text: false, |
| 206 | multi_select: false, |
| 207 | }], |
| 208 | }; |
| 209 | assert!(request.validate().is_ok()); |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn from_value_accepts_four_options_and_flags() { |
| 214 | // Mirrors the json!-literal style used in tools/subagent/tests.rs and |
| 215 | // exercises the schema-loosening from issue #3102: 4 options (was capped |
| 216 | // at 3) plus the new allow_free_text / multi_select flags. |
| 217 | let input = json!({ |
| 218 | "questions": [{ |
| 219 | "header": "Scope", |
| 220 | "id": "scope", |
| 221 | "question": "Which surfaces should this change affect?", |
| 222 | "options": [ |
| 223 | { "label": "TUI", "description": "Visible modal flow only" }, |
| 224 | { "label": "Headless", "description": "Protocol event only" }, |
| 225 | { "label": "All surfaces", "description": "TUI and headless" }, |
| 226 | { "label": "CLI", "description": "Command-line surface" } |
| 227 | ], |
| 228 | "allow_free_text": true, |
| 229 | "multi_select": true |
| 230 | }] |
| 231 | }); |
| 232 | let request = UserInputRequest::from_value(&input).expect("4 options + flags parse"); |
| 233 | assert_eq!(request.questions.len(), 1); |
| 234 | assert_eq!(request.questions[0].options.len(), 4); |
| 235 | assert!(request.questions[0].allow_free_text); |
| 236 | assert!(request.questions[0].multi_select); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn from_value_defaults_flags_when_omitted() { |
| 241 | // Backwards compatibility: a legacy payload omitting the new boolean |
| 242 | // fields must still parse, defaulting both to false. |
| 243 | let input = json!({ |
| 244 | "questions": [{ |
| 245 | "header": "Pick", |
| 246 | "id": "choice", |
| 247 | "question": "Which?", |
| 248 | "options": [ |
| 249 | { "label": "A", "description": "a" }, |
| 250 | { "label": "B", "description": "b" } |
| 251 | ] |
| 252 | }] |
| 253 | }); |
| 254 | let request = UserInputRequest::from_value(&input).expect("legacy payload parses"); |
| 255 | assert!(!request.questions[0].allow_free_text); |
| 256 | assert!(!request.questions[0].multi_select); |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn rejects_five_options() { |
| 261 | let input = json!({ |
| 262 | "questions": [{ |
| 263 | "header": "Pick", |
| 264 | "id": "choice", |
| 265 | "question": "Which?", |
| 266 | "options": [ |
| 267 | { "label": "A", "description": "a" }, |
| 268 | { "label": "B", "description": "b" }, |
| 269 | { "label": "C", "description": "c" }, |
| 270 | { "label": "D", "description": "d" }, |
| 271 | { "label": "E", "description": "e" } |
| 272 | ] |
| 273 | }] |
| 274 | }); |
| 275 | let err = UserInputRequest::from_value(&input).expect_err("5 options must fail"); |
| 276 | assert!(err.to_string().contains("2 to 4 items")); |
| 277 | } |
| 278 | |
| 279 | fn yes_no_question(header: &str, id: &str) -> UserInputQuestion { |
| 280 | UserInputQuestion { |
| 281 | header: header.to_string(), |
| 282 | id: id.to_string(), |
| 283 | question: "?".to_string(), |
| 284 | options: vec![ |
| 285 | UserInputOption { |
| 286 | label: "A".to_string(), |
| 287 | description: "A".to_string(), |
| 288 | }, |
| 289 | UserInputOption { |
| 290 | label: "B".to_string(), |
| 291 | description: "B".to_string(), |
| 292 | }, |
| 293 | ], |
| 294 | allow_free_text: false, |
| 295 | multi_select: false, |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | #[test] |
| 300 | fn rejects_too_many_questions() { |
| 301 | let request = UserInputRequest { |
| 302 | questions: vec![ |
| 303 | yes_no_question("Q1", "q1"), |
| 304 | yes_no_question("Q2", "q2"), |
| 305 | yes_no_question("Q3", "q3"), |
| 306 | yes_no_question("Q4", "q4"), |
| 307 | ], |
| 308 | }; |
| 309 | assert!(request.validate().is_err()); |
| 310 | } |
| 311 | } |
| 312 |