1use std::sync::Mutex;
42
43use async_trait::async_trait;
44use serde::Serialize;
45use serde_json::{json, Map, Value};
46
47use crate::observer::{FrameObserver, FramePushEvent};
48use crate::processor::frame::{Direction, Frame, FunctionCall};
49use crate::processor::metrics::MetricsData;
50
51pub const PROTOCOL_VERSION: &str = "1.3.0";
54
55pub const MESSAGE_LABEL: &str = "rtvi-ai";
57
58#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct RtviMessage {
66 pub label: &'static str,
68 #[serde(rename = "type")]
70 pub kind: &'static str,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub data: Option<Value>,
74}
75
76impl RtviMessage {
77 fn bare(kind: &'static str) -> Self {
78 Self {
79 label: MESSAGE_LABEL,
80 kind,
81 data: None,
82 }
83 }
84
85 fn with_data(kind: &'static str, data: Value) -> Self {
86 Self {
87 label: MESSAGE_LABEL,
88 kind,
89 data: Some(data),
90 }
91 }
92
93 pub fn kind(&self) -> &'static str {
95 self.kind
96 }
97}
98
99pub trait RtviSink: Send + Sync {
103 fn send(&self, message: RtviMessage);
105}
106
107#[derive(Debug, Default)]
110pub struct VecSink {
111 messages: Mutex<Vec<RtviMessage>>,
112}
113
114impl VecSink {
115 pub fn new() -> Self {
117 Self::default()
118 }
119
120 pub fn messages(&self) -> Vec<RtviMessage> {
122 self.messages.lock().map(|g| g.clone()).unwrap_or_default()
123 }
124
125 pub fn kinds(&self) -> Vec<&'static str> {
127 self.messages
128 .lock()
129 .map(|g| g.iter().map(|m| m.kind).collect())
130 .unwrap_or_default()
131 }
132
133 pub fn clear(&self) {
135 if let Ok(mut g) = self.messages.lock() {
136 g.clear();
137 }
138 }
139}
140
141impl RtviSink for VecSink {
142 fn send(&self, message: RtviMessage) {
143 if let Ok(mut g) = self.messages.lock() {
144 g.push(message);
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum FunctionCallReportLevel {
154 Disabled,
156 #[default]
158 None,
159 Name,
161 Full,
163}
164
165impl FunctionCallReportLevel {
166 fn shows_name(self) -> bool {
167 matches!(self, Self::Name | Self::Full)
168 }
169}
170
171#[derive(Debug, Clone, Copy)]
175pub struct RtviObserverParams {
176 pub bot_output_enabled: bool,
177 pub bot_llm_enabled: bool,
178 pub bot_tts_enabled: bool,
179 pub bot_speaking_enabled: bool,
180 pub user_llm_enabled: bool,
181 pub user_speaking_enabled: bool,
182 pub user_mute_enabled: bool,
183 pub user_transcription_enabled: bool,
184 pub metrics_enabled: bool,
185 pub function_call_report_level: FunctionCallReportLevel,
187}
188
189impl Default for RtviObserverParams {
190 fn default() -> Self {
191 Self {
192 bot_output_enabled: true,
193 bot_llm_enabled: true,
194 bot_tts_enabled: true,
195 bot_speaking_enabled: true,
196 user_llm_enabled: true,
197 user_speaking_enabled: true,
198 user_mute_enabled: true,
199 user_transcription_enabled: true,
200 metrics_enabled: true,
201 function_call_report_level: FunctionCallReportLevel::Full,
202 }
203 }
204}
205
206#[derive(Default)]
209struct RtviState {
210 frames_seen: Vec<u64>,
212 bot_transcription: String,
214}
215
216pub struct RtviObserver {
225 sink: std::sync::Arc<dyn RtviSink>,
226 params: RtviObserverParams,
227 state: Mutex<RtviState>,
228}
229
230impl RtviObserver {
231 pub fn new(sink: std::sync::Arc<dyn RtviSink>) -> Self {
233 Self::with_params(sink, RtviObserverParams::default())
234 }
235
236 pub fn with_params(sink: std::sync::Arc<dyn RtviSink>, params: RtviObserverParams) -> Self {
238 Self {
239 sink,
240 params,
241 state: Mutex::new(RtviState::default()),
242 }
243 }
244
245 fn emit(&self, message: RtviMessage) {
246 self.sink.send(message);
247 }
248
249 pub fn map_frame(&self, frame: &Frame) {
252 match frame {
253 Frame::UserStartedSpeaking if self.params.user_speaking_enabled => {
254 self.emit(RtviMessage::bare("user-started-speaking"));
255 }
256 Frame::UserStoppedSpeaking if self.params.user_speaking_enabled => {
257 self.emit(RtviMessage::bare("user-stopped-speaking"));
258 }
259 Frame::SttMute(muted) if self.params.user_mute_enabled => {
260 self.emit(RtviMessage::bare(if *muted {
261 "user-mute-started"
262 } else {
263 "user-mute-stopped"
264 }));
265 }
266 Frame::BotStartedSpeaking if self.params.bot_speaking_enabled => {
267 self.emit(RtviMessage::bare("bot-started-speaking"));
268 }
269 Frame::BotStoppedSpeaking if self.params.bot_speaking_enabled => {
270 self.emit(RtviMessage::bare("bot-stopped-speaking"));
271 }
272 Frame::Transcription {
273 text,
274 user_id,
275 final_,
276 ..
277 } => {
278 if user_id.as_ref() == "bot" {
283 if self.params.bot_output_enabled {
284 self.emit(RtviMessage::with_data(
285 "bot-transcription",
286 json!({ "text": text }),
287 ));
288 }
289 } else if self.params.user_transcription_enabled {
290 self.emit(RtviMessage::with_data(
291 "user-transcription",
292 json!({
293 "text": text,
294 "user_id": user_id.as_ref(),
295 "timestamp": "",
296 "final": final_,
297 }),
298 ));
299 }
300 }
301 Frame::InterimTranscription { text, user_id, .. } => {
302 if user_id.as_ref() != "bot" && self.params.user_transcription_enabled {
305 self.emit(RtviMessage::with_data(
306 "user-transcription",
307 json!({
308 "text": text,
309 "user_id": user_id.as_ref(),
310 "timestamp": "",
311 "final": false,
312 }),
313 ));
314 }
315 }
316 Frame::LlmResponseStart if self.params.bot_llm_enabled => {
317 self.emit(RtviMessage::bare("bot-llm-started"));
318 }
319 Frame::LlmResponseEnd if self.params.bot_llm_enabled => {
320 self.emit(RtviMessage::bare("bot-llm-stopped"));
321 }
322 Frame::LlmText(text) if self.params.bot_llm_enabled => {
323 self.emit(RtviMessage::with_data(
324 "bot-llm-text",
325 json!({ "text": text }),
326 ));
327 self.flush_bot_transcription_on_sentence(text);
328 }
329 Frame::TtsStarted { .. } if self.params.bot_tts_enabled => {
330 self.emit(RtviMessage::bare("bot-tts-started"));
331 }
332 Frame::TtsStopped { .. } if self.params.bot_tts_enabled => {
333 self.emit(RtviMessage::bare("bot-tts-stopped"));
334 }
335 Frame::TtsText { text, .. } => {
336 if self.params.bot_output_enabled {
340 self.emit(RtviMessage::with_data(
341 "bot-output",
342 json!({
343 "text": text,
344 "spoken": true,
345 "aggregated_by": "word",
346 }),
347 ));
348 }
349 if self.params.bot_tts_enabled {
350 self.emit(RtviMessage::with_data(
351 "bot-tts-text",
352 json!({ "text": text }),
353 ));
354 }
355 }
356 Frame::FunctionCallsStarted(calls) => {
357 for call in calls {
358 self.emit_function_call_started(call);
359 }
360 }
361 Frame::FunctionCallInProgress { call, .. } => {
362 self.emit_function_call_in_progress(call);
363 }
364 Frame::FunctionCallResult(result) => {
365 self.emit_function_call_stopped(
366 &result.function_name,
367 &result.tool_call_id,
368 false,
369 Some(&result.result),
370 );
371 }
372 Frame::FunctionCallCancel {
373 function_name,
374 tool_call_id,
375 } => {
376 self.emit_function_call_stopped(function_name, tool_call_id, true, None);
377 }
378 Frame::Metrics(data) if self.params.metrics_enabled => {
379 if let Some(msg) = map_metrics(data) {
380 self.emit(msg);
381 }
382 }
383 _ => {}
384 }
385 }
386
387 fn flush_bot_transcription_on_sentence(&self, text: &str) {
390 let flush = {
391 let Ok(mut st) = self.state.lock() else {
392 return;
393 };
394 st.bot_transcription.push_str(text);
395 if ends_sentence(&st.bot_transcription) && !st.bot_transcription.is_empty() {
396 Some(std::mem::take(&mut st.bot_transcription))
397 } else {
398 None
399 }
400 };
401 if let Some(sentence) = flush {
402 self.emit(RtviMessage::with_data(
403 "bot-transcription",
404 json!({ "text": sentence }),
405 ));
406 }
407 }
408
409 fn emit_function_call_started(&self, call: &FunctionCall) {
410 let level = self.params.function_call_report_level;
411 if level == FunctionCallReportLevel::Disabled {
412 return;
413 }
414 let mut data = Map::new();
415 if level.shows_name() {
416 data.insert("function_name".into(), json!(call.function_name));
417 }
418 self.emit(RtviMessage::with_data(
419 "llm-function-call-started",
420 Value::Object(data),
421 ));
422 }
423
424 fn emit_function_call_in_progress(&self, call: &FunctionCall) {
425 let level = self.params.function_call_report_level;
426 if level == FunctionCallReportLevel::Disabled {
427 return;
428 }
429 let mut data = Map::new();
430 data.insert("tool_call_id".into(), json!(call.tool_call_id));
431 if level.shows_name() {
432 data.insert("function_name".into(), json!(call.function_name));
433 }
434 if level == FunctionCallReportLevel::Full {
435 data.insert("arguments".into(), call.arguments.clone());
436 }
437 self.emit(RtviMessage::with_data(
438 "llm-function-call-in-progress",
439 Value::Object(data),
440 ));
441 }
442
443 fn emit_function_call_stopped(
444 &self,
445 function_name: &str,
446 tool_call_id: &str,
447 cancelled: bool,
448 result: Option<&Value>,
449 ) {
450 let level = self.params.function_call_report_level;
451 if level == FunctionCallReportLevel::Disabled {
452 return;
453 }
454 let mut data = Map::new();
455 data.insert("tool_call_id".into(), json!(tool_call_id));
456 data.insert("cancelled".into(), json!(cancelled));
457 if level.shows_name() {
458 data.insert("function_name".into(), json!(function_name));
459 }
460 if level == FunctionCallReportLevel::Full {
461 if let Some(r) = result {
462 data.insert("result".into(), r.clone());
463 }
464 }
465 self.emit(RtviMessage::with_data(
466 "llm-function-call-stopped",
467 Value::Object(data),
468 ));
469 }
470}
471
472fn ends_sentence(text: &str) -> bool {
475 text.trim_end()
476 .chars()
477 .next_back()
478 .is_some_and(|c| matches!(c, '.' | '!' | '?' | '。' | '!' | '?'))
479}
480
481fn map_metrics(data: &[MetricsData]) -> Option<RtviMessage> {
486 let mut ttfb: Vec<Value> = Vec::new();
487 let mut processing: Vec<Value> = Vec::new();
488 let mut tokens: Vec<Value> = Vec::new();
489 let mut characters: Vec<Value> = Vec::new();
490
491 for d in data {
492 match d {
493 MetricsData::Ttfb {
494 processor,
495 model,
496 seconds,
497 } => ttfb.push(metric_value(processor, model.as_deref(), *seconds)),
498 MetricsData::Processing {
499 processor,
500 model,
501 seconds,
502 } => processing.push(metric_value(processor, model.as_deref(), *seconds)),
503 MetricsData::LlmUsage { tokens: usage, .. } => {
504 tokens.push(serde_json::to_value(usage).unwrap_or(Value::Null));
505 }
506 MetricsData::TtsUsage {
507 processor,
508 characters: chars,
509 } => characters.push(json!({ "processor": processor, "value": chars })),
510 MetricsData::TurnPrediction { .. } => {}
512 }
513 }
514
515 if ttfb.is_empty() && processing.is_empty() && tokens.is_empty() && characters.is_empty() {
516 return None;
517 }
518
519 let mut map = Map::new();
520 if !ttfb.is_empty() {
521 map.insert("ttfb".into(), Value::Array(ttfb));
522 }
523 if !processing.is_empty() {
524 map.insert("processing".into(), Value::Array(processing));
525 }
526 if !tokens.is_empty() {
527 map.insert("tokens".into(), Value::Array(tokens));
528 }
529 if !characters.is_empty() {
530 map.insert("characters".into(), Value::Array(characters));
531 }
532 Some(RtviMessage::with_data("metrics", Value::Object(map)))
533}
534
535fn metric_value(processor: &str, model: Option<&str>, value: f64) -> Value {
538 let mut m = Map::new();
539 m.insert("processor".into(), json!(processor));
540 if let Some(model) = model {
541 m.insert("model".into(), json!(model));
542 }
543 m.insert("value".into(), json!(value));
544 Value::Object(m)
545}
546
547#[async_trait]
548impl FrameObserver for RtviObserver {
549 async fn on_push(&self, e: &FramePushEvent<'_>) {
550 if e.meta.broadcast_sibling_id.is_some() && e.direction != Direction::Downstream {
552 return;
553 }
554 {
556 let Ok(mut st) = self.state.lock() else {
557 return;
558 };
559 if st.frames_seen.contains(&e.meta.id) {
560 return;
561 }
562 st.frames_seen.push(e.meta.id);
563 }
564 self.map_frame(e.frame);
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use crate::processor::frame::{FrameMeta, FunctionCallResult};
572 use crate::processor::metrics::LlmTokenUsage;
573 use std::sync::Arc;
574
575 fn obs_with(params: RtviObserverParams) -> (RtviObserver, Arc<VecSink>) {
576 let sink = Arc::new(VecSink::new());
577 let obs = RtviObserver::with_params(sink.clone(), params);
578 (obs, sink)
579 }
580
581 fn obs() -> (RtviObserver, Arc<VecSink>) {
582 obs_with(RtviObserverParams::default())
583 }
584
585 async fn push(obs: &RtviObserver, frame: Frame) {
586 let meta = FrameMeta::new(&frame);
587 let e = FramePushEvent {
588 source: "src",
589 destination: "dst",
590 frame: &frame,
591 meta: &meta,
592 direction: Direction::Downstream,
593 timestamp_ns: 0,
594 };
595 obs.on_push(&e).await;
596 }
597
598 #[tokio::test]
599 async fn transcription_splits_user_vs_bot_by_user_id() {
600 let (obs, sink) = obs();
604 push(
605 &obs,
606 Frame::Transcription {
607 text: "hello there".into(),
608 user_id: Arc::from("user"),
609 language: None,
610 final_: true,
611 },
612 )
613 .await;
614 push(
615 &obs,
616 Frame::Transcription {
617 text: "hi, how can I help?".into(),
618 user_id: Arc::from("bot"),
619 language: None,
620 final_: true,
621 },
622 )
623 .await;
624 assert_eq!(
625 sink.kinds(),
626 vec!["user-transcription", "bot-transcription"]
627 );
628 }
629
630 #[tokio::test]
631 async fn speaking_edges_map_to_rtvi() {
632 let (obs, sink) = obs();
633 push(&obs, Frame::UserStartedSpeaking).await;
634 push(&obs, Frame::UserStoppedSpeaking).await;
635 push(&obs, Frame::BotStartedSpeaking).await;
636 push(&obs, Frame::BotStoppedSpeaking).await;
637 assert_eq!(
638 sink.kinds(),
639 vec![
640 "user-started-speaking",
641 "user-stopped-speaking",
642 "bot-started-speaking",
643 "bot-stopped-speaking",
644 ]
645 );
646 }
647
648 #[tokio::test]
649 async fn user_transcription_final_and_interim() {
650 let (obs, sink) = obs();
651 push(
652 &obs,
653 Frame::Transcription {
654 text: "hello world".into(),
655 user_id: Arc::from("u1"),
656 language: None,
657 final_: true,
658 },
659 )
660 .await;
661 push(
662 &obs,
663 Frame::InterimTranscription {
664 text: "hel".into(),
665 user_id: Arc::from("u1"),
666 language: None,
667 },
668 )
669 .await;
670 let msgs = sink.messages();
671 assert_eq!(msgs.len(), 2);
672 assert_eq!(msgs[0].kind, "user-transcription");
673 let d0 = msgs[0].data.as_ref().unwrap();
674 assert_eq!(d0["text"], "hello world");
675 assert_eq!(d0["user_id"], "u1");
676 assert_eq!(d0["final"], true);
677 let d1 = msgs[1].data.as_ref().unwrap();
678 assert_eq!(d1["final"], false);
679 assert_eq!(d1["text"], "hel");
680 }
681
682 #[tokio::test]
683 async fn stt_mute_maps_to_user_mute() {
684 let (obs, sink) = obs();
685 push(&obs, Frame::SttMute(true)).await;
686 push(&obs, Frame::SttMute(false)).await;
687 assert_eq!(sink.kinds(), vec!["user-mute-started", "user-mute-stopped"]);
688 }
689
690 #[tokio::test]
691 async fn llm_lifecycle_and_bot_transcription_on_sentence() {
692 let (obs, sink) = obs();
693 push(&obs, Frame::LlmResponseStart).await;
694 push(&obs, Frame::LlmText("Hello".into())).await;
695 push(&obs, Frame::LlmText(" there.".into())).await;
696 push(&obs, Frame::LlmResponseEnd).await;
697 let kinds = sink.kinds();
698 assert_eq!(
700 kinds,
701 vec![
702 "bot-llm-started",
703 "bot-llm-text",
704 "bot-llm-text",
705 "bot-transcription",
706 "bot-llm-stopped",
707 ]
708 );
709 let bt = sink
711 .messages()
712 .into_iter()
713 .find(|m| m.kind == "bot-transcription")
714 .unwrap();
715 assert_eq!(bt.data.unwrap()["text"], "Hello there.");
716 }
717
718 #[tokio::test]
719 async fn tts_text_emits_bot_output_and_tts_text() {
720 let (obs, sink) = obs();
721 push(
722 &obs,
723 Frame::TtsText {
724 text: "spoken".into(),
725 context_id: None,
726 },
727 )
728 .await;
729 let msgs = sink.messages();
730 assert_eq!(msgs.len(), 2);
731 assert_eq!(msgs[0].kind, "bot-output");
732 assert_eq!(msgs[0].data.as_ref().unwrap()["spoken"], true);
733 assert_eq!(msgs[0].data.as_ref().unwrap()["text"], "spoken");
734 assert_eq!(msgs[1].kind, "bot-tts-text");
735 }
736
737 #[tokio::test]
738 async fn tts_lifecycle_maps() {
739 let (obs, sink) = obs();
740 push(&obs, Frame::TtsStarted { context_id: None }).await;
741 push(&obs, Frame::TtsStopped { context_id: None }).await;
742 assert_eq!(sink.kinds(), vec!["bot-tts-started", "bot-tts-stopped"]);
743 }
744
745 #[tokio::test]
746 async fn function_call_full_level_exposes_name_args_result() {
747 let (obs, sink) = obs(); push(
749 &obs,
750 Frame::FunctionCallsStarted(vec![FunctionCall {
751 function_name: "get_weather".into(),
752 tool_call_id: "call-1".into(),
753 arguments: json!({"city": "SG"}),
754 }]),
755 )
756 .await;
757 push(
758 &obs,
759 Frame::FunctionCallInProgress {
760 call: FunctionCall {
761 function_name: "get_weather".into(),
762 tool_call_id: "call-1".into(),
763 arguments: json!({"city": "SG"}),
764 },
765 cancel_on_interruption: false,
766 },
767 )
768 .await;
769 push(
770 &obs,
771 Frame::FunctionCallResult(FunctionCallResult {
772 function_name: "get_weather".into(),
773 tool_call_id: "call-1".into(),
774 result: json!({"temp": 31}),
775 }),
776 )
777 .await;
778 let msgs = sink.messages();
779 assert_eq!(
780 sink.kinds(),
781 vec![
782 "llm-function-call-started",
783 "llm-function-call-in-progress",
784 "llm-function-call-stopped",
785 ]
786 );
787 assert_eq!(
789 msgs[0].data.as_ref().unwrap()["function_name"],
790 "get_weather"
791 );
792 let ip = msgs[1].data.as_ref().unwrap();
794 assert_eq!(ip["tool_call_id"], "call-1");
795 assert_eq!(ip["arguments"]["city"], "SG");
796 let st = msgs[2].data.as_ref().unwrap();
798 assert_eq!(st["cancelled"], false);
799 assert_eq!(st["result"]["temp"], 31);
800 }
801
802 #[tokio::test]
803 async fn function_call_cancel_maps_to_stopped_cancelled() {
804 let (obs, sink) = obs();
805 push(
806 &obs,
807 Frame::FunctionCallCancel {
808 function_name: "f".into(),
809 tool_call_id: "c1".into(),
810 },
811 )
812 .await;
813 let m = &sink.messages()[0];
814 assert_eq!(m.kind, "llm-function-call-stopped");
815 assert_eq!(m.data.as_ref().unwrap()["cancelled"], true);
816 assert!(m.data.as_ref().unwrap().get("result").is_none());
817 }
818
819 #[tokio::test]
820 async fn function_call_none_level_hides_name_and_args() {
821 let (obs, sink) = obs_with(RtviObserverParams {
822 function_call_report_level: FunctionCallReportLevel::None,
823 ..Default::default()
824 });
825 push(
826 &obs,
827 Frame::FunctionCallInProgress {
828 call: FunctionCall {
829 function_name: "secret".into(),
830 tool_call_id: "c1".into(),
831 arguments: json!({"k": "v"}),
832 },
833 cancel_on_interruption: false,
834 },
835 )
836 .await;
837 let d = sink.messages()[0].data.clone().unwrap();
838 assert_eq!(d["tool_call_id"], "c1");
839 assert!(d.get("function_name").is_none());
840 assert!(d.get("arguments").is_none());
841 }
842
843 #[tokio::test]
844 async fn function_call_disabled_emits_nothing() {
845 let (obs, sink) = obs_with(RtviObserverParams {
846 function_call_report_level: FunctionCallReportLevel::Disabled,
847 ..Default::default()
848 });
849 push(
850 &obs,
851 Frame::FunctionCallsStarted(vec![FunctionCall {
852 function_name: "f".into(),
853 tool_call_id: "c1".into(),
854 arguments: json!({}),
855 }]),
856 )
857 .await;
858 assert!(sink.messages().is_empty());
859 }
860
861 #[tokio::test]
862 async fn metrics_bucket_into_rtvi_metrics_message() {
863 let (obs, sink) = obs();
864 push(
865 &obs,
866 Frame::Metrics(vec![
867 MetricsData::Ttfb {
868 processor: "stt".into(),
869 model: Some("nova-2".into()),
870 seconds: 0.12,
871 },
872 MetricsData::Processing {
873 processor: "llm".into(),
874 model: None,
875 seconds: 0.5,
876 },
877 MetricsData::LlmUsage {
878 processor: "llm".into(),
879 model: Some("gpt-4o".into()),
880 tokens: LlmTokenUsage {
881 prompt_tokens: 10,
882 completion_tokens: 20,
883 total_tokens: 30,
884 ..Default::default()
885 },
886 },
887 MetricsData::TtsUsage {
888 processor: "tts".into(),
889 characters: 42,
890 },
891 MetricsData::TurnPrediction {
893 processor: "turn".into(),
894 is_complete: true,
895 probability: 0.9,
896 e2e_processing_ms: 5.0,
897 },
898 ]),
899 )
900 .await;
901 let msgs = sink.messages();
902 assert_eq!(msgs.len(), 1);
903 assert_eq!(msgs[0].kind, "metrics");
904 let d = msgs[0].data.as_ref().unwrap();
905 assert_eq!(d["ttfb"][0]["processor"], "stt");
906 assert_eq!(d["ttfb"][0]["model"], "nova-2");
907 assert_eq!(d["ttfb"][0]["value"], 0.12);
908 assert!(d["processing"][0].get("model").is_none());
910 assert_eq!(d["processing"][0]["value"], 0.5);
911 assert_eq!(d["tokens"][0]["total_tokens"], 30);
912 assert_eq!(d["characters"][0]["value"], 42);
913 assert!(d.get("turn").is_none());
915 }
916
917 #[tokio::test]
918 async fn metrics_with_only_turn_prediction_emits_nothing() {
919 let (obs, sink) = obs();
920 push(
921 &obs,
922 Frame::Metrics(vec![MetricsData::TurnPrediction {
923 processor: "turn".into(),
924 is_complete: false,
925 probability: 0.1,
926 e2e_processing_ms: 1.0,
927 }]),
928 )
929 .await;
930 assert!(sink.messages().is_empty());
931 }
932
933 #[tokio::test]
934 async fn params_disable_suppresses_family() {
935 let (obs, sink) = obs_with(RtviObserverParams {
936 user_transcription_enabled: false,
937 metrics_enabled: false,
938 ..Default::default()
939 });
940 push(
941 &obs,
942 Frame::Transcription {
943 text: "x".into(),
944 user_id: Arc::from("u"),
945 language: None,
946 final_: true,
947 },
948 )
949 .await;
950 push(
951 &obs,
952 Frame::Metrics(vec![MetricsData::Ttfb {
953 processor: "p".into(),
954 model: None,
955 seconds: 0.1,
956 }]),
957 )
958 .await;
959 assert!(sink.messages().is_empty());
960 }
961
962 #[tokio::test]
963 async fn dedup_by_frame_id_maps_once() {
964 let (obs, sink) = obs();
965 let frame = Frame::UserStartedSpeaking;
966 let meta = FrameMeta::new(&frame);
967 for dst in ["a", "b"] {
969 let e = FramePushEvent {
970 source: "src",
971 destination: dst,
972 frame: &frame,
973 meta: &meta,
974 direction: Direction::Downstream,
975 timestamp_ns: 0,
976 };
977 obs.on_push(&e).await;
978 }
979 assert_eq!(sink.kinds(), vec!["user-started-speaking"]);
980 }
981
982 #[tokio::test]
983 async fn broadcast_upstream_copy_is_skipped() {
984 let (obs, sink) = obs();
985 let frame = Frame::UserStartedSpeaking;
986 let mut meta = FrameMeta::new(&frame);
987 meta.broadcast_sibling_id = Some(999);
988 let e = FramePushEvent {
989 source: "src",
990 destination: "dst",
991 frame: &frame,
992 meta: &meta,
993 direction: Direction::Upstream,
994 timestamp_ns: 0,
995 };
996 obs.on_push(&e).await;
997 assert!(sink.messages().is_empty());
998 }
999
1000 #[test]
1001 fn rtvi_message_serializes_to_pipecat_wire_shape() {
1002 let bare = RtviMessage::bare("user-started-speaking");
1004 let v = serde_json::to_value(&bare).unwrap();
1005 assert_eq!(v["label"], "rtvi-ai");
1006 assert_eq!(v["type"], "user-started-speaking");
1007 assert!(v.get("data").is_none());
1008
1009 let with = RtviMessage::with_data("bot-llm-text", json!({"text": "hi"}));
1011 let v = serde_json::to_value(&with).unwrap();
1012 assert_eq!(v["type"], "bot-llm-text");
1013 assert_eq!(v["data"]["text"], "hi");
1014 }
1015
1016 #[test]
1017 fn ends_sentence_detects_terminal_punctuation() {
1018 assert!(ends_sentence("Done."));
1019 assert!(ends_sentence("Really? "));
1020 assert!(ends_sentence("Wow!"));
1021 assert!(!ends_sentence("Not yet"));
1022 assert!(!ends_sentence(""));
1023 }
1024}