polyc-agent 2026.8.0

The agent turn loop: provider + tool-call routing, shared by the control plane and harness.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
//! `ask_question` (#1660): parse/validate the model's clarifying-question
//! batch, and the pure types the turn loop's question-pause phase builds and
//! consumes.
//!
//! This is a **sibling pause path to the tool-approval gate, not a reuse of
//! it** — see issue #1660 and its parent PRD #1659. This module owns only
//! the parse/validate half (invariant I5: a malformed call is rejected back
//! to the model as a tool-call error and never reaches a pause or an
//! event-log write); the pause/resume machinery that consumes
//! [`QuestionItem`] (a pending-question record, the turn result's own
//! pending-questions list) lands in a later slice of this same issue.
//!
//! # Why the tool name is duplicated here rather than imported
//!
//! `polyc_tools` already depends on `polyc_agent` (for
//! [`crate::ToolExecutor`]), so this crate cannot depend back on
//! `polyc_tools` without a cycle. [`ASK_QUESTION_TOOL_NAME`] is therefore the
//! same literal as `polyc_tools::ask_question::TOOL_NAME`, duplicated
//! deliberately — the same reasoning `polyc_proto`'s `INVITE_TOOL_NAME`
//! duplication already documents. A cross-crate test in `polyc-tools` pins
//! the two literals equal.

use serde_json::Value;

/// The `ask_question` tool name the turn loop recognizes to trigger the
/// question-pause phase.
///
/// Kept in sync with `polyc_tools::ask_question::TOOL_NAME` by a cross-crate
/// test in that crate (see the module doc for why it's not imported
/// directly).
pub const ASK_QUESTION_TOOL_NAME: &str = "ask_question";

/// Maximum number of questions in a single `ask_question` call. Kept in sync
/// with `polyc_tools::ask_question::MAX_QUESTIONS_PER_CALL`.
pub const MAX_QUESTIONS_PER_CALL: usize = 3;

/// Minimum number of options a question may offer. Kept in sync with
/// `polyc_tools::ask_question::MIN_OPTIONS`.
pub const MIN_OPTIONS: usize = 2;

/// Maximum number of options a question may offer. Kept in sync with
/// `polyc_tools::ask_question::MAX_OPTIONS`.
pub const MAX_OPTIONS: usize = 4;

/// Maximum length (in characters) of a question's `header`. Kept in sync with
/// `polyc_tools::ask_question::MAX_HEADER_CHARS`.
pub const MAX_HEADER_CHARS: usize = 60;

/// Maximum length (in characters) of an option's `label`. Kept in sync with
/// `polyc_tools::ask_question::MAX_OPTION_LABEL_CHARS`.
pub const MAX_OPTION_LABEL_CHARS: usize = 48;

/// Maximum length (in characters) of a one-sentence field (`question` or an
/// option's `description`). Kept in sync with
/// `polyc_tools::ask_question::MAX_SENTENCE_CHARS`.
pub const MAX_SENTENCE_CHARS: usize = 200;

const ARG_QUESTIONS: &str = "questions";
const ARG_HEADER: &str = "header";
const ARG_QUESTION: &str = "question";
const ARG_OPTIONS: &str = "options";
const ARG_LABEL: &str = "label";
const ARG_DESCRIPTION: &str = "description";
const ARG_RECOMMENDED: &str = "recommended";

/// One option a question offers: a short label and its one-sentence
/// consequence. At most one option per [`QuestionItem`] is `recommended`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestionOption {
    /// A few words naming this option.
    pub label: String,
    /// The one-sentence consequence of picking this option.
    pub description: String,
    /// Whether this is the model's recommendation (at most one per question).
    pub recommended: bool,
}

/// One clarifying question: a short header, a one-sentence prompt, and 2-4
/// mutually exclusive [`QuestionOption`]s.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestionItem {
    /// A short label (fits a chat-surface button-row heading).
    pub header: String,
    /// The one-sentence question to ask.
    pub question: String,
    /// 2-4 mutually exclusive options.
    pub options: Vec<QuestionOption>,
}

/// One question from an `ask_question` call, paused and awaiting an answer.
///
/// Identity is `(call_id, index)` (`#1660`): one `ask_question` call carries
/// 1-3 questions, each individually answerable, so `index` is the question's
/// position WITHIN the call's own `questions` array — never a batch-wide
/// counter across multiple `ask_question` calls in the same turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingQuestion {
    /// Provider-assigned tool-call id of the `ask_question` call this
    /// question came from. Shared by every [`PendingQuestion`] the same call
    /// produced.
    pub call_id: String,
    /// This question's position within its call's `questions` array
    /// (0-based).
    pub index: u32,
    /// The question itself: header, prompt, and options.
    pub item: QuestionItem,
    /// The raw `ask_question` call's full arguments JSON (every question in
    /// the call, not just this one) — the audit binding a later signed
    /// answer must match against, mirroring `PendingApproval::args_json`.
    pub args_json: String,
}

/// A validation failure from [`parse_ask_question_args`] (invariant I5).
///
/// The `Display` text IS the reader-facing sentence — fed back to the model
/// verbatim as the tool call's own `{"error": ...}` result. This is a typed
/// wrapper over that message, not a bare `String`
/// (`thiserror` in libraries), but it changes nothing about what the model
/// sees: `Display` renders the wrapped text byte-for-byte, the same shape
/// `polyc_query::ScopedQueryError::Rejected`/`SourceBudgetExceeded` already
/// use for a pre-rendered, safe message with no further variants to
/// distinguish programmatically.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct QuestionArgsError(String);

impl QuestionArgsError {
    fn new(message: impl Into<String>) -> Self {
        Self(message.into())
    }
}

/// Parses and validates one `ask_question` call's arguments (invariant I5).
///
/// Rejects: malformed JSON; a `questions` array of 0 or more than
/// [`MAX_QUESTIONS_PER_CALL`]; a question missing `header`/`question`/
/// `options`; a `header` over [`MAX_HEADER_CHARS`]; a `question` or option
/// `description` over [`MAX_SENTENCE_CHARS`]; an `options` array with fewer
/// than [`MIN_OPTIONS`] or more than [`MAX_OPTIONS`] entries; an option with
/// an empty or missing `label`/`description`, or a `label` over
/// [`MAX_OPTION_LABEL_CHARS`]; more than one option marked `recommended` on
/// the same question.
///
/// # Errors
///
/// Returns a complete, reader-facing sentence — fed back to the model
/// verbatim as the tool call's own `{"error": ...}` result, never a pause
/// and never an event-log write (I5) — describing exactly what was wrong.
pub fn parse_ask_question_args(args_json: &str) -> Result<Vec<QuestionItem>, QuestionArgsError> {
    let value: Value = serde_json::from_str(args_json)
        .map_err(|_| QuestionArgsError::new("That ask_question call did not parse as JSON."))?;
    let questions = value
        .get(ARG_QUESTIONS)
        .and_then(Value::as_array)
        .ok_or_else(|| QuestionArgsError::new("ask_question needs a \"questions\" array."))?;

    if questions.is_empty() {
        return Err(QuestionArgsError::new(
            "ask_question needs at least 1 question, got 0.",
        ));
    }
    if questions.len() > MAX_QUESTIONS_PER_CALL {
        return Err(QuestionArgsError::new(format!(
            "ask_question allows at most {MAX_QUESTIONS_PER_CALL} questions per call, got {}.",
            questions.len()
        )));
    }

    questions.iter().map(parse_question_item).collect()
}

/// Parses and validates one entry of the `questions` array.
fn parse_question_item(v: &Value) -> Result<QuestionItem, QuestionArgsError> {
    let header = required_str(v, ARG_HEADER, "header")?;
    if header.chars().count() > MAX_HEADER_CHARS {
        return Err(QuestionArgsError::new(format!(
            "A question's header must be at most {MAX_HEADER_CHARS} characters."
        )));
    }
    let question = required_str(v, ARG_QUESTION, "question")?;
    if question.chars().count() > MAX_SENTENCE_CHARS {
        return Err(QuestionArgsError::new(format!(
            "A question must be at most {MAX_SENTENCE_CHARS} characters."
        )));
    }

    let options_v = v
        .get(ARG_OPTIONS)
        .and_then(Value::as_array)
        .ok_or_else(|| QuestionArgsError::new("Each question needs an \"options\" array."))?;
    if options_v.len() < MIN_OPTIONS || options_v.len() > MAX_OPTIONS {
        return Err(QuestionArgsError::new(format!(
            "Each question needs between {MIN_OPTIONS} and {MAX_OPTIONS} options, got {}.",
            options_v.len()
        )));
    }

    let mut options = Vec::with_capacity(options_v.len());
    let mut recommended_count = 0usize;
    for o in options_v {
        let label = required_str(o, ARG_LABEL, "label")?;
        if label.chars().count() > MAX_OPTION_LABEL_CHARS {
            return Err(QuestionArgsError::new(format!(
                "An option's label must be at most {MAX_OPTION_LABEL_CHARS} characters."
            )));
        }
        let description = required_str(o, ARG_DESCRIPTION, "description")?;
        if description.chars().count() > MAX_SENTENCE_CHARS {
            return Err(QuestionArgsError::new(format!(
                "An option's description must be at most {MAX_SENTENCE_CHARS} characters."
            )));
        }
        let recommended = o
            .get(ARG_RECOMMENDED)
            .and_then(Value::as_bool)
            .unwrap_or(false);
        if recommended {
            recommended_count += 1;
        }
        options.push(QuestionOption {
            label,
            description,
            recommended,
        });
    }
    if recommended_count > 1 {
        return Err(QuestionArgsError::new(
            "A question may mark at most one option recommended.",
        ));
    }

    Ok(QuestionItem {
        header,
        question,
        options,
    })
}

/// Reads a required, non-empty string field from a JSON object, or a
/// complete reader-facing sentence naming what's missing/empty.
fn required_str(v: &Value, key: &str, human: &str) -> Result<String, QuestionArgsError> {
    let s = v
        .get(key)
        .and_then(Value::as_str)
        .ok_or_else(|| QuestionArgsError::new(format!("ask_question is missing a {human}.")))?;
    if s.trim().is_empty() {
        return Err(QuestionArgsError::new(format!(
            "ask_question's {human} can't be empty."
        )));
    }
    Ok(s.to_owned())
}

/// A resolved answer's state (invariant I4: three states, pairwise
/// distinguishable in the tool result).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnswerState {
    /// A human picked one of the offered options.
    Answered,
    /// A human explicitly declined to choose — "use your own judgment" —
    /// distinct from [`Self::Answered`] so the agent never mistakes a
    /// decline for a real answer.
    Declined,
    /// Nobody answered before the idle window elapsed; the control plane
    /// auto-resolved to the recommended option (or the first option if none
    /// was marked). Distinct from both other states so the agent is told
    /// explicitly this was an assumption, not a genuine human answer.
    AutoResolved,
}

/// `s` did not match any of `polyc_crypto::question`'s three signed state
/// strings.
#[derive(Debug, Clone, thiserror::Error)]
#[error("unrecognized question-answer state {0:?}")]
pub struct UnrecognizedAnswerState(String);

/// The ONE place the wire/persisted `state` string (`polyc_crypto::question`'s
/// `ANSWERED_STATE`/`DECLINED_STATE`/`AUTO_RESOLVED_STATE` consts — the proto
/// field itself stays a plain string; only its Rust-side conversion is
/// centralized here) converts to and from [`AnswerState`]. Every reader that
/// decodes a signed answer's `state` — `harness_dialer.rs`'s
/// `From<&QuestionAnswerRecord>` and `polyc_turn_runner::verify_question_answers`
/// — calls this instead of re-deriving its own if/else chain.
impl std::str::FromStr for AnswerState {
    type Err = UnrecognizedAnswerState;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s == polyc_crypto::question::ANSWERED_STATE {
            Ok(Self::Answered)
        } else if s == polyc_crypto::question::DECLINED_STATE {
            Ok(Self::Declined)
        } else if s == polyc_crypto::question::AUTO_RESOLVED_STATE {
            Ok(Self::AutoResolved)
        } else {
            Err(UnrecognizedAnswerState(s.to_owned()))
        }
    }
}

/// The reverse of `AnswerState`'s `FromStr` impl above — the exact
/// wire/persisted state string this state signs as.
impl From<AnswerState> for String {
    fn from(state: AnswerState) -> Self {
        match state {
            AnswerState::Answered => polyc_crypto::question::ANSWERED_STATE,
            AnswerState::Declined => polyc_crypto::question::DECLINED_STATE,
            AnswerState::AutoResolved => polyc_crypto::question::AUTO_RESOLVED_STATE,
        }
        .to_owned()
    }
}

/// A verified, signature-checked answer to one pending question.
///
/// The harness-side counterpart of
/// `polyc_crypto::question::VerifiedQuestionAnswer`, carrying only what the
/// turn loop needs to build the tool result (never the raw signature bytes;
/// those stay in the crypto/wire layers).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedAnswer {
    /// The `ask_question` call id this answer resolves.
    pub call_id: String,
    /// This answer's question index within its call's `questions` array.
    pub index: u32,
    /// The resolved state.
    pub state: AnswerState,
    /// The chosen option's index, when [`AnswerState::Answered`] or
    /// [`AnswerState::AutoResolved`]. `None` for a decline.
    pub selected_index: Option<u32>,
    /// The chosen option's label, mirrored alongside the index. Empty for a
    /// decline.
    pub selected_label: String,
    /// Who answered — empty for [`AnswerState::AutoResolved`] (nobody did).
    pub answered_by: String,
}

/// The reader-facing note appended to a declined question's result — the
/// single source for this sentence so it can never read differently between
/// the turn-loop result payload and (later) any edge-facing copy.
const DECLINED_NOTE: &str =
    "The user explicitly declined to choose — use your own judgment and proceed.";

/// The reader-facing note appended to an auto-resolved question's result.
const AUTO_RESOLVED_NOTE: &str = "Nobody answered before the idle window elapsed, so this was \
    auto-resolved to the recommended option — this is an assumption, not a real answer; flag it \
    and re-ask later if it turns out to matter.";

/// Build the combined tool-call result JSON for one `ask_question` call.
///
/// Called once every question in the call has a [`VerifiedAnswer`]
/// (invariant I4: answered, declined, and auto-resolved each produce a
/// distinct, machine-readable `state`).
///
/// `items` is the call's own parsed questions (in order); `answers` is every
/// [`VerifiedAnswer`] resolving one of them, matched by
/// [`VerifiedAnswer::index`]. An index in `items` with no matching entry in
/// `answers` renders as `"state": "unresolved"` — a defensive fallback the
/// caller must never actually reach (see [`crate::step::QuestionResumePrePass`],
/// which only calls this once every index resolves).
#[must_use]
pub fn question_call_result_json(items: &[QuestionItem], answers: &[VerifiedAnswer]) -> String {
    let entries: Vec<Value> = items
        .iter()
        .enumerate()
        .map(|(i, item)| {
            let index = u32::try_from(i).unwrap_or(u32::MAX);
            let Some(answer) = answers.iter().find(|a| a.index == index) else {
                return serde_json::json!({ "header": item.header, "state": "unresolved" });
            };
            match answer.state {
                AnswerState::Answered => serde_json::json!({
                    "header": item.header,
                    "state": "answered",
                    "selected_index": answer.selected_index,
                    "selected_label": answer.selected_label,
                }),
                AnswerState::Declined => serde_json::json!({
                    "header": item.header,
                    "state": "declined",
                    "note": DECLINED_NOTE,
                }),
                AnswerState::AutoResolved => serde_json::json!({
                    "header": item.header,
                    "state": "auto_resolved",
                    "selected_index": answer.selected_index,
                    "selected_label": answer.selected_label,
                    "note": AUTO_RESOLVED_NOTE,
                }),
            }
        })
        .collect();
    serde_json::json!({ "answers": entries }).to_string()
}

/// The reader-facing note attached to a still-pending interim result
/// (invariant I8) — the transcript-only splice
/// [`crate::step::QuestionResumePrePass`] builds when new turn input arrives
/// before a question is genuinely answered, so the model can act on the new
/// input instead of the whole turn silently re-pausing on the same dangling
/// call.
const STILL_PENDING_NOTE: &str = "Nobody has answered this yet — it's still open, not a real \
    answer. Don't re-ask it and don't assume what the answer will be. Handle whatever the user \
    just said, and only circle back to this question if it still matters once you have.";

/// Builds a transcript-only interim result for a call whose question(s) are
/// still unanswered when new turn input arrives (invariant I8).
///
/// An unrelated message arriving while a question is pending must reach the
/// model on its very next dispatch, not be silently dropped. Distinct from
/// [`question_call_result_json`]'s three real states
/// (invariant I4, `answered`/`declined`/`auto_resolved`) — `"state":
/// "still_pending"` can never be confused with a genuine answer. The caller
/// ([`crate::step::QuestionResumePrePass`], the only one) must splice this
/// into the provider-facing transcript ONLY, never into the durable
/// `TurnCtx::outputs` — persisting it would make the call look answered on
/// every later resume, permanently losing the real question.
#[must_use]
pub fn question_still_pending_json(items: &[QuestionItem]) -> String {
    let entries: Vec<Value> = items
        .iter()
        .map(|item| {
            serde_json::json!({
                "header": item.header,
                "state": "still_pending",
                "note": STILL_PENDING_NOTE,
            })
        })
        .collect();
    serde_json::json!({ "answers": entries }).to_string()
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;
    use std::str::FromStr;

    /// Every `AnswerState` round-trips through its wire string unchanged —
    /// the single shared conversion `harness_dialer.rs` and
    /// `polyc_turn_runner::verify_question_answers` both call instead of
    /// re-deriving their own if/else chain.
    #[test]
    fn answer_state_round_trips_through_its_wire_string() {
        for state in [
            AnswerState::Answered,
            AnswerState::Declined,
            AnswerState::AutoResolved,
        ] {
            let wire: String = state.into();
            assert_eq!(AnswerState::from_str(&wire).unwrap(), state);
        }
    }

    #[test]
    fn answer_state_wire_strings_match_the_crypto_crate_consts() {
        assert_eq!(
            String::from(AnswerState::Answered),
            polyc_crypto::question::ANSWERED_STATE
        );
        assert_eq!(
            String::from(AnswerState::Declined),
            polyc_crypto::question::DECLINED_STATE
        );
        assert_eq!(
            String::from(AnswerState::AutoResolved),
            polyc_crypto::question::AUTO_RESOLVED_STATE
        );
    }

    #[test]
    fn answer_state_rejects_an_unrecognized_string() {
        assert!(AnswerState::from_str("not_a_real_state").is_err());
    }

    /// A single well-formed question with 2 options, one recommended.
    fn valid_call() -> String {
        serde_json::json!({
            "questions": [{
                "header": "Deploy target",
                "question": "Which environment should this ship to?",
                "options": [
                    {"label": "Staging", "description": "Deploys to staging only.", "recommended": true},
                    {"label": "Production", "description": "Deploys straight to production."}
                ]
            }]
        })
        .to_string()
    }

    #[test]
    fn parses_a_well_formed_call() {
        let items = parse_ask_question_args(&valid_call()).expect("valid call parses");
        assert_eq!(items.len(), 1);
        let q = &items[0];
        assert_eq!(q.header, "Deploy target");
        assert_eq!(q.question, "Which environment should this ship to?");
        assert_eq!(q.options.len(), 2);
        assert!(q.options[0].recommended);
        assert!(!q.options[1].recommended);
    }

    #[test]
    fn rejects_garbage_json() {
        let err = parse_ask_question_args("not json").unwrap_err();
        assert!(!err.to_string().is_empty());
    }

    /// `QuestionArgsError` is a typed wrapper over the exact model-facing
    /// sentence — its `Display` output must be byte-for-byte the message
    /// text, not `"QuestionArgsError(...)"` or any other wrapped rendering,
    /// since the caller feeds it to the model verbatim as the tool call's
    /// own `{"error": ...}` result.
    #[test]
    fn error_display_is_exactly_the_model_facing_sentence() {
        let err = parse_ask_question_args(r#"{"questions": []}"#).unwrap_err();
        assert_eq!(
            err.to_string(),
            "ask_question needs at least 1 question, got 0."
        );
    }

    #[test]
    fn rejects_zero_questions() {
        let err = parse_ask_question_args(r#"{"questions": []}"#).unwrap_err();
        assert!(err.to_string().contains("at least 1 question"), "{err}");
    }

    #[test]
    fn rejects_more_than_three_questions() {
        let one = serde_json::json!({
            "header": "h", "question": "q?",
            "options": [
                {"label": "a", "description": "d"},
                {"label": "b", "description": "d"}
            ]
        });
        let args = serde_json::json!({ "questions": [one.clone(), one.clone(), one.clone(), one] })
            .to_string();
        let err = parse_ask_question_args(&args).unwrap_err();
        assert!(err.to_string().contains("at most 3 questions"), "{err}");
    }

    #[test]
    fn rejects_fewer_than_two_options() {
        let args = serde_json::json!({
            "questions": [{
                "header": "h", "question": "q?",
                "options": [{"label": "a", "description": "d"}]
            }]
        })
        .to_string();
        let err = parse_ask_question_args(&args).unwrap_err();
        assert!(err.to_string().contains("between 2 and 4 options"), "{err}");
    }

    #[test]
    fn rejects_more_than_four_options() {
        let opt = serde_json::json!({"label": "a", "description": "d"});
        let args = serde_json::json!({
            "questions": [{
                "header": "h", "question": "q?",
                "options": [opt.clone(), opt.clone(), opt.clone(), opt.clone(), opt]
            }]
        })
        .to_string();
        let err = parse_ask_question_args(&args).unwrap_err();
        assert!(err.to_string().contains("between 2 and 4 options"), "{err}");
    }

    #[test]
    fn rejects_empty_option_label() {
        let args = serde_json::json!({
            "questions": [{
                "header": "h", "question": "q?",
                "options": [
                    {"label": "", "description": "d"},
                    {"label": "b", "description": "d"}
                ]
            }]
        })
        .to_string();
        let err = parse_ask_question_args(&args).unwrap_err();
        assert!(err.to_string().contains("label"), "{err}");
        assert!(err.to_string().contains("empty"), "{err}");
    }

    #[test]
    fn rejects_over_length_header() {
        let long_header = "x".repeat(MAX_HEADER_CHARS + 1);
        let args = serde_json::json!({
            "questions": [{
                "header": long_header, "question": "q?",
                "options": [
                    {"label": "a", "description": "d"},
                    {"label": "b", "description": "d"}
                ]
            }]
        })
        .to_string();
        let err = parse_ask_question_args(&args).unwrap_err();
        assert!(err.to_string().contains("header"), "{err}");
    }

    #[test]
    fn rejects_two_recommended_options() {
        let args = serde_json::json!({
            "questions": [{
                "header": "h", "question": "q?",
                "options": [
                    {"label": "a", "description": "d", "recommended": true},
                    {"label": "b", "description": "d", "recommended": true}
                ]
            }]
        })
        .to_string();
        let err = parse_ask_question_args(&args).unwrap_err();
        assert!(err.to_string().contains("at most one option"), "{err}");
    }

    #[test]
    fn rejects_missing_options_field() {
        let args = serde_json::json!({
            "questions": [{"header": "h", "question": "q?"}]
        })
        .to_string();
        let err = parse_ask_question_args(&args).unwrap_err();
        assert!(err.to_string().contains("options"), "{err}");
    }

    fn two_items() -> Vec<QuestionItem> {
        vec![
            QuestionItem {
                header: "Deploy target".to_owned(),
                question: "Which environment?".to_owned(),
                options: vec![
                    QuestionOption {
                        label: "Staging".to_owned(),
                        description: "d1".to_owned(),
                        recommended: false,
                    },
                    QuestionOption {
                        label: "Production".to_owned(),
                        description: "d2".to_owned(),
                        recommended: true,
                    },
                ],
            },
            QuestionItem {
                header: "Notify team?".to_owned(),
                question: "Should we notify the team?".to_owned(),
                options: vec![
                    QuestionOption {
                        label: "Yes".to_owned(),
                        description: "d3".to_owned(),
                        recommended: false,
                    },
                    QuestionOption {
                        label: "No".to_owned(),
                        description: "d4".to_owned(),
                        recommended: false,
                    },
                ],
            },
        ]
    }

    /// Invariant I4: answered / declined / auto-resolved must each produce a
    /// distinct, pairwise-different result JSON the model can act on
    /// differently.
    #[test]
    fn answered_declined_and_auto_resolved_produce_distinct_results() {
        let items = vec![two_items()[0].clone()];
        let answered = question_call_result_json(
            &items,
            &[VerifiedAnswer {
                call_id: "call-1".to_owned(),
                index: 0,
                state: AnswerState::Answered,
                selected_index: Some(1),
                selected_label: "Production".to_owned(),
                answered_by: "slack:T1:U9".to_owned(),
            }],
        );
        let declined = question_call_result_json(
            &items,
            &[VerifiedAnswer {
                call_id: "call-1".to_owned(),
                index: 0,
                state: AnswerState::Declined,
                selected_index: None,
                selected_label: String::new(),
                answered_by: "slack:T1:U9".to_owned(),
            }],
        );
        let auto_resolved = question_call_result_json(
            &items,
            &[VerifiedAnswer {
                call_id: "call-1".to_owned(),
                index: 0,
                state: AnswerState::AutoResolved,
                selected_index: Some(1),
                selected_label: "Production".to_owned(),
                answered_by: String::new(),
            }],
        );

        assert_ne!(answered, declined);
        assert_ne!(answered, auto_resolved);
        assert_ne!(declined, auto_resolved);

        let a: serde_json::Value = serde_json::from_str(&answered).unwrap();
        assert_eq!(a["answers"][0]["state"], "answered");
        assert_eq!(a["answers"][0]["selected_label"], "Production");

        let d: serde_json::Value = serde_json::from_str(&declined).unwrap();
        assert_eq!(d["answers"][0]["state"], "declined");
        assert!(d["answers"][0].get("selected_index").is_none());

        let r: serde_json::Value = serde_json::from_str(&auto_resolved).unwrap();
        assert_eq!(r["answers"][0]["state"], "auto_resolved");
        assert!(
            r["answers"][0]["note"]
                .as_str()
                .unwrap()
                .contains("assumption"),
            "an auto-resolved answer must flag itself as an assumption, not a real answer"
        );
    }

    /// Invariant I8: the still-pending interim state is pairwise distinct
    /// from every I4 real answer state, so the model can never mistake "no
    /// one has answered yet" for a genuine answer/decline/auto-resolution.
    #[test]
    fn still_pending_is_distinct_from_every_real_answer_state() {
        let items = vec![two_items()[0].clone()];
        let still_pending = question_still_pending_json(&items);
        let answered = question_call_result_json(
            &items,
            &[VerifiedAnswer {
                call_id: "call-1".to_owned(),
                index: 0,
                state: AnswerState::Answered,
                selected_index: Some(1),
                selected_label: "Production".to_owned(),
                answered_by: "slack:T1:U9".to_owned(),
            }],
        );
        let declined = question_call_result_json(
            &items,
            &[VerifiedAnswer {
                call_id: "call-1".to_owned(),
                index: 0,
                state: AnswerState::Declined,
                selected_index: None,
                selected_label: String::new(),
                answered_by: "slack:T1:U9".to_owned(),
            }],
        );
        let auto_resolved = question_call_result_json(
            &items,
            &[VerifiedAnswer {
                call_id: "call-1".to_owned(),
                index: 0,
                state: AnswerState::AutoResolved,
                selected_index: Some(1),
                selected_label: "Production".to_owned(),
                answered_by: String::new(),
            }],
        );

        assert_ne!(still_pending, answered);
        assert_ne!(still_pending, declined);
        assert_ne!(still_pending, auto_resolved);

        let v: serde_json::Value = serde_json::from_str(&still_pending).unwrap();
        assert_eq!(v["answers"][0]["state"], "still_pending");
        assert!(v["answers"][0].get("selected_index").is_none());
        assert!(
            v["answers"][0]["note"]
                .as_str()
                .unwrap()
                .contains("still open"),
            "the still-pending note must tell the model this is not a real answer"
        );
    }

    /// A call with more than one question renders one entry per question, in
    /// order, each independently resolved.
    #[test]
    fn multi_question_call_renders_one_entry_per_question() {
        let items = two_items();
        let json = question_call_result_json(
            &items,
            &[
                VerifiedAnswer {
                    call_id: "call-1".to_owned(),
                    index: 0,
                    state: AnswerState::Answered,
                    selected_index: Some(0),
                    selected_label: "Staging".to_owned(),
                    answered_by: "slack:T1:U9".to_owned(),
                },
                VerifiedAnswer {
                    call_id: "call-1".to_owned(),
                    index: 1,
                    state: AnswerState::Declined,
                    selected_index: None,
                    selected_label: String::new(),
                    answered_by: "slack:T1:U9".to_owned(),
                },
            ],
        );
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(v["answers"].as_array().unwrap().len(), 2);
        assert_eq!(v["answers"][0]["header"], "Deploy target");
        assert_eq!(v["answers"][0]["state"], "answered");
        assert_eq!(v["answers"][1]["header"], "Notify team?");
        assert_eq!(v["answers"][1]["state"], "declined");
    }
}