Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Connect>` 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
Expand Down
2 changes: 1 addition & 1 deletion src/matrix/twilio-voice.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}
},
Expand Down
32 changes: 31 additions & 1 deletion src/translator/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -561,6 +562,21 @@ function streamToStartStream(
return [{ name: "StartStream", attrs }];
}

// Per-document counter for generated Connect/Stream names. Twilio's <Stream name>
// is optional, but Bandwidth's <StopStream> needs a name that matches the
// <StartStream> it stops. Reset at the start of each translateTwiml call
// (same module-state caveat as bxmlByteBudget).
let connectStreamSeq = 0;

/** Twilio <Connect><Stream> → BW <StartStream mode="bidirectional"/> followed by
* <StopStream wait="true"/>.
*
* Bandwidth ends the call as soon as BXML execution runs out of verbs, so a bare
* <StartStream> 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 <Connect> semantics, where
* any verbs after <Connect> run only once the stream has ended. */
function translateConnect(
node: TwimlNode,
findings: Finding[],
Expand All @@ -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 <StopStream wait="true"> 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 <Connect> run after the stream ends, matching " +
"Twilio's blocking semantics.",
findings,
);
return [...els, { name: "StopStream", attrs: { name, wait: "true" } }];
}

// Twilio <Start> with <Transcription> noun → BW <StartTranscription>.
Expand Down
6 changes: 4 additions & 2 deletions test/translate-dial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ describe("Connect/Stream", () => {
k === "stream" ? `wss://translator.test/streams?dest=${encodeURIComponent(u)}` : u,
},
);
expect(r.bxml).toContain(
`<StartStream destination="wss://translator.test/streams?dest=wss%3A%2F%2Fbot.test%2Faudio"`,
// Attribute order is not significant; a generated name now precedes destination (VAPI-3985).
expect(r.bxml).toMatch(
/<StartStream [^>]*destination="wss:\/\/translator\.test\/streams\?dest=wss%3A%2F%2Fbot\.test%2Faudio"/,
);
expect(r.bxml).toContain(`<StopStream name="connect-stream-1" wait="true"/>`);
expect(r.findings.some((f) => f.severity === "warning" && f.verb === "Stream")).toBe(true);
});
});
40 changes: 39 additions & 1 deletion test/translate-stream-conference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<Response><Connect><Stream name="agent" url="wss://bot.test/ws" track="both_tracks"/></Connect></Response>`,
{ rewriteUrl: rw },
Expand All @@ -15,6 +15,42 @@ describe("Stream lifecycle", () => {
expect(r.bxml).toContain(`mode="bidirectional"`);
expect(r.bxml).toContain(`tracks="both"`);
expect(r.bxml).toContain(`<StartStream`);
// VAPI-3985: a bare StartStream ends the call on answer. The StopStream must
// follow it, carry the same name, and block until the bot closes the socket.
expect(r.bxml).toMatch(/<StartStream [^>]*name="agent"[^>]*\/><StopStream name="agent" wait="true"\/>/);
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(
`<Response><Connect><Stream url="wss://bot.test/ws"/></Connect></Response>`,
{ rewriteUrl: rw },
);
expect(r.hasErrors).toBe(false);
const m = r.bxml.match(/<StartStream [^>]*name="([^"]+)"[^>]*\/><StopStream name="([^"]+)" wait="true"\/>/);
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(`<Response><Connect><Stream url="wss://a.test/ws"/></Connect></Response>`);
const r = translateTwiml(`<Response><Connect><Stream url="wss://b.test/ws"/></Connect></Response>`);
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(
`<Response><Connect><Stream name="agent" url="wss://bot.test/ws"/></Connect><Say>Goodbye</Say></Response>`,
{ rewriteUrl: rw },
);
expect(r.hasErrors).toBe(false);
expect(r.bxml).toMatch(/<StartStream [^>]*\/><StopStream name="agent" wait="true"\/><SpeakSentence>Goodbye<\/SpeakSentence>/);
});

it("StartStream is never the last verb for Connect>Stream", () => {
const r = translateTwiml(`<Response><Connect><Stream url="wss://bot.test/ws"/></Connect></Response>`);
expect(r.bxml).not.toMatch(/<StartStream [^>]*\/><\/Response>/);
});

it("Start>Stream → StartStream mode=unidirectional (fork)", () => {
Expand All @@ -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("<StopStream");
});

it("Stop>Stream → StopStream by name", () => {
Expand Down
Loading