Skip to main content

opencode_voice/app/
approval.rs

1//! Approval flow integration — SSE event handlers for the approval queue.
2//!
3//! These functions are called from the main event loop in [`super`] when
4//! SSE events arrive from OpenCode.  They update the [`ApprovalQueue`] and
5//! transition the recording state machine as needed.
6//!
7//! Voice-driven reply logic lives in [`super::recording::try_handle_approval`].
8//! This module provides the SSE handlers and the shared
9//! [`refresh_approval_display`] helper used after a voice reply is dispatched.
10
11use crate::approval::types::{PermissionRequest, QuestionRequest};
12use crate::state::RecordingState;
13
14use super::VoiceApp;
15
16/// Called when a `permission.asked` SSE event arrives.
17///
18/// Adds the request to the approval queue and transitions to
19/// [`RecordingState::ApprovalPending`] if not already there.
20pub(crate) fn handle_sse_permission_asked(app: &mut VoiceApp, req: PermissionRequest) {
21    app.approval_queue.add_permission(req);
22    if app.state != RecordingState::ApprovalPending {
23        app.state = RecordingState::ApprovalPending;
24    }
25    app.render_display();
26}
27
28/// Called when a `permission.replied` SSE event arrives.
29///
30/// Removes the matching request from the queue.  If the queue is now empty
31/// and the state is still [`RecordingState::ApprovalPending`], transitions
32/// back to [`RecordingState::Idle`].
33pub(crate) fn handle_sse_permission_replied(
34    app: &mut VoiceApp,
35    _session_id: &str,
36    request_id: &str,
37    _reply: &str,
38) {
39    app.approval_queue.remove(request_id);
40    if !app.approval_queue.has_pending() && app.state == RecordingState::ApprovalPending {
41        app.state = RecordingState::Idle;
42    }
43    app.render_display();
44}
45
46/// Called when a `question.asked` SSE event arrives.
47///
48/// Adds the request to the approval queue and transitions to
49/// [`RecordingState::ApprovalPending`] if not already there.
50pub(crate) fn handle_sse_question_asked(app: &mut VoiceApp, req: QuestionRequest) {
51    app.approval_queue.add_question(req);
52    if app.state != RecordingState::ApprovalPending {
53        app.state = RecordingState::ApprovalPending;
54    }
55    app.render_display();
56}
57
58/// Called when a `question.replied` SSE event arrives.
59///
60/// Removes the matching request from the queue.  If the queue is now empty
61/// and the state is still [`RecordingState::ApprovalPending`], transitions
62/// back to [`RecordingState::Idle`].
63pub(crate) fn handle_sse_question_replied(
64    app: &mut VoiceApp,
65    _session_id: &str,
66    request_id: &str,
67    _answers: Vec<Vec<String>>,
68) {
69    app.approval_queue.remove(request_id);
70    if !app.approval_queue.has_pending() && app.state == RecordingState::ApprovalPending {
71        app.state = RecordingState::Idle;
72    }
73    app.render_display();
74}
75
76/// Called when a `question.rejected` SSE event arrives.
77///
78/// Removes the matching request from the queue.  If the queue is now empty
79/// and the state is still [`RecordingState::ApprovalPending`], transitions
80/// back to [`RecordingState::Idle`].
81pub(crate) fn handle_sse_question_rejected(
82    app: &mut VoiceApp,
83    _session_id: &str,
84    request_id: &str,
85) {
86    app.approval_queue.remove(request_id);
87    if !app.approval_queue.has_pending() && app.state == RecordingState::ApprovalPending {
88        app.state = RecordingState::Idle;
89    }
90    app.render_display();
91}
92
93/// Called when a `session.status` SSE event arrives.
94///
95/// When the session transitions to **busy**, it means the AI has resumed
96/// work.  Any pending permissions or questions must have already been
97/// answered — possibly by the user interacting directly with the OpenCode
98/// TUI rather than through voice.  In that case, the individual
99/// `permission.replied` / `question.replied` SSE events *should* also have
100/// arrived, but as a safety net (e.g. SSE reconnection gaps) we clear all
101/// stale approvals here.
102///
103/// When the session transitions to **idle** with approvals still in the
104/// queue, those approvals are stale (the session finished without us
105/// receiving individual reply events) and are also cleared.
106pub(crate) fn handle_sse_session_status(app: &mut VoiceApp, _session_id: &str, busy: bool) {
107    if busy && app.approval_queue.has_pending() {
108        // AI resumed work → all pending approvals were answered externally.
109        app.display
110            .log("[voice] Session became busy — clearing pending approvals (answered externally).");
111        app.approval_queue.clear();
112        if app.state == RecordingState::ApprovalPending {
113            app.state = RecordingState::Idle;
114        }
115        app.render_display();
116    } else if !busy && app.approval_queue.has_pending() {
117        // Session went idle but we still have approvals — they are stale.
118        app.display
119            .log("[voice] Session idle — clearing stale approvals.");
120        app.approval_queue.clear();
121        if app.state == RecordingState::ApprovalPending {
122            app.state = RecordingState::Idle;
123        }
124        app.render_display();
125    }
126}
127
128/// Refreshes the approval display after a voice-driven reply has been sent.
129///
130/// Applies the following state-machine rules and then re-renders the terminal:
131///
132/// * If the queue still has pending items **and** the current state is
133///   [`RecordingState::Idle`] or [`RecordingState::ApprovalPending`] →
134///   transition to (or stay at) [`RecordingState::ApprovalPending`].
135/// * If the queue is now empty **and** the current state is
136///   [`RecordingState::ApprovalPending`] → transition to
137///   [`RecordingState::Idle`].
138/// * Otherwise (e.g. Recording, Transcribing, Error) → leave the state
139///   unchanged and just re-render.
140pub(crate) fn refresh_approval_display(app: &mut VoiceApp) {
141    if app.approval_queue.has_pending() {
142        if app.state == RecordingState::Idle || app.state == RecordingState::ApprovalPending {
143            app.state = RecordingState::ApprovalPending;
144        }
145    } else if app.state == RecordingState::ApprovalPending {
146        app.state = RecordingState::Idle;
147    }
148    app.render_display();
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::app::VoiceApp;
155    use crate::approval::types::{PermissionRequest, QuestionRequest};
156    use crate::config::{AppConfig, ModelSize};
157    use std::path::PathBuf;
158
159    fn test_config() -> AppConfig {
160        AppConfig {
161            whisper_model_path: PathBuf::from("/nonexistent/model.bin"),
162            opencode_port: 4096,
163            toggle_key: ' ',
164            model_size: ModelSize::TinyEn,
165            auto_submit: true,
166            server_password: None,
167            data_dir: PathBuf::from("/nonexistent/data"),
168            audio_device: None,
169            use_global_hotkey: false,
170            global_hotkey: "right_option".to_string(),
171            push_to_talk: true,
172            handle_prompts: true,
173            debug: false,
174        }
175    }
176
177    fn make_permission(id: &str) -> PermissionRequest {
178        PermissionRequest {
179            id: id.to_string(),
180            permission: "bash".to_string(),
181            metadata: serde_json::Value::Null,
182        }
183    }
184
185    fn make_question(id: &str) -> QuestionRequest {
186        QuestionRequest {
187            id: id.to_string(),
188            questions: vec![],
189        }
190    }
191
192    #[test]
193    fn test_permission_asked_transitions_to_approval_pending() {
194        let mut app = VoiceApp::new(test_config()).unwrap();
195        assert_eq!(app.state, RecordingState::Idle);
196        handle_sse_permission_asked(&mut app, make_permission("p1"));
197        assert_eq!(app.state, RecordingState::ApprovalPending);
198        assert!(app.approval_queue.has_pending());
199    }
200
201    #[test]
202    fn test_permission_replied_removes_from_queue_and_returns_to_idle() {
203        let mut app = VoiceApp::new(test_config()).unwrap();
204        handle_sse_permission_asked(&mut app, make_permission("p1"));
205        assert_eq!(app.state, RecordingState::ApprovalPending);
206
207        handle_sse_permission_replied(&mut app, "sess", "p1", "once");
208        assert_eq!(app.state, RecordingState::Idle);
209        assert!(!app.approval_queue.has_pending());
210    }
211
212    #[test]
213    fn test_question_asked_transitions_to_approval_pending() {
214        let mut app = VoiceApp::new(test_config()).unwrap();
215        handle_sse_question_asked(&mut app, make_question("q1"));
216        assert_eq!(app.state, RecordingState::ApprovalPending);
217    }
218
219    #[test]
220    fn test_question_replied_removes_from_queue() {
221        let mut app = VoiceApp::new(test_config()).unwrap();
222        handle_sse_question_asked(&mut app, make_question("q1"));
223        handle_sse_question_replied(&mut app, "sess", "q1", vec![]);
224        assert_eq!(app.state, RecordingState::Idle);
225        assert!(!app.approval_queue.has_pending());
226    }
227
228    #[test]
229    fn test_question_rejected_removes_from_queue() {
230        let mut app = VoiceApp::new(test_config()).unwrap();
231        handle_sse_question_asked(&mut app, make_question("q1"));
232        handle_sse_question_rejected(&mut app, "sess", "q1");
233        assert_eq!(app.state, RecordingState::Idle);
234        assert!(!app.approval_queue.has_pending());
235    }
236
237    // ── handle_sse_session_status ──────────────────────────────────────
238
239    #[test]
240    fn test_session_busy_clears_pending_approvals() {
241        let mut app = VoiceApp::new(test_config()).unwrap();
242        handle_sse_permission_asked(&mut app, make_permission("p1"));
243        handle_sse_question_asked(&mut app, make_question("q1"));
244        assert_eq!(app.state, RecordingState::ApprovalPending);
245        assert_eq!(app.approval_queue.len(), 2);
246
247        // Session becomes busy → AI resumed → approvals were answered externally.
248        handle_sse_session_status(&mut app, "sess", true);
249        assert_eq!(app.state, RecordingState::Idle);
250        assert!(!app.approval_queue.has_pending());
251    }
252
253    #[test]
254    fn test_session_idle_clears_stale_approvals() {
255        let mut app = VoiceApp::new(test_config()).unwrap();
256        handle_sse_permission_asked(&mut app, make_permission("p1"));
257        assert_eq!(app.state, RecordingState::ApprovalPending);
258
259        // Session went idle with approvals still in queue → stale.
260        handle_sse_session_status(&mut app, "sess", false);
261        assert_eq!(app.state, RecordingState::Idle);
262        assert!(!app.approval_queue.has_pending());
263    }
264
265    #[test]
266    fn test_session_busy_no_op_without_pending() {
267        let mut app = VoiceApp::new(test_config()).unwrap();
268        assert_eq!(app.state, RecordingState::Idle);
269
270        // No pending approvals → should be a no-op.
271        handle_sse_session_status(&mut app, "sess", true);
272        assert_eq!(app.state, RecordingState::Idle);
273    }
274
275    #[test]
276    fn test_session_busy_does_not_change_recording_state() {
277        let mut app = VoiceApp::new(test_config()).unwrap();
278        handle_sse_permission_asked(&mut app, make_permission("p1"));
279        // Manually set to Recording (user started recording before session went busy).
280        app.state = RecordingState::Recording;
281
282        handle_sse_session_status(&mut app, "sess", true);
283        // Queue should be cleared but state should stay Recording (not forced to Idle).
284        assert!(!app.approval_queue.has_pending());
285        assert_eq!(app.state, RecordingState::Recording);
286    }
287
288    #[test]
289    fn test_session_idle_no_op_without_pending() {
290        let mut app = VoiceApp::new(test_config()).unwrap();
291        assert_eq!(app.state, RecordingState::Idle);
292
293        // No pending approvals → idle event should be a no-op.
294        handle_sse_session_status(&mut app, "sess", false);
295        assert_eq!(app.state, RecordingState::Idle);
296        assert!(!app.approval_queue.has_pending());
297    }
298
299    #[test]
300    fn test_session_idle_does_not_change_transcribing_state() {
301        let mut app = VoiceApp::new(test_config()).unwrap();
302        handle_sse_permission_asked(&mut app, make_permission("p1"));
303        // Manually set to Transcribing (user finished recording, transcription in progress).
304        app.state = RecordingState::Transcribing;
305
306        handle_sse_session_status(&mut app, "sess", false);
307        // Queue should be cleared but state should stay Transcribing (not forced to Idle).
308        assert!(!app.approval_queue.has_pending());
309        assert_eq!(app.state, RecordingState::Transcribing);
310    }
311
312    #[test]
313    fn test_multiple_approvals_stay_pending_until_all_cleared() {
314        let mut app = VoiceApp::new(test_config()).unwrap();
315        handle_sse_permission_asked(&mut app, make_permission("p1"));
316        handle_sse_question_asked(&mut app, make_question("q1"));
317        assert_eq!(app.approval_queue.len(), 2);
318
319        handle_sse_permission_replied(&mut app, "sess", "p1", "once");
320        // Still one item left — should remain ApprovalPending.
321        assert_eq!(app.state, RecordingState::ApprovalPending);
322
323        handle_sse_question_rejected(&mut app, "sess", "q1");
324        // Queue now empty — should return to Idle.
325        assert_eq!(app.state, RecordingState::Idle);
326    }
327}