From 3eb94f712cad0174a14d8923b4e9bf24939205b2 Mon Sep 17 00:00:00 2001 From: Madhu Ramasubramanian Date: Mon, 21 Sep 2026 20:00:49 -0400 Subject: [PATCH] VAPI-3989: map Twilio Stream children to StreamParam Twilio bots attach key/value context to a media stream with children on ; that is how most bots receive callSid, tenant, and similar values when the WebSocket opens. The translator read only the attributes and ignored its children, so a Stream with two Parameters produced byte-identical BXML to one with none, no finding was raised, and the bot connected with an empty customParameters map. Translator: - Each becomes a nested under the emitted , in order, for both Connect (bidirectional) and Start (fork) streams. Attribute values are XML-escaped by the builder. - Bandwidth allows at most 12 StreamParam per StartStream; extras are dropped with a Stream warning naming the count. Twilio caps name+value at 500 chars combined, so Bandwidth's 256/2048 per-attribute limits cannot be exceeded by valid TwiML and are not re-checked. - A missing name or value, or any non-Parameter child, is dropped with a warning instead of emitting BXML Bandwidth would reject. Bridge: - Add customParametersFromBwStart(), which maps Bandwidth's StartStream "start" event (streamParams: flat name->value map) to the Twilio customParameters map the bridge already forwards in its own "start" message. Values are coerced to strings; malformed input yields {}. Wiring a live Bandwidth source that calls it is VAPI-3991. Docs: update the Stream matrix note and AGENTS.md. Tests cover ordering, nesting inside StartStream, the fork case, escaping, the 12 cap, invalid Parameters, unknown children, and the bridge mapper end to end. --- AGENTS.md | 6 +- src/matrix/twilio-voice.json | 2 +- src/streams/bridge.ts | 26 +++++++- src/translator/translate.ts | 50 ++++++++++++++- test/streams-wire.test.ts | 44 ++++++++++++- test/translate-stream-conference.test.ts | 80 ++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a47aff..fa4bbf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,11 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess. TwiML omits one. `ConversationRelay` and `VirtualAgent` are unsupported (separate IoV). - `Stream` — Twilio's WS message schema is emulated by the translator's stream - bridge; live Bandwidth-side binding requires fixture capture. + bridge; live Bandwidth-side binding requires fixture capture. `` + children map to nested `` elements in order (Bandwidth allows + at most 12; extras are dropped with a warning). Bandwidth echoes them in its + `start` event as `streamParams`, and the bridge forwards them to the bot as + Twilio `customParameters`. - `Conference` — basic named conferences work, but `waitUrl` hold music has no Bandwidth equivalent, `beep` is only partially supported, and `startConferenceOnEnter`/`endConferenceOnExit`/`maxParticipants` have no diff --git a/src/matrix/twilio-voice.json b/src/matrix/twilio-voice.json index 27c3192..806911e 100644 --- a/src/matrix/twilio-voice.json +++ b/src/matrix/twilio-voice.json @@ -147,7 +147,7 @@ "Stream": { "bxml": "StartStream", "status": "partial", - "notes": "Twilio WS message schema is emulated by the translator's stream bridge; live Bandwidth-side binding requires fixture capture.", + "notes": "Twilio WS message schema is emulated by the translator's stream bridge; live Bandwidth-side binding requires fixture capture. children become nested elements (Bandwidth allows at most 12) and reach the bot as customParameters.", "docsUrl": "https://dev.bandwidth.com/docs/voice/bxml/startStream", "attributes": {} }, diff --git a/src/streams/bridge.ts b/src/streams/bridge.ts index 4b9f1c3..79eae06 100644 --- a/src/streams/bridge.ts +++ b/src/streams/bridge.ts @@ -20,11 +20,35 @@ export interface BridgeOpts { botUrl: string; callSid: string; accountSid: string; - /** Optional key/value pairs forwarded verbatim in the TwiML start message. */ + /** Key/value pairs forwarded verbatim as `customParameters` in the Twilio + * "start" message. These are the TwiML values, which the + * translator emits as and Bandwidth echoes back in its own + * "start" event as `streamParams`; build them with customParametersFromBwStart. */ customParameters?: Record; source: BwStreamSource; } +/** + * Map Bandwidth's StartStream WebSocket "start" event to Twilio `customParameters`. + * + * Bandwidth copies every under the into + * the start event as `streamParams: { name: value, ... }` (a flat map, per the + * StartStream docs). Twilio delivers the same data as `start.customParameters`, + * also a flat string map, so the mapping is a copy with values coerced to + * strings. Anything that is not a plain object yields an empty map; a bot + * always receives a `customParameters` object, never undefined. + */ +export function customParametersFromBwStart(event: unknown): Record { + const params = (event as { streamParams?: unknown } | null)?.streamParams; + if (params === null || typeof params !== "object" || Array.isArray(params)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(params as Record)) { + if (v === undefined || v === null) continue; + out[k] = typeof v === "string" ? v : String(v); + } + return out; +} + export class TwilioStreamBridge { readonly streamSid: string; private ws: WebSocket; diff --git a/src/translator/translate.ts b/src/translator/translate.ts index c551552..e930848 100644 --- a/src/translator/translate.ts +++ b/src/translator/translate.ts @@ -543,8 +543,53 @@ const TWILIO_STREAM_TRACK_TO_BW: Record = { both_tracks: "both", }; +// Bandwidth's documented ceiling on children per . +// Twilio sets no count limit on , so anything past this is dropped +// with a warning rather than emitting BXML Bandwidth would reject outright. +const MAX_STREAM_PARAMS = 12; + +/** Twilio children → BW . + * Bandwidth copies these into the WebSocket "start" event as a `streamParams` + * map, which the stream bridge forwards to the bot as Twilio `customParameters` + * (see customParametersFromBwStart in streams/bridge.ts). Order is preserved. + * Twilio caps name+value at 500 chars combined, so Bandwidth's per-attribute + * limits (256 / 2048) cannot be exceeded by valid TwiML and are not re-checked. */ +function streamParams(stream: TwimlNode, findings: Finding[]): XmlEl[] { + const out: XmlEl[] = []; + let dropped = 0; + for (const child of stream.children) { + if (child.name !== "Parameter") { + warn("Stream", `Stream child <${child.name}> has no Bandwidth equivalent and was dropped.`, findings); + continue; + } + const { name, value } = child.attrs; + if (name === undefined || value === undefined) { + warn( + "Stream", + `Stream requires both name and value; dropped .`, + findings, + ); + continue; + } + if (out.length >= MAX_STREAM_PARAMS) { + dropped++; + continue; + } + out.push({ name: "StreamParam", attrs: { name, value } }); + } + if (dropped > 0) + warn( + "Stream", + `Bandwidth allows at most ${MAX_STREAM_PARAMS} StreamParam per StartStream; ` + + `${dropped} Stream element(s) beyond that were dropped.`, + findings, + ); + return out; +} + /** Twilio noun → BW . mode is bidirectional under - * (audio flows both ways) and unidirectional under (a fork). */ + * (audio flows both ways) and unidirectional under (a fork). + * children become nested elements. */ function streamToStartStream( stream: TwimlNode, mode: "bidirectional" | "unidirectional", @@ -559,7 +604,8 @@ function streamToStartStream( mode, tracks: stream.attrs.track ? TWILIO_STREAM_TRACK_TO_BW[stream.attrs.track] ?? "inbound" : "inbound", }; - return [{ name: "StartStream", attrs }]; + const params = streamParams(stream, findings); + return [params.length ? { name: "StartStream", attrs, children: params } : { name: "StartStream", attrs }]; } // Per-document counter for generated Connect/Stream names. Twilio's diff --git a/test/streams-wire.test.ts b/test/streams-wire.test.ts index 1f0b760..a7d0daf 100644 --- a/test/streams-wire.test.ts +++ b/test/streams-wire.test.ts @@ -10,7 +10,11 @@ import { describe, it, expect } from "vitest"; import { WebSocketServer, WebSocket } from "ws"; import { EventEmitter } from "node:events"; -import { TwilioStreamBridge, type BwStreamSource } from "../src/streams/bridge.js"; +import { + TwilioStreamBridge, + customParametersFromBwStart, + type BwStreamSource, +} from "../src/streams/bridge.js"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -107,6 +111,44 @@ describe("start message", () => { }); }); + // VAPI-3989: Bandwidth echoes values in its "start" event as a + // flat `streamParams` map; the bot must see them as Twilio customParameters. + it("forwards Bandwidth streamParams to the bot as customParameters", async () => { + // Shape per the StartStream docs' start-event example. + const bwStart = { + eventType: "start", + metadata: { accountId: "9900778", callId: "c-abc", to: "+15550001111", from: "+15550002222" }, + streamParams: { callSid: "CA123", tenant: "acme" }, + }; + const port = nextPort(); + const { messages, close } = await botServer(port); + const source = new FakeBwSource(); + const bridge = new TwilioStreamBridge({ + botUrl: `ws://127.0.0.1:${port}`, + callSid: "CA123", + accountSid: "AC222", + customParameters: customParametersFromBwStart(bwStart), + source, + }); + await bridge.ready(); + await waitFor(() => messages.length >= 2); + bridge.close(); + close(); + + const startMsg = messages.find((m: any) => m.event === "start") as any; + expect(startMsg.start.customParameters).toEqual({ callSid: "CA123", tenant: "acme" }); + }); + + it("customParametersFromBwStart tolerates missing or malformed streamParams", () => { + expect(customParametersFromBwStart({ eventType: "start" })).toEqual({}); + expect(customParametersFromBwStart({ streamParams: null })).toEqual({}); + expect(customParametersFromBwStart({ streamParams: [1, 2] })).toEqual({}); + expect(customParametersFromBwStart(undefined)).toEqual({}); + expect(customParametersFromBwStart("start")).toEqual({}); + // Values are always strings on the Twilio side, even if Bandwidth ever sent a number. + expect(customParametersFromBwStart({ streamParams: { n: 42, s: "x", nil: null } })).toEqual({ n: "42", s: "x" }); + }); + it("customParameters defaults to empty object when omitted", async () => { const port = nextPort(); const { messages, close } = await botServer(port); diff --git a/test/translate-stream-conference.test.ts b/test/translate-stream-conference.test.ts index a342113..e8372e9 100644 --- a/test/translate-stream-conference.test.ts +++ b/test/translate-stream-conference.test.ts @@ -53,6 +53,86 @@ describe("Stream lifecycle", () => { expect(r.bxml).not.toMatch(/]*\/><\/Response>/); }); + // VAPI-3989: children used to be dropped silently, so bots got an + // empty customParameters map and could not identify the call or tenant. + describe("Stream → StreamParam (VAPI-3989)", () => { + it("emits one nested StreamParam per Parameter, in order, inside StartStream", () => { + const r = translateTwiml( + ` + + + `, + { rewriteUrl: rw }, + ); + expect(r.hasErrors).toBe(false); + expect(r.bxml).toMatch( + /]*name="agent"[^>]*><\/StartStream>/, + ); + // No drop warnings when every Parameter is valid and within the limit. + expect(r.findings.some((f) => /dropped/.test(f.message))).toBe(false); + }); + + it("output differs from the same Stream without Parameters", () => { + const a = translateTwiml(``); + const b = translateTwiml( + ``, + ); + expect(b.bxml).not.toBe(a.bxml); + expect(a.bxml).not.toContain("StreamParam"); + }); + + it("also applies to Start>Stream forks", () => { + const r = translateTwiml( + ``, + ); + expect(r.hasErrors).toBe(false); + expect(r.bxml).toContain(``); + expect(r.bxml).not.toContain(" { + const r = translateTwiml( + ``, + ); + expect(r.bxml).toContain(``); + }); + + it("keeps the first 12 Parameters and warns about the rest (Bandwidth limit)", () => { + const params = Array.from({ length: 14 }, (_, i) => ``).join(""); + const r = translateTwiml( + `${params}`, + ); + expect(r.hasErrors).toBe(false); + expect(r.bxml.match(/ f.verb === "Stream" && /at most 12/.test(f.message) && /2 /.test(f.message))).toBe(true); + }); + + it("drops a Parameter missing name or value with a warning instead of emitting invalid BXML", () => { + const r = translateTwiml( + ` + + + + `, + ); + expect(r.hasErrors).toBe(false); + expect(r.bxml.match(/`); + expect(r.findings.filter((f) => f.verb === "Stream" && /requires both name and value/.test(f.message))).toHaveLength(2); + }); + + it("warns about non-Parameter children of Stream", () => { + const r = translateTwiml( + ``, + ); + expect(r.hasErrors).toBe(false); + expect(r.bxml).not.toContain("Bogus"); + expect(r.findings.some((f) => f.verb === "Stream" && //.test(f.message))).toBe(true); + }); + }); + it("Start>Stream → StartStream mode=unidirectional (fork)", () => { const r = translateTwiml( ``,