use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use anyhow::anyhow;
use bytes::Bytes;
use core::fmt;
use futures::channel::mpsc::{self, Receiver, Sender};
use futures::stream::StreamExt;
use futures::{future::poll_fn, pin_mut, select, FutureExt, SinkExt, Stream};
use http::Request;
use pin_project::pin_project;
use serde::Serialize;
use tokio_tungstenite::{tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream};
use tungstenite::handshake::client;
use uuid::Uuid;
use crate::agent::messages::{
ForceEndTurnMessage, FunctionCallResponseMessage, InjectAgentMessageMessage,
InjectUserMessageMessage, KeepAliveMessage, UpdateListenMessage, UpdatePromptMessage,
UpdateSpeakMessage, UpdateThinkMessage,
};
use crate::agent::response::AgentResponse;
use crate::agent::settings::SettingsMessage;
use crate::{Deepgram, DeepgramError, Result};
const AGENT_WS_URL: &str = "wss://agent.deepgram.com/v1/agent/converse";
const CLOSE_DRAIN_IDLE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub struct Agent<'a>(&'a Deepgram);
impl Deepgram {
pub fn agent(&self) -> Agent<'_> {
self.into()
}
}
impl<'a> From<&'a Deepgram> for Agent<'a> {
fn from(deepgram: &'a Deepgram) -> Self {
Self(deepgram)
}
}
impl Agent<'_> {
pub fn deepgram(&self) -> &Deepgram {
self.0
}
pub async fn start(&self) -> Result<(AgentHandle, AgentEventStream)> {
self.start_at_url(AGENT_WS_URL).await
}
pub async fn start_at_url(&self, url: &str) -> Result<(AgentHandle, AgentEventStream)> {
let url: url::Url = url.parse().map_err(|_| DeepgramError::InvalidUrl)?;
validate_agent_url(&url)?;
let host = crate::websocket_host_header(&url).ok_or(DeepgramError::InvalidUrl)?;
let request = {
let http_builder = Request::builder()
.method("GET")
.uri(url.to_string())
.header("sec-websocket-key", client::generate_key())
.header("host", host)
.header("connection", "upgrade")
.header("upgrade", "websocket")
.header("sec-websocket-version", "13")
.header("user-agent", crate::USER_AGENT);
let http_builder = if let Some(auth) = &self.0.auth {
http_builder.header("authorization", auth.header_value())
} else {
http_builder
};
http_builder.body(())?
};
let tls = self.0.tls.resolve_for(&url).await;
let (ws_stream, upgrade_response) = tokio_tungstenite::connect_async_tls_with_config(
request,
None,
false,
Some(tls.connector()),
)
.await
.map_err(|err| tls.connect_error(err, url.host_str().unwrap_or_default()))?;
let request_id = upgrade_response
.headers()
.get("dg-request-id")
.and_then(|h| h.to_str().ok())
.and_then(|s| Uuid::parse_str(s).ok());
let (message_tx, message_rx) = mpsc::channel(256);
let (response_tx, response_rx) = mpsc::channel(256);
tokio::task::spawn(run_agent_worker(ws_stream, message_rx, response_tx));
let handle = AgentHandle {
message_tx,
request_id,
};
let stream = AgentEventStream {
rx: response_rx,
request_id,
};
Ok((handle, stream))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct InsecureAgentUrl {
pub url: String,
}
impl fmt::Display for InsecureAgentUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"refusing to open a Voice Agent session at `{}`: the Deepgram credential is sent in \
the Authorization header and `ws://` would transmit it in cleartext. Use `wss://`; \
plain `ws://` is only accepted for loopback hosts (localhost, 127.0.0.0/8, ::1) in \
local tests",
self.url
)
}
}
impl std::error::Error for InsecureAgentUrl {}
fn validate_agent_url(url: &url::Url) -> Result<()> {
match url.scheme() {
"wss" => Ok(()),
"ws" if is_loopback_host(url) => Ok(()),
"ws" => Err(InsecureAgentUrl {
url: format!(
"{}://{}",
url.scheme(),
crate::websocket_host_header(url).unwrap_or_default()
),
}
.into()),
_ => Err(DeepgramError::InvalidUrl),
}
}
fn is_loopback_host(url: &url::Url) -> bool {
match url.host() {
Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(addr)) => addr.is_loopback(),
Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
None => false,
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[allow(clippy::large_enum_variant)]
pub enum AgentEvent {
Json(AgentResponse),
Audio(Bytes),
}
#[derive(Debug)]
pub struct AgentHandle {
message_tx: Sender<WsMessage>,
request_id: Option<Uuid>,
}
impl AgentHandle {
pub fn request_id(&self) -> Option<Uuid> {
self.request_id
}
pub async fn send_settings(&mut self, message: SettingsMessage) -> Result<()> {
self.send_json(&message).await
}
pub async fn send_update_listen(&mut self, message: UpdateListenMessage) -> Result<()> {
self.send_json(&message).await
}
pub async fn send_update_speak(&mut self, message: UpdateSpeakMessage) -> Result<()> {
self.send_json(&message).await
}
pub async fn send_update_think(&mut self, message: UpdateThinkMessage) -> Result<()> {
self.send_json(&message).await
}
pub async fn send_update_prompt(&mut self, message: UpdatePromptMessage) -> Result<()> {
self.send_json(&message).await
}
pub async fn send_inject_user_message(
&mut self,
message: InjectUserMessageMessage,
) -> Result<()> {
self.send_json(&message).await
}
pub async fn send_inject_agent_message(
&mut self,
message: InjectAgentMessageMessage,
) -> Result<()> {
self.send_json(&message).await
}
pub async fn send_function_call_response(
&mut self,
message: FunctionCallResponseMessage,
) -> Result<()> {
self.send_json(&message).await
}
pub async fn send_data(&mut self, data: Vec<u8>) -> Result<()> {
self.message_tx
.send(WsMessage::Audio(data))
.await
.map_err(|err| DeepgramError::InternalClientError(err.into()))
}
pub async fn keep_alive(&mut self) -> Result<()> {
self.send_json(&KeepAliveMessage::default()).await
}
pub async fn force_end_turn(&mut self) -> Result<()> {
self.send_json(&ForceEndTurnMessage::default()).await
}
pub async fn close(&mut self) -> Result<()> {
if !self.message_tx.is_closed() {
self.message_tx
.send(WsMessage::Close)
.await
.map_err(|err| DeepgramError::InternalClientError(err.into()))?;
self.message_tx.close_channel();
}
Ok(())
}
pub async fn send_raw_json(&mut self, value: serde_json::Value) -> Result<()> {
self.send_json(&value).await
}
async fn send_json<T: Serialize>(&mut self, value: &T) -> Result<()> {
let serialized = serde_json::to_string(value)?;
self.message_tx
.send(WsMessage::Json(serialized))
.await
.map_err(|err| DeepgramError::InternalClientError(err.into()))
}
}
#[derive(Debug)]
#[pin_project]
pub struct AgentEventStream {
#[pin]
rx: Receiver<Result<AgentEvent>>,
request_id: Option<Uuid>,
}
impl AgentEventStream {
pub fn request_id(&self) -> Option<Uuid> {
self.request_id
}
}
impl Stream for AgentEventStream {
type Item = Result<AgentEvent>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.project().rx.poll_next(cx)
}
}
#[derive(Debug)]
enum WsMessage {
Json(String),
Audio(Vec<u8>),
Close,
}
async fn run_agent_worker(
ws_stream: WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>,
message_rx: Receiver<WsMessage>,
response_tx: Sender<Result<AgentEvent>>,
) -> Result<()> {
let (ws_send, ws_recv) = ws_stream.split();
drive_agent_session(
ws_send,
ws_recv,
message_rx,
response_tx,
CLOSE_DRAIN_IDLE_TIMEOUT,
)
.await
}
async fn drive_agent_session<Si, St>(
mut ws_send: Si,
ws_recv: St,
mut message_rx: Receiver<WsMessage>,
mut response_tx: Sender<Result<AgentEvent>>,
close_drain_idle_timeout: Duration,
) -> Result<()>
where
Si: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
St: Stream<Item = std::result::Result<Message, tungstenite::Error>> + Unpin,
{
let mut ws_recv = ws_recv.fuse();
let mut is_open = true;
let mut error_tx = response_tx.clone();
enum Step {
Inbound(Option<std::result::Result<Message, tungstenite::Error>>),
Outbound(Option<WsMessage>),
ResponsesClosed,
DrainTimedOut,
}
loop {
let step = {
let inbound = async {
match poll_fn(|cx| response_tx.poll_ready(cx)).await {
Err(_) => Step::ResponsesClosed,
Ok(()) if !is_open => {
match tokio::time::timeout(close_drain_idle_timeout, ws_recv.next()).await {
Ok(inbound) => Step::Inbound(inbound),
Err(_elapsed) => Step::DrainTimedOut,
}
}
Ok(()) => Step::Inbound(ws_recv.next().await),
}
}
.fuse();
pin_mut!(inbound);
if is_open {
select! {
step = inbound => step,
message = message_rx.next() => Step::Outbound(message),
}
} else {
inbound.await
}
};
match step {
Step::ResponsesClosed => break,
Step::DrainTimedOut => {
let _ =
response_tx.start_send(Err(DeepgramError::UnexpectedServerResponse(anyhow!(
"the Voice Agent server sent nothing for {}s after the Close frame \
and did not complete the closing handshake; dropping the \
connection",
close_drain_idle_timeout.as_secs_f64()
))));
break;
}
Step::Inbound(inbound) => {
if let std::ops::ControlFlow::Break(stop) =
handle_agent_inbound(inbound, &mut ws_send, &mut response_tx).await
{
if matches!(stop, InboundStop::TransportGone) {
is_open = false;
}
break;
}
}
Step::Outbound(Some(WsMessage::Json(json))) => {
if let Err(err) = ws_send.send(Message::Text(json.into())).await {
let _ = error_tx.try_send(Err(err.into()));
is_open = false;
break;
}
}
Step::Outbound(Some(WsMessage::Audio(audio))) => {
if let Err(err) = ws_send.send(Message::Binary(Bytes::from(audio))).await {
let _ = error_tx.try_send(Err(err.into()));
is_open = false;
break;
}
}
Step::Outbound(Some(WsMessage::Close)) | Step::Outbound(None) => {
let _ = ws_send.send(Message::Close(None)).await;
is_open = false;
}
}
}
message_rx.close();
while message_rx.try_recv().is_ok() {}
if is_open {
let _ = ws_send.send(Message::Close(None)).await;
}
response_tx.close_channel();
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InboundStop {
TransportUsable,
TransportGone,
}
async fn handle_agent_inbound<S>(
inbound: Option<std::result::Result<Message, tungstenite::Error>>,
ws_send: &mut S,
response_tx: &mut Sender<Result<AgentEvent>>,
) -> std::ops::ControlFlow<InboundStop>
where
S: futures::Sink<Message> + Unpin,
{
use std::ops::ControlFlow;
match inbound {
Some(Ok(Message::Text(text))) => {
let parsed: std::result::Result<AgentResponse, _> = serde_json::from_str(&text);
let event = parsed.map(AgentEvent::Json).map_err(DeepgramError::from);
if response_tx.start_send(event).is_err() {
return ControlFlow::Break(InboundStop::TransportUsable);
}
}
Some(Ok(Message::Binary(bytes))) => {
if response_tx
.start_send(Ok(AgentEvent::Audio(bytes)))
.is_err()
{
return ControlFlow::Break(InboundStop::TransportUsable);
}
}
Some(Ok(Message::Ping(payload))) => {
let _ = ws_send.send(Message::Pong(payload)).await;
}
Some(Ok(Message::Pong(_))) => {
}
Some(Ok(Message::Close(None))) => return ControlFlow::Break(InboundStop::TransportUsable),
Some(Ok(Message::Close(Some(frame)))) => {
if u16::from(frame.code) != 1000 {
let _ = response_tx.start_send(Err(DeepgramError::WebsocketClose {
code: frame.code.into(),
reason: frame.reason.to_string(),
}));
}
return ControlFlow::Break(InboundStop::TransportUsable);
}
Some(Ok(Message::Frame(_))) => {
}
Some(Err(err)) => {
let _ = response_tx.start_send(Err(err.into()));
return ControlFlow::Break(InboundStop::TransportGone);
}
None => return ControlFlow::Break(InboundStop::TransportGone),
}
ControlFlow::Continue(())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::agent::messages::{InjectAgentBehavior, KeepAliveMessage};
use crate::agent::settings::{AgentConfig, SettingsMessage};
use crate::agent::{
audio::{AudioConfig, AudioInput, AudioInputEncoding},
listen::{AgentListenProvider, AgentListenSettings, DeepgramListenV2Provider},
speak::{DeepgramSpeakModel, DeepgramSpeakProvider, SpeakProvider, SpeakSettings},
think::{OpenAiModel, OpenAiThinkProvider, ThinkProvider, ThinkSettings},
InlineAgentConfig,
};
struct FailingSink;
impl futures::Sink<Message> for FailingSink {
type Error = tungstenite::Error;
fn poll_ready(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn start_send(
self: Pin<&mut Self>,
_item: Message,
) -> std::result::Result<(), Self::Error> {
Err(tungstenite::Error::AlreadyClosed)
}
fn poll_flush(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn poll_close(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
}
struct SilentStream;
impl Stream for SilentStream {
type Item = std::result::Result<Message, tungstenite::Error>;
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Poll::Pending
}
}
#[derive(Clone, Default)]
struct RecordingSink(std::sync::Arc<std::sync::Mutex<Vec<Message>>>);
impl RecordingSink {
fn written(&self) -> Vec<Message> {
self.0.lock().expect("sink mutex is never poisoned").clone()
}
}
impl futures::Sink<Message> for RecordingSink {
type Error = tungstenite::Error;
fn poll_ready(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn start_send(self: Pin<&mut Self>, item: Message) -> std::result::Result<(), Self::Error> {
self.0
.lock()
.expect("sink mutex is never poisoned")
.push(item);
Ok(())
}
fn poll_flush(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn poll_close(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn write_failure_ends_worker_after_single_error_and_fails_later_sends() {
let (message_tx, message_rx) = mpsc::channel::<WsMessage>(8);
let (response_tx, mut response_rx) = mpsc::channel::<Result<AgentEvent>>(8);
let worker = tokio::spawn(drive_agent_session(
FailingSink,
SilentStream,
message_rx,
response_tx,
CLOSE_DRAIN_IDLE_TIMEOUT,
));
let mut handle = AgentHandle {
message_tx,
request_id: None,
};
handle.send_data(vec![0u8; 8]).await.unwrap();
let first = tokio::time::timeout(Duration::from_secs(5), response_rx.next())
.await
.expect("the worker must end promptly after a failed write")
.expect("the terminal error must be forwarded");
assert!(first.is_err(), "expected the write error, got {first:?}");
assert!(
tokio::time::timeout(Duration::from_secs(5), response_rx.next())
.await
.expect("the event stream must end")
.is_none(),
"exactly one error may be forwarded for one fault"
);
tokio::time::timeout(Duration::from_secs(5), worker)
.await
.expect("the worker task must return")
.expect("the worker task must not panic")
.expect("the worker returns Ok after terminal cleanup");
assert!(
handle.send_data(vec![0u8; 8]).await.is_err(),
"send_data after a terminal write error must fail, not be dropped"
);
assert!(
handle.keep_alive().await.is_err(),
"keep_alive after a terminal write error must fail, not be dropped"
);
assert!(handle.close().await.is_ok(), "close is a no-op once ended");
}
#[tokio::test]
async fn drain_after_close_is_bounded_and_ends_the_stream() {
let (message_tx, message_rx) = mpsc::channel::<WsMessage>(8);
let (response_tx, mut response_rx) = mpsc::channel::<Result<AgentEvent>>(8);
let sink = RecordingSink::default();
let worker = tokio::spawn(drive_agent_session(
sink.clone(),
SilentStream,
message_rx,
response_tx,
Duration::from_millis(200),
));
let mut handle = AgentHandle {
message_tx,
request_id: None,
};
handle.close().await.expect("close is accepted");
let first = tokio::time::timeout(Duration::from_secs(5), response_rx.next())
.await
.expect("the worker must give up within the drain timeout")
.expect("the drain timeout is surfaced before the stream ends");
assert!(
matches!(first, Err(DeepgramError::UnexpectedServerResponse(_))),
"expected the drain-timeout error, got {first:?}"
);
assert!(
tokio::time::timeout(Duration::from_secs(5), response_rx.next())
.await
.expect("the event stream must end")
.is_none(),
"the stream must end after the drain-timeout error"
);
tokio::time::timeout(Duration::from_secs(5), worker)
.await
.expect("the worker task must return")
.expect("the worker task must not panic")
.expect("the worker returns Ok after terminal cleanup");
let written = sink.written();
assert_eq!(
written
.iter()
.filter(|message| matches!(message, Message::Close(_)))
.count(),
1,
"expected exactly one Close frame, got {written:?}"
);
assert!(
handle.send_data(vec![0u8; 8]).await.is_err(),
"sends must fail once the worker has given up on the close handshake"
);
}
#[test]
fn agent_url_is_well_known_saas_endpoint() {
let url: url::Url = AGENT_WS_URL.parse().expect("valid URL");
assert_eq!(url.scheme(), "wss");
assert_eq!(url.host_str(), Some("agent.deepgram.com"));
assert_eq!(url.path(), "/v1/agent/converse");
}
#[test]
fn all_client_messages_serialize_cleanly() {
let settings = SettingsMessage::new(
AudioConfig::new(
Some(AudioInput::new(AudioInputEncoding::Linear16, 16_000)),
None,
),
AgentConfig::inline(InlineAgentConfig::from_parts(
AgentListenSettings::new(AgentListenProvider::DeepgramV2(
DeepgramListenV2Provider::new("flux-general-en"),
)),
ThinkSettings::new(ThinkProvider::OpenAi(OpenAiThinkProvider::new(
OpenAiModel::Gpt4oMini,
))),
SpeakSettings::new(SpeakProvider::Deepgram(DeepgramSpeakProvider::new(
DeepgramSpeakModel::Aura2ThaliaEn,
))),
)),
);
serde_json::to_string(&settings).expect("settings serializes");
serde_json::to_string(&UpdateSpeakMessage::one(SpeakSettings::new(
SpeakProvider::Deepgram(DeepgramSpeakProvider::new(
DeepgramSpeakModel::AuraAsteriaEn,
)),
)))
.expect("update_speak serializes");
serde_json::to_string(&UpdateThinkMessage::one(ThinkSettings::new(
ThinkProvider::OpenAi(OpenAiThinkProvider::new(OpenAiModel::Gpt4o)),
)))
.expect("update_think serializes");
serde_json::to_string(&UpdatePromptMessage::new("hi")).expect("update_prompt serializes");
serde_json::to_string(&InjectUserMessageMessage::new("hello"))
.expect("inject_user serializes");
serde_json::to_string(
&InjectAgentMessageMessage::new("hi").with_behavior(InjectAgentBehavior::Queue),
)
.expect("inject_agent serializes");
serde_json::to_string(&FunctionCallResponseMessage::with_id("f1", "fn", "{}"))
.expect("function_call_response serializes");
serde_json::to_string(&KeepAliveMessage::default()).expect("keep_alive serializes");
serde_json::to_string(&UpdateListenMessage::new(AgentListenSettings::new(
AgentListenProvider::DeepgramV2(DeepgramListenV2Provider::new("flux-general-en")),
)))
.expect("update_listen serializes");
serde_json::to_string(&ForceEndTurnMessage::default()).expect("force_end_turn serializes");
}
fn parse(url: &str) -> url::Url {
url.parse().expect("valid URL")
}
#[test]
fn tls_rule_allows_wss_anywhere() {
assert!(validate_agent_url(&parse("wss://agent.deepgram.com/v1/agent/converse")).is_ok());
assert!(validate_agent_url(&parse("wss://agent.internal.example:8443/agent")).is_ok());
assert!(validate_agent_url(&parse(AGENT_WS_URL)).is_ok());
}
#[test]
fn tls_rule_allows_cleartext_only_for_loopback() {
for ok in [
"ws://127.0.0.1:9000/v1/agent/converse",
"ws://127.0.0.1/v1/agent/converse",
"ws://127.42.0.7:1234/",
"ws://localhost:9000/",
"ws://LOCALHOST:9000/",
"ws://[::1]:9000/",
] {
assert!(
validate_agent_url(&parse(ok)).is_ok(),
"{ok} should be allowed"
);
}
}
#[test]
fn tls_rule_rejects_remote_cleartext_with_clear_error() {
for bad in [
"ws://agent.deepgram.com/v1/agent/converse",
"ws://10.0.0.5:9000/agent",
"ws://192.168.1.20/agent",
"ws://[2001:db8::1]:9000/agent",
"ws://localhost.example.com/agent",
"ws://evil-localhost/agent",
] {
let err = validate_agent_url(&parse(bad)).expect_err(bad);
let insecure = match &err {
DeepgramError::InsecureAgentUrl(insecure) => insecure,
other => panic!("{bad}: expected InsecureAgentUrl, got {other:?}"),
};
let parsed = parse(bad);
let expected_origin = format!(
"ws://{}",
crate::websocket_host_header(&parsed).expect("bad URLs here all have hosts")
);
assert_eq!(insecure.url, expected_origin, "{bad}");
let text = err.to_string();
assert!(text.contains("cleartext"), "{bad}: {text}");
assert!(text.contains("wss://"), "{bad}: {text}");
assert!(text.contains("Authorization"), "{bad}: {text}");
}
}
#[test]
fn insecure_url_error_omits_userinfo_path_and_query() {
let bad = "ws://alice:s3cret@agent.example.com:8080/v1/agent/converse?token=abc";
let err = validate_agent_url(&parse(bad)).expect_err(bad);
let DeepgramError::InsecureAgentUrl(insecure) = &err else {
panic!("expected InsecureAgentUrl, got {err:?}");
};
assert_eq!(insecure.url, "ws://agent.example.com:8080");
let text = err.to_string();
for secret in ["alice", "s3cret", "token=abc", "/v1/agent/converse"] {
assert!(
!text.contains(secret),
"error text leaked {secret:?}: {text}"
);
}
assert!(text.contains("ws://agent.example.com:8080"), "{text}");
}
#[test]
fn tls_rule_rejects_non_websocket_schemes_as_invalid_url() {
for bad in [
"http://127.0.0.1:9000/",
"https://agent.deepgram.com/",
"ftp://x/",
] {
assert!(
matches!(
validate_agent_url(&parse(bad)),
Err(DeepgramError::InvalidUrl)
),
"{bad}"
);
}
}
#[test]
fn agent_event_audio_variant_round_trip_via_bytes() {
let bytes = Bytes::from_static(&[0x10, 0x20, 0x30]);
let event = AgentEvent::Audio(bytes.clone());
match event {
AgentEvent::Audio(b) => assert_eq!(b, bytes),
AgentEvent::Json(_) => panic!("expected Audio"),
}
}
}