Skip to main content

vox_rtc_server/
session.rs

1use crate::error::{Result, VoxRtcError};
2use crate::socket::RawSocketChannel;
3use crate::types::*;
4use serde_json::Value;
5use std::ops::ControlFlow;
6use std::sync::{Arc, Mutex};
7use tokio::sync::broadcast::error::RecvError;
8use uuid::Uuid;
9use tokio::task::JoinHandle;
10use tokio::time::{Duration, timeout};
11
12#[derive(Clone)]
13pub struct VoxRtcControlSession {
14    channel: RawSocketChannel,
15    session_id: String,
16    channel_name: String,
17    join_timeout: Duration,
18    response_generation: Arc<Mutex<ResponseGeneration>>,
19}
20
21#[derive(Default)]
22struct ResponseGeneration {
23    counter: u64,
24    id: Option<String>,
25}
26
27pub struct Listener {
28    handle: JoinHandle<()>,
29}
30
31impl Drop for Listener {
32    fn drop(&mut self) {
33        self.handle.abort();
34    }
35}
36
37impl VoxRtcControlSession {
38    pub(crate) fn new(
39        channel: RawSocketChannel,
40        session_id: String,
41        join_timeout: Duration,
42    ) -> Self {
43        let channel_name = format!("/rtc/{session_id}");
44        Self {
45            channel,
46            session_id,
47            channel_name,
48            join_timeout,
49            response_generation: Arc::new(Mutex::new(ResponseGeneration::default())),
50        }
51    }
52
53    pub fn session_id(&self) -> &str {
54        &self.session_id
55    }
56
57    pub fn channel_name(&self) -> &str {
58        &self.channel_name
59    }
60
61    pub async fn join(&self) -> Result<()> {
62        let mut states = self.channel.subscribe_state();
63        self.channel.join().await?;
64        let channel_name = self.channel.name().to_owned();
65        let channel = self.channel.clone();
66        timeout(self.join_timeout, async move {
67            loop {
68                let state = *states.borrow_and_update();
69                match state {
70                    ChannelState::Joined => return Ok(()),
71                    ChannelState::Closed | ChannelState::Declined => {
72                        let reason = join_decline_reason(channel.decline_reason().await);
73                        return Err(VoxRtcError::JoinFailed {
74                            channel: channel_name,
75                            state: format!("{state:?}"),
76                            reason,
77                        });
78                    }
79                    _ => {}
80                }
81                if states.changed().await.is_err() {
82                    return Err(VoxRtcError::Disconnected);
83                }
84            }
85        })
86        .await
87        .map_err(|_| VoxRtcError::JoinTimeout(self.channel_name.clone()))?
88    }
89
90    pub async fn close(&self) -> Result<()> {
91        self.channel.leave().await
92    }
93
94    pub fn on_event<F>(&self, handler: F) -> Listener
95    where
96        F: Fn(WireEvent) + Send + Sync + 'static,
97    {
98        let mut messages = self.channel.subscribe_messages();
99        let session_id = self.session_id.clone();
100        let channel_name = self.channel_name.clone();
101        Listener {
102            handle: tokio::spawn(async move {
103                loop {
104                    match next_message(messages.recv().await) {
105                        ControlFlow::Break(()) => break,
106                        ControlFlow::Continue(None) => continue,
107                        ControlFlow::Continue(Some((event, payload))) => handler(WireEvent {
108                            r#type: event,
109                            data: payload,
110                            session_id: session_id.clone(),
111                            channel_name: channel_name.clone(),
112                        }),
113                    }
114                }
115            }),
116        }
117    }
118
119    pub fn on<F>(&self, event_name: impl Into<String>, handler: F) -> Listener
120    where
121        F: Fn(EventData) + Send + Sync + 'static,
122    {
123        let event_name = event_name.into();
124        let mut messages = self.channel.subscribe_messages();
125        Listener {
126            handle: tokio::spawn(async move {
127                loop {
128                    match next_message(messages.recv().await) {
129                        ControlFlow::Break(()) => break,
130                        ControlFlow::Continue(None) => continue,
131                        ControlFlow::Continue(Some((event, payload))) => {
132                            if event == event_name {
133                                handler(payload);
134                            }
135                        }
136                    }
137                }
138            }),
139        }
140    }
141
142    pub fn on_session_attached<F>(&self, handler: F) -> Listener
143    where
144        F: Fn(SessionAttachedEvent) + Send + Sync + 'static,
145    {
146        let session_id = self.session_id.clone();
147        let channel_name = self.channel_name.clone();
148        self.on(EVENT_RTC_SESSION_ATTACHED, move |payload| {
149            handler(SessionAttachedEvent {
150                session_id: session_id.clone(),
151                channel_name: channel_name.clone(),
152                data: payload,
153            })
154        })
155    }
156
157    pub fn on_session_created<F>(&self, handler: F) -> Listener
158    where
159        F: Fn(SessionCreatedEvent) + Send + Sync + 'static,
160    {
161        let session_id = self.session_id.clone();
162        let channel_name = self.channel_name.clone();
163        self.on(EVENT_SESSION_CREATED, move |payload| {
164            let session = payload.get("session").and_then(Value::as_object).cloned();
165            handler(SessionCreatedEvent {
166                session_id: session_id.clone(),
167                channel_name: channel_name.clone(),
168                data: payload,
169                session,
170            });
171        })
172    }
173
174    pub fn on_transcript<F>(&self, handler: F) -> Listener
175    where
176        F: Fn(TranscriptEvent) + Send + Sync + 'static,
177    {
178        let session_id = self.session_id.clone();
179        let channel_name = self.channel_name.clone();
180        self.on(EVENT_TRANSCRIPT_COMPLETED, move |payload| {
181            handler(TranscriptEvent {
182                session_id: session_id.clone(),
183                channel_name: channel_name.clone(),
184                transcript: required_string(&payload, "transcript", ""),
185                language: optional_string(&payload, "language"),
186                start_ms: optional_number(&payload, "start_ms"),
187                end_ms: optional_number(&payload, "end_ms"),
188                eou_probability: optional_number(&payload, "eou_probability"),
189                topics: optional_string_vec(&payload, "topics"),
190                entities: transcript_entities(&payload),
191                words: transcript_words(&payload),
192                speech_context: payload
193                    .get("speech_context")
194                    .cloned()
195                    .and_then(|value| serde_json::from_value::<SpeechContext>(value).ok())
196                    .filter(SpeechContext::is_valid),
197                data: payload,
198            });
199        })
200    }
201
202    pub fn on_turn_state_changed<F>(&self, handler: F) -> Listener
203    where
204        F: Fn(TurnStateEvent) + Send + Sync + 'static,
205    {
206        let session_id = self.session_id.clone();
207        let channel_name = self.channel_name.clone();
208        self.on(EVENT_TURN_STATE_CHANGED, move |payload| {
209            handler(TurnStateEvent {
210                session_id: session_id.clone(),
211                channel_name: channel_name.clone(),
212                state: required_string(&payload, "state", "unknown"),
213                previous_state: optional_string(&payload, "previous_state"),
214                data: payload,
215            });
216        })
217    }
218
219    pub fn on_speech_started<F>(&self, handler: F) -> Listener
220    where
221        F: Fn(SpeechStartedEvent) + Send + Sync + 'static,
222    {
223        let session_id = self.session_id.clone();
224        let channel_name = self.channel_name.clone();
225        self.on(EVENT_SPEECH_STARTED, move |payload| {
226            handler(SpeechStartedEvent {
227                session_id: session_id.clone(),
228                channel_name: channel_name.clone(),
229                timestamp_ms: optional_number(&payload, "timestamp_ms"),
230                data: payload,
231            });
232        })
233    }
234
235    pub fn on_speech_stopped<F>(&self, handler: F) -> Listener
236    where
237        F: Fn(SpeechStoppedEvent) + Send + Sync + 'static,
238    {
239        let session_id = self.session_id.clone();
240        let channel_name = self.channel_name.clone();
241        self.on(EVENT_SPEECH_STOPPED, move |payload| {
242            handler(SpeechStoppedEvent {
243                session_id: session_id.clone(),
244                channel_name: channel_name.clone(),
245                timestamp_ms: optional_number(&payload, "timestamp_ms"),
246                data: payload,
247            });
248        })
249    }
250
251    pub fn on_transcript_delta<F>(&self, handler: F) -> Listener
252    where
253        F: Fn(TranscriptDeltaEvent) + Send + Sync + 'static,
254    {
255        let session_id = self.session_id.clone();
256        let channel_name = self.channel_name.clone();
257        self.on(EVENT_TRANSCRIPT_DELTA, move |payload| {
258            handler(TranscriptDeltaEvent {
259                session_id: session_id.clone(),
260                channel_name: channel_name.clone(),
261                delta: required_string(&payload, "delta", ""),
262                start_ms: optional_number(&payload, "start_ms"),
263                end_ms: optional_number(&payload, "end_ms"),
264                data: payload,
265            });
266        })
267    }
268
269    pub fn on_turn_eou_predicted<F>(&self, handler: F) -> Listener
270    where
271        F: Fn(TurnEouPredictedEvent) + Send + Sync + 'static,
272    {
273        let session_id = self.session_id.clone();
274        let channel_name = self.channel_name.clone();
275        self.on(EVENT_TURN_EOU_PREDICTED, move |payload| {
276            handler(TurnEouPredictedEvent {
277                session_id: session_id.clone(),
278                channel_name: channel_name.clone(),
279                probability: optional_number(&payload, "probability"),
280                threshold: optional_number(&payload, "threshold"),
281                delay_ms: optional_number(&payload, "delay_ms"),
282                start_ms: optional_number(&payload, "start_ms"),
283                end_ms: optional_number(&payload, "end_ms"),
284                decision: optional_string(&payload, "decision"),
285                action: optional_string(&payload, "action"),
286                turn_detector: optional_string(&payload, "turn_detector"),
287                data: payload,
288            });
289        })
290    }
291
292    pub fn on_response_created<F>(&self, handler: F) -> Listener
293    where
294        F: Fn(ResponseEvent) + Send + Sync + 'static,
295    {
296        self.on_response_event(EVENT_RESPONSE_CREATED, handler)
297    }
298
299    pub fn on_response_committed<F>(&self, handler: F) -> Listener
300    where
301        F: Fn(ResponseEvent) + Send + Sync + 'static,
302    {
303        self.on_response_event(EVENT_RESPONSE_COMMITTED, handler)
304    }
305
306    pub fn on_response_done<F>(&self, handler: F) -> Listener
307    where
308        F: Fn(ResponseEvent) + Send + Sync + 'static,
309    {
310        self.on_response_event(EVENT_RESPONSE_DONE, handler)
311    }
312
313    pub fn on_response_cancelled<F>(&self, handler: F) -> Listener
314    where
315        F: Fn(ResponseEvent) + Send + Sync + 'static,
316    {
317        self.on_response_event(EVENT_RESPONSE_CANCELLED, handler)
318    }
319
320    pub fn on_response_audio_clear<F>(&self, handler: F) -> Listener
321    where
322        F: Fn(ResponseEvent) + Send + Sync + 'static,
323    {
324        self.on_response_event(EVENT_RESPONSE_AUDIO_CLEAR, handler)
325    }
326
327    fn on_response_event<F>(&self, event_name: &'static str, handler: F) -> Listener
328    where
329        F: Fn(ResponseEvent) + Send + Sync + 'static,
330    {
331        let session_id = self.session_id.clone();
332        let channel_name = self.channel_name.clone();
333        self.on(event_name, move |payload| {
334            handler(response_event(payload, &session_id, &channel_name));
335        })
336    }
337
338    pub fn on_interruption_detected<F>(&self, handler: F) -> Listener
339    where
340        F: Fn(InterruptionEvent) + Send + Sync + 'static,
341    {
342        self.on_interruption_event(EVENT_INTERRUPTION_DETECTED, handler)
343    }
344
345    pub fn on_interruption_false_positive<F>(&self, handler: F) -> Listener
346    where
347        F: Fn(InterruptionEvent) + Send + Sync + 'static,
348    {
349        self.on_interruption_event(EVENT_INTERRUPTION_FALSE_POSITIVE, handler)
350    }
351
352    fn on_interruption_event<F>(&self, event_name: &'static str, handler: F) -> Listener
353    where
354        F: Fn(InterruptionEvent) + Send + Sync + 'static,
355    {
356        let session_id = self.session_id.clone();
357        let channel_name = self.channel_name.clone();
358        self.on(event_name, move |payload| {
359            handler(InterruptionEvent {
360                response: response_event(payload.clone(), &session_id, &channel_name),
361                vad_active_ms: optional_number(&payload, "vad_active_ms"),
362                partial_transcript: optional_string(&payload, "partial_transcript"),
363                reason: optional_nonempty_string(&payload, "reason"),
364            });
365        })
366    }
367
368    pub fn on_browser_event<F>(&self, handler: F) -> Listener
369    where
370        F: Fn(BrowserEvent) + Send + Sync + 'static,
371    {
372        let session_id = self.session_id.clone();
373        let channel_name = self.channel_name.clone();
374        self.on(EVENT_BROWSER_EVENT, move |payload| {
375            handler(BrowserEvent {
376                session_id: session_id.clone(),
377                channel_name: channel_name.clone(),
378                event: required_string(&payload, "event", ""),
379                payload: payload.get("payload").cloned().unwrap_or(Value::Null),
380                data: payload,
381            });
382        })
383    }
384
385    pub fn on_close<F>(&self, handler: F) -> Listener
386    where
387        F: Fn(CloseEvent) + Send + Sync + 'static,
388    {
389        let session_id = self.session_id.clone();
390        let channel_name = self.channel_name.clone();
391        self.on(EVENT_RTC_CLIENT_DISCONNECTED, move |payload| {
392            handler(CloseEvent {
393                session_id: session_id.clone(),
394                channel_name: channel_name.clone(),
395                reason: required_string(&payload, "reason", "unknown"),
396                connection_state: optional_string(&payload, "connection_state"),
397                ice_connection_state: optional_string(&payload, "ice_connection_state"),
398                data_channel_state: optional_string(&payload, "data_channel_state"),
399                data: payload,
400            });
401        })
402    }
403
404    pub fn on_error<F>(&self, handler: F) -> Listener
405    where
406        F: Fn(ErrorEvent) + Send + Sync + 'static,
407    {
408        let session_id = self.session_id.clone();
409        let channel_name = self.channel_name.clone();
410        self.on(EVENT_ERROR, move |payload| {
411            handler(ErrorEvent {
412                session_id: session_id.clone(),
413                channel_name: channel_name.clone(),
414                message: optional_string(&payload, "message"),
415                code: optional_nonempty_string(&payload, "code"),
416                recoverable: recoverable_flag(&payload),
417                generation_id: optional_nonempty_string(&payload, "generation_id"),
418                data: payload,
419            });
420        })
421    }
422
423    pub fn on_signaling_error<F>(&self, handler: F) -> Listener
424    where
425        F: Fn(SignalingErrorEvent) + Send + Sync + 'static,
426    {
427        let session_id = self.session_id.clone();
428        let channel_name = self.channel_name.clone();
429        self.on(EVENT_RTC_SIGNALING_ERROR, move |payload| {
430            handler(SignalingErrorEvent {
431                session_id: session_id.clone(),
432                channel_name: channel_name.clone(),
433                message: optional_string(&payload, "message"),
434                generation: optional_i64(&payload, "generation"),
435                data: payload,
436            });
437        })
438    }
439
440    pub async fn send_control(&self, event: &str, payload: EventData) -> Result<()> {
441        self.channel.send_message(event, payload).await
442    }
443
444    pub async fn send_offer(
445        &self,
446        offer: Value,
447        restart: bool,
448        generation: Option<u64>,
449    ) -> Result<()> {
450        let mut payload = EventData::new();
451        payload.insert("offer".to_owned(), offer);
452        payload.insert("restart".to_owned(), Value::Bool(restart));
453        if let Some(generation) = generation {
454            payload.insert("generation".to_owned(), Value::from(generation));
455        }
456        self.send_control("rtc.offer", payload).await
457    }
458
459    pub async fn send_ice_candidate(
460        &self,
461        candidate: Option<Value>,
462        generation: Option<u64>,
463    ) -> Result<()> {
464        let mut payload = EventData::new();
465        payload.insert("candidate".to_owned(), candidate.unwrap_or(Value::Null));
466        if let Some(generation) = generation {
467            payload.insert("generation".to_owned(), Value::from(generation));
468        }
469        self.send_control("rtc.ice_candidate", payload).await
470    }
471
472    pub async fn close_rtc(&self, reason: impl Into<String>) -> Result<()> {
473        let mut payload = EventData::new();
474        payload.insert("reason".to_owned(), Value::String(reason.into()));
475        self.send_control("rtc.close", payload).await
476    }
477
478    pub async fn configure(&self, config: SessionConfig) -> Result<()> {
479        let mut payload = EventData::new();
480        payload.insert(
481            "session".to_owned(),
482            Value::Object(session_config_payload(config)),
483        );
484        self.send_control("session.update", payload).await
485    }
486
487    pub async fn start_response(&self, options: Option<ResponseOptions>) -> Result<()> {
488        let (_, payload) = self.start_payload(options);
489        self.send_control("response.start", payload).await
490    }
491
492    pub async fn start_response_and_wait(
493        &self,
494        options: Option<ResponseOptions>,
495        wait_timeout: Duration,
496    ) -> Result<StartAck> {
497        let (generation_id, payload) = self.start_payload(options);
498        let mut messages = self.channel.subscribe_messages();
499        self.send_control("response.start", payload).await?;
500        timeout(wait_timeout, async move {
501            loop {
502                match next_message(messages.recv().await) {
503                    ControlFlow::Break(()) => return Err(VoxRtcError::ChannelClosed),
504                    ControlFlow::Continue(None) => continue,
505                    ControlFlow::Continue(Some((event, data))) => {
506                        if optional_nonempty_string(&data, "generation_id").as_deref()
507                            != Some(generation_id.as_str())
508                        {
509                            continue;
510                        }
511                        if event == EVENT_RESPONSE_CREATED {
512                            return Ok(StartAck {
513                                accepted: true,
514                                generation_id: generation_id.clone(),
515                                response_id: optional_string(&data, "response_id"),
516                                output: response_output(&data),
517                                error_code: None,
518                                error_message: None,
519                                recoverable: true,
520                            });
521                        }
522                        if event == EVENT_ERROR {
523                            return Ok(StartAck {
524                                accepted: false,
525                                generation_id: generation_id.clone(),
526                                response_id: optional_string(&data, "response_id"),
527                                output: None,
528                                error_code: optional_nonempty_string(&data, "code"),
529                                error_message: optional_string(&data, "message"),
530                                recoverable: recoverable_flag(&data),
531                            });
532                        }
533                    }
534                }
535            }
536        })
537        .await
538        .map_err(|_| VoxRtcError::Timeout("response.start acknowledgement"))?
539    }
540
541    pub async fn append_response_text(
542        &self,
543        delta: impl Into<String>,
544        options: Option<ResponseOptions>,
545    ) -> Result<()> {
546        let explicit = explicit_generation(&options);
547        let mut payload = response_options_payload(options);
548        payload.insert("delta".to_owned(), Value::String(delta.into()));
549        self.thread_generation(&mut payload, explicit);
550        self.send_control("response.delta", payload).await
551    }
552
553    pub async fn commit_response(&self, options: Option<ResponseOptions>) -> Result<()> {
554        let explicit = explicit_generation(&options);
555        let mut payload = EventData::new();
556        self.thread_generation(&mut payload, explicit);
557        self.send_control("response.commit", payload).await
558    }
559
560    pub async fn cancel_response(&self, options: Option<ResponseOptions>) -> Result<()> {
561        let explicit = explicit_generation(&options);
562        let mut payload = EventData::new();
563        self.thread_generation(&mut payload, explicit);
564        self.clear_response_generation();
565        self.send_control("response.cancel", payload).await
566    }
567
568    pub async fn replace_response_text(
569        &self,
570        text: impl Into<String>,
571        options: Option<ResponseOptions>,
572    ) -> Result<()> {
573        self.clear_response_generation();
574        let explicit = explicit_generation(&options);
575        let mut payload = response_options_payload(options);
576        payload.insert("text".to_owned(), Value::String(text.into()));
577        if let Some(generation_id) = explicit {
578            payload.insert("generation_id".to_owned(), Value::String(generation_id));
579        }
580        self.send_control("response.replace_text", payload).await
581    }
582
583    pub async fn send_text_response(
584        &self,
585        text: impl Into<String>,
586        options: Option<ResponseOptions>,
587        cancel_first: bool,
588    ) -> Result<()> {
589        let text = text.into();
590        if cancel_first {
591            return self.replace_response_text(text, options).await;
592        }
593        self.start_response(options.clone()).await?;
594        self.append_response_text(text, options.clone()).await?;
595        self.commit_response(options).await
596    }
597
598    pub async fn send_client_event(&self, envelope: ClientEventEnvelope) -> Result<()> {
599        let mut payload = EventData::new();
600        payload.insert("event".to_owned(), Value::String(envelope.event));
601        payload.insert("payload".to_owned(), envelope.payload);
602        self.send_control(EVENT_CLIENT_EVENT, payload).await
603    }
604
605    fn start_payload(&self, options: Option<ResponseOptions>) -> (String, EventData) {
606        let explicit = explicit_generation(&options);
607        let mut payload = response_options_payload(options);
608        let generation_id = match explicit {
609            Some(id) => self.set_response_generation(id),
610            None => self.next_response_generation(),
611        };
612        payload.insert(
613            "generation_id".to_owned(),
614            Value::String(generation_id.clone()),
615        );
616        (generation_id, payload)
617    }
618
619    fn next_response_generation(&self) -> String {
620        let mut state = self
621            .response_generation
622            .lock()
623            .expect("response generation mutex poisoned");
624        state.counter += 1;
625        let generation_id = format!("generation_{}_{}", state.counter, Uuid::new_v4());
626        state.id = Some(generation_id.clone());
627        generation_id
628    }
629
630    fn set_response_generation(&self, generation_id: String) -> String {
631        let mut state = self
632            .response_generation
633            .lock()
634            .expect("response generation mutex poisoned");
635        state.counter += 1;
636        state.id = Some(generation_id.clone());
637        generation_id
638    }
639
640    fn thread_generation(&self, payload: &mut EventData, explicit: Option<String>) {
641        match explicit {
642            Some(generation_id) => {
643                payload.insert("generation_id".to_owned(), Value::String(generation_id));
644            }
645            None => self.add_response_generation(payload),
646        }
647    }
648
649    fn add_response_generation(&self, payload: &mut EventData) {
650        let state = self
651            .response_generation
652            .lock()
653            .expect("response generation mutex poisoned");
654        if let Some(generation_id) = &state.id {
655            payload.insert(
656                "generation_id".to_owned(),
657                Value::String(generation_id.clone()),
658            );
659        }
660    }
661
662    fn clear_response_generation(&self) {
663        self.response_generation
664            .lock()
665            .expect("response generation mutex poisoned")
666            .id = None;
667    }
668}
669
670fn next_message(
671    result: std::result::Result<(String, EventData), RecvError>,
672) -> ControlFlow<(), Option<(String, EventData)>> {
673    match result {
674        Ok(message) => ControlFlow::Continue(Some(message)),
675        Err(RecvError::Lagged(_)) => ControlFlow::Continue(None),
676        Err(RecvError::Closed) => ControlFlow::Break(()),
677    }
678}
679
680fn join_decline_reason(reason: Option<EventData>) -> Option<String> {
681    let reason = reason?;
682    for key in ["message", "reason", "error"] {
683        if let Some(value) = reason.get(key).and_then(Value::as_str)
684            && !value.is_empty()
685        {
686            return Some(value.to_owned());
687        }
688    }
689    if reason.is_empty() {
690        None
691    } else {
692        Some(Value::Object(reason).to_string())
693    }
694}
695
696fn insert_opt(session: &mut EventData, key: &str, value: Option<String>) {
697    if let Some(value) = value {
698        session.insert(key.to_owned(), Value::String(value));
699    }
700}
701
702fn session_config_payload(config: SessionConfig) -> EventData {
703    let mut session = config.extra;
704    insert_opt(&mut session, "stt_model", config.stt_model);
705    insert_opt(&mut session, "tts_model", config.tts_model);
706    insert_opt(&mut session, "voice", config.voice);
707    insert_opt(&mut session, "turn_profile", config.turn_profile);
708    insert_opt(&mut session, "vad_backend", config.vad_backend);
709    insert_opt(&mut session, "turn_detector", config.turn_detector);
710    if let Some(enabled) = config.speech_context {
711        session.insert("speech_context".to_owned(), Value::Bool(enabled));
712    }
713    session
714}
715
716fn response_options_payload(options: Option<ResponseOptions>) -> EventData {
717    let mut payload = EventData::new();
718    if let Some(options) = options {
719        if let Some(allow) = options.allow_interruptions {
720            payload.insert("allow_interruptions".to_owned(), Value::Bool(allow));
721        }
722        if let Some(output) = options.output {
723            payload.insert(
724                "output".to_owned(),
725                Value::Object(response_output_options_payload(output)),
726            );
727        }
728    }
729    payload
730}
731
732fn response_output_options_payload(output: ResponseOutputOptions) -> EventData {
733    let mut payload = EventData::new();
734    insert_opt(&mut payload, "model", output.model);
735    insert_opt(&mut payload, "voice", output.voice);
736    insert_opt(&mut payload, "language", output.language);
737    if let Some(speed) = output.speed
738        && let Some(number) = serde_json::Number::from_f64(speed)
739    {
740        payload.insert("speed".to_owned(), Value::Number(number));
741    }
742    if let Some(params) = output.params {
743        payload.insert("params".to_owned(), Value::Object(params));
744    }
745    payload
746}
747
748fn explicit_generation(options: &Option<ResponseOptions>) -> Option<String> {
749    options
750        .as_ref()
751        .and_then(|options| options.generation_id.clone())
752        .filter(|id| !id.is_empty())
753}
754
755fn response_event(payload: EventData, session_id: &str, channel_name: &str) -> ResponseEvent {
756    let output = response_output(&payload);
757    ResponseEvent {
758        session_id: session_id.to_owned(),
759        channel_name: channel_name.to_owned(),
760        response_id: optional_string(&payload, "response_id"),
761        generation_id: optional_nonempty_string(&payload, "generation_id"),
762        output,
763        data: payload,
764    }
765}
766
767fn response_output(payload: &EventData) -> Option<ResponseOutput> {
768    let output = payload.get("output")?.as_object()?;
769    let model = optional_nonempty_string(output, "model")?;
770    let language = optional_nonempty_string(output, "language")?;
771    let speed = optional_number(output, "speed")?;
772    let params = output.get("params")?.as_object()?.clone();
773    Some(ResponseOutput {
774        model,
775        voice: optional_nonempty_string(output, "voice"),
776        language,
777        speed,
778        params,
779    })
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use crate::socket::test_channel;
786    use serde_json::json;
787    use tokio::sync::broadcast;
788    use tokio::sync::mpsc;
789
790    async fn session() -> (VoxRtcControlSession, broadcast::Sender<(String, EventData)>) {
791        let (channel, sender) = test_channel().await;
792        let session =
793            VoxRtcControlSession::new(channel, "sess-1".to_owned(), Duration::from_secs(1));
794        (session, sender)
795    }
796
797    fn payload(value: Value) -> EventData {
798        value.as_object().cloned().expect("object payload")
799    }
800
801    #[test]
802    fn join_decline_reason_prefers_structured_message_fields() {
803        assert_eq!(
804            join_decline_reason(Some(payload(json!({ "message": "expired" })))),
805            Some("expired".to_owned())
806        );
807        assert_eq!(
808            join_decline_reason(Some(payload(json!({ "reason": "missing" })))),
809            Some("missing".to_owned())
810        );
811        assert_eq!(
812            join_decline_reason(Some(payload(json!({ "channel": "/rtc/abc" })))),
813            Some(r#"{"channel":"/rtc/abc"}"#.to_owned())
814        );
815        assert_eq!(join_decline_reason(Some(EventData::new())), None);
816        assert_eq!(join_decline_reason(None), None);
817    }
818
819    async fn recv<T>(rx: &mut mpsc::UnboundedReceiver<T>) -> T {
820        timeout(Duration::from_secs(1), rx.recv())
821            .await
822            .expect("handler fired within timeout")
823            .expect("handler produced an event")
824    }
825
826    #[test]
827    fn next_message_classifies_lag_close_and_ok() {
828        assert!(matches!(
829            next_message(Ok(("e".to_owned(), EventData::new()))),
830            ControlFlow::Continue(Some(_))
831        ));
832        assert!(matches!(
833            next_message(Err(RecvError::Lagged(7))),
834            ControlFlow::Continue(None)
835        ));
836        assert!(matches!(
837            next_message(Err(RecvError::Closed)),
838            ControlFlow::Break(())
839        ));
840    }
841
842    #[test]
843    fn session_config_serializes_explicit_false_speech_context() {
844        let payload = session_config_payload(SessionConfig {
845            speech_context: Some(false),
846            ..Default::default()
847        });
848        assert_eq!(payload.get("speech_context"), Some(&Value::Bool(false)));
849    }
850
851    #[tokio::test]
852    async fn response_commands_share_one_generation_id() {
853        let (session, _) = session().await;
854        let generation_id = session.next_response_generation();
855        let mut delta = payload(json!({ "delta": "hello" }));
856        session.add_response_generation(&mut delta);
857        let mut commit = EventData::new();
858        session.add_response_generation(&mut commit);
859
860        assert_eq!(
861            delta.get("generation_id"),
862            Some(&Value::String(generation_id.clone()))
863        );
864        assert_eq!(
865            commit.get("generation_id"),
866            Some(&Value::String(generation_id))
867        );
868    }
869
870    #[tokio::test]
871    async fn on_error_parses_typed_fields() {
872        let (session, sender) = session().await;
873        let (tx, mut rx) = mpsc::unbounded_channel();
874        let _listener = session.on_error(move |event| {
875            tx.send(event).unwrap();
876        });
877        sender
878            .send((
879                EVENT_ERROR.to_owned(),
880                payload(json!({
881                    "message": "cannot start now",
882                    "code": ERROR_CODE_SESSION_FAILED,
883                    "recoverable": false,
884                    "generation_id": "gen-9"
885                })),
886            ))
887            .unwrap();
888        let event = recv(&mut rx).await;
889        assert_eq!(event.message.as_deref(), Some("cannot start now"));
890        assert_eq!(event.code.as_deref(), Some(ERROR_CODE_SESSION_FAILED));
891        assert!(!event.recoverable);
892        assert_eq!(event.generation_id.as_deref(), Some("gen-9"));
893    }
894
895    #[tokio::test]
896    async fn on_error_defaults_missing_recoverable_to_true() {
897        let (session, sender) = session().await;
898        let (tx, mut rx) = mpsc::unbounded_channel();
899        let _listener = session.on_error(move |event| {
900            tx.send(event).unwrap();
901        });
902        sender
903            .send((
904                EVENT_ERROR.to_owned(),
905                payload(json!({ "message": "legacy server", "code": "" })),
906            ))
907            .unwrap();
908        let event = recv(&mut rx).await;
909        assert!(event.recoverable);
910        assert_eq!(event.code, None);
911        assert_eq!(event.generation_id, None);
912    }
913
914    #[tokio::test]
915    async fn start_payload_uses_explicit_generation_id() {
916        let (session, _) = session().await;
917        let options = ResponseOptions {
918            allow_interruptions: Some(false),
919            generation_id: Some("gen-7".to_owned()),
920            ..Default::default()
921        };
922        let (generation_id, start) = session.start_payload(Some(options));
923        assert_eq!(generation_id, "gen-7");
924        assert_eq!(
925            start.get("generation_id"),
926            Some(&Value::String("gen-7".to_owned()))
927        );
928        assert_eq!(start.get("allow_interruptions"), Some(&Value::Bool(false)));
929
930        let mut commit = EventData::new();
931        session.thread_generation(&mut commit, None);
932        assert_eq!(
933            commit.get("generation_id"),
934            Some(&Value::String("gen-7".to_owned()))
935        );
936    }
937
938    #[tokio::test]
939    async fn start_payload_generates_generation_id_when_absent() {
940        let (session, _) = session().await;
941        let (generation_id, start) = session.start_payload(None);
942        assert!(generation_id.starts_with("generation_1_"));
943        assert!(generation_id.len() > "generation_1_".len());
944        assert_eq!(
945            start.get("generation_id"),
946            Some(&Value::String(generation_id))
947        );
948    }
949
950    #[tokio::test]
951    async fn start_payload_serializes_response_output() {
952        let (session, _) = session().await;
953        let options = ResponseOptions {
954            generation_id: Some("gen-output".to_owned()),
955            output: Some(ResponseOutputOptions {
956                model: Some("qwen3-tts:0.6b-clone".to_owned()),
957                voice: Some("samantha".to_owned()),
958                language: Some("fr".to_owned()),
959                speed: Some(0.9),
960                params: Some(payload(json!({ "temperature": 0.7 }))),
961            }),
962            ..Default::default()
963        };
964
965        let (_, start) = session.start_payload(Some(options));
966
967        assert_eq!(
968            start.get("output"),
969            Some(&json!({
970                "model": "qwen3-tts:0.6b-clone",
971                "voice": "samantha",
972                "language": "fr",
973                "speed": 0.9,
974                "params": { "temperature": 0.7 }
975            }))
976        );
977    }
978
979    #[tokio::test]
980    async fn explicit_generation_id_overrides_tracked_one() {
981        let (session, _) = session().await;
982        let tracked = session.next_response_generation();
983        let mut delta = payload(json!({ "delta": "hi" }));
984        session.thread_generation(&mut delta, Some("gen-42".to_owned()));
985        assert_eq!(
986            delta.get("generation_id"),
987            Some(&Value::String("gen-42".to_owned()))
988        );
989        assert_ne!(tracked, "gen-42");
990    }
991
992    #[tokio::test]
993    async fn response_events_expose_generation_id() {
994        let (session, sender) = session().await;
995        let (tx, mut rx) = mpsc::unbounded_channel();
996        let _listener = session.on_response_created(move |event| {
997            tx.send(event).unwrap();
998        });
999        sender
1000            .send((
1001                EVENT_RESPONSE_CREATED.to_owned(),
1002                payload(json!({ "response_id": "resp-1", "generation_id": "gen-1" })),
1003            ))
1004            .unwrap();
1005        let event = recv(&mut rx).await;
1006        assert_eq!(event.response_id.as_deref(), Some("resp-1"));
1007        assert_eq!(event.generation_id.as_deref(), Some("gen-1"));
1008    }
1009
1010    #[tokio::test]
1011    async fn audio_clear_and_interruption_expose_generation_id() {
1012        let (session, sender) = session().await;
1013        let (clear_tx, mut clear_rx) = mpsc::unbounded_channel();
1014        let _clear = session.on_response_audio_clear(move |event| {
1015            clear_tx.send(event).unwrap();
1016        });
1017        let (int_tx, mut int_rx) = mpsc::unbounded_channel();
1018        let _interruption = session.on_interruption_detected(move |event| {
1019            int_tx.send(event).unwrap();
1020        });
1021        sender
1022            .send((
1023                EVENT_RESPONSE_AUDIO_CLEAR.to_owned(),
1024                payload(json!({ "response_id": "resp-2", "generation_id": "gen-2" })),
1025            ))
1026            .unwrap();
1027        sender
1028            .send((
1029                EVENT_INTERRUPTION_DETECTED.to_owned(),
1030                payload(json!({
1031                    "response_id": "resp-2",
1032                    "generation_id": "gen-2",
1033                    "vad_active_ms": 250
1034                })),
1035            ))
1036            .unwrap();
1037        let clear = recv(&mut clear_rx).await;
1038        assert_eq!(clear.generation_id.as_deref(), Some("gen-2"));
1039        let interruption = recv(&mut int_rx).await;
1040        assert_eq!(interruption.response.generation_id.as_deref(), Some("gen-2"));
1041        assert_eq!(interruption.vad_active_ms, Some(250.0));
1042    }
1043
1044    #[tokio::test]
1045    async fn on_signaling_error_parses_message_and_generation() {
1046        let (session, sender) = session().await;
1047        let (tx, mut rx) = mpsc::unbounded_channel();
1048        let _listener = session.on_signaling_error(move |event| {
1049            tx.send(event).unwrap();
1050        });
1051        sender
1052            .send((
1053                EVENT_RTC_SIGNALING_ERROR.to_owned(),
1054                payload(json!({
1055                    "message": "setLocalDescription failed",
1056                    "generation": 3
1057                })),
1058            ))
1059            .unwrap();
1060        let event = recv(&mut rx).await;
1061        assert_eq!(event.message.as_deref(), Some("setLocalDescription failed"));
1062        assert_eq!(event.generation, Some(3));
1063    }
1064
1065    #[tokio::test]
1066    async fn on_signaling_error_leaves_generation_none_when_absent() {
1067        let (session, sender) = session().await;
1068        let (tx, mut rx) = mpsc::unbounded_channel();
1069        let _listener = session.on_signaling_error(move |event| {
1070            tx.send(event).unwrap();
1071        });
1072        sender
1073            .send((
1074                EVENT_RTC_SIGNALING_ERROR.to_owned(),
1075                payload(json!({ "message": "RTC signaling failed" })),
1076            ))
1077            .unwrap();
1078        let event = recv(&mut rx).await;
1079        assert_eq!(event.message.as_deref(), Some("RTC signaling failed"));
1080        assert_eq!(event.generation, None);
1081    }
1082
1083    #[tokio::test]
1084    async fn on_transcript_exposes_entities_and_words() {
1085        let (session, sender) = session().await;
1086        let (tx, mut rx) = mpsc::unbounded_channel();
1087        let _listener = session.on_transcript(move |event| {
1088            tx.send(event).unwrap();
1089        });
1090        sender
1091            .send((
1092                EVENT_TRANSCRIPT_COMPLETED.to_owned(),
1093                payload(json!({
1094                    "transcript": "call Ada",
1095                    "entities": [
1096                        { "type": "PRODUCT", "text": "Ada", "start_char": 5, "end_char": 8 }
1097                    ],
1098                    "words": [
1099                        { "word": "call", "start_ms": 0, "end_ms": 300 },
1100                        { "word": "Ada", "start_ms": 300, "end_ms": 600, "confidence": 0.91 }
1101                    ],
1102                    "speech_context": serde_json::from_str::<Value>(include_str!(
1103                        "../../../fixtures/speech-context-v2.json"
1104                    )).unwrap()
1105                })),
1106            ))
1107            .unwrap();
1108        let event = recv(&mut rx).await;
1109        assert_eq!(
1110            event.entities,
1111            vec![TranscriptEntity {
1112                r#type: "PRODUCT".to_owned(),
1113                text: "Ada".to_owned(),
1114                start_char: 5,
1115                end_char: 8,
1116            }]
1117        );
1118        assert_eq!(event.words.len(), 2);
1119        assert_eq!(event.words[0].word, "call");
1120        assert_eq!(event.words[0].start_ms, 0.0);
1121        assert_eq!(event.words[0].confidence, None);
1122        assert_eq!(event.words[1].confidence, Some(0.91));
1123        let context = event.speech_context.expect("speech context");
1124        assert_eq!(context.schema_version, 2);
1125        assert_eq!(context.status, SpeechContextStatus::Complete);
1126        assert_eq!(
1127            context.emotions.as_deref(),
1128            Some(
1129                &[SpeechContextSpan {
1130                    label: "surprised".to_owned(),
1131                    start_ms: 0,
1132                    end_ms: 2500,
1133                }][..]
1134            )
1135        );
1136        let sounds = context.sounds.expect("sound spans");
1137        assert_eq!(sounds.len(), 2);
1138        assert_eq!(sounds[0].span.label, "fireworks");
1139        assert_eq!(sounds[0].score, 0.42);
1140    }
1141
1142    #[tokio::test]
1143    async fn on_transcript_defaults_entities_and_words_to_empty() {
1144        let (session, sender) = session().await;
1145        let (tx, mut rx) = mpsc::unbounded_channel();
1146        let _listener = session.on_transcript(move |event| {
1147            tx.send(event).unwrap();
1148        });
1149        sender
1150            .send((
1151                EVENT_TRANSCRIPT_COMPLETED.to_owned(),
1152                payload(json!({ "transcript": "hello" })),
1153            ))
1154            .unwrap();
1155        let event = recv(&mut rx).await;
1156        assert!(event.entities.is_empty());
1157        assert!(event.words.is_empty());
1158        assert!(event.speech_context.is_none());
1159    }
1160
1161    #[tokio::test]
1162    async fn on_transcript_preserves_text_but_rejects_malformed_speech_context() {
1163        let (session, sender) = session().await;
1164        let (tx, mut rx) = mpsc::unbounded_channel();
1165        let _listener = session.on_transcript(move |event| {
1166            tx.send(event).unwrap();
1167        });
1168        sender
1169            .send((
1170                EVENT_TRANSCRIPT_COMPLETED.to_owned(),
1171                payload(json!({
1172                    "transcript": "still delivered",
1173                    "speech_context": {
1174                        "schema_version": 2,
1175                        "status": "complete",
1176                        "emotions": [],
1177                        "vocal": [],
1178                        "sounds": [
1179                            {
1180                                "label": "fireworks",
1181                                "start_ms": 0,
1182                                "end_ms": 960,
1183                                "score": 1.1
1184                            }
1185                        ]
1186                    }
1187                })),
1188            ))
1189            .unwrap();
1190        let event = recv(&mut rx).await;
1191        assert_eq!(event.transcript, "still delivered");
1192        assert!(event.speech_context.is_none());
1193    }
1194
1195    #[tokio::test]
1196    async fn interruption_events_expose_reason() {
1197        let (session, sender) = session().await;
1198        let (det_tx, mut det_rx) = mpsc::unbounded_channel();
1199        let _detected = session.on_interruption_detected(move |event| {
1200            det_tx.send(event).unwrap();
1201        });
1202        let (fp_tx, mut fp_rx) = mpsc::unbounded_channel();
1203        let _false_positive = session.on_interruption_false_positive(move |event| {
1204            fp_tx.send(event).unwrap();
1205        });
1206        sender
1207            .send((
1208                EVENT_INTERRUPTION_DETECTED.to_owned(),
1209                payload(json!({
1210                    "response_id": "resp-3",
1211                    "generation_id": "gen-3",
1212                    "reason": "speech_overlap"
1213                })),
1214            ))
1215            .unwrap();
1216        sender
1217            .send((
1218                EVENT_INTERRUPTION_FALSE_POSITIVE.to_owned(),
1219                payload(json!({ "response_id": "resp-3", "reason": "backchannel" })),
1220            ))
1221            .unwrap();
1222        let detected = recv(&mut det_rx).await;
1223        assert_eq!(detected.reason.as_deref(), Some("speech_overlap"));
1224        let false_positive = recv(&mut fp_rx).await;
1225        assert_eq!(false_positive.reason.as_deref(), Some("backchannel"));
1226    }
1227
1228    #[tokio::test]
1229    async fn start_response_and_wait_resolves_on_matching_created() {
1230        let (session, sender) = session().await;
1231        let options = ResponseOptions {
1232            generation_id: Some("gen-ack".to_owned()),
1233            ..Default::default()
1234        };
1235        tokio::spawn(async move {
1236            tokio::time::sleep(Duration::from_millis(100)).await;
1237            sender
1238                .send((
1239                    EVENT_RESPONSE_CREATED.to_owned(),
1240                    payload(json!({ "response_id": "resp-other", "generation_id": "gen-other" })),
1241                ))
1242                .unwrap();
1243            sender
1244                .send((
1245                    EVENT_RESPONSE_CREATED.to_owned(),
1246                    payload(json!({
1247                        "response_id": "resp-9",
1248                        "generation_id": "gen-ack",
1249                        "output": {
1250                            "model": "qwen3-tts:0.6b-clone",
1251                            "voice": "samantha",
1252                            "language": "fr",
1253                            "speed": 0.9,
1254                            "params": { "temperature": 0.7 }
1255                        }
1256                    })),
1257                ))
1258                .unwrap();
1259        });
1260        let ack = session
1261            .start_response_and_wait(Some(options), Duration::from_secs(2))
1262            .await
1263            .expect("ack within timeout");
1264        assert!(ack.accepted);
1265        assert_eq!(ack.generation_id, "gen-ack");
1266        assert_eq!(ack.response_id.as_deref(), Some("resp-9"));
1267        assert_eq!(
1268            ack.output,
1269            Some(ResponseOutput {
1270                model: "qwen3-tts:0.6b-clone".to_owned(),
1271                voice: Some("samantha".to_owned()),
1272                language: "fr".to_owned(),
1273                speed: 0.9,
1274                params: payload(json!({ "temperature": 0.7 })),
1275            })
1276        );
1277        assert!(ack.recoverable);
1278        assert_eq!(ack.error_code, None);
1279    }
1280
1281    #[tokio::test]
1282    async fn start_response_and_wait_surfaces_typed_rejection() {
1283        let (session, sender) = session().await;
1284        let options = ResponseOptions {
1285            generation_id: Some("gen-rejected".to_owned()),
1286            ..Default::default()
1287        };
1288        tokio::spawn(async move {
1289            tokio::time::sleep(Duration::from_millis(100)).await;
1290            sender
1291                .send((
1292                    EVENT_ERROR.to_owned(),
1293                    payload(json!({
1294                        "message": "busy",
1295                        "code": ERROR_CODE_RESPONSE_ALREADY_ACTIVE,
1296                        "recoverable": true,
1297                        "generation_id": "gen-rejected"
1298                    })),
1299                ))
1300                .unwrap();
1301        });
1302        let ack = session
1303            .start_response_and_wait(Some(options), Duration::from_secs(2))
1304            .await
1305            .expect("rejection within timeout");
1306        assert!(!ack.accepted);
1307        assert_eq!(ack.generation_id, "gen-rejected");
1308        assert_eq!(
1309            ack.error_code.as_deref(),
1310            Some(ERROR_CODE_RESPONSE_ALREADY_ACTIVE)
1311        );
1312        assert_eq!(ack.error_message.as_deref(), Some("busy"));
1313        assert!(ack.recoverable);
1314    }
1315
1316    #[tokio::test]
1317    async fn start_response_and_wait_times_out_without_ack() {
1318        let (session, _sender) = session().await;
1319        let error = session
1320            .start_response_and_wait(None, Duration::from_millis(100))
1321            .await
1322            .expect_err("no ack must time out");
1323        assert!(matches!(error, VoxRtcError::Timeout(_)));
1324    }
1325
1326    #[tokio::test]
1327    async fn on_speech_started_fires_with_timestamp() {
1328        let (session, sender) = session().await;
1329        let (tx, mut rx) = mpsc::unbounded_channel();
1330        let _listener = session.on_speech_started(move |event| {
1331            tx.send(event).unwrap();
1332        });
1333        sender
1334            .send((
1335                EVENT_SPEECH_STARTED.to_owned(),
1336                payload(json!({ "session_id": "sess-1", "timestamp_ms": 1234 })),
1337            ))
1338            .unwrap();
1339        let event = recv(&mut rx).await;
1340        assert_eq!(event.session_id, "sess-1");
1341        assert_eq!(event.channel_name, "/rtc/sess-1");
1342        assert_eq!(event.timestamp_ms, Some(1234.0));
1343    }
1344
1345    #[tokio::test]
1346    async fn on_speech_stopped_fires_with_timestamp() {
1347        let (session, sender) = session().await;
1348        let (tx, mut rx) = mpsc::unbounded_channel();
1349        let _listener = session.on_speech_stopped(move |event| {
1350            tx.send(event).unwrap();
1351        });
1352        sender
1353            .send((
1354                EVENT_SPEECH_STOPPED.to_owned(),
1355                payload(json!({ "timestamp_ms": 5678 })),
1356            ))
1357            .unwrap();
1358        let event = recv(&mut rx).await;
1359        assert_eq!(event.timestamp_ms, Some(5678.0));
1360    }
1361
1362    #[tokio::test]
1363    async fn on_transcript_delta_fires_with_fields() {
1364        let (session, sender) = session().await;
1365        let (tx, mut rx) = mpsc::unbounded_channel();
1366        let _listener = session.on_transcript_delta(move |event| {
1367            tx.send(event).unwrap();
1368        });
1369        sender
1370            .send((
1371                EVENT_TRANSCRIPT_DELTA.to_owned(),
1372                payload(json!({ "delta": "hel", "start_ms": 10, "end_ms": 20 })),
1373            ))
1374            .unwrap();
1375        let event = recv(&mut rx).await;
1376        assert_eq!(event.delta, "hel");
1377        assert_eq!(event.start_ms, Some(10.0));
1378        assert_eq!(event.end_ms, Some(20.0));
1379    }
1380
1381    #[tokio::test]
1382    async fn on_turn_eou_predicted_fires_with_fields() {
1383        let (session, sender) = session().await;
1384        let (tx, mut rx) = mpsc::unbounded_channel();
1385        let _listener = session.on_turn_eou_predicted(move |event| {
1386            tx.send(event).unwrap();
1387        });
1388        sender
1389            .send((
1390                EVENT_TURN_EOU_PREDICTED.to_owned(),
1391                payload(json!({
1392                    "probability": 0.82,
1393                    "threshold": 0.5,
1394                    "delay_ms": 120,
1395                    "start_ms": 0,
1396                    "end_ms": 300,
1397                    "decision": "end",
1398                    "action": "commit",
1399                    "turn_detector": "smart"
1400                })),
1401            ))
1402            .unwrap();
1403        let event = recv(&mut rx).await;
1404        assert_eq!(event.probability, Some(0.82));
1405        assert_eq!(event.threshold, Some(0.5));
1406        assert_eq!(event.delay_ms, Some(120.0));
1407        assert_eq!(event.start_ms, Some(0.0));
1408        assert_eq!(event.end_ms, Some(300.0));
1409        assert_eq!(event.decision.as_deref(), Some("end"));
1410        assert_eq!(event.action.as_deref(), Some("commit"));
1411        assert_eq!(event.turn_detector.as_deref(), Some("smart"));
1412    }
1413
1414    #[tokio::test]
1415    async fn handler_survives_a_lagged_broadcast() {
1416        let (session, sender) = session().await;
1417        let (tx, mut rx) = mpsc::unbounded_channel();
1418        let _listener = session.on_speech_started(move |event| {
1419            tx.send(event.timestamp_ms).unwrap();
1420        });
1421
1422        for index in 0..2100u32 {
1423            let _ = sender.send((
1424                EVENT_SPEECH_STARTED.to_owned(),
1425                payload(json!({ "timestamp_ms": index })),
1426            ));
1427        }
1428        let _ = sender.send((
1429            EVENT_SPEECH_STARTED.to_owned(),
1430            payload(json!({ "timestamp_ms": 9999 })),
1431        ));
1432
1433        let mut saw_final = false;
1434        while let Ok(Some(value)) = timeout(Duration::from_secs(1), rx.recv()).await {
1435            if value == Some(9999.0) {
1436                saw_final = true;
1437                break;
1438            }
1439        }
1440        assert!(
1441            saw_final,
1442            "loop must keep delivering events after a broadcast lag"
1443        );
1444    }
1445}