Skip to main content

ocserve_core/
question.rs

1//! Question rendezvous — port of upstream `Question` service
2//! (`opencode/src/question/index.ts`): pending map, `question.asked/replied/
3//! rejected` events, reply/reject resolution. Bounded: wait cap replaces
4//! upstream's infinite Deferred await (AGENTS §2.3 — divergence documented
5//! in the runner: timeout resolves as a reject).
6
7use serde_json::Value;
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::Duration;
11
12/// Runner-side bound for an unanswered question (upstream waits forever).
13pub const QUESTION_WAIT: Duration = Duration::from_secs(1800);
14
15/// v1 RejectedError message (verbatim): the tool surfaces this to the model.
16pub const REJECTED_MESSAGE: &str = "The user dismissed this question";
17
18#[derive(Debug, Clone)]
19pub enum Outcome {
20    /// reply: answers in question order (each = selected labels).
21    Answers(Vec<Vec<String>>),
22    /// reject / wait timeout / channel closed.
23    Rejected,
24}
25
26struct Pending {
27    request: Value,
28    tx: tokio::sync::oneshot::Sender<Outcome>,
29}
30
31/// Pending question requests across sessions (GET /question list + resolve).
32#[derive(Default)]
33pub struct QuestionGate {
34    pending: parking_lot::Mutex<HashMap<String, Pending>>,
35}
36
37/// Dropping the runner's guard removes its pending entry (upstream's
38/// Effect.ensuring — covers abort of the background prompt task).
39pub struct PendingGuard {
40    gate: Arc<QuestionGate>,
41    id: String,
42}
43
44impl Drop for PendingGuard {
45    fn drop(&mut self) {
46        self.gate.pending.lock().remove(&self.id);
47    }
48}
49
50impl QuestionGate {
51    pub fn new() -> Arc<Self> {
52        Arc::new(Self::default())
53    }
54
55    /// Register a pending ask; returns the outcome receiver + cleanup guard.
56    pub fn register(
57        self: &Arc<Self>,
58        id: &str,
59        request: Value,
60    ) -> (tokio::sync::oneshot::Receiver<Outcome>, PendingGuard) {
61        let (tx, rx) = tokio::sync::oneshot::channel();
62        self.pending
63            .lock()
64            .insert(id.to_string(), Pending { request, tx });
65        let guard = PendingGuard {
66            gate: Arc::clone(self),
67            id: id.to_string(),
68        };
69        (rx, guard)
70    }
71
72    /// `POST /question/{id}/reply` — answers must be an array of string
73    /// arrays (v1 Question.Reply). Unknown id → false (404 upstream).
74    pub fn reply(&self, id: &str, answers: Vec<Vec<String>>) -> bool {
75        match self.pending.lock().remove(id) {
76            Some(p) => p.tx.send(Outcome::Answers(answers)).is_ok(),
77            None => false,
78        }
79    }
80
81    /// `POST /question/{id}/reject` (no body). Unknown id → false.
82    pub fn reject(&self, id: &str) -> bool {
83        match self.pending.lock().remove(id) {
84            Some(p) => p.tx.send(Outcome::Rejected).is_ok(),
85            None => false,
86        }
87    }
88
89    /// Runner-side timeout cleanup (entry gone → list no longer shows it).
90    pub fn remove(&self, id: &str) {
91        self.pending.lock().remove(id);
92    }
93
94    /// `GET /question` — all pending Request objects (v1 Question.Request).
95    pub fn list(&self) -> Vec<Value> {
96        self.pending
97            .lock()
98            .values()
99            .map(|p| p.request.clone())
100            .collect()
101    }
102
103    /// Runner wait with the bounded cap; timeout → Rejected + cleanup.
104    pub async fn wait(&self, id: &str, rx: tokio::sync::oneshot::Receiver<Outcome>) -> Outcome {
105        match tokio::time::timeout(QUESTION_WAIT, rx).await {
106            Ok(Ok(outcome)) => outcome,
107            Ok(Err(_)) => Outcome::Rejected, // sender dropped
108            Err(_) => {
109                self.remove(id);
110                Outcome::Rejected
111            }
112        }
113    }
114}
115
116/// v1 QuestionTool output formatting (verbatim template).
117pub fn format_output(questions: &Value, answers: &[Vec<String>]) -> String {
118    let items: Vec<String> = questions
119        .as_array()
120        .map(|qs| {
121            qs.iter()
122                .enumerate()
123                .map(|(i, q)| {
124                    let answer = answers
125                        .get(i)
126                        .map(|a| a.join(", "))
127                        .filter(|s| !s.is_empty())
128                        .unwrap_or_else(|| "Unanswered".to_string());
129                    format!(
130                        "\"{}\"=\"{}\"",
131                        q["question"].as_str().unwrap_or_default(),
132                        answer
133                    )
134                })
135                .collect()
136        })
137        .unwrap_or_default();
138    format!(
139        "User has answered your questions: {}. You can now continue with the user's answers in mind.",
140        items.join(", ")
141    )
142}
143
144/// v1 QuestionTool title.
145pub fn format_title(questions: &Value) -> String {
146    let n = questions.as_array().map(|a| a.len()).unwrap_or(0);
147    format!("Asked {} question{}", n, if n > 1 { "s" } else { "" })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use serde_json::json;
154
155    #[test]
156    fn output_matches_v1_template() {
157        let qs = json!([
158            {"question": "Pick a color", "header": "Color",
159             "options": [{"label": "Red", "description": "r"}]},
160            {"question": "Pick speed", "header": "Speed",
161             "options": [{"label": "Fast", "description": "f"}]}
162        ]);
163        let answers = vec![vec!["Red".to_string()], vec![]];
164        assert_eq!(
165            format_output(&qs, &answers),
166            "User has answered your questions: \"Pick a color\"=\"Red\", \
167             \"Pick speed\"=\"Unanswered\". You can now continue with the user's answers in mind."
168        );
169        assert_eq!(format_title(&qs), "Asked 2 questions");
170        assert_eq!(format_title(&json!([])), "Asked 0 question"); // v1: s only when n>1
171    }
172
173    #[tokio::test]
174    async fn register_reply_list_roundtrip() {
175        let gate = QuestionGate::new();
176        let req = json!({"id": "que_1", "sessionID": "ses_1", "questions": []});
177        let (rx, _guard) = gate.register("que_1", req.clone());
178        assert_eq!(gate.list(), vec![req.clone()]);
179        assert!(gate.reply("que_1", vec![vec!["a".into()]]));
180        assert!(gate.list().is_empty(), "entry removed on reply");
181        match rx.await.unwrap() {
182            Outcome::Answers(a) => assert_eq!(a, vec![vec!["a".to_string()]]),
183            _ => panic!("expected answers"),
184        }
185        // double reply → false (404)
186        assert!(!gate.reply("que_1", vec![]));
187    }
188
189    #[tokio::test]
190    async fn reject_resolves_and_unknown_is_false() {
191        let gate = QuestionGate::new();
192        let (rx, _guard) = gate.register("que_2", json!({"id": "que_2"}));
193        assert!(gate.reject("que_2"));
194        assert!(matches!(rx.await.unwrap(), Outcome::Rejected));
195        assert!(!gate.reject("que_missing"));
196        assert!(!gate.reply("que_missing", vec![]));
197    }
198
199    #[tokio::test(start_paused = true)]
200    async fn wait_timeout_rejects_and_cleans_pending() {
201        let gate = QuestionGate::new();
202        let (rx, _guard) = gate.register("que_3", json!({"id": "que_3"}));
203        assert_eq!(gate.list().len(), 1);
204        // no reply ever — advance past QUESTION_WAIT under paused time
205        tokio::time::advance(QUESTION_WAIT + Duration::from_secs(1)).await;
206        let outcome = gate.wait("que_3", rx).await;
207        assert!(matches!(outcome, Outcome::Rejected));
208        assert!(gate.list().is_empty(), "timeout cleans the pending map");
209    }
210
211    #[tokio::test]
212    async fn guard_drop_cleans_pending_on_abort() {
213        let gate = QuestionGate::new();
214        {
215            let (_rx, _guard) = gate.register("que_4", json!({"id": "que_4"}));
216            assert_eq!(gate.list().len(), 1);
217        } // guard dropped → task aborted equivalent
218        assert!(
219            gate.list().is_empty(),
220            "drop removes pending (upstream ensuring)"
221        );
222    }
223}