From 1a7afc242da4afd7c51a67756a6f7236acc3934b Mon Sep 17 00:00:00 2001 From: Madhu Ramasubramanian Date: Fri, 18 Sep 2026 11:20:49 -0400 Subject: [PATCH] VAPI-3985: hold the call open after Connect/Stream with StopStream wait="true" Twilio translated to a bare with nothing after it. Bandwidth ends a call when BXML execution runs out of verbs, so every translated AI-voice flow hung up on answer (30 ms after stream start on a real call), and the translator reported hasErrors: false. Per the StartStream docs (Bidirectional streams), emit immediately after the StartStream. BXML execution blocks there until the bot closes the WebSocket, which also restores Twilio's semantics: verbs after now run once the stream ends instead of immediately over the bot's first words. - Generate a per-document stream name (connect-stream-N) when the TwiML has none, since StopStream must name the stream it stops. - Emit a Connect warning finding explaining the inserted verb. - Start/Stream (unidirectional fork) is unchanged. - Update the Connect matrix note and AGENTS.md; update tests that pinned the bare output and add coverage for the new shape, generated names, and trailing-verb ordering. Verified on real calls 2026-09-17 (account 9900778): the identical StartStream followed by StopStream wait="true" held the call with audio both ways until hangup; bare StartStream dropped in 0.03 s. --- AGENTS.md | 10 ++++-- src/matrix/twilio-voice.json | 2 +- src/translator/translate.ts | 32 ++++++++++++++++++- test/translate-dial.test.ts | 6 ++-- test/translate-stream-conference.test.ts | 40 +++++++++++++++++++++++- 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2d7448b..0a47aff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,9 +105,13 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess. `Siprec` and `VirtualAgent` nouns are unsupported. - `Refer` — Bandwidth only honors `Refer` on inbound SIP URI calls, so a PSTN call leg cannot be REFER'd (a platform constraint, not a translation gap). - - `Connect` — the `Stream` noun maps to `StartStream` via the Media Streams - bridge; `ConversationRelay` and `VirtualAgent` are unsupported (separate - IoV). + - `Connect` — the `Stream` noun maps to `StartStream` followed by + `StopStream wait="true"`, which holds the call open until the bot closes + the WebSocket (Bandwidth ends a call when BXML runs out of verbs, so a bare + `StartStream` hangs up on answer). Verbs after `` therefore run + after the stream ends, as on Twilio. A stream name is generated when the + 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. - `Conference` — basic named conferences work, but `waitUrl` hold music has diff --git a/src/matrix/twilio-voice.json b/src/matrix/twilio-voice.json index f3809c9..27c3192 100644 --- a/src/matrix/twilio-voice.json +++ b/src/matrix/twilio-voice.json @@ -140,7 +140,7 @@ "Connect": { "bxml": "StartStream", "status": "partial", - "notes": "Stream noun maps to StartStream via the Media Streams bridge; ConversationRelay and VirtualAgent are unsupported (separate IoV).", + "notes": "Stream noun maps to StartStream followed by StopStream wait=\"true\", which holds the call open until the bot closes the WebSocket (a bare StartStream ends the call on answer). ConversationRelay and VirtualAgent are unsupported (separate IoV).", "docsUrl": "https://dev.bandwidth.com/docs/voice/bxml/startStream", "attributes": {} }, diff --git a/src/translator/translate.ts b/src/translator/translate.ts index 9849484..c551552 100644 --- a/src/translator/translate.ts +++ b/src/translator/translate.ts @@ -196,6 +196,7 @@ function stampCallbackAuth(els: XmlEl[], auth: { username: string; password: str export function translateTwiml(twiml: string, opts: TranslateOptions = {}): TranslateResult { bxmlByteBudget = MAX_TOTAL_BXML_BYTES; + connectStreamSeq = 0; const root = parseTwiml(twiml); const findings: Finding[] = []; const rewrite = opts.rewriteUrl ?? ((u: string) => u); @@ -561,6 +562,21 @@ function streamToStartStream( return [{ name: "StartStream", attrs }]; } +// Per-document counter for generated Connect/Stream names. Twilio's +// is optional, but Bandwidth's needs a name that matches the +// it stops. Reset at the start of each translateTwiml call +// (same module-state caveat as bxmlByteBudget). +let connectStreamSeq = 0; + +/** Twilio → BW followed by + * . + * + * Bandwidth ends the call as soon as BXML execution runs out of verbs, so a bare + * hangs up on answer (verified on real calls, VAPI-3985). The + * StartStream docs' recommended fix for bidirectional streams is a StopStream + * with wait="true" right after it: BXML execution blocks there until the bot + * closes the WebSocket. That also reproduces Twilio's semantics, where + * any verbs after run only once the stream has ended. */ function translateConnect( node: TwimlNode, findings: Finding[], @@ -573,7 +589,21 @@ function translateConnect( findings, `Connect noun <${node.children[0]?.name ?? "?"}> is not supported (ConversationRelay/VirtualAgent are out of translator scope).`, ); - return streamToStartStream(stream, "bidirectional", findings, rewrite); + + const name = stream.attrs.name ?? `connect-stream-${++connectStreamSeq}`; + const named: TwimlNode = { ...stream, attrs: { ...stream.attrs, name } }; + const els = streamToStartStream(named, "bidirectional", findings, rewrite); + if (!els) return null; + + warn( + "Connect", + 'Inserted after StartStream. Bandwidth ends the call when BXML runs out ' + + "of verbs, so a bare StartStream hangs up on answer. With it, the call stays up until the bot " + + "closes the WebSocket, and any verbs after run after the stream ends, matching " + + "Twilio's blocking semantics.", + findings, + ); + return [...els, { name: "StopStream", attrs: { name, wait: "true" } }]; } // Twilio with noun → BW . diff --git a/test/translate-dial.test.ts b/test/translate-dial.test.ts index 1dd6fac..2cbbadf 100644 --- a/test/translate-dial.test.ts +++ b/test/translate-dial.test.ts @@ -61,9 +61,11 @@ describe("Connect/Stream", () => { k === "stream" ? `wss://translator.test/streams?dest=${encodeURIComponent(u)}` : u, }, ); - expect(r.bxml).toContain( - `]*destination="wss:\/\/translator\.test\/streams\?dest=wss%3A%2F%2Fbot\.test%2Faudio"/, ); + expect(r.bxml).toContain(``); expect(r.findings.some((f) => f.severity === "warning" && f.verb === "Stream")).toBe(true); }); }); diff --git a/test/translate-stream-conference.test.ts b/test/translate-stream-conference.test.ts index 586dfbf..a342113 100644 --- a/test/translate-stream-conference.test.ts +++ b/test/translate-stream-conference.test.ts @@ -5,7 +5,7 @@ const rw = (u: string) => u.startsWith("wss") ? `wss://translator.test/streams?dest=${encodeURIComponent(u)}` : u; describe("Stream lifecycle", () => { - it("Connect>Stream → StartStream mode=bidirectional with name and tracks", () => { + it("Connect>Stream → StartStream mode=bidirectional with name and tracks, held by StopStream wait", () => { const r = translateTwiml( ``, { rewriteUrl: rw }, @@ -15,6 +15,42 @@ describe("Stream lifecycle", () => { expect(r.bxml).toContain(`mode="bidirectional"`); expect(r.bxml).toContain(`tracks="both"`); expect(r.bxml).toContain(`]*name="agent"[^>]*\/>/); + expect(r.findings.some((f) => f.verb === "Connect" && /StopStream/.test(f.message))).toBe(true); + }); + + it("Connect>Stream without a name gets a generated name shared by StartStream and StopStream", () => { + const r = translateTwiml( + ``, + { rewriteUrl: rw }, + ); + expect(r.hasErrors).toBe(false); + const m = r.bxml.match(/]*name="([^"]+)"[^>]*\/>/); + expect(m).not.toBeNull(); + expect(m![1]).toBe(m![2]); + expect(m![1]).toBe("connect-stream-1"); + }); + + it("generated Connect stream names restart at 1 for each document", () => { + translateTwiml(``); + const r = translateTwiml(``); + expect(r.bxml).toContain(`name="connect-stream-1"`); + }); + + it("verbs after Connect are emitted after the StopStream, so they run once the stream ends", () => { + const r = translateTwiml( + `Goodbye`, + { rewriteUrl: rw }, + ); + expect(r.hasErrors).toBe(false); + expect(r.bxml).toMatch(/]*\/>Goodbye<\/SpeakSentence>/); + }); + + it("StartStream is never the last verb for Connect>Stream", () => { + const r = translateTwiml(``); + expect(r.bxml).not.toMatch(/]*\/><\/Response>/); }); it("Start>Stream → StartStream mode=unidirectional (fork)", () => { @@ -25,6 +61,8 @@ describe("Stream lifecycle", () => { expect(r.hasErrors).toBe(false); expect(r.bxml).toContain(`mode="unidirectional"`); expect(r.bxml).toContain(`name="fork1"`); + // A fork does not need the call held; no StopStream is inserted (VAPI-3985 is Connect-only). + expect(r.bxml).not.toContain("Stream → StopStream by name", () => {