gemini_adk_rs/live/events.rs
1//! Semantic events emitted by the L1 processor.
2//!
3//! Subscribe via `LiveHandle::events()` (broadcast receiver) or
4//! `LiveHandle::stream()` (a [`futures_util::Stream`]). Zero-cost when no
5//! subscribers.
6
7use std::pin::Pin;
8use std::task::{Context, Poll};
9use std::time::Duration;
10
11use bytes::Bytes;
12use futures_util::Stream;
13use tokio::sync::broadcast;
14
15/// Semantic events emitted by the Live session processor.
16///
17/// The L1 equivalent of L0's [`SessionEvent`](gemini_genai_rs::prelude::SessionEvent).
18/// L0 events are wire-level; LiveEvents are semantic (extractions completed,
19/// phases transitioned, tools executed).
20///
21/// Subscribe via [`LiveHandle::events()`](super::handle::LiveHandle::events).
22/// Multiple independent subscribers supported. Zero-cost when no subscribers
23/// exist (`broadcast::send` with 0 receivers is a no-op).
24#[derive(Debug, Clone)]
25#[non_exhaustive]
26pub enum LiveEvent {
27 // -- Fast-lane events (high frequency, sync emission) --
28 /// Raw PCM audio from model. Uses `Bytes` (refcounted) — clone is
29 /// a pointer increment (~2ns), not a deep copy.
30 Audio(Bytes),
31 /// Non-audio media from the model: Gemini 3.8 Live Avatar video
32 /// (`video/mp4`), synchronized with [`Audio`](Self::Audio).
33 Media(gemini_genai_rs::session::InlineMedia),
34 /// Incremental text token from model.
35 TextDelta(String),
36 /// Complete text response (all deltas concatenated).
37 TextComplete(String),
38 /// User speech transcription.
39 InputTranscript {
40 /// The transcribed text content.
41 text: String,
42 /// Whether this is the final transcription for the utterance.
43 is_final: bool,
44 },
45 /// Model speech transcription.
46 OutputTranscript {
47 /// The transcribed text content.
48 text: String,
49 /// Whether this is the final transcription for the utterance.
50 is_final: bool,
51 },
52 /// Model reasoning/thinking content.
53 Thought(String),
54 /// Voice activity detected — user started speaking.
55 VadStart,
56 /// Voice activity ended — user stopped speaking.
57 VadEnd,
58
59 // -- Control-lane events (lower frequency, async emission) --
60 /// Extraction completed. Emitted for both the top-level result
61 /// AND each flattened key (e.g., "order.items", "order.phase").
62 Extraction {
63 /// Extractor name, or `"extractor.field"` for flattened keys.
64 name: String,
65 /// The extracted JSON value.
66 value: serde_json::Value,
67 },
68 /// Extraction failed.
69 ExtractionError {
70 /// Name of the extractor that failed.
71 name: String,
72 /// Human-readable error description.
73 error: String,
74 },
75 /// A raw extraction field was considered for promotion into authoritative state.
76 StatePromotion {
77 /// Extractor name that produced the field.
78 extractor: String,
79 /// Field name inside the extractor result.
80 field: String,
81 /// State key targeted by the promotion rule.
82 state_key: String,
83 /// Whether the promotion was accepted and written.
84 accepted: bool,
85 /// Human-readable reason for the decision.
86 reason: String,
87 /// Extracted value that was considered.
88 value: serde_json::Value,
89 },
90 /// Phase machine transitioned.
91 PhaseTransition {
92 /// Phase the machine transitioned from.
93 from: String,
94 /// Phase the machine transitioned to.
95 to: String,
96 /// Human-readable reason for the transition.
97 reason: String,
98 },
99 /// Tool dispatched and result obtained.
100 ToolExecution {
101 /// Name of the tool that was called.
102 name: String,
103 /// Arguments passed to the tool.
104 args: serde_json::Value,
105 /// Result returned by the tool.
106 result: serde_json::Value,
107 },
108 /// Tool calls cancelled — either by the server (a `ToolCallCancelled`
109 /// wire event) or locally when a user barge-in interrupted an in-flight
110 /// inline tool. No response is sent for a cancelled call, and a cancelled
111 /// call never advances the governed flow.
112 ToolCancelled {
113 /// IDs of the cancelled tool calls.
114 ids: Vec<String>,
115 },
116 /// Model completed a conversational turn.
117 TurnComplete,
118 /// Model output interrupted by user speech.
119 Interrupted,
120 /// A tool call in a stage with a filler timing has run longer than the
121 /// stage allows: play an earcon or a holding line now.
122 FillerCue {
123 /// The tool still running.
124 tool: String,
125 /// How long it has run, in milliseconds.
126 elapsed_ms: u64,
127 },
128 /// A verbatim stage's required text was compared with what the model
129 /// said this turn.
130 VerbatimChecked {
131 /// The stage.
132 step: String,
133 /// Word-level similarity, 0–1.
134 similarity: f64,
135 /// Whether it was close enough to count as verbatim.
136 passed: bool,
137 },
138 /// The user stayed silent past the active stage's reprompt timing, and
139 /// the model was asked to repeat its question.
140 Reprompted {
141 /// How long the user had been silent, in milliseconds.
142 silence_ms: u64,
143 },
144 /// Session connected to Gemini.
145 Connected,
146 /// Session disconnected.
147 Disconnected {
148 /// Optional reason for disconnection (server-provided or error message).
149 reason: Option<String>,
150 },
151 /// Unrecoverable error.
152 Error(String),
153 /// Server requesting session wind-down.
154 GoAway {
155 /// Time remaining before the server closes the connection.
156 time_left: Duration,
157 },
158
159 // -- Periodic events --
160 /// Aggregated session telemetry snapshot.
161 Telemetry(serde_json::Value),
162 /// Per-turn latency and token metrics.
163 TurnMetrics {
164 /// Turn number (1-indexed).
165 turn: u32,
166 /// End-to-end latency for this turn in milliseconds.
167 latency_ms: u32,
168 /// Number of prompt tokens consumed.
169 prompt_tokens: u32,
170 /// Number of response tokens generated.
171 response_tokens: u32,
172 },
173}
174
175/// A [`futures_util::Stream`] of [`LiveEvent`]s from a Live session.
176///
177/// Created by [`LiveHandle::stream()`](super::handle::LiveHandle::stream).
178/// Wraps the underlying [`broadcast::Receiver`] with stream semantics:
179///
180/// - **Lagged**: if this subscriber falls behind the broadcast buffer, the
181/// missed events are skipped and the stream continues with the next
182/// available event (no error item is yielded).
183/// - **Closed**: when the session's event channel closes, the stream ends
184/// (`next()` returns `None`).
185///
186/// Composes with all `futures`/`tokio-stream` combinators:
187///
188/// ```rust,ignore
189/// use futures_util::StreamExt;
190///
191/// let mut stream = handle.stream();
192/// while let Some(ev) = stream.next().await {
193/// match ev {
194/// LiveEvent::TextDelta(t) => print!("{t}"),
195/// LiveEvent::TurnComplete => println!(),
196/// _ => {}
197/// }
198/// }
199/// ```
200pub struct LiveEventStream {
201 inner: Pin<Box<dyn Stream<Item = LiveEvent> + Send>>,
202}
203
204impl LiveEventStream {
205 /// Wrap a broadcast receiver of [`LiveEvent`]s as a stream.
206 pub(crate) fn new(rx: broadcast::Receiver<LiveEvent>) -> Self {
207 let inner = futures_util::stream::unfold(rx, |mut rx| async move {
208 loop {
209 match rx.recv().await {
210 Ok(ev) => return Some((ev, rx)),
211 // Skip lagged (missed) events and keep going.
212 Err(broadcast::error::RecvError::Lagged(_)) => continue,
213 // Channel closed: end the stream.
214 Err(broadcast::error::RecvError::Closed) => return None,
215 }
216 }
217 });
218 Self {
219 inner: Box::pin(inner),
220 }
221 }
222}
223
224impl Stream for LiveEventStream {
225 type Item = LiveEvent;
226
227 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
228 self.inner.as_mut().poll_next(cx)
229 }
230}
231
232impl std::fmt::Debug for LiveEventStream {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 f.debug_struct("LiveEventStream").finish_non_exhaustive()
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use futures_util::StreamExt;
242
243 #[tokio::test]
244 async fn stream_yields_events_in_order_and_ends_on_close() {
245 let (tx, rx) = broadcast::channel::<LiveEvent>(16);
246 let mut stream = LiveEventStream::new(rx);
247
248 tx.send(LiveEvent::VadStart).unwrap();
249 tx.send(LiveEvent::TextDelta("hi".into())).unwrap();
250 tx.send(LiveEvent::TurnComplete).unwrap();
251
252 assert!(matches!(stream.next().await, Some(LiveEvent::VadStart)));
253 match stream.next().await {
254 Some(LiveEvent::TextDelta(t)) => assert_eq!(t, "hi"),
255 other => panic!("expected TextDelta, got {other:?}"),
256 }
257 assert!(matches!(stream.next().await, Some(LiveEvent::TurnComplete)));
258
259 // Closing the channel ends the stream.
260 drop(tx);
261 assert!(stream.next().await.is_none(), "stream ends on Closed");
262 }
263
264 #[tokio::test]
265 async fn stream_skips_lagged_events_and_continues() {
266 // Capacity-2 channel: sending 5 events before polling forces a lag.
267 let (tx, rx) = broadcast::channel::<LiveEvent>(2);
268 let mut stream = LiveEventStream::new(rx);
269
270 for i in 0..5u32 {
271 tx.send(LiveEvent::TextDelta(format!("e{i}"))).unwrap();
272 }
273
274 // The first poll observes the lag, skips it, and yields the oldest
275 // event still buffered (e3), then e4 — no error, no end-of-stream.
276 match stream.next().await {
277 Some(LiveEvent::TextDelta(t)) => assert_eq!(t, "e3"),
278 other => panic!("expected e3 after lag skip, got {other:?}"),
279 }
280 match stream.next().await {
281 Some(LiveEvent::TextDelta(t)) => assert_eq!(t, "e4"),
282 other => panic!("expected e4, got {other:?}"),
283 }
284
285 // The stream is still alive after the lag.
286 tx.send(LiveEvent::TurnComplete).unwrap();
287 assert!(matches!(stream.next().await, Some(LiveEvent::TurnComplete)));
288
289 drop(tx);
290 assert!(stream.next().await.is_none());
291 }
292}