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            approval_mode: true,
173        }
174    }
175
176    fn make_permission(id: &str) -> PermissionRequest {
177        PermissionRequest {
178            id: id.to_string(),
179            permission: "bash".to_string(),
180            metadata: serde_json::Value::Null,
181        }
182    }
183
184    fn make_question(id: &str) -> QuestionRequest {
185        QuestionRequest {
186            id: id.to_string(),
187            questions: vec![],
188        }
189    }
190
191    #[test]
192    fn test_permission_asked_transitions_to_approval_pending() {
193        let mut app = VoiceApp::new(test_config()).unwrap();
194        assert_eq!(app.state, RecordingState::Idle);
195        handle_sse_permission_asked(&mut app, make_permission("p1"));
196        assert_eq!(app.state, RecordingState::ApprovalPending);
197        assert!(app.approval_queue.has_pending());
198    }
199
200    #[test]
201    fn test_permission_replied_removes_from_queue_and_returns_to_idle() {
202        let mut app = VoiceApp::new(test_config()).unwrap();
203        handle_sse_permission_asked(&mut app, make_permission("p1"));
204        assert_eq!(app.state, RecordingState::ApprovalPending);
205
206        handle_sse_permission_replied(&mut app, "sess", "p1", "once");
207        assert_eq!(app.state, RecordingState::Idle);
208        assert!(!app.approval_queue.has_pending());
209    }
210
211    #[test]
212    fn test_question_asked_transitions_to_approval_pending() {
213        let mut app = VoiceApp::new(test_config()).unwrap();
214        handle_sse_question_asked(&mut app, make_question("q1"));
215        assert_eq!(app.state, RecordingState::ApprovalPending);
216    }
217
218    #[test]
219    fn test_question_replied_removes_from_queue() {
220        let mut app = VoiceApp::new(test_config()).unwrap();
221        handle_sse_question_asked(&mut app, make_question("q1"));
222        handle_sse_question_replied(&mut app, "sess", "q1", vec![]);
223        assert_eq!(app.state, RecordingState::Idle);
224        assert!(!app.approval_queue.has_pending());
225    }
226
227    #[test]
228    fn test_question_rejected_removes_from_queue() {
229        let mut app = VoiceApp::new(test_config()).unwrap();
230        handle_sse_question_asked(&mut app, make_question("q1"));
231        handle_sse_question_rejected(&mut app, "sess", "q1");
232        assert_eq!(app.state, RecordingState::Idle);
233        assert!(!app.approval_queue.has_pending());
234    }
235
236    // ── handle_sse_session_status ──────────────────────────────────────
237
238    #[test]
239    fn test_session_busy_clears_pending_approvals() {
240        let mut app = VoiceApp::new(test_config()).unwrap();
241        handle_sse_permission_asked(&mut app, make_permission("p1"));
242        handle_sse_question_asked(&mut app, make_question("q1"));
243        assert_eq!(app.state, RecordingState::ApprovalPending);
244        assert_eq!(app.approval_queue.len(), 2);
245
246        // Session becomes busy → AI resumed → approvals were answered externally.
247        handle_sse_session_status(&mut app, "sess", true);
248        assert_eq!(app.state, RecordingState::Idle);
249        assert!(!app.approval_queue.has_pending());
250    }
251
252    #[test]
253    fn test_session_idle_clears_stale_approvals() {
254        let mut app = VoiceApp::new(test_config()).unwrap();
255        handle_sse_permission_asked(&mut app, make_permission("p1"));
256        assert_eq!(app.state, RecordingState::ApprovalPending);
257
258        // Session went idle with approvals still in queue → stale.
259        handle_sse_session_status(&mut app, "sess", false);
260        assert_eq!(app.state, RecordingState::Idle);
261        assert!(!app.approval_queue.has_pending());
262    }
263
264    #[test]
265    fn test_session_busy_no_op_without_pending() {
266        let mut app = VoiceApp::new(test_config()).unwrap();
267        assert_eq!(app.state, RecordingState::Idle);
268
269        // No pending approvals → should be a no-op.
270        handle_sse_session_status(&mut app, "sess", true);
271        assert_eq!(app.state, RecordingState::Idle);
272    }
273
274    #[test]
275    fn test_session_busy_does_not_change_recording_state() {
276        let mut app = VoiceApp::new(test_config()).unwrap();
277        handle_sse_permission_asked(&mut app, make_permission("p1"));
278        // Manually set to Recording (user started recording before session went busy).
279        app.state = RecordingState::Recording;
280
281        handle_sse_session_status(&mut app, "sess", true);
282        // Queue should be cleared but state should stay Recording (not forced to Idle).
283        assert!(!app.approval_queue.has_pending());
284        assert_eq!(app.state, RecordingState::Recording);
285    }
286
287    #[test]
288    fn test_session_idle_no_op_without_pending() {
289        let mut app = VoiceApp::new(test_config()).unwrap();
290        assert_eq!(app.state, RecordingState::Idle);
291
292        // No pending approvals → idle event should be a no-op.
293        handle_sse_session_status(&mut app, "sess", false);
294        assert_eq!(app.state, RecordingState::Idle);
295        assert!(!app.approval_queue.has_pending());
296    }
297
298    #[test]
299    fn test_session_idle_does_not_change_transcribing_state() {
300        let mut app = VoiceApp::new(test_config()).unwrap();
301        handle_sse_permission_asked(&mut app, make_permission("p1"));
302        // Manually set to Transcribing (user finished recording, transcription in progress).
303        app.state = RecordingState::Transcribing;
304
305        handle_sse_session_status(&mut app, "sess", false);
306        // Queue should be cleared but state should stay Transcribing (not forced to Idle).
307        assert!(!app.approval_queue.has_pending());
308        assert_eq!(app.state, RecordingState::Transcribing);
309    }
310
311    #[test]
312    fn test_multiple_approvals_stay_pending_until_all_cleared() {
313        let mut app = VoiceApp::new(test_config()).unwrap();
314        handle_sse_permission_asked(&mut app, make_permission("p1"));
315        handle_sse_question_asked(&mut app, make_question("q1"));
316        assert_eq!(app.approval_queue.len(), 2);
317
318        handle_sse_permission_replied(&mut app, "sess", "p1", "once");
319        // Still one item left — should remain ApprovalPending.
320        assert_eq!(app.state, RecordingState::ApprovalPending);
321
322        handle_sse_question_rejected(&mut app, "sess", "q1");
323        // Queue now empty — should return to Idle.
324        assert_eq!(app.state, RecordingState::Idle);
325    }
326}