Skip to main content

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}