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) = ¬ify {
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}