Skip to main content

supercode_harness/tools/
question.rs

1//! BP-3 (§2 module 6 `tools.question`, catalog row "Structured
2//! user-question tool"): the tool a model uses to ask the USER a
3//! multiple-choice or free-text question mid-run, and wait for the answer.
4//!
5//! **One door, not a new one.** The design already names this module's
6//! protocol side: `crate::mcp::McpElicitationHandler` is documented as "the
7//! `tools.question` surface's PROTOCOL side (§2.1 dep)". So this tool does
8//! not invent a transport — it asks through that same handler, which under
9//! an SDK-owned runtime is the frontend request broker
10//! (`crate::server::FrontendRequestBridge::elicitation_handler`): the
11//! request is published into the sequenced frontend event stream as a
12//! `{"type":"request","request":{…}}` envelope and the turn BLOCKS on it
13//! until `harness.v1.runtimes.respond` answers with the content. That is the
14//! same broker, the same `respond` door, and the same request id space the
15//! approvals path uses; the two differ only in `kind` (an approval is
16//! allow/deny, a question carries structured answers back), which is exactly
17//! why `crate::approvals` filters non-approval kinds out of its listing.
18//!
19//! **Headless is deny-default** (§2 module 6's own "⚡ headless print mode
20//! (deny-default like OC, oc§1)"): with no handler installed nobody can
21//! answer, so the call fails with a message telling the model to decide for
22//! itself rather than hanging or silently inventing an answer.
23//!
24//! **Shape.** [`AskUserTool`] takes Claude Code's `AskUserQuestion` shape —
25//! 1-4 questions, each with a short header, 1-4 labelled options, an
26//! optional `multiSelect`, and (always) a free-text fallback. The same tool
27//! object is registered under Codex's experimental spelling
28//! [`REQUEST_USER_INPUT`] when a preset asks for it, so a continued Codex
29//! session's own tool name keeps resolving.
30
31use async_trait::async_trait;
32use serde::{Deserialize, Serialize};
33use serde_json::{json, Value};
34
35use crate::error::{Error, Result};
36use crate::mcp::{ElicitationAction, ElicitationRequest};
37use crate::tools::{Tool, ToolContext};
38
39/// Registered name of the question tool (Claude Code's `AskUserQuestion`).
40pub const ASK_USER: &str = "ask_user";
41
42/// Codex's experimental spelling for the same capability (cx§1
43/// `request_user_input`), registered as an alias under `cx-parity`.
44pub const REQUEST_USER_INPUT: &str = "request_user_input";
45
46/// The handler `ask_user` asks through, wrapped so [`ToolContext`] can stay
47/// `Debug` — the same newtype shape (and the same reason) as
48/// [`crate::tools::ToolApprovalHandler`].
49#[derive(Clone)]
50pub struct UserQuestionHandler(pub std::sync::Arc<dyn crate::mcp::McpElicitationHandler>);
51
52impl std::fmt::Debug for UserQuestionHandler {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.write_str("UserQuestionHandler(..)")
55    }
56}
57
58impl std::ops::Deref for UserQuestionHandler {
59    type Target = dyn crate::mcp::McpElicitationHandler;
60    fn deref(&self) -> &Self::Target {
61        &*self.0
62    }
63}
64
65/// Claude Code's cap: at most four questions in one call.
66pub const MAX_QUESTIONS: usize = 4;
67
68/// At most four options per question (the CC shape's own cap).
69pub const MAX_OPTIONS: usize = 4;
70
71/// One selectable answer.
72#[derive(Debug, Clone, Deserialize, Serialize)]
73pub struct QuestionOption {
74    /// Short label shown to the user (and the token an answer names).
75    pub label: String,
76    /// Optional longer explanation.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub description: Option<String>,
79}
80
81/// One question in an [`AskUserTool`] call.
82#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct Question {
84    /// The question text.
85    pub question: String,
86    /// Short header naming what is being decided (CC renders this as the
87    /// tab/column label). Defaults to the empty string.
88    #[serde(default)]
89    pub header: String,
90    /// Whether the user may pick more than one option.
91    #[serde(default, rename = "multiSelect", alias = "multi_select")]
92    pub multi_select: bool,
93    /// The offered options. May be empty for a purely free-text question.
94    #[serde(default)]
95    pub options: Vec<QuestionOption>,
96}
97
98#[derive(Debug, Deserialize)]
99struct AskUserArgs {
100    questions: Vec<Question>,
101}
102
103/// The structured user-question tool. `name` is the registered spelling —
104/// [`ASK_USER`] under `cc-parity`, additionally [`REQUEST_USER_INPUT`]
105/// under `cx-parity`.
106#[derive(Debug, Clone)]
107pub struct AskUserTool {
108    name: &'static str,
109}
110
111impl AskUserTool {
112    /// A tool registered under `name` (one of [`ASK_USER`] /
113    /// [`REQUEST_USER_INPUT`]).
114    pub fn new(name: &'static str) -> Self {
115        AskUserTool { name }
116    }
117}
118
119impl Default for AskUserTool {
120    fn default() -> Self {
121        AskUserTool::new(ASK_USER)
122    }
123}
124
125/// The JSON Schema handed to the frontend as the requested answer shape:
126/// one property per question, keyed by its 1-based index (`q1`, `q2`, …) so
127/// the mapping back is positional and cannot be confused by duplicate
128/// headers. Every property is free-text-capable — the offered labels travel
129/// as `x-options` (and are repeated in the description) rather than as a
130/// JSON-Schema `enum`, because the CC shape always permits a free-text
131/// answer that is not one of the labels.
132fn requested_schema(questions: &[Question]) -> Value {
133    let mut properties = serde_json::Map::new();
134    let mut required = Vec::new();
135    for (index, q) in questions.iter().enumerate() {
136        let key = format!("q{}", index + 1);
137        let labels: Vec<&str> = q.options.iter().map(|o| o.label.as_str()).collect();
138        let mut description = q.question.clone();
139        if !labels.is_empty() {
140            description.push_str(&format!(
141                " (options: {}; free text is also accepted)",
142                labels.join(" | ")
143            ));
144        }
145        let mut prop = json!({
146            "title": if q.header.is_empty() { q.question.clone() } else { q.header.clone() },
147            "description": description,
148            "x-options": q.options,
149            "x-multi-select": q.multi_select,
150        });
151        if q.multi_select {
152            prop["type"] = json!("array");
153            prop["items"] = json!({"type": "string"});
154        } else {
155            prop["type"] = json!("string");
156        }
157        properties.insert(key.clone(), prop);
158        required.push(key);
159    }
160    json!({
161        "type": "object",
162        "properties": properties,
163        "required": required,
164    })
165}
166
167/// Render the questions as the human-readable prompt line the request
168/// carries alongside its schema.
169fn message_for(questions: &[Question]) -> String {
170    let mut out = String::new();
171    for (index, q) in questions.iter().enumerate() {
172        if index > 0 {
173            out.push_str("\n\n");
174        }
175        if !q.header.is_empty() {
176            out.push_str(&format!("[{}] ", q.header));
177        }
178        out.push_str(&q.question);
179        for opt in &q.options {
180            out.push_str(&format!("\n  - {}", opt.label));
181            if let Some(d) = &opt.description {
182                out.push_str(&format!(" — {d}"));
183            }
184        }
185        if q.multi_select {
186            out.push_str("\n  (multiple selections allowed)");
187        }
188    }
189    out
190}
191
192/// Format the frontend's `content` object back into the text the model
193/// reads: one `header/question -> answer` line per question, plus the raw
194/// JSON so a model that prefers structure has it.
195fn format_answers(questions: &[Question], content: &Value) -> String {
196    let mut lines = Vec::new();
197    for (index, q) in questions.iter().enumerate() {
198        let key = format!("q{}", index + 1);
199        let answer = content.get(&key).map(render_answer).unwrap_or_else(|| {
200            content
201                .get(&q.header)
202                .map(render_answer)
203                .unwrap_or_else(|| "(no answer)".to_string())
204        });
205        let label = if q.header.is_empty() {
206            q.question.clone()
207        } else {
208            q.header.clone()
209        };
210        lines.push(format!("{label}: {answer}"));
211    }
212    format!(
213        "The user answered:\n{}\n\nraw: {}",
214        lines.join("\n"),
215        content
216    )
217}
218
219fn render_answer(v: &Value) -> String {
220    match v {
221        Value::String(s) => s.clone(),
222        Value::Array(items) => items
223            .iter()
224            .map(render_answer)
225            .collect::<Vec<_>>()
226            .join(", "),
227        other => other.to_string(),
228    }
229}
230
231#[async_trait]
232impl Tool for AskUserTool {
233    fn name(&self) -> &str {
234        self.name
235    }
236    fn description(&self) -> &str {
237        "Ask the user 1-4 structured questions and wait for the answers. Use it when a \
238         decision is genuinely the user's to make (a choice between real alternatives, a \
239         missing fact only they have) — never to ask permission for work you were already \
240         asked to do. Each question offers labelled options; the user may also answer in \
241         free text."
242    }
243    fn parameters(&self) -> Value {
244        json!({
245            "type": "object",
246            "properties": {
247                "questions": {
248                    "type": "array",
249                    "minItems": 1,
250                    "maxItems": MAX_QUESTIONS,
251                    "description": "1-4 questions to ask at once.",
252                    "items": {
253                        "type": "object",
254                        "properties": {
255                            "question": {"type": "string", "description": "The question text."},
256                            "header": {
257                                "type": "string",
258                                "description": "Short label (a few words) naming what is being decided."
259                            },
260                            "multiSelect": {
261                                "type": "boolean",
262                                "description": "Whether the user may pick more than one option."
263                            },
264                            "options": {
265                                "type": "array",
266                                "maxItems": MAX_OPTIONS,
267                                "items": {
268                                    "type": "object",
269                                    "properties": {
270                                        "label": {"type": "string"},
271                                        "description": {"type": "string"}
272                                    },
273                                    "required": ["label"],
274                                    "additionalProperties": false
275                                }
276                            }
277                        },
278                        "required": ["question", "options"],
279                        "additionalProperties": false
280                    }
281                }
282            },
283            "required": ["questions"],
284            "additionalProperties": false
285        })
286    }
287    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
288        let a: AskUserArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
289            tool: self.name().to_string(),
290            message: e.to_string(),
291        })?;
292        if a.questions.is_empty() || a.questions.len() > MAX_QUESTIONS {
293            return Err(Error::InvalidArguments {
294                tool: self.name().to_string(),
295                message: format!(
296                    "ask between 1 and {MAX_QUESTIONS} questions in one call (got {})",
297                    a.questions.len()
298                ),
299            });
300        }
301        for q in &a.questions {
302            if q.question.trim().is_empty() {
303                return Err(Error::InvalidArguments {
304                    tool: self.name().to_string(),
305                    message: "every question needs non-empty text".to_string(),
306                });
307            }
308            if q.options.len() > MAX_OPTIONS {
309                return Err(Error::InvalidArguments {
310                    tool: self.name().to_string(),
311                    message: format!("at most {MAX_OPTIONS} options per question"),
312                });
313            }
314            if q.options.iter().any(|o| o.label.trim().is_empty()) {
315                return Err(Error::InvalidArguments {
316                    tool: self.name().to_string(),
317                    message: "every option needs a non-empty label".to_string(),
318                });
319            }
320        }
321        // Headless (no interactive frontend attached) is deny-default: the
322        // model is told plainly that nobody can answer, so it decides for
323        // itself instead of waiting on a request that can never resolve.
324        let Some(handler) = ctx.question_handler.as_ref() else {
325            return Err(Error::tool(
326                self.name(),
327                "no interactive frontend is attached, so the user cannot be asked (headless \
328                 run): make the best decision you can and say which assumption you made",
329            ));
330        };
331        let request = ElicitationRequest {
332            message: message_for(&a.questions),
333            requested_schema: requested_schema(&a.questions),
334        };
335        let response = handler.handle(&request).await;
336        match response.action {
337            ElicitationAction::Accept => {
338                let content = response.content.unwrap_or_else(|| json!({}));
339                Ok(format_answers(&a.questions, &content))
340            }
341            ElicitationAction::Decline => Ok(
342                "The user declined to answer. Proceed with your own best judgement and say \
343                    what you assumed."
344                    .to_string(),
345            ),
346            ElicitationAction::Cancel => Ok("The user dismissed the question without \
347                                             answering. Proceed with your own best judgement \
348                                             and say what you assumed."
349                .to_string()),
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::mcp::{ElicitationResponse, McpElicitationHandler};
358    use std::sync::Arc;
359
360    struct Answering(Value);
361
362    #[async_trait]
363    impl McpElicitationHandler for Answering {
364        async fn handle(&self, _request: &ElicitationRequest) -> ElicitationResponse {
365            ElicitationResponse {
366                action: ElicitationAction::Accept,
367                content: Some(self.0.clone()),
368            }
369        }
370    }
371
372    struct Declining;
373
374    #[async_trait]
375    impl McpElicitationHandler for Declining {
376        async fn handle(&self, _request: &ElicitationRequest) -> ElicitationResponse {
377            ElicitationResponse {
378                action: ElicitationAction::Decline,
379                content: None,
380            }
381        }
382    }
383
384    fn one_question() -> Value {
385        json!({
386            "questions": [{
387                "question": "Which database?",
388                "header": "Database",
389                "options": [{"label": "postgres"}, {"label": "sqlite", "description": "local"}]
390            }]
391        })
392    }
393
394    #[tokio::test]
395    async fn headless_is_deny_default() {
396        let ctx = ToolContext::new(std::env::temp_dir());
397        let err = AskUserTool::default()
398            .execute(one_question(), &ctx)
399            .await
400            .expect_err("no handler must refuse");
401        assert!(err.to_string().contains("no interactive frontend"), "{err}");
402    }
403
404    #[tokio::test]
405    async fn an_answer_comes_back_to_the_model() {
406        let mut ctx = ToolContext::new(std::env::temp_dir());
407        ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
408            json!({"q1": "sqlite"}),
409        ))));
410        let out = AskUserTool::default()
411            .execute(one_question(), &ctx)
412            .await
413            .unwrap();
414        assert!(out.contains("Database: sqlite"), "{out}");
415    }
416
417    #[tokio::test]
418    async fn multi_select_answers_render_as_a_list() {
419        let mut ctx = ToolContext::new(std::env::temp_dir());
420        ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
421            json!({"q1": ["a", "b"]}),
422        ))));
423        let out = AskUserTool::default()
424            .execute(
425                json!({"questions": [{
426                    "question": "Which ones?",
427                    "header": "Targets",
428                    "multiSelect": true,
429                    "options": [{"label": "a"}, {"label": "b"}]
430                }]}),
431                &ctx,
432            )
433            .await
434            .unwrap();
435        assert!(out.contains("Targets: a, b"), "{out}");
436    }
437
438    #[tokio::test]
439    async fn free_text_is_accepted_even_when_it_matches_no_option() {
440        let mut ctx = ToolContext::new(std::env::temp_dir());
441        ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
442            json!({"q1": "duckdb, actually"}),
443        ))));
444        let out = AskUserTool::default()
445            .execute(one_question(), &ctx)
446            .await
447            .unwrap();
448        assert!(out.contains("duckdb, actually"), "{out}");
449    }
450
451    #[tokio::test]
452    async fn a_decline_is_reported_not_invented() {
453        let mut ctx = ToolContext::new(std::env::temp_dir());
454        ctx.question_handler = Some(UserQuestionHandler(Arc::new(Declining)));
455        let out = AskUserTool::default()
456            .execute(one_question(), &ctx)
457            .await
458            .unwrap();
459        assert!(out.contains("declined"), "{out}");
460    }
461
462    #[tokio::test]
463    async fn more_than_four_questions_is_refused() {
464        let mut ctx = ToolContext::new(std::env::temp_dir());
465        ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(json!({})))));
466        let many: Vec<Value> = (0..5)
467            .map(|i| json!({"question": format!("q{i}"), "options": []}))
468            .collect();
469        let err = AskUserTool::default()
470            .execute(json!({"questions": many}), &ctx)
471            .await
472            .expect_err("five questions must be refused");
473        assert!(err.to_string().contains("between 1 and 4"), "{err}");
474    }
475
476    #[test]
477    fn the_requested_schema_never_constrains_the_answer_to_an_enum() {
478        let questions = vec![Question {
479            question: "Which database?".into(),
480            header: "Database".into(),
481            multi_select: false,
482            options: vec![QuestionOption {
483                label: "postgres".into(),
484                description: None,
485            }],
486        }];
487        let schema = requested_schema(&questions);
488        let prop = &schema["properties"]["q1"];
489        assert_eq!(prop["type"], "string");
490        assert!(prop.get("enum").is_none(), "free text must stay possible");
491        assert_eq!(prop["x-options"][0]["label"], "postgres");
492    }
493
494    #[test]
495    fn the_cx_alias_keeps_its_own_registered_name() {
496        assert_eq!(
497            AskUserTool::new(REQUEST_USER_INPUT).name(),
498            "request_user_input"
499        );
500        assert_eq!(AskUserTool::default().name(), "ask_user");
501    }
502}