Skip to main content

polyc_agent/
question.rs

1//! `ask_question` (#1660): parse/validate the model's clarifying-question
2//! batch, and the pure types the turn loop's question-pause phase builds and
3//! consumes.
4//!
5//! This is a **sibling pause path to the tool-approval gate, not a reuse of
6//! it** — see issue #1660 and its parent PRD #1659. This module owns only
7//! the parse/validate half (invariant I5: a malformed call is rejected back
8//! to the model as a tool-call error and never reaches a pause or an
9//! event-log write); the pause/resume machinery that consumes
10//! [`QuestionItem`] (a pending-question record, the turn result's own
11//! pending-questions list) lands in a later slice of this same issue.
12//!
13//! # Why the tool name is duplicated here rather than imported
14//!
15//! `polyc_tools` already depends on `polyc_agent` (for
16//! [`crate::ToolExecutor`]), so this crate cannot depend back on
17//! `polyc_tools` without a cycle. [`ASK_QUESTION_TOOL_NAME`] is therefore the
18//! same literal as `polyc_tools::ask_question::TOOL_NAME`, duplicated
19//! deliberately — the same reasoning `polyc_proto`'s `INVITE_TOOL_NAME`
20//! duplication already documents. A cross-crate test in `polyc-tools` pins
21//! the two literals equal.
22
23use serde_json::Value;
24
25/// The `ask_question` tool name the turn loop recognizes to trigger the
26/// question-pause phase.
27///
28/// Kept in sync with `polyc_tools::ask_question::TOOL_NAME` by a cross-crate
29/// test in that crate (see the module doc for why it's not imported
30/// directly).
31pub const ASK_QUESTION_TOOL_NAME: &str = "ask_question";
32
33/// Maximum number of questions in a single `ask_question` call. Kept in sync
34/// with `polyc_tools::ask_question::MAX_QUESTIONS_PER_CALL`.
35pub const MAX_QUESTIONS_PER_CALL: usize = 3;
36
37/// Minimum number of options a question may offer. Kept in sync with
38/// `polyc_tools::ask_question::MIN_OPTIONS`.
39pub const MIN_OPTIONS: usize = 2;
40
41/// Maximum number of options a question may offer. Kept in sync with
42/// `polyc_tools::ask_question::MAX_OPTIONS`.
43pub const MAX_OPTIONS: usize = 4;
44
45/// Maximum length (in characters) of a question's `header`. Kept in sync with
46/// `polyc_tools::ask_question::MAX_HEADER_CHARS`.
47pub const MAX_HEADER_CHARS: usize = 60;
48
49/// Maximum length (in characters) of an option's `label`. Kept in sync with
50/// `polyc_tools::ask_question::MAX_OPTION_LABEL_CHARS`.
51pub const MAX_OPTION_LABEL_CHARS: usize = 48;
52
53/// Maximum length (in characters) of a one-sentence field (`question` or an
54/// option's `description`). Kept in sync with
55/// `polyc_tools::ask_question::MAX_SENTENCE_CHARS`.
56pub const MAX_SENTENCE_CHARS: usize = 200;
57
58const ARG_QUESTIONS: &str = "questions";
59const ARG_HEADER: &str = "header";
60const ARG_QUESTION: &str = "question";
61const ARG_OPTIONS: &str = "options";
62const ARG_LABEL: &str = "label";
63const ARG_DESCRIPTION: &str = "description";
64const ARG_RECOMMENDED: &str = "recommended";
65
66/// One option a question offers: a short label and its one-sentence
67/// consequence. At most one option per [`QuestionItem`] is `recommended`.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct QuestionOption {
70    /// A few words naming this option.
71    pub label: String,
72    /// The one-sentence consequence of picking this option.
73    pub description: String,
74    /// Whether this is the model's recommendation (at most one per question).
75    pub recommended: bool,
76}
77
78/// One clarifying question: a short header, a one-sentence prompt, and 2-4
79/// mutually exclusive [`QuestionOption`]s.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct QuestionItem {
82    /// A short label (fits a chat-surface button-row heading).
83    pub header: String,
84    /// The one-sentence question to ask.
85    pub question: String,
86    /// 2-4 mutually exclusive options.
87    pub options: Vec<QuestionOption>,
88}
89
90/// One question from an `ask_question` call, paused and awaiting an answer.
91///
92/// Identity is `(call_id, index)` (`#1660`): one `ask_question` call carries
93/// 1-3 questions, each individually answerable, so `index` is the question's
94/// position WITHIN the call's own `questions` array — never a batch-wide
95/// counter across multiple `ask_question` calls in the same turn.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct PendingQuestion {
98    /// Provider-assigned tool-call id of the `ask_question` call this
99    /// question came from. Shared by every [`PendingQuestion`] the same call
100    /// produced.
101    pub call_id: String,
102    /// This question's position within its call's `questions` array
103    /// (0-based).
104    pub index: u32,
105    /// The question itself: header, prompt, and options.
106    pub item: QuestionItem,
107    /// The raw `ask_question` call's full arguments JSON (every question in
108    /// the call, not just this one) — the audit binding a later signed
109    /// answer must match against, mirroring `PendingApproval::args_json`.
110    pub args_json: String,
111}
112
113/// A validation failure from [`parse_ask_question_args`] (invariant I5).
114///
115/// The `Display` text IS the reader-facing sentence — fed back to the model
116/// verbatim as the tool call's own `{"error": ...}` result. This is a typed
117/// wrapper over that message, not a bare `String`
118/// (`thiserror` in libraries), but it changes nothing about what the model
119/// sees: `Display` renders the wrapped text byte-for-byte, the same shape
120/// `polyc_query::ScopedQueryError::Rejected`/`SourceBudgetExceeded` already
121/// use for a pre-rendered, safe message with no further variants to
122/// distinguish programmatically.
123#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
124#[error("{0}")]
125pub struct QuestionArgsError(String);
126
127impl QuestionArgsError {
128    fn new(message: impl Into<String>) -> Self {
129        Self(message.into())
130    }
131}
132
133/// Parses and validates one `ask_question` call's arguments (invariant I5).
134///
135/// Rejects: malformed JSON; a `questions` array of 0 or more than
136/// [`MAX_QUESTIONS_PER_CALL`]; a question missing `header`/`question`/
137/// `options`; a `header` over [`MAX_HEADER_CHARS`]; a `question` or option
138/// `description` over [`MAX_SENTENCE_CHARS`]; an `options` array with fewer
139/// than [`MIN_OPTIONS`] or more than [`MAX_OPTIONS`] entries; an option with
140/// an empty or missing `label`/`description`, or a `label` over
141/// [`MAX_OPTION_LABEL_CHARS`]; more than one option marked `recommended` on
142/// the same question.
143///
144/// # Errors
145///
146/// Returns a complete, reader-facing sentence — fed back to the model
147/// verbatim as the tool call's own `{"error": ...}` result, never a pause
148/// and never an event-log write (I5) — describing exactly what was wrong.
149pub fn parse_ask_question_args(args_json: &str) -> Result<Vec<QuestionItem>, QuestionArgsError> {
150    let value: Value = serde_json::from_str(args_json)
151        .map_err(|_| QuestionArgsError::new("That ask_question call did not parse as JSON."))?;
152    let questions = value
153        .get(ARG_QUESTIONS)
154        .and_then(Value::as_array)
155        .ok_or_else(|| QuestionArgsError::new("ask_question needs a \"questions\" array."))?;
156
157    if questions.is_empty() {
158        return Err(QuestionArgsError::new(
159            "ask_question needs at least 1 question, got 0.",
160        ));
161    }
162    if questions.len() > MAX_QUESTIONS_PER_CALL {
163        return Err(QuestionArgsError::new(format!(
164            "ask_question allows at most {MAX_QUESTIONS_PER_CALL} questions per call, got {}.",
165            questions.len()
166        )));
167    }
168
169    questions.iter().map(parse_question_item).collect()
170}
171
172/// Parses and validates one entry of the `questions` array.
173fn parse_question_item(v: &Value) -> Result<QuestionItem, QuestionArgsError> {
174    let header = required_str(v, ARG_HEADER, "header")?;
175    if header.chars().count() > MAX_HEADER_CHARS {
176        return Err(QuestionArgsError::new(format!(
177            "A question's header must be at most {MAX_HEADER_CHARS} characters."
178        )));
179    }
180    let question = required_str(v, ARG_QUESTION, "question")?;
181    if question.chars().count() > MAX_SENTENCE_CHARS {
182        return Err(QuestionArgsError::new(format!(
183            "A question must be at most {MAX_SENTENCE_CHARS} characters."
184        )));
185    }
186
187    let options_v = v
188        .get(ARG_OPTIONS)
189        .and_then(Value::as_array)
190        .ok_or_else(|| QuestionArgsError::new("Each question needs an \"options\" array."))?;
191    if options_v.len() < MIN_OPTIONS || options_v.len() > MAX_OPTIONS {
192        return Err(QuestionArgsError::new(format!(
193            "Each question needs between {MIN_OPTIONS} and {MAX_OPTIONS} options, got {}.",
194            options_v.len()
195        )));
196    }
197
198    let mut options = Vec::with_capacity(options_v.len());
199    let mut recommended_count = 0usize;
200    for o in options_v {
201        let label = required_str(o, ARG_LABEL, "label")?;
202        if label.chars().count() > MAX_OPTION_LABEL_CHARS {
203            return Err(QuestionArgsError::new(format!(
204                "An option's label must be at most {MAX_OPTION_LABEL_CHARS} characters."
205            )));
206        }
207        let description = required_str(o, ARG_DESCRIPTION, "description")?;
208        if description.chars().count() > MAX_SENTENCE_CHARS {
209            return Err(QuestionArgsError::new(format!(
210                "An option's description must be at most {MAX_SENTENCE_CHARS} characters."
211            )));
212        }
213        let recommended = o
214            .get(ARG_RECOMMENDED)
215            .and_then(Value::as_bool)
216            .unwrap_or(false);
217        if recommended {
218            recommended_count += 1;
219        }
220        options.push(QuestionOption {
221            label,
222            description,
223            recommended,
224        });
225    }
226    if recommended_count > 1 {
227        return Err(QuestionArgsError::new(
228            "A question may mark at most one option recommended.",
229        ));
230    }
231
232    Ok(QuestionItem {
233        header,
234        question,
235        options,
236    })
237}
238
239/// Reads a required, non-empty string field from a JSON object, or a
240/// complete reader-facing sentence naming what's missing/empty.
241fn required_str(v: &Value, key: &str, human: &str) -> Result<String, QuestionArgsError> {
242    let s = v
243        .get(key)
244        .and_then(Value::as_str)
245        .ok_or_else(|| QuestionArgsError::new(format!("ask_question is missing a {human}.")))?;
246    if s.trim().is_empty() {
247        return Err(QuestionArgsError::new(format!(
248            "ask_question's {human} can't be empty."
249        )));
250    }
251    Ok(s.to_owned())
252}
253
254/// A resolved answer's state (invariant I4: three states, pairwise
255/// distinguishable in the tool result).
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum AnswerState {
258    /// A human picked one of the offered options.
259    Answered,
260    /// A human explicitly declined to choose — "use your own judgment" —
261    /// distinct from [`Self::Answered`] so the agent never mistakes a
262    /// decline for a real answer.
263    Declined,
264    /// Nobody answered before the idle window elapsed; the control plane
265    /// auto-resolved to the recommended option (or the first option if none
266    /// was marked). Distinct from both other states so the agent is told
267    /// explicitly this was an assumption, not a genuine human answer.
268    AutoResolved,
269}
270
271/// `s` did not match any of `polyc_crypto::question`'s three signed state
272/// strings.
273#[derive(Debug, Clone, thiserror::Error)]
274#[error("unrecognized question-answer state {0:?}")]
275pub struct UnrecognizedAnswerState(String);
276
277/// The ONE place the wire/persisted `state` string (`polyc_crypto::question`'s
278/// `ANSWERED_STATE`/`DECLINED_STATE`/`AUTO_RESOLVED_STATE` consts — the proto
279/// field itself stays a plain string; only its Rust-side conversion is
280/// centralized here) converts to and from [`AnswerState`]. Every reader that
281/// decodes a signed answer's `state` — `harness_dialer.rs`'s
282/// `From<&QuestionAnswerRecord>` and `polyc_turn_runner::verify_question_answers`
283/// — calls this instead of re-deriving its own if/else chain.
284impl std::str::FromStr for AnswerState {
285    type Err = UnrecognizedAnswerState;
286
287    fn from_str(s: &str) -> Result<Self, Self::Err> {
288        if s == polyc_crypto::question::ANSWERED_STATE {
289            Ok(Self::Answered)
290        } else if s == polyc_crypto::question::DECLINED_STATE {
291            Ok(Self::Declined)
292        } else if s == polyc_crypto::question::AUTO_RESOLVED_STATE {
293            Ok(Self::AutoResolved)
294        } else {
295            Err(UnrecognizedAnswerState(s.to_owned()))
296        }
297    }
298}
299
300/// The reverse of `AnswerState`'s `FromStr` impl above — the exact
301/// wire/persisted state string this state signs as.
302impl From<AnswerState> for String {
303    fn from(state: AnswerState) -> Self {
304        match state {
305            AnswerState::Answered => polyc_crypto::question::ANSWERED_STATE,
306            AnswerState::Declined => polyc_crypto::question::DECLINED_STATE,
307            AnswerState::AutoResolved => polyc_crypto::question::AUTO_RESOLVED_STATE,
308        }
309        .to_owned()
310    }
311}
312
313/// A verified, signature-checked answer to one pending question.
314///
315/// The harness-side counterpart of
316/// `polyc_crypto::question::VerifiedQuestionAnswer`, carrying only what the
317/// turn loop needs to build the tool result (never the raw signature bytes;
318/// those stay in the crypto/wire layers).
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct VerifiedAnswer {
321    /// The `ask_question` call id this answer resolves.
322    pub call_id: String,
323    /// This answer's question index within its call's `questions` array.
324    pub index: u32,
325    /// The resolved state.
326    pub state: AnswerState,
327    /// The chosen option's index, when [`AnswerState::Answered`] or
328    /// [`AnswerState::AutoResolved`]. `None` for a decline.
329    pub selected_index: Option<u32>,
330    /// The chosen option's label, mirrored alongside the index. Empty for a
331    /// decline.
332    pub selected_label: String,
333    /// Who answered — empty for [`AnswerState::AutoResolved`] (nobody did).
334    pub answered_by: String,
335}
336
337/// The reader-facing note appended to a declined question's result — the
338/// single source for this sentence so it can never read differently between
339/// the turn-loop result payload and (later) any edge-facing copy.
340const DECLINED_NOTE: &str =
341    "The user explicitly declined to choose — use your own judgment and proceed.";
342
343/// The reader-facing note appended to an auto-resolved question's result.
344const AUTO_RESOLVED_NOTE: &str = "Nobody answered before the idle window elapsed, so this was \
345    auto-resolved to the recommended option — this is an assumption, not a real answer; flag it \
346    and re-ask later if it turns out to matter.";
347
348/// Build the combined tool-call result JSON for one `ask_question` call.
349///
350/// Called once every question in the call has a [`VerifiedAnswer`]
351/// (invariant I4: answered, declined, and auto-resolved each produce a
352/// distinct, machine-readable `state`).
353///
354/// `items` is the call's own parsed questions (in order); `answers` is every
355/// [`VerifiedAnswer`] resolving one of them, matched by
356/// [`VerifiedAnswer::index`]. An index in `items` with no matching entry in
357/// `answers` renders as `"state": "unresolved"` — a defensive fallback the
358/// caller must never actually reach (see [`crate::step::QuestionResumePrePass`],
359/// which only calls this once every index resolves).
360#[must_use]
361pub fn question_call_result_json(items: &[QuestionItem], answers: &[VerifiedAnswer]) -> String {
362    let entries: Vec<Value> = items
363        .iter()
364        .enumerate()
365        .map(|(i, item)| {
366            let index = u32::try_from(i).unwrap_or(u32::MAX);
367            let Some(answer) = answers.iter().find(|a| a.index == index) else {
368                return serde_json::json!({ "header": item.header, "state": "unresolved" });
369            };
370            match answer.state {
371                AnswerState::Answered => serde_json::json!({
372                    "header": item.header,
373                    "state": "answered",
374                    "selected_index": answer.selected_index,
375                    "selected_label": answer.selected_label,
376                }),
377                AnswerState::Declined => serde_json::json!({
378                    "header": item.header,
379                    "state": "declined",
380                    "note": DECLINED_NOTE,
381                }),
382                AnswerState::AutoResolved => serde_json::json!({
383                    "header": item.header,
384                    "state": "auto_resolved",
385                    "selected_index": answer.selected_index,
386                    "selected_label": answer.selected_label,
387                    "note": AUTO_RESOLVED_NOTE,
388                }),
389            }
390        })
391        .collect();
392    serde_json::json!({ "answers": entries }).to_string()
393}
394
395/// The reader-facing note attached to a still-pending interim result
396/// (invariant I8) — the transcript-only splice
397/// [`crate::step::QuestionResumePrePass`] builds when new turn input arrives
398/// before a question is genuinely answered, so the model can act on the new
399/// input instead of the whole turn silently re-pausing on the same dangling
400/// call.
401const STILL_PENDING_NOTE: &str = "Nobody has answered this yet — it's still open, not a real \
402    answer. Don't re-ask it and don't assume what the answer will be. Handle whatever the user \
403    just said, and only circle back to this question if it still matters once you have.";
404
405/// Builds a transcript-only interim result for a call whose question(s) are
406/// still unanswered when new turn input arrives (invariant I8).
407///
408/// An unrelated message arriving while a question is pending must reach the
409/// model on its very next dispatch, not be silently dropped. Distinct from
410/// [`question_call_result_json`]'s three real states
411/// (invariant I4, `answered`/`declined`/`auto_resolved`) — `"state":
412/// "still_pending"` can never be confused with a genuine answer. The caller
413/// ([`crate::step::QuestionResumePrePass`], the only one) must splice this
414/// into the provider-facing transcript ONLY, never into the durable
415/// `TurnCtx::outputs` — persisting it would make the call look answered on
416/// every later resume, permanently losing the real question.
417#[must_use]
418pub fn question_still_pending_json(items: &[QuestionItem]) -> String {
419    let entries: Vec<Value> = items
420        .iter()
421        .map(|item| {
422            serde_json::json!({
423                "header": item.header,
424                "state": "still_pending",
425                "note": STILL_PENDING_NOTE,
426            })
427        })
428        .collect();
429    serde_json::json!({ "answers": entries }).to_string()
430}
431
432#[cfg(test)]
433mod tests {
434    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
435    use super::*;
436    use std::str::FromStr;
437
438    /// Every `AnswerState` round-trips through its wire string unchanged —
439    /// the single shared conversion `harness_dialer.rs` and
440    /// `polyc_turn_runner::verify_question_answers` both call instead of
441    /// re-deriving their own if/else chain.
442    #[test]
443    fn answer_state_round_trips_through_its_wire_string() {
444        for state in [
445            AnswerState::Answered,
446            AnswerState::Declined,
447            AnswerState::AutoResolved,
448        ] {
449            let wire: String = state.into();
450            assert_eq!(AnswerState::from_str(&wire).unwrap(), state);
451        }
452    }
453
454    #[test]
455    fn answer_state_wire_strings_match_the_crypto_crate_consts() {
456        assert_eq!(
457            String::from(AnswerState::Answered),
458            polyc_crypto::question::ANSWERED_STATE
459        );
460        assert_eq!(
461            String::from(AnswerState::Declined),
462            polyc_crypto::question::DECLINED_STATE
463        );
464        assert_eq!(
465            String::from(AnswerState::AutoResolved),
466            polyc_crypto::question::AUTO_RESOLVED_STATE
467        );
468    }
469
470    #[test]
471    fn answer_state_rejects_an_unrecognized_string() {
472        assert!(AnswerState::from_str("not_a_real_state").is_err());
473    }
474
475    /// A single well-formed question with 2 options, one recommended.
476    fn valid_call() -> String {
477        serde_json::json!({
478            "questions": [{
479                "header": "Deploy target",
480                "question": "Which environment should this ship to?",
481                "options": [
482                    {"label": "Staging", "description": "Deploys to staging only.", "recommended": true},
483                    {"label": "Production", "description": "Deploys straight to production."}
484                ]
485            }]
486        })
487        .to_string()
488    }
489
490    #[test]
491    fn parses_a_well_formed_call() {
492        let items = parse_ask_question_args(&valid_call()).expect("valid call parses");
493        assert_eq!(items.len(), 1);
494        let q = &items[0];
495        assert_eq!(q.header, "Deploy target");
496        assert_eq!(q.question, "Which environment should this ship to?");
497        assert_eq!(q.options.len(), 2);
498        assert!(q.options[0].recommended);
499        assert!(!q.options[1].recommended);
500    }
501
502    #[test]
503    fn rejects_garbage_json() {
504        let err = parse_ask_question_args("not json").unwrap_err();
505        assert!(!err.to_string().is_empty());
506    }
507
508    /// `QuestionArgsError` is a typed wrapper over the exact model-facing
509    /// sentence — its `Display` output must be byte-for-byte the message
510    /// text, not `"QuestionArgsError(...)"` or any other wrapped rendering,
511    /// since the caller feeds it to the model verbatim as the tool call's
512    /// own `{"error": ...}` result.
513    #[test]
514    fn error_display_is_exactly_the_model_facing_sentence() {
515        let err = parse_ask_question_args(r#"{"questions": []}"#).unwrap_err();
516        assert_eq!(
517            err.to_string(),
518            "ask_question needs at least 1 question, got 0."
519        );
520    }
521
522    #[test]
523    fn rejects_zero_questions() {
524        let err = parse_ask_question_args(r#"{"questions": []}"#).unwrap_err();
525        assert!(err.to_string().contains("at least 1 question"), "{err}");
526    }
527
528    #[test]
529    fn rejects_more_than_three_questions() {
530        let one = serde_json::json!({
531            "header": "h", "question": "q?",
532            "options": [
533                {"label": "a", "description": "d"},
534                {"label": "b", "description": "d"}
535            ]
536        });
537        let args = serde_json::json!({ "questions": [one.clone(), one.clone(), one.clone(), one] })
538            .to_string();
539        let err = parse_ask_question_args(&args).unwrap_err();
540        assert!(err.to_string().contains("at most 3 questions"), "{err}");
541    }
542
543    #[test]
544    fn rejects_fewer_than_two_options() {
545        let args = serde_json::json!({
546            "questions": [{
547                "header": "h", "question": "q?",
548                "options": [{"label": "a", "description": "d"}]
549            }]
550        })
551        .to_string();
552        let err = parse_ask_question_args(&args).unwrap_err();
553        assert!(err.to_string().contains("between 2 and 4 options"), "{err}");
554    }
555
556    #[test]
557    fn rejects_more_than_four_options() {
558        let opt = serde_json::json!({"label": "a", "description": "d"});
559        let args = serde_json::json!({
560            "questions": [{
561                "header": "h", "question": "q?",
562                "options": [opt.clone(), opt.clone(), opt.clone(), opt.clone(), opt]
563            }]
564        })
565        .to_string();
566        let err = parse_ask_question_args(&args).unwrap_err();
567        assert!(err.to_string().contains("between 2 and 4 options"), "{err}");
568    }
569
570    #[test]
571    fn rejects_empty_option_label() {
572        let args = serde_json::json!({
573            "questions": [{
574                "header": "h", "question": "q?",
575                "options": [
576                    {"label": "", "description": "d"},
577                    {"label": "b", "description": "d"}
578                ]
579            }]
580        })
581        .to_string();
582        let err = parse_ask_question_args(&args).unwrap_err();
583        assert!(err.to_string().contains("label"), "{err}");
584        assert!(err.to_string().contains("empty"), "{err}");
585    }
586
587    #[test]
588    fn rejects_over_length_header() {
589        let long_header = "x".repeat(MAX_HEADER_CHARS + 1);
590        let args = serde_json::json!({
591            "questions": [{
592                "header": long_header, "question": "q?",
593                "options": [
594                    {"label": "a", "description": "d"},
595                    {"label": "b", "description": "d"}
596                ]
597            }]
598        })
599        .to_string();
600        let err = parse_ask_question_args(&args).unwrap_err();
601        assert!(err.to_string().contains("header"), "{err}");
602    }
603
604    #[test]
605    fn rejects_two_recommended_options() {
606        let args = serde_json::json!({
607            "questions": [{
608                "header": "h", "question": "q?",
609                "options": [
610                    {"label": "a", "description": "d", "recommended": true},
611                    {"label": "b", "description": "d", "recommended": true}
612                ]
613            }]
614        })
615        .to_string();
616        let err = parse_ask_question_args(&args).unwrap_err();
617        assert!(err.to_string().contains("at most one option"), "{err}");
618    }
619
620    #[test]
621    fn rejects_missing_options_field() {
622        let args = serde_json::json!({
623            "questions": [{"header": "h", "question": "q?"}]
624        })
625        .to_string();
626        let err = parse_ask_question_args(&args).unwrap_err();
627        assert!(err.to_string().contains("options"), "{err}");
628    }
629
630    fn two_items() -> Vec<QuestionItem> {
631        vec![
632            QuestionItem {
633                header: "Deploy target".to_owned(),
634                question: "Which environment?".to_owned(),
635                options: vec![
636                    QuestionOption {
637                        label: "Staging".to_owned(),
638                        description: "d1".to_owned(),
639                        recommended: false,
640                    },
641                    QuestionOption {
642                        label: "Production".to_owned(),
643                        description: "d2".to_owned(),
644                        recommended: true,
645                    },
646                ],
647            },
648            QuestionItem {
649                header: "Notify team?".to_owned(),
650                question: "Should we notify the team?".to_owned(),
651                options: vec![
652                    QuestionOption {
653                        label: "Yes".to_owned(),
654                        description: "d3".to_owned(),
655                        recommended: false,
656                    },
657                    QuestionOption {
658                        label: "No".to_owned(),
659                        description: "d4".to_owned(),
660                        recommended: false,
661                    },
662                ],
663            },
664        ]
665    }
666
667    /// Invariant I4: answered / declined / auto-resolved must each produce a
668    /// distinct, pairwise-different result JSON the model can act on
669    /// differently.
670    #[test]
671    fn answered_declined_and_auto_resolved_produce_distinct_results() {
672        let items = vec![two_items()[0].clone()];
673        let answered = question_call_result_json(
674            &items,
675            &[VerifiedAnswer {
676                call_id: "call-1".to_owned(),
677                index: 0,
678                state: AnswerState::Answered,
679                selected_index: Some(1),
680                selected_label: "Production".to_owned(),
681                answered_by: "slack:T1:U9".to_owned(),
682            }],
683        );
684        let declined = question_call_result_json(
685            &items,
686            &[VerifiedAnswer {
687                call_id: "call-1".to_owned(),
688                index: 0,
689                state: AnswerState::Declined,
690                selected_index: None,
691                selected_label: String::new(),
692                answered_by: "slack:T1:U9".to_owned(),
693            }],
694        );
695        let auto_resolved = question_call_result_json(
696            &items,
697            &[VerifiedAnswer {
698                call_id: "call-1".to_owned(),
699                index: 0,
700                state: AnswerState::AutoResolved,
701                selected_index: Some(1),
702                selected_label: "Production".to_owned(),
703                answered_by: String::new(),
704            }],
705        );
706
707        assert_ne!(answered, declined);
708        assert_ne!(answered, auto_resolved);
709        assert_ne!(declined, auto_resolved);
710
711        let a: serde_json::Value = serde_json::from_str(&answered).unwrap();
712        assert_eq!(a["answers"][0]["state"], "answered");
713        assert_eq!(a["answers"][0]["selected_label"], "Production");
714
715        let d: serde_json::Value = serde_json::from_str(&declined).unwrap();
716        assert_eq!(d["answers"][0]["state"], "declined");
717        assert!(d["answers"][0].get("selected_index").is_none());
718
719        let r: serde_json::Value = serde_json::from_str(&auto_resolved).unwrap();
720        assert_eq!(r["answers"][0]["state"], "auto_resolved");
721        assert!(
722            r["answers"][0]["note"]
723                .as_str()
724                .unwrap()
725                .contains("assumption"),
726            "an auto-resolved answer must flag itself as an assumption, not a real answer"
727        );
728    }
729
730    /// Invariant I8: the still-pending interim state is pairwise distinct
731    /// from every I4 real answer state, so the model can never mistake "no
732    /// one has answered yet" for a genuine answer/decline/auto-resolution.
733    #[test]
734    fn still_pending_is_distinct_from_every_real_answer_state() {
735        let items = vec![two_items()[0].clone()];
736        let still_pending = question_still_pending_json(&items);
737        let answered = question_call_result_json(
738            &items,
739            &[VerifiedAnswer {
740                call_id: "call-1".to_owned(),
741                index: 0,
742                state: AnswerState::Answered,
743                selected_index: Some(1),
744                selected_label: "Production".to_owned(),
745                answered_by: "slack:T1:U9".to_owned(),
746            }],
747        );
748        let declined = question_call_result_json(
749            &items,
750            &[VerifiedAnswer {
751                call_id: "call-1".to_owned(),
752                index: 0,
753                state: AnswerState::Declined,
754                selected_index: None,
755                selected_label: String::new(),
756                answered_by: "slack:T1:U9".to_owned(),
757            }],
758        );
759        let auto_resolved = question_call_result_json(
760            &items,
761            &[VerifiedAnswer {
762                call_id: "call-1".to_owned(),
763                index: 0,
764                state: AnswerState::AutoResolved,
765                selected_index: Some(1),
766                selected_label: "Production".to_owned(),
767                answered_by: String::new(),
768            }],
769        );
770
771        assert_ne!(still_pending, answered);
772        assert_ne!(still_pending, declined);
773        assert_ne!(still_pending, auto_resolved);
774
775        let v: serde_json::Value = serde_json::from_str(&still_pending).unwrap();
776        assert_eq!(v["answers"][0]["state"], "still_pending");
777        assert!(v["answers"][0].get("selected_index").is_none());
778        assert!(
779            v["answers"][0]["note"]
780                .as_str()
781                .unwrap()
782                .contains("still open"),
783            "the still-pending note must tell the model this is not a real answer"
784        );
785    }
786
787    /// A call with more than one question renders one entry per question, in
788    /// order, each independently resolved.
789    #[test]
790    fn multi_question_call_renders_one_entry_per_question() {
791        let items = two_items();
792        let json = question_call_result_json(
793            &items,
794            &[
795                VerifiedAnswer {
796                    call_id: "call-1".to_owned(),
797                    index: 0,
798                    state: AnswerState::Answered,
799                    selected_index: Some(0),
800                    selected_label: "Staging".to_owned(),
801                    answered_by: "slack:T1:U9".to_owned(),
802                },
803                VerifiedAnswer {
804                    call_id: "call-1".to_owned(),
805                    index: 1,
806                    state: AnswerState::Declined,
807                    selected_index: None,
808                    selected_label: String::new(),
809                    answered_by: "slack:T1:U9".to_owned(),
810                },
811            ],
812        );
813        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
814        assert_eq!(v["answers"].as_array().unwrap().len(), 2);
815        assert_eq!(v["answers"][0]["header"], "Deploy target");
816        assert_eq!(v["answers"][0]["state"], "answered");
817        assert_eq!(v["answers"][1]["header"], "Notify team?");
818        assert_eq!(v["answers"][1]["state"], "declined");
819    }
820}