use serde::{Deserialize, Serialize};
use crate::agent::listen::AgentListenSettings;
use crate::agent::settings::SettingsMessage;
use crate::agent::speak::SpeakSettings;
use crate::agent::think::ThinkSettings;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
#[allow(clippy::large_enum_variant)]
pub enum ClientMessage {
Settings(SettingsMessage),
UpdateListen(UpdateListenMessage),
UpdateSpeak(UpdateSpeakMessage),
UpdateThink(UpdateThinkMessage),
UpdatePrompt(UpdatePromptMessage),
InjectUserMessage(InjectUserMessageMessage),
InjectAgentMessage(InjectAgentMessageMessage),
FunctionCallResponse(FunctionCallResponseMessage),
KeepAlive(KeepAliveMessage),
ForceEndTurn(ForceEndTurnMessage),
}
impl ClientMessage {
pub fn settings(message: SettingsMessage) -> Self {
Self::Settings(message)
}
pub fn update_listen(listen: AgentListenSettings) -> Self {
Self::UpdateListen(UpdateListenMessage::new(listen))
}
pub fn force_end_turn() -> Self {
Self::ForceEndTurn(ForceEndTurnMessage::default())
}
pub fn update_speak_one(speak: SpeakSettings) -> Self {
Self::UpdateSpeak(UpdateSpeakMessage::one(speak))
}
pub fn update_speak_many(speak: impl IntoIterator<Item = SpeakSettings>) -> Self {
Self::UpdateSpeak(UpdateSpeakMessage::many(speak))
}
pub fn update_think_one(think: ThinkSettings) -> Self {
Self::UpdateThink(UpdateThinkMessage::one(think))
}
pub fn update_think_many(think: impl IntoIterator<Item = ThinkSettings>) -> Self {
Self::UpdateThink(UpdateThinkMessage::many(think))
}
pub fn update_prompt(prompt: impl Into<String>) -> Self {
Self::UpdatePrompt(UpdatePromptMessage::new(prompt))
}
pub fn inject_user_message(content: impl Into<String>) -> Self {
Self::InjectUserMessage(InjectUserMessageMessage::new(content))
}
pub fn inject_agent_message(message: impl Into<String>) -> Self {
Self::InjectAgentMessage(InjectAgentMessageMessage::new(message))
}
pub fn function_call_response(name: impl Into<String>, content: impl Into<String>) -> Self {
Self::FunctionCallResponse(FunctionCallResponseMessage::new(name, content))
}
pub fn keep_alive() -> Self {
Self::KeepAlive(KeepAliveMessage::default())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum UpdateListenType {
#[default]
UpdateListen,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UpdateListenMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: UpdateListenType,
pub listen: AgentListenSettings,
}
impl UpdateListenMessage {
pub fn new(listen: AgentListenSettings) -> Self {
Self {
message_type: UpdateListenType::default(),
listen,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum UpdateSpeakType {
#[default]
UpdateSpeak,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UpdateSpeakMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: UpdateSpeakType,
#[serde(
serialize_with = "serialize_speak_one_or_many",
deserialize_with = "deserialize_speak_one_or_many"
)]
pub speak: Vec<SpeakSettings>,
}
impl UpdateSpeakMessage {
pub fn one(speak: SpeakSettings) -> Self {
Self {
message_type: UpdateSpeakType::default(),
speak: vec![speak],
}
}
pub fn many(speak: impl IntoIterator<Item = SpeakSettings>) -> Self {
Self {
message_type: UpdateSpeakType::default(),
speak: speak.into_iter().collect(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum UpdateThinkType {
#[default]
UpdateThink,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UpdateThinkMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: UpdateThinkType,
#[serde(
serialize_with = "serialize_think_one_or_many",
deserialize_with = "deserialize_think_one_or_many"
)]
pub think: Vec<ThinkSettings>,
}
impl UpdateThinkMessage {
pub fn one(think: ThinkSettings) -> Self {
Self {
message_type: UpdateThinkType::default(),
think: vec![think],
}
}
pub fn many(think: impl IntoIterator<Item = ThinkSettings>) -> Self {
Self {
message_type: UpdateThinkType::default(),
think: think.into_iter().collect(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum UpdatePromptType {
#[default]
UpdatePrompt,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UpdatePromptMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: UpdatePromptType,
pub prompt: String,
}
impl UpdatePromptMessage {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
message_type: UpdatePromptType::default(),
prompt: prompt.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum InjectUserMessageType {
#[default]
InjectUserMessage,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct InjectUserMessageMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: InjectUserMessageType,
pub content: String,
}
impl InjectUserMessageMessage {
pub fn new(content: impl Into<String>) -> Self {
Self {
message_type: InjectUserMessageType::default(),
content: content.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum InjectAgentMessageType {
#[default]
InjectAgentMessage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum InjectAgentBehavior {
#[default]
Default,
Queue,
Interrupt,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct InjectAgentMessageMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: InjectAgentMessageType,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub behavior: Option<InjectAgentBehavior>,
}
impl InjectAgentMessageMessage {
pub fn new(message: impl Into<String>) -> Self {
Self {
message_type: InjectAgentMessageType::default(),
message: message.into(),
behavior: None,
}
}
pub fn with_behavior(mut self, behavior: InjectAgentBehavior) -> Self {
self.behavior = Some(behavior);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum FunctionCallResponseType {
#[default]
FunctionCallResponse,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FunctionCallResponseMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: FunctionCallResponseType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub name: String,
pub content: String,
}
impl FunctionCallResponseMessage {
pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
Self {
message_type: FunctionCallResponseType::default(),
id: None,
name: name.into(),
content: content.into(),
}
}
pub fn with_id(
id: impl Into<String>,
name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self {
message_type: FunctionCallResponseType::default(),
id: Some(id.into()),
name: name.into(),
content: content.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum KeepAliveType {
#[default]
KeepAlive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct KeepAliveMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: KeepAliveType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ForceEndTurnType {
#[default]
ForceEndTurn,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ForceEndTurnMessage {
#[serde(rename = "type", default)]
#[allow(missing_docs)]
pub message_type: ForceEndTurnType,
}
fn serialize_speak_one_or_many<S>(values: &[SpeakSettings], ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if values.len() == 1 {
values[0].serialize(ser)
} else {
values.serialize(ser)
}
}
fn deserialize_speak_one_or_many<'de, D>(de: D) -> Result<Vec<SpeakSettings>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
enum OneOrMany {
Many(Vec<SpeakSettings>),
One(SpeakSettings),
}
Ok(match OneOrMany::deserialize(de)? {
OneOrMany::One(x) => vec![x],
OneOrMany::Many(v) => v,
})
}
fn serialize_think_one_or_many<S>(values: &[ThinkSettings], ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if values.len() == 1 {
values[0].serialize(ser)
} else {
values.serialize(ser)
}
}
fn deserialize_think_one_or_many<'de, D>(de: D) -> Result<Vec<ThinkSettings>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
enum OneOrMany {
Many(Vec<ThinkSettings>),
One(ThinkSettings),
}
Ok(match OneOrMany::deserialize(de)? {
OneOrMany::One(x) => vec![x],
OneOrMany::Many(v) => v,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::audio::{AudioConfig, AudioInput, AudioInputEncoding};
use crate::agent::listen::{
AgentListenProvider, AgentListenSettings, DeepgramListenV2Provider,
};
use crate::agent::settings::{AgentConfig, InlineAgentConfig};
use crate::agent::speak::{
DeepgramSpeakExpressivity, DeepgramSpeakModel, DeepgramSpeakProvider, SpeakProvider,
SpeakSettings,
};
use crate::agent::think::{OpenAiModel, OpenAiThinkProvider, ThinkProvider};
use serde_json::json;
fn sample_speak() -> SpeakSettings {
SpeakSettings::new(SpeakProvider::Deepgram(DeepgramSpeakProvider::new(
DeepgramSpeakModel::Aura2ThaliaEn,
)))
}
fn sample_think() -> ThinkSettings {
ThinkSettings::new(ThinkProvider::OpenAi(OpenAiThinkProvider::new(
OpenAiModel::Gpt4oMini,
)))
}
#[test]
fn update_speak_one_round_trip() {
let raw = json!({
"type": "UpdateSpeak",
"speak": {
"provider": {
"type": "deepgram",
"model": "aura-2-thalia-en"
}
}
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
assert!(matches!(msg, ClientMessage::UpdateSpeak(_)));
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn update_speak_many_round_trip() {
let raw = json!({
"type": "UpdateSpeak",
"speak": [
{ "provider": { "type": "deepgram", "model": "aura-asteria-en" } },
{ "provider": { "type": "deepgram", "model": "aura-2-luna-en" } }
]
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
if let ClientMessage::UpdateSpeak(m) = &msg {
assert_eq!(m.speak.len(), 2);
} else {
panic!("expected UpdateSpeak");
}
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn update_speak_with_flux_tts_expressivity_exact_wire_json() {
let msg = ClientMessage::update_speak_one(SpeakSettings::new(SpeakProvider::Deepgram(
DeepgramSpeakProvider::v2(DeepgramSpeakModel::FluxAlexisEn)
.with_speed(1.05)
.with_expressivity(DeepgramSpeakExpressivity::NegativeTwo),
)));
assert_eq!(
serde_json::to_string(&msg).unwrap(),
concat!(
r#"{"type":"UpdateSpeak","speak":{"provider":{"type":"deepgram","#,
r#""version":"v2","model":"flux-alexis-en","speed":1.05,"expressivity":-2}}}"#,
)
);
let value = serde_json::to_value(&msg).unwrap();
assert!(value["speak"]["provider"]["expressivity"].is_number());
let quoted = json!({
"type": "UpdateSpeak",
"speak": { "provider": {
"type": "deepgram",
"version": "v2",
"model": "flux-alexis-en",
"speed": 1.05,
"expressivity": "-2"
}}
});
assert_eq!(
serde_json::from_value::<ClientMessage>(quoted).unwrap(),
msg
);
}
#[test]
fn update_listen_exact_wire_json() {
let msg = ClientMessage::update_listen(AgentListenSettings::new(
AgentListenProvider::DeepgramV2(
DeepgramListenV2Provider::new("flux-general-multi")
.with_language_hints(["en", "es"])
.with_eot_threshold(1.0)
.with_keyterms(["Deepgram"]),
),
));
assert_eq!(
serde_json::to_string(&msg).unwrap(),
r#"{"type":"UpdateListen","listen":{"provider":{"type":"deepgram","version":"v2","model":"flux-general-multi","language_hints":["en","es"],"eot_threshold":1.0,"keyterms":["Deepgram"]}}}"#
);
}
#[test]
fn update_listen_round_trip() {
let raw = json!({
"type": "UpdateListen",
"listen": {
"provider": {
"type": "deepgram",
"version": "v1",
"model": "nova-3",
"language": "en-US"
}
}
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
match &msg {
ClientMessage::UpdateListen(m) => {
assert_eq!(m.message_type, UpdateListenType::UpdateListen);
assert!(matches!(
m.listen.provider,
AgentListenProvider::DeepgramV1(_)
));
}
other => panic!("expected UpdateListen, got {other:?}"),
}
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn force_end_turn_exact_wire_json() {
assert_eq!(
serde_json::to_string(&ForceEndTurnMessage::default()).unwrap(),
r#"{"type":"ForceEndTurn"}"#
);
let raw = json!({ "type": "ForceEndTurn" });
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
assert!(matches!(msg, ClientMessage::ForceEndTurn(_)));
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
assert_eq!(
serde_json::to_value(ClientMessage::force_end_turn()).unwrap(),
raw
);
}
#[test]
fn update_think_round_trip() {
let raw = json!({
"type": "UpdateThink",
"think": {
"provider": {
"type": "open_ai",
"model": "gpt-4o"
}
}
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
assert!(matches!(msg, ClientMessage::UpdateThink(_)));
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn update_prompt_round_trip() {
let raw = json!({
"type": "UpdatePrompt",
"prompt": "You are now in customer-support mode."
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
if let ClientMessage::UpdatePrompt(m) = &msg {
assert_eq!(m.prompt, "You are now in customer-support mode.");
} else {
panic!("expected UpdatePrompt");
}
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn inject_user_message_round_trip() {
let raw = json!({
"type": "InjectUserMessage",
"content": "What's the weather?"
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
assert!(matches!(msg, ClientMessage::InjectUserMessage(_)));
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn inject_agent_message_default_behavior() {
let raw = json!({
"type": "InjectAgentMessage",
"message": "Sorry, I missed that."
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
if let ClientMessage::InjectAgentMessage(m) = &msg {
assert_eq!(m.message, "Sorry, I missed that.");
assert!(m.behavior.is_none());
} else {
panic!("expected InjectAgentMessage");
}
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn inject_agent_message_queue_behavior() {
let raw = json!({
"type": "InjectAgentMessage",
"message": "By the way, business hours are 9-5.",
"behavior": "queue"
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
if let ClientMessage::InjectAgentMessage(m) = &msg {
assert_eq!(m.behavior, Some(InjectAgentBehavior::Queue));
} else {
panic!("expected InjectAgentMessage");
}
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn inject_agent_message_interrupt_behavior() {
let raw = json!({
"type": "InjectAgentMessage",
"message": "One moment — transferring you now.",
"behavior": "interrupt"
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
if let ClientMessage::InjectAgentMessage(m) = &msg {
assert_eq!(m.behavior, Some(InjectAgentBehavior::Interrupt));
} else {
panic!("expected InjectAgentMessage");
}
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
let built = InjectAgentMessageMessage::new("One moment — transferring you now.")
.with_behavior(InjectAgentBehavior::Interrupt);
assert_eq!(
serde_json::to_string(&built).unwrap(),
r#"{"type":"InjectAgentMessage","message":"One moment — transferring you now.","behavior":"interrupt"}"#
);
}
#[test]
fn function_call_response_with_id_round_trip() {
let raw = json!({
"type": "FunctionCallResponse",
"id": "func_42",
"name": "get_weather",
"content": "{\"temp\": 72}"
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
if let ClientMessage::FunctionCallResponse(m) = &msg {
assert_eq!(m.id.as_deref(), Some("func_42"));
assert_eq!(m.name, "get_weather");
} else {
panic!("expected FunctionCallResponse");
}
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn function_call_response_without_id_round_trip() {
let raw = json!({
"type": "FunctionCallResponse",
"name": "internal_lookup",
"content": "[{\"id\": 1}]"
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
if let ClientMessage::FunctionCallResponse(m) = &msg {
assert!(m.id.is_none());
} else {
panic!("expected FunctionCallResponse");
}
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn keep_alive_round_trip() {
let raw = json!({ "type": "KeepAlive" });
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
assert!(matches!(msg, ClientMessage::KeepAlive(_)));
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn settings_dispatched_through_client_message() {
let raw = json!({
"type": "Settings",
"audio": {
"input": { "encoding": "linear16", "sample_rate": 16000 }
},
"agent": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
});
let msg: ClientMessage = serde_json::from_value(raw.clone()).unwrap();
assert!(matches!(msg, ClientMessage::Settings(_)));
assert_eq!(serde_json::to_value(&msg).unwrap(), raw);
}
#[test]
fn unknown_type_does_not_match_any_variant() {
let raw = json!({ "type": "MysteryMessage", "x": 1 });
let err = serde_json::from_value::<ClientMessage>(raw).unwrap_err();
let s = err.to_string();
assert!(!s.is_empty());
}
#[test]
fn convenience_constructors() {
let _ = ClientMessage::settings(crate::agent::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"),
)),
sample_think(),
sample_speak(),
)),
));
let _ = ClientMessage::update_speak_one(sample_speak());
let _ = ClientMessage::update_speak_many([sample_speak(), sample_speak()]);
let _ = ClientMessage::update_think_one(sample_think());
let _ = ClientMessage::update_think_many([sample_think()]);
let _ = ClientMessage::update_prompt("hi");
let _ = ClientMessage::inject_user_message("hi");
let _ = ClientMessage::inject_agent_message("hi");
let _ = ClientMessage::function_call_response("fn", "{}");
let _ = ClientMessage::keep_alive();
let _ = ClientMessage::update_listen(AgentListenSettings::new(
AgentListenProvider::DeepgramV2(DeepgramListenV2Provider::new("flux-general-en")),
));
let _ = ClientMessage::force_end_turn();
}
#[test]
fn inject_agent_with_behavior_builder() {
let m = InjectAgentMessageMessage::new("hi").with_behavior(InjectAgentBehavior::Queue);
let v = serde_json::to_value(&m).unwrap();
assert_eq!(v["behavior"], "queue");
}
#[test]
fn function_call_response_with_id_builder() {
let m = FunctionCallResponseMessage::with_id("f1", "fn", "{}");
assert_eq!(m.id.as_deref(), Some("f1"));
}
#[test]
fn behavior_default_is_default() {
assert_eq!(InjectAgentBehavior::default(), InjectAgentBehavior::Default);
}
}