Skip to main content

car_engine/
voice_turn.rs

1//! Two-track voice turn orchestration.
2//!
3//! Quip's `SidecarReasoningService` + `MediaPlatformService.HandleUtteranceAsync`
4//! ported to CAR. See `docs/proposals/voice-sidecar-orchestration.md` and
5//! `docs/proposals/voice-sidecar-orchestration-plan.md` for the design
6//! rationale and implementation plan.
7//!
8//! The two-track shape:
9//!
10//! ```text
11//!                      user utterance
12//!                            │
13//!                            ▼
14//!           ┌────────────────────────────────────┐
15//!           │                                    │
16//!        FAST_TURN_1 (<500ms first audio)    SIDECAR
17//!        streaming LLM, voice-context        full LLM with tools (2-8s)
18//!           │                                    │
19//!           ▼                                    │
20//!        TTS streaming → play              oneshot::Sender<SidecarResult>
21//! ```
22//!
23//! The caller obtains a [`VoiceTurnHandle`], drains the fast stream into
24//! TTS immediately, awaits the sidecar with a timeout, and plays the
25//! result if it arrives in time. [`VoiceTurnControl`] is a cheap clonable
26//! handle for cross-task cancellation (see plan §6.8).
27
28use std::collections::HashMap;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::{Arc, Mutex};
31
32use car_eventlog::{EventKind, EventLog};
33use car_inference::{GenerateRequest, InferenceEngine, StreamEvent};
34use serde_json::Value;
35use tokio::sync::{mpsc, oneshot};
36use tokio_util::sync::CancellationToken;
37
38/// Mint a new turn id. Process-wide, monotonic, never reused.
39fn next_turn_id() -> u64 {
40    static COUNTER: AtomicU64 = AtomicU64::new(1);
41    COUNTER.fetch_add(1, Ordering::Relaxed)
42}
43
44/// Result delivered by the sidecar track once the substantive answer
45/// has been produced (or a fast-data path has hit).
46#[derive(Debug, Clone)]
47pub struct SidecarResult {
48    /// The turn this result belongs to. Callers MUST gate playback
49    /// on `result.turn_id == current_turn_id` so stale results from
50    /// superseded turns are dropped after a barge-in.
51    pub turn_id: u64,
52    /// Substantive answer to play through TTS.
53    pub text: String,
54    /// Optional structured data (tool results, etc.) for callers that
55    /// want to render something richer than text-to-speech.
56    pub data: Option<serde_json::Value>,
57}
58
59/// Failure modes distinguishable for telemetry / fallback policy.
60#[derive(Debug, thiserror::Error)]
61pub enum VoiceTurnError {
62    /// The underlying inference call failed. The string carries the
63    /// upstream error's display form (we don't bubble the concrete
64    /// `InferenceError` to keep this enum FFI-friendly).
65    #[error("inference failed: {0}")]
66    Inference(String),
67    /// The turn was cancelled — typically a barge-in or supersession
68    /// by a newer utterance.
69    #[error("turn cancelled (barge-in or supersession)")]
70    Cancelled,
71}
72
73/// Cheap, clonable control surface for an in-flight voice turn.
74///
75/// Stored separately from the receivers so an orchestrator can keep a
76/// copy in `Mutex<Option<VoiceTurnControl>>` and cancel from a different
77/// task than the one driving the streams. See plan §6.8.
78#[derive(Clone)]
79pub struct VoiceTurnControl {
80    /// Unique id for this turn; threaded through [`SidecarResult`] and
81    /// telemetry. Use it to drop stale results after barge-in.
82    pub turn_id: u64,
83    cancel: CancellationToken,
84}
85
86impl VoiceTurnControl {
87    /// Cancel the in-flight turn. Idempotent.
88    pub fn cancel(&self) {
89        self.cancel.cancel();
90    }
91
92    /// Has the turn been cancelled?
93    pub fn is_cancelled(&self) -> bool {
94        self.cancel.is_cancelled()
95    }
96}
97
98/// Two-track handle returned by [`dispatch_voice_turn`].
99///
100/// `fast` streams [`StreamEvent`]s for the fast track (text deltas,
101/// tool-call markers, usage, done). `sidecar` resolves once with the
102/// substantive answer or an error. `control` is a cheap clonable
103/// surface that can be cached by the orchestrator for cancellation
104/// from another task.
105///
106/// Caller MUST drive both receivers. Dropping `fast` cancels the fast
107/// task; dropping `sidecar` cancels the sidecar task. To cancel both
108/// without driving them, call [`Self::cancel`] (or
109/// [`VoiceTurnControl::cancel`] on a stored clone).
110pub struct VoiceTurnHandle {
111    pub control: VoiceTurnControl,
112    pub fast: mpsc::Receiver<StreamEvent>,
113    pub sidecar: oneshot::Receiver<Result<SidecarResult, VoiceTurnError>>,
114}
115
116impl VoiceTurnHandle {
117    /// Convenience accessor for `self.control.turn_id`.
118    pub fn turn_id(&self) -> u64 {
119        self.control.turn_id
120    }
121
122    /// Cancel both tracks. Idempotent.
123    pub fn cancel(&self) {
124        self.control.cancel();
125    }
126}
127
128/// Foreign-implemented data fetcher. When attached, the sidecar task
129/// gives this fetcher the utterance first; on `Some(Ok(text))` the
130/// LLM is skipped entirely and the text becomes the sidecar's answer.
131///
132/// On `None` (no match) or `Some(Err(e))` (match but fetch failed),
133/// the sidecar falls through to the LLM path. Use this for the
134/// "fast data path" optimization in quip's playbook: email/calendar
135/// queries that are pure data lookups don't need an LLM round trip.
136///
137/// Implementations are typically host-side (calendar API, mail API)
138/// and should already format their output for voice narration —
139/// short, no markdown. See `car_voice::format_for_voice` for a
140/// minimal helper.
141#[async_trait::async_trait]
142pub trait DirectDataFetcher: Send + Sync {
143    /// Try to satisfy the utterance directly without invoking the LLM.
144    ///
145    /// - `Some(Ok(text))` → use this as the sidecar answer.
146    /// - `Some(Err(msg))` → match recognised but fetch failed; fall
147    ///   through to LLM (msg is logged, not surfaced).
148    /// - `None` → not a fast-data candidate; fall through to LLM.
149    async fn try_fetch(&self, utterance: &str) -> Option<Result<String, String>>;
150}
151
152/// Optional telemetry sink for voice turn events. Wraps an
153/// `Arc<Mutex<EventLog>>` so emissions are thread-safe across the
154/// fast and sidecar tasks. Pass `None` to disable.
155#[derive(Clone)]
156pub struct VoiceTelemetry {
157    log: Arc<Mutex<EventLog>>,
158}
159
160impl VoiceTelemetry {
161    /// Wrap an existing event log for voice telemetry.
162    pub fn new(log: Arc<Mutex<EventLog>>) -> Self {
163        Self { log }
164    }
165
166    /// Append a voice eventlog entry. Callers in `car-voice`'s
167    /// orchestrator emit `VoiceBridgePlayed` and `VoiceSidecarTimedOut`
168    /// through this; the engine-side spawn helpers emit the rest.
169    pub fn emit(&self, kind: EventKind, turn_id: u64, extra: Vec<(&str, Value)>) {
170        let mut data: HashMap<String, Value> = HashMap::new();
171        data.insert("turn_id".to_string(), Value::from(turn_id));
172        for (k, v) in extra {
173            data.insert(k.to_string(), v);
174        }
175        if let Ok(mut guard) = self.log.lock() {
176            guard.append(kind, None, None, data);
177        }
178    }
179}
180
181/// Dispatch a voice-turn utterance with a fast-and-sidecar split.
182///
183/// `engine` runs both inference calls. `fast_request` is streamed
184/// through the returned `VoiceTurnHandle::fast` channel; `sidecar_request`
185/// runs to completion and resolves the `sidecar` oneshot once.
186///
187/// Both inference requests must be pre-composed by the caller — including
188/// any voice-context overlay (use `car_voice::compose_voice_context`).
189/// This crate stays neutral on prompt construction.
190///
191/// The `voice.turn.fast_done` frame that closes a turn's fast track, carrying
192/// `local_last_resort` / `substituted_from` / `substitution_reason` when the
193/// on-device last resort answered it (the track's
194/// [`StreamEvent::Substituted`], car#1439). One builder for the daemon's and
195/// the UniFFI host's voice paths, so the two frames cannot drift.
196pub fn fast_done_frame(
197    turn_id: u64,
198    substitution: Option<&car_inference::StreamSubstitution>,
199) -> String {
200    let mut frame = serde_json::json!({ "type": "voice.turn.fast_done", "turn_id": turn_id });
201    if let Some(served) = substitution {
202        served.annotate(&mut frame);
203    }
204    frame.to_string()
205}
206
207/// Wraps [`dispatch_voice_turn_with_telemetry`] with no telemetry — use
208/// the `_with_telemetry` variant when you want eventlog emissions.
209pub fn dispatch_voice_turn(
210    engine: Arc<InferenceEngine>,
211    utterance: String,
212    fast_request: GenerateRequest,
213    sidecar_request: GenerateRequest,
214) -> VoiceTurnHandle {
215    dispatch_voice_turn_with_telemetry(engine, utterance, fast_request, sidecar_request, None)
216}
217
218/// Same as [`dispatch_voice_turn`] but emits voice eventlog kinds
219/// (`VoiceFastTurnStarted`, `VoiceFastTurnEnded`, `VoiceSidecarResolved`,
220/// `VoiceSidecarFailed`, `VoiceSidecarTimedOut`, `VoiceTurnCancelled`)
221/// to the supplied telemetry sink as the turn progresses.
222pub fn dispatch_voice_turn_with_telemetry(
223    engine: Arc<InferenceEngine>,
224    _utterance: String,
225    fast_request: GenerateRequest,
226    sidecar_request: GenerateRequest,
227    telemetry: Option<VoiceTelemetry>,
228) -> VoiceTurnHandle {
229    let turn_id = next_turn_id();
230    let cancel = CancellationToken::new();
231    let (fast_tx, fast_rx) = mpsc::channel::<StreamEvent>(64);
232    let (sidecar_tx, sidecar_rx) = oneshot::channel();
233
234    if let Some(t) = telemetry.as_ref() {
235        t.emit(EventKind::VoiceFastTurnStarted, turn_id, vec![]);
236    }
237
238    spawn_fast_task(
239        engine.clone(),
240        fast_request,
241        fast_tx,
242        cancel.clone(),
243        turn_id,
244        telemetry.clone(),
245    );
246    spawn_sidecar_task(
247        engine,
248        sidecar_request,
249        sidecar_tx,
250        cancel.clone(),
251        turn_id,
252        telemetry,
253    );
254
255    VoiceTurnHandle {
256        control: VoiceTurnControl { turn_id, cancel },
257        fast: fast_rx,
258        sidecar: sidecar_rx,
259    }
260}
261
262/// Variant of [`dispatch_voice_turn`] that skips the fast inference call.
263///
264/// Use when the caller has already played a hardcoded bridge phrase
265/// (the "STRUCTURAL HALLUCINATION FIX" in quip's terms) and only needs
266/// the sidecar's substantive answer. The returned handle's `fast`
267/// channel is pre-closed so callers don't have to special-case the
268/// shape.
269pub fn dispatch_voice_turn_sidecar_only(
270    engine: Arc<InferenceEngine>,
271    utterance: String,
272    sidecar_request: GenerateRequest,
273) -> VoiceTurnHandle {
274    dispatch_voice_turn_sidecar_only_with_telemetry(engine, utterance, sidecar_request, None)
275}
276
277/// Same as [`dispatch_voice_turn_sidecar_only`] but emits voice
278/// eventlog kinds for the sidecar half of the turn.
279pub fn dispatch_voice_turn_sidecar_only_with_telemetry(
280    engine: Arc<InferenceEngine>,
281    utterance: String,
282    sidecar_request: GenerateRequest,
283    telemetry: Option<VoiceTelemetry>,
284) -> VoiceTurnHandle {
285    dispatch_voice_turn_sidecar_only_with_classifier(
286        engine,
287        utterance,
288        sidecar_request,
289        None,
290        telemetry,
291    )
292}
293
294/// Variant of [`dispatch_voice_turn_sidecar_only`] that consults a
295/// [`DirectDataFetcher`] before invoking the LLM. On a fetcher hit
296/// the LLM call is skipped entirely — the fetcher's text becomes
297/// the sidecar's `SidecarResult`. On miss, falls through to the
298/// LLM-based sidecar exactly like the bare `_with_telemetry` variant.
299pub fn dispatch_voice_turn_sidecar_only_with_classifier(
300    engine: Arc<InferenceEngine>,
301    utterance: String,
302    sidecar_request: GenerateRequest,
303    fetcher: Option<Arc<dyn DirectDataFetcher>>,
304    telemetry: Option<VoiceTelemetry>,
305) -> VoiceTurnHandle {
306    let turn_id = next_turn_id();
307    let cancel = CancellationToken::new();
308    // Closed channel — recv() returns None immediately. Capacity 1 is
309    // arbitrary; we never send anything.
310    let (fast_tx, fast_rx) = mpsc::channel::<StreamEvent>(1);
311    drop(fast_tx);
312    let (sidecar_tx, sidecar_rx) = oneshot::channel();
313
314    spawn_sidecar_task_classified(
315        engine,
316        utterance,
317        sidecar_request,
318        sidecar_tx,
319        cancel.clone(),
320        turn_id,
321        fetcher,
322        telemetry,
323    );
324
325    VoiceTurnHandle {
326        control: VoiceTurnControl { turn_id, cancel },
327        fast: fast_rx,
328        sidecar: sidecar_rx,
329    }
330}
331
332fn spawn_fast_task(
333    engine: Arc<InferenceEngine>,
334    request: GenerateRequest,
335    out: mpsc::Sender<StreamEvent>,
336    cancel: CancellationToken,
337    turn_id: u64,
338    telemetry: Option<VoiceTelemetry>,
339) {
340    tokio::spawn(async move {
341        let cancelled_during = tokio::select! {
342            biased;
343            _ = cancel.cancelled() => {
344                tracing::debug!(turn_id, "fast task cancelled before inference start");
345                true
346            }
347            res = engine.generate_tracked_stream(request) => {
348                match res {
349                    Ok(mut handle) => {
350                        relay_fast_stream(&mut handle.events, &out, &cancel, turn_id).await;
351                        cancel.is_cancelled()
352                    }
353                    Err(e) => {
354                        tracing::error!(turn_id, error=%e, "fast turn inference failed");
355                        false
356                    }
357                }
358            }
359        };
360        if let Some(t) = telemetry {
361            if cancelled_during {
362                t.emit(
363                    EventKind::VoiceTurnCancelled,
364                    turn_id,
365                    vec![("track", "fast".into())],
366                );
367            } else {
368                t.emit(EventKind::VoiceFastTurnEnded, turn_id, vec![]);
369            }
370        }
371    });
372}
373
374async fn relay_fast_stream(
375    rx: &mut mpsc::Receiver<StreamEvent>,
376    out: &mpsc::Sender<StreamEvent>,
377    cancel: &CancellationToken,
378    turn_id: u64,
379) {
380    loop {
381        tokio::select! {
382            biased;
383            _ = cancel.cancelled() => {
384                tracing::debug!(turn_id, "fast stream cancelled mid-relay");
385                break;
386            }
387            evt = rx.recv() => match evt {
388                Some(e) => {
389                    if out.send(e).await.is_err() {
390                        // Receiver dropped — caller has stopped listening.
391                        break;
392                    }
393                }
394                None => break,
395            }
396        }
397    }
398}
399
400fn spawn_sidecar_task_classified(
401    engine: Arc<InferenceEngine>,
402    utterance: String,
403    request: GenerateRequest,
404    sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
405    cancel: CancellationToken,
406    turn_id: u64,
407    fetcher: Option<Arc<dyn DirectDataFetcher>>,
408    telemetry: Option<VoiceTelemetry>,
409) {
410    tokio::spawn(async move {
411        // Try the direct fetcher first. On hit, skip the LLM entirely.
412        if let Some(f) = fetcher.as_ref() {
413            let fetch_outcome = tokio::select! {
414                biased;
415                _ = cancel.cancelled() => None,
416                outcome = f.try_fetch(&utterance) => outcome,
417            };
418            match fetch_outcome {
419                Some(Ok(text)) => {
420                    let result = Ok(SidecarResult {
421                        turn_id,
422                        text: text.clone(),
423                        data: None,
424                    });
425                    if let Some(t) = telemetry {
426                        t.emit(
427                            EventKind::VoiceSidecarResolved,
428                            turn_id,
429                            vec![
430                                ("text_len", Value::from(text.len())),
431                                ("source", "direct_fetch".into()),
432                            ],
433                        );
434                    }
435                    let _ = sender.send(result);
436                    return;
437                }
438                Some(Err(e)) => {
439                    tracing::debug!(turn_id, error=%e, "DirectDataFetcher errored; falling through to LLM");
440                }
441                None => { /* no match — fall through */ }
442            }
443            // If the fetch attempt was cancelled (the cancel branch ran),
444            // honour that immediately rather than starting an LLM call.
445            if cancel.is_cancelled() {
446                let _ = sender.send(Err(VoiceTurnError::Cancelled));
447                if let Some(t) = telemetry {
448                    t.emit(
449                        EventKind::VoiceTurnCancelled,
450                        turn_id,
451                        vec![("track", "sidecar".into())],
452                    );
453                }
454                return;
455            }
456        }
457        run_llm_sidecar(engine, request, sender, cancel, turn_id, telemetry).await;
458    });
459}
460
461async fn run_llm_sidecar(
462    engine: Arc<InferenceEngine>,
463    request: GenerateRequest,
464    sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
465    cancel: CancellationToken,
466    turn_id: u64,
467    telemetry: Option<VoiceTelemetry>,
468) {
469    let result = tokio::select! {
470        biased;
471        _ = cancel.cancelled() => Err(VoiceTurnError::Cancelled),
472        res = engine.generate(request) => {
473            res.map(|text| SidecarResult { turn_id, text, data: None })
474               .map_err(|e| VoiceTurnError::Inference(e.to_string()))
475        }
476    };
477    if let Some(t) = telemetry {
478        match &result {
479            Ok(r) => t.emit(
480                EventKind::VoiceSidecarResolved,
481                turn_id,
482                vec![("text_len", Value::from(r.text.len()))],
483            ),
484            Err(VoiceTurnError::Cancelled) => {
485                t.emit(
486                    EventKind::VoiceTurnCancelled,
487                    turn_id,
488                    vec![("track", "sidecar".into())],
489                );
490            }
491            Err(VoiceTurnError::Inference(e)) => {
492                t.emit(
493                    EventKind::VoiceSidecarFailed,
494                    turn_id,
495                    vec![("error", Value::from(e.clone()))],
496                );
497            }
498        }
499    }
500    let _ = sender.send(result);
501}
502
503fn spawn_sidecar_task(
504    engine: Arc<InferenceEngine>,
505    request: GenerateRequest,
506    sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
507    cancel: CancellationToken,
508    turn_id: u64,
509    telemetry: Option<VoiceTelemetry>,
510) {
511    tokio::spawn(run_llm_sidecar(
512        engine, request, sender, cancel, turn_id, telemetry,
513    ));
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn turn_ids_are_monotonic_and_unique() {
522        let a = next_turn_id();
523        let b = next_turn_id();
524        let c = next_turn_id();
525        assert!(b > a);
526        assert!(c > b);
527    }
528
529    #[test]
530    fn control_cancel_is_observable() {
531        let control = VoiceTurnControl {
532            turn_id: 42,
533            cancel: CancellationToken::new(),
534        };
535        assert!(!control.is_cancelled());
536        let clone = control.clone();
537        clone.cancel();
538        assert!(control.is_cancelled());
539    }
540
541    #[test]
542    fn handle_turn_id_delegates_to_control() {
543        let (_tx, fast_rx) = mpsc::channel::<StreamEvent>(1);
544        let (_stx, sidecar_rx) = oneshot::channel();
545        let handle = VoiceTurnHandle {
546            control: VoiceTurnControl {
547                turn_id: 7,
548                cancel: CancellationToken::new(),
549            },
550            fast: fast_rx,
551            sidecar: sidecar_rx,
552        };
553        assert_eq!(handle.turn_id(), 7);
554        assert!(!handle.control.is_cancelled());
555        handle.cancel();
556        assert!(handle.control.is_cancelled());
557    }
558
559    #[tokio::test]
560    async fn closed_fast_channel_recv_is_none() {
561        // Mirrors the channel shape that `dispatch_voice_turn_sidecar_only`
562        // produces — a pre-closed mpsc so callers can `.recv()` without
563        // special-casing the sidecar-only flavour.
564        let (fast_tx, mut fast_rx) = mpsc::channel::<StreamEvent>(1);
565        drop(fast_tx);
566        assert!(fast_rx.recv().await.is_none());
567    }
568
569    #[tokio::test]
570    async fn cancellation_propagates_to_relay_fast_stream() {
571        // Wire a producer rx, an out channel, and a cancellation token.
572        // Cancelling the token should stop the relay even while the
573        // producer is still alive.
574        let (in_tx, mut in_rx) = mpsc::channel::<StreamEvent>(8);
575        let (out_tx, mut out_rx) = mpsc::channel::<StreamEvent>(8);
576        let cancel = CancellationToken::new();
577
578        // Producer pushes until the channel fills. Bounded mpsc back-pressure
579        // means it will block; we cancel the relay before it drains.
580        let producer = tokio::spawn(async move {
581            for i in 0..100u32 {
582                if in_tx
583                    .send(StreamEvent::TextDelta(format!("d{i}")))
584                    .await
585                    .is_err()
586                {
587                    break;
588                }
589            }
590        });
591
592        let cancel_clone = cancel.clone();
593        let relay = tokio::spawn(async move {
594            relay_fast_stream(&mut in_rx, &out_tx, &cancel_clone, 1).await;
595        });
596
597        // Drain at least one event, then cancel.
598        let first = out_rx.recv().await.expect("first event");
599        match first {
600            StreamEvent::TextDelta(_) => {}
601            other => panic!("unexpected event: {other:?}"),
602        }
603        cancel.cancel();
604
605        // Relay should exit promptly.
606        tokio::time::timeout(std::time::Duration::from_secs(1), relay)
607            .await
608            .expect("relay did not exit after cancel")
609            .expect("relay panicked");
610
611        producer.abort();
612    }
613
614    #[tokio::test]
615    async fn direct_fetcher_hit_skips_llm_and_resolves_sidecar() {
616        struct Hit;
617        #[async_trait::async_trait]
618        impl DirectDataFetcher for Hit {
619            async fn try_fetch(&self, _u: &str) -> Option<Result<String, String>> {
620                Some(Ok("3 emails: Bob, Alice, Carol".to_string()))
621            }
622        }
623        let cancel = CancellationToken::new();
624        let (tx, rx) = oneshot::channel();
625        let log = Arc::new(Mutex::new(EventLog::new()));
626        let telemetry = VoiceTelemetry::new(log.clone());
627        // We never reach the engine — its generate() would panic on a
628        // bare InferenceEngine without backends. The fetcher hit path
629        // must short-circuit before that.
630        let dummy_engine = Arc::new(car_inference::InferenceEngine::new(
631            car_inference::InferenceConfig::default(),
632        ));
633        spawn_sidecar_task_classified(
634            dummy_engine,
635            "any new email today".to_string(),
636            GenerateRequest::default(),
637            tx,
638            cancel,
639            99,
640            Some(Arc::new(Hit)),
641            Some(telemetry),
642        );
643        let r = rx.await.expect("oneshot delivered").expect("ok");
644        assert_eq!(r.turn_id, 99);
645        assert_eq!(r.text, "3 emails: Bob, Alice, Carol");
646        // Resolved event tagged source=direct_fetch.
647        let g = log.lock().unwrap();
648        let evt = g.events().last().expect("event emitted");
649        assert_eq!(evt.kind, EventKind::VoiceSidecarResolved);
650        assert_eq!(evt.data.get("source"), Some(&Value::from("direct_fetch")));
651    }
652
653    #[tokio::test]
654    async fn direct_fetcher_miss_falls_through_but_we_observe_no_short_circuit() {
655        // We only verify that the fetch path enters the LLM branch by
656        // confirming the spawn task hasn't resolved the oneshot via the
657        // fetcher hit path. We can't drive a real LLM here, so we
658        // cancel quickly and observe the Cancelled error instead of a
659        // SidecarResult — proves the fetcher's None did not produce a
660        // direct-resolve.
661        struct Miss;
662        #[async_trait::async_trait]
663        impl DirectDataFetcher for Miss {
664            async fn try_fetch(&self, _u: &str) -> Option<Result<String, String>> {
665                None
666            }
667        }
668        let cancel = CancellationToken::new();
669        let (tx, rx) = oneshot::channel();
670        let dummy_engine = Arc::new(car_inference::InferenceEngine::new(
671            car_inference::InferenceConfig::default(),
672        ));
673        spawn_sidecar_task_classified(
674            dummy_engine,
675            "what's the weather".to_string(),
676            GenerateRequest::default(),
677            tx,
678            cancel.clone(),
679            100,
680            Some(Arc::new(Miss)),
681            None,
682        );
683        // Cancel before the LLM has a chance to fail; we expect Cancelled.
684        cancel.cancel();
685        match rx.await.expect("oneshot delivered") {
686            Err(VoiceTurnError::Cancelled) => {}
687            other => panic!("expected Cancelled after fetcher miss + cancel, got {other:?}"),
688        }
689    }
690
691    #[test]
692    fn telemetry_emit_appends_to_eventlog() {
693        let log = Arc::new(Mutex::new(EventLog::new()));
694        let telemetry = VoiceTelemetry::new(log.clone());
695        telemetry.emit(EventKind::VoiceFastTurnStarted, 7, vec![]);
696        telemetry.emit(
697            EventKind::VoiceSidecarResolved,
698            7,
699            vec![("text_len", Value::from(42usize))],
700        );
701        let g = log.lock().unwrap();
702        let events = g.events();
703        assert_eq!(events.len(), 2);
704        assert_eq!(events[0].kind, EventKind::VoiceFastTurnStarted);
705        assert_eq!(events[0].data.get("turn_id"), Some(&Value::from(7u64)));
706        assert_eq!(events[1].kind, EventKind::VoiceSidecarResolved);
707        assert_eq!(events[1].data.get("text_len"), Some(&Value::from(42usize)));
708    }
709
710    #[tokio::test]
711    async fn dropped_out_channel_stops_relay_without_cancel() {
712        let (in_tx, mut in_rx) = mpsc::channel::<StreamEvent>(8);
713        let (out_tx, out_rx) = mpsc::channel::<StreamEvent>(8);
714        let cancel = CancellationToken::new();
715
716        // Caller drops their receiver — the relay should exit on the
717        // next send attempt.
718        drop(out_rx);
719
720        let cancel_clone = cancel.clone();
721        let relay = tokio::spawn(async move {
722            relay_fast_stream(&mut in_rx, &out_tx, &cancel_clone, 1).await;
723        });
724
725        // Push one event so the relay's send fails.
726        in_tx
727            .send(StreamEvent::TextDelta("x".into()))
728            .await
729            .unwrap();
730
731        tokio::time::timeout(std::time::Duration::from_secs(1), relay)
732            .await
733            .expect("relay did not exit after out_rx drop")
734            .expect("relay panicked");
735    }
736}