use crate::audio::AudioChunk;
use crate::error::Result;
use crate::events::{ClientEvent, ServerEvent, ToolResponse};
use async_trait::async_trait;
use futures::Stream;
use std::pin::Pin;
#[derive(Debug, Clone)]
pub enum ContextMutationOutcome {
Applied,
RequiresResumption(Box<crate::config::RealtimeConfig>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisconnectReason {
pub code: Option<u16>,
pub reason: String,
}
impl std::fmt::Display for DisconnectReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.code {
Some(code) if !self.reason.is_empty() => write!(f, "{code}:{}", self.reason),
Some(code) => write!(f, "{code}"),
None if !self.reason.is_empty() => write!(f, "{}", self.reason),
None => write!(f, "unknown"),
}
}
}
#[async_trait]
pub trait RealtimeSession: Send + Sync {
fn session_id(&self) -> &str;
fn is_connected(&self) -> bool;
fn disconnect_reason(&self) -> Option<DisconnectReason> {
None
}
async fn send_audio(&self, audio: &AudioChunk) -> Result<()>;
async fn send_audio_base64(&self, audio_base64: &str) -> Result<()>;
async fn send_text(&self, text: &str) -> Result<()>;
async fn send_video_frame(&self, _mime_type: &str, _data_base64: &str) -> Result<()> {
Ok(())
}
async fn send_tool_response(&self, response: ToolResponse) -> Result<()>;
async fn send_tool_output(&self, response: ToolResponse) -> Result<()> {
self.send_tool_response(response).await
}
async fn commit_audio(&self) -> Result<()>;
async fn clear_audio(&self) -> Result<()>;
async fn create_response(&self) -> Result<()>;
async fn interrupt(&self) -> Result<()>;
async fn send_event(&self, event: ClientEvent) -> Result<()>;
async fn next_event(&self) -> Option<Result<ServerEvent>>;
fn events(&self) -> Pin<Box<dyn Stream<Item = Result<ServerEvent>> + Send + '_>>;
async fn close(&self) -> Result<()>;
async fn mutate_context(
&self,
config: crate::config::RealtimeConfig,
) -> Result<ContextMutationOutcome>;
}
#[async_trait]
pub trait RealtimeSessionExt: RealtimeSession {
async fn send_audio_and_wait(&self, audio: &AudioChunk) -> Result<Vec<ServerEvent>> {
self.send_audio(audio).await?;
self.commit_audio().await?;
let mut events = Vec::new();
while let Some(event) = self.next_event().await {
let event = event?;
let is_done = matches!(&event, ServerEvent::ResponseDone { .. });
events.push(event);
if is_done {
break;
}
}
Ok(events)
}
async fn send_text_and_wait(&self, text: &str) -> Result<Vec<ServerEvent>> {
self.send_text(text).await?;
self.create_response().await?;
let mut events = Vec::new();
while let Some(event) = self.next_event().await {
let event = event?;
let is_done = matches!(&event, ServerEvent::ResponseDone { .. });
events.push(event);
if is_done {
break;
}
}
Ok(events)
}
async fn collect_audio(&self) -> Result<Vec<Vec<u8>>> {
let mut audio_chunks = Vec::new();
while let Some(event) = self.next_event().await {
match event? {
ServerEvent::AudioDelta { delta, .. } => {
audio_chunks.push(delta);
}
ServerEvent::ResponseDone { .. } => break,
ServerEvent::Error { error, .. } => {
return Err(crate::error::RealtimeError::server(
error.code.unwrap_or_default(),
error.message,
));
}
_ => {}
}
}
Ok(audio_chunks)
}
}
impl<T: RealtimeSession> RealtimeSessionExt for T {}
pub type BoxedSession = Box<dyn RealtimeSession>;
#[cfg(test)]
mod disconnect_reason_tests {
use super::DisconnectReason;
#[test]
fn a_code_and_reason_render_together() {
let reason =
DisconnectReason { code: Some(1008), reason: "The operation was aborted.".to_string() };
assert_eq!(reason.to_string(), "1008:The operation was aborted.");
}
#[test]
fn either_half_alone_still_says_something() {
assert_eq!(
DisconnectReason { code: Some(1011), reason: String::new() }.to_string(),
"1011"
);
assert_eq!(
DisconnectReason { code: None, reason: "going away".to_string() }.to_string(),
"going away"
);
}
#[test]
fn an_absent_close_frame_is_named_rather_than_blank() {
assert_eq!(DisconnectReason { code: None, reason: String::new() }.to_string(), "unknown");
}
}