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    // The turn's model calls are charged to the request that started the
341    // turn: the task carries its spend scope (a plain spawn would drop it,
342    // and the gate would refuse the call).
343    car_inference::spend::spawn_scoped(async move {
344        let cancelled_during = tokio::select! {
345            biased;
346            _ = cancel.cancelled() => {
347                tracing::debug!(turn_id, "fast task cancelled before inference start");
348                true
349            }
350            res = engine.generate_tracked_stream(request) => {
351                match res {
352                    Ok(mut handle) => {
353                        relay_fast_stream(&mut handle.events, &out, &cancel, turn_id).await;
354                        cancel.is_cancelled()
355                    }
356                    Err(e) => {
357                        tracing::error!(turn_id, error=%e, "fast turn inference failed");
358                        false
359                    }
360                }
361            }
362        };
363        if let Some(t) = telemetry {
364            if cancelled_during {
365                t.emit(
366                    EventKind::VoiceTurnCancelled,
367                    turn_id,
368                    vec![("track", "fast".into())],
369                );
370            } else {
371                t.emit(EventKind::VoiceFastTurnEnded, turn_id, vec![]);
372            }
373        }
374    });
375}
376
377async fn relay_fast_stream(
378    rx: &mut mpsc::Receiver<StreamEvent>,
379    out: &mpsc::Sender<StreamEvent>,
380    cancel: &CancellationToken,
381    turn_id: u64,
382) {
383    loop {
384        tokio::select! {
385            biased;
386            _ = cancel.cancelled() => {
387                tracing::debug!(turn_id, "fast stream cancelled mid-relay");
388                break;
389            }
390            evt = rx.recv() => match evt {
391                Some(e) => {
392                    if out.send(e).await.is_err() {
393                        // Receiver dropped — caller has stopped listening.
394                        break;
395                    }
396                }
397                None => break,
398            }
399        }
400    }
401}
402
403fn spawn_sidecar_task_classified(
404    engine: Arc<InferenceEngine>,
405    utterance: String,
406    request: GenerateRequest,
407    sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
408    cancel: CancellationToken,
409    turn_id: u64,
410    fetcher: Option<Arc<dyn DirectDataFetcher>>,
411    telemetry: Option<VoiceTelemetry>,
412) {
413    // The turn's model calls are charged to the request that started the
414    // turn: the task carries its spend scope (a plain spawn would drop it,
415    // and the gate would refuse the call).
416    car_inference::spend::spawn_scoped(async move {
417        // Try the direct fetcher first. On hit, skip the LLM entirely.
418        if let Some(f) = fetcher.as_ref() {
419            let fetch_outcome = tokio::select! {
420                biased;
421                _ = cancel.cancelled() => None,
422                outcome = f.try_fetch(&utterance) => outcome,
423            };
424            match fetch_outcome {
425                Some(Ok(text)) => {
426                    let result = Ok(SidecarResult {
427                        turn_id,
428                        text: text.clone(),
429                        data: None,
430                    });
431                    if let Some(t) = telemetry {
432                        t.emit(
433                            EventKind::VoiceSidecarResolved,
434                            turn_id,
435                            vec![
436                                ("text_len", Value::from(text.len())),
437                                ("source", "direct_fetch".into()),
438                            ],
439                        );
440                    }
441                    let _ = sender.send(result);
442                    return;
443                }
444                Some(Err(e)) => {
445                    tracing::debug!(turn_id, error=%e, "DirectDataFetcher errored; falling through to LLM");
446                }
447                None => { /* no match — fall through */ }
448            }
449            // If the fetch attempt was cancelled (the cancel branch ran),
450            // honour that immediately rather than starting an LLM call.
451            if cancel.is_cancelled() {
452                let _ = sender.send(Err(VoiceTurnError::Cancelled));
453                if let Some(t) = telemetry {
454                    t.emit(
455                        EventKind::VoiceTurnCancelled,
456                        turn_id,
457                        vec![("track", "sidecar".into())],
458                    );
459                }
460                return;
461            }
462        }
463        run_llm_sidecar(engine, request, sender, cancel, turn_id, telemetry).await;
464    });
465}
466
467async fn run_llm_sidecar(
468    engine: Arc<InferenceEngine>,
469    request: GenerateRequest,
470    sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
471    cancel: CancellationToken,
472    turn_id: u64,
473    telemetry: Option<VoiceTelemetry>,
474) {
475    let result = tokio::select! {
476        biased;
477        _ = cancel.cancelled() => Err(VoiceTurnError::Cancelled),
478        res = engine.generate(request) => {
479            res.map(|text| SidecarResult { turn_id, text, data: None })
480               .map_err(|e| VoiceTurnError::Inference(e.to_string()))
481        }
482    };
483    if let Some(t) = telemetry {
484        match &result {
485            Ok(r) => t.emit(
486                EventKind::VoiceSidecarResolved,
487                turn_id,
488                vec![("text_len", Value::from(r.text.len()))],
489            ),
490            Err(VoiceTurnError::Cancelled) => {
491                t.emit(
492                    EventKind::VoiceTurnCancelled,
493                    turn_id,
494                    vec![("track", "sidecar".into())],
495                );
496            }
497            Err(VoiceTurnError::Inference(e)) => {
498                t.emit(
499                    EventKind::VoiceSidecarFailed,
500                    turn_id,
501                    vec![("error", Value::from(e.clone()))],
502                );
503            }
504        }
505    }
506    let _ = sender.send(result);
507}
508
509fn spawn_sidecar_task(
510    engine: Arc<InferenceEngine>,
511    request: GenerateRequest,
512    sender: oneshot::Sender<Result<SidecarResult, VoiceTurnError>>,
513    cancel: CancellationToken,
514    turn_id: u64,
515    telemetry: Option<VoiceTelemetry>,
516) {
517    // The turn's model calls are charged to the request that started the
518    // turn: the task carries its spend scope (a plain spawn would drop it,
519    // and the gate would refuse the call).
520    car_inference::spend::spawn_scoped(run_llm_sidecar(
521        engine, request, sender, cancel, turn_id, telemetry,
522    ));
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn turn_ids_are_monotonic_and_unique() {
531        let a = next_turn_id();
532        let b = next_turn_id();
533        let c = next_turn_id();
534        assert!(b > a);
535        assert!(c > b);
536    }
537
538    #[test]
539    fn control_cancel_is_observable() {
540        let control = VoiceTurnControl {
541            turn_id: 42,
542            cancel: CancellationToken::new(),
543        };
544        assert!(!control.is_cancelled());
545        let clone = control.clone();
546        clone.cancel();
547        assert!(control.is_cancelled());
548    }
549
550    #[test]
551    fn handle_turn_id_delegates_to_control() {
552        let (_tx, fast_rx) = mpsc::channel::<StreamEvent>(1);
553        let (_stx, sidecar_rx) = oneshot::channel();
554        let handle = VoiceTurnHandle {
555            control: VoiceTurnControl {
556                turn_id: 7,
557                cancel: CancellationToken::new(),
558            },
559            fast: fast_rx,
560            sidecar: sidecar_rx,
561        };
562        assert_eq!(handle.turn_id(), 7);
563        assert!(!handle.control.is_cancelled());
564        handle.cancel();
565        assert!(handle.control.is_cancelled());
566    }
567
568    #[tokio::test]
569    async fn closed_fast_channel_recv_is_none() {
570        // Mirrors the channel shape that `dispatch_voice_turn_sidecar_only`
571        // produces — a pre-closed mpsc so callers can `.recv()` without
572        // special-casing the sidecar-only flavour.
573        let (fast_tx, mut fast_rx) = mpsc::channel::<StreamEvent>(1);
574        drop(fast_tx);
575        assert!(fast_rx.recv().await.is_none());
576    }
577
578    #[tokio::test]
579    async fn cancellation_propagates_to_relay_fast_stream() {
580        // Wire a producer rx, an out channel, and a cancellation token.
581        // Cancelling the token should stop the relay even while the
582        // producer is still alive.
583        let (in_tx, mut in_rx) = mpsc::channel::<StreamEvent>(8);
584        let (out_tx, mut out_rx) = mpsc::channel::<StreamEvent>(8);
585        let cancel = CancellationToken::new();
586
587        // Producer pushes until the channel fills. Bounded mpsc back-pressure
588        // means it will block; we cancel the relay before it drains.
589        let producer = tokio::spawn(async move {
590            for i in 0..100u32 {
591                if in_tx
592                    .send(StreamEvent::TextDelta(format!("d{i}")))
593                    .await
594                    .is_err()
595                {
596                    break;
597                }
598            }
599        });
600
601        let cancel_clone = cancel.clone();
602        let relay = tokio::spawn(async move {
603            relay_fast_stream(&mut in_rx, &out_tx, &cancel_clone, 1).await;
604        });
605
606        // Drain at least one event, then cancel.
607        let first = out_rx.recv().await.expect("first event");
608        match first {
609            StreamEvent::TextDelta(_) => {}
610            other => panic!("unexpected event: {other:?}"),
611        }
612        cancel.cancel();
613
614        // Relay should exit promptly.
615        tokio::time::timeout(std::time::Duration::from_secs(1), relay)
616            .await
617            .expect("relay did not exit after cancel")
618            .expect("relay panicked");
619
620        producer.abort();
621    }
622
623    #[tokio::test]
624    async fn direct_fetcher_hit_skips_llm_and_resolves_sidecar() {
625        struct Hit;
626        #[async_trait::async_trait]
627        impl DirectDataFetcher for Hit {
628            async fn try_fetch(&self, _u: &str) -> Option<Result<String, String>> {
629                Some(Ok("3 emails: Bob, Alice, Carol".to_string()))
630            }
631        }
632        let cancel = CancellationToken::new();
633        let (tx, rx) = oneshot::channel();
634        let log = Arc::new(Mutex::new(EventLog::new()));
635        let telemetry = VoiceTelemetry::new(log.clone());
636        // We never reach the engine — its generate() would panic on a
637        // bare InferenceEngine without backends. The fetcher hit path
638        // must short-circuit before that.
639        let dummy_engine = Arc::new(car_inference::InferenceEngine::new(
640            car_inference::InferenceConfig::default(),
641        ));
642        spawn_sidecar_task_classified(
643            dummy_engine,
644            "any new email today".to_string(),
645            GenerateRequest::default(),
646            tx,
647            cancel,
648            99,
649            Some(Arc::new(Hit)),
650            Some(telemetry),
651        );
652        let r = rx.await.expect("oneshot delivered").expect("ok");
653        assert_eq!(r.turn_id, 99);
654        assert_eq!(r.text, "3 emails: Bob, Alice, Carol");
655        // Resolved event tagged source=direct_fetch.
656        let g = log.lock().unwrap();
657        let evt = g.events().last().expect("event emitted");
658        assert_eq!(evt.kind, EventKind::VoiceSidecarResolved);
659        assert_eq!(evt.data.get("source"), Some(&Value::from("direct_fetch")));
660    }
661
662    #[tokio::test]
663    async fn direct_fetcher_miss_falls_through_but_we_observe_no_short_circuit() {
664        // We only verify that the fetch path enters the LLM branch by
665        // confirming the spawn task hasn't resolved the oneshot via the
666        // fetcher hit path. We can't drive a real LLM here, so we
667        // cancel quickly and observe the Cancelled error instead of a
668        // SidecarResult — proves the fetcher's None did not produce a
669        // direct-resolve.
670        struct Miss;
671        #[async_trait::async_trait]
672        impl DirectDataFetcher for Miss {
673            async fn try_fetch(&self, _u: &str) -> Option<Result<String, String>> {
674                None
675            }
676        }
677        let cancel = CancellationToken::new();
678        let (tx, rx) = oneshot::channel();
679        let dummy_engine = Arc::new(car_inference::InferenceEngine::new(
680            car_inference::InferenceConfig::default(),
681        ));
682        spawn_sidecar_task_classified(
683            dummy_engine,
684            "what's the weather".to_string(),
685            GenerateRequest::default(),
686            tx,
687            cancel.clone(),
688            100,
689            Some(Arc::new(Miss)),
690            None,
691        );
692        // Cancel before the LLM has a chance to fail; we expect Cancelled.
693        cancel.cancel();
694        match rx.await.expect("oneshot delivered") {
695            Err(VoiceTurnError::Cancelled) => {}
696            other => panic!("expected Cancelled after fetcher miss + cancel, got {other:?}"),
697        }
698    }
699
700    #[test]
701    fn telemetry_emit_appends_to_eventlog() {
702        let log = Arc::new(Mutex::new(EventLog::new()));
703        let telemetry = VoiceTelemetry::new(log.clone());
704        telemetry.emit(EventKind::VoiceFastTurnStarted, 7, vec![]);
705        telemetry.emit(
706            EventKind::VoiceSidecarResolved,
707            7,
708            vec![("text_len", Value::from(42usize))],
709        );
710        let g = log.lock().unwrap();
711        let events = g.events();
712        assert_eq!(events.len(), 2);
713        assert_eq!(events[0].kind, EventKind::VoiceFastTurnStarted);
714        assert_eq!(events[0].data.get("turn_id"), Some(&Value::from(7u64)));
715        assert_eq!(events[1].kind, EventKind::VoiceSidecarResolved);
716        assert_eq!(events[1].data.get("text_len"), Some(&Value::from(42usize)));
717    }
718
719    #[tokio::test]
720    async fn dropped_out_channel_stops_relay_without_cancel() {
721        let (in_tx, mut in_rx) = mpsc::channel::<StreamEvent>(8);
722        let (out_tx, out_rx) = mpsc::channel::<StreamEvent>(8);
723        let cancel = CancellationToken::new();
724
725        // Caller drops their receiver — the relay should exit on the
726        // next send attempt.
727        drop(out_rx);
728
729        let cancel_clone = cancel.clone();
730        let relay = tokio::spawn(async move {
731            relay_fast_stream(&mut in_rx, &out_tx, &cancel_clone, 1).await;
732        });
733
734        // Push one event so the relay's send fails.
735        in_tx
736            .send(StreamEvent::TextDelta("x".into()))
737            .await
738            .unwrap();
739
740        tokio::time::timeout(std::time::Duration::from_secs(1), relay)
741            .await
742            .expect("relay did not exit after out_rx drop")
743            .expect("relay panicked");
744    }
745}