Skip to main content

flowcat_core/pipeline/
s2s.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! The live Gemini-Live **S2S path as a processor pipeline** — the realtime call
4//! orchestration (PROCESSOR-DESIGN §7). Assembled by [`build_s2s_task`].
5//!
6//! Each of the four trait seams becomes a [`FrameProcessor`]:
7//!
8//! ```text
9//!   [transport pump] → TransportInput → RealtimeServiceProcessor → BrainProcessor → TransportOutput
10//!                                              ▲                        │
11//!                                        (tool results          (RecorderProcessor taps both legs)
12//!                                         upstream)              (TranscriptProcessor taps text)
13//!                                                                (FinalizeProcessor on End)
14//! ```
15//!
16//! Every conversation event maps to a `process_frame` (PROCESSOR-DESIGN §7.1):
17//! - carrier audio in → [`TransportInput`] emits `InputAudio` → the realtime
18//!   service `send_audio`s it (after carrier→16k resample);
19//! - `RealtimeEvent::AudioOut` → the realtime service emits `OutputAudio`(24k) →
20//!   [`TransportOutput`] resamples 24k→carrier + plays;
21//! - a workflow (MCP) `ToolCall` → [`BrainProcessor`] relays via [`ToolRelay`] and
22//!   pushes a `FunctionCallResult` straight back upstream (no transition);
23//! - a transition/end `ToolCall` → [`BrainProcessor`] pushes `UpdateSettings`
24//!   (new prompt+tools → `update_system`) / an upstream `End`;
25//! - `RealtimeEvent::Interrupted` → an `Interruption` broadcast → `TransportOutput`
26//!   clears;
27//! - `Usage` → folded into the shared [`LiveState`];
28//! - terminal `End` → [`FinalizeProcessor`] runs the artifact-upload + `complete`
29//!   (the `LiveState`/`finalize` logic).
30//!
31//! **Generic over the flowcat-core seams** — `T: MediaTransport`, `R: RealtimeLlm`,
32//! `B: AgentBrain`, `S: SessionSource` — so flowcat-core stays embedder-agnostic.
33//! The host wires the concrete brain/session implementations in.
34//!
35//! ## A note on the transport *pump* (the codified source-emit pattern)
36//!
37//! A pure *source* processor cannot self-emit frames from the framework's frozen
38//! [`FrameProcessor::start`] hook (it receives no [`Link`]). Per the
39//! source-emit ruling (PROCESSOR-DESIGN §10 Q6), the framework **codifies the
40//! external-pump pattern** as the standard via the [`SourcePump`] helper rather than
41//! growing the just-frozen trait: the transport's `recv()` loop runs in a reader task
42//! that `emit`s `InputAudio`/`StreamStart`/`Stop` into the pipeline **head** — exactly
43//! pipecat's `BaseInputTransport` reader-task model, hosted beside the
44//! [`PipelineTask`]. Feeding the head (not a mid-chain `push`) is what preserves the
45//! Start→ready handshake: queued frames can't reach any `process_frame` before every
46//! processor's `start()` ran. [`TransportInput`] is then the head marker stage the
47//! pump feeds. Every *downstream* seam (realtime/brain/output) reacts in
48//! `process_frame` (where the `Link` exists) and lazily spawns its own reader task on
49//! first frame — the pattern §6.1 describes.
50
51use std::sync::atomic::{AtomicBool, Ordering};
52use std::sync::{Arc, Mutex};
53use std::time::Duration;
54
55use async_trait::async_trait;
56use serde_json::json;
57use tokio::time::Instant;
58
59use crate::audio::AudioRecorder;
60use crate::brain::AgentBrain;
61use crate::codec::Resampler;
62use crate::error::{FlowcatError, Result};
63use crate::processor::frame::{AudioFrame, Frame, FrameClass, StartParams};
64use crate::processor::{Envelope, FrameProcessor, Link, ProcessorSetup, StopReason};
65use crate::realtime::{RealtimeKickoff, RealtimeLlm};
66use crate::session::SessionSource;
67use crate::transcript::Transcript;
68use crate::transport::{MediaIn, MediaTransport};
69use crate::types::{AudioChunk, BrainAction, Finalize, RealtimeEvent, ToolDecl, Usage};
70
71use super::{Pipeline, PipelineTask, PipelineTaskParams, SourceHandle, SourcePump};
72
73/// Gemini Live's output sample rate (PCM). Bot audio arrives at this rate
74/// (mirrors `call.rs::GEMINI_OUTPUT_RATE`). The *input* rate is no longer a constant
75/// here — it comes from `RealtimeLlm::input_sample_rate()` (16 kHz Gemini / 24 kHz
76/// OpenAI) so the model's required rate drives both the session config and the
77/// resampler.
78const GEMINI_OUTPUT_RATE: u32 = 24_000;
79
80// ===========================================================================
81// Custom frames — the S2S-specific signals that ride the Frame::Custom hatch.
82//
83// These are not in the v1 named-variant set (PROCESSOR-DESIGN §1.2 keeps the
84// long tail in Custom). The S2S path needs to
85// carry a *tool call from the realtime model* downstream to the brain and the
86// brain's *tool result* back upstream to the realtime service; we model both with
87// Custom frames so flowcat-core gains no new named variant for this private path.
88// ===========================================================================
89
90/// A tool/function call the realtime model emitted (model → brain, downstream).
91/// Carried in [`Frame::Custom`]. The [`BrainProcessor`] consumes it.
92///
93/// `pub(crate)` (constructor + fields) so the cascaded task assembler
94/// ([`super::cascaded_task`]) can feed the *cascaded LLM's* function-calls to the
95/// **same** [`BrainProcessor`] via this frame — the integration that lets the
96/// cascaded path reuse the realtime path's brain/transition wiring verbatim. The
97/// realtime path is unchanged.
98#[derive(Debug, Clone)]
99pub(crate) struct ModelToolCall {
100    pub(crate) id: String,
101    pub(crate) name: String,
102    pub(crate) args: serde_json::Value,
103}
104
105impl crate::processor::frame::CustomFrame for ModelToolCall {
106    fn frame_class(&self) -> FrameClass {
107        // System: tool calls are control signals that must reach the brain even
108        // through a backlog (mirrors `RealtimeEvent::ToolCall` jumping the select).
109        FrameClass::System
110    }
111    fn name(&self) -> &'static str {
112        "ModelToolCall"
113    }
114    fn as_any(&self) -> &dyn std::any::Any {
115        self
116    }
117}
118
119/// A tool result the brain produced (brain → realtime service, upstream). Carried
120/// in [`Frame::Custom`]. The [`RealtimeServiceProcessor`] consumes it and calls
121/// `send_tool_result`. Uninterruptible (a produced result must always be delivered).
122///
123/// `pub(crate)` so the cascaded task ([`super::cascaded_task`]) can consume the
124/// **same** brain output upstream of the cascaded LLM (feeding an MCP result back
125/// into the rolling LLM context). The realtime path is unchanged.
126#[derive(Debug, Clone)]
127pub(crate) struct ToolResult {
128    pub(crate) id: String,
129    pub(crate) result: serde_json::Value,
130}
131
132impl crate::processor::frame::CustomFrame for ToolResult {
133    fn frame_class(&self) -> FrameClass {
134        FrameClass::System
135    }
136    fn uninterruptible(&self) -> bool {
137        true
138    }
139    fn name(&self) -> &'static str {
140        "ToolResult"
141    }
142    fn as_any(&self) -> &dyn std::any::Any {
143        self
144    }
145}
146
147/// A re-prompt instruction the brain produced on a transition (brain → realtime
148/// service, upstream). Carried in [`Frame::Custom`]. The
149/// [`RealtimeServiceProcessor`] consumes it and calls `update_system`.
150///
151/// `pub(crate)` so the cascaded task ([`super::cascaded_task`]) can consume the
152/// **same** transition re-prompt upstream of the cascaded LLM (swapping the rolling
153/// context's system prompt + tools, then re-running the LLM). Realtime is unchanged.
154#[derive(Debug, Clone)]
155pub(crate) struct Reprompt {
156    pub(crate) prompt: String,
157    pub(crate) tools: Vec<ToolDecl>,
158}
159
160impl crate::processor::frame::CustomFrame for Reprompt {
161    fn frame_class(&self) -> FrameClass {
162        FrameClass::System
163    }
164    fn uninterruptible(&self) -> bool {
165        true
166    }
167    fn name(&self) -> &'static str {
168        "Reprompt"
169    }
170    fn as_any(&self) -> &dyn std::any::Any {
171        self
172    }
173}
174
175// ===========================================================================
176// Shared live-call state — the faithful analogue of call.rs::LiveState.
177//
178// In `Call::run` one `LiveState` is owned by the single select! task. In the
179// processor model the recorder/transcript/usage taps + finalize live in different
180// processor tasks, so the state is shared behind an Arc<Mutex<…>> — the same data,
181// accumulated by the same code paths, consumed by FinalizeProcessor on End.
182// ===========================================================================
183
184/// Per-call accumulated state, shared across the recorder/transcript/finalize
185/// processors (recorder, transcript, usage accumulation, disposition +
186/// collected_vars fold).
187///
188/// `pub(crate)` so the cascaded task ([`super::cascaded_task`]) reuses the **same**
189/// shared-state type + recorder/transcript/finalize processors (no parallel impl).
190pub(crate) struct LiveState {
191    pub(crate) recorder: AudioRecorder,
192    pub(crate) transcript: Transcript,
193    pub(crate) disposition: Option<String>,
194    pub(crate) collected_vars: serde_json::Value,
195    usage: Usage,
196    /// Count of REAL workflow tool calls relayed this call (not transitions) —
197    /// reported as `usage_metrics.tool_calls` for the Run-detail "Tool Calls" metric.
198    pub(crate) tool_calls: u64,
199    error: Option<FlowcatError>,
200    /// Set once a terminal transport error is seen (peer gone). De-dupes the log
201    /// (warn once, not per buffered frame) and lets the sink stop sending / end the call.
202    pub(crate) transport_dead: bool,
203}
204
205impl LiveState {
206    pub(crate) fn new(carrier_rate: u32) -> Self {
207        Self {
208            recorder: AudioRecorder::new(carrier_rate),
209            transcript: Transcript::new(),
210            disposition: None,
211            collected_vars: serde_json::Value::Null,
212            usage: Usage::default(),
213            tool_calls: 0,
214            error: None,
215            transport_dead: false,
216        }
217    }
218
219    pub(crate) fn record_error(&mut self, e: FlowcatError) {
220        // A terminal transport error repeats on every buffered frame — log it once.
221        let is_transport = matches!(e, FlowcatError::Transport(_));
222        if is_transport && self.transport_dead {
223            return;
224        }
225        tracing::warn!(error = %e, "live-phase error (s2s pipeline)");
226        if is_transport {
227            self.transport_dead = true;
228        }
229        if self.error.is_none() {
230            self.error = Some(e);
231        }
232    }
233
234    /// Fold one provider [`Usage`] report into the running totals.
235    fn accumulate_usage(&mut self, u: &Usage) {
236        self.usage.input_tokens = add_opt(self.usage.input_tokens, u.input_tokens);
237        self.usage.output_tokens = add_opt(self.usage.output_tokens, u.output_tokens);
238        self.usage.total_tokens = add_opt(self.usage.total_tokens, u.total_tokens);
239        if u.extra.is_some() {
240            self.usage.extra = u.extra.clone();
241        }
242    }
243
244    /// Serialize the accumulated provider [`Usage`] plus the call's wall-clock
245    /// `duration_seconds` (from the recorder timeline). The control plane reads
246    /// `usage_metrics.duration_seconds` for the Interactions duration column and
247    /// the duration-derived cost/cost-per-minute, so it must ride in this object.
248    fn usage_json(&self) -> serde_json::Value {
249        let mut v = serde_json::to_value(&self.usage).unwrap_or(serde_json::Value::Null);
250        if let serde_json::Value::Object(ref mut map) = v {
251            map.entry("duration_seconds".to_string())
252                .or_insert_with(|| serde_json::json!(self.recorder.duration_seconds()));
253            // Run-detail observability counts (the control plane reads these off
254            // usage_metrics for "User/Bot Turns" + "Tool Calls").
255            map.entry("user_turns".to_string())
256                .or_insert_with(|| serde_json::json!(self.transcript.user_turns()));
257            map.entry("bot_turns".to_string())
258                .or_insert_with(|| serde_json::json!(self.transcript.bot_turns()));
259            map.entry("tool_calls".to_string())
260                .or_insert_with(|| serde_json::json!(self.tool_calls));
261        }
262        v
263    }
264}
265
266/// Sum two optional counts, treating `None` as "not reported" (so `None + x = x`).
267fn add_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
268    match (a, b) {
269        (Some(a), Some(b)) => Some(a + b),
270        (Some(a), None) => Some(a),
271        (None, b) => b,
272    }
273}
274
275/// Fold an optional `disposition` into the brain's collected-vars JSON.
276/// `pub(crate)` for the cascaded finalize reuse.
277pub(crate) fn merge_disposition(
278    vars: serde_json::Value,
279    disposition: Option<String>,
280) -> serde_json::Value {
281    match (vars, disposition) {
282        (serde_json::Value::Object(mut map), Some(d)) => {
283            // Key matches the control plane's DISPOSITION_VAR ("call_disposition")
284            // — the embedder reads `collected_vars->>'call_disposition'` to record
285            // the call's disposition.
286            map.entry("call_disposition".to_string())
287                .or_insert(serde_json::Value::String(d));
288            serde_json::Value::Object(map)
289        }
290        (other, _) => other,
291    }
292}
293
294pub(crate) type SharedState = Arc<Mutex<LiveState>>;
295
296// ===========================================================================
297// TransportInput — the head marker stage the transport pump feeds.
298// ===========================================================================
299
300/// The head/source-side stage (PROCESSOR-DESIGN §7.1). The transport's `recv()`
301/// loop is driven by the external [`spawn_transport_pump`] (see the module note);
302/// this processor is the named head the pump injects into and forwards from. It is
303/// a thin pass-through so `InputAudio`/lifecycle flows on to the realtime service.
304pub struct TransportInput {
305    name: &'static str,
306}
307
308impl TransportInput {
309    pub(crate) fn new() -> Self {
310        Self {
311            name: "TransportInput",
312        }
313    }
314}
315
316#[async_trait]
317impl FrameProcessor for TransportInput {
318    fn name(&self) -> &str {
319        self.name
320    }
321    // Default `process_frame` forwards — the pump feeds InputAudio/StreamStart/Stop
322    // at the head and they pass straight through to the realtime service.
323}
324
325/// A [`MediaTransport`] shared (behind an async mutex) between the pump's `recv`
326/// loop and [`TransportOutput`]'s `send_audio`/`send_clear`. In `Call::run` the
327/// single `select!` task owns the whole transport and interleaves recv/send on it;
328/// the processor split needs both a reader and a writer task, so the one transport
329/// object is shared here. `recv` mostly awaits new media, releasing the lock so a
330/// concurrent `send_audio` is never starved.
331pub(crate) struct SharedTransport<T: MediaTransport>(Arc<tokio::sync::Mutex<T>>);
332
333impl<T: MediaTransport> Clone for SharedTransport<T> {
334    fn clone(&self) -> Self {
335        SharedTransport(self.0.clone())
336    }
337}
338
339impl<T: MediaTransport> SharedTransport<T> {
340    pub(crate) fn new(t: T) -> Self {
341        SharedTransport(Arc::new(tokio::sync::Mutex::new(t)))
342    }
343}
344
345#[async_trait]
346impl<T: MediaTransport> MediaTransport for SharedTransport<T> {
347    async fn recv(&mut self) -> Option<MediaIn> {
348        self.0.lock().await.recv().await
349    }
350    async fn send_audio(&mut self, chunk: AudioChunk) -> std::result::Result<(), FlowcatError> {
351        self.0.lock().await.send_audio(chunk).await
352    }
353    async fn send_clear(&mut self) -> std::result::Result<(), FlowcatError> {
354        self.0.lock().await.send_clear().await
355    }
356    fn carrier_rate(&self) -> u32 {
357        // carrier_rate is a const property; read it once up front instead (the
358        // assembler captures it before sharing). This path is not on the hot loop.
359        // We cannot `.await` here, so callers must not rely on it post-share — the
360        // assembler passes the rate explicitly to each processor instead.
361        0
362    }
363}
364
365/// Spawn the transport's `recv()` pump via the standard [`SourcePump`] helper (the
366/// codified source-emit pattern, PROCESSOR-DESIGN §10 Q6): drive
367/// [`MediaTransport::recv`] and `emit` each event into the pipeline head, mirroring
368/// `call.rs`'s carrier→model select arm. Emits a `ClientConnected` kickoff-gate
369/// marker on stream start (the realtime service kicks off on it), `InputAudio` per
370/// audio chunk, and a terminal `End` on `Stop`/exhaustion. The returned
371/// [`SourcePump`] aborts the reader on drop.
372pub(crate) fn spawn_transport_pump<T: MediaTransport + 'static>(
373    mut transport: SharedTransport<T>,
374    head: tokio::sync::mpsc::UnboundedSender<Frame>,
375) -> SourcePump {
376    SourcePump::spawn(head, move |h: SourceHandle| async move {
377        loop {
378            match transport.recv().await {
379                Some(MediaIn::StreamStart { call_id }) => {
380                    tracing::debug!(call_id, "media stream started (s2s pipeline)");
381                    // ClientConnected = the kickoff gate (the realtime service's
382                    // transport_started analogue). System frame, jumps the queue.
383                    let _ = h.emit(Frame::ClientConnected);
384                }
385                Some(MediaIn::Audio(chunk)) => {
386                    let frame = Frame::InputAudio(Arc::new(AudioFrame::from(&chunk)));
387                    if h.emit(frame).is_err() {
388                        break; // pipeline gone
389                    }
390                }
391                // Carrier stop or transport exhausted → drain the pipeline.
392                Some(MediaIn::Stop) | None => {
393                    h.end();
394                    break;
395                }
396            }
397        }
398    })
399}
400
401// ===========================================================================
402// RealtimeServiceProcessor — wraps a RealtimeLlm.
403// ===========================================================================
404
405/// Wraps a [`RealtimeLlm`] (the existing Gemini client through the trait — never a
406/// rewrite). On first `InputAudio` (after the stream-start kickoff gate) it lazily
407/// spawns the `next_event` reader task (the existing reader-task→mpsc bridge made a
408/// processor-internal task, PROCESSOR-DESIGN §6.1) which emits downstream
409/// `OutputAudio`/transcription/tool-call/`Interruption`/usage frames and a terminal
410/// `End` on `Closed`. It feeds each `InputAudio` to `send_audio`; consumes the
411/// brain's upstream [`ToolResult`]→`send_tool_result` and [`Reprompt`]→`update_system`.
412pub struct RealtimeServiceProcessor<R: RealtimeLlm + RealtimeKickoff> {
413    /// The realtime session, shared with the lazily-spawned reader task.
414    realtime: Arc<tokio::sync::Mutex<R>>,
415    /// Carrier→model-input resampler (one per call, preserves filter state — mirrors
416    /// `call.rs`'s long-lived `in_resampler`). Targets [`input_rate`](Self::input_rate).
417    in_resampler: Option<Resampler>,
418    carrier_rate: u32,
419    /// The model's required input sample rate (Hz) — the resampler's target (16 kHz
420    /// for Gemini, 24 kHz for OpenAI Realtime; from `RealtimeLlm::input_sample_rate`).
421    input_rate: u32,
422    /// Whether the carrier stream has started (the kickoff gate).
423    transport_started: bool,
424    /// Whether bot-first kickoff has fired (once per call).
425    kicked_off: bool,
426    /// Whether the reader task has been spawned (once).
427    reader_spawned: bool,
428    /// Shared live state (for error recording).
429    state: SharedState,
430    /// The pipeline-head queue, so a terminal model event (`Closed`/`None`) ends
431    /// the call by injecting `End` at the **head** — Source converts it to a
432    /// downstream drain through the *whole* chain (so Source/TransportInput break
433    /// too, a clean fast teardown). A `push_down(End)` from mid-chain would leave
434    /// the head processors lingering on the grace window (PROCESSOR-DESIGN §4.1).
435    end_tx: tokio::sync::mpsc::UnboundedSender<Frame>,
436    /// Abort handle for the spawned model-event reader task. The reader reconnects
437    /// the realtime session on transient drops (Gemini `1008` aborts), so once
438    /// the call ends it would keep reconnecting in the background until the reconnect
439    /// ceiling — burning provider quota long after teardown. We abort it in
440    /// [`stop`](FrameProcessor::stop) so the reader (and its realtime session) die
441    /// with the call. `None` until the reader is spawned.
442    reader_handle: Option<tokio::task::AbortHandle>,
443}
444
445impl<R: RealtimeLlm + RealtimeKickoff + 'static> RealtimeServiceProcessor<R> {
446    fn new(
447        realtime: R,
448        carrier_rate: u32,
449        input_rate: u32,
450        state: SharedState,
451        end_tx: tokio::sync::mpsc::UnboundedSender<Frame>,
452    ) -> Self {
453        Self {
454            realtime: Arc::new(tokio::sync::Mutex::new(realtime)),
455            in_resampler: None,
456            carrier_rate,
457            input_rate,
458            transport_started: false,
459            kicked_off: false,
460            reader_spawned: false,
461            state,
462            end_tx,
463            reader_handle: None,
464        }
465    }
466
467    /// Spawn the model-event reader: pump `next_event` and emit the matching
468    /// downstream frames, mirroring `call.rs`'s model→carrier select arm. Runs
469    /// until `Closed`/`None`, then pushes a terminal `End` so the pipeline drains.
470    fn spawn_reader(&mut self, link: Link) {
471        if self.reader_spawned {
472            return;
473        }
474        self.reader_spawned = true;
475        let realtime = self.realtime.clone();
476        let end_tx = self.end_tx.clone();
477        let handle = tokio::spawn(async move {
478            // Readiness notify (if the provider supports the lock-free path). With
479            // it, we never hold the session lock across the idle wait between bot
480            // turns — so `send_audio` (caller audio in) is never starved and the
481            // call doesn't deadlock after the greeting. Without it (other
482            // providers / mocks), fall back to the blocking `next_event`.
483            let notify = { realtime.lock().await.event_notify() };
484            // Accumulates streaming user-transcription deltas into one growing
485            // interim line; cleared when the matching `UserText` finalizes it.
486            let mut user_partial = String::new();
487            // Accumulates the bot's response transcript deltas. Emitted as ONE
488            // finalized line at the response boundary (`response.done` → Usage,
489            // barge-in, or stream end) — never per delta — so an interleaved user
490            // partial can't split the bot's sentence into several bubbles, and the
491            // bot line is finalized (the UI's "speaking…" clears) because nothing
492            // else produces `BotStoppedSpeaking` on the realtime path.
493            let mut bot_partial = String::new();
494            // Flush the accumulated bot line (if any) + signal the turn ended.
495            macro_rules! flush_bot {
496                () => {
497                    if !bot_partial.is_empty() {
498                        link.push_down(Frame::Transcription {
499                            text: std::mem::take(&mut bot_partial),
500                            user_id: Arc::from("bot"),
501                            language: None,
502                            final_: true,
503                        })
504                        .await;
505                        link.push_down(Frame::BotStoppedSpeaking).await;
506                    }
507                };
508            }
509            loop {
510                let event = if let Some(notify) = &notify {
511                    loop {
512                        let polled = {
513                            let mut rt = realtime.lock().await;
514                            rt.poll_event().await
515                        };
516                        match polled {
517                            crate::realtime::PollEvent::Ready(ev) => break ev,
518                            // No event ready: await readiness WITHOUT the lock.
519                            crate::realtime::PollEvent::Pending => notify.notified().await,
520                        }
521                    }
522                } else {
523                    let mut rt = realtime.lock().await;
524                    rt.next_event().await
525                };
526                let Some(event) = event else {
527                    // Realtime stream ended → flush any pending bot line, then drain
528                    // via the head (Source breaks too).
529                    flush_bot!();
530                    let _ = end_tx.send(Frame::End { reason: None });
531                    break;
532                };
533                match event {
534                    RealtimeEvent::AudioOut(chunk) => {
535                        // 24k bot audio — TransportOutput resamples + records it.
536                        let af = Arc::new(AudioFrame::from(&chunk));
537                        link.push_down(Frame::OutputAudio(af)).await;
538                    }
539                    RealtimeEvent::UserInterimText(delta) => {
540                        // A streaming partial: grow the interim line and push it as
541                        // a non-final transcription so the UI updates one bubble in
542                        // place (rather than one bubble per word).
543                        user_partial.push_str(&delta);
544                        link.push_down(Frame::InterimTranscription {
545                            text: user_partial.clone(),
546                            user_id: Arc::from("user"),
547                            language: None,
548                        })
549                        .await;
550                    }
551                    RealtimeEvent::UserText(text) => {
552                        // The finalized utterance (provider "completed" event) is
553                        // authoritative — emit it as the committed line and reset
554                        // the interim accumulator for the next turn.
555                        user_partial.clear();
556                        link.push_down(Frame::Transcription {
557                            text,
558                            user_id: Arc::from("user"),
559                            language: None,
560                            final_: true,
561                        })
562                        .await;
563                    }
564                    RealtimeEvent::BotText(text) => {
565                        // Accumulate; the full line is emitted at the response
566                        // boundary (see `flush_bot!`), not per delta.
567                        bot_partial.push_str(&text);
568                    }
569                    RealtimeEvent::ToolCall { id, name, args } => {
570                        link.push_down(Frame::Custom(Arc::new(ModelToolCall { id, name, args })))
571                            .await;
572                    }
573                    RealtimeEvent::Interrupted => {
574                        // Barge-in: the bot was cut off — finalize whatever it had
575                        // said, then broadcast both directions; TransportOutput
576                        // clears the carrier's playback (call.rs's send_clear arm).
577                        flush_bot!();
578                        link.broadcast(Frame::Interruption).await;
579                    }
580                    RealtimeEvent::Usage(u) => {
581                        // `response.done` → the bot turn ended: flush its line first,
582                        // then carry usage downstream as a Custom frame for the
583                        // recorder tap to fold into the shared LiveState (call.rs
584                        // accumulates it on its single LiveState).
585                        flush_bot!();
586                        link.push_down(Frame::Custom(Arc::new(UsageReport(u))))
587                            .await;
588                    }
589                    RealtimeEvent::Closed => {
590                        // Flush any pending bot line, then drain via the head so
591                        // Source/TransportInput break too.
592                        flush_bot!();
593                        let _ = end_tx.send(Frame::End { reason: None });
594                        break;
595                    }
596                }
597            }
598        });
599        // Keep the abort handle so `stop()` can kill the reader at teardown —
600        // otherwise it keeps reconnecting the realtime session after the call ends.
601        self.reader_handle = Some(handle.abort_handle());
602    }
603}
604
605/// A usage report riding the Custom hatch (realtime service → recorder tap).
606/// `pub(crate)` so the cascaded LLM-usage tap can fold usage into the **same**
607/// shared `LiveState` via the reused [`RecorderProcessor`].
608#[derive(Debug, Clone)]
609pub(crate) struct UsageReport(pub(crate) Usage);
610
611impl crate::processor::frame::CustomFrame for UsageReport {
612    fn frame_class(&self) -> FrameClass {
613        FrameClass::Data
614    }
615    fn name(&self) -> &'static str {
616        "UsageReport"
617    }
618    fn as_any(&self) -> &dyn std::any::Any {
619        self
620    }
621}
622
623#[async_trait]
624impl<R: RealtimeLlm + RealtimeKickoff + 'static> FrameProcessor for RealtimeServiceProcessor<R> {
625    fn name(&self) -> &str {
626        "RealtimeService"
627    }
628
629    async fn start(&mut self, _setup: &ProcessorSetup, _params: &StartParams) -> Result<()> {
630        // The realtime session was already `connect`ed by the assembler (mirroring
631        // `Call::run` connecting before its loop). Build the carrier→input-rate
632        // resampler (16k for Gemini, 24k for OpenAI — the model's required input).
633        self.in_resampler = Some(Resampler::new(self.carrier_rate, self.input_rate)?);
634        Ok(())
635    }
636
637    /// Teardown: abort the model-event reader so it stops reconnecting the realtime
638    /// session once the call has ended. Without this the reader (which transparently
639    /// reconnects on Gemini's `1008` aborts) keeps re-opening the session in the
640    /// background until the reconnect ceiling — burning provider quota and a
641    /// connection for minutes after every call. Aborting drops the reader's clone of
642    /// the session `Arc`, so the realtime WS closes with the call.
643    async fn stop(&mut self, _reason: StopReason) -> Result<()> {
644        if let Some(handle) = self.reader_handle.take() {
645            handle.abort();
646        }
647        Ok(())
648    }
649
650    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
651        match &env.frame {
652            // Stream-start gate (call.rs's MediaIn::StreamStart arm): kickoff once
653            // BOTH the transport stream is up and the model is connected. The model
654            // was connected synchronously by the assembler, so this is the gate.
655            Frame::ClientConnected => {
656                self.transport_started = true;
657                // Lazily spawn the model-event reader now we have a Link.
658                self.spawn_reader(link.clone());
659                if self.transport_started && !self.kicked_off {
660                    let mut rt = self.realtime.lock().await;
661                    if let Err(e) = rt.kickoff().await {
662                        drop(rt);
663                        self.state.lock().unwrap().record_error(e);
664                        let _ = self.end_tx.send(Frame::End { reason: None });
665                        return Ok(());
666                    }
667                    self.kicked_off = true;
668                }
669                // Forward downstream (the recorder/etc. ignore it).
670                link.push(env.meta, env.frame, env.direction).await;
671            }
672
673            // Caller audio in (call.rs's MediaIn::Audio arm): record at carrier rate,
674            // resample carrier→16k, push to the model. Forward InputAudio downstream
675            // so the RecorderProcessor taps the inbound leg.
676            Frame::InputAudio(audio) => {
677                // Ensure the reader is running even if audio precedes ClientConnected.
678                self.spawn_reader(link.clone());
679                let chunk = AudioChunk::from(audio.as_ref());
680                let resampler = self
681                    .in_resampler
682                    .as_mut()
683                    .expect("in_resampler set in start()");
684                match resampler.process(&chunk) {
685                    Ok(up) => {
686                        if !up.is_empty() {
687                            let mut rt = self.realtime.lock().await;
688                            if let Err(e) = rt.send_audio(up).await {
689                                drop(rt);
690                                self.state.lock().unwrap().record_error(e);
691                                let _ = self.end_tx.send(Frame::End { reason: None });
692                                return Ok(());
693                            }
694                        }
695                    }
696                    Err(e) => {
697                        self.state.lock().unwrap().record_error(e);
698                        let _ = self.end_tx.send(Frame::End { reason: None });
699                        return Ok(());
700                    }
701                }
702                // Forward downstream for the RecorderProcessor tap.
703                link.push(env.meta, env.frame, env.direction).await;
704            }
705
706            // Upstream tool result from the brain → send_tool_result (call.rs's
707            // realtime.send_tool_result calls).
708            Frame::Custom(c) if c.as_any().is::<ToolResult>() => {
709                let tr = c.as_any().downcast_ref::<ToolResult>().unwrap();
710                let mut rt = self.realtime.lock().await;
711                if let Err(e) = rt.send_tool_result(tr.id.clone(), tr.result.clone()).await {
712                    drop(rt);
713                    self.state.lock().unwrap().record_error(e);
714                }
715            }
716
717            // Upstream re-prompt from the brain → update_system (call.rs's
718            // realtime.update_system call on a transition).
719            Frame::Custom(c) if c.as_any().is::<Reprompt>() => {
720                let rp = c.as_any().downcast_ref::<Reprompt>().unwrap();
721                let mut rt = self.realtime.lock().await;
722                if let Err(e) = rt.update_system(rp.prompt.clone(), rp.tools.clone()).await {
723                    drop(rt);
724                    self.state.lock().unwrap().record_error(e);
725                }
726            }
727
728            _ => {
729                link.push(env.meta, env.frame, env.direction).await;
730            }
731        }
732        Ok(())
733    }
734}
735
736// ===========================================================================
737// BrainProcessor + ToolRelay.
738// ===========================================================================
739
740/// The seam that relays a *workflow* (MCP/HTTP) tool call to the control plane,
741/// abstracting [`SessionSource::tool_call`] + [`SessionSource::node_tools`] so the
742/// [`BrainProcessor`] is generic over the session (PROCESSOR-DESIGN §6.2: the
743/// node-tools/tool-call relay becomes a `ToolRelay` injected into `BrainProcessor`).
744#[async_trait]
745pub trait ToolRelay: Send + Sync {
746    /// The current node's MCP/HTTP workflow tools (degrades to empty on error).
747    async fn node_tools(&self, node_id: &str) -> Vec<ToolDecl>;
748    /// Relay a workflow tool call → the control plane runs the egress; returns the
749    /// content string to feed back to the model verbatim.
750    async fn relay(&self, node_id: &str, tool_name: &str, args: &serde_json::Value) -> String;
751}
752
753/// A [`ToolRelay`] over a [`SessionSource`] + the run id/token — the generic glue
754/// that lets `BrainProcessor` stay session-agnostic (the embedder's concrete
755/// `SessionSource` plugs in here).
756pub struct SessionToolRelay<S: SessionSource> {
757    session: Arc<S>,
758    run_id: i64,
759    token: String,
760}
761
762impl<S: SessionSource> SessionToolRelay<S> {
763    pub(crate) fn new(session: Arc<S>, run_id: i64, token: String) -> Self {
764        Self {
765            session,
766            run_id,
767            token,
768        }
769    }
770}
771
772#[async_trait]
773impl<S: SessionSource> ToolRelay for SessionToolRelay<S> {
774    async fn node_tools(&self, node_id: &str) -> Vec<ToolDecl> {
775        self.session
776            .node_tools(self.run_id, &self.token, node_id)
777            .await
778            .unwrap_or_default()
779    }
780    async fn relay(&self, node_id: &str, tool_name: &str, args: &serde_json::Value) -> String {
781        match self
782            .session
783            .tool_call(self.run_id, &self.token, node_id, tool_name, args)
784            .await
785        {
786            Ok(c) => c,
787            Err(_) => "the tool is temporarily unavailable".to_string(),
788        }
789    }
790}
791
792/// Shape an MCP tool's text result into the JSON **object** Gemini Live requires
793/// for `functionResponses[].response`.
794///
795/// That field is typed as a protobuf `Struct`, so a bare string (or any non-object)
796/// is rejected with `1007 "Invalid value at … response (Struct)"`, which tears down
797/// the call. MCP returns a tool's output as text that is *usually itself JSON* — we
798/// parse it through so the model receives structured data, and wrap any non-object
799/// payload (plain text, a JSON array, a scalar) in `{result: …}` so the response is
800/// always a valid `Struct`.
801fn mcp_result_struct(content: &str) -> serde_json::Value {
802    match serde_json::from_str::<serde_json::Value>(content) {
803        Ok(v @ serde_json::Value::Object(_)) => v,
804        Ok(other) => json!({ "result": other }),
805        Err(_) => json!({ "result": content }),
806    }
807}
808
809/// Holds the graph state and maps a model tool-call to either a **workflow-tool
810/// relay** (emit `FunctionCallResult` straight back upstream, no transition —
811/// mirrors `mcp_tool_call_is_relayed_not_treated_as_transition`) or a
812/// [`BrainAction`] (`Transition`→`Reprompt`+result, `End`→upstream `End`, `Stay`).
813///
814/// Generic over [`AgentBrain`] + [`ToolRelay`]; consumes [`ModelToolCall`] frames,
815/// pushes [`ToolResult`]/[`Reprompt`] upstream to the realtime service. Holds the
816/// MCP-name set for the current node (the `mcp_names` branch set in call.rs).
817pub struct BrainProcessor<B: AgentBrain, Rl: ToolRelay> {
818    brain: B,
819    relay: Arc<Rl>,
820    /// The current node's workflow-tool names (call.rs's `mcp_names`).
821    mcp_names: std::collections::HashSet<String>,
822    /// Shared live state (transcript marker + disposition + collected_vars).
823    state: SharedState,
824    /// The pipeline-head queue, so `BrainAction::End` ends the call by injecting
825    /// `End` at the head (Source → downstream drain through the whole chain). This
826    /// is the "request to end" of PROCESSOR-DESIGN §4.1, routed through the head so
827    /// every processor (incl. Source/TransportInput) breaks for a clean teardown.
828    end_tx: tokio::sync::mpsc::UnboundedSender<Frame>,
829}
830
831impl<B: AgentBrain, Rl: ToolRelay> BrainProcessor<B, Rl> {
832    pub(crate) fn new(
833        brain: B,
834        relay: Arc<Rl>,
835        mcp_names: std::collections::HashSet<String>,
836        state: SharedState,
837        end_tx: tokio::sync::mpsc::UnboundedSender<Frame>,
838    ) -> Self {
839        Self {
840            brain,
841            relay,
842            mcp_names,
843            state,
844            end_tx,
845        }
846    }
847
848    /// Resolve the new node's tool set after a transition (transitions + node MCP),
849    /// refreshing `mcp_names` — verbatim `call.rs::resolve_tools`'s ordering
850    /// (transitions first, then MCP).
851    async fn resolve_tools(&mut self) -> Vec<ToolDecl> {
852        let node_id = self.brain.current_node_id();
853        let mcp = self.relay.node_tools(&node_id).await;
854        self.mcp_names = mcp.iter().map(|t| t.name.clone()).collect();
855        let mut tools = self.brain.tools();
856        tools.extend(mcp);
857        tools
858    }
859}
860
861#[async_trait]
862impl<B: AgentBrain + 'static, Rl: ToolRelay + 'static> FrameProcessor for BrainProcessor<B, Rl> {
863    fn name(&self) -> &str {
864        "Brain"
865    }
866
867    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
868        let Frame::Custom(c) = &env.frame else {
869            link.push(env.meta, env.frame, env.direction).await;
870            return Ok(());
871        };
872        let Some(tc) = c.as_any().downcast_ref::<ModelToolCall>() else {
873            link.push(env.meta, env.frame, env.direction).await;
874            return Ok(());
875        };
876        let id = tc.id.clone();
877        let name = tc.name.clone();
878        let args = tc.args.clone();
879
880        // ---- MCP/HTTP workflow tool (NOT a transition) ----  (call.rs:276)
881        if self.mcp_names.contains(&name) {
882            // A real tool call (not a transition) — tally for usage_metrics.tool_calls.
883            self.state.lock().unwrap().tool_calls += 1;
884            let node_id = self.brain.current_node_id();
885            let content = self.relay.relay(&node_id, &name, &args).await;
886            // Shape the MCP text result into the JSON object the model requires
887            // (see `mcp_result_struct`): a bare string here is rejected by Gemini
888            // Live with `1007 Invalid value at … response (Struct)`, killing the call.
889            link.push_up(Frame::Custom(Arc::new(ToolResult {
890                id,
891                result: mcp_result_struct(&content),
892            })))
893            .await;
894            return Ok(());
895        }
896
897        // ---- Otherwise: a transition / end / stay ----  (call.rs:307)
898        match self.brain.on_tool_call(&name, &args) {
899            BrainAction::Transition {
900                system_prompt, say, ..
901            } => {
902                // Re-resolve the NEW node's tool set; re-prompt; ack {moved}; mark
903                // the transition in the transcript (verbatim call.rs ordering:
904                // update_system, then send_tool_result, then transcript marker).
905                let tools = self.resolve_tools().await;
906                link.push_up(Frame::Custom(Arc::new(Reprompt {
907                    prompt: system_prompt,
908                    tools,
909                })))
910                .await;
911                link.push_up(Frame::Custom(Arc::new(ToolResult {
912                    id,
913                    result: json!({ "status": "moved" }),
914                })))
915                .await;
916                let marker = match &say {
917                    Some(s) if !s.is_empty() => format!("[transition: {name}] {s}"),
918                    _ => format!("[transition: {name}]"),
919                };
920                self.state.lock().unwrap().transcript.push_bot(&marker);
921            }
922            BrainAction::Stay => {
923                link.push_up(Frame::Custom(Arc::new(ToolResult {
924                    id,
925                    result: json!({ "status": "stay" }),
926                })))
927                .await;
928            }
929            BrainAction::End { disposition } => {
930                // Ack {ended}, set disposition, request End (the Source converts the
931                // upstream End to a downstream drain — call.rs breaks the loop here).
932                link.push_up(Frame::Custom(Arc::new(ToolResult {
933                    id,
934                    result: json!({ "status": "ended" }),
935                })))
936                .await;
937                self.state.lock().unwrap().disposition = disposition;
938                // Capture collected vars now (the brain is consumed at end-of-call;
939                // call.rs reads collected_vars after the loop while brain is alive).
940                let vars = self.brain.collected_vars();
941                self.state.lock().unwrap().collected_vars = vars;
942                // Request the pipeline drain via the head (Source converts to a
943                // downstream End through the whole chain — clean fast teardown).
944                let _ = self.end_tx.send(Frame::End { reason: None });
945            }
946        }
947        Ok(())
948    }
949}
950
951// ===========================================================================
952// TransportOutput — wraps a MediaTransport's send_audio/send_clear.
953// ===========================================================================
954
955/// Safety cap on the end-of-call playout drain — a runaway estimate must never
956/// pin teardown open. No single bot utterance approaches this. (Shared with the
957/// cascaded sink, which has the identical end-of-call drain.)
958pub(super) const MAX_PLAYOUT_DRAIN: Duration = Duration::from_secs(15);
959
960/// Advance the "all bot audio sent so far has finished playing at the carrier"
961/// instant by a freshly-sent chunk's real-time duration.
962///
963/// The carrier plays audio in real time while we `send_audio` faster than real
964/// time, so we track when playback will actually catch up. Back-to-back chunks
965/// **queue** (the new chunk starts where the previous one ends); a chunk sent
966/// after a gap (`until` already in the past) starts from `now`. Pure arithmetic
967/// so the [`TransportOutput`] end-of-call drain is unit-testable without timing.
968/// `pub(super)` so the cascaded sink reuses the exact same estimate.
969pub(super) fn advance_playout(
970    until: Option<Instant>,
971    now: Instant,
972    samples: usize,
973    rate: u32,
974) -> Instant {
975    let dur = Duration::from_secs_f64(samples as f64 / rate.max(1) as f64);
976    let base = match until {
977        Some(t) if t > now => t,
978        _ => now,
979    };
980    base + dur
981}
982
983/// The sink-side stage (PROCESSOR-DESIGN §7.1). Consumes `OutputAudio`/`TtsAudio`
984/// → resamples 24k→carrier and `send_audio`s to the carrier; consumes
985/// `Interruption` → `send_clear`. Records the outbound (bot) leg at 24k into the
986/// shared recorder (call.rs records the bot chunk at 24k before the resample).
987pub struct TransportOutput<T: MediaTransport> {
988    /// The carrier transport (shared so the lazily-spawned… not needed — held here).
989    transport: T,
990    /// 24k→carrier resampler (one per call, preserves filter state — mirrors
991    /// `call.rs`'s long-lived `out_resampler`).
992    out_resampler: Option<Resampler>,
993    carrier_rate: u32,
994    /// Wall-clock instant by which all bot audio sent so far will have finished
995    /// playing at the carrier (real-time playout). Advanced on every `send_audio`,
996    /// cleared on barge-in. On `End` we wait out the remaining tail so the final
997    /// utterance (e.g. the goodbye the model emits alongside `endCall`) isn't
998    /// truncated when the carrier WS closes. `None` = nothing pending.
999    bot_audio_until: Option<Instant>,
1000    state: SharedState,
1001}
1002
1003impl<T: MediaTransport> TransportOutput<T> {
1004    fn new(transport: T, carrier_rate: u32, state: SharedState) -> Self {
1005        Self {
1006            transport,
1007            out_resampler: None,
1008            carrier_rate,
1009            bot_audio_until: None,
1010            state,
1011        }
1012    }
1013}
1014
1015#[async_trait]
1016impl<T: MediaTransport + 'static> FrameProcessor for TransportOutput<T> {
1017    fn name(&self) -> &str {
1018        "TransportOutput"
1019    }
1020
1021    async fn start(&mut self, _setup: &ProcessorSetup, _params: &StartParams) -> Result<()> {
1022        self.out_resampler = Some(Resampler::new(GEMINI_OUTPUT_RATE, self.carrier_rate)?);
1023        Ok(())
1024    }
1025
1026    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
1027        match &env.frame {
1028            // Bot audio out (call.rs's RealtimeEvent::AudioOut arm): record at 24k,
1029            // resample 24k→carrier, send to the carrier.
1030            Frame::OutputAudio(audio) | Frame::TtsAudio { audio, .. } => {
1031                let chunk = AudioChunk::from(audio.as_ref());
1032                // Record the bot leg at its source rate (24k) — call.rs does
1033                // `st.recorder.push_outbound(&chunk)` at 24k before resampling.
1034                self.state.lock().unwrap().recorder.push_outbound(&chunk);
1035                let resampler = self
1036                    .out_resampler
1037                    .as_mut()
1038                    .expect("out_resampler set in start()");
1039                match resampler.process(&chunk) {
1040                    Ok(down) => {
1041                        if !down.is_empty() {
1042                            // Track when this chunk finishes playing (carrier plays
1043                            // in real time) so `End` can wait out the tail.
1044                            let samples = down.pcm.len();
1045                            if let Err(e) = self.transport.send_audio(down).await {
1046                                self.state.lock().unwrap().record_error(e);
1047                            } else {
1048                                self.bot_audio_until = Some(advance_playout(
1049                                    self.bot_audio_until,
1050                                    Instant::now(),
1051                                    samples,
1052                                    self.carrier_rate,
1053                                ));
1054                            }
1055                        }
1056                    }
1057                    Err(e) => {
1058                        self.state.lock().unwrap().record_error(e);
1059                    }
1060                }
1061            }
1062            // Barge-in (call.rs's RealtimeEvent::Interrupted arm): flush the carrier.
1063            Frame::Interruption => {
1064                if let Err(e) = self.transport.send_clear().await {
1065                    self.state.lock().unwrap().record_error(e);
1066                }
1067                // The carrier dropped its queued bot audio — nothing left to drain.
1068                self.bot_audio_until = None;
1069                // Forward the interruption in its direction (framework also drains).
1070                link.push(env.meta, env.frame, env.direction).await;
1071            }
1072            // End-of-call: let the final bot utterance finish playing before the
1073            // carrier WS closes. The model emits the goodbye audio AND `endCall` in
1074            // one turn, so by the time `End` reaches us the goodbye is already
1075            // `send_audio`'d but still playing out — tearing down now truncates it
1076            // (cut off mid-"thank you for calling…"). Hold `End` here until the
1077            // real-time tail drains (bounded by `MAX_PLAYOUT_DRAIN`), then forward.
1078            Frame::End { .. } => {
1079                if let Some(until) = self.bot_audio_until.take() {
1080                    let now = Instant::now();
1081                    if until > now {
1082                        tokio::time::sleep((until - now).min(MAX_PLAYOUT_DRAIN)).await;
1083                    }
1084                }
1085                link.push(env.meta, env.frame, env.direction).await;
1086            }
1087            _ => {
1088                link.push(env.meta, env.frame, env.direction).await;
1089            }
1090        }
1091        Ok(())
1092    }
1093}
1094
1095// ===========================================================================
1096// RecorderProcessor — taps both legs into the shared recorder + folds usage.
1097// ===========================================================================
1098
1099/// Taps the inbound (caller) leg into the shared recorder and folds usage reports.
1100/// The outbound (bot) leg is recorded by [`TransportOutput`] (where the 24k chunk
1101/// is in hand), exactly mirroring `call.rs` which records inbound at carrier rate
1102/// and outbound at 24k. A pure observer-style tap: forwards every frame unchanged.
1103pub struct RecorderProcessor {
1104    state: SharedState,
1105}
1106
1107impl RecorderProcessor {
1108    pub(crate) fn new(state: SharedState) -> Self {
1109        Self { state }
1110    }
1111}
1112
1113#[async_trait]
1114impl FrameProcessor for RecorderProcessor {
1115    fn name(&self) -> &str {
1116        "Recorder"
1117    }
1118
1119    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
1120        match &env.frame {
1121            // Inbound caller audio at carrier rate — call.rs's push_inbound.
1122            Frame::InputAudio(audio) => {
1123                let chunk = AudioChunk::from(audio.as_ref());
1124                self.state.lock().unwrap().recorder.push_inbound(&chunk);
1125            }
1126            // Usage report — fold into the shared LiveState (call.rs accumulate_usage).
1127            Frame::Custom(c) if c.as_any().is::<UsageReport>() => {
1128                let u = c.as_any().downcast_ref::<UsageReport>().unwrap();
1129                self.state.lock().unwrap().accumulate_usage(&u.0);
1130            }
1131            _ => {}
1132        }
1133        link.push(env.meta, env.frame, env.direction).await;
1134        Ok(())
1135    }
1136}
1137
1138// ===========================================================================
1139// TranscriptProcessor — taps user/bot transcription into the shared transcript.
1140// ===========================================================================
1141
1142/// Taps `Transcription` frames (user/bot) into the shared transcript, mirroring
1143/// `call.rs`'s `RealtimeEvent::UserText`/`BotText` arms (`transcript.push_user/bot`).
1144/// Forwards every frame unchanged.
1145pub struct TranscriptProcessor {
1146    state: SharedState,
1147}
1148
1149impl TranscriptProcessor {
1150    pub(crate) fn new(state: SharedState) -> Self {
1151        Self { state }
1152    }
1153}
1154
1155#[async_trait]
1156impl FrameProcessor for TranscriptProcessor {
1157    fn name(&self) -> &str {
1158        "Transcript"
1159    }
1160
1161    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
1162        // Realtime path: user/bot transcription frames (user_id distinguishes). The
1163        // cascaded path taps its transcript at the aggregators instead (the user line
1164        // in `UserContextAggregator`, the bot reply in `AssistantContextAggregator`),
1165        // since there the transcription is consumed / the reply rides `LlmText`.
1166        if let Frame::Transcription { text, user_id, .. } = &env.frame {
1167            let mut st = self.state.lock().unwrap();
1168            if user_id.as_ref() == "bot" {
1169                st.transcript.push_bot(text);
1170            } else {
1171                st.transcript.push_user(text);
1172            }
1173        }
1174        link.push(env.meta, env.frame, env.direction).await;
1175        Ok(())
1176    }
1177}
1178
1179// ===========================================================================
1180// FinalizeProcessor — on terminal End: artifact upload + complete.
1181// ===========================================================================
1182
1183/// On terminal `End` (the `stop` hook), runs the `LiveState`/`finalize`
1184/// artifact-upload + `SessionSource::complete` logic verbatim from `call.rs`
1185/// (render recording + transcript, upload via presigned URL, persist the stored
1186/// keys, fold disposition into collected_vars, write the run result). Generic over
1187/// [`SessionSource`].
1188pub struct FinalizeProcessor<S: SessionSource> {
1189    session: Arc<S>,
1190    run_id: i64,
1191    token: String,
1192    state: SharedState,
1193    /// Guard so finalize runs at most once (End may arrive then Stop on teardown).
1194    finalized: Arc<AtomicBool>,
1195}
1196
1197impl<S: SessionSource> FinalizeProcessor<S> {
1198    pub(crate) fn new(session: Arc<S>, run_id: i64, token: String, state: SharedState) -> Self {
1199        Self {
1200            session,
1201            run_id,
1202            token,
1203            state,
1204            finalized: Arc::new(AtomicBool::new(false)),
1205        }
1206    }
1207
1208    /// The finalize body — a faithful port of `call.rs::finalize` (best-effort
1209    /// artifact upload + `complete`). Renders + uploads the recording and
1210    /// transcript, persists the stored keys, and writes the run result.
1211    async fn finalize(&self) {
1212        // Snapshot the shared state under the lock, then drop it for the awaits.
1213        let (recording_bytes, transcript_bytes, usage_json, collected_vars) = {
1214            let mut st = self.state.lock().unwrap();
1215            // Fold the final disposition into collected_vars (call.rs does this
1216            // after the loop). collected_vars was captured at End from the brain.
1217            let vars = merge_disposition(st.collected_vars.clone(), st.disposition.clone());
1218            st.collected_vars = vars.clone();
1219            let recording = st.recorder.render_wav();
1220            let transcript = st.transcript.render();
1221            (recording, transcript, st.usage_json(), vars)
1222        };
1223
1224        let recording_url = match recording_bytes {
1225            Ok(bytes) => self.upload_artifact("recording", bytes, "audio/wav").await,
1226            Err(e) => {
1227                tracing::warn!(error = %e, "render recording wav failed; skipping recording artifact");
1228                None
1229            }
1230        };
1231        let transcript_url = self
1232            .upload_artifact("transcript", transcript_bytes, "application/json")
1233            .await;
1234
1235        let fin = Finalize {
1236            usage: usage_json,
1237            collected_vars,
1238            recording_url,
1239            transcript_url,
1240        };
1241        if let Err(e) = self.session.complete(self.run_id, &self.token, fin).await {
1242            tracing::warn!(error = %e, run_id = self.run_id, "session.complete failed");
1243        }
1244    }
1245
1246    /// Upload one artifact — verbatim `call.rs::upload_artifact` (presigned PUT,
1247    /// persist the stored key, never the secret-bearing URL).
1248    async fn upload_artifact(
1249        &self,
1250        kind: &str,
1251        bytes: Vec<u8>,
1252        content_type: &str,
1253    ) -> Option<String> {
1254        let target = match self
1255            .session
1256            .artifact_upload_url(self.run_id, &self.token, kind)
1257            .await
1258        {
1259            Ok(t) => t,
1260            Err(e) => {
1261                tracing::warn!(error = %e, kind, "artifact_upload_url failed; skipping {kind}");
1262                return None;
1263            }
1264        };
1265        let ct = if target.content_type.is_empty() {
1266            content_type.to_string()
1267        } else {
1268            target.content_type.clone()
1269        };
1270        match self.session.put_bytes(&target.url, bytes, &ct).await {
1271            Ok(()) => Some(target.key),
1272            Err(e) => {
1273                tracing::warn!(error = %e, kind, "put_bytes failed; skipping {kind} url");
1274                None
1275            }
1276        }
1277    }
1278}
1279
1280#[async_trait]
1281impl<S: SessionSource + 'static> FrameProcessor for FinalizeProcessor<S> {
1282    fn name(&self) -> &str {
1283        "Finalize"
1284    }
1285
1286    async fn stop(&mut self, reason: StopReason) -> Result<()> {
1287        // Finalize on graceful End (and a Cancel teardown too — best-effort, once).
1288        if matches!(reason, StopReason::EndOfTask | StopReason::Cancelled)
1289            && !self.finalized.swap(true, Ordering::SeqCst)
1290        {
1291            self.finalize().await;
1292        }
1293        Ok(())
1294    }
1295}
1296
1297// ===========================================================================
1298// The assembler — build the S2S PipelineTask.
1299// ===========================================================================
1300
1301/// A built S2S task plus its transport pump handle (PROCESSOR-DESIGN §7.1).
1302///
1303/// Drive it with `task.run().await`; the pump feeds the transport's `recv()` into
1304/// the pipeline head and aborts when `run()` returns.
1305pub struct S2sTask {
1306    /// The wired pipeline task — run it to completion.
1307    pub task: PipelineTask,
1308    /// The transport-pump source reader (aborted on drop / after `run`).
1309    pump: SourcePump,
1310}
1311
1312impl S2sTask {
1313    /// Run the S2S pipeline to completion, then stop the transport pump.
1314    pub async fn run(self) -> Result<()> {
1315        let res = self.task.run().await;
1316        self.pump.abort();
1317        res
1318    }
1319}
1320
1321/// Assemble the S2S processor pipeline (PROCESSOR-DESIGN §7.1):
1322/// `[TransportInput → RealtimeService → Brain → Recorder → Transcript → TransportOutput → Finalize]`
1323/// plus the transport pump that feeds the head.
1324///
1325/// Connects the realtime model with the brain's opening prompt + tools (the start
1326/// node's transitions + MCP workflow tools), then wires the processor graph.
1327/// Generic over the four seams; the queue handle the pump uses is the task's own.
1328#[allow(clippy::too_many_arguments)]
1329/// Build the realtime (s2s) pipeline task. Delegates to
1330/// [`build_s2s_task_with_observers`] with no observers (the historical signature —
1331/// unchanged for every existing caller + test).
1332pub async fn build_s2s_task<T, R, B, S>(
1333    transport: T,
1334    realtime: R,
1335    brain: B,
1336    session: S,
1337    run_id: i64,
1338    token: String,
1339    model: String,
1340) -> Result<S2sTask>
1341where
1342    T: MediaTransport + 'static,
1343    R: RealtimeLlm + RealtimeKickoff + 'static,
1344    B: AgentBrain + 'static,
1345    S: SessionSource + 'static,
1346{
1347    build_s2s_task_with_observers(
1348        transport,
1349        realtime,
1350        brain,
1351        session,
1352        run_id,
1353        token,
1354        model,
1355        vec![],
1356    )
1357    .await
1358}
1359
1360/// `build_s2s_task` with external pipeline `observers` (e.g. an `RtviObserver` that
1361/// streams live transcript/RTF events). `build_s2s_task` delegates here with no
1362/// observers — non-breaking for every existing caller.
1363#[allow(clippy::too_many_arguments)]
1364pub async fn build_s2s_task_with_observers<T, R, B, S>(
1365    transport: T,
1366    mut realtime: R,
1367    brain: B,
1368    session: S,
1369    run_id: i64,
1370    token: String,
1371    model: String,
1372    observers: Vec<Arc<dyn crate::observer::FrameObserver>>,
1373) -> Result<S2sTask>
1374where
1375    T: MediaTransport + 'static,
1376    R: RealtimeLlm + RealtimeKickoff + 'static,
1377    B: AgentBrain + 'static,
1378    S: SessionSource + 'static,
1379{
1380    let carrier_rate = transport.carrier_rate();
1381    let session = Arc::new(session);
1382    let relay = Arc::new(SessionToolRelay::new(
1383        session.clone(),
1384        run_id,
1385        token.clone(),
1386    ));
1387
1388    // 1. Resolve the start node's tool set (transitions + node MCP tools) and the
1389    //    MCP-name branch set — verbatim call.rs::resolve_tools ordering.
1390    let node_id = brain.current_node_id();
1391    let mcp = relay.node_tools(&node_id).await;
1392    let mcp_names: std::collections::HashSet<String> = mcp.iter().map(|t| t.name.clone()).collect();
1393    let mut initial_tools = brain.tools();
1394    initial_tools.extend(mcp);
1395
1396    // 2. Connect the realtime model with the opening prompt + tools (call.rs's
1397    //    pre-loop connect). On failure, finalize immediately (run is never built).
1398    // The model's required input rate (16k Gemini / 24k OpenAI Realtime). Captured
1399    // before `realtime` is moved into the processor; drives both the session config
1400    // we send the model AND the carrier→input resampler so the two never disagree.
1401    let input_rate = realtime.input_sample_rate();
1402    let setup = crate::types::RealtimeSetup {
1403        model,
1404        system_prompt: brain.system_prompt(),
1405        tools: initial_tools,
1406        input_sample_rate: input_rate,
1407        output_sample_rate: GEMINI_OUTPUT_RATE,
1408    };
1409    if let Err(e) = realtime.connect(setup).await {
1410        // Build a fresh state and finalize (mirrors call.rs's connect-fail branch).
1411        let state: SharedState = Arc::new(Mutex::new(LiveState::new(carrier_rate)));
1412        state.lock().unwrap().record_error(e);
1413        let fin = FinalizeProcessor::new(session, run_id, token, state);
1414        fin.finalize().await;
1415        return Err(FlowcatError::Realtime(
1416            "realtime connect failed; finalized".into(),
1417        ));
1418    }
1419
1420    // 3. The shared live-call state (the LiveState analogue).
1421    let state: SharedState = Arc::new(Mutex::new(LiveState::new(carrier_rate)));
1422
1423    // 4. Share the one transport between the pump (recv) and TransportOutput (send).
1424    let shared = SharedTransport::new(transport);
1425
1426    // 5. The end-request channel. An inner processor (brain End / realtime Closed)
1427    //    requests a clean drain by sending `End` here; a forwarder injects it at the
1428    //    pipeline **head** (Source → downstream End through the whole chain). This is
1429    //    the §4.1 "request to end" routed through the head so every processor breaks.
1430    let (end_tx, mut end_rx) = tokio::sync::mpsc::unbounded_channel::<Frame>();
1431
1432    // 6. Build the processor chain.
1433    let processors: Vec<Box<dyn FrameProcessor>> = vec![
1434        Box::new(TransportInput::new()),
1435        Box::new(RealtimeServiceProcessor::new(
1436            realtime,
1437            carrier_rate,
1438            input_rate,
1439            state.clone(),
1440            end_tx.clone(),
1441        )),
1442        Box::new(BrainProcessor::new(
1443            brain,
1444            relay,
1445            mcp_names,
1446            state.clone(),
1447            end_tx.clone(),
1448        )),
1449        Box::new(RecorderProcessor::new(state.clone())),
1450        Box::new(TranscriptProcessor::new(state.clone())),
1451        Box::new(TransportOutput::new(
1452            shared.clone(),
1453            carrier_rate,
1454            state.clone(),
1455        )),
1456        Box::new(FinalizeProcessor::new(
1457            session,
1458            run_id,
1459            token,
1460            state.clone(),
1461        )),
1462    ];
1463    let pipeline = Pipeline::new(processors);
1464
1465    // 7. Build the task. Disable the idle timeout (call.rs has no idle gate; the
1466    //    call ends on brain End / transport Stop). Audio rates match Gemini.
1467    let params = PipelineTaskParams {
1468        audio_in_sample_rate: input_rate,
1469        audio_out_sample_rate: GEMINI_OUTPUT_RATE,
1470        idle_timeout: None,
1471        ..Default::default()
1472    };
1473    let task = PipelineTask::new(pipeline, params, observers);
1474
1475    // 8. Forward end-requests into the task head (so Source breaks → clean drain).
1476    let head = task.queue_sender();
1477    tokio::spawn(async move {
1478        while let Some(f) = end_rx.recv().await {
1479            if head.send(f).is_err() {
1480                break;
1481            }
1482        }
1483    });
1484
1485    // 9. Spawn the transport pump feeding the task head.
1486    let pump = spawn_transport_pump(shared, task.queue_sender());
1487
1488    Ok(S2sTask { task, pump })
1489}
1490
1491// ===========================================================================
1492// §7.2 — the S2S pipeline behaviour gate.
1493//
1494// Drive the S2S `PipelineTask` from the scripted mocks (the shared
1495// `pipeline::s2s_test_mocks`) and assert the 6 captured output sets match the
1496// golden values (PROCESSOR-DESIGN §7.2):
1497//   (a) count + order of `playAudio` frames to the carrier;
1498//   (b) the (id, status) tool-result sequence;
1499//   (c) fin.recording_url / fin.transcript_url stored keys;
1500//   (d) fin.collected_vars incl. the folded disposition;
1501//   (e) fin.usage.total_tokens;
1502//   (f) the relayed (node_id, tool_name, args) + verbatim MCP result.
1503// Covers BOTH scenarios (audio-both-ways + finalize, and MCP-relay-not-transition).
1504//
1505// NOTE: these golden values are exactly the outputs the now-deleted `Call::run`
1506// reference oracle produced — the differential test proved `build_s2s_task` is
1507// byte-for-byte equal to `Call::run` on both scenarios, so with `Call` removed
1508// the processor pipeline is asserted directly against that proven expected surface.
1509// ===========================================================================
1510#[cfg(test)]
1511mod tests {
1512    use super::*;
1513    use crate::pipeline::s2s_test_mocks::{
1514        Captured, McpMockRealtime, MockBrain, MockRealtime, MockSession, MockSocket, CARRIER_RATE,
1515    };
1516    use crate::serializer::PlivoSerializer;
1517    use crate::transport::WsCarrierTransport;
1518    use crate::types::WsOut;
1519    use std::sync::atomic::AtomicBool;
1520    use std::time::Duration;
1521
1522    const TEST_MODEL: &str = "models/test-realtime";
1523
1524    /// Teardown must abort the model-event reader. Without it the reader keeps
1525    /// reconnecting the realtime session after the call ends (Gemini `1008` reconnect),
1526    /// burning provider quota for minutes. We stand a never-finishing
1527    /// task in for the reader, plant its handle, and assert `stop()` cancels it.
1528    #[tokio::test]
1529    async fn stop_aborts_the_reader_task() {
1530        let reader = tokio::spawn(async {
1531            loop {
1532                tokio::task::yield_now().await;
1533            }
1534        });
1535        let (end_tx, _end_rx) = tokio::sync::mpsc::unbounded_channel::<Frame>();
1536        let state: SharedState = Arc::new(Mutex::new(LiveState::new(CARRIER_RATE)));
1537        let mock = MockRealtime::new(
1538            Arc::new(AtomicBool::new(false)),
1539            Arc::new(Mutex::new(0)),
1540            Arc::new(Mutex::new(Vec::new())),
1541        );
1542        let mut proc = RealtimeServiceProcessor::new(mock, CARRIER_RATE, 16_000, state, end_tx);
1543        proc.reader_handle = Some(reader.abort_handle());
1544        assert!(!reader.is_finished(), "reader runs before stop()");
1545
1546        proc.stop(StopReason::EndOfTask).await.unwrap();
1547
1548        // After abort the task resolves to a cancelled JoinError.
1549        let joined = reader.await;
1550        assert!(
1551            joined.is_err() && joined.unwrap_err().is_cancelled(),
1552            "stop() must abort the reader task"
1553        );
1554        assert!(
1555            proc.reader_handle.is_none(),
1556            "the handle is taken on stop()"
1557        );
1558    }
1559
1560    /// The captured outputs of one harness run — the §7.2 comparison surface.
1561    #[derive(Debug, PartialEq)]
1562    struct Captures {
1563        /// (a) The `playAudio` carrier frames, in order (full JSON payloads).
1564        play_audio: Vec<String>,
1565        /// (b) The (id, status) tool-result sequence (MockRealtime scenario).
1566        tool_results: Vec<(String, String)>,
1567        /// (c) recording + transcript stored keys.
1568        recording_url: Option<String>,
1569        transcript_url: Option<String>,
1570        /// (d) collected_vars incl. disposition.
1571        collected_vars: serde_json::Value,
1572        /// (e) usage total tokens.
1573        total_tokens: Option<u64>,
1574        /// (f) relayed (node_id, tool_name, args).
1575        relayed: Vec<(String, String, serde_json::Value)>,
1576        /// (f) the raw (id, result) sequence (MCP scenario — verbatim contents).
1577        raw_results: Vec<(String, serde_json::Value)>,
1578    }
1579
1580    /// Extract the `playAudio` frames (in order) from the captured carrier output.
1581    fn play_audio_frames(sent: &[WsOut]) -> Vec<String> {
1582        sent.iter()
1583            .filter_map(|o| match o {
1584                WsOut::Text(t) if t.contains("playAudio") => Some(t.clone()),
1585                _ => None,
1586            })
1587            .collect()
1588    }
1589
1590    /// Build the captures struct from the shared mock buffers.
1591    fn collect(
1592        sent: &Arc<Mutex<Vec<WsOut>>>,
1593        tool_results: &Arc<Mutex<Vec<(String, String)>>>,
1594        raw_results: &Arc<Mutex<Vec<(String, serde_json::Value)>>>,
1595        captured: &Arc<Mutex<Captured>>,
1596    ) -> Captures {
1597        let c = captured.lock().unwrap();
1598        let fin = c.finalize.as_ref();
1599        Captures {
1600            play_audio: play_audio_frames(&sent.lock().unwrap()),
1601            tool_results: tool_results.lock().unwrap().clone(),
1602            recording_url: fin.and_then(|f| f.recording_url.clone()),
1603            transcript_url: fin.and_then(|f| f.transcript_url.clone()),
1604            collected_vars: fin
1605                .map(|f| f.collected_vars.clone())
1606                .unwrap_or(serde_json::Value::Null),
1607            total_tokens: fin.and_then(|f| f.usage.get("total_tokens").and_then(|v| v.as_u64())),
1608            relayed: c.tool_calls.clone(),
1609            raw_results: raw_results.lock().unwrap().clone(),
1610        }
1611    }
1612
1613    // ---- Scenario 1: audio both ways + finalize (the END_TOOL script) -------
1614
1615    /// Run the S2S `PipelineTask` for scenario 1 and capture its outputs.
1616    async fn run_pipeline_scenario1() -> Captures {
1617        let sent = Arc::new(Mutex::new(Vec::<WsOut>::new()));
1618        let connected = Arc::new(AtomicBool::new(false));
1619        let audio_received = Arc::new(Mutex::new(0usize));
1620        let tool_results = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
1621        let seen_tools = Arc::new(Mutex::new(Vec::<String>::new()));
1622        let captured = Arc::new(Mutex::new(Captured::default()));
1623        let raw_results = Arc::new(Mutex::new(Vec::<(String, serde_json::Value)>::new()));
1624
1625        let transport = WsCarrierTransport::new(
1626            MockSocket::new(sent.clone()),
1627            PlivoSerializer::new(CARRIER_RATE),
1628        );
1629        let s2s = build_s2s_task(
1630            transport,
1631            MockRealtime::new(
1632                connected.clone(),
1633                audio_received.clone(),
1634                tool_results.clone(),
1635            ),
1636            MockBrain::new(seen_tools.clone()),
1637            MockSession::new(captured.clone()),
1638            4242,
1639            "tok-abc".into(),
1640            TEST_MODEL.into(),
1641        )
1642        .await
1643        .expect("build_s2s_task");
1644        tokio::time::timeout(Duration::from_secs(5), s2s.run())
1645            .await
1646            .expect("S2S pipeline timed out")
1647            .expect("S2S pipeline errored");
1648        collect(&sent, &tool_results, &raw_results, &captured)
1649    }
1650
1651    #[tokio::test]
1652    async fn s2s_pipeline_matches_golden_audio_and_finalize() {
1653        let pipeline = run_pipeline_scenario1().await;
1654
1655        // (a) The playAudio frames reached the carrier. The bot audio is fully
1656        // deterministic (one scripted AudioOut → resample 24k→8k → one Plivo
1657        // playAudio frame), so assert the exact count + that the frame is a
1658        // well-formed playAudio carrying a non-empty base64 payload (restores the
1659        // count+order constraint the differential oracle previously gave).
1660        assert_eq!(
1661            pipeline.play_audio.len(),
1662            1,
1663            "expected exactly one playAudio frame for the scripted bot audio"
1664        );
1665        let frame: serde_json::Value =
1666            serde_json::from_str(&pipeline.play_audio[0]).expect("playAudio frame is valid JSON");
1667        assert_eq!(frame["event"], "playAudio");
1668        assert!(
1669            frame["media"]["payload"]
1670                .as_str()
1671                .is_some_and(|p| !p.is_empty()),
1672            "playAudio frame must carry a non-empty base64 payload"
1673        );
1674
1675        // (b) The (id, status) tool-result sequence (golden: the END_TOOL ack).
1676        assert_eq!(
1677            pipeline.tool_results,
1678            vec![("fc-end-1".into(), "ended".into())]
1679        );
1680
1681        // (c) The stored recording/transcript keys.
1682        assert_eq!(
1683            pipeline.recording_url.as_deref(),
1684            Some("runs/4242/recording")
1685        );
1686        assert_eq!(
1687            pipeline.transcript_url.as_deref(),
1688            Some("runs/4242/transcript")
1689        );
1690
1691        // (d) collected_vars incl. the folded disposition.
1692        assert_eq!(pipeline.collected_vars["call_disposition"], "completed");
1693        assert_eq!(pipeline.collected_vars["name"], "Ada");
1694        assert_eq!(pipeline.collected_vars["intent"], "support");
1695
1696        // (e) Usage total tokens.
1697        assert_eq!(pipeline.total_tokens, Some(15));
1698    }
1699
1700    /// Records user-side transcription frames as `(final, text)` at their ORIGIN
1701    /// (`source == "RealtimeService"`) so multi-hop traversal doesn't double-count.
1702    #[derive(Default)]
1703    struct UserTranscriptRecorder {
1704        seen: Mutex<Vec<(bool, String)>>,
1705    }
1706    #[async_trait::async_trait]
1707    impl crate::observer::FrameObserver for UserTranscriptRecorder {
1708        async fn on_push(&self, e: &crate::observer::FramePushEvent<'_>) {
1709            if e.source != "RealtimeService" {
1710                return;
1711            }
1712            match e.frame {
1713                Frame::InterimTranscription { text, user_id, .. } if user_id.as_ref() == "user" => {
1714                    self.seen.lock().unwrap().push((false, text.clone()));
1715                }
1716                Frame::Transcription {
1717                    text,
1718                    user_id,
1719                    final_,
1720                    ..
1721                } if user_id.as_ref() == "user" => {
1722                    self.seen.lock().unwrap().push((*final_, text.clone()));
1723                }
1724                _ => {}
1725            }
1726        }
1727    }
1728
1729    /// The realtime user-transcription path emits streaming partials as a single
1730    /// GROWING interim line (`final:false`), then ONE finalized line (`final:true`)
1731    /// — never one committed bubble per word. (Scripted deltas "I'd " + "like to
1732    /// end now" → interim "I'd " then "I'd like to end now"; `.completed` →
1733    /// final "I'd like to end now".)
1734    #[tokio::test]
1735    async fn user_transcription_streams_one_interim_line_then_finalizes() {
1736        let sent = Arc::new(Mutex::new(Vec::<WsOut>::new()));
1737        let recorder = Arc::new(UserTranscriptRecorder::default());
1738
1739        let transport = WsCarrierTransport::new(
1740            MockSocket::new(sent.clone()),
1741            PlivoSerializer::new(CARRIER_RATE),
1742        );
1743        let s2s = build_s2s_task_with_observers(
1744            transport,
1745            MockRealtime::new(
1746                Arc::new(AtomicBool::new(false)),
1747                Arc::new(Mutex::new(0)),
1748                Arc::new(Mutex::new(Vec::new())),
1749            ),
1750            MockBrain::new(Arc::new(Mutex::new(Vec::new()))),
1751            MockSession::new(Arc::new(Mutex::new(Captured::default()))),
1752            4242,
1753            "tok-abc".into(),
1754            TEST_MODEL.into(),
1755            vec![recorder.clone()],
1756        )
1757        .await
1758        .expect("build_s2s_task_with_observers");
1759        tokio::time::timeout(Duration::from_secs(5), s2s.run())
1760            .await
1761            .expect("S2S pipeline timed out")
1762            .expect("S2S pipeline errored");
1763
1764        let seen = recorder.seen.lock().unwrap().clone();
1765        assert_eq!(
1766            seen,
1767            vec![
1768                (false, "I'd ".to_string()),
1769                (false, "I'd like to end now".to_string()),
1770                (true, "I'd like to end now".to_string()),
1771            ],
1772            "expected two growing interim lines then one final, got {seen:?}"
1773        );
1774    }
1775
1776    /// Records bot-side frames at their origin (`source == "RealtimeService"`):
1777    /// every final bot transcript line + whether a `BotStoppedSpeaking` was emitted.
1778    #[derive(Default)]
1779    struct BotTranscriptRecorder {
1780        lines: Mutex<Vec<String>>,
1781        stopped: std::sync::atomic::AtomicUsize,
1782    }
1783    #[async_trait::async_trait]
1784    impl crate::observer::FrameObserver for BotTranscriptRecorder {
1785        async fn on_push(&self, e: &crate::observer::FramePushEvent<'_>) {
1786            if e.source != "RealtimeService" {
1787                return;
1788            }
1789            match e.frame {
1790                Frame::Transcription { text, user_id, .. } if user_id.as_ref() == "bot" => {
1791                    self.lines.lock().unwrap().push(text.clone());
1792                }
1793                Frame::BotStoppedSpeaking => {
1794                    self.stopped
1795                        .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1796                }
1797                _ => {}
1798            }
1799        }
1800    }
1801
1802    /// The bot transcript is coalesced into ONE finalized line per response (flushed
1803    /// at `response.done`/Usage), and a `BotStoppedSpeaking` is emitted so the UI's
1804    /// "speaking…" indicator clears — never one bubble per delta. (MockRealtime
1805    /// scripts a single `BotText` then `Usage`.)
1806    #[tokio::test]
1807    async fn bot_transcript_coalesces_into_one_final_and_signals_stopped() {
1808        let sent = Arc::new(Mutex::new(Vec::<WsOut>::new()));
1809        let recorder = Arc::new(BotTranscriptRecorder::default());
1810
1811        let transport = WsCarrierTransport::new(
1812            MockSocket::new(sent.clone()),
1813            PlivoSerializer::new(CARRIER_RATE),
1814        );
1815        let s2s = build_s2s_task_with_observers(
1816            transport,
1817            MockRealtime::new(
1818                Arc::new(AtomicBool::new(false)),
1819                Arc::new(Mutex::new(0)),
1820                Arc::new(Mutex::new(Vec::new())),
1821            ),
1822            MockBrain::new(Arc::new(Mutex::new(Vec::new()))),
1823            MockSession::new(Arc::new(Mutex::new(Captured::default()))),
1824            4242,
1825            "tok-abc".into(),
1826            TEST_MODEL.into(),
1827            vec![recorder.clone()],
1828        )
1829        .await
1830        .expect("build_s2s_task_with_observers");
1831        tokio::time::timeout(Duration::from_secs(5), s2s.run())
1832            .await
1833            .expect("S2S pipeline timed out")
1834            .expect("S2S pipeline errored");
1835
1836        let lines = recorder.lines.lock().unwrap().clone();
1837        assert_eq!(
1838            lines,
1839            vec!["Hello, how can I help?".to_string()],
1840            "bot transcript should be one coalesced final line, got {lines:?}"
1841        );
1842        assert!(
1843            recorder.stopped.load(std::sync::atomic::Ordering::SeqCst) >= 1,
1844            "a BotStoppedSpeaking must be emitted so the UI finalizes the bubble"
1845        );
1846    }
1847
1848    // ---- Scenario 2: MCP relay (not a transition) + end --------------------
1849
1850    const MCP_TOOL: &str = "list_doctors";
1851
1852    fn mcp_node_tools() -> Vec<ToolDecl> {
1853        vec![ToolDecl {
1854            name: MCP_TOOL.into(),
1855            description: "List available doctors.".into(),
1856            params: json!({
1857                "type": "object",
1858                "properties": { "specialty": { "type": "string" } }
1859            }),
1860        }]
1861    }
1862
1863    /// Run the S2S `PipelineTask` for the MCP scenario and capture.
1864    async fn run_pipeline_scenario2() -> (Captures, Vec<Vec<String>>) {
1865        let sent = Arc::new(Mutex::new(Vec::<WsOut>::new()));
1866        let seen_tools = Arc::new(Mutex::new(Vec::<String>::new()));
1867        let raw_results = Arc::new(Mutex::new(Vec::<(String, serde_json::Value)>::new()));
1868        let advertised = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
1869        let captured = Arc::new(Mutex::new(Captured::default()));
1870        let tool_results = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
1871
1872        let session =
1873            MockSession::with_node_tools(captured.clone(), mcp_node_tools(), "Dr. Smith, Dr. Lee");
1874        let transport = WsCarrierTransport::new(
1875            MockSocket::new(sent.clone()),
1876            PlivoSerializer::new(CARRIER_RATE),
1877        );
1878        let s2s = build_s2s_task(
1879            transport,
1880            McpMockRealtime::new(raw_results.clone(), advertised.clone(), MCP_TOOL),
1881            MockBrain::new(seen_tools.clone()),
1882            session,
1883            4242,
1884            "tok-abc".into(),
1885            TEST_MODEL.into(),
1886        )
1887        .await
1888        .expect("build_s2s_task");
1889        tokio::time::timeout(Duration::from_secs(5), s2s.run())
1890            .await
1891            .expect("S2S pipeline timed out")
1892            .expect("S2S pipeline errored");
1893        let caps = collect(&sent, &tool_results, &raw_results, &captured);
1894        let adv = advertised.lock().unwrap().clone();
1895        (caps, adv)
1896    }
1897
1898    #[tokio::test]
1899    async fn s2s_pipeline_matches_golden_mcp_relay_not_transition() {
1900        let (pipeline, pipe_adv) = run_pipeline_scenario2().await;
1901
1902        // (f) The relayed (node_id, tool_name, args) to the control plane.
1903        assert_eq!(pipeline.relayed.len(), 1, "exactly one MCP tool relayed");
1904        let (node_id, tool_name, args) = &pipeline.relayed[0];
1905        assert_eq!(node_id, "start");
1906        assert_eq!(tool_name, MCP_TOOL);
1907        assert_eq!(args["specialty"], "dentistry");
1908
1909        // (f) The RAW (id, result) sequence — the MCP result is shaped into a JSON
1910        // object (Gemini requires `response` to be a protobuf Struct; a bare string
1911        // is `1007`-rejected). The mock returns plain text, so it is wrapped as
1912        // `{result: …}`; endCall stays the {status: ended} envelope.
1913        let mcp = pipeline
1914            .raw_results
1915            .iter()
1916            .find(|(id, _)| id == "fc-mcp-1")
1917            .expect("MCP tool result sent back to the model");
1918        assert_eq!(mcp.1, json!({ "result": "Dr. Smith, Dr. Lee" }));
1919        let end = pipeline
1920            .raw_results
1921            .iter()
1922            .find(|(id, _)| id == "fc-end-1")
1923            .expect("endCall result sent");
1924        assert_eq!(end.1["status"], "ended");
1925
1926        // The MCP tool was advertised at connect AFTER the transitions (stable
1927        // ordering: transitions first, then MCP tools).
1928        assert_eq!(
1929            pipe_adv.first(),
1930            Some(&vec!["end_call".to_string(), MCP_TOOL.to_string()]),
1931            "transitions first, then MCP tools"
1932        );
1933    }
1934
1935    // ---- Pure-helper unit tests for the canonical finalize helpers ----------
1936    // These cover edge cases the golden integration tests don't exercise; they
1937    // target the S2S-path implementations (the only copies now that call.rs is
1938    // gone): `merge_disposition`, `add_opt`, `LiveState::accumulate_usage`.
1939
1940    #[test]
1941    fn merge_disposition_inserts_into_object() {
1942        let merged = merge_disposition(json!({ "a": 1 }), Some("busy".into()));
1943        assert_eq!(merged["call_disposition"], "busy");
1944        assert_eq!(merged["a"], 1);
1945    }
1946
1947    #[test]
1948    fn merge_disposition_does_not_clobber_existing() {
1949        let merged = merge_disposition(
1950            json!({ "call_disposition": "preset" }),
1951            Some("override".into()),
1952        );
1953        assert_eq!(merged["call_disposition"], "preset", "existing value wins");
1954    }
1955
1956    #[test]
1957    fn merge_disposition_none_and_non_object_passthrough() {
1958        assert_eq!(
1959            merge_disposition(json!({ "a": 1 }), None),
1960            json!({ "a": 1 })
1961        );
1962        assert_eq!(
1963            merge_disposition(json!("scalar"), Some("x".into())),
1964            json!("scalar")
1965        );
1966    }
1967
1968    #[test]
1969    fn add_opt_sums_and_treats_none_as_zero() {
1970        assert_eq!(add_opt(Some(2), Some(3)), Some(5));
1971        assert_eq!(add_opt(Some(2), None), Some(2));
1972        assert_eq!(add_opt(None, Some(3)), Some(3));
1973        assert_eq!(add_opt(None, None), None);
1974    }
1975
1976    #[test]
1977    fn live_state_accumulates_usage_across_reports() {
1978        let mut st = LiveState::new(CARRIER_RATE);
1979        st.accumulate_usage(&Usage {
1980            input_tokens: Some(10),
1981            output_tokens: Some(2),
1982            total_tokens: Some(12),
1983            extra: None,
1984        });
1985        st.accumulate_usage(&Usage {
1986            input_tokens: Some(5),
1987            output_tokens: Some(1),
1988            total_tokens: Some(6),
1989            extra: Some(json!({ "cached": 3 })),
1990        });
1991        // Run-detail observability counts ride in the same usage object.
1992        st.transcript.push_user("hi");
1993        st.transcript.push_bot("hello");
1994        st.tool_calls = 2;
1995        let u = st.usage_json();
1996        assert_eq!(u["input_tokens"], 15);
1997        assert_eq!(u["output_tokens"], 3);
1998        assert_eq!(u["total_tokens"], 18);
1999        assert_eq!(u["extra"]["cached"], 3, "last non-null extra kept");
2000        // Wall-clock call duration rides alongside the token totals (0.0 here —
2001        // no audio pushed — but the control plane requires the field present).
2002        assert!(
2003            u["duration_seconds"].is_number(),
2004            "usage_json must surface duration_seconds"
2005        );
2006        assert_eq!(u["user_turns"], 1, "one user turn surfaced");
2007        assert_eq!(u["bot_turns"], 1, "one bot turn surfaced");
2008        assert_eq!(u["tool_calls"], 2, "tool calls tallied");
2009    }
2010
2011    // ---- mcp_result_struct: Gemini `response` must be a protobuf Struct --------
2012    // Regression for the 1007 "Invalid value at … response (Struct)" that dropped
2013    // live calls the instant a tool (e.g. get_initial_prompt) returned JSON text.
2014
2015    #[test]
2016    fn mcp_result_object_passes_through_as_struct() {
2017        // A JSON-object result (get_initial_prompt's `{a2ui: …}`) is already a
2018        // Struct → forwarded as-is so the model gets the structured fields.
2019        let v = mcp_result_struct(r#"{"a2ui":{"kind":"card"},"prompt":"hi"}"#);
2020        assert!(v.is_object(), "object result must stay an object");
2021        assert_eq!(v["a2ui"]["kind"], "card");
2022        assert_eq!(v["prompt"], "hi");
2023    }
2024
2025    #[test]
2026    fn mcp_result_plain_text_is_wrapped() {
2027        // Non-JSON text → wrapped so `response` is still a Struct (the exact bug:
2028        // a bare string was sent verbatim and Gemini 1007-rejected it).
2029        let v = mcp_result_struct("Dr. Smith, Dr. Lee");
2030        assert_eq!(v, json!({ "result": "Dr. Smith, Dr. Lee" }));
2031        assert!(v.is_object());
2032    }
2033
2034    #[test]
2035    fn mcp_result_json_non_object_is_wrapped() {
2036        // Valid JSON that is NOT an object (array / scalar) is not a Struct either,
2037        // so it must be wrapped rather than forwarded bare.
2038        assert_eq!(mcp_result_struct("[1,2,3]"), json!({ "result": [1, 2, 3] }));
2039        assert_eq!(mcp_result_struct("42"), json!({ "result": 42 }));
2040        assert_eq!(mcp_result_struct("true"), json!({ "result": true }));
2041        assert!(mcp_result_struct("[1,2,3]").is_object());
2042    }
2043
2044    // ---- advance_playout: the end-of-call drain estimate ----------------------
2045    // Guards the fix for the goodbye being cut off — teardown must wait out the
2046    // bot audio still playing at the carrier when `endCall` fires.
2047
2048    #[test]
2049    fn advance_playout_tracks_realtime_tail() {
2050        let now = Instant::now();
2051        // From nothing: one second of 8 kHz audio ⇒ plays until now + 1 s.
2052        let t = advance_playout(None, now, 8000, 8000);
2053        assert_eq!(t, now + Duration::from_secs(1));
2054        // Back-to-back: the next chunk queues after the previous one ends.
2055        let t2 = advance_playout(Some(t), now, 8000, 8000);
2056        assert_eq!(t2, now + Duration::from_secs(2));
2057        // A chunk sent after the tail already drained anchors to `now`, not the past.
2058        let gap = advance_playout(Some(now - Duration::from_secs(5)), now, 8000, 8000);
2059        assert_eq!(gap, now + Duration::from_secs(1));
2060        // Rate is respected: a 20 ms frame (160 samples @ 8 kHz).
2061        let frame = advance_playout(None, now, 160, 8000);
2062        assert_eq!(frame, now + Duration::from_millis(20));
2063    }
2064}