use std::borrow::Cow;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::provider::Provider;
use crate::realtime_transcript::RealtimeTranscriptEvent;
use crate::session::PendingSystemContextAppend;
use crate::types::{ContentBlock, Message, SessionId, StopReason, ToolDef, Usage};
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveAdapterStatus {
Idle,
Opening,
Ready,
Degraded { reason: LiveDegradationReason },
Closing,
Closed,
}
impl LiveAdapterStatus {
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Closed)
}
#[must_use]
pub fn accepts_commands(&self) -> bool {
matches!(self, Self::Ready)
}
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveDegradationReason {
RateLimited,
ProviderThrottled,
NetworkUnstable,
Other {
#[cfg_attr(feature = "schema", schemars(with = "String"))]
detail: Cow<'static, str>,
},
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LiveToolResult {
pub call_id: String,
pub content: Vec<ContentBlock>,
pub is_error: bool,
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "modality", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveResponseModality {
Audio,
Text,
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveAdapterCommand {
Open {
snapshot: LiveProjectionSnapshot,
},
Refresh {
snapshot: LiveProjectionSnapshot,
},
SendInput {
chunk: LiveInputChunk,
},
CommitInput {
#[serde(default, skip_serializing_if = "Option::is_none")]
response_modality: Option<LiveResponseModality>,
},
Interrupt,
TruncateAssistantOutput {
item_id: String,
content_index: u32,
audio_played_ms: u64,
},
SubmitToolResult {
result: LiveToolResult,
},
SubmitToolError {
call_id: String,
error: String,
},
Close,
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "observation", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveAdapterObservation {
Ready,
UserTranscriptFinal {
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
previous_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_index: Option<u32>,
text: String,
},
AssistantTextDelta {
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
previous_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
delta_id: Option<String>,
delta: String,
},
AssistantTranscriptDelta {
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
previous_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
delta_id: Option<String>,
delta: String,
},
AssistantAudioChunk {
#[serde(with = "base64_bytes")]
#[cfg_attr(feature = "schema", schemars(with = "String"))]
data: Vec<u8>,
sample_rate_hz: u32,
channels: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_index: Option<u32>,
},
AssistantTranscriptFinal {
provider_item_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
previous_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
response_id: Option<String>,
text: String,
stop_reason: StopReason,
usage: Usage,
},
AssistantTranscriptTruncated {
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
previous_item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
text: Option<String>,
},
RealtimeTranscript {
event: RealtimeTranscriptEvent,
},
ToolCallRequested {
provider_call_id: String,
tool_name: String,
arguments: serde_json::Value,
},
TurnInterrupted {
#[serde(default, skip_serializing_if = "Option::is_none")]
response_id: Option<String>,
},
TurnCompleted {
#[serde(default, skip_serializing_if = "Option::is_none")]
response_id: Option<String>,
stop_reason: StopReason,
usage: Usage,
},
StatusChanged {
status: LiveAdapterStatus,
},
Error {
code: LiveAdapterErrorCode,
message: String,
},
CommandRejected {
code: LiveAdapterErrorCode,
message: String,
},
}
mod base64_bytes {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
let encoded = BASE64_STANDARD.encode(bytes);
serializer.serialize_str(&encoded)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let encoded = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
BASE64_STANDARD
.decode(encoded.as_ref())
.map_err(serde::de::Error::custom)
}
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "code", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveAdapterErrorCode {
ConnectionFailed,
ConnectionLost,
ConfigRejected {
reason: LiveConfigRejectionReason,
},
ProviderError,
AuthenticationFailed,
InternalError,
Other {
raw: String,
},
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveConfigRejectionReason {
ChannelIdentitySwap {
from_model: String,
from_provider: Provider,
to_model: String,
to_provider: Provider,
},
NonRealtimeResolution { detail: String },
ImageInputNotImplemented,
VideoFrameInputNotImplemented,
UnsupportedInputChunkVariant,
RefreshModelSwap {
from_model: String,
to_model: String,
},
RefreshProviderSwap {
from_provider: String,
to_provider: String,
},
RefreshAudioConfigMismatch { detail: String },
AudioInputFormatMismatch {
expected_sample_rate_hz: u32,
expected_channels: u16,
actual_sample_rate_hz: u32,
actual_channels: u16,
},
Other { detail: String },
}
impl std::fmt::Display for LiveConfigRejectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ChannelIdentitySwap {
from_model,
from_provider,
to_model,
to_provider,
} => {
write!(
f,
"model_swap: {from_model} ({from_provider:?}) -> {to_model} ({to_provider:?})"
)
}
Self::NonRealtimeResolution { detail } => {
write!(f, "non_realtime_resolution: {detail}")
}
Self::ImageInputNotImplemented => f.write_str("image_input_not_implemented"),
Self::VideoFrameInputNotImplemented => f.write_str("video_frame_input_not_implemented"),
Self::UnsupportedInputChunkVariant => f.write_str("unsupported_input_chunk_variant"),
Self::RefreshModelSwap {
from_model,
to_model,
} => write!(
f,
"live adapter refresh: model swap from `{from_model}` to `{to_model}` requires close + reopen \
(provider session.update cannot rebind model in place)"
),
Self::RefreshProviderSwap {
from_provider,
to_provider,
} => write!(
f,
"live adapter refresh: provider swap from `{from_provider}` to `{to_provider}` requires close + reopen"
),
Self::RefreshAudioConfigMismatch { detail } => {
write!(f, "live adapter refresh: audio config mismatch ({detail})")
}
Self::AudioInputFormatMismatch {
expected_sample_rate_hz,
expected_channels,
actual_sample_rate_hz,
actual_channels,
} => write!(
f,
"audio_input_format_mismatch: chunk declared rate={actual_sample_rate_hz}Hz \
ch={actual_channels} but bound provider session is fixed at \
rate={expected_sample_rate_hz}Hz ch={expected_channels}"
),
Self::Other { detail } => f.write_str(detail),
}
}
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveProjectionSnapshot {
pub session_id: SessionId,
pub snapshot_version: u64,
#[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
pub seed_messages: Vec<Message>,
#[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
pub visible_tools: Vec<ToolDef>,
pub system_prompt: Option<String>,
pub model_id: String,
pub provider_id: String,
pub audio_config: Option<LiveAudioConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
pub runtime_system_context: Vec<PendingSystemContextAppend>,
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LiveAudioConfig {
pub input_sample_rate_hz: u32,
pub input_channels: u16,
pub output_sample_rate_hz: u32,
pub output_channels: u16,
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveInputChunk {
Audio {
#[serde(with = "base64_bytes")]
#[cfg_attr(feature = "schema", schemars(with = "String"))]
data: Vec<u8>,
sample_rate_hz: u32,
channels: u16,
},
Text {
text: String,
},
Image {
mime: String,
#[serde(with = "base64_bytes")]
#[cfg_attr(feature = "schema", schemars(with = "String"))]
data: Vec<u8>,
},
VideoFrame {
codec: String,
#[serde(with = "base64_bytes")]
#[cfg_attr(feature = "schema", schemars(with = "String"))]
data: Vec<u8>,
timestamp_ms: u64,
},
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "transport", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveTransportBootstrap {
Websocket {
url: String,
token: String,
},
Webrtc {
token: String,
answer_method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
http_url: Option<String>,
},
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LiveChannelCapabilities {
pub audio_in: bool,
pub audio_out: bool,
pub text_in: bool,
pub text_out: bool,
pub image_in: bool,
pub video_in: bool,
pub transcript_supported: bool,
pub barge_in_supported: bool,
pub provider_native_resume: bool,
}
impl Default for LiveChannelCapabilities {
fn default() -> Self {
Self {
audio_in: false,
audio_out: false,
text_in: false,
text_out: false,
image_in: false,
video_in: false,
transcript_supported: false,
barge_in_supported: false,
provider_native_resume: false,
}
}
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveChannelOpenResponse {
pub transport: LiveTransportBootstrap,
pub input_audio_format: Option<LiveAudioConfig>,
pub capabilities: LiveChannelCapabilities,
pub continuity: LiveContinuityMode,
}
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LiveContinuityMode {
Fresh,
TranscriptOnly,
Degraded,
ProviderNativeResume { provider_session_id: String },
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
pub trait LiveAdapter: Send + Sync {
async fn send_command(&self, command: LiveAdapterCommand) -> Result<(), LiveAdapterError>;
async fn next_observation(&self) -> Result<Option<LiveAdapterObservation>, LiveAdapterError>;
fn status(&self) -> LiveAdapterStatus;
async fn close(&self) -> Result<(), LiveAdapterError>;
fn capabilities(&self) -> LiveChannelCapabilities {
LiveChannelCapabilities::default()
}
async fn inject_observation(
&self,
_observation: LiveAdapterObservation,
) -> Result<(), LiveAdapterError> {
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum LiveAdapterError {
#[error("adapter not ready: current status is {status:?}")]
NotReady { status: LiveAdapterStatus },
#[error("provider error: {message}")]
ProviderError {
code: LiveAdapterErrorCode,
message: String,
},
#[error("transport error: {message}")]
TransportError { message: String },
#[error("adapter is closed")]
Closed,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use crate::types::{StopReason, Usage};
#[test]
fn idle_is_not_terminal_and_does_not_accept_commands() {
let status = LiveAdapterStatus::Idle;
assert!(!status.is_terminal());
assert!(!status.accepts_commands());
}
#[test]
fn ready_accepts_commands_and_is_not_terminal() {
let status = LiveAdapterStatus::Ready;
assert!(!status.is_terminal());
assert!(status.accepts_commands());
}
#[test]
fn degraded_does_not_accept_commands() {
let status = LiveAdapterStatus::Degraded {
reason: LiveDegradationReason::RateLimited,
};
assert!(!status.is_terminal());
assert!(!status.accepts_commands());
}
#[test]
fn closed_is_terminal() {
let status = LiveAdapterStatus::Closed;
assert!(status.is_terminal());
assert!(!status.accepts_commands());
}
#[test]
fn opening_and_closing_are_transient() {
assert!(!LiveAdapterStatus::Opening.is_terminal());
assert!(!LiveAdapterStatus::Opening.accepts_commands());
assert!(!LiveAdapterStatus::Closing.is_terminal());
assert!(!LiveAdapterStatus::Closing.accepts_commands());
}
#[test]
fn degradation_reason_round_trips_typed_variants() {
for reason in [
LiveDegradationReason::RateLimited,
LiveDegradationReason::ProviderThrottled,
LiveDegradationReason::NetworkUnstable,
] {
let json = serde_json::to_string(&reason).unwrap();
let deser: LiveDegradationReason = serde_json::from_str(&json).unwrap();
assert_eq!(reason, deser);
}
}
#[test]
fn degradation_reason_other_round_trips_with_static_cow() {
let reason = LiveDegradationReason::Other {
detail: Cow::Borrowed("upstream maintenance"),
};
let json = serde_json::to_string(&reason).unwrap();
let deser: LiveDegradationReason = serde_json::from_str(&json).unwrap();
assert_eq!(reason, deser);
}
#[test]
fn command_interrupt_round_trips() {
let cmd = LiveAdapterCommand::Interrupt;
let json = serde_json::to_string(&cmd).unwrap();
let deser: LiveAdapterCommand = serde_json::from_str(&json).unwrap();
assert!(matches!(deser, LiveAdapterCommand::Interrupt));
}
#[test]
fn command_send_audio_input_round_trips() {
let cmd = LiveAdapterCommand::SendInput {
chunk: LiveInputChunk::Audio {
data: vec![0u8; 480],
sample_rate_hz: 24000,
channels: 1,
},
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(!json.contains("[0,0,0,0"));
let deser: LiveAdapterCommand = serde_json::from_str(&json).unwrap();
match deser {
LiveAdapterCommand::SendInput {
chunk:
LiveInputChunk::Audio {
data,
sample_rate_hz,
channels,
},
} => {
assert_eq!(data.len(), 480);
assert_eq!(sample_rate_hz, 24000);
assert_eq!(channels, 1);
}
other => panic!("expected SendInput/Audio, got {other:?}"),
}
}
#[test]
fn command_send_text_input_round_trips() {
let cmd = LiveAdapterCommand::SendInput {
chunk: LiveInputChunk::Text {
text: "hello".into(),
},
};
let json = serde_json::to_string(&cmd).unwrap();
let deser: LiveAdapterCommand = serde_json::from_str(&json).unwrap();
match deser {
LiveAdapterCommand::SendInput {
chunk: LiveInputChunk::Text { text },
} => assert_eq!(text, "hello"),
other => panic!("expected SendInput/Text, got {other:?}"),
}
}
#[test]
fn command_submit_tool_result_round_trips() {
let cmd = LiveAdapterCommand::SubmitToolResult {
result: LiveToolResult {
call_id: "call_123".into(),
content: vec![ContentBlock::Text { text: "42".into() }],
is_error: false,
},
};
let json = serde_json::to_string(&cmd).unwrap();
let deser: LiveAdapterCommand = serde_json::from_str(&json).unwrap();
match deser {
LiveAdapterCommand::SubmitToolResult { result } => {
assert_eq!(result.call_id, "call_123");
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
match &result.content[0] {
ContentBlock::Text { text } => assert_eq!(text, "42"),
other => panic!("expected Text content, got {other:?}"),
}
}
other => panic!("expected SubmitToolResult, got {other:?}"),
}
}
#[test]
fn command_truncate_round_trips() {
let cmd = LiveAdapterCommand::TruncateAssistantOutput {
item_id: "item_abc".into(),
content_index: 0,
audio_played_ms: 3200,
};
let json = serde_json::to_string(&cmd).unwrap();
let deser: LiveAdapterCommand = serde_json::from_str(&json).unwrap();
match deser {
LiveAdapterCommand::TruncateAssistantOutput {
item_id,
content_index,
audio_played_ms,
} => {
assert_eq!(item_id, "item_abc");
assert_eq!(content_index, 0);
assert_eq!(audio_played_ms, 3200);
}
other => panic!("expected TruncateAssistantOutput, got {other:?}"),
}
}
#[test]
fn command_open_serializes_with_snapshot_version() {
let cmd = LiveAdapterCommand::Open {
snapshot: LiveProjectionSnapshot {
session_id: SessionId::new(),
snapshot_version: 42,
seed_messages: vec![],
visible_tools: vec![],
system_prompt: Some("You are helpful.".into()),
model_id: "gpt-5.4".into(),
provider_id: "openai".into(),
audio_config: None,
runtime_system_context: vec![],
},
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"snapshot_version\":42"));
assert!(json.contains("\"provider_id\":\"openai\""));
assert!(!json.contains("runtime_system_context"));
}
#[test]
fn snapshot_round_trips_with_runtime_system_context() {
use std::time::SystemTime;
let snapshot = LiveProjectionSnapshot {
session_id: SessionId::new(),
snapshot_version: 7,
seed_messages: vec![],
visible_tools: vec![],
system_prompt: None,
model_id: "gpt-5.4".into(),
provider_id: "openai".into(),
audio_config: None,
runtime_system_context: vec![crate::session::PendingSystemContextAppend {
text: "peer terminal: pty=42".into(),
source: Some("peer_terminal".into()),
idempotency_key: Some("k1".into()),
accepted_at: SystemTime::UNIX_EPOCH,
}],
};
let json = serde_json::to_string(&snapshot).unwrap();
assert!(json.contains("runtime_system_context"));
assert!(json.contains("peer_terminal"));
let deser: LiveProjectionSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(deser.runtime_system_context.len(), 1);
assert_eq!(
deser.runtime_system_context[0].text,
"peer terminal: pty=42"
);
assert_eq!(
deser.runtime_system_context[0].source.as_deref(),
Some("peer_terminal")
);
}
#[test]
fn observation_tool_call_requested_round_trips() {
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_456".into(),
tool_name: "web_search".into(),
arguments: serde_json::json!({"query": "meerkat habitat"}),
};
let json = serde_json::to_string(&obs).unwrap();
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn observation_tool_call_uses_provider_call_id_not_domain_handle() {
let obs = LiveAdapterObservation::ToolCallRequested {
provider_call_id: "call_789".into(),
tool_name: "calculator".into(),
arguments: serde_json::json!({}),
};
if let LiveAdapterObservation::ToolCallRequested {
provider_call_id, ..
} = &obs
{
assert!(provider_call_id.starts_with("call_"));
} else {
panic!("wrong variant");
}
}
#[test]
fn observation_turn_completed_round_trips() {
let obs = LiveAdapterObservation::TurnCompleted {
response_id: None,
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: None,
cache_read_tokens: None,
},
};
let json = serde_json::to_string(&obs).unwrap();
assert!(!json.contains("response_id"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn observation_turn_completed_round_trips_with_response_id() {
let obs = LiveAdapterObservation::TurnCompleted {
response_id: Some("resp_42".into()),
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 12,
output_tokens: 7,
cache_creation_tokens: None,
cache_read_tokens: None,
},
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"response_id\":\"resp_42\""));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn observation_barge_in_is_typed_interrupt_not_side_channel() {
let obs = LiveAdapterObservation::TurnInterrupted { response_id: None };
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("turn_interrupted"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn observation_turn_interrupted_round_trips_with_response_id() {
let obs = LiveAdapterObservation::TurnInterrupted {
response_id: Some("resp_42".into()),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"response_id\":\"resp_42\""));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn user_transcript_final_round_trips_with_provider_item_id() {
let obs = LiveAdapterObservation::UserTranscriptFinal {
provider_item_id: Some("item_123".into()),
previous_item_id: None,
content_index: None,
text: "hello".into(),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"provider_item_id\":\"item_123\""));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn user_transcript_final_round_trips_without_provider_item_id() {
let obs = LiveAdapterObservation::UserTranscriptFinal {
provider_item_id: None,
previous_item_id: None,
content_index: None,
text: "hello".into(),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(
!json.contains("provider_item_id"),
"absent field must be skipped on serialize, got {json}"
);
assert!(!json.contains("null"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn assistant_text_delta_round_trips_with_and_without_provider_item_id() {
let with = LiveAdapterObservation::AssistantTextDelta {
provider_item_id: Some("item_xyz".into()),
previous_item_id: None,
content_index: None,
response_id: None,
delta_id: None,
delta: "hi".into(),
};
let json = serde_json::to_string(&with).unwrap();
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(with, deser);
let without = LiveAdapterObservation::AssistantTextDelta {
provider_item_id: None,
previous_item_id: None,
content_index: None,
response_id: None,
delta_id: None,
delta: "hi".into(),
};
let json = serde_json::to_string(&without).unwrap();
assert!(!json.contains("provider_item_id"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(without, deser);
}
#[test]
fn assistant_transcript_delta_round_trips_with_full_ordering_identity() {
let obs = LiveAdapterObservation::AssistantTranscriptDelta {
provider_item_id: Some("item_t1".into()),
previous_item_id: Some("item_t0".into()),
content_index: Some(0),
response_id: Some("resp_42".into()),
delta_id: Some("delta_3".into()),
delta: "spoken".into(),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(
json.contains("\"observation\":\"assistant_transcript_delta\""),
"AssistantTranscriptDelta must serialize with its own snake_case tag, got {json}"
);
assert!(
!json.contains("\"observation\":\"assistant_text_delta\""),
"AssistantTranscriptDelta must NOT collide with assistant_text_delta tag, got {json}"
);
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn assistant_transcript_delta_round_trips_without_optional_identity() {
let obs = LiveAdapterObservation::AssistantTranscriptDelta {
provider_item_id: None,
previous_item_id: None,
content_index: None,
response_id: None,
delta_id: None,
delta: "spoken".into(),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(!json.contains("provider_item_id"));
assert!(!json.contains("response_id"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn user_transcript_final_round_trips_with_full_ordering_identity() {
let obs = LiveAdapterObservation::UserTranscriptFinal {
provider_item_id: Some("item_123".into()),
previous_item_id: Some("item_122".into()),
content_index: Some(0),
text: "hello".into(),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"previous_item_id\":\"item_122\""));
assert!(json.contains("\"content_index\":0"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn assistant_text_delta_round_trips_with_full_ordering_identity() {
let obs = LiveAdapterObservation::AssistantTextDelta {
provider_item_id: Some("item_xyz".into()),
previous_item_id: Some("item_xyw".into()),
content_index: Some(2),
response_id: Some("resp_42".into()),
delta_id: Some("delta_7".into()),
delta: "hi".into(),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"response_id\":\"resp_42\""));
assert!(json.contains("\"delta_id\":\"delta_7\""));
assert!(json.contains("\"content_index\":2"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn assistant_transcript_final_round_trips_with_ordering_identity() {
let obs = LiveAdapterObservation::AssistantTranscriptFinal {
provider_item_id: "item_abc".into(),
previous_item_id: Some("item_aba".into()),
content_index: Some(1),
response_id: Some("resp_9".into()),
text: "final transcript".into(),
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
cache_creation_tokens: None,
cache_read_tokens: None,
},
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"previous_item_id\":\"item_aba\""));
assert!(json.contains("\"response_id\":\"resp_9\""));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn assistant_transcript_final_round_trips_without_optional_ordering() {
let obs = LiveAdapterObservation::AssistantTranscriptFinal {
provider_item_id: "item_only".into(),
previous_item_id: None,
content_index: None,
response_id: None,
text: "final".into(),
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_tokens: None,
cache_read_tokens: None,
},
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"provider_item_id\":\"item_only\""));
assert!(!json.contains("previous_item_id"));
assert!(!json.contains("content_index"));
assert!(!json.contains("response_id"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn realtime_transcript_passthrough_round_trips() {
let inner = RealtimeTranscriptEvent::AssistantTurnInterrupted {
response_id: "resp_123".into(),
};
let obs = LiveAdapterObservation::RealtimeTranscript { event: inner };
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"observation\":\"realtime_transcript\""));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn assistant_transcript_truncated_round_trips_all_shapes() {
let both = LiveAdapterObservation::AssistantTranscriptTruncated {
provider_item_id: Some("item_abc".into()),
previous_item_id: Some("item_aba".into()),
content_index: Some(0),
response_id: Some("resp_5".into()),
text: Some("partial transcript".into()),
};
let json = serde_json::to_string(&both).unwrap();
assert!(json.contains("\"response_id\":\"resp_5\""));
assert!(json.contains("\"content_index\":0"));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(both, deser);
let neither = LiveAdapterObservation::AssistantTranscriptTruncated {
provider_item_id: None,
previous_item_id: None,
content_index: None,
response_id: None,
text: None,
};
let json = serde_json::to_string(&neither).unwrap();
assert!(!json.contains("provider_item_id"));
assert!(!json.contains("response_id"));
assert!(!json.contains("content_index"));
assert!(!json.contains("\"text\""));
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(neither, deser);
let id_only = LiveAdapterObservation::AssistantTranscriptTruncated {
provider_item_id: Some("item_def".into()),
previous_item_id: None,
content_index: None,
response_id: None,
text: None,
};
let json = serde_json::to_string(&id_only).unwrap();
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(id_only, deser);
let text_only = LiveAdapterObservation::AssistantTranscriptTruncated {
provider_item_id: None,
previous_item_id: None,
content_index: None,
response_id: None,
text: Some("only text".into()),
};
let json = serde_json::to_string(&text_only).unwrap();
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(text_only, deser);
}
#[test]
fn observation_status_changed_round_trips() {
let obs = LiveAdapterObservation::StatusChanged {
status: LiveAdapterStatus::Degraded {
reason: LiveDegradationReason::ProviderThrottled,
},
};
let json = serde_json::to_string(&obs).unwrap();
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn observation_error_round_trips() {
let obs = LiveAdapterObservation::Error {
code: LiveAdapterErrorCode::ConnectionLost,
message: "ws closed unexpectedly".into(),
};
let json = serde_json::to_string(&obs).unwrap();
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn observation_command_rejected_round_trips_with_distinct_tag() {
let obs = LiveAdapterObservation::CommandRejected {
code: LiveAdapterErrorCode::ConfigRejected {
reason: LiveConfigRejectionReason::ImageInputNotImplemented,
},
message: "image_input_not_implemented".into(),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"command_rejected\""), "got {json}");
assert!(!json.contains("\"observation\":\"error\""), "got {json}");
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
}
#[test]
fn assistant_audio_chunk_serializes_as_base64_string_not_int_array() {
let bytes = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x7F, 0xFF];
let obs = LiveAdapterObservation::AssistantAudioChunk {
data: bytes.clone(),
sample_rate_hz: 24000,
channels: 1,
response_id: None,
item_id: None,
content_index: None,
};
let json = serde_json::to_string(&obs).unwrap();
assert!(
!json.contains("[222,173,"),
"audio bytes must not serialize as JSON integer array; got {json}"
);
let expected = BASE64_STANDARD.encode(&bytes);
assert!(
json.contains(&format!("\"data\":\"{expected}\"")),
"expected base64 string for data; got {json}"
);
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
match deser {
LiveAdapterObservation::AssistantAudioChunk { data, .. } => {
assert_eq!(data, bytes);
}
other => panic!("expected AssistantAudioChunk, got {other:?}"),
}
}
#[test]
fn assistant_audio_chunk_carries_identity_fields_round_trip() {
let obs = LiveAdapterObservation::AssistantAudioChunk {
data: vec![1u8, 2, 3],
sample_rate_hz: 24_000,
channels: 1,
response_id: Some("resp_abc".to_string()),
item_id: Some("item_123".to_string()),
content_index: Some(0),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("\"response_id\":\"resp_abc\""), "got {json}");
assert!(json.contains("\"item_id\":\"item_123\""), "got {json}");
assert!(json.contains("\"content_index\":0"), "got {json}");
let deser: LiveAdapterObservation = serde_json::from_str(&json).unwrap();
assert_eq!(obs, deser);
let obs_anon = LiveAdapterObservation::AssistantAudioChunk {
data: vec![4u8, 5, 6],
sample_rate_hz: 24_000,
channels: 1,
response_id: None,
item_id: None,
content_index: None,
};
let json_anon = serde_json::to_string(&obs_anon).unwrap();
assert!(!json_anon.contains("response_id"), "got {json_anon}");
assert!(!json_anon.contains("item_id"), "got {json_anon}");
assert!(!json_anon.contains("content_index"), "got {json_anon}");
let deser_anon: LiveAdapterObservation = serde_json::from_str(&json_anon).unwrap();
assert_eq!(obs_anon, deser_anon);
}
#[test]
fn live_input_audio_serializes_as_base64_string() {
let bytes = vec![1u8, 2, 3, 4, 5];
let chunk = LiveInputChunk::Audio {
data: bytes.clone(),
sample_rate_hz: 24000,
channels: 1,
};
let json = serde_json::to_string(&chunk).unwrap();
assert!(!json.contains("[1,2,3,4,5]"));
let expected = BASE64_STANDARD.encode(&bytes);
assert!(json.contains(&expected));
let deser: LiveInputChunk = serde_json::from_str(&json).unwrap();
match deser {
LiveInputChunk::Audio { data, .. } => assert_eq!(data, bytes),
other => panic!("expected Audio, got {other:?}"),
}
}
#[test]
fn snapshot_version_is_monotonic_marker() {
let s1 = LiveProjectionSnapshot {
session_id: SessionId::new(),
snapshot_version: 1,
seed_messages: vec![],
visible_tools: vec![],
system_prompt: None,
model_id: "gpt-5.4".into(),
provider_id: "openai".into(),
audio_config: None,
runtime_system_context: vec![],
};
assert_eq!(s1.snapshot_version, 1);
let s2 = LiveProjectionSnapshot {
snapshot_version: 2,
..s1
};
assert_eq!(s2.snapshot_version, 2);
}
#[test]
fn transport_bootstrap_is_tagged_not_bare_url() {
let ws = LiveTransportBootstrap::Websocket {
url: "wss://example.com/live".into(),
token: "tok_abc".into(),
};
let json = serde_json::to_string(&ws).unwrap();
assert!(json.contains("\"transport\":\"websocket\""));
}
#[test]
fn webrtc_bootstrap_is_browser_offer_signaling_not_meerkat_offer() {
let webrtc = LiveTransportBootstrap::Webrtc {
token: "tok_webrtc".into(),
answer_method: "live/webrtc/answer".into(),
http_url: None,
};
let json = serde_json::to_value(&webrtc).unwrap();
assert_eq!(json["transport"], "webrtc");
assert_eq!(json["answer_method"], "live/webrtc/answer");
assert!(
json.get("offer_sdp").is_none(),
"Meerkat must not pre-compute browser-offer SDP"
);
}
#[test]
fn continuity_mode_distinguishes_provider_native_from_transcript_only() {
let resume = LiveContinuityMode::ProviderNativeResume {
provider_session_id: "sess_abc".into(),
};
assert_ne!(resume, LiveContinuityMode::TranscriptOnly);
assert_ne!(
LiveContinuityMode::TranscriptOnly,
LiveContinuityMode::Degraded
);
assert_ne!(LiveContinuityMode::Degraded, LiveContinuityMode::Fresh);
}
#[test]
fn continuity_mode_provider_native_resume_round_trips() {
let mode = LiveContinuityMode::ProviderNativeResume {
provider_session_id: "sess_42".to_string(),
};
let json = serde_json::to_string(&mode).unwrap();
assert!(
json.contains("\"mode\":\"provider_native_resume\""),
"ProviderNativeResume must serialize with the snake_case mode tag, got {json}"
);
assert!(
json.contains("\"provider_session_id\":\"sess_42\""),
"ProviderNativeResume must carry provider_session_id verbatim, got {json}"
);
let deser: LiveContinuityMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, deser);
}
#[test]
fn continuity_mode_payload_less_variants_round_trip() {
for mode in [
LiveContinuityMode::Fresh,
LiveContinuityMode::TranscriptOnly,
LiveContinuityMode::Degraded,
] {
let json = serde_json::to_string(&mode).unwrap();
let deser: LiveContinuityMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, deser);
}
}
#[test]
fn live_input_image_round_trips_with_base64_bytes() {
let bytes = vec![0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let chunk = LiveInputChunk::Image {
mime: "image/png".to_string(),
data: bytes.clone(),
};
let json = serde_json::to_string(&chunk).unwrap();
assert!(
!json.contains("[137,80,78"),
"image bytes must not serialize as JSON integer array; got {json}"
);
let expected = BASE64_STANDARD.encode(&bytes);
assert!(
json.contains(&format!("\"data\":\"{expected}\"")),
"expected base64 string for image data; got {json}"
);
assert!(json.contains("\"kind\":\"image\""));
assert!(json.contains("\"mime\":\"image/png\""));
let deser: LiveInputChunk = serde_json::from_str(&json).unwrap();
match deser {
LiveInputChunk::Image { mime, data } => {
assert_eq!(mime, "image/png");
assert_eq!(data, bytes);
}
other => panic!("expected Image, got {other:?}"),
}
}
#[test]
fn live_input_video_frame_round_trips_with_codec_and_timestamp() {
let bytes = vec![0u8, 1, 2, 3, 4, 5, 6, 7];
let chunk = LiveInputChunk::VideoFrame {
codec: "vp8".to_string(),
data: bytes.clone(),
timestamp_ms: 12_345,
};
let json = serde_json::to_string(&chunk).unwrap();
assert!(json.contains("\"kind\":\"video_frame\""));
assert!(json.contains("\"codec\":\"vp8\""));
assert!(json.contains("\"timestamp_ms\":12345"));
let expected = BASE64_STANDARD.encode(&bytes);
assert!(json.contains(&format!("\"data\":\"{expected}\"")));
let deser: LiveInputChunk = serde_json::from_str(&json).unwrap();
match deser {
LiveInputChunk::VideoFrame {
codec,
data,
timestamp_ms,
} => {
assert_eq!(codec, "vp8");
assert_eq!(data, bytes);
assert_eq!(timestamp_ms, 12_345);
}
other => panic!("expected VideoFrame, got {other:?}"),
}
}
#[test]
fn channel_open_response_includes_continuity() {
let resp = LiveChannelOpenResponse {
transport: LiveTransportBootstrap::Websocket {
url: "wss://example.com/live".into(),
token: "tok_abc".into(),
},
input_audio_format: Some(LiveAudioConfig {
input_sample_rate_hz: 24000,
input_channels: 1,
output_sample_rate_hz: 24000,
output_channels: 1,
}),
capabilities: LiveChannelCapabilities {
audio_in: true,
audio_out: true,
text_in: true,
text_out: true,
image_in: false,
video_in: false,
transcript_supported: true,
barge_in_supported: true,
provider_native_resume: false,
},
continuity: LiveContinuityMode::TranscriptOnly,
};
let json = serde_json::to_string(&resp).unwrap();
let deser: LiveChannelOpenResponse = serde_json::from_str(&json).unwrap();
assert_eq!(resp, deser);
assert_eq!(deser.continuity, LiveContinuityMode::TranscriptOnly);
assert!(!deser.capabilities.provider_native_resume);
assert!(deser.capabilities.audio_in);
assert!(deser.capabilities.audio_out);
}
#[test]
fn default_capabilities_make_no_claims() {
let caps = LiveChannelCapabilities::default();
assert!(!caps.audio_in);
assert!(!caps.audio_out);
assert!(!caps.text_in);
assert!(!caps.text_out);
assert!(!caps.barge_in_supported);
assert!(!caps.transcript_supported);
assert!(!caps.image_in);
assert!(!caps.video_in);
assert!(!caps.provider_native_resume);
}
#[test]
fn capabilities_carry_typed_image_and_video_booleans() {
let gpt_realtime_2 = LiveChannelCapabilities {
image_in: true,
..LiveChannelCapabilities::default()
};
let gemini_live = LiveChannelCapabilities {
video_in: true,
..LiveChannelCapabilities::default()
};
assert!(gpt_realtime_2.image_in);
assert!(!gpt_realtime_2.video_in);
assert!(gemini_live.video_in);
assert!(!gemini_live.image_in);
}
#[test]
fn adapter_error_codes_round_trip() {
for code in [
LiveAdapterErrorCode::ConnectionFailed,
LiveAdapterErrorCode::ConnectionLost,
LiveAdapterErrorCode::ProviderError,
LiveAdapterErrorCode::AuthenticationFailed,
LiveAdapterErrorCode::InternalError,
] {
let json = serde_json::to_string(&code).unwrap();
let deser: LiveAdapterErrorCode = serde_json::from_str(&json).unwrap();
assert_eq!(code, deser);
}
}
#[test]
fn adapter_error_code_config_rejected_round_trips() {
let code = LiveAdapterErrorCode::ConfigRejected {
reason: LiveConfigRejectionReason::RefreshModelSwap {
from_model: "gpt-realtime".to_string(),
to_model: "gpt-realtime-mini-v2".to_string(),
},
};
let json = serde_json::to_string(&code).unwrap();
assert!(
json.contains("\"code\":\"config_rejected\""),
"ConfigRejected must serialize with the snake_case tag, got {json}"
);
assert!(
json.contains("\"kind\":\"refresh_model_swap\""),
"reason must serialize with its typed kind discriminator, got {json}"
);
let deser: LiveAdapterErrorCode = serde_json::from_str(&json).unwrap();
assert_eq!(code, deser);
match deser {
LiveAdapterErrorCode::ConfigRejected { reason } => {
assert!(matches!(
reason,
LiveConfigRejectionReason::RefreshModelSwap { ref to_model, .. }
if to_model == "gpt-realtime-mini-v2"
));
assert!(format!("{reason}").contains("close + reopen"));
}
other => panic!("expected ConfigRejected, got {other:?}"),
}
}
#[test]
fn config_rejection_reason_round_trips_each_typed_variant() {
let cases = vec![
LiveConfigRejectionReason::ChannelIdentitySwap {
from_model: "gpt-realtime".into(),
from_provider: Provider::OpenAI,
to_model: "gpt-realtime-2".into(),
to_provider: Provider::OpenAI,
},
LiveConfigRejectionReason::NonRealtimeResolution {
detail: "ModelNotRealtime { model: \"gpt-5.4\", provider: \"openai\" }".into(),
},
LiveConfigRejectionReason::ImageInputNotImplemented,
LiveConfigRejectionReason::VideoFrameInputNotImplemented,
LiveConfigRejectionReason::UnsupportedInputChunkVariant,
LiveConfigRejectionReason::RefreshModelSwap {
from_model: "gpt-realtime".into(),
to_model: "gpt-realtime-2".into(),
},
LiveConfigRejectionReason::RefreshProviderSwap {
from_provider: "openai".into(),
to_provider: "anthropic".into(),
},
LiveConfigRejectionReason::RefreshAudioConfigMismatch {
detail: "rate=16000/16000 ch=1/1 cannot be applied in place".into(),
},
LiveConfigRejectionReason::AudioInputFormatMismatch {
expected_sample_rate_hz: 24_000,
expected_channels: 1,
actual_sample_rate_hz: 16_000,
actual_channels: 2,
},
LiveConfigRejectionReason::Other {
detail: "diagnostic catch-all".into(),
},
];
for case in cases {
let json = serde_json::to_string(&case).unwrap();
assert!(
json.contains("\"kind\":\""),
"reason must carry a `kind` discriminator, got {json}"
);
let back: LiveConfigRejectionReason = serde_json::from_str(&json).unwrap();
assert_eq!(case, back, "round-trip must preserve identity");
assert!(!format!("{case}").is_empty());
}
}
#[test]
fn adapter_error_code_other_preserves_raw_value() {
let code = LiveAdapterErrorCode::Other {
raw: "provider_specific_42".into(),
};
let json = serde_json::to_string(&code).unwrap();
let deser: LiveAdapterErrorCode = serde_json::from_str(&json).unwrap();
assert_eq!(code, deser);
match deser {
LiveAdapterErrorCode::Other { raw } => assert_eq!(raw, "provider_specific_42"),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn adapter_error_not_ready_carries_status() {
let err = LiveAdapterError::NotReady {
status: LiveAdapterStatus::Opening,
};
let msg = err.to_string();
assert!(msg.contains("Opening"));
}
#[test]
fn adapter_error_closed_is_distinct_from_not_ready() {
let closed = LiveAdapterError::Closed;
let not_ready = LiveAdapterError::NotReady {
status: LiveAdapterStatus::Closed,
};
assert_ne!(closed, not_ready);
}
#[test]
fn live_tool_result_round_trips() {
let result = LiveToolResult {
call_id: "call_abc".into(),
content: vec![ContentBlock::Text {
text: "answer is 42".into(),
}],
is_error: false,
};
let json = serde_json::to_string(&result).unwrap();
let deser: LiveToolResult = serde_json::from_str(&json).unwrap();
assert_eq!(result, deser);
}
#[test]
fn live_tool_result_error_flag_round_trips() {
let result = LiveToolResult {
call_id: "call_err".into(),
content: vec![ContentBlock::Text {
text: "tool not found".into(),
}],
is_error: true,
};
let json = serde_json::to_string(&result).unwrap();
let deser: LiveToolResult = serde_json::from_str(&json).unwrap();
assert!(deser.is_error);
}
#[test]
fn live_tool_result_preserves_multiple_content_blocks() {
let result = LiveToolResult {
call_id: "call_multi".into(),
content: vec![
ContentBlock::Text {
text: "first".into(),
},
ContentBlock::Text {
text: "second".into(),
},
],
is_error: false,
};
let json = serde_json::to_string(&result).unwrap();
let deser: LiveToolResult = serde_json::from_str(&json).unwrap();
assert_eq!(deser.content.len(), 2);
assert_eq!(result, deser);
}
#[cfg(feature = "schema")]
#[test]
fn live_adapter_observation_emits_round_trippable_schema() {
let schema = schemars::schema_for!(LiveAdapterObservation);
let json = serde_json::to_string(&schema).unwrap();
let _deser: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(json.contains("LiveAdapterObservation"));
}
#[cfg(feature = "schema")]
#[test]
fn live_adapter_command_emits_round_trippable_schema() {
let schema = schemars::schema_for!(LiveAdapterCommand);
let json = serde_json::to_string(&schema).unwrap();
let _deser: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(json.contains("LiveAdapterCommand"));
}
#[cfg(feature = "schema")]
#[test]
fn live_channel_open_response_emits_round_trippable_schema() {
let schema = schemars::schema_for!(LiveChannelOpenResponse);
let json = serde_json::to_string(&schema).unwrap();
let _deser: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(json.contains("LiveChannelOpenResponse"));
}
}