Repository navigation
VAPI-4042: emit Twilio's Gather/Record defaults and clamp to BW's ranges - #32
Open
mramasubramanian-bw wants to merge 5 commits into
Open
mramasubramanian-bw wants to merge 5 commits into
mramasubramanian-bw wants to merge 5 commits into
Conversation
Twilio and Bandwidth default differently for several Gather/Record attributes; leaving them unset let Bandwidth's own default silently change IVR behavior instead of matching Twilio's. Gather: - finishOnKey omitted -> emit "#" (Twilio's default); Bandwidth's own default is "" (disabled). finishOnKey="" still passes through. - timeout omitted -> default "5", set as both firstDigitTimeout and interDigitTimeout (Bandwidth splits what Twilio combines). - numDigits/timeout clamped to Bandwidth's documented ranges with a warning naming the original value. - A Gather with no action re-requests the current document, matching Twilio, when the server provides the document's URL. Record: - maxLength omitted -> emit "3600" (Twilio's default); Bandwidth's own default is 60s. - timeout (silence ends the recording) is now mapped at all, to silenceTimeout; previously dropped entirely. timeout="0" (Twilio: disable) and silenceTimeout="0" (Bandwidth: no timeout) agree. - finishOnKey narrowed to Bandwidth's single terminating digit (preferring #, then *, then the first character), with a warning, since Bandwidth can't represent Twilio's multi-key set (default: any key). A single explicit digit passes through unchanged. - playBeep/trim now warn on the omitted (Twilio-default) case too, not only when explicitly set to a non-matching value. - A Record with no action re-requests the current document, matching Twilio, when the server provides the document's URL. The "re-request current document" behavior is driven by a new TranslateOptions.documentUrl, set only by the server (one line in app.ts); standalone BXML generation leaves it unset and keeps the original warning, since there's no live document for it to re-fetch.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
The warning a customer sees when a value is outside Bandwidth's range
said "clamped to X" instead of plainly saying what happened. It now
says "using X instead". The matrix notes, AGENTS.md, and the new test
names follow the same change ("kept within Bandwidth's limits" /
"limits X to Bandwidth's maximum" rather than "clamped").
The clampAttr function name and its internal comments are unchanged;
"clamp" was already an internal term in this file (the Say/Play loop
count logic), so this only touches text a person actually reads.
- "" on timeout/maxLength was read as 0 (Number("") === 0), not as
missing: <Record maxLength=""/> cut voicemail to 1 second, and
<Gather timeout=""/> sent an empty firstDigitTimeout. Treated as
missing now (clampAttr's callers use || instead of ?? for these).
- A value that isn't a plain finite number ("abc", "Infinity") now
warns and falls back to Twilio's default, instead of being copied
into the BXML as-is for Bandwidth to reject. A value that IS a
number but an unusual form ("0x3C", "1e1") is normalized to a plain
decimal instead of being emitted as written.
- Record's finishOnKey narrowing now only picks a real keypad
character: "ab1" narrows to "1", not "a", and a value with no digit,
"*", or "#" at all (e.g. "a") is dropped instead of passed through,
so Bandwidth's own default applies.
- Range warnings now name the Twilio attribute the customer actually
wrote (timeout, maxLength) rather than only Bandwidth's name for it
(firstDigitTimeout, maxDuration), while still saying which BXML
attribute is affected.
- Identical findings (verb + message) collapse to one per document, so
a document with many ordinary bare Records doesn't bury a real
out-of-range warning under repeats of the same generic one.
- Corrected AGENTS.md: Bandwidth already defaults firstDigitTimeout and
interDigitTimeout to 5s on its own, so an omitted Gather timeout
isn't a behavior change; the real fix is that an explicit timeout
previously only reached firstDigitTimeout. Also listed maxLength
among the range-limited attributes and stopped calling finishOnKey's
narrowing a range limit.
- Pulled the duplicated "no action -> re-request the current document,
else warn" branching out of translateGather/translateRecord into one
resolveActionUrl helper.
…findings
- Most serious: a Gather with no action that reaches a server instance
with no cached copy of its document (restart, a different replica)
now ends the call instead of re-fetching and replaying the exact
same prompt. Before this fix, the same conditions would recur every
time, looping forever. A new RewriteContext.selfReference flag
(carried as &self=1 on the URL) lets /bw/continue tell "this points
at a real customer action" apart from "this points at myself".
- A whitespace-only value (e.g. " ") hit the same Number("")===0 bug
as an empty one; both are now treated as not set.
- Gather's finishOnKey had no validation at all: "ab" (no real keypad
key) went out unchanged. It's now filtered to the characters
Bandwidth recognizes (0-9, *, #), same as Record already was.
- An invalid Record finishOnKey (no real key at all) now disables the
stop key ("") instead of falling back to Bandwidth's own default
("#"), which would have given a customer's TwiML a stop key they
never asked for.
- translate.ts's dedupeFindings and compatibility-check/analyze.ts's
own separate dedup logic disagreed on what counts as a duplicate
(one included severity, the other didn't). Now there's one shared,
exported dedupeFindings both use.
- Checked numDigits="2.5" against `band bxml raw` directly: Bandwidth
accepts decimal values for these attributes (its own docs say so),
so this is not a bug.
- Re-confirmed against Bandwidth's live docs that firstDigitTimeout
and interDigitTimeout both already default to 5s on Bandwidth's
side, same as previously corrected.
- Left the module-level translation state (now six variables reset per
call, including the new one from this PR) as a follow-up: threading
a context object through every verb-translation function instead
is a much larger, separate change from this PR's scope.
- A very small value (e.g. timeout="0.0000001") came out as firstDigitTimeout="1e-7" -- String(n) switches to scientific notation below 1e-6, contradicting this code's own claim that it never would. Fixed with a dedicated plain-decimal formatter. - numDigits is now rounded to a whole number with a warning (numDigits="2.5" -> "3"): there's no such thing as collecting half a digit. timeout/maxLength keep a fractional value as written -- a fractional duration is a real concept, and Bandwidth's own docs allow decimal values there (confirmed: band bxml raw accepts them). - A non-numeric Gather timeout (e.g. "abc") produced two near-identical warnings, one per BXML attribute it maps to. Numeric validation now happens once per Twilio attribute, range-checking happens separately, so it warns once. - Gather's finishOnKey="##" warned about "narrowing" even though "#" is kept exactly -- nothing was actually lost. Now only warns when there's a genuine choice between distinct keys or an invalid character is being dropped. - A whitespace-only finishOnKey (e.g. " ") turned the stop key off, unlike every other blank attribute, which falls back to Twilio's default. Now treated the same way as omitted on both verbs. - matrix.json and AGENTS.md still described Record's invalid-finishOnKey case as "dropped, falling back to Bandwidth's default (#)" -- stale from before the previous round changed that to disabling the stop key instead. Corrected, and extended both to cover the Gather character-filtering and single-warning behavior these docs were missing entirely. - Documented two things explicitly rather than silently fixing them a fourth time: a bare Gather's no-document-to-resume fallback ends the call rather than loop, which is a real behavior gap from Twilio on a multi-replica deployment with no session affinity -- flagged in AGENTS.md as worth a shared document cache if it matters for a given deployment. And the module-level translation state, now flagged three times across three reviews, is left as a deliberate follow-up in a code comment explaining why: the real fix (a context object threaded through every verb function) is a larger, separate change than felt safe to rush right before merge.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
<Gather>/<Record>now use Twilio's own default values instead of letting Bandwidth's different defaults silently change IVR behavior:finishOnKey,timeout→firstDigitTimeout/interDigitTimeout, Record'smaxLength, and Record'stimeout→silenceTimeout(this wasn't translated to anything before).numDigits/timeout/maxLengthare now kept within the limits Bandwidth documents, with a warning, instead of letting an out-of-range value through for Bandwidth to reject. A value that isn't a plain number at all (empty or all spaces,"abc") now warns and falls back to Twilio's own default, instead of being copied into the BXML as written. A number is always written out in plain decimal form, never scientific notation or a hex/scientific-notation string.numDigitsis additionally rounded to a whole number, since there's no such thing as collecting half a digit —timeout/maxLengthkeep a fractional value as written, since a fractional duration is a real, normal concept.finishOnKeyis now checked on both verbs. Gather keeps only the characters Bandwidth actually recognizes as a key (a digit,*, or#) and drops anything else, so a value like"ab"can't silently turn the stop-key feature off. Record is cut down to the one real keypad key it can use to stop — preferring#, then*, then the first valid one present — since Bandwidth can't use a whole set of keys the way Twilio does; a value with no valid key at all turns the stop-key feature off, rather than quietly adopting Bandwidth's own default key. A blank (whitespace-only)finishOnKeyis treated the same as leaving it out, not as a value to filter or disable.<Gather>/<Record>with noactionnow sends its result back to the same page that served it, matching Twilio, when the server knows that page's address. If that page can't be reached because a different server has no record of it, the call now ends instead of replaying the same prompt — see the Reviewer Notes for a real limitation of this fallback.playBeep/trimnow warn when they're left out too (Twilio's default plays a beep and trims silence), not only when someone sets them to a different value on purpose.Motivation / Context
translateGather/translateRecordonly copied an attribute when the TwiML set it. Where Twilio's default is different from Bandwidth's, the translated verb behaved differently with no warning: a default<Record>cut voicemail off at 60 seconds with no way to stop on silence, a default<Gather>counted#as just another digit and could cut an entry short mid-way, and a value outside Bandwidth's limits made Bandwidth reject the whole call. Tracked in VAPI-4042.Reviewer Notes
<Gather>with noactionpoints itsgatherUrlback at the page that served it. If the caller presses nothing and that page is reached on a server that has no cached copy of the document (a restart, or — in a multi-replica deployment with no session affinity routing a call's requests back to the same server — a different replica), there is no way to know what verb should run next, so the call now ends instead of looping (a second round of review caught that it used to loop forever by re-fetching and replaying that same page). Ending the call is still a real behavior difference from Twilio, which would simply continue. Fixing this properly needs a document cache shared across replicas instead of each process's own memory, which is a larger change than this PR; flagged inAGENTS.mdfor anyone whose deployment this affects.TranslateOptions.documentUrl, set only by the server (one line inapp.ts, next to the existingrewriteUrl). The standalone BXML generator (src/bxml-generator) leaves it unset and keeps the original warning, since it has no live server to send a request back to — doing this there would just produce a useless emptygatherUrl=""in a static file.timeoutsets bothfirstDigitTimeoutandinterDigitTimeout, each kept within its own limits (0-60 and 1-60). One Twilio value can land differently for each — e.g.timeout="0"leavesfirstDigitTimeout="0"alone but raisesinterDigitTimeoutto1— so these can be two separate warnings when the value is out of range. A value that isn't a number at all (e.g."abc") is checked once, not once per BXML attribute, so that case produces a single warning, not two near-identical ones.finishOnKey: Bandwidth's docs say "this digit" (singular), so a Twilio value with more than one distinct valid character (including the implicit default, any of1234567890*#) can only be reduced to one character — and only to a character Bandwidth actually recognizes as a key. A single such character someone set on purpose passes through with no warning, since that's an exact match; a value made of nothing but that one character repeated (e.g."##") is also left alone with no warning, since nothing is actually being narrowed away. A value with none of those characters at all (e.g. a letter) can't be reduced to anything; since that value would never match a real key press on Twilio either, the stop-key feature is turned off rather than quietly adopting Bandwidth's own default key, which would give the customer a way to stop the recording they never wrote into their TwiML.playBeep/trimwarn whenever Twilio's own behavior (play a beep / trim silence) wouldn't happen on Bandwidth — including when they're left out, since Twilio's defaults for both are "yes, do it" (playBeepdefaults totrue,trimdefaults totrim-silence). The one setting that doesn't warn for each (playBeep="false",trim="do-not-trim") is the one case where Bandwidth's fixed behavior (never beeps, never trims) actually matches.Record timeout="0"(Twilio: turn off silence detection) and Bandwidth's ownsilenceTimeout="0"(no timeout) already mean the same thing, so that value needed no special handling.timeout/maxLength/numDigits/finishOnKeyset to an empty or all-spaces value are all treated the same as leaving the attribute out — not as the number zero (Number(" ")is0in JavaScript), and not, forfinishOnKey, as a value to filter down to nothing — since a blank value isn't something a customer would set on purpose, there's no warning either, just the same default used for the missing case. An exactly emptyfinishOnKey=""is handled differently from a blank (whitespace) one, since both Twilio and Bandwidth document that specific value as "turn the stop-key feature off" on purpose.timeout,maxLength), not only Bandwidth's name for it (firstDigitTimeout,maxDuration), while still saying which Bandwidth attribute is affected — so the warning can be found by searching the customer's own TwiML.numDigits="2.5"directly againstband bxml raw(Bandwidth's own BXML validator): it's accepted, for both Gather's and Record's attributes. Bandwidth's own docs list Gather's ranges as "decimal values" (silent on Record's), so a fractional duration isn't a bug —timeout/maxLengthpass a fraction through as a plain decimal.numDigitsis the one exception: a fractional digit count has no real meaning regardless of whether Bandwidth's parser happens to accept the string, so that one is rounded.<Record>s previously repeated the same 3 generic warnings for every one of them, which could bury a real out-of-range warning under dozens of repeats of an unrelated one. The compatibility-check tool had its own separate copy of this same logic (used when one file has several TwiML documents embedded in it), which disagreed slightly on what counts as a duplicate; both now share one implementation.translateGatherandtranslateRecordis now one shared helper.documentUrlfrom this PR) as module-level variables reset at the start of each translation, rather than in one object passed through every verb-translating function. The real fix is a genuine improvement, but it touches the signature of every verb-translation function and its recursive call sites — a larger, separate refactor than felt safe to do right before merging a PR that's already been through three rounds of correctness fixes. Left as a code comment at the declaration site so it isn't re-discovered a fourth time.Breaking Changes
<Gather>(nofinishOnKey) now stops on#, matching Twilio; before,#was just another digit (Bandwidth's own default turns off the stop-key feature). Any flow that expected#to be collected along with the other digits will now stop there instead.<Record>(notimeout) now stops after 5 seconds of silence (silenceTimeout), which had no equivalent at all before — a long pause mid-recording can now end it sooner than it used to.<Gather>/<Record>with noactionattribute now sends a real request back to the customer's current page (carryingDigits/RecordingUrl, etc.), instead of Bandwidth quietly dropping the result (Gather) or moving on to the next verb once the recording ends (Record). This matches Twilio, but it's a request shape that page may not have expected before. In a multi-replica deployment with no session affinity, a silent caller on a bare<Gather>can have the call end instead of continuing — see the Reviewer Notes.numDigits/timeout/maxLengthoutside Bandwidth's documented range, or not a plain number at all, are now brought back into range (with a warning) instead of passed through as written, which used to risk Bandwidth rejecting the call outright.<Gather>with notimeoutset does not change behavior: Bandwidth already defaultsfirstDigitTimeout/interDigitTimeoutto 5 seconds on its own, matching Twilio (re-confirmed directly against Bandwidth's live docs). What does change: an explicittimeoutpreviously only reachedfirstDigitTimeout, silently leavinginterDigitTimeoutat Bandwidth's 5-second default regardless of what was set — now it governs both, matching Twilio's singletimeoutvalue doing both jobs. (An earlier version of this PR description got this backwards; a reviewer caught it.)Tests
Added
Gather > Twilio defaults› emits finishOnKey's Twilio default (#) when omittedterminatingDigits="#"whenfinishOnKeyisn't setfinishOnKey="ab"doesn't go out unchangedfinishOnKey="1a#"becomes"1#"timeoutstill sets both attributesGather > keeping values within Bandwidth's limits› brings an out-of-range timeout back within limits for both firstDigitTimeout and interDigitTimeout, with a warning for eachtimeout="0"only raises one of the two attributesnumDigitsis brought into range with a warningNumber("")is0; an empty value must not be read as a real zero" "numDigits=" "timeout="0x3C"becomes"60", not left as the hex stringnumDigits="1e1"becomes"10"numDigits="abc"becomes"50"with a warning, not passed through as"abc"timeout="0.0000001"doesn't come out as"1e-7"numDigits="2.5"becomes"3", since you can't collect half a digittimeout="abc"produces exactly one warning, not twoGather > no action, re-requesting the current document› points gatherUrl at the current document when the server provides one, with no warningdocumentUrlset →gatherUrlpoints back at the same page; the Gather's position, empty-result flag, and self-reference marker all travel throughRecord > Twilio defaults› emits maxLength's Twilio default (3600) when omittedmaxDuration="3600", not Bandwidth's 60-second defaultsilenceTimeoutis translated to something at all nowRecord > keeping values within Bandwidth's limits› limits maxLength to Bandwidth's 10800s (3h) maximumsilenceTimeoutmaxLengthmust not cut recordings to 1 secondtimeouttimeoutmaxLength="90.5"stays"90.5", no warning — a fractional recording length is a real conceptRecord > finishOnKey -> a single terminatingDigit› warns when omitted#default only covers one of Twilio's "any key" set#is picked over the other characters in the set#isn't an optionfinishOnKey="ab1"picks"1", not"a"finishOnKey="a"turns the feature off instead of adopting Bandwidth's"#"finishOnKey="##"doesn't warn — nothing was actually narrowed"#", with the same warning as the omitted caseRecord > playBeep and trim› warns when playBeep is omitted and when explicitly trueRecord > no action, re-requesting the current document› points recordCompleteUrl at the current document when the server provides onerecordCompleteUrlpoints back at the same page, no "missing action" warningrepeated identical findings collapse to one› a document with many bare Records reports each generic warning once, not once per Record<Record>s produce oneplayBeepwarning, not ten<Record>s with different out-of-rangemaxLengths each keep their own warninganalyzeSource› collapses the same finding repeated across several<Response>blocks in one fileGather/Record with no action re-request the current document, like Twilio› Gather posts Digits back to the same voice URL that served itgenerate → band BXML conformance› band accepts generated BXML for gather-record-limits.xmlfinishOnKey=""/multi-character cases, checked against Bandwidth's own CLIModified
Gather› maps attributes and nested promptsinterDigitTimeout="7"timeoutnow sets both attributes, not justfirstDigitTimeoutTest Plan
npm run typechecknpm test, includingtest/bxml-generator-band-conformance.test.tswith thebandCLI installedfinishOnKeyon both verbs, the no-action case sending results back to the original voice page, and the replay-loop scenario reproduced across two separate server instances with no shared memory;band bxml rawaccepted every document, including every new edge case from this roundfinishOnKeystops on#, and the#is not included inDigitstimeoutstops on silence (silenceTimeout), not at themaxLengthsafety capactionsends its result back to the page that served it, with the caller's digits/recording