Skip to main content

flowcat_core/observer/
rtvi.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! RTVI-protocol observer (pure frame→message mapping, no network).
4//!
5//! Translates pipeline [`Frame`]s into outgoing **RTVI client-protocol**
6//! messages (user/bot transcription, speaking edges, LLM/TTS lifecycle,
7//! function-call signalling, metrics). Mirrors pipecat's
8//! `processors/frameworks/rtvi/observer.py` (`RTVIObserver`) and the v1 message
9//! shapes in `.../rtvi/models.py`.
10//!
11//! This module is **pure**: it maps a frame to an [`RtviMessage`] and hands it to
12//! an injectable [`RtviSink`]. The actual transport (WebSocket/Daily/etc.) lives
13//! in the embedder; tests use the in-memory [`VecSink`]. No `reqwest`/network dep
14//! is pulled into flowcat-core (the networked OTel/Sentry/Langfuse exporters live
15//! in `flowcat-services` instead).
16//!
17//! ## What is mapped (parity with pipecat `RTVIObserver.on_push_frame`)
18//!
19//! | Frame | RTVI message `type` |
20//! |---|---|
21//! | [`Frame::UserStartedSpeaking`] | `user-started-speaking` |
22//! | [`Frame::UserStoppedSpeaking`] | `user-stopped-speaking` |
23//! | [`Frame::BotStartedSpeaking`] | `bot-started-speaking` |
24//! | [`Frame::BotStoppedSpeaking`] | `bot-stopped-speaking` |
25//! | [`Frame::SttMute`] | `user-mute-started` / `user-mute-stopped` |
26//! | [`Frame::Transcription`] (`user_id != "bot"`) | `user-transcription` (`final: true`) |
27//! | [`Frame::Transcription`] (`user_id == "bot"`, realtime s2s) | `bot-transcription` |
28//! | [`Frame::InterimTranscription`] (`user_id != "bot"`) | `user-transcription` (`final: false`) |
29//! | [`Frame::LlmResponseStart`] | `bot-llm-started` |
30//! | [`Frame::LlmResponseEnd`] | `bot-llm-stopped` |
31//! | [`Frame::LlmText`] | `bot-llm-text` (+ `bot-transcription` on sentence end) |
32//! | [`Frame::TtsStarted`] | `bot-tts-started` |
33//! | [`Frame::TtsStopped`] | `bot-tts-stopped` |
34//! | [`Frame::TtsText`] | `bot-tts-text` + `bot-output` (`spoken: true`) |
35//! | [`Frame::FunctionCallsStarted`] | `llm-function-call-started` (per call) |
36//! | [`Frame::FunctionCallInProgress`] | `llm-function-call-in-progress` |
37//! | [`Frame::FunctionCallResult`] | `llm-function-call-stopped` (`cancelled: false`) |
38//! | [`Frame::FunctionCallCancel`] | `llm-function-call-stopped` (`cancelled: true`) |
39//! | [`Frame::Metrics`] | `metrics` (bucketed ttfb/processing/tokens/characters) |
40
41use 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
51/// RTVI protocol version this observer emits (mirrors pipecat
52/// `models.PROTOCOL_VERSION`).
53pub const PROTOCOL_VERSION: &str = "1.3.0";
54
55/// The constant `label` on every RTVI message (pipecat `MESSAGE_LABEL`).
56pub const MESSAGE_LABEL: &str = "rtvi-ai";
57
58/// One outgoing RTVI server→client message. Serializes to the exact pipecat v1
59/// wire shape: `{ "label": "rtvi-ai", "type": "<type>", "data"?: { … } }`.
60///
61/// `data` is omitted entirely (not `null`) for payload-free messages, matching
62/// pipecat's `Message` model where `data` defaults to `None` and the observer
63/// dumps with `exclude_none=True`.
64#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct RtviMessage {
66    /// Always [`MESSAGE_LABEL`].
67    pub label: &'static str,
68    /// The RTVI message type literal, e.g. `"user-transcription"`.
69    #[serde(rename = "type")]
70    pub kind: &'static str,
71    /// Message payload; omitted when absent.
72    #[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    /// The RTVI message `type` (e.g. `"metrics"`), for tests/inspection.
94    pub fn kind(&self) -> &'static str {
95        self.kind
96    }
97}
98
99/// Where the [`RtviObserver`] hands finished messages. Decouples the pure
100/// frame→message mapping from any transport — the live transport (WS/Daily) in
101/// the embedder implements this; tests use [`VecSink`].
102pub trait RtviSink: Send + Sync {
103    /// Deliver one mapped RTVI message toward the client.
104    fn send(&self, message: RtviMessage);
105}
106
107/// An in-memory [`RtviSink`] that records every message — the test/inspection
108/// sink. Cheap to clone-share via `Arc`.
109#[derive(Debug, Default)]
110pub struct VecSink {
111    messages: Mutex<Vec<RtviMessage>>,
112}
113
114impl VecSink {
115    /// A fresh, empty recording sink.
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// All messages recorded so far, in send order.
121    pub fn messages(&self) -> Vec<RtviMessage> {
122        self.messages.lock().map(|g| g.clone()).unwrap_or_default()
123    }
124
125    /// The recorded message `type`s, in order (handy for assertions).
126    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    /// Drop all recorded messages.
134    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/// How much function-call detail an RTVI message exposes (pipecat
150/// `RTVIFunctionCallReportLevel`). Defaults to [`None`](Self::None) — the most
151/// secure level that still emits the event (tool-call-id only, no name/args).
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum FunctionCallReportLevel {
154    /// Emit no event for this function call.
155    Disabled,
156    /// Event with `tool_call_id` only — no function name, args, or result.
157    #[default]
158    None,
159    /// Add the function name (still no args/result).
160    Name,
161    /// Add function name, arguments, and result.
162    Full,
163}
164
165impl FunctionCallReportLevel {
166    fn shows_name(self) -> bool {
167        matches!(self, Self::Name | Self::Full)
168    }
169}
170
171/// Toggles for which RTVI message families the observer emits. Mirrors pipecat
172/// `RTVIObserverParams`. All default to **on** (the conventional client UX),
173/// except audio levels (off — those need the audio payload + a rate limiter).
174#[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    /// Detail level for function-call events (security gate).
186    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/// Mutable per-call aggregation state, behind one `Mutex` so the observer stays
207/// `Sync` while the `on_push` hook takes `&self`.
208#[derive(Default)]
209struct RtviState {
210    /// Frame ids already mapped (dedup — pipecat `_frames_seen`).
211    frames_seen: Vec<u64>,
212    /// Accumulating bot LLM text for the legacy `bot-transcription` message.
213    bot_transcription: String,
214}
215
216/// Pure frame→RTVI-message observer (PROCESSOR-DESIGN §5.1; pipecat
217/// `RTVIObserver`). Plugs into the pipeline [`Observer`](crate::observer::Observer)
218/// fan-out via [`FrameObserver`]; for each pushed frame it emits zero or more
219/// [`RtviMessage`]s to the injected [`RtviSink`].
220///
221/// Like pipecat, it observes on the **push** hook (`on_push_frame`), processes
222/// only the **downstream** copy of broadcast frames, and de-duplicates by frame
223/// id so a frame seen at multiple links is mapped once.
224pub struct RtviObserver {
225    sink: std::sync::Arc<dyn RtviSink>,
226    params: RtviObserverParams,
227    state: Mutex<RtviState>,
228}
229
230impl RtviObserver {
231    /// Build an observer that maps frames to `sink` with default params.
232    pub fn new(sink: std::sync::Arc<dyn RtviSink>) -> Self {
233        Self::with_params(sink, RtviObserverParams::default())
234    }
235
236    /// Build an observer with explicit [`RtviObserverParams`].
237    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    /// Map one pushed frame to RTVI message(s). Pure (besides the sink + the
250    /// aggregation state). Exposed for direct unit testing without a pipeline.
251    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                // The realtime (s2s) path emits BOTH sides as `Transcription`,
279                // distinguished only by `user_id` ("user" vs "bot"). Map bot text to a
280                // bot message, not a user bubble. Cascaded never sends `user_id=="bot"`
281                // here (its bot text flows via LlmText/TtsText), so this is safe.
282                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                // Bot interims are dropped (only finalized bot text becomes a bubble);
303                // a bot interim must never render as a user bubble.
304                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                // The bot's spoken text — drives both `bot-output` (spoken) and
337                // `bot-tts-text`, gated independently (pipecat
338                // `_send_aggregated_llm_text`).
339                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    /// Legacy `bot-transcription`: accumulate bot LLM text and flush a full
388    /// sentence when one completes (pipecat `_handle_llm_text_frame`).
389    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
472/// Whether `text` ends a sentence (a coarse port of pipecat
473/// `match_endofsentence`): the last non-space char is terminal punctuation.
474fn ends_sentence(text: &str) -> bool {
475    text.trim_end()
476        .chars()
477        .next_back()
478        .is_some_and(|c| matches!(c, '.' | '!' | '?' | '。' | '!' | '?'))
479}
480
481/// Bucket a `Frame::Metrics` payload into the RTVI `metrics` message shape
482/// (pipecat `_handle_metrics`): `{ ttfb: [...], processing: [...], tokens: [...],
483/// characters: [...] }`, each value mirroring pipecat's `model_dump`. Returns
484/// `None` for an all-empty/unmapped batch (turn-prediction has no RTVI bucket).
485fn 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            // Turn-prediction has no RTVI metrics bucket in pipecat — skip.
511            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
535/// `{ processor, model?, value }` — pipecat's TTFB/Processing `model_dump`
536/// (`value` is the seconds figure; `model` omitted when absent).
537fn 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        // For broadcast frames, only the downstream copy is mapped (avoid dupes).
551        if e.meta.broadcast_sibling_id.is_some() && e.direction != Direction::Downstream {
552            return;
553        }
554        // Dedup by frame id (a frame seen at multiple links maps once).
555        {
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        // Realtime s2s emits both sides as Frame::Transcription differing only by
601        // user_id. user → user-transcription (user bubble); bot → bot-transcription
602        // (bot bubble), NOT another user bubble.
603        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        // start, llm-text, llm-text + bot-transcription (sentence flush), stop
699        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        // The accumulated bot-transcription text is the full sentence.
710        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(); // default = Full
748        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        // started carries the name at Full.
788        assert_eq!(
789            msgs[0].data.as_ref().unwrap()["function_name"],
790            "get_weather"
791        );
792        // in-progress carries tool_call_id + name + arguments.
793        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        // stopped (not cancelled) carries result.
797        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                // Turn-prediction has no RTVI bucket and must be dropped.
892                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        // processing with no model omits the model key.
909        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        // No turn bucket.
914        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        // Same frame id pushed at two links → mapped once.
968        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        // Bare message: data omitted entirely.
1003        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        // Data message keeps the payload under `data`.
1010        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}