Skip to main content

mermaid_cli/providers/
questions.rs

1//! Inline question broker for the `ask_user_question` tool.
2//!
3//! Generalizes the approval flow (`super::approval::ApprovalBroker`) from a
4//! yes/no decision to an arbitrary set of multiple-choice answers. When the
5//! tool runs, it calls [`QuestionBroker::request`] (injected into
6//! [`ExecContext`]). That sends a `Msg::QuestionAsked` to the reducer — which
7//! renders a selectable modal — and parks the tool task on a oneshot until the
8//! user answers. The reducer (pure) emits `Cmd::ResolveQuestion`; the
9//! `EffectRunner` calls [`QuestionBroker::resolve`]; the parked task wakes with
10//! the user's answers and the tool returns them to the model. The turn pauses
11//! for free: while parked, the task hasn't sent `Msg::ToolFinished`, so its
12//! outcome slot stays `None` and no follow-up model call fires.
13//!
14//! Lock discipline mirrors the approval broker: `pending` uses
15//! [`std::sync::Mutex`] (whose guard is `!Send`) so a guard held across an
16//! `.await` fails to compile. Every critical section is tiny and synchronous.
17//!
18//! [`ExecContext`]: crate::providers::ctx::ExecContext
19
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex};
22
23use tokio::sync::{mpsc, oneshot};
24use tokio_util::sync::CancellationToken;
25
26use crate::domain::{Msg, Question, QuestionResolution, ToolCallId, TurnId};
27
28/// Owned by the interactive `EffectRunner`, cloned into each `ExecContext`.
29/// Absent (`None`) in headless runs — the tool then proceeds without a human.
30#[derive(Clone)]
31pub struct QuestionBroker {
32    pending: Arc<Mutex<HashMap<ToolCallId, oneshot::Sender<QuestionResolution>>>>,
33    msg_tx: mpsc::Sender<Msg>,
34}
35
36impl QuestionBroker {
37    pub fn new(msg_tx: mpsc::Sender<Msg>) -> Self {
38        Self {
39            pending: Arc::new(Mutex::new(HashMap::new())),
40            msg_tx,
41        }
42    }
43
44    /// Ask the user a batch of questions and block until they answer (or the
45    /// turn is cancelled). Fail-safe: a dropped sender, a gone reducer, or a
46    /// cancel all resolve to `Dismissed`.
47    pub async fn request(
48        &self,
49        token: &CancellationToken,
50        turn: TurnId,
51        call_id: ToolCallId,
52        questions: Vec<Question>,
53    ) -> QuestionResolution {
54        let (tx, rx) = oneshot::channel();
55        // Register the sender; the guard drops at the end of this statement —
56        // never held across the awaits below.
57        self.pending
58            .lock()
59            .unwrap_or_else(|poisoned| poisoned.into_inner())
60            .insert(call_id, tx);
61
62        let sent = self
63            .msg_tx
64            .send(Msg::QuestionAsked {
65                turn,
66                call_id,
67                questions,
68            })
69            .await;
70        if sent.is_err() {
71            // Reducer is gone — clean up and dismiss.
72            self.pending
73                .lock()
74                .unwrap_or_else(|poisoned| poisoned.into_inner())
75                .remove(&call_id);
76            return QuestionResolution::Dismissed;
77        }
78
79        tokio::select! {
80            biased;
81            _ = token.cancelled() => {
82                self.pending.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&call_id);
83                QuestionResolution::Dismissed
84            }
85            resolution = rx => resolution.unwrap_or(QuestionResolution::Dismissed),
86        }
87    }
88
89    /// Deliver the user's answers to the parked task.
90    pub fn resolve(&self, call_id: ToolCallId, resolution: QuestionResolution) {
91        let entry = self
92            .pending
93            .lock()
94            .unwrap_or_else(|poisoned| poisoned.into_inner())
95            .remove(&call_id);
96        if let Some(tx) = entry {
97            let _ = tx.send(resolution);
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::domain::{QuestionAnswer, QuestionOption};
106
107    fn sample_questions() -> Vec<Question> {
108        vec![Question {
109            header: "Database".to_string(),
110            question: "Which database?".to_string(),
111            kind: crate::domain::QuestionKind::Select,
112            options: vec![QuestionOption {
113                label: "PostgreSQL".to_string(),
114                description: None,
115                recommended: true,
116                preview: None,
117            }],
118            memory_key: None,
119        }]
120    }
121
122    #[tokio::test]
123    async fn resolve_delivers_answers() {
124        let (tx, _rx) = mpsc::channel::<Msg>(8);
125        let broker = QuestionBroker::new(tx);
126
127        let b2 = broker.clone();
128        let handle = tokio::spawn(async move {
129            b2.request(
130                &CancellationToken::new(),
131                TurnId(1),
132                ToolCallId(1),
133                sample_questions(),
134            )
135            .await
136        });
137        // Poll until the request has registered, then resolve it.
138        let answers = vec![QuestionAnswer {
139            header: "Database".to_string(),
140            question: "Which database?".to_string(),
141            selected: vec!["PostgreSQL".to_string()],
142            note: None,
143        }];
144        for _ in 0..100 {
145            broker.resolve(
146                ToolCallId(1),
147                QuestionResolution::Answered {
148                    answers: answers.clone(),
149                    remember: false,
150                },
151            );
152            tokio::task::yield_now().await;
153            if broker
154                .pending
155                .lock()
156                .unwrap_or_else(|poisoned| poisoned.into_inner())
157                .is_empty()
158            {
159                break;
160            }
161        }
162        let resolution = handle.await.unwrap();
163        assert_eq!(
164            resolution,
165            QuestionResolution::Answered {
166                answers,
167                remember: false
168            }
169        );
170    }
171
172    #[tokio::test]
173    async fn cancel_token_dismisses() {
174        let (tx, _rx) = mpsc::channel::<Msg>(8);
175        let broker = QuestionBroker::new(tx);
176        let token = CancellationToken::new();
177        let token2 = token.clone();
178        let handle = tokio::spawn(async move {
179            broker
180                .request(&token2, TurnId(1), ToolCallId(2), sample_questions())
181                .await
182        });
183        tokio::task::yield_now().await;
184        token.cancel();
185        assert_eq!(handle.await.unwrap(), QuestionResolution::Dismissed);
186    }
187}