Slice 3 — OpenAI Realtime brain: swap echo for the brain (#4)
All checks were successful
CI / fmt (push) Successful in 1m40s
CI / clippy (push) Successful in 2m24s
CI / test (1.85) (push) Successful in 5m8s
CI / test (stable) (push) Successful in 5m20s
CI / deny (push) Successful in 1m34s

This commit was merged in pull request #4.
This commit is contained in:
2026-07-01 22:25:09 +00:00
parent 2f3f92ec6b
commit c30a45232d
36 changed files with 7227 additions and 20 deletions

View File

@@ -41,10 +41,14 @@ pub use protocol::{
AudioPayload, DecodedFrame, DecodedPayload, Envelope, ErrorPayload, FrameKind, HelloPayload,
PROTOCOL_VERSION, Payload, ReasonPayload, SAMPLES_PER_FRAME as WIRE_SAMPLES_PER_FRAME,
SessionEndPayload, TapProtoError, decode_envelope, decode_pcm, encode_audio_in,
encode_audio_out, encode_bye, encode_error, encode_hello, encode_pcm, encode_session_end,
encode_audio_out, encode_bye, encode_error, encode_function_call, encode_function_call_output,
encode_hello, encode_pcm, encode_session_end, encode_speech_started, encode_speech_stopped,
encode_tools_update,
};
// Slice-3 additive (spec §3).
pub use protocol::{FunctionCallOutputPayload, FunctionCallPayload, ToolsUpdatePayload};
pub use tap_audio_pipe::TapAudioPipe;
pub use tap_client::{TapClientError, run_tap_client};
pub use tap_client::{FunctionCallEvent, FunctionCallOutputEvent, TapClientError, run_tap_client};
#[cfg(test)]
mod tests {

View File

@@ -47,6 +47,21 @@ pub enum FrameKind {
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)]
@@ -111,6 +126,11 @@ impl Serialize for Envelope {
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)?;
@@ -142,6 +162,23 @@ impl Serialize for Envelope {
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()
}
@@ -165,6 +202,16 @@ pub enum Payload {
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)]
@@ -211,6 +258,42 @@ pub struct ErrorPayload {
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
@@ -231,7 +314,17 @@ pub enum DecodedPayload {
SessionEnd(SessionEndPayload),
Bye(ReasonPayload),
Error(ErrorPayload),
/// Unknown `type` — log + count + drop (spec §3.4).
/// 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,
}
@@ -369,6 +462,111 @@ pub fn encode_error(code: &str, msg: &str, seq: u64, ts: u64) -> Result<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
@@ -440,6 +638,20 @@ pub fn decode_envelope(s: &str) -> Result<DecodedFrame, TapProtoError> {
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 {
@@ -596,4 +808,96 @@ mod tests {
}
));
}
/// 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}"
);
}
}
}

View File

@@ -63,11 +63,46 @@ pub enum TapClientError {
/// `close` is a shared borrow (`&mut oneshot::Receiver<()>`) so the
/// `TapEngine` reconnect loop (Task 7) can share one close signal across
/// reconnect attempts of the same session (see module docs).
///
/// # slice-3 §5.2 — the tool-call side-channel
///
/// Two extra mpsc halves (relative to slice-2's audio-only pump) carry the
/// brain's `function_call` proposals out to the binary's poll task and the
/// binary's `function_call_output` replies back onto the wire:
///
/// - `tx_function_call` — the TapClient emits a `FunctionCallEvent` here
/// whenever it observes a tap `function_call` frame on its inbound WS
/// stream. The binary's poll task drains this in the same cycle it drains
/// the existing `flush_tx` side-channel (slice-2 §5.3 step 4 — one extra
/// channel, same cycle) and dispatches via `ToolRegistry::dispatch`.
/// - `rx_function_call_output` — the binary's poll task writes
/// `FunctionCallOutputEvent`s here (after a `ToolRegistry::dispatch` call
/// completes); the TapClient drains this in the same `tokio::select!`
/// pump as the audio + close arms and sends each as a `function_call_output`
/// tap WS frame to the brain.
///
/// Both halves are mpsc ends (not oneshot) because the brain may propose
/// multiple tool calls per session (one-of-many, not one-of-one) and the
/// binary may queue multiple replies before the TapClient's pump cycle
/// drains them.
//
// clippy::too_many_arguments: the slice-3 §5.2 design added two more mpsc
// halves to slice-2's already-5-arg pump signature for a total of 8. Each
// arg is a distinct channel end with a distinct lifetime owner (the WS
// stream, the session id, two sender/receiver pairs for audio, two for the
// tool-call side-channel, the metrics Arc, the close oneshot). Wrapping
// them in a struct would obscure that all-but-one are channel ends shared
// with the binary's poll task — the flat signature mirrors slice-2's
// precedent and keeps the call site readable. Suppress per AGENTS.md's
// "documented inline rationale" exception to the -D warnings bar.
#[allow(clippy::too_many_arguments)]
pub async fn run_tap_client<T>(
mut ws: WebSocketStream<T>,
session_id: ChannelId,
rx_pcm_in: &mut mpsc::Receiver<PcmFrame>,
tx_audio_out: mpsc::Sender<PcmFrame>,
tx_function_call: mpsc::Sender<FunctionCallEvent>,
rx_function_call_output: &mut mpsc::Receiver<FunctionCallOutputEvent>,
metrics: Arc<TapMetrics>,
close: &mut oneshot::Receiver<()>,
) -> Result<(), TapClientError>
@@ -175,6 +210,44 @@ where
}
}
}
// slice-3 §5.2: drain a `function_call_output` event the binary's
// poll task wrote (after `ToolRegistry::dispatch` returned) + send
// it as a `function_call_output` tap WS frame to the brain. The
// `seq_egress` bump mirrors the audio arm — every egress frame
// shares the same per-direction counter (spec §3.1).
//
// Like `rx_pcm_in.recv()`, this is one of many arms in the
// select! — the engine's `tx_function_call_output` sender lives
// in the binary; `run_tap_client` returns when the brain WS ends
// or close fires, regardless of pending function_call_output
// events (they're dropped on close — same posture as pending
// audio_out frames on teardown).
out = rx_function_call_output.recv() => {
if let Some(out) = out {
let ts = elapsed_ms(session_start);
let result_str = out.0.result.to_string();
match crate::protocol::encode_function_call_output(
&out.0.id,
&out.0.status,
&result_str,
seq_egress,
ts,
) {
Ok(s) => {
seq_egress += 1;
if let Err(e) = ws.send(Message::Text(s)).await {
warn!(error = ?e, %session_id, "ws send function_call_output failed");
return Err(e.into());
}
info!(%session_id, call_id = %out.0.id, status = %out.0.status, "sent function_call_output to brain");
}
Err(e) => {
metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
warn!(error = ?e, "encode function_call_output failed; dropping");
}
}
}
}
// Inbound WS frame from brain.
msg = ws.next() => {
let Some(msg) = msg else {
@@ -188,7 +261,7 @@ where
if let Ok(text) = msg.into_text() {
handle_brain_frame(
&text, &mut last_seq_ingress, &tx_audio_out,
&metrics, session_start,
&tx_function_call, &metrics, session_start,
).await;
}
}
@@ -251,6 +324,7 @@ async fn handle_brain_frame(
text: &str,
last_seq_ingress: &mut Option<u64>,
tx_audio_out: &mpsc::Sender<PcmFrame>,
tx_function_call: &mpsc::Sender<FunctionCallEvent>,
metrics: &Arc<TapMetrics>,
session_start: Instant,
) {
@@ -307,6 +381,41 @@ async fn handle_brain_frame(
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
warn!("unexpected frame direction from brain; dropping");
}
// Slice-3 spec §5.2: `function_call` flows through the side-channel
// (NON-BLOCKING try_send — the binary's poll task drains on its own
// cycle). The same "drop + observe" posture as audio_out applies if
// the channel is full: a backed-up binary means we drop the proposal
// and the brain gets no reply (the brain process knows no
// function_call_output arrived → its OpenAI pump keeps going; the
// model tolerates missing replies per OpenAI's design).
DecodedPayload::FunctionCall(p) => {
if tx_function_call.try_send(FunctionCallEvent(p)).is_err() {
metrics.outbound_dropped.fetch_add(1, Ordering::Relaxed);
warn!(
"function_call dropped (binary poll task not draining; brain will see no reply)"
);
}
}
// Slice-3 (spec §3.2): `function_call_output` is core→brain; ignore
// if a brain sends one back (a misbehaving brain — same posture as
// `SessionEnd`/`AudioIn` from brain above).
DecodedPayload::FunctionCallOutput(_) => {
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
warn!("unexpected function_call_output from brain; dropping");
}
// Slice-3 advisory — same "logged + counted, not forwarded" posture
// as `Unknown`. The FOB reflex loop in step 4 will act on these;
// slice-3 only pre-paves the wire event.
DecodedPayload::SpeechStarted | DecodedPayload::SpeechStopped => {
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
debug!("advisory interruption event observed; not acted on in slice-3");
}
DecodedPayload::ToolsUpdate(_) => {
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
debug!(
"tools.update observed; slice-3 dispatch keys off function_call by name, not catalog"
);
}
}
let _ = session_start; // used for ts computation if added later
}
@@ -315,6 +424,36 @@ fn elapsed_ms(start: Instant) -> u64 {
start.elapsed().as_millis() as u64
}
/// A `function_call` event the TapClient **observed** on its inbound WS
/// stream and forwarded to the binary's poll task via the
/// `tx_function_call` side-channel (spec §5.2). The binary's poll task
/// drains this (alongside the existing `flush_rx` side-channel — slice-2
/// §5.3 step 4) and dispatches each event through `ToolRegistry::dispatch`.
///
/// # Why a thin newtype over `FunctionCallPayload` (and not a bare alias)?
///
/// A type alias (`pub type FunctionCallEvent = FunctionCallPayload;`) would
/// let the binary pass a `FunctionCallPayload` where a `FunctionCallEvent`
/// is expected without surfacing the intent. The newtype draws a small but
/// real boundary: the wire-payload type (`FunctionCallPayload`) lives in
/// `protocol.rs` for (de)serialization; the side-channel event type
/// (`FunctionCallEvent`) lives here for dispatch. They share a shape but
/// carry different semantic weight — honoring the newtype-over-primitives
/// convention from AGENTS.md even at the message level.
#[derive(Debug, Clone)]
pub struct FunctionCallEvent(pub crate::protocol::FunctionCallPayload);
/// A `function_call_output` event the binary's poll task **emits** back to
/// the TapClient via the `tx_function_call_output` side-channel (spec §5.2
/// — the binary's poll task dispatches through `ToolRegistry::dispatch`,
/// serializes the `ToolResult`, and writes the output here). The TapClient
/// drains this in the same `tokio::select!` pump cycle as the audio + close
/// arms and sends each as a `function_call_output` tap WS frame.
///
/// Same newtype-over-payload rationale as `FunctionCallEvent`.
#[derive(Debug, Clone)]
pub struct FunctionCallOutputEvent(pub crate::protocol::FunctionCallOutputPayload);
#[cfg(test)]
mod tests {
// TapClient is heavily async; its real behavior is exercised in the
@@ -322,6 +461,7 @@ mod tests {
// tests here cover the pure helpers.
use super::*;
use crate::protocol::encode_function_call;
#[test]
fn elapsed_ms_is_monotonic_nonneg() {
@@ -330,4 +470,83 @@ mod tests {
// First call ~0; just assert it's a valid u64.
assert_eq!(ms, ms); // tautology but clippy-clean
}
/// slice-3 spec §5.2: when the TapClient observes a tap `function_call`
/// frame on its inbound WS stream it emits a `FunctionCallEvent` on
/// the `tx_function_call` side-channel. The binary's poll task drains
/// that and dispatches via `ToolRegistry::dispatch`. This test pins the
/// contract end-to-end through the pure helper (`handle_brain_frame`):
/// hand it a wire-encoded function_call frame + a fresh mpsc pair and
/// assert the receiver observes the forwarded event.
#[tokio::test]
async fn handle_brain_frame_forwards_function_call_to_side_channel() {
let (tx_fc, mut rx_fc) = mpsc::channel::<FunctionCallEvent>(8);
let (tx_audio_out, _rx_audio_out) = mpsc::channel::<PcmFrame>(8);
let metrics = Arc::new(TapMetrics::new());
// Build a wire function_call frame: id="call-1", name="hangup", args={}.
let wire = encode_function_call("call-1", "hangup", "{}", 1, 100).unwrap();
let mut last_seq: Option<u64> = None;
handle_brain_frame(
&wire,
&mut last_seq,
&tx_audio_out,
&tx_fc,
&metrics,
Instant::now(),
)
.await;
// The side-channel must have observed exactly one FunctionCallEvent
// carrying the wire's id/name/args.
let event = tokio::time::timeout(Duration::from_millis(200), rx_fc.recv())
.await
.expect("tx_function_call drained within 200ms")
.expect("channel not closed");
assert_eq!(event.0.id, "call-1");
assert_eq!(event.0.name, "hangup");
assert_eq!(event.0.args, serde_json::json!({}));
// seq tracking still updates for the side-channeled event.
assert_eq!(last_seq, Some(1));
}
/// slice-3 spec §5.2 — the *advisory* interrupt events (`speech_started`
/// /`speech_stopped`) and `tools.update` are observed (logged + counted)
/// but do NOT flow through the function_call side-channel (only
/// `function_call` does — that's the only event with a binary-side
/// disposal). This pins that boundary: an advisory event must NOT
/// produce a `FunctionCallEvent` even with the channel plumbed.
#[tokio::test]
async fn advisory_events_are_logged_not_forwarded_to_function_call_channel() {
let (tx_fc, mut rx_fc) = mpsc::channel::<FunctionCallEvent>(8);
let (tx_audio_out, _rx_audio_out) = mpsc::channel::<PcmFrame>(8);
let metrics = Arc::new(TapMetrics::new());
let wire = crate::protocol::encode_speech_started(2, 200).unwrap();
let mut last_seq: Option<u64> = None;
handle_brain_frame(
&wire,
&mut last_seq,
&tx_audio_out,
&tx_fc,
&metrics,
Instant::now(),
)
.await;
// No FunctionCallEvent forwarded — the channel stays empty. Pick a
// tight bounded receive so the test fails fast if a future refactor
// starts forwarding advisory events here.
assert!(
tokio::time::timeout(Duration::from_millis(50), rx_fc.recv())
.await
.is_err(),
"no FunctionCallEvent expected for advisory events"
);
// The advisory event IS still observed via metrics (seq gap tracking
// + the unknown-slot counter remains 0 — speech_started is now a
// known payload variant).
assert_eq!(last_seq, Some(2));
}
}