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