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}