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/// Refreshes the approval display after a voice-driven reply has been sent.
94///
95/// Applies the following state-machine rules and then re-renders the terminal:
96///
97/// * If the queue still has pending items **and** the current state is
98///   [`RecordingState::Idle`] or [`RecordingState::ApprovalPending`] →
99///   transition to (or stay at) [`RecordingState::ApprovalPending`].
100/// * If the queue is now empty **and** the current state is
101///   [`RecordingState::ApprovalPending`] → transition to
102///   [`RecordingState::Idle`].
103/// * Otherwise (e.g. Recording, Transcribing, Error) → leave the state
104///   unchanged and just re-render.
105pub(crate) fn refresh_approval_display(app: &mut VoiceApp) {
106    if app.approval_queue.has_pending() {
107        if app.state == RecordingState::Idle || app.state == RecordingState::ApprovalPending {
108            app.state = RecordingState::ApprovalPending;
109        }
110    } else if app.state == RecordingState::ApprovalPending {
111        app.state = RecordingState::Idle;
112    }
113    app.render_display();
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::app::VoiceApp;
120    use crate::approval::types::{PermissionRequest, QuestionRequest};
121    use crate::config::{AppConfig, ModelSize};
122    use std::path::PathBuf;
123
124    fn test_config() -> AppConfig {
125        AppConfig {
126            whisper_model_path: PathBuf::from("/nonexistent/model.bin"),
127            opencode_port: 4096,
128            toggle_key: ' ',
129            model_size: ModelSize::TinyEn,
130            auto_submit: true,
131            server_password: None,
132            data_dir: PathBuf::from("/nonexistent/data"),
133            audio_device: None,
134            use_global_hotkey: false,
135            global_hotkey: "right_option".to_string(),
136            push_to_talk: true,
137            approval_mode: true,
138        }
139    }
140
141    fn make_permission(id: &str) -> PermissionRequest {
142        PermissionRequest {
143            id: id.to_string(),
144            permission: "bash".to_string(),
145            metadata: serde_json::Value::Null,
146        }
147    }
148
149    fn make_question(id: &str) -> QuestionRequest {
150        QuestionRequest {
151            id: id.to_string(),
152            questions: vec![],
153        }
154    }
155
156    #[test]
157    fn test_permission_asked_transitions_to_approval_pending() {
158        let mut app = VoiceApp::new(test_config()).unwrap();
159        assert_eq!(app.state, RecordingState::Idle);
160        handle_sse_permission_asked(&mut app, make_permission("p1"));
161        assert_eq!(app.state, RecordingState::ApprovalPending);
162        assert!(app.approval_queue.has_pending());
163    }
164
165    #[test]
166    fn test_permission_replied_removes_from_queue_and_returns_to_idle() {
167        let mut app = VoiceApp::new(test_config()).unwrap();
168        handle_sse_permission_asked(&mut app, make_permission("p1"));
169        assert_eq!(app.state, RecordingState::ApprovalPending);
170
171        handle_sse_permission_replied(&mut app, "sess", "p1", "once");
172        assert_eq!(app.state, RecordingState::Idle);
173        assert!(!app.approval_queue.has_pending());
174    }
175
176    #[test]
177    fn test_question_asked_transitions_to_approval_pending() {
178        let mut app = VoiceApp::new(test_config()).unwrap();
179        handle_sse_question_asked(&mut app, make_question("q1"));
180        assert_eq!(app.state, RecordingState::ApprovalPending);
181    }
182
183    #[test]
184    fn test_question_replied_removes_from_queue() {
185        let mut app = VoiceApp::new(test_config()).unwrap();
186        handle_sse_question_asked(&mut app, make_question("q1"));
187        handle_sse_question_replied(&mut app, "sess", "q1", vec![]);
188        assert_eq!(app.state, RecordingState::Idle);
189        assert!(!app.approval_queue.has_pending());
190    }
191
192    #[test]
193    fn test_question_rejected_removes_from_queue() {
194        let mut app = VoiceApp::new(test_config()).unwrap();
195        handle_sse_question_asked(&mut app, make_question("q1"));
196        handle_sse_question_rejected(&mut app, "sess", "q1");
197        assert_eq!(app.state, RecordingState::Idle);
198        assert!(!app.approval_queue.has_pending());
199    }
200
201    #[test]
202    fn test_multiple_approvals_stay_pending_until_all_cleared() {
203        let mut app = VoiceApp::new(test_config()).unwrap();
204        handle_sse_permission_asked(&mut app, make_permission("p1"));
205        handle_sse_question_asked(&mut app, make_question("q1"));
206        assert_eq!(app.approval_queue.len(), 2);
207
208        handle_sse_permission_replied(&mut app, "sess", "p1", "once");
209        // Still one item left — should remain ApprovalPending.
210        assert_eq!(app.state, RecordingState::ApprovalPending);
211
212        handle_sse_question_rejected(&mut app, "sess", "q1");
213        // Queue now empty — should return to Idle.
214        assert_eq!(app.state, RecordingState::Idle);
215    }
216}