Skip to main content

a3s_code_core/
ask_user.rs

1//! Structured question that parks a run without a write, a permission grant,
2//! or a host steer.
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::sync::{Mutex, OnceLock};
8
9pub const USER_QUESTION_SCHEMA: &str = "a3s.code.user-question.v1";
10pub const USER_ANSWER_SCHEMA: &str = "a3s.code.user-answer.v1";
11const MAX_QUESTIONS_PER_RUN: u32 = 3;
12const MAX_OPTIONS: usize = 8;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct UserQuestionV1 {
16    pub schema: String,
17    pub question_id: String,
18    pub question: String,
19    pub options: Vec<String>,
20    pub allow_free_text: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum AskUserResume {
25    Answered { text: String },
26    Unanswered,
27}
28
29fn counts() -> &'static Mutex<HashMap<String, u32>> {
30    static COUNTS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
31    COUNTS.get_or_init(|| Mutex::new(HashMap::new()))
32}
33
34#[derive(Debug)]
35pub enum AskUserError {
36    CapExceeded,
37    Invalid(&'static str),
38}
39
40/// Admit one question for `run_id` before a question fact is appended.
41///
42/// The fourth question in a run is rejected here. An answer is a
43/// `question.answered` fact, not an in-process wait.
44pub fn begin(
45    run_id: &str,
46    question_id: &str,
47    question: &str,
48    options: &[String],
49    allow_free_text: bool,
50) -> Result<UserQuestionV1, AskUserError> {
51    if question.trim().is_empty() {
52        return Err(AskUserError::Invalid("question is required"));
53    }
54    if options.len() > MAX_OPTIONS {
55        return Err(AskUserError::Invalid("too many options"));
56    }
57    let mut counts = counts().lock().expect("ask-user counts");
58    let count = counts.entry(run_id.to_string()).or_insert(0);
59    if *count >= MAX_QUESTIONS_PER_RUN {
60        return Err(AskUserError::CapExceeded);
61    }
62    *count += 1;
63    let sequence = *count;
64    drop(counts);
65    Ok(UserQuestionV1 {
66        schema: USER_QUESTION_SCHEMA.to_string(),
67        question_id: format!("{question_id}-{sequence}"),
68        question: question.to_string(),
69        options: options.to_vec(),
70        allow_free_text,
71    })
72}
73
74fn pending_answers() -> &'static Mutex<HashMap<String, String>> {
75    static ANSWERS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
76    ANSWERS.get_or_init(|| Mutex::new(HashMap::new()))
77}
78
79fn answer_notify() -> &'static tokio::sync::Notify {
80    static NOTIFY: OnceLock<tokio::sync::Notify> = OnceLock::new();
81    NOTIFY.get_or_init(tokio::sync::Notify::new)
82}
83
84/// Record a host answer for a later `question.answered` fact.
85///
86/// Returns false. The call does not settle the run; the fact log does.
87pub fn answer(question_id: &str, text: &str) -> bool {
88    if question_id.trim().is_empty() || text.trim().is_empty() {
89        return false;
90    }
91    pending_answers()
92        .lock()
93        .expect("ask-user answers")
94        .insert(question_id.to_string(), text.to_string());
95    answer_notify().notify_waiters();
96    false
97}
98
99/// Wait until [`answer`] stores text for `question_id`, or the run is cancelled.
100pub async fn wait_for_answer(
101    question_id: &str,
102    cancel: &tokio_util::sync::CancellationToken,
103) -> Option<String> {
104    loop {
105        let notified = answer_notify().notified();
106        if let Some(text) = pending_answers()
107            .lock()
108            .expect("ask-user answers")
109            .remove(question_id)
110        {
111            return Some(text);
112        }
113        if cancel.is_cancelled() {
114            return None;
115        }
116        tokio::select! {
117            _ = notified => {}
118            _ = cancel.cancelled() => return None,
119        }
120    }
121}
122
123/// Cancellation no longer synthesizes an unanswered result.
124pub fn cancel(_question_id: &str) -> bool {
125    false
126}
127
128pub fn resume_message(question_id: &str, resume: &AskUserResume) -> String {
129    let value = match resume {
130        AskUserResume::Answered { text } => serde_json::json!({
131            "schema": USER_ANSWER_SCHEMA,
132            "question_id": question_id,
133            "status": "answered",
134            "answer": text,
135            "permission_grant": false,
136        }),
137        AskUserResume::Unanswered => serde_json::json!({
138            "schema": USER_ANSWER_SCHEMA,
139            "question_id": question_id,
140            "status": "unanswered",
141            "permission_grant": false,
142        }),
143    };
144    value.to_string()
145}
146
147pub fn is_permission_grant(metadata: &Value) -> bool {
148    metadata
149        .get("permission_grant")
150        .and_then(Value::as_bool)
151        .unwrap_or(false)
152        || metadata
153            .get("approved")
154            .and_then(Value::as_bool)
155            .unwrap_or(false)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn begin_keeps_allow_free_text_and_does_not_answer_in_process() {
164        let question = begin("run-1", "q-1", "Which file?", &["a.rs".into()], true).unwrap();
165        assert_eq!(question.schema, USER_QUESTION_SCHEMA);
166        assert!(question.allow_free_text);
167        assert_eq!(question.options, vec!["a.rs".to_string()]);
168        assert!(!answer(&question.question_id, "a.rs"));
169        assert!(!cancel(&question.question_id));
170        let message = resume_message(
171            &question.question_id,
172            &AskUserResume::Answered {
173                text: "a.rs".into(),
174            },
175        );
176        let value: Value = serde_json::from_str(&message).unwrap();
177        assert_eq!(value["status"], "answered");
178        assert!(!is_permission_grant(&value));
179    }
180
181    #[test]
182    fn question_cap_rejects_a_fourth_question() {
183        for index in 0..MAX_QUESTIONS_PER_RUN {
184            begin("run-cap", &format!("q-{index}"), "again?", &[], false).unwrap();
185        }
186        assert!(matches!(
187            begin("run-cap", "q-over", "again?", &[], false),
188            Err(AskUserError::CapExceeded)
189        ));
190    }
191
192    #[test]
193    fn begin_rejects_empty_question_and_option_overflow() {
194        assert!(matches!(
195            begin("run-invalid", "q-empty", "   ", &[], false),
196            Err(AskUserError::Invalid(_))
197        ));
198        let options: Vec<String> = (0..=MAX_OPTIONS)
199            .map(|index| format!("opt-{index}"))
200            .collect();
201        assert!(matches!(
202            begin("run-invalid", "q-options", "pick one?", &options, false),
203            Err(AskUserError::Invalid(_))
204        ));
205    }
206
207    #[tokio::test]
208    async fn wait_for_answer_reads_the_stored_host_text() {
209        let cancel = tokio_util::sync::CancellationToken::new();
210        let question_id = format!("wait-{}", std::process::id());
211        let cancel_for_answer = cancel.clone();
212        let id = question_id.clone();
213        tokio::spawn(async move {
214            let _ = cancel_for_answer;
215            assert!(!answer(&id, "host-token"));
216        });
217        let text = tokio::time::timeout(
218            std::time::Duration::from_secs(2),
219            wait_for_answer(&question_id, &cancel),
220        )
221        .await
222        .expect("host answer")
223        .expect("stored answer");
224        assert_eq!(text, "host-token");
225    }
226
227    #[test]
228    fn answer_and_cancel_return_false_for_unknown_question_ids() {
229        assert!(!answer("missing-question", "nope"));
230        assert!(!cancel("missing-question"));
231    }
232
233    #[test]
234    fn is_permission_grant_honors_legacy_approved_field() {
235        let metadata = serde_json::json!({"approved": true});
236        assert!(is_permission_grant(&metadata));
237        assert!(!is_permission_grant(
238            &serde_json::json!({"permission_grant": false})
239        ));
240    }
241}