Skip to main content

opencode_voice/app/
recording.rs

1//! Recording session management — async input event handlers for the recording state machine.
2//!
3//! These functions are called from the main event loop in [`super`] when
4//! keyboard or hotkey input events arrive.  They implement the full recording
5//! pipeline: cpal capture → Whisper transcription → OpenCode injection.
6//!
7//! # Push-to-talk flow
8//!
9//! 1. [`handle_push_to_talk_start`] — opens a [`CpalRecorder`], starts the
10//!    audio stream, spawns an energy-forwarding task, transitions to
11//!    [`RecordingState::Recording`].
12//! 2. [`handle_push_to_talk_stop`] — stops the recorder, checks minimum
13//!    duration, writes a [`TempWav`], transcribes via Whisper, injects into
14//!    OpenCode, transitions back to Idle (or ApprovalPending).
15//!
16use crate::audio::capture::CpalRecorder;
17use crate::audio::wav::TempWav;
18use crate::state::{AppEvent, RecordingState};
19
20use super::VoiceApp;
21
22/// A `Send`-able wrapper around a raw pointer to [`WhisperEngine`].
23///
24/// # Safety
25///
26/// The caller must guarantee that:
27/// 1. The pointed-to `WhisperEngine` outlives all tasks that hold this wrapper.
28/// 2. The engine is never mutated while tasks are running.
29/// 3. No two tasks call `transcribe` concurrently on the same engine
30///    (whisper-rs is not thread-safe for concurrent inference).
31///
32/// In practice the engine is owned by `VoiceApp` which lives for the entire
33/// duration of the program, and we only ever run one transcription at a time.
34struct SendWhisperPtr(*const crate::transcribe::engine::WhisperEngine);
35
36// SAFETY: see doc comment above.
37unsafe impl Send for SendWhisperPtr {}
38
39impl SendWhisperPtr {
40    /// Returns a shared reference to the pointed-to engine.
41    ///
42    /// # Safety
43    ///
44    /// The caller must ensure the pointer is valid and the engine is not
45    /// concurrently mutated.
46    unsafe fn as_ref(&self) -> &crate::transcribe::engine::WhisperEngine {
47        &*self.0
48    }
49}
50
51// Minimum recording duration in seconds.  Recordings shorter than this are
52// silently discarded (e.g. accidental key taps).
53const MIN_RECORDING_SECS: f64 = 0.5;
54
55// Minimum number of i16 samples required to attempt transcription.
56// At 16 kHz mono: 0.5 s × 16 000 = 8 000 samples.
57const MIN_SAMPLES: usize = 8_000;
58
59// ─── Public entry points ────────────────────────────────────────────────────
60
61/// Handles a toggle event in standard (non-push-to-talk) mode.
62///
63/// Starts recording from [`RecordingState::Idle`] or
64/// [`RecordingState::ApprovalPending`].  Stops recording from
65/// [`RecordingState::Recording`].  Other states are ignored.
66pub(crate) async fn handle_toggle(app: &mut VoiceApp) {
67    match app.state {
68        RecordingState::Idle | RecordingState::ApprovalPending => {
69            app.state = RecordingState::Recording;
70            app.current_level = None;
71            app.render_display();
72        }
73        RecordingState::Recording => {
74            app.state = RecordingState::Transcribing;
75            app.current_level = None;
76            app.render_display();
77        }
78        _ => {
79            // Ignore toggle in other states.
80        }
81    }
82}
83
84
85/// Starts push-to-talk recording (key pressed down).
86///
87/// Opens a [`CpalRecorder`] for the configured audio device, starts the
88/// stream, spawns a task that forwards RMS energy values as
89/// [`AppEvent::AudioChunk`] events, and transitions to
90/// [`RecordingState::Recording`].
91///
92/// Recording is allowed from both [`RecordingState::Idle`] and
93/// [`RecordingState::ApprovalPending`].  In the latter case the user may
94/// be speaking to answer a pending approval, or to inject a new prompt —
95/// the transcription pipeline handles both.
96///
97/// If the recorder cannot be opened (e.g. no microphone), the error is
98/// reported via [`VoiceApp::handle_error`] and the state remains unchanged.
99pub(crate) async fn handle_push_to_talk_start(app: &mut VoiceApp) {
100    if app.state != RecordingState::Idle && app.state != RecordingState::ApprovalPending {
101        return;
102    }
103
104    let device = app.audio_config.device.as_deref();
105
106    // Create and start the recorder.
107    let mut recorder = match CpalRecorder::new(device) {
108        Ok(r) => r,
109        Err(e) => {
110            app.handle_error(&format!("Failed to open audio device: {}", e));
111            return;
112        }
113    };
114
115    let energy_rx = match recorder.start() {
116        Ok(rx) => rx,
117        Err(e) => {
118            app.handle_error(&format!("Failed to start recording: {}", e));
119            return;
120        }
121    };
122
123    // Spawn a task that reads RMS energy from the recorder and forwards it to
124    // the event loop as AudioChunk events so the level meter stays live.
125    let event_tx = app.event_tx.clone();
126    let mut energy_rx = energy_rx;
127    tokio::spawn(async move {
128        while let Some(rms_energy) = energy_rx.recv().await {
129            if event_tx
130                .send(AppEvent::AudioChunk { rms_energy })
131                .is_err()
132            {
133                break; // Event loop has shut down.
134            }
135        }
136    });
137
138    // Store the recorder so handle_push_to_talk_stop can retrieve it.
139    app.recorder = Some(recorder);
140
141    {
142        let name = app.recorder.as_ref()
143            .and_then(|r| r.device_name())
144            .unwrap_or("unknown");
145        app.debug_log(format_args!("recording started  device: {}", name));
146    }
147
148    app.state = RecordingState::Recording;
149    app.current_level = None;
150    app.render_display();
151}
152
153/// Stops push-to-talk recording (key released).
154///
155/// Retrieves the active [`CpalRecorder`], stops it, checks the minimum
156/// recording duration, writes a [`TempWav`], transcribes via Whisper, and
157/// injects the result into OpenCode.  Transitions back to Idle (or
158/// [`RecordingState::ApprovalPending`] if there are pending approvals).
159///
160/// Short recordings (< 0.5 s) are silently discarded.
161pub(crate) async fn handle_push_to_talk_stop(app: &mut VoiceApp) {
162    if app.state != RecordingState::Recording {
163        return;
164    }
165
166    // Take the recorder out of the app struct.
167    let mut recorder = match app.recorder.take() {
168        Some(r) => r,
169        None => {
170            // No recorder — just return to idle.
171            return_to_idle_or_approval(app);
172            return;
173        }
174    };
175
176    // Check duration before stopping (stop() clears the start_time).
177    let duration = recorder.duration();
178
179    // Stop the stream and collect samples.
180    let samples = match recorder.stop() {
181        Ok(s) => s,
182        Err(e) => {
183            app.handle_error(&format!("Failed to stop recording: {}", e));
184            return;
185        }
186    };
187
188    app.debug_log(format_args!("recording stopped  duration: {:.2}s  samples: {}", duration, samples.len()));
189
190    // Discard very short recordings.
191    if duration < MIN_RECORDING_SECS || samples.len() < MIN_SAMPLES {
192        app.display.log(&format!(
193            "[voice] Recording too short ({:.2}s, {} samples) — discarded.",
194            duration,
195            samples.len()
196        ));
197        return_to_idle_or_approval(app);
198        return;
199    }
200
201    // Transition to Transcribing while we process.
202    app.state = RecordingState::Transcribing;
203    app.current_level = None;
204    app.render_display();
205
206    // Write samples to a temporary WAV file.
207    // TempWav is RAII: if we return early (error path) before calling
208    // into_path(), the file is automatically deleted on drop.
209    let wav = TempWav::new();
210    if let Err(e) = wav.write(&samples, &app.audio_config) {
211        app.handle_error(&format!("Failed to write WAV file: {}", e));
212        return;
213    }
214
215    // Consume TempWav without deleting the file; we own cleanup from here.
216    let wav_path = wav.into_path();
217
218    // Run Whisper transcription on a blocking thread (CPU-bound).
219    let transcript = match &app.whisper {
220        None => {
221            // No model loaded — clean up and return.
222            let _ = std::fs::remove_file(&wav_path);
223            app.handle_error("Whisper model not loaded. Run 'opencode-voice setup'.");
224            return;
225        }
226        Some(_) => {
227            // Clone the path for the blocking closure.
228            let path_for_task = wav_path.clone();
229
230            // SAFETY: We need to move the WhisperEngine reference into the
231            // blocking task.  We use a raw pointer wrapped in SendWhisperPtr
232            // to work around the borrow checker — this is safe because:
233            //   1. We await the task before returning, so the engine outlives it.
234            //   2. The task does not outlive this function frame.
235            //   3. WhisperEngine is not mutated.
236            let engine_ptr = SendWhisperPtr(
237                app.whisper.as_ref().unwrap() as *const crate::transcribe::engine::WhisperEngine,
238            );
239
240            let result = tokio::task::spawn_blocking(move || {
241                // SAFETY: see SendWhisperPtr safety doc.
242                let engine = unsafe { engine_ptr.as_ref() };
243                engine.transcribe(&path_for_task)
244            })
245            .await;
246
247            // Clean up the WAV file regardless of transcription outcome.
248            let _ = std::fs::remove_file(&wav_path);
249
250            match result {
251                Ok(Ok(r)) => r,
252                Ok(Err(e)) => {
253                    app.handle_error(&format!("Transcription failed: {}", e));
254                    return;
255                }
256                Err(e) => {
257                    app.handle_error(&format!("Transcription task panicked: {}", e));
258                    return;
259                }
260            }
261        }
262    };
263
264    let text = transcript.text.trim().to_string();
265
266    if app.config.debug {
267        if text.is_empty() {
268            app.debug_log(format_args!("transcript: (empty)"));
269        } else {
270            app.debug_log(format_args!("transcript: {}", text));
271        }
272        // In debug mode, skip OpenCode injection entirely.
273        app.last_transcript = if text.is_empty() { None } else { Some(text) };
274        return_to_idle_or_approval(app);
275        return;
276    }
277
278    if text.is_empty() {
279        // Nothing transcribed — return to idle without injecting.
280        return_to_idle_or_approval(app);
281        return;
282    }
283
284    // Store the transcript for the idle display.
285    app.last_transcript = Some(text.clone());
286
287    // Check if there is a pending approval that this text might answer.
288    if app.approval_queue.has_pending() {
289        let handled = try_handle_approval(app, &text).await;
290        if handled {
291            return_to_idle_or_approval(app);
292            return;
293        }
294    }
295
296    // Inject the transcribed text into OpenCode.
297    inject_text(app, &text).await;
298}
299
300// ─── Helpers ────────────────────────────────────────────────────────────────
301
302/// Injects `text` into OpenCode and transitions to Injecting → Idle.
303///
304/// Calls `bridge.append_prompt` and, if `auto_submit` is enabled,
305/// `bridge.submit_prompt`.  On error, calls `handle_error`.
306async fn inject_text(app: &mut VoiceApp, text: &str) {
307    app.state = RecordingState::Injecting;
308    app.render_display();
309
310    if let Err(e) = app.bridge.append_prompt(text, None, None).await {
311        app.handle_error(&format!("Failed to inject text: {}", e));
312        return;
313    }
314
315    if app.config.auto_submit {
316        if let Err(e) = app.bridge.submit_prompt().await {
317            app.handle_error(&format!("Failed to submit prompt: {}", e));
318            return;
319        }
320    }
321
322    return_to_idle_or_approval(app);
323}
324
325/// Transitions to [`RecordingState::ApprovalPending`] if there are pending
326/// approvals, otherwise to [`RecordingState::Idle`].  Updates the display.
327pub(crate) fn return_to_idle_or_approval(app: &mut VoiceApp) {
328    if app.approval_queue.has_pending() {
329        app.state = RecordingState::ApprovalPending;
330    } else {
331        app.state = RecordingState::Idle;
332    }
333    app.current_level = None;
334    app.render_display();
335}
336
337/// Attempts to handle `text` as a voice reply to the front-most pending approval.
338///
339/// # Behaviour
340///
341/// 1. Peeks the approval queue.  If empty, returns `false` immediately so the
342///    caller can fall through to normal prompt injection.
343/// 2. **Permission** — calls [`match_permission_command`].  On a match, sends
344///    the reply via [`OpenCodeBridge::reply_permission`], removes the item from
345///    the queue, calls [`refresh_approval_display`], and returns `true`.
346///    On [`MatchResult::NoMatch`] returns `false`.
347/// 3. **Question** — calls [`match_question_answer`].
348///    * [`MatchResult::QuestionAnswer`] → [`OpenCodeBridge::reply_question`] →
349///      remove → refresh → `true`.
350///    * [`MatchResult::QuestionReject`] → [`OpenCodeBridge::reject_question`] →
351///      remove → refresh → `true`.
352///    * [`MatchResult::NoMatch`] → `false`.
353///
354/// Bridge call failures are reported via [`VoiceApp::handle_error`] (non-fatal)
355/// and the function still returns `true` so the text is not re-injected as a
356/// normal prompt.
357pub(crate) async fn try_handle_approval(app: &mut VoiceApp, text: &str) -> bool {
358    use crate::approval::matcher::{match_permission_command, match_question_answer, MatchResult};
359    use crate::approval::types::PendingApproval;
360
361    // Peek at the front of the queue.  Clone what we need so we can release
362    // the borrow on `app` before making async bridge calls.
363    let pending = match app.approval_queue.peek() {
364        Some(p) => p.clone(),
365        None => return false,
366    };
367
368    match &pending {
369        PendingApproval::Permission(_req) => {
370            let result = match_permission_command(text);
371            match result {
372                MatchResult::PermissionReply { reply, message } => {
373                    let id = pending.id().to_string();
374                    let msg_ref = message.as_deref();
375                    if let Err(e) = app.bridge.reply_permission(&id, reply, msg_ref).await {
376                        app.handle_error(&format!("Failed to reply to permission: {}", e));
377                    }
378                    app.approval_queue.remove(&id);
379                    super::approval::refresh_approval_display(app);
380                    true
381                }
382                MatchResult::NoMatch => false,
383                // match_permission_command never returns QuestionAnswer / QuestionReject,
384                // but the compiler requires exhaustive matching.
385                _ => false,
386            }
387        }
388
389        PendingApproval::Question(req) => {
390            // Clone the request so we can pass it to the matcher without
391            // holding a borrow on `app`.
392            let req_clone = req.clone();
393            let result = match_question_answer(text, &req_clone);
394            match result {
395                MatchResult::QuestionAnswer { answers } => {
396                    let id = pending.id().to_string();
397                    if let Err(e) = app.bridge.reply_question(&id, answers).await {
398                        app.handle_error(&format!("Failed to reply to question: {}", e));
399                    }
400                    app.approval_queue.remove(&id);
401                    super::approval::refresh_approval_display(app);
402                    true
403                }
404                MatchResult::QuestionReject => {
405                    let id = pending.id().to_string();
406                    if let Err(e) = app.bridge.reject_question(&id).await {
407                        app.handle_error(&format!("Failed to reject question: {}", e));
408                    }
409                    app.approval_queue.remove(&id);
410                    super::approval::refresh_approval_display(app);
411                    true
412                }
413                MatchResult::NoMatch => false,
414                // match_question_answer never returns PermissionReply.
415                _ => false,
416            }
417        }
418    }
419}
420
421// ─── Tests ───────────────────────────────────────────────────────────────────
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::app::VoiceApp;
427    use crate::config::{AppConfig, ModelSize};
428    use std::path::PathBuf;
429
430    fn test_config() -> AppConfig {
431        AppConfig {
432            whisper_model_path: PathBuf::from("/nonexistent/model.bin"),
433            opencode_port: 4096,
434            toggle_key: ' ',
435            model_size: ModelSize::TinyEn,
436            auto_submit: true,
437            server_password: None,
438            data_dir: PathBuf::from("/nonexistent/data"),
439            audio_device: None,
440            use_global_hotkey: false,
441            global_hotkey: "right_option".to_string(),
442            push_to_talk: false,
443            handle_prompts: false,
444            debug: false,
445        }
446    }
447
448    // ── handle_toggle ────────────────────────────────────────────────────────
449
450    #[tokio::test]
451    async fn test_handle_toggle_idle_to_recording() {
452        let mut app = VoiceApp::new(test_config()).unwrap();
453        assert_eq!(app.state, RecordingState::Idle);
454        handle_toggle(&mut app).await;
455        assert_eq!(app.state, RecordingState::Recording);
456    }
457
458    #[tokio::test]
459    async fn test_handle_toggle_recording_to_transcribing() {
460        let mut app = VoiceApp::new(test_config()).unwrap();
461        app.state = RecordingState::Recording;
462        handle_toggle(&mut app).await;
463        assert_eq!(app.state, RecordingState::Transcribing);
464    }
465
466    #[tokio::test]
467    async fn test_handle_toggle_approval_pending_to_recording() {
468        let mut app = VoiceApp::new(test_config()).unwrap();
469        app.state = RecordingState::ApprovalPending;
470        handle_toggle(&mut app).await;
471        assert_eq!(app.state, RecordingState::Recording);
472    }
473
474    #[tokio::test]
475    async fn test_handle_toggle_ignores_transcribing_state() {
476        let mut app = VoiceApp::new(test_config()).unwrap();
477        app.state = RecordingState::Transcribing;
478        handle_toggle(&mut app).await;
479        assert_eq!(app.state, RecordingState::Transcribing);
480    }
481
482    // ── handle_push_to_talk_start / stop ─────────────────────────────────────
483
484    #[tokio::test]
485    async fn test_handle_push_to_talk_start_ignores_transcribing() {
486        let mut app = VoiceApp::new(test_config()).unwrap();
487        app.state = RecordingState::Transcribing;
488        handle_push_to_talk_start(&mut app).await;
489        // Should remain Transcribing — PTT start is only allowed from Idle or ApprovalPending.
490        assert_eq!(app.state, RecordingState::Transcribing);
491    }
492
493    #[tokio::test]
494    async fn test_handle_push_to_talk_start_ignores_recording() {
495        let mut app = VoiceApp::new(test_config()).unwrap();
496        app.state = RecordingState::Recording;
497        handle_push_to_talk_start(&mut app).await;
498        // Should remain Recording — PTT start is only allowed from Idle or ApprovalPending.
499        assert_eq!(app.state, RecordingState::Recording);
500    }
501
502    #[tokio::test]
503    async fn test_handle_push_to_talk_stop_ignores_idle() {
504        let mut app = VoiceApp::new(test_config()).unwrap();
505        // Calling stop when not recording should be a no-op.
506        handle_push_to_talk_stop(&mut app).await;
507        assert_eq!(app.state, RecordingState::Idle);
508    }
509
510    #[tokio::test]
511    async fn test_handle_push_to_talk_stop_no_recorder_returns_to_idle() {
512        let mut app = VoiceApp::new(test_config()).unwrap();
513        // Manually set Recording state without a recorder.
514        app.state = RecordingState::Recording;
515        handle_push_to_talk_stop(&mut app).await;
516        // Should return to Idle (no recorder → return_to_idle_or_approval).
517        assert_eq!(app.state, RecordingState::Idle);
518    }
519
520    // ── return_to_idle_or_approval ───────────────────────────────────────────
521
522    #[test]
523    fn test_return_to_idle_when_no_pending() {
524        let mut app = VoiceApp::new(test_config()).unwrap();
525        app.state = RecordingState::Injecting;
526        return_to_idle_or_approval(&mut app);
527        assert_eq!(app.state, RecordingState::Idle);
528    }
529
530    #[test]
531    fn test_return_to_approval_pending_when_queue_has_items() {
532        use crate::approval::types::PermissionRequest;
533
534        let mut app = VoiceApp::new(test_config()).unwrap();
535        app.state = RecordingState::Injecting;
536
537        // Add a pending approval.
538        app.approval_queue.add_permission(PermissionRequest {
539            id: "p1".to_string(),
540            permission: "bash".to_string(),
541            metadata: serde_json::Value::Null,
542        });
543
544        return_to_idle_or_approval(&mut app);
545        assert_eq!(app.state, RecordingState::ApprovalPending);
546    }
547
548    // ── try_handle_approval ──────────────────────────────────────────────────
549
550    /// Returns false when the approval queue is empty (nothing to handle).
551    #[tokio::test]
552    async fn test_try_handle_approval_empty_queue_returns_false() {
553        let mut app = VoiceApp::new(test_config()).unwrap();
554        // Queue is empty — any text should return false.
555        let result = try_handle_approval(&mut app, "yes").await;
556        assert!(!result, "empty queue should return false");
557    }
558
559    /// Returns false when the text does not match any permission pattern.
560    #[tokio::test]
561    async fn test_try_handle_approval_permission_no_match_returns_false() {
562        use crate::approval::types::PermissionRequest;
563
564        let mut app = VoiceApp::new(test_config()).unwrap();
565        app.approval_queue.add_permission(PermissionRequest {
566            id: "p1".to_string(),
567            permission: "bash".to_string(),
568            metadata: serde_json::Value::Null,
569        });
570
571        // "hello world" does not match any permission command.
572        let result = try_handle_approval(&mut app, "hello world").await;
573        assert!(!result, "unrecognised text should return false");
574        // Item must still be in the queue.
575        assert!(app.approval_queue.has_pending());
576    }
577
578    /// Returns false when the text does not match any question option.
579    #[tokio::test]
580    async fn test_try_handle_approval_question_no_match_returns_false() {
581        use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
582
583        let mut app = VoiceApp::new(test_config()).unwrap();
584        app.approval_queue.add_question(QuestionRequest {
585            id: "q1".to_string(),
586            questions: vec![QuestionInfo {
587                question: "Pick one".to_string(),
588                options: vec![
589                    QuestionOption {
590                        label: "Alpha".to_string(),
591                    },
592                    QuestionOption {
593                        label: "Beta".to_string(),
594                    },
595                ],
596                custom: false, // no custom answers allowed
597            }],
598        });
599
600        // "gamma" is not an option and custom is disabled.
601        let result = try_handle_approval(&mut app, "gamma").await;
602        assert!(!result, "unrecognised question answer should return false");
603        assert!(app.approval_queue.has_pending());
604    }
605
606    /// A matching permission command removes the item from the queue and
607    /// returns true.  The bridge call will fail (no server running) but the
608    /// function should still return true and remove the item.
609    #[tokio::test]
610    async fn test_try_handle_approval_permission_match_removes_item_and_returns_true() {
611        use crate::approval::types::PermissionRequest;
612
613        let mut app = VoiceApp::new(test_config()).unwrap();
614        app.approval_queue.add_permission(PermissionRequest {
615            id: "p1".to_string(),
616            permission: "bash".to_string(),
617            metadata: serde_json::Value::Null,
618        });
619        app.state = RecordingState::ApprovalPending;
620
621        // "yes" matches the Once permission pattern.
622        // The bridge call will fail (no server), but the item is still removed.
623        let result = try_handle_approval(&mut app, "yes").await;
624        assert!(result, "matched permission should return true");
625        assert!(
626            !app.approval_queue.has_pending(),
627            "item should be removed from queue after match"
628        );
629    }
630
631    /// A matching question answer removes the item from the queue and returns
632    /// true.
633    #[tokio::test]
634    async fn test_try_handle_approval_question_match_removes_item_and_returns_true() {
635        use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
636
637        let mut app = VoiceApp::new(test_config()).unwrap();
638        app.approval_queue.add_question(QuestionRequest {
639            id: "q1".to_string(),
640            questions: vec![QuestionInfo {
641                question: "Pick one".to_string(),
642                options: vec![
643                    QuestionOption {
644                        label: "Alpha".to_string(),
645                    },
646                    QuestionOption {
647                        label: "Beta".to_string(),
648                    },
649                ],
650                custom: false,
651            }],
652        });
653        app.state = RecordingState::ApprovalPending;
654
655        // "alpha" matches the first option exactly.
656        let result = try_handle_approval(&mut app, "alpha").await;
657        assert!(result, "matched question answer should return true");
658        assert!(
659            !app.approval_queue.has_pending(),
660            "item should be removed from queue after match"
661        );
662    }
663
664    /// A question rejection phrase removes the item from the queue and returns
665    /// true.
666    #[tokio::test]
667    async fn test_try_handle_approval_question_reject_removes_item_and_returns_true() {
668        use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
669
670        let mut app = VoiceApp::new(test_config()).unwrap();
671        app.approval_queue.add_question(QuestionRequest {
672            id: "q2".to_string(),
673            questions: vec![QuestionInfo {
674                question: "Pick one".to_string(),
675                options: vec![QuestionOption {
676                    label: "Yes".to_string(),
677                }],
678                custom: false,
679            }],
680        });
681        app.state = RecordingState::ApprovalPending;
682
683        // "skip" is a question rejection phrase.
684        let result = try_handle_approval(&mut app, "skip").await;
685        assert!(result, "question rejection should return true");
686        assert!(
687            !app.approval_queue.has_pending(),
688            "item should be removed from queue after rejection"
689        );
690    }
691
692}