adk_realtime/session.rs
1//! Core RealtimeSession trait definition.
2
3use crate::audio::AudioChunk;
4use crate::error::Result;
5use crate::events::{ClientEvent, ServerEvent, ToolResponse};
6use async_trait::async_trait;
7use futures::Stream;
8use std::pin::Pin;
9
10/// The outcome of an attempt to mutate the session context mid-flight.
11#[derive(Debug, Clone)]
12pub enum ContextMutationOutcome {
13 /// Provider successfully updated the active session via sideband.
14 Applied,
15 /// Provider requires the transport to be rebound with a new configuration.
16 RequiresResumption(Box<crate::config::RealtimeConfig>),
17}
18
19/// A real-time bidirectional streaming session.
20///
21/// This trait provides a unified interface for real-time voice/audio sessions
22/// across different providers (OpenAI, Gemini, etc.).
23///
24/// # Example
25///
26/// ```rust,ignore
27/// use adk_realtime::{RealtimeSession, ServerEvent};
28///
29/// async fn handle_session(session: &dyn RealtimeSession) -> Result<()> {
30/// // Send audio
31/// session.send_audio(audio_chunk).await?;
32///
33/// // Receive events
34/// while let Some(event) = session.next_event().await {
35/// match event? {
36/// ServerEvent::AudioDelta { delta, .. } => { /* play audio */ }
37/// ServerEvent::FunctionCallDone { name, arguments, call_id, .. } => {
38/// // Execute tool and respond
39/// let result = execute_tool(&name, &arguments);
40/// session.send_tool_response(call_id, result).await?;
41/// }
42/// _ => {}
43/// }
44/// }
45/// Ok(())
46/// }
47/// ```
48/// Why a provider closed the stream, when it said.
49///
50/// A closed transport and a provider that deliberately hung up both reach a
51/// polling caller as `next_event() -> None`, so without this they produce the
52/// same terminal record. Distinguishing them matters: "the provider aborted an
53/// idle session" and "the network dropped" call for different responses, and
54/// an application that records the first as a generic stream failure sends an
55/// operator looking for a defect that is not there.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct DisconnectReason {
58 /// The WebSocket close code, if the peer sent a close frame.
59 pub code: Option<u16>,
60 /// The peer's stated reason. Provider text, so treat it as data.
61 pub reason: String,
62}
63
64impl std::fmt::Display for DisconnectReason {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self.code {
67 Some(code) if !self.reason.is_empty() => write!(f, "{code}:{}", self.reason),
68 Some(code) => write!(f, "{code}"),
69 None if !self.reason.is_empty() => write!(f, "{}", self.reason),
70 None => write!(f, "unknown"),
71 }
72 }
73}
74
75#[async_trait]
76pub trait RealtimeSession: Send + Sync {
77 /// Get the session ID.
78 fn session_id(&self) -> &str;
79
80 /// Check if the session is currently connected.
81 fn is_connected(&self) -> bool;
82
83 /// Why the stream ended, if the provider said and the session recorded it.
84 ///
85 /// Defaulted to `None` so existing sessions keep compiling; a provider that
86 /// sees a close frame should override it. Only meaningful after the event
87 /// stream has ended — before that it reports whatever the last close was,
88 /// which for a live session is nothing.
89 fn disconnect_reason(&self) -> Option<DisconnectReason> {
90 None
91 }
92
93 /// Send raw audio data to the server.
94 ///
95 /// The audio should be in the format specified in the session configuration.
96 async fn send_audio(&self, audio: &AudioChunk) -> Result<()>;
97
98 /// Send base64-encoded audio directly.
99 async fn send_audio_base64(&self, audio_base64: &str) -> Result<()>;
100
101 /// Send a text message.
102 async fn send_text(&self, text: &str) -> Result<()>;
103
104 /// Send a single video/image frame (base64-encoded, e.g. a JPEG) to the
105 /// model for multimodal input. `mime_type` is the frame's media type
106 /// (e.g. `image/jpeg`). The default is a no-op for providers/sessions that
107 /// don't accept visual input.
108 async fn send_video_frame(&self, _mime_type: &str, _data_base64: &str) -> Result<()> {
109 Ok(())
110 }
111
112 /// Send a tool/function response (output **and** a response trigger).
113 async fn send_tool_response(&self, response: ToolResponse) -> Result<()>;
114
115 /// Send a tool/function output **without** triggering a response.
116 ///
117 /// When one model response dispatches several parallel tool calls, send each
118 /// output with this method and then call
119 /// [`create_response`](Self::create_response) exactly once — issuing a
120 /// `response.create` per output would collide with the still-active response
121 /// on providers like OpenAI. The default delegates to
122 /// [`send_tool_response`](Self::send_tool_response) for backends that do not
123 /// separate the two.
124 async fn send_tool_output(&self, response: ToolResponse) -> Result<()> {
125 self.send_tool_response(response).await
126 }
127
128 /// Commit the audio buffer (for manual VAD mode).
129 async fn commit_audio(&self) -> Result<()>;
130
131 /// Clear the audio input buffer.
132 async fn clear_audio(&self) -> Result<()>;
133
134 /// Trigger a response from the model.
135 async fn create_response(&self) -> Result<()>;
136
137 /// Interrupt/cancel the current response.
138 async fn interrupt(&self) -> Result<()>;
139
140 /// Send a raw client event.
141 async fn send_event(&self, event: ClientEvent) -> Result<()>;
142
143 /// Get the next event from the server.
144 ///
145 /// Returns `None` when the session is closed.
146 async fn next_event(&self) -> Option<Result<ServerEvent>>;
147
148 /// Get a stream of server events.
149 fn events(&self) -> Pin<Box<dyn Stream<Item = Result<ServerEvent>> + Send + '_>>;
150
151 /// Close the session gracefully.
152 async fn close(&self) -> Result<()>;
153
154 /// Attempt to mutate the session parameters mid-flight.
155 ///
156 /// For providers that support native hot-swapping (e.g., OpenAI), this
157 /// mutates the parameters without tearing down the connection and returns `Ok(ContextMutationOutcome::Applied)`.
158 /// For providers that require a static configuration (e.g., Gemini), this
159 /// returns `Ok(ContextMutationOutcome::RequiresResumption(config))` to signal
160 /// the runner to queue a session reconnect or resumption safely.
161 async fn mutate_context(
162 &self,
163 config: crate::config::RealtimeConfig,
164 ) -> Result<ContextMutationOutcome>;
165}
166
167/// Extension trait for RealtimeSession with convenience methods.
168#[async_trait]
169pub trait RealtimeSessionExt: RealtimeSession {
170 /// Send audio and wait for the response to complete.
171 async fn send_audio_and_wait(&self, audio: &AudioChunk) -> Result<Vec<ServerEvent>> {
172 self.send_audio(audio).await?;
173 self.commit_audio().await?;
174
175 let mut events = Vec::new();
176 while let Some(event) = self.next_event().await {
177 let event = event?;
178 let is_done = matches!(&event, ServerEvent::ResponseDone { .. });
179 events.push(event);
180 if is_done {
181 break;
182 }
183 }
184 Ok(events)
185 }
186
187 /// Send text and wait for the response to complete.
188 async fn send_text_and_wait(&self, text: &str) -> Result<Vec<ServerEvent>> {
189 self.send_text(text).await?;
190 self.create_response().await?;
191
192 let mut events = Vec::new();
193 while let Some(event) = self.next_event().await {
194 let event = event?;
195 let is_done = matches!(&event, ServerEvent::ResponseDone { .. });
196 events.push(event);
197 if is_done {
198 break;
199 }
200 }
201 Ok(events)
202 }
203
204 /// Collect all audio chunks from a response (as raw bytes).
205 async fn collect_audio(&self) -> Result<Vec<Vec<u8>>> {
206 let mut audio_chunks = Vec::new();
207 while let Some(event) = self.next_event().await {
208 match event? {
209 ServerEvent::AudioDelta { delta, .. } => {
210 audio_chunks.push(delta);
211 }
212 ServerEvent::ResponseDone { .. } => break,
213 ServerEvent::Error { error, .. } => {
214 return Err(crate::error::RealtimeError::server(
215 error.code.unwrap_or_default(),
216 error.message,
217 ));
218 }
219 _ => {}
220 }
221 }
222 Ok(audio_chunks)
223 }
224}
225
226// Blanket implementation
227impl<T: RealtimeSession> RealtimeSessionExt for T {}
228
229/// A boxed session type for dynamic dispatch.
230pub type BoxedSession = Box<dyn RealtimeSession>;
231
232#[cfg(test)]
233mod disconnect_reason_tests {
234 use super::DisconnectReason;
235
236 /// The string is what ends up in an application's terminal record, so the
237 /// shape is a contract, not a debug convenience.
238 #[test]
239 fn a_code_and_reason_render_together() {
240 let reason =
241 DisconnectReason { code: Some(1008), reason: "The operation was aborted.".to_string() };
242
243 assert_eq!(reason.to_string(), "1008:The operation was aborted.");
244 }
245
246 /// Providers do not always send both halves, and a close with only a code
247 /// still carries the distinction the caller needs.
248 #[test]
249 fn either_half_alone_still_says_something() {
250 assert_eq!(
251 DisconnectReason { code: Some(1011), reason: String::new() }.to_string(),
252 "1011"
253 );
254 assert_eq!(
255 DisconnectReason { code: None, reason: "going away".to_string() }.to_string(),
256 "going away"
257 );
258 }
259
260 /// A transport that simply died sends no close frame at all. Rendering that
261 /// as an empty string would produce terminal records ending in a bare
262 /// separator, which reads as truncation rather than absence.
263 #[test]
264 fn an_absent_close_frame_is_named_rather_than_blank() {
265 assert_eq!(DisconnectReason { code: None, reason: String::new() }.to_string(), "unknown");
266 }
267}