Files
rutster/crates/rutster-tap/src/protocol.rs
opencode controller d1f4a9fcf4 feat(tap): additive v1 protocol extensions (spec §3) — speech_started/stopped, function_call, function_call_output, tools.update
Five new event types in slice-2's v1 protocol. Forwards-compatible
per slice-2 §3.4: FrameKind's #[serde(other)] Unknown absorbs the
new types in old brains (they log + count + drop). No wire-format
break, no version bump.

New kid on the dispatch: tools.update — brain declares its catalog
on hello so the core's tool registry can validate function_call
events by name (Task 6). The 'function_call'/'function_call_output'
pair is the FOB-boundary dispatch contract the brain's translator
(Task 4) wires to OpenAI Realtime's
response.function_call_arguments.done / conversation.item.create.

TDD: 6 tests fail-red on missing impl, pass-green after this task.
Slice-2's existing protocol tests stay green — the additions are
purely additive.
2026-07-01 01:47:22 -04:00

904 lines
33 KiB
Rust

//! # Tap wire protocol (spec §3 — versioned JSON event framing)
//!
//! One JSON object per WS text frame. PCM payloads are base64-encoded
//! **little-endian i16** bytes (spec §3, §9 — explicit LE, not host-endian).
//! Every envelope carries `v: 1` + `type` + per-direction `seq` + advisory `ts`.
//!
//! ## Why JSON + base64 over binary length-prefixed framing
//!
//! ARCHITECTURE.md names WSS as presumptive transport because the consumer
//! is a Python script / an OpenAI-Realtime-style API for which event-
//! framed WSS is the de-facto protocol. A JSON event envelope maps onto
//! that ecosystem directly — a hand-rolled Python brain uses `json.loads`;
//! the step-3 OpenAI adapter translates our events to OpenAI's event
//! schema. A binary length-prefixed framing would force every brain to
//! implement a byte-parser. ~33% wire overhead (65 KB/s at 24 kHz mono
//! i16) is negligible at slice-2's scale; brain-authoring ergonomics
//! dominate. A future-rung `v: 2` may negotiate a binary mode (spec §9).
use rutster_media::PcmFrame;
use serde::ser::{SerializeStruct, Serializer};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Wire protocol version. Slice-2 ships v1 (spec §3.1, §3.4).
pub const PROTOCOL_VERSION: u8 = 1;
/// Samples per 20 ms frame @ 24 kHz mono. Re-declared locally for wire-
/// format validation; MUST match `rutster_media::pcm::SAMPLES_PER_FRAME`.
/// (Re-declaring rather than `use`-ing the constant keeps the wire-format
/// spec pinned in this crate — a future bump in `rutster-media` doesn't
/// silently change the wire format without an explicit bump here.)
pub const SAMPLES_PER_FRAME: usize = 480;
/// Frame kind — wire `type` field. Serde renames to the snake_case wire
/// names (spec §3.2, §3.3).
///
/// # Why an enum (not `String`)
/// Exhaustiveness: when a new variant is added, every `match` site is
/// flagged by the compiler. A `String` would let new frame kinds slip
/// in silently. Unknown wire values deserialize to `FrameKind::Unknown`
/// (see `serde(other)`) — the receiver drops them per spec §3.4.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameKind {
Hello,
AudioIn,
AudioOut,
SessionEnd,
Bye,
Error,
/// brain → core: user speech started (advisory; translated from OpenAI
/// `input_audio_buffer.speech_started`; spec §3.2).
SpeechStarted,
/// brain → core: user speech stopped (advisory; OpenAI
/// `input_audio_buffer.speech_stopped`; spec §3.2).
SpeechStopped,
/// brain → core: brain proposes a tool call (translated from OpenAI
/// `response.function_call_arguments.done`; spec §3.2).
FunctionCall,
/// core → brain: tool registry reply (spec §3.3).
FunctionCallOutput,
/// brain → core: brain declares its tool catalog on hello + on changes
/// (spec §3.2).
#[serde(rename = "tools.update")]
ToolsUpdate,
/// Unknown wire `type` values land here (spec §3.4: log + count + drop).
/// `#[serde(other)]` catches any string not in the variants above.
#[serde(other)]
Unknown,
}
/// On-wire envelope (spec §3.1).
///
/// # Serde strategy — manual `Serialize`, no derived `Deserialize`
///
/// The wire contract is ONE flat JSON object: payload fields (`session_id`,
/// `pcm`, `samples`, `reason`, `code`, `message`, ...) are *siblings* of
/// `v`, `type`, `seq`, `ts` — not nested under a `payload` key (spec §3.1).
///
/// The obvious serde approach — `#[derive(Serialize, Deserialize)]` on
/// `Envelope` with `#[serde(flatten)] payload: Payload` where `Payload` is
/// `#[serde(tag = "type")]` — **does not work**: both the envelope's
/// `#[serde(rename = "type")] kind` field and the flattened payload's tag
/// claim the same JSON `type` key, producing `duplicate field "type"` on
/// serialize and `missing field "type"` for unknown wire values on
/// deserialize (Payload has no `Unknown` variant to absorb them).
///
/// The fix applied here (recommended in the task brief): keep `Envelope` as
/// the encoding-side type with a manual `Serialize` that writes payload
/// fields flat as siblings of `v`/`type`/`seq`/`ts`. Deserialization goes
/// through a separate `WireEnvelope` intermediate (private) that flattens
/// leftover fields into a `serde_json::Map` and dispatches on `kind` to
/// build the specific payload struct. This matches the wire spec exactly
/// and lets `FrameKind::Unknown` (via `#[serde(other)]`) flow through to
/// `DecodedPayload::Unknown` — which the brief's tagged-enum approach
/// cannot do.
#[derive(Debug, Clone)]
pub struct Envelope {
pub v: u8,
pub kind: FrameKind,
pub seq: u64,
pub ts: u64,
pub payload: Payload,
}
/// Flatten envelope + payload into ONE JSON object (spec §3.1's flat
/// shape). This is the wire byte contract: payload fields as siblings of
/// `v`/`type`/`seq`/`ts`, never nested under a `payload` key.
///
/// The derived `#[derive(Serialize)]` + `#[serde(flatten)]` approach
/// failed (see the type-level doc above for the duplicate-`type` diagnosis).
/// Writing `Serialize` manually is the minimal-invasive fix: it controls
/// the wire shape directly and avoids fighting serde's flatten-over-
/// tagged-enum machinery.
///
/// Field order is pinned (`v`, `type`, `seq`, `ts`, then payload fields)
/// to make the wire bytes deterministic — JSON object key order isn't
/// semantically meaningful but pinning it helps snapshot tests and
/// interop with brains that read key order for debugging.
impl Serialize for Envelope {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let payload_field_count = match &self.payload {
Payload::Hello(_) => 4, // session_id, sample_rate, channels, frame_ms
Payload::AudioIn(_) | Payload::AudioOut(_) => 2, // pcm, samples
Payload::SessionEnd(_) | Payload::Bye(_) => 1, // reason
Payload::Error(_) => 2, // code, message
// Slice-3 adds: empty payload (0 fields), 3-field payloads, 1-field payload.
Payload::SpeechStarted | Payload::SpeechStopped => 0,
Payload::FunctionCall(_) => 3, // id, name, args
Payload::FunctionCallOutput(_) => 3, // id, status, result
Payload::ToolsUpdate(_) => 1, // tools
};
let mut st = serializer.serialize_struct("Envelope", 4 + payload_field_count)?;
st.serialize_field("v", &self.v)?;
// `FrameKind`'s own serde derive turns `AudioIn` → "audio_in" etc.
// via its `#[serde(rename_all = "snake_case")]`. The wire key is
// "type" (spec §3.1's name); Rust's field name `kind` doesn't
// appear on the wire at all.
st.serialize_field("type", &self.kind)?;
st.serialize_field("seq", &self.seq)?;
st.serialize_field("ts", &self.ts)?;
match &self.payload {
Payload::Hello(p) => {
st.serialize_field("session_id", &p.session_id)?;
st.serialize_field("sample_rate", &p.sample_rate)?;
st.serialize_field("channels", &p.channels)?;
st.serialize_field("frame_ms", &p.frame_ms)?;
}
Payload::AudioIn(p) | Payload::AudioOut(p) => {
st.serialize_field("pcm", &p.pcm)?;
st.serialize_field("samples", &p.samples)?;
}
Payload::SessionEnd(p) => {
st.serialize_field("reason", &p.reason)?;
}
Payload::Bye(p) => {
st.serialize_field("reason", &p.reason)?;
}
Payload::Error(p) => {
st.serialize_field("code", &p.code)?;
st.serialize_field("message", &p.message)?;
}
Payload::SpeechStarted | Payload::SpeechStopped => {
// No payload fields — the envelope's `v`/`type`/`seq`/`ts`
// is the whole message. The event name IS the message.
}
Payload::FunctionCall(p) => {
st.serialize_field("id", &p.id)?;
st.serialize_field("name", &p.name)?;
st.serialize_field("args", &p.args)?;
}
Payload::FunctionCallOutput(p) => {
st.serialize_field("id", &p.id)?;
st.serialize_field("status", &p.status)?;
st.serialize_field("result", &p.result)?;
}
Payload::ToolsUpdate(p) => {
st.serialize_field("tools", &p.tools)?;
}
}
st.end()
}
}
/// Payload — internal-only data carrier; never directly serialized.
/// `Envelope`'s manual `Serialize` flattens payload fields into the
/// envelope. `decode_envelope` builds payloads from a flat
/// `serde_json::Map` (see `WireEnvelope` in `decode_envelope`).
///
/// `AudioIn` and `AudioOut` share the same `AudioPayload` shape because
/// the wire format does (spec §3.2, §3.3); direction is carried by the
/// envelope's `type` field, not by a payload tag. The decode side
/// surfaces direction via [`DecodedPayload`] (separate `AudioIn`/
/// `AudioOut` variants) so callers `match` exhaustively on direction.
#[derive(Debug, Clone)]
pub enum Payload {
Hello(HelloPayload),
AudioIn(AudioPayload),
AudioOut(AudioPayload),
SessionEnd(SessionEndPayload),
Bye(ReasonPayload),
Error(ErrorPayload),
/// Slice-3 additive (spec §3.2): brain → core, advisory; empty payload
/// (the event name IS the message).
SpeechStarted,
SpeechStopped,
/// Slice-3 additive (spec §3.2).
FunctionCall(FunctionCallPayload),
/// Slice-3 additive (spec §3.3): core → brain reply.
FunctionCallOutput(FunctionCallOutputPayload),
/// Slice-3 additive (spec §3.2): brain → core catalog declaration.
ToolsUpdate(ToolsUpdatePayload),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloPayload {
pub session_id: String,
#[serde(default = "default_sample_rate")]
pub sample_rate: u32,
#[serde(default = "default_channels")]
pub channels: String,
#[serde(default = "default_frame_ms")]
pub frame_ms: u32,
}
fn default_sample_rate() -> u32 {
24000
}
fn default_channels() -> String {
"mono".to_string()
}
fn default_frame_ms() -> u32 {
20
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioPayload {
/// base64-encoded LE i16 bytes (spec §3, §9).
pub pcm: String,
pub samples: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEndPayload {
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasonPayload {
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorPayload {
pub code: String,
pub message: String,
}
/// `function_call` payload (brain → core; spec §3.2). Carries the
/// brain-minted id + tool name + args. Args is a raw JSON Value (not a
/// typed struct) so any tool schema is allowed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallPayload {
pub id: String,
pub name: String,
/// Raw JSON arguments — the tool registry dispatches by name and lets
/// each Tool impl parse the args itself. (OpenAI sends `arguments` as a
/// JSON string; the translator parses it back to a Value before
/// emitting the function_call tap frame.)
pub args: serde_json::Value,
}
/// `function_call_output` payload (core → brain; spec §3.3). The reply for
/// a `function_call`. `status` is one of `"ok"`, `"error"`, `"not_implemented"`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallOutputPayload {
pub id: String,
pub status: String,
pub result: serde_json::Value,
}
/// `tools.update` payload (brain → core; spec §3.2). The brain declares its
/// tool catalog so the core's tool registry can validate function_call
/// events by name.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsUpdatePayload {
/// An array of tool descriptors (each has `name`, `description`,
/// `parameters`). The shape is intentionally permissive (a JSON array,
/// not a typed Vec<ToolSchema>) so the brain can declare schemas the
/// core doesn't know about — the core only checks the `name` field for
/// dispatch, ignores the rest.
pub tools: serde_json::Value,
}
/// Decoded frame — what `decode_envelope` returns. The kind is split into
/// `audio_in` / `audio_out` variants (not just `Audio(AudioPayload)`) so
/// callers `match` exhaustively on direction (spec §3.2 vs §3.3 — the two
/// carry the same payload shape but mean opposite directions).
#[derive(Debug, Clone)]
pub struct DecodedFrame {
pub v: u8,
pub seq: u64,
pub ts: u64,
pub payload: DecodedPayload,
}
#[derive(Debug, Clone)]
pub enum DecodedPayload {
Hello(HelloPayload),
AudioIn(AudioPayload),
AudioOut(AudioPayload),
SessionEnd(SessionEndPayload),
Bye(ReasonPayload),
Error(ErrorPayload),
/// Slice-3 (spec §3.2): the brain detected user speech started/stopped.
SpeechStarted,
SpeechStopped,
/// Slice-3 (spec §3.2): brain wants the core to execute a tool.
FunctionCall(FunctionCallPayload),
/// Slice-3 (spec §3.3): the core's tool-registry reply.
FunctionCallOutput(FunctionCallOutputPayload),
/// Slice-3 (spec §3.2): brain declares its catalog so the core can
/// validate function_call events.
ToolsUpdate(ToolsUpdatePayload),
/// Unknown `type` — log + count + drop (spec §3.4 of slice-2).
Unknown,
}
#[derive(Debug, Error)]
pub enum TapProtoError {
#[error("JSON (de)serialization failed: {0}")]
Json(#[from] serde_json::Error),
#[error("base64 decode failed: {0}")]
Base64(#[from] base64::DecodeError),
#[error("protocol version mismatch: got {got}, expected {expected}")]
Version { got: u8, expected: u8 },
#[error("sample count mismatch: got {got}, expected {expected}")]
SampleCount { got: usize, expected: usize },
#[error("decoded PCM length {got} is not a whole number of i16 samples (expected even)")]
OddPcmLength { got: usize },
}
/// Encode a `PcmFrame` → base64 LE i16 bytes (spec §3, §9 — explicit LE).
pub fn encode_pcm(frame: &PcmFrame) -> String {
use base64::Engine as _;
// `i16::to_le_bytes()` — explicit little-endian, NOT host-endian.
// The wire contract is LE-only in v1; a big-endian brain would need
// v2 endianness negotiation (spec §9). On x86_64/aarch64 this is a
// no-op at the hardware level but the explicit call site documents
// the wire contract for the reader.
let bytes: Vec<u8> = frame.samples.iter().flat_map(|s| s.to_le_bytes()).collect();
base64::engine::general_purpose::STANDARD.encode(&bytes)
}
/// Decode base64 LE i16 bytes → `PcmFrame`. Validates `samples` count.
pub fn decode_pcm(pcm_b64: &str, expected_samples: usize) -> Result<PcmFrame, TapProtoError> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD.decode(pcm_b64)?;
if bytes.len() % 2 != 0 {
return Err(TapProtoError::OddPcmLength { got: bytes.len() });
}
let got_samples = bytes.len() / 2;
if got_samples != expected_samples {
return Err(TapProtoError::SampleCount {
got: got_samples,
expected: expected_samples,
});
}
let mut samples = [0i16; SAMPLES_PER_FRAME];
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
// `i16::from_le_bytes` — explicit LE decode, matches `encode_pcm`.
samples[i] = i16::from_le_bytes([chunk[0], chunk[1]]);
}
Ok(PcmFrame { samples })
}
/// Build an outgoing `audio_in` frame string (core → brain, spec §3.2).
pub fn encode_audio_in(frame: &PcmFrame, seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::AudioIn,
seq,
ts,
payload: Payload::AudioIn(AudioPayload {
pcm: encode_pcm(frame),
samples: SAMPLES_PER_FRAME,
}),
};
Ok(serde_json::to_string(&env)?)
}
/// Build an outgoing `audio_out` frame string (brain → core, spec §3.3).
pub fn encode_audio_out(frame: &PcmFrame, seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::AudioOut,
seq,
ts,
payload: Payload::AudioOut(AudioPayload {
pcm: encode_pcm(frame),
samples: SAMPLES_PER_FRAME,
}),
};
Ok(serde_json::to_string(&env)?)
}
pub fn encode_hello(session_id: &str, seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::Hello,
seq,
ts,
payload: Payload::Hello(HelloPayload {
session_id: session_id.to_string(),
sample_rate: 24000,
channels: "mono".to_string(),
frame_ms: 20,
}),
};
Ok(serde_json::to_string(&env)?)
}
pub fn encode_session_end(reason: &str, seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::SessionEnd,
seq,
ts,
payload: Payload::SessionEnd(SessionEndPayload {
reason: reason.to_string(),
}),
};
Ok(serde_json::to_string(&env)?)
}
pub fn encode_bye(reason: &str, seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::Bye,
seq,
ts,
payload: Payload::Bye(ReasonPayload {
reason: reason.to_string(),
}),
};
Ok(serde_json::to_string(&env)?)
}
pub fn encode_error(code: &str, msg: &str, seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::Error,
seq,
ts,
payload: Payload::Error(ErrorPayload {
code: code.to_string(),
message: msg.to_string(),
}),
};
Ok(serde_json::to_string(&env)?)
}
/// Build `speech_started` (brain → core, advisory; spec §3.2).
pub fn encode_speech_started(seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::SpeechStarted,
seq,
ts,
payload: Payload::SpeechStarted,
};
Ok(serde_json::to_string(&env)?)
}
/// Build `speech_stopped` (brain → core, advisory; spec §3.2).
pub fn encode_speech_stopped(seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::SpeechStopped,
seq,
ts,
payload: Payload::SpeechStopped,
};
Ok(serde_json::to_string(&env)?)
}
/// Build `function_call` (brain → core; spec §3.2). `args_json_str` is the
/// raw JSON string the brain's translator parses from OpenAI's
/// `response.function_call_arguments.done.arguments` (which is itself a
/// JSON string in OpenAI's wire format).
pub fn encode_function_call(
id: &str,
name: &str,
args_json_str: &str,
seq: u64,
ts: u64,
) -> Result<String, TapProtoError> {
let args: serde_json::Value = if args_json_str.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_str(args_json_str)?
};
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::FunctionCall,
seq,
ts,
payload: Payload::FunctionCall(FunctionCallPayload {
id: id.to_string(),
name: name.to_string(),
args,
}),
};
Ok(serde_json::to_string(&env)?)
}
/// Build `function_call_output` (core → brain; spec §3.3). `result_json_str`
/// is the raw JSON string the tool-registry dispatch returns (serialized
/// into the result field of the payload).
pub fn encode_function_call_output(
id: &str,
status: &str, // "ok" | "error" | "not_implemented"
result_json_str: &str,
seq: u64,
ts: u64,
) -> Result<String, TapProtoError> {
let result: serde_json::Value = if result_json_str.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_str(result_json_str)?
};
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::FunctionCallOutput,
seq,
ts,
payload: Payload::FunctionCallOutput(FunctionCallOutputPayload {
id: id.to_string(),
status: status.to_string(),
result,
}),
};
Ok(serde_json::to_string(&env)?)
}
/// Build `tools.update` (brain → core; spec §3.2). `tools_json_str` is the
/// raw JSON array of tool descriptors.
pub fn encode_tools_update(
tools_json_str: &str,
seq: u64,
ts: u64,
) -> Result<String, TapProtoError> {
let tools: serde_json::Value = if tools_json_str.is_empty() {
serde_json::Value::Array(vec![])
} else {
serde_json::from_str(tools_json_str)?
};
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::ToolsUpdate,
seq,
ts,
payload: Payload::ToolsUpdate(ToolsUpdatePayload { tools }),
};
Ok(serde_json::to_string(&env)?)
}
/// Decode an incoming wire string into a `DecodedFrame`. Validates `v` and
/// (for audio frames) `samples` and PCM round-trip. Unknown `type` values
/// decode to `DecodedPayload::Unknown` (caller logs + counts + drops per
/// spec §3.4).
///
/// # Deserialize strategy — `WireEnvelope` + dispatch on `kind`
///
/// We can't deserialize straight into `Envelope` because `Envelope` has no
/// derived `Deserialize` (see its type-level doc). Instead we deserialize
/// into a private `WireEnvelope` whose `#[serde(flatten)] extra:
/// Map<String, Value>` hoovers up every field not named `v`/`type`/`seq`/
/// `ts` — that's the payload's fields, exactly the flat wire shape spec §3.1
/// mandates. Then we dispatch on `kind` (recovered via `FrameKind`'s
/// `#[serde(other)] Unknown` fallback) and deserialize the `extra` map
/// into the specific payload struct.
///
/// This is the cleanest way to (a) keep the flat wire shape, (b) distinguish
/// `audio_in` from `audio_out` (they share `AudioPayload` shape — only
/// `kind` tells them apart), and (c) route unknown `type` strings to
/// `DecodedPayload::Unknown` rather than erroring (the brief's tagged-enum
/// approach can't do (c)).
pub fn decode_envelope(s: &str) -> Result<DecodedFrame, TapProtoError> {
let w: WireEnvelope = serde_json::from_str(s)?;
if w.v != PROTOCOL_VERSION {
return Err(TapProtoError::Version {
got: w.v,
expected: PROTOCOL_VERSION,
});
}
let extra_value = serde_json::Value::Object(w.extra);
let payload = match w.kind {
FrameKind::Hello => {
let p: HelloPayload = serde_json::from_value(extra_value)?;
DecodedPayload::Hello(p)
}
FrameKind::AudioIn => {
let p: AudioPayload = serde_json::from_value(extra_value)?;
if p.samples != SAMPLES_PER_FRAME {
return Err(TapProtoError::SampleCount {
got: p.samples,
expected: SAMPLES_PER_FRAME,
});
}
// Validate the PCM decodes (drops malformations early; hot-path
// policy is "drop + observe" — caller logs + counts, doesn't crash).
let _ = decode_pcm(&p.pcm, p.samples)?;
DecodedPayload::AudioIn(p)
}
FrameKind::AudioOut => {
let p: AudioPayload = serde_json::from_value(extra_value)?;
if p.samples != SAMPLES_PER_FRAME {
return Err(TapProtoError::SampleCount {
got: p.samples,
expected: SAMPLES_PER_FRAME,
});
}
let _ = decode_pcm(&p.pcm, p.samples)?;
DecodedPayload::AudioOut(p)
}
FrameKind::SessionEnd => {
let p: SessionEndPayload = serde_json::from_value(extra_value)?;
DecodedPayload::SessionEnd(p)
}
FrameKind::Bye => {
let p: ReasonPayload = serde_json::from_value(extra_value)?;
DecodedPayload::Bye(p)
}
FrameKind::Error => {
let p: ErrorPayload = serde_json::from_value(extra_value)?;
DecodedPayload::Error(p)
}
FrameKind::SpeechStarted => DecodedPayload::SpeechStarted,
FrameKind::SpeechStopped => DecodedPayload::SpeechStopped,
FrameKind::FunctionCall => {
let p: FunctionCallPayload = serde_json::from_value(extra_value)?;
DecodedPayload::FunctionCall(p)
}
FrameKind::FunctionCallOutput => {
let p: FunctionCallOutputPayload = serde_json::from_value(extra_value)?;
DecodedPayload::FunctionCallOutput(p)
}
FrameKind::ToolsUpdate => {
let p: ToolsUpdatePayload = serde_json::from_value(extra_value)?;
DecodedPayload::ToolsUpdate(p)
}
FrameKind::Unknown => DecodedPayload::Unknown,
};
Ok(DecodedFrame {
v: w.v,
seq: w.seq,
ts: w.ts,
payload,
})
}
/// Private intermediate for `decode_envelope`: parses the four named
/// envelope fields and flattens every other JSON key (the payload fields)
/// into `extra`. The `extra` map is then dispatched on `kind` to build
/// the specific payload struct (see `decode_envelope`).
///
/// `seq` and `ts` carry `#[serde(default)]` because spec §3.1 marks `ts`
/// as advisory and a malformed peer might omit `seq`; we'd rather decode-
/// and-observe than reject. (No slice-2 test exercises this, but it
/// matches the "drop + observe" posture of spec §3.8.)
#[derive(Deserialize)]
struct WireEnvelope {
v: u8,
#[serde(rename = "type")]
kind: FrameKind,
#[serde(default)]
seq: u64,
#[serde(default)]
ts: u64,
#[serde(flatten)]
extra: serde_json::Map<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_in_round_trips() {
let mut frame = PcmFrame::zeroed();
frame.samples[0] = 1234;
frame.samples[479] = -5678;
let wire = encode_audio_in(&frame, 7, 42).unwrap();
let decoded = decode_envelope(&wire).unwrap();
assert_eq!(decoded.v, 1);
assert_eq!(decoded.seq, 7);
assert_eq!(decoded.ts, 42);
match decoded.payload {
DecodedPayload::AudioIn(p) => {
assert_eq!(p.samples, 480);
let decoded_frame = decode_pcm(&p.pcm, p.samples).unwrap();
assert_eq!(decoded_frame.samples[0], 1234);
assert_eq!(decoded_frame.samples[479], -5678);
}
other => panic!("expected AudioIn, got {:?}", other),
}
}
#[test]
fn audio_out_round_trips() {
let frame = PcmFrame::zeroed();
let wire = encode_audio_out(&frame, 0, 0).unwrap();
let decoded = decode_envelope(&wire).unwrap();
assert!(matches!(decoded.payload, DecodedPayload::AudioOut(_)));
}
#[test]
fn hello_round_trips() {
let wire = encode_hello("550e8400-e29b-41d4-a716-446655440000", 0, 0).unwrap();
let decoded = decode_envelope(&wire).unwrap();
match decoded.payload {
DecodedPayload::Hello(p) => {
assert_eq!(p.session_id, "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(p.sample_rate, 24000);
assert_eq!(p.channels, "mono");
assert_eq!(p.frame_ms, 20);
}
other => panic!("expected Hello, got {:?}", other),
}
}
#[test]
fn session_end_round_trips() {
let wire = encode_session_end("hangup", 3, 100).unwrap();
let decoded = decode_envelope(&wire).unwrap();
match decoded.payload {
DecodedPayload::SessionEnd(p) => assert_eq!(p.reason, "hangup"),
other => panic!("expected SessionEnd, got {:?}", other),
}
}
#[test]
fn bye_round_trips() {
let wire = encode_bye("core_shutdown", 5, 200).unwrap();
let decoded = decode_envelope(&wire).unwrap();
assert!(matches!(decoded.payload, DecodedPayload::Bye(_)));
}
#[test]
fn error_round_trips() {
let wire = encode_error("bad_samples", "expected 480", 1, 50).unwrap();
let decoded = decode_envelope(&wire).unwrap();
match decoded.payload {
DecodedPayload::Error(p) => {
assert_eq!(p.code, "bad_samples");
assert_eq!(p.message, "expected 480");
}
other => panic!("expected Error, got {:?}", other),
}
}
#[test]
fn pcm_is_explicit_little_endian() {
let mut frame = PcmFrame::zeroed();
frame.samples[0] = 0x0102; // 258 — LE bytes are [0x02, 0x01]
let s = encode_pcm(&frame);
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&s)
.unwrap();
// First two bytes are samples[0] in LE: [0x02, 0x01].
assert_eq!(bytes[0], 0x02);
assert_eq!(bytes[1], 0x01);
}
#[test]
fn sample_count_mismatch_returns_error() {
let wire = r#"{"v":1,"type":"audio_in","seq":0,"ts":0,"pcm":"AAAA","samples":100}"#;
let err = decode_envelope(wire).unwrap_err();
assert!(matches!(
err,
TapProtoError::SampleCount {
got: 100,
expected: 480
}
));
}
#[test]
fn unknown_type_decodes_to_unknown_variant() {
let wire = r#"{"v":1,"type":"future_event","seq":0,"ts":0}"#;
let decoded = decode_envelope(wire).unwrap();
assert!(matches!(decoded.payload, DecodedPayload::Unknown));
}
#[test]
fn version_mismatch_returns_error() {
let wire = r#"{"v":99,"type":"hello","seq":0,"ts":0,"session_id":"x"}"#;
let err = decode_envelope(wire).unwrap_err();
assert!(matches!(
err,
TapProtoError::Version {
got: 99,
expected: 1
}
));
}
/// Slice-3 additive event types must round-trip through (de)serialization
/// without breaking slice-2's v1 contract. Every new kind + payload.
#[test]
fn speech_started_round_trips() {
let s = encode_speech_started(7, 100).unwrap();
assert!(s.contains("\"type\":\"speech_started\""));
assert!(s.contains("\"v\":1"));
let d = decode_envelope(&s).unwrap();
assert_eq!(d.seq, 7);
assert_eq!(d.ts, 100);
assert!(matches!(d.payload, DecodedPayload::SpeechStarted));
}
#[test]
fn speech_stopped_round_trips() {
let s = encode_speech_stopped(9, 200).unwrap();
assert!(s.contains("\"type\":\"speech_stopped\""));
let d = decode_envelope(&s).unwrap();
assert_eq!(d.seq, 9);
assert!(matches!(d.payload, DecodedPayload::SpeechStopped));
}
#[test]
fn function_call_round_trips() {
let s = encode_function_call("abc-123", "hangup", "{}", 0, 0).unwrap();
assert!(s.contains("\"type\":\"function_call\""));
assert!(s.contains("\"id\":\"abc-123\""));
assert!(s.contains("\"name\":\"hangup\""));
let d = decode_envelope(&s).unwrap();
match d.payload {
DecodedPayload::FunctionCall(p) => {
assert_eq!(p.id, "abc-123");
assert_eq!(p.name, "hangup");
}
other => panic!("expected FunctionCall, got {other:?}"),
}
}
#[test]
fn function_call_output_round_trips() {
let s =
encode_function_call_output("abc-123", "ok", r#"{"channel_state":"Closing"}"#, 0, 0)
.unwrap();
assert!(s.contains("\"type\":\"function_call_output\""));
assert!(s.contains("\"status\":\"ok\""));
let d = decode_envelope(&s).unwrap();
match d.payload {
DecodedPayload::FunctionCallOutput(p) => {
assert_eq!(p.id, "abc-123");
assert_eq!(p.status, "ok");
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn tools_update_round_trips() {
let tools_json = r#"[{"name":"hangup","description":"hang up the call"}]"#;
let s = encode_tools_update(tools_json, 0, 0).unwrap();
assert!(s.contains("\"type\":\"tools.update\""));
assert!(s.contains("\"tools\":["));
let d = decode_envelope(&s).unwrap();
match d.payload {
DecodedPayload::ToolsUpdate(p) => {
assert!(p.tools.is_array());
assert_eq!(p.tools.as_array().unwrap().len(), 1);
}
other => panic!("expected ToolsUpdate, got {other:?}"),
}
}
/// Forwards-compat: slice-2's echo brain sees the new types as unknown
/// (the `#[serde(other)]` on the old enum absorbed them). With the new
/// enum in place, the kinds decode to their new variants (rather than
/// Unknown). This test asserts the decode no longer drops them.
#[test]
fn new_kinds_decode_to_their_variants_not_unknown() {
for s in [
encode_speech_started(0, 0).unwrap(),
encode_speech_stopped(0, 0).unwrap(),
encode_function_call("x", "x", "null", 0, 0).unwrap(),
encode_function_call_output("x", "ok", "null", 0, 0).unwrap(),
encode_tools_update("[]", 0, 0).unwrap(),
] {
let d = decode_envelope(&s).unwrap();
assert!(
!matches!(d.payload, DecodedPayload::Unknown),
"new event type decoded as Unknown: {s}"
);
}
}
}