Slice 3 — OpenAI Realtime brain: swap echo for the brain #4
1
crates/rutster-tap/tests/fixtures/function_call.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/function_call.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"type":"function_call","seq":0,"ts":0,"id":"abc-123","name":"hangup","args":{}}
|
||||||
1
crates/rutster-tap/tests/fixtures/function_call_output.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/function_call_output.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"type":"function_call_output","seq":0,"ts":0,"id":"abc-123","status":"ok","result":{"channel_state":"Closing"}}
|
||||||
1
crates/rutster-tap/tests/fixtures/speech_started.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/speech_started.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"type":"speech_started","seq":7,"ts":100}
|
||||||
1
crates/rutster-tap/tests/fixtures/speech_stopped.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/speech_stopped.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"type":"speech_stopped","seq":9,"ts":200}
|
||||||
1
crates/rutster-tap/tests/fixtures/tools_update.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/tools_update.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"type":"tools.update","seq":0,"ts":0,"tools":[{"description":"hang up the call","name":"hangup"}]}
|
||||||
225
crates/rutster-tap/tests/protocol_events.rs
Normal file
225
crates/rutster-tap/tests/protocol_events.rs
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
//! Slice-3 golden-fixture tests for the tap protocol's new event types.
|
||||||
|
//!
|
||||||
|
//! ## Why this file exists (alongside `protocol.rs`'s inline tests)
|
||||||
|
//!
|
||||||
|
//! `protocol.rs` already has inline round-trip tests for each new slice-3
|
||||||
|
//! event kind, but they assert with `String::contains` — a *substring* check.
|
||||||
|
//! That catches "the right `type` tag is present" but it cannot catch:
|
||||||
|
//!
|
||||||
|
//! - a change in **field order** (the `Envelope` `Serialize` impl at
|
||||||
|
//! `protocol.rs::Serialize_for_Envelope` pins field order deliberately —
|
||||||
|
//! see its doc comment: "field order is pinned to make the wire bytes
|
||||||
|
//! deterministic… helps snapshot tests and interop with brains that read
|
||||||
|
//! key order for debugging");
|
||||||
|
//! - a stray extra field;
|
||||||
|
//! - a missing field;
|
||||||
|
//! - a change in whitespace or number formatting.
|
||||||
|
//!
|
||||||
|
//! These fixtures lock the **exact wire bytes** produced by each `encode_*`
|
||||||
|
//! function. Any future refactor that alters the on-wire shape — even
|
||||||
|
//! cosmetically — breaks a test here, forcing a conscious fixture update +
|
||||||
|
//! a protocol-version bump conversation (spec §3.1: `PROTOCOL_VERSION` is
|
||||||
|
//! the contract door).
|
||||||
|
//!
|
||||||
|
//! ## Forwards-compat (the slice-2 §3.4 "drop + observe" contract)
|
||||||
|
//!
|
||||||
|
//! The last test here re-asserts the load-bearing decode-side rule: an
|
||||||
|
//! unknown `type` string deserializes to `DecodedPayload::Unknown` (via
|
||||||
|
//! `FrameKind`'s `#[serde(other)]` fallback) rather than erroring. This is
|
||||||
|
//! the foundation of the forwards-compat posture — a brain speaking a
|
||||||
|
//! future protocol version's event kind must not crash an older core; it
|
||||||
|
//! gets logged + counted + dropped (the behavioral side lives in
|
||||||
|
//! `tap_client::handle_brain_frame` and is exercised end-to-end by the
|
||||||
|
//! slice-2 / slice-3 integration tests, not here).
|
||||||
|
//!
|
||||||
|
//! ## Why string equality (not `serde_json::Value` equality)
|
||||||
|
//!
|
||||||
|
//! Comparing parsed `serde_json::Value`s would be *semantic* equality — it
|
||||||
|
//! would treat `{"a":1,"b":2}` and `{"b":2,"a":1}` as the same. That
|
||||||
|
//! throws away the field-order pinning the `Envelope` serializer
|
||||||
|
//! guarantees. Comparing the raw encoded `String` to the fixture's bytes
|
||||||
|
//! keeps the order contract load-bearing.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use rutster_tap::{
|
||||||
|
DecodedPayload, decode_envelope, encode_function_call, encode_function_call_output,
|
||||||
|
encode_speech_started, encode_speech_stopped, encode_tools_update,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Read a fixture file from `tests/fixtures/<name>`. Uses
|
||||||
|
/// `CARGO_MANIFEST_DIR` so the path is stable regardless of which
|
||||||
|
/// directory `cargo test` is invoked from. Trailing newlines are stripped
|
||||||
|
/// because `serde_json::to_string` (what the encoders call) never emits one
|
||||||
|
/// — a fixture checked into git shouldn't acquire a phantom newline mismatch.
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
let path = format!("{}/tests/fixtures/{}", env!("CARGO_MANIFEST_DIR"), name);
|
||||||
|
let raw =
|
||||||
|
fs::read_to_string(&path).unwrap_or_else(|e| panic!("read fixture {name} ({path}): {e}"));
|
||||||
|
raw.trim_end().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── speech_started ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_speech_started_wire_bytes_match_fixture() {
|
||||||
|
let encoded = encode_speech_started(7, 100).unwrap();
|
||||||
|
let golden = fixture("speech_started.json");
|
||||||
|
assert_eq!(
|
||||||
|
encoded, golden,
|
||||||
|
"speech_started wire bytes drifted from golden fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speech_started_fixture_decodes_to_speechstarted_variant() {
|
||||||
|
let golden = fixture("speech_started.json");
|
||||||
|
let decoded = decode_envelope(&golden).unwrap();
|
||||||
|
assert_eq!(decoded.seq, 7);
|
||||||
|
assert_eq!(decoded.ts, 100);
|
||||||
|
assert!(
|
||||||
|
matches!(decoded.payload, DecodedPayload::SpeechStarted),
|
||||||
|
"speech_started fixture did not decode to SpeechStarted: {:?}",
|
||||||
|
decoded.payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── speech_stopped ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_speech_stopped_wire_bytes_match_fixture() {
|
||||||
|
let encoded = encode_speech_stopped(9, 200).unwrap();
|
||||||
|
let golden = fixture("speech_stopped.json");
|
||||||
|
assert_eq!(
|
||||||
|
encoded, golden,
|
||||||
|
"speech_stopped wire bytes drifted from golden fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speech_stopped_fixture_decodes_to_speechstopped_variant() {
|
||||||
|
let golden = fixture("speech_stopped.json");
|
||||||
|
let decoded = decode_envelope(&golden).unwrap();
|
||||||
|
assert_eq!(decoded.seq, 9);
|
||||||
|
assert_eq!(decoded.ts, 200);
|
||||||
|
assert!(
|
||||||
|
matches!(decoded.payload, DecodedPayload::SpeechStopped),
|
||||||
|
"speech_stopped fixture did not decode to SpeechStopped: {:?}",
|
||||||
|
decoded.payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── function_call ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_function_call_wire_bytes_match_fixture() {
|
||||||
|
// `args_json_str="{}"` — the encoder parses it into `serde_json::Value`
|
||||||
|
// (an empty object) so the wire field is `"args":{}`, not `"args":"{}"`.
|
||||||
|
// This is load-bearing: a brain emitting a function_call with a raw
|
||||||
|
// JSON-string args would be a different wire shape (and a translator
|
||||||
|
// bug). The fixture pins the parsed-object shape.
|
||||||
|
let encoded = encode_function_call("abc-123", "hangup", "{}", 0, 0).unwrap();
|
||||||
|
let golden = fixture("function_call.json");
|
||||||
|
assert_eq!(
|
||||||
|
encoded, golden,
|
||||||
|
"function_call wire bytes drifted from golden fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn function_call_fixture_decodes_to_functioncall_with_fields() {
|
||||||
|
let golden = fixture("function_call.json");
|
||||||
|
let decoded = decode_envelope(&golden).unwrap();
|
||||||
|
match decoded.payload {
|
||||||
|
DecodedPayload::FunctionCall(p) => {
|
||||||
|
assert_eq!(p.id, "abc-123");
|
||||||
|
assert_eq!(p.name, "hangup");
|
||||||
|
assert_eq!(p.args, serde_json::Value::Object(serde_json::Map::new()));
|
||||||
|
}
|
||||||
|
other => panic!("expected FunctionCall, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── function_call_output ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_function_call_output_wire_bytes_match_fixture() {
|
||||||
|
let encoded =
|
||||||
|
encode_function_call_output("abc-123", "ok", r#"{"channel_state":"Closing"}"#, 0, 0)
|
||||||
|
.unwrap();
|
||||||
|
let golden = fixture("function_call_output.json");
|
||||||
|
assert_eq!(
|
||||||
|
encoded, golden,
|
||||||
|
"function_call_output wire bytes drifted from golden fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn function_call_output_fixture_decodes_to_functioncalloutput_with_fields() {
|
||||||
|
let golden = fixture("function_call_output.json");
|
||||||
|
let decoded = decode_envelope(&golden).unwrap();
|
||||||
|
match decoded.payload {
|
||||||
|
DecodedPayload::FunctionCallOutput(p) => {
|
||||||
|
assert_eq!(p.id, "abc-123");
|
||||||
|
assert_eq!(p.status, "ok");
|
||||||
|
// The result field is a parsed JSON object, not a string.
|
||||||
|
assert_eq!(
|
||||||
|
p.result.get("channel_state"),
|
||||||
|
Some(&serde_json::json!("Closing"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tools.update ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_tools_update_wire_bytes_match_fixture() {
|
||||||
|
let tools_json = r#"[{"name":"hangup","description":"hang up the call"}]"#;
|
||||||
|
let encoded = encode_tools_update(tools_json, 0, 0).unwrap();
|
||||||
|
let golden = fixture("tools_update.json");
|
||||||
|
assert_eq!(
|
||||||
|
encoded, golden,
|
||||||
|
"tools.update wire bytes drifted from golden fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tools_update_fixture_decodes_to_toolsupdate_with_array() {
|
||||||
|
let golden = fixture("tools_update.json");
|
||||||
|
let decoded = decode_envelope(&golden).unwrap();
|
||||||
|
match decoded.payload {
|
||||||
|
DecodedPayload::ToolsUpdate(p) => {
|
||||||
|
let arr = p
|
||||||
|
.tools
|
||||||
|
.as_array()
|
||||||
|
.expect("tools field must be a JSON array");
|
||||||
|
assert_eq!(arr.len(), 1);
|
||||||
|
assert_eq!(arr[0].get("name"), Some(&serde_json::json!("hangup")));
|
||||||
|
}
|
||||||
|
other => panic!("expected ToolsUpdate, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── forwards-compat (slice-2 §3.4 "drop + observe") ────────────────────
|
||||||
|
|
||||||
|
/// The load-bearing forwards-compat rule on the decode side: an unknown
|
||||||
|
/// `type` string deserializes to `DecodedPayload::Unknown` (via
|
||||||
|
/// `FrameKind`'s `#[serde(other)]`), **not** an error. A future-protocol
|
||||||
|
/// brain emitting `"type":"future_event"` must not crash an older core;
|
||||||
|
/// the core logs + counts + drops it (the observe-and-continue posture
|
||||||
|
/// the eventual fuzz harness will stress). This re-asserts the contract
|
||||||
|
/// the inline `unknown_type_decodes_to_unknown_variant` test established,
|
||||||
|
/// here in the external fixture file so it sits next to the golden wire
|
||||||
|
/// shapes it protects.
|
||||||
|
#[test]
|
||||||
|
fn unknown_wire_type_decodes_to_unknown_variant_not_error() {
|
||||||
|
let wire = r#"{"v":1,"type":"future_event","seq":0,"ts":0}"#;
|
||||||
|
let decoded = decode_envelope(wire).expect("unknown type must not error (forwards-compat)");
|
||||||
|
assert!(
|
||||||
|
matches!(decoded.payload, DecodedPayload::Unknown),
|
||||||
|
"forwards-compat broken: unknown type decoded to {:?}, expected Unknown",
|
||||||
|
decoded.payload
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user