gemini_genai_rs/session/events.rs
1//! Session events, commands, and turn tracking.
2//!
3//! [`SessionEvent`] — events emitted by the server for application consumption.
4//! [`SessionCommand`] — commands sent from the application to the transport.
5//! [`Turn`] — tracking for a single model response turn.
6//! [`recv_event`] — broadcast lag-tolerant event receiver.
7
8use super::errors::SessionError;
9use super::state::SessionPhase;
10use crate::protocol::{Content, FunctionCall, FunctionResponse, UsageMetadata};
11use bytes::Bytes;
12use std::time::{Duration, Instant};
13use tokio::sync::broadcast;
14
15// ---------------------------------------------------------------------------
16// Events (server -> application)
17// ---------------------------------------------------------------------------
18
19/// Events emitted by the session, consumed by application code.
20#[derive(Debug, Clone)]
21#[non_exhaustive]
22pub enum SessionEvent {
23 /// Session connected and setup complete.
24 Connected,
25 /// Incremental text from model response.
26 TextDelta(String),
27 /// Complete text of a finished model turn.
28 TextComplete(String),
29 /// Audio data from model response (PCM16 samples, base64-decoded).
30 ///
31 /// Uses [`bytes::Bytes`] for zero-copy fan-out: cloning a `Bytes` handle
32 /// bumps an `Arc` refcount instead of copying the underlying data.
33 AudioData(bytes::Bytes),
34 /// Non-audio media from the model, such as Gemini 3.8 Live Avatar video
35 /// (`video/mp4`, 24 FPS, synchronized with the speech in
36 /// [`AudioData`](Self::AudioData)).
37 Media(InlineMedia),
38 /// Input transcription from server.
39 InputTranscription(String),
40 /// Output transcription from server.
41 OutputTranscription(String),
42 /// Thought/reasoning summary from the model (when includeThoughts is enabled).
43 Thought(String),
44 /// Model requested tool calls.
45 ToolCall(Vec<FunctionCall>),
46 /// Server cancelled pending tool calls.
47 ToolCallCancelled(Vec<String>),
48 /// Progress of a longer interaction (`serverContent.interactionStatus`),
49 /// e.g. `"IN_PROGRESS"` when Gemini 3.8 Live Extended Thinking has said a
50 /// holding line and will answer in a later turn.
51 InteractionStatus(String),
52 /// Model turn is complete (it's the user's turn now).
53 TurnComplete,
54 /// Model finished generating its full response.
55 ///
56 /// Fires even if the generation was interrupted — tells you the model's
57 /// internal generation pipeline has stopped. Distinct from `TurnComplete`
58 /// which is the turn-taking signal.
59 GenerationComplete,
60 /// Model was interrupted by barge-in.
61 Interrupted,
62 /// Session phase changed.
63 PhaseChanged(SessionPhase),
64 /// Server sent GoAway: it will close the connection after `time_left`
65 /// (when it said how long). The transport reconnects on its own; this
66 /// is the moment to drain buffered audio and expect a resumption handle.
67 GoAway(Option<Duration>),
68 /// Session disconnected (with optional reason).
69 Disconnected(Option<String>),
70 /// Non-fatal error. The session loop keeps running (reconnecting where
71 /// the transport allows); match on the [`SessionError`] variants to tell
72 /// a refused setup from a dropped socket.
73 Error(SessionError),
74 /// Session resumption update with handle, resumability, and consumed index.
75 SessionResumeUpdate(ResumeInfo),
76 /// Server-side voice activity detected (user started speaking).
77 VoiceActivityStart,
78 /// Server-side voice activity ended (user stopped speaking).
79 VoiceActivityEnd,
80 /// Token usage metadata from server (for context window tracking).
81 ///
82 /// Contains full token breakdown: prompt, response, cached, tool-use,
83 /// thinking tokens, plus per-modality details.
84 Usage(UsageMetadata),
85}
86
87/// A chunk of non-audio media the model produced inline.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct InlineMedia {
90 /// MIME type as the server gave it, e.g. `"video/mp4"`.
91 pub mime_type: String,
92 /// The decoded bytes. Cloning shares them (see [`bytes::Bytes`]).
93 pub data: bytes::Bytes,
94}
95
96impl InlineMedia {
97 /// Whether this is a video chunk (`video/*`).
98 pub fn is_video(&self) -> bool {
99 self.mime_type.starts_with("video/")
100 }
101}
102
103/// Session resumption information from the server.
104#[derive(Debug, Clone)]
105pub struct ResumeInfo {
106 /// Opaque handle for session resumption.
107 pub handle: String,
108 /// Whether the session is currently resumable.
109 pub resumable: bool,
110 /// Index of the last client message consumed by the server.
111 pub last_consumed_index: Option<String>,
112}
113
114// ---------------------------------------------------------------------------
115// Commands (application -> server)
116// ---------------------------------------------------------------------------
117
118/// Commands sent from application code to the session transport.
119#[derive(Debug, Clone)]
120pub enum SessionCommand {
121 /// Send audio data (raw PCM16 bytes, will be base64-encoded).
122 ///
123 /// [`Bytes`] both ways: the same handle type as [`SessionEvent::AudioData`],
124 /// so audio can be forwarded without a copy.
125 SendAudio(Bytes),
126 /// Send a text message.
127 SendText(String),
128 /// Send tool responses.
129 SendToolResponse(Vec<FunctionResponse>),
130 /// Signal activity start (client VAD detected speech).
131 ActivityStart,
132 /// Signal activity end (client VAD detected silence).
133 ActivityEnd,
134 /// Send client content (conversation history or context injection).
135 SendClientContent {
136 /// Conversation turns to include.
137 turns: Vec<Content>,
138 /// Whether this completes the client's turn.
139 turn_complete: bool,
140 },
141 /// Send video/image data (raw JPEG bytes, will be base64-encoded).
142 SendVideo(Bytes),
143 /// Update system instruction mid-session (sends client_content with role=system).
144 UpdateInstruction(String),
145 /// Gracefully disconnect.
146 Disconnect,
147}
148
149// ---------------------------------------------------------------------------
150// Turn tracking
151// ---------------------------------------------------------------------------
152
153/// Represents a single model response turn.
154#[derive(Debug, Clone)]
155pub struct Turn {
156 /// Unique turn identifier.
157 pub id: String,
158 /// Accumulated text parts.
159 pub text: String,
160 /// Whether this turn included audio.
161 pub has_audio: bool,
162 /// Tool calls requested in this turn.
163 pub tool_calls: Vec<FunctionCall>,
164 /// When the turn started.
165 pub started_at: Instant,
166 /// When the turn completed (if complete).
167 pub completed_at: Option<Instant>,
168 /// Whether the turn was interrupted.
169 pub interrupted: bool,
170}
171
172impl Turn {
173 /// Create a new turn.
174 pub fn new() -> Self {
175 Self {
176 id: uuid::Uuid::new_v4().to_string(),
177 text: String::new(),
178 has_audio: false,
179 tool_calls: Vec::new(),
180 started_at: Instant::now(),
181 completed_at: None,
182 interrupted: false,
183 }
184 }
185
186 /// Duration of the turn.
187 pub fn duration(&self) -> std::time::Duration {
188 let end = self.completed_at.unwrap_or_else(Instant::now);
189 end.duration_since(self.started_at)
190 }
191}
192
193impl Default for Turn {
194 fn default() -> Self {
195 Self::new()
196 }
197}
198
199// ---------------------------------------------------------------------------
200// Broadcast lag helper
201// ---------------------------------------------------------------------------
202
203/// Receive the next event from a broadcast receiver, handling lag gracefully.
204///
205/// If the receiver falls behind (too slow to keep up with the sender), the
206/// skipped events are logged and the next available event is returned.
207/// Returns `None` when the channel is closed.
208///
209/// # Example
210///
211/// ```no_run
212/// # use gemini_genai_rs::session::{SessionHandle, recv_event};
213/// # async fn run(handle: SessionHandle) {
214/// let mut events = handle.subscribe();
215/// while let Some(event) = recv_event(&mut events).await {
216/// // handle event
217/// }
218/// # }
219/// ```
220pub async fn recv_event(rx: &mut broadcast::Receiver<SessionEvent>) -> Option<SessionEvent> {
221 loop {
222 match rx.recv().await {
223 Ok(event) => return Some(event),
224 Err(broadcast::error::RecvError::Lagged(n)) => {
225 tracing::warn!(skipped = n, "Event subscriber lagged, skipped {n} events");
226 continue;
227 }
228 Err(broadcast::error::RecvError::Closed) => return None,
229 }
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[tokio::test]
238 async fn recv_event_returns_events_normally() {
239 let (tx, mut rx) = broadcast::channel(16);
240
241 tx.send(SessionEvent::Connected).unwrap();
242 tx.send(SessionEvent::TurnComplete).unwrap();
243
244 let event = recv_event(&mut rx).await;
245 assert!(matches!(event, Some(SessionEvent::Connected)));
246
247 let event = recv_event(&mut rx).await;
248 assert!(matches!(event, Some(SessionEvent::TurnComplete)));
249 }
250
251 #[tokio::test]
252 async fn recv_event_returns_none_on_closed_channel() {
253 let (tx, mut rx) = broadcast::channel::<SessionEvent>(16);
254 drop(tx);
255
256 let event = recv_event(&mut rx).await;
257 assert!(event.is_none(), "should return None when channel is closed");
258 }
259
260 #[tokio::test]
261 async fn recv_event_handles_lag() {
262 // Create a tiny broadcast channel (capacity 2)
263 let (tx, mut rx) = broadcast::channel(2);
264
265 // Send 4 events — the receiver will lag behind
266 for i in 0..4 {
267 let _ = tx.send(SessionEvent::TextDelta(format!("msg{i}")));
268 }
269
270 // recv_event should skip the lagged events and return the next available
271 let event = recv_event(&mut rx).await;
272 assert!(event.is_some(), "should get an event after lag");
273 }
274}