From d53785576bbad9cd2e346789059f0bf6ee522b6a Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Tue, 30 Jun 2026 15:49:53 -0700 Subject: [PATCH 1/9] feat(voice): allow per-call websocket_url override on handle_incoming_call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.0.0 (#48) centralized wss URL derivation: handle_incoming_call now derives the ConversationRelay WebSocket URL from TACConfig.voice_public_domain + voice_websocket_path, and TwiMLOptions dropped its websocket_url field. That introduced an asymmetry — outbound (initiate_outbound_conversation) can still override the URL per call via InitiateVoiceConversationOptions.websocket_url, but inbound had no per-call seam, and the on_inbound_call_twiml customizer can override every TwiML field except the URL. This blocks affinity-routed hosts (e.g. Azure Hosted Agents) that must append a per-call token (?agent_session_id={CallSid}) to the upgrade URL so the WS lands on the same warm sandbox that served the TwiML; their only recourse was to bypass the channel and call generate_twiml directly. Add an optional keyword-only websocket_url to handle_incoming_call, defaulting to the derived URL. Backward-compatible (existing callers and the FastAPI server are unaffected) and symmetric with the outbound seam. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tac/channels/voice/channel.py | 11 ++++++++- tests/test_voice_channel.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 2615c0d..bbf9e1d 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -156,6 +156,8 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, twiml_request: TwiMLRequest | None = None, + *, + websocket_url: str | None = None, ) -> str: """ Generate TwiML response for incoming voice calls. @@ -185,11 +187,18 @@ async def handle_incoming_call( Args: twiml_request: Parsed Twilio webhook fields. Passed to the customizer if one is configured on the channel. + websocket_url: Public WebSocket URL for ConversationRelay. Optional — + defaults to the URL derived from ``TACConfig.voice_public_domain`` + + ``voice_websocket_path``. Pass it here only to override the URL + for a specific call (e.g. an affinity-routed host that appends a + per-call token to the upgrade URL). Mirrors + ``InitiateVoiceConversationOptions.websocket_url`` on the outbound + path. Returns: TwiML XML string for call connection. """ - websocket_url = self._resolve_websocket_url("handle_incoming_call") + websocket_url = websocket_url or self._resolve_websocket_url("handle_incoming_call") customized: TwiMLOptions | None = None if self._on_inbound_call_twiml is not None and twiml_request is not None: diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index d28d563..440bd76 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -584,6 +584,45 @@ async def test_handle_incoming_call_default_greeting(self) -> None: assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml assert 'conversationConfiguration="conv_configuration_test123"' in twiml + @pytest.mark.asyncio + async def test_handle_incoming_call_websocket_url_override(self) -> None: + """Test a per-call websocket_url overrides the derived URL. + + Mirrors the outbound seam (InitiateVoiceConversationOptions.websocket_url). + Regression guard for affinity-routed hosts (e.g. Azure Hosted Agents) + that append a per-call token to the ConversationRelay upgrade URL. + """ + from tac.channels.voice import VoiceChannelConfig + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + default_twiml_options=TwiMLOptions(welcome_greeting="Welcome!"), + ), + ) + + override = "wss://example.com/ws?agent_session_id=CA123" + twiml = await channel.handle_incoming_call(websocket_url=override) + + # The override URL is emitted verbatim... + assert f'url="{override}"' in twiml + # ...and the derived URL (without the query string) is NOT used. + assert 'url="wss://example.com/ws"' not in twiml + # The override only changes the URL; other layered fields still apply. + assert 'welcomeGreeting="Welcome!"' in twiml + assert 'conversationConfiguration="conv_configuration_test123"' in twiml + + @pytest.mark.asyncio + async def test_handle_incoming_call_default_websocket_url(self) -> None: + """Test handle_incoming_call still derives the URL when none is passed.""" + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + + twiml = await channel.handle_incoming_call() + + assert 'url="wss://example.com/ws"' in twiml + @pytest.mark.asyncio async def test_prompt_with_empty_voice_prompt(self) -> None: """Test handling prompt message with empty voice_prompt.""" From fe62b28f53c77783a99a3e2feb57285fcf34faa2 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Wed, 1 Jul 2026 16:02:45 -0700 Subject: [PATCH 2/9] refactor(voice): route per-call websocket_url through TwiMLOptions, not a param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone handle_incoming_call(websocket_url=...) param with a layered TwiMLOptions.websocket_url field, so per-call URL control flows through the existing on_inbound_call_twiml customizer instead of a separate method arg. #48 removed websocket_url from TwiMLOptions because it was `str = ""` — a "design lie" that could silently render url="". This restores it *safely* as `str | None = None`: unset falls through to the derived URL, so it can never produce url="". It rides the same merge as every other field — customizer > default_twiml_options > TAC default (TACConfig-derived URL, applied as the final `or` fallback, mirroring action_url). Precedent: action_url is already a TwiMLOptions field, and `extra` could already inject arbitrary attrs — so this makes the most fundamental attribute typed and safe rather than only reachable untyped. - models/voice.py: add TwiMLOptions.websocket_url: str | None = None - channels/voice/twiml.py: register it in _HANDLED_OUTSIDE_LOOP (passed as the positional websocket_url arg to generate_twiml, not the generic attr loop) - channels/voice/channel.py: resolve merged.websocket_url or derived, for both inbound and outbound (outbound: options.websocket_url still wins) - tests: drive the override through on_inbound_call_twiml and default_twiml_options No new public method or parameter; backward-compatible (unset behaves as today). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tac/channels/voice/channel.py | 35 ++++++++------- src/tac/channels/voice/twiml.py | 7 ++- src/tac/models/voice.py | 9 ++++ tests/test_voice_channel.py | 75 +++++++++++++++++++++++++++---- 4 files changed, 99 insertions(+), 27 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index bbf9e1d..76af81a 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -156,8 +156,6 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, twiml_request: TwiMLRequest | None = None, - *, - websocket_url: str | None = None, ) -> str: """ Generate TwiML response for incoming voice calls. @@ -175,36 +173,35 @@ async def handle_incoming_call( and ``twiml_request`` is given. 2. ``VoiceChannelConfig.default_twiml_options`` — per-channel defaults. 3. TAC defaults: a fixed default ``welcome_greeting``, - ``conversation_configuration`` from ``TACConfig``, and + ``conversation_configuration`` from ``TACConfig``, ``action_url`` resolved via Studio handoff (when ``studio_handoff_flow_sid`` is configured), else derived from - ``TACConfig.voice_public_domain`` + ``voice_action_path``. + ``TACConfig.voice_public_domain`` + ``voice_action_path``, and the + ``websocket_url`` derived from ``TACConfig.voice_public_domain`` + + ``voice_websocket_path``. Fields not set at a layer fall through to lower layers. Lists (``languages``) and nested models (``custom_parameters``) replace wholesale when set at a higher-priority layer. + ``websocket_url`` follows the same layering: a customizer or + ``default_twiml_options`` may override it per call (e.g. an + affinity-routed host appending a per-call token to the upgrade URL); + if unset it falls back to the ``TACConfig``-derived URL. + Args: twiml_request: Parsed Twilio webhook fields. Passed to the customizer if one is configured on the channel. - websocket_url: Public WebSocket URL for ConversationRelay. Optional — - defaults to the URL derived from ``TACConfig.voice_public_domain`` - + ``voice_websocket_path``. Pass it here only to override the URL - for a specific call (e.g. an affinity-routed host that appends a - per-call token to the upgrade URL). Mirrors - ``InitiateVoiceConversationOptions.websocket_url`` on the outbound - path. Returns: TwiML XML string for call connection. """ - websocket_url = websocket_url or self._resolve_websocket_url("handle_incoming_call") - customized: TwiMLOptions | None = None if self._on_inbound_call_twiml is not None and twiml_request is not None: customized = await self._on_inbound_call_twiml(twiml_request) merged = self._build_twiml_options(customized) + websocket_url = merged.websocket_url or self._resolve_websocket_url("handle_incoming_call") return twiml.generate_twiml(websocket_url, merged) def _build_twiml_options(self, per_call: TwiMLOptions | None) -> TwiMLOptions: @@ -524,9 +521,6 @@ async def initiate_outbound_conversation( ``TACConfig.voice_websocket_path``, unless overridden per-call via ``options.websocket_url``. """ - websocket_url = options.websocket_url or self._resolve_websocket_url( - "initiate_outbound_conversation" - ) from_number = self.tac.config.phone_number self.logger.info( @@ -540,6 +534,15 @@ async def initiate_outbound_conversation( # is no equivalent for outbound). merged = self._build_twiml_options(options.twiml_options) + # ``options.websocket_url`` is the dedicated per-call outbound override + # and wins over any websocket_url that came through the layered + # ``twiml_options`` merge; both fall back to the TACConfig-derived URL. + websocket_url = ( + options.websocket_url + or merged.websocket_url + or self._resolve_websocket_url("initiate_outbound_conversation") + ) + try: twiml_xml = twiml.generate_twiml(websocket_url, merged) diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index 1eab08c..1634bcb 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -42,9 +42,12 @@ ) # Fields on TwiMLOptions that this module handles specially (not via the -# generic _OPTIONAL_RELAY_ATTRS loop) — the action_url, the -# children list, the children dict, and the extra escape hatch. +# generic _OPTIONAL_RELAY_ATTRS loop) — the websocket_url (resolved through the +# layered merge and passed in as the positional ``websocket_url`` arg), the +# action_url, the children list, the children dict, and +# the extra escape hatch. _HANDLED_OUTSIDE_LOOP = { + "websocket_url", "action_url", "languages", "custom_parameters", diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index f3de5e1..bb18fea 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -193,6 +193,15 @@ class TwiMLOptions(BaseModel): description="Conversation Service SID for ConversationRelay to automatically " "manage conversation creation and participants.", ) + websocket_url: str | None = Field( + None, + description="ConversationRelay WebSocket URL (the " + "attribute). Leave None (the default) to use the URL the channel derives " + "from TACConfig.voice_public_domain + voice_websocket_path. Set it only for a " + "per-call URL — e.g. an affinity-routed host that appends a token to the " + "upgrade URL — typically from an on_inbound_call_twiml customizer. Like every " + "other field, it layers customizer > default_twiml_options > TAC default.", + ) # Language, TTS, STT language: str | None = Field( diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 440bd76..9769587 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -585,14 +585,16 @@ async def test_handle_incoming_call_default_greeting(self) -> None: assert 'conversationConfiguration="conv_configuration_test123"' in twiml @pytest.mark.asyncio - async def test_handle_incoming_call_websocket_url_override(self) -> None: - """Test a per-call websocket_url overrides the derived URL. + async def test_handle_incoming_call_websocket_url_via_customizer(self) -> None: + """Test an on_inbound_call_twiml customizer can override websocket_url per call. - Mirrors the outbound seam (InitiateVoiceConversationOptions.websocket_url). - Regression guard for affinity-routed hosts (e.g. Azure Hosted Agents) - that append a per-call token to the ConversationRelay upgrade URL. + websocket_url is a normal TwiMLOptions field, so it rides the same + layered merge as every other attribute. This is the affinity-routed-host + case (e.g. Azure Hosted Agents) appending a per-call token to the + upgrade URL — done through the existing customizer, no new API surface. """ from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequest tac = TAC(get_test_config()) channel = VoiceChannel( @@ -603,19 +605,74 @@ async def test_handle_incoming_call_websocket_url_override(self) -> None: ) override = "wss://example.com/ws?agent_session_id=CA123" - twiml = await channel.handle_incoming_call(websocket_url=override) - # The override URL is emitted verbatim... + async def customize(req: TwiMLRequest) -> TwiMLOptions: + return TwiMLOptions( + websocket_url=f"wss://example.com/ws?agent_session_id={req.call_sid}" + ) + + channel.on_inbound_call_twiml(customize) + + twiml = await channel.handle_incoming_call( + twiml_request=TwiMLRequest.from_form({"CallSid": "CA123"}) + ) + + # The customizer's URL is emitted verbatim... assert f'url="{override}"' in twiml - # ...and the derived URL (without the query string) is NOT used. + # ...and the bare derived URL (without the query string) is NOT used. assert 'url="wss://example.com/ws"' not in twiml # The override only changes the URL; other layered fields still apply. assert 'welcomeGreeting="Welcome!"' in twiml assert 'conversationConfiguration="conv_configuration_test123"' in twiml + @pytest.mark.asyncio + async def test_handle_incoming_call_websocket_url_via_default_options(self) -> None: + """Test default_twiml_options.websocket_url overrides the derived URL.""" + from tac.channels.voice import VoiceChannelConfig + + tac = TAC(get_test_config()) + override = "wss://static.example.com/socket" + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + default_twiml_options=TwiMLOptions(websocket_url=override), + ), + ) + + twiml = await channel.handle_incoming_call() + + assert f'url="{override}"' in twiml + assert 'url="wss://example.com/ws"' not in twiml + + @pytest.mark.asyncio + async def test_handle_incoming_call_customizer_beats_default_options(self) -> None: + """Test customizer websocket_url wins over default_twiml_options (precedence).""" + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequest + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + default_twiml_options=TwiMLOptions(websocket_url="wss://static.example.com/socket"), + ), + ) + + async def customize(req: TwiMLRequest) -> TwiMLOptions: + return TwiMLOptions(websocket_url="wss://per-call.example.com/ws") + + channel.on_inbound_call_twiml(customize) + + twiml = await channel.handle_incoming_call( + twiml_request=TwiMLRequest.from_form({"CallSid": "CA999"}) + ) + + assert 'url="wss://per-call.example.com/ws"' in twiml + assert "static.example.com" not in twiml + @pytest.mark.asyncio async def test_handle_incoming_call_default_websocket_url(self) -> None: - """Test handle_incoming_call still derives the URL when none is passed.""" + """Test handle_incoming_call derives the URL when no layer sets it.""" tac = TAC(get_test_config()) channel = VoiceChannel(tac) From 50069cde56d7321950f7b85d6037beccabbc6c57 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 2 Jul 2026 11:59:48 -0700 Subject: [PATCH 3/9] feat(voice): add server_twiml_options layer + let generate_twiml read url from options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give a custom in-process server (one that holds a VoiceChannel and generates TwiML itself — e.g. Azure's TACHostedAgentsApp for Foundry Hosted Agents) a way to inject per-call transport facts without stealing the application's on_inbound_call_twiml hook. handle_incoming_call gains a keyword-only server_twiml_options: TwiMLOptions. It layers by specificity, matching the existing action_url precedence: TAC defaults → default_twiml_options → server_twiml_options → app customizer i.e. per-call server facts beat static channel defaults, and the application's per-call on_inbound_call_twiml customizer still wins over everything — a developer's explicit choice is never silently overridden by server plumbing. This is the layer through which the affinity-routed host supplies a per-call websocket_url (with e.g. an agent_session_id token) that default_twiml_options structurally can't express. Also make generate_twiml's positional websocket_url optional, falling back to options.websocket_url (positional wins when both given). This lets a channel-less caller (an edge server that builds its own per-call URL) pass everything in one object: generate_twiml(options=TwiMLOptions(websocket_url=...)). Raises ValueError if no URL is provided via either source. _build_twiml_options / _resolve_action_url now take (server, per_call) layers. Outbound passes (None, options.twiml_options) — no server layer for outbound. Tests: server_twiml_options sets per-call websocket_url; app customizer beats server layer (dev wins contested fields, server's uncontested websocket_url still applies); server layer beats default_twiml_options. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tac/channels/voice/channel.py | 72 ++++++++++++++++++++++--------- src/tac/channels/voice/twiml.py | 21 ++++++++- tests/test_voice_channel.py | 70 ++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 23 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 76af81a..cd4c4a5 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -156,6 +156,8 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, twiml_request: TwiMLRequest | None = None, + *, + server_twiml_options: TwiMLOptions | None = None, ) -> str: """ Generate TwiML response for incoming voice calls. @@ -170,9 +172,12 @@ async def handle_incoming_call( TwiML fields are merged per-field, highest precedence first: 1. Output of the customizer registered via ``VoiceChannel.on_inbound_call_twiml(...)`` if configured - and ``twiml_request`` is given. - 2. ``VoiceChannelConfig.default_twiml_options`` — per-channel defaults. - 3. TAC defaults: a fixed default ``welcome_greeting``, + and ``twiml_request`` is given. (Application-owned.) + 2. ``server_twiml_options`` — per-call values supplied by the calling + server (host-owned transport facts, e.g. a per-call ``websocket_url`` + with an affinity token). + 3. ``VoiceChannelConfig.default_twiml_options`` — per-channel defaults. + 4. TAC defaults: a fixed default ``welcome_greeting``, ``conversation_configuration`` from ``TACConfig``, ``action_url`` resolved via Studio handoff (when ``studio_handoff_flow_sid`` is configured), else derived from @@ -184,14 +189,24 @@ async def handle_incoming_call( (``languages``) and nested models (``custom_parameters``) replace wholesale when set at a higher-priority layer. - ``websocket_url`` follows the same layering: a customizer or - ``default_twiml_options`` may override it per call (e.g. an - affinity-routed host appending a per-call token to the upgrade URL); - if unset it falls back to the ``TACConfig``-derived URL. + ``websocket_url`` follows the same layering; if unset at every layer it + falls back to the ``TACConfig``-derived URL. + + The ``server_twiml_options`` layer lets a custom in-process server (one + that holds a ``VoiceChannel`` and generates TwiML itself — e.g. an + affinity-routed host that bakes a per-call token into the upgrade URL) + inject its transport facts without stealing the application's + ``on_inbound_call_twiml`` hook. The application customizer still sits on + top, so a developer's explicit choices win. (Servers with no + ``VoiceChannel`` in the TwiML-generating process — e.g. an edge proxy — + should call the low-level ``generate_twiml`` directly instead.) Args: twiml_request: Parsed Twilio webhook fields. Passed to the customizer if one is configured on the channel. + server_twiml_options: Per-call TwiML values supplied by the calling + server, layered above ``default_twiml_options`` and below the + application customizer. Returns: TwiML XML string for call connection. @@ -200,22 +215,29 @@ async def handle_incoming_call( if self._on_inbound_call_twiml is not None and twiml_request is not None: customized = await self._on_inbound_call_twiml(twiml_request) - merged = self._build_twiml_options(customized) + merged = self._build_twiml_options(server_twiml_options, customized) websocket_url = merged.websocket_url or self._resolve_websocket_url("handle_incoming_call") return twiml.generate_twiml(websocket_url, merged) - def _build_twiml_options(self, per_call: TwiMLOptions | None) -> TwiMLOptions: - """Layer TwiML options: TAC defaults → channel ``default_twiml_options`` - → ``per_call`` (customizer output for inbound, or + def _build_twiml_options( + self, + server: TwiMLOptions | None, + per_call: TwiMLOptions | None, + ) -> TwiMLOptions: + """Layer TwiML options, lowest precedence first: TAC defaults → + ``default_twiml_options`` → ``server`` (calling server's per-call + values) → ``per_call`` (application customizer output for inbound, or ``InitiateVoiceConversationOptions.twiml_options`` for outbound). """ merged = TwiMLOptions( welcome_greeting=DEFAULT_WELCOME_GREETING, conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_action_url(per_call), + action_url=self._resolve_action_url(server, per_call), ) if self.config.default_twiml_options is not None: self._overlay_fields(merged, self.config.default_twiml_options) + if server is not None: + self._overlay_fields(merged, server) if per_call is not None: self._overlay_fields(merged, per_call) return merged @@ -240,14 +262,19 @@ def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: continue setattr(target, field, getattr(source, field)) - def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: + def _resolve_action_url( + self, + server: TwiMLOptions | None, + customized: TwiMLOptions | None, + ) -> str | None: """Resolve the TwiML ```` URL. Precedence (highest to lowest): - 1. customizer - 2. channel ``default_twiml_options`` - 3. Studio handoff (when ``studio_handoff_flow_sid`` is configured) - 4. Channel default — derived from ``TACConfig.voice_public_domain`` + 1. application customizer + 2. ``server`` (calling server's per-call options) + 3. channel ``default_twiml_options`` + 4. Studio handoff (when ``studio_handoff_flow_sid`` is configured) + 5. Channel default — derived from ``TACConfig.voice_public_domain`` + ``TACConfig.voice_action_path``. User-expressed intent (Studio handoff is configured explicitly on @@ -264,6 +291,8 @@ def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: """ if customized is not None and "action_url" in customized.model_fields_set: return customized.action_url + if server is not None and "action_url" in server.model_fields_set: + return server.action_url if ( self.config.default_twiml_options is not None and "action_url" in self.config.default_twiml_options.model_fields_set @@ -529,10 +558,11 @@ async def initiate_outbound_conversation( from_number=mask_phone(from_number), ) - # Same layering as handle_incoming_call, minus the customizer - # (customizers receive a TwiMLRequest from an inbound webhook; there - # is no equivalent for outbound). - merged = self._build_twiml_options(options.twiml_options) + # Same layering as handle_incoming_call, minus the application + # customizer (customizers receive a TwiMLRequest from an inbound webhook; + # there is no equivalent for outbound). options.twiml_options is the + # per-call override; there is no separate server layer for outbound. + merged = self._build_twiml_options(None, options.twiml_options) # ``options.websocket_url`` is the dedicated per-call outbound override # and wins over any websocket_url that came through the layered diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index 1634bcb..f4d554b 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -81,7 +81,7 @@ def _verify_attrs_in_sync() -> None: def generate_twiml( - websocket_url: str, + websocket_url: str | None = None, options: TwiMLOptions | dict[str, Any] | None = None, ) -> str: """ @@ -92,9 +92,16 @@ def generate_twiml( static ``twiml_options`` from ``VoiceChannelConfig``, and any per-call customizer output. + The WebSocket URL may be supplied either as the positional ``websocket_url`` + argument or as ``options.websocket_url``. The positional argument wins when + both are given. This lets a channel-less caller (e.g. an edge server that + builds its own per-call URL) pass everything through ``options`` in one + object: ``generate_twiml(options=TwiMLOptions(websocket_url=...))``. + Args: websocket_url: Public WebSocket URL for ConversationRelay - (e.g. ``'wss://example.ngrok.app/ws'``). + (e.g. ``'wss://example.ngrok.app/ws'``). Optional if + ``options.websocket_url`` is set. options: Optional ``TwiMLOptions`` (or dict). See ``TwiMLOptions`` for supported fields. Newly-added ConversationRelay attributes not yet typed on the model can be passed via ``extra``. @@ -102,6 +109,9 @@ def generate_twiml( Returns: TwiML XML string ready to return to Twilio. + Raises: + ValueError: If no WebSocket URL is provided via either source. + Example: >>> twiml = generate_twiml( ... "wss://example.com/voice", @@ -116,6 +126,13 @@ def generate_twiml( elif isinstance(options, dict): options = TwiMLOptions(**options) + resolved_websocket_url = websocket_url or options.websocket_url + if not resolved_websocket_url: + raise ValueError( + "generate_twiml requires a WebSocket URL — pass it positionally or " + "set options.websocket_url." + ) + response = VoiceResponse() # Create Connect verb with optional action diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 9769587..b2b40e5 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1653,6 +1653,76 @@ async def test_default_options_action_url_none_suppresses(self) -> None: twiml = await channel.handle_incoming_call() assert "action=" not in twiml + @pytest.mark.asyncio + async def test_server_twiml_options_sets_per_call_websocket_url(self) -> None: + """A custom in-process server passes per-call transport facts via + server_twiml_options — e.g. an affinity URL — without registering a + customizer. This is the TACHostedAgentsApp use case.""" + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + + affinity_url = "wss://example.com/ws?agent_session_id=CA123" + twiml = await channel.handle_incoming_call( + server_twiml_options=TwiMLOptions( + websocket_url=affinity_url, + custom_parameters={"agent_session_id": "CA123"}, + ), + ) + + assert f'url="{affinity_url}"' in twiml + assert 'url="wss://example.com/ws"' not in twiml # not the derived URL + # conversation_configuration still populated from TACConfig (not clobbered). + assert 'conversationConfiguration="conv_configuration_test123"' in twiml + + @pytest.mark.asyncio + async def test_app_customizer_beats_server_twiml_options(self) -> None: + """Precedence: the application's on_inbound_call_twiml customizer sits + ABOVE server_twiml_options — a developer's explicit choice wins over the + server's per-call values (for fields the dev sets).""" + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequest + + async def app_customizer(req: TwiMLRequest) -> TwiMLOptions: + return TwiMLOptions(welcome_greeting="App wins") + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac, config=VoiceChannelConfig()) + channel.on_inbound_call_twiml(app_customizer) + + twiml = await channel.handle_incoming_call( + twiml_request=TwiMLRequest(), + server_twiml_options=TwiMLOptions( + websocket_url="wss://example.com/ws?agent_session_id=CA1", + welcome_greeting="Server loses", + ), + ) + + # Dev's greeting wins the contested field... + assert 'welcomeGreeting="App wins"' in twiml + assert "Server loses" not in twiml + # ...but the server's websocket_url (dev didn't set it) still applies. + assert 'url="wss://example.com/ws?agent_session_id=CA1"' in twiml + + @pytest.mark.asyncio + async def test_server_twiml_options_beats_default_options(self) -> None: + """server_twiml_options sits above default_twiml_options.""" + from tac.channels.voice import VoiceChannelConfig + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + default_twiml_options=TwiMLOptions(welcome_greeting="Channel default"), + ), + ) + + twiml = await channel.handle_incoming_call( + server_twiml_options=TwiMLOptions(welcome_greeting="Server override"), + ) + + assert 'welcomeGreeting="Server override"' in twiml + assert "Channel default" not in twiml + class TestStaticTwiMLOptions: """VoiceChannelConfig.twiml_options applies to every call without a callback.""" From 569aba022ee879f263a3f7654e4e5393b822d571 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 2 Jul 2026 12:09:45 -0700 Subject: [PATCH 4/9] docs(voice): trim redundant comments on server_twiml_options / generate_twiml Cut the prose that repeated the precedence list and Args entries; keep the non-obvious behavior (layering order, positional-vs-options URL) stated once. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tac/channels/voice/channel.py | 28 ++++++++-------------------- src/tac/channels/voice/twiml.py | 6 ++---- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index cd4c4a5..2e0e6e1 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -187,26 +187,16 @@ async def handle_incoming_call( Fields not set at a layer fall through to lower layers. Lists (``languages``) and nested models (``custom_parameters``) replace - wholesale when set at a higher-priority layer. - - ``websocket_url`` follows the same layering; if unset at every layer it - falls back to the ``TACConfig``-derived URL. - - The ``server_twiml_options`` layer lets a custom in-process server (one - that holds a ``VoiceChannel`` and generates TwiML itself — e.g. an - affinity-routed host that bakes a per-call token into the upgrade URL) - inject its transport facts without stealing the application's - ``on_inbound_call_twiml`` hook. The application customizer still sits on - top, so a developer's explicit choices win. (Servers with no - ``VoiceChannel`` in the TwiML-generating process — e.g. an edge proxy — - should call the low-level ``generate_twiml`` directly instead.) + wholesale when set at a higher-priority layer. ``websocket_url`` falls + back to the ``TACConfig``-derived URL if unset at every layer. Args: twiml_request: Parsed Twilio webhook fields. Passed to the customizer if one is configured on the channel. - server_twiml_options: Per-call TwiML values supplied by the calling - server, layered above ``default_twiml_options`` and below the - application customizer. + server_twiml_options: Per-call TwiML supplied by a custom in-process + server (e.g. an affinity-routed host injecting a per-call + ``websocket_url``), layered above ``default_twiml_options`` and + below the application customizer. Returns: TwiML XML string for call connection. @@ -558,10 +548,8 @@ async def initiate_outbound_conversation( from_number=mask_phone(from_number), ) - # Same layering as handle_incoming_call, minus the application - # customizer (customizers receive a TwiMLRequest from an inbound webhook; - # there is no equivalent for outbound). options.twiml_options is the - # per-call override; there is no separate server layer for outbound. + # Outbound has no inbound customizer and no server layer; the per-call + # override is options.twiml_options. merged = self._build_twiml_options(None, options.twiml_options) # ``options.websocket_url`` is the dedicated per-call outbound override diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index f4d554b..05def97 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -92,10 +92,8 @@ def generate_twiml( static ``twiml_options`` from ``VoiceChannelConfig``, and any per-call customizer output. - The WebSocket URL may be supplied either as the positional ``websocket_url`` - argument or as ``options.websocket_url``. The positional argument wins when - both are given. This lets a channel-less caller (e.g. an edge server that - builds its own per-call URL) pass everything through ``options`` in one + The WebSocket URL may be passed positionally or as ``options.websocket_url`` + (positional wins when both are given), so a caller can pass everything in one object: ``generate_twiml(options=TwiMLOptions(websocket_url=...))``. Args: From 46685317a89db8fb4e18bd0fb2c92d2105679d5e Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 2 Jul 2026 12:16:11 -0700 Subject: [PATCH 5/9] fix(voice): generate_twiml must emit the resolved URL, not the raw positional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The options.websocket_url fallback was computed into resolved_websocket_url and validated, but was still built from the raw positional websocket_url. So generate_twiml(options=TwiMLOptions(websocket_url=...)) with no positional arg passed the guard yet emitted with no url — Twilio rejects that and drops the call. Use resolved_websocket_url for the url. Unreached by the channel's own call sites (they always pass a positional URL), but it's the documented channel-less path added in this branch. Tests: URL supplied via options only is emitted; positional wins over options; no URL via either source raises. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tac/channels/voice/twiml.py | 2 +- tests/test_voice_channel.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index 05def97..3bb7c72 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -141,7 +141,7 @@ def generate_twiml( # Build ConversationRelay kwargs. The twilio SDK converts snake_case to # camelCase automatically, and serializes bool/str as TwiML attribute values. - relay_kwargs: dict[str, Any] = {"url": websocket_url} + relay_kwargs: dict[str, Any] = {"url": resolved_websocket_url} for attr in _OPTIONAL_RELAY_ATTRS: value = getattr(options, attr) if value is None: diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index b2b40e5..9814a6d 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1168,6 +1168,34 @@ def test_generate_twiml_minimal(self) -> None: assert "welcomeGreeting" not in twiml assert "action=" not in twiml + def test_generate_twiml_url_from_options_only(self) -> None: + """URL supplied only via options.websocket_url (no positional arg) is + emitted on — the channel-less caller path.""" + twiml = generate_twiml( + options=TwiMLOptions( + websocket_url="wss://example.com/ws?agent_session_id=CA1", + conversation_configuration="cc", + ) + ) + + assert 'url="wss://example.com/ws?agent_session_id=CA1"' in twiml + assert 'conversationConfiguration="cc"' in twiml + + def test_generate_twiml_positional_url_wins_over_options(self) -> None: + """When both are given, the positional websocket_url wins.""" + twiml = generate_twiml( + "wss://positional.example.com/ws", + TwiMLOptions(websocket_url="wss://options.example.com/ws"), + ) + + assert 'url="wss://positional.example.com/ws"' in twiml + assert "options.example.com" not in twiml + + def test_generate_twiml_requires_a_url(self) -> None: + """No URL via either source raises ValueError.""" + with pytest.raises(ValueError, match="requires a WebSocket URL"): + generate_twiml(options=TwiMLOptions(welcome_greeting="hi")) + def test_generate_twiml_with_welcome_greeting(self) -> None: """Test TwiML generation with welcome greeting.""" twiml = generate_twiml( From 2207f5917288f77f65afd3b194ac246571495cdc Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 2 Jul 2026 12:41:27 -0700 Subject: [PATCH 6/9] =?UTF-8?q?refactor(voice):=20rename=20server=5Ftwiml?= =?UTF-8?q?=5Foptions=20=E2=86=92=20host=5Ftwiml=5Foptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "server" collided with TACFastAPIServer — and misleadingly so, since that built-in server is precisely the one that does NOT pass this arg (only a custom in-process host does). "host" disambiguates and matches the framing used throughout: host-owned per-call transport facts (e.g. an affinity websocket_url). Renamed the public keyword arg on handle_incoming_call, the internal _build_twiml_options/_resolve_action_url `server` param → `host`, and all docstrings/tests. Unreleased and not yet adopted downstream, so this is a free rename now rather than a breaking change after Azure pins it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tac/channels/voice/channel.py | 34 +++++++++++++++---------------- tests/test_voice_channel.py | 30 +++++++++++++-------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 2e0e6e1..86eaa3e 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -157,7 +157,7 @@ async def handle_incoming_call( self, twiml_request: TwiMLRequest | None = None, *, - server_twiml_options: TwiMLOptions | None = None, + host_twiml_options: TwiMLOptions | None = None, ) -> str: """ Generate TwiML response for incoming voice calls. @@ -173,9 +173,9 @@ async def handle_incoming_call( 1. Output of the customizer registered via ``VoiceChannel.on_inbound_call_twiml(...)`` if configured and ``twiml_request`` is given. (Application-owned.) - 2. ``server_twiml_options`` — per-call values supplied by the calling - server (host-owned transport facts, e.g. a per-call ``websocket_url`` - with an affinity token). + 2. ``host_twiml_options`` — per-call transport facts supplied by the + host (the code owning the route), e.g. a per-call ``websocket_url`` + with an affinity token. 3. ``VoiceChannelConfig.default_twiml_options`` — per-channel defaults. 4. TAC defaults: a fixed default ``welcome_greeting``, ``conversation_configuration`` from ``TACConfig``, @@ -193,8 +193,8 @@ async def handle_incoming_call( Args: twiml_request: Parsed Twilio webhook fields. Passed to the customizer if one is configured on the channel. - server_twiml_options: Per-call TwiML supplied by a custom in-process - server (e.g. an affinity-routed host injecting a per-call + host_twiml_options: Per-call TwiML supplied by a custom in-process + host (e.g. an affinity-routed deployment injecting a per-call ``websocket_url``), layered above ``default_twiml_options`` and below the application customizer. @@ -205,29 +205,29 @@ async def handle_incoming_call( if self._on_inbound_call_twiml is not None and twiml_request is not None: customized = await self._on_inbound_call_twiml(twiml_request) - merged = self._build_twiml_options(server_twiml_options, customized) + merged = self._build_twiml_options(host_twiml_options, customized) websocket_url = merged.websocket_url or self._resolve_websocket_url("handle_incoming_call") return twiml.generate_twiml(websocket_url, merged) def _build_twiml_options( self, - server: TwiMLOptions | None, + host: TwiMLOptions | None, per_call: TwiMLOptions | None, ) -> TwiMLOptions: """Layer TwiML options, lowest precedence first: TAC defaults → - ``default_twiml_options`` → ``server`` (calling server's per-call - values) → ``per_call`` (application customizer output for inbound, or + ``default_twiml_options`` → ``host`` (calling host's per-call values) → + ``per_call`` (application customizer output for inbound, or ``InitiateVoiceConversationOptions.twiml_options`` for outbound). """ merged = TwiMLOptions( welcome_greeting=DEFAULT_WELCOME_GREETING, conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_action_url(server, per_call), + action_url=self._resolve_action_url(host, per_call), ) if self.config.default_twiml_options is not None: self._overlay_fields(merged, self.config.default_twiml_options) - if server is not None: - self._overlay_fields(merged, server) + if host is not None: + self._overlay_fields(merged, host) if per_call is not None: self._overlay_fields(merged, per_call) return merged @@ -254,14 +254,14 @@ def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: def _resolve_action_url( self, - server: TwiMLOptions | None, + host: TwiMLOptions | None, customized: TwiMLOptions | None, ) -> str | None: """Resolve the TwiML ```` URL. Precedence (highest to lowest): 1. application customizer - 2. ``server`` (calling server's per-call options) + 2. ``host`` (calling host's per-call options) 3. channel ``default_twiml_options`` 4. Studio handoff (when ``studio_handoff_flow_sid`` is configured) 5. Channel default — derived from ``TACConfig.voice_public_domain`` @@ -281,8 +281,8 @@ def _resolve_action_url( """ if customized is not None and "action_url" in customized.model_fields_set: return customized.action_url - if server is not None and "action_url" in server.model_fields_set: - return server.action_url + if host is not None and "action_url" in host.model_fields_set: + return host.action_url if ( self.config.default_twiml_options is not None and "action_url" in self.config.default_twiml_options.model_fields_set diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 9814a6d..d75852b 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1682,16 +1682,16 @@ async def test_default_options_action_url_none_suppresses(self) -> None: assert "action=" not in twiml @pytest.mark.asyncio - async def test_server_twiml_options_sets_per_call_websocket_url(self) -> None: - """A custom in-process server passes per-call transport facts via - server_twiml_options — e.g. an affinity URL — without registering a + async def test_host_twiml_options_sets_per_call_websocket_url(self) -> None: + """A custom in-process host passes per-call transport facts via + host_twiml_options — e.g. an affinity URL — without registering a customizer. This is the TACHostedAgentsApp use case.""" tac = TAC(get_test_config()) channel = VoiceChannel(tac) affinity_url = "wss://example.com/ws?agent_session_id=CA123" twiml = await channel.handle_incoming_call( - server_twiml_options=TwiMLOptions( + host_twiml_options=TwiMLOptions( websocket_url=affinity_url, custom_parameters={"agent_session_id": "CA123"}, ), @@ -1703,10 +1703,10 @@ async def test_server_twiml_options_sets_per_call_websocket_url(self) -> None: assert 'conversationConfiguration="conv_configuration_test123"' in twiml @pytest.mark.asyncio - async def test_app_customizer_beats_server_twiml_options(self) -> None: + async def test_app_customizer_beats_host_twiml_options(self) -> None: """Precedence: the application's on_inbound_call_twiml customizer sits - ABOVE server_twiml_options — a developer's explicit choice wins over the - server's per-call values (for fields the dev sets).""" + ABOVE host_twiml_options — a developer's explicit choice wins over the + host's per-call values (for fields the dev sets).""" from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequest @@ -1719,21 +1719,21 @@ async def app_customizer(req: TwiMLRequest) -> TwiMLOptions: twiml = await channel.handle_incoming_call( twiml_request=TwiMLRequest(), - server_twiml_options=TwiMLOptions( + host_twiml_options=TwiMLOptions( websocket_url="wss://example.com/ws?agent_session_id=CA1", - welcome_greeting="Server loses", + welcome_greeting="Host loses", ), ) # Dev's greeting wins the contested field... assert 'welcomeGreeting="App wins"' in twiml - assert "Server loses" not in twiml - # ...but the server's websocket_url (dev didn't set it) still applies. + assert "Host loses" not in twiml + # ...but the host's websocket_url (dev didn't set it) still applies. assert 'url="wss://example.com/ws?agent_session_id=CA1"' in twiml @pytest.mark.asyncio - async def test_server_twiml_options_beats_default_options(self) -> None: - """server_twiml_options sits above default_twiml_options.""" + async def test_host_twiml_options_beats_default_options(self) -> None: + """host_twiml_options sits above default_twiml_options.""" from tac.channels.voice import VoiceChannelConfig tac = TAC(get_test_config()) @@ -1745,10 +1745,10 @@ async def test_server_twiml_options_beats_default_options(self) -> None: ) twiml = await channel.handle_incoming_call( - server_twiml_options=TwiMLOptions(welcome_greeting="Server override"), + host_twiml_options=TwiMLOptions(welcome_greeting="Host override"), ) - assert 'welcomeGreeting="Server override"' in twiml + assert 'welcomeGreeting="Host override"' in twiml assert "Channel default" not in twiml From cb8bc45aa9b16a1fdb9ade879425a46fb935c03b Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 2 Jul 2026 13:34:07 -0700 Subject: [PATCH 7/9] chore: bump version to 2.1.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 23a4d4f..4a1b0f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "twilio-agent-connect" -version = "2.0.0" +version = "2.1.0" description = "A Python framework for building agentic applications with Twilio" authors = [{ name = "Twilio Conversation AI", email = "team_conversational-ai@twilio.com" }] readme = "README.md" diff --git a/uv.lock b/uv.lock index 99bbd81..0151204 100644 --- a/uv.lock +++ b/uv.lock @@ -2430,7 +2430,7 @@ wheels = [ [[package]] name = "twilio-agent-connect" -version = "2.0.0" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 146ebd41280e3d25773df280e94be13846fe7ce9 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 2 Jul 2026 17:54:34 -0700 Subject: [PATCH 8/9] =?UTF-8?q?fix(voice):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20stale=20docs=20+=20empty=20websocket=5Furl=20handli?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TwiMLOptions.websocket_url docstring: correct the layering to include the host_twiml_options layer (customizer > host_twiml_options > default_twiml_options > TAC default) and point at handle_incoming_call's host_twiml_options rather than "the customizer". - twiml.py _HANDLED_OUTSIDE_LOOP comment: note websocket_url resolves from the positional arg OR options.websocket_url, not just the positional. - Reject empty/whitespace websocket_url at the model via a field_validator, so a misconfiguration fails fast instead of silently emitting . With that guard, resolve the URL by None-coalescing (positional wins when both set) rather than truthiness, honoring the documented precedence. Tests: empty/whitespace websocket_url raises; None still falls through. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tac/channels/voice/channel.py | 19 +++++++++++++------ src/tac/channels/voice/twiml.py | 13 ++++++++----- src/tac/models/voice.py | 21 ++++++++++++++++++--- tests/test_voice_channel.py | 10 ++++++++++ 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 86eaa3e..67c795f 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -206,7 +206,13 @@ async def handle_incoming_call( customized = await self._on_inbound_call_twiml(twiml_request) merged = self._build_twiml_options(host_twiml_options, customized) - websocket_url = merged.websocket_url or self._resolve_websocket_url("handle_incoming_call") + # merged.websocket_url is either a validated non-empty URL (set by some + # layer) or None; fall back to the TACConfig-derived URL only when None. + websocket_url = ( + merged.websocket_url + if merged.websocket_url is not None + else self._resolve_websocket_url("handle_incoming_call") + ) return twiml.generate_twiml(websocket_url, merged) def _build_twiml_options( @@ -555,11 +561,12 @@ async def initiate_outbound_conversation( # ``options.websocket_url`` is the dedicated per-call outbound override # and wins over any websocket_url that came through the layered # ``twiml_options`` merge; both fall back to the TACConfig-derived URL. - websocket_url = ( - options.websocket_url - or merged.websocket_url - or self._resolve_websocket_url("initiate_outbound_conversation") - ) + if options.websocket_url is not None: + websocket_url = options.websocket_url + elif merged.websocket_url is not None: + websocket_url = merged.websocket_url + else: + websocket_url = self._resolve_websocket_url("initiate_outbound_conversation") try: twiml_xml = twiml.generate_twiml(websocket_url, merged) diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index 3bb7c72..dba144b 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -42,10 +42,11 @@ ) # Fields on TwiMLOptions that this module handles specially (not via the -# generic _OPTIONAL_RELAY_ATTRS loop) — the websocket_url (resolved through the -# layered merge and passed in as the positional ``websocket_url`` arg), the -# action_url, the children list, the children dict, and -# the extra escape hatch. +# generic _OPTIONAL_RELAY_ATTRS loop) — the websocket_url (emitted as the +# ```` attribute, resolved from the positional +# ``websocket_url`` arg or ``options.websocket_url``), the action_url, the +# children list, the children dict, and the extra +# escape hatch. _HANDLED_OUTSIDE_LOOP = { "websocket_url", "action_url", @@ -124,7 +125,9 @@ def generate_twiml( elif isinstance(options, dict): options = TwiMLOptions(**options) - resolved_websocket_url = websocket_url or options.websocket_url + # Positional arg wins when both are set; fall back to options.websocket_url. + # (TwiMLOptions rejects an empty websocket_url, so any value here is real.) + resolved_websocket_url = websocket_url if websocket_url is not None else options.websocket_url if not resolved_websocket_url: raise ValueError( "generate_twiml requires a WebSocket URL — pass it positionally or " diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index bb18fea..3436c7f 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator # Twilio uses the same four-value enum for several attributes that control # what caller input (DTMF, speech, both, neither) triggers a given behavior. @@ -199,8 +199,9 @@ class TwiMLOptions(BaseModel): "attribute). Leave None (the default) to use the URL the channel derives " "from TACConfig.voice_public_domain + voice_websocket_path. Set it only for a " "per-call URL — e.g. an affinity-routed host that appends a token to the " - "upgrade URL — typically from an on_inbound_call_twiml customizer. Like every " - "other field, it layers customizer > default_twiml_options > TAC default.", + "upgrade URL, typically via handle_incoming_call's host_twiml_options. Like " + "every other field, it layers: on_inbound_call_twiml customizer > " + "host_twiml_options > default_twiml_options > TAC default.", ) # Language, TTS, STT @@ -328,6 +329,20 @@ class TwiMLOptions(BaseModel): model_config = {"populate_by_name": True} + @field_validator("websocket_url") + @classmethod + def _reject_empty_websocket_url(cls, v: str | None) -> str | None: + """Reject an empty/whitespace-only ``websocket_url``. Leave it ``None`` + (the default) to fall through to the derived URL; an empty string is a + misconfiguration that would otherwise emit ````. + """ + if v is not None and not v.strip(): + raise ValueError( + "websocket_url cannot be empty — omit it (None) to use the " + "derived URL, or provide a non-empty URL." + ) + return v + @model_validator(mode="after") def _reject_extra_shadowing_typed_fields(self) -> "TwiMLOptions": """Fail fast when ``extra`` includes a key that has a typed field on diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index d75852b..5547654 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1196,6 +1196,16 @@ def test_generate_twiml_requires_a_url(self) -> None: with pytest.raises(ValueError, match="requires a WebSocket URL"): generate_twiml(options=TwiMLOptions(welcome_greeting="hi")) + def test_twiml_options_rejects_empty_websocket_url(self) -> None: + """An empty/whitespace websocket_url is a misconfiguration — reject it + at the model rather than silently emitting .""" + with pytest.raises(ValueError, match="websocket_url cannot be empty"): + TwiMLOptions(websocket_url="") + with pytest.raises(ValueError, match="websocket_url cannot be empty"): + TwiMLOptions(websocket_url=" ") + # None (the default) is fine — falls through to the derived URL. + assert TwiMLOptions(websocket_url=None).websocket_url is None + def test_generate_twiml_with_welcome_greeting(self) -> None: """Test TwiML generation with welcome greeting.""" twiml = generate_twiml( From c2fac3b4a134a28a5d89eb0d7947e9bc0e473def Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 2 Jul 2026 18:10:28 -0700 Subject: [PATCH 9/9] fix(voice): default_twiml_options now outranks host_twiml_options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the inbound TwiML merge precedence so channel-wide default_twiml_options wins over per-call host_twiml_options. New order (high→low): on_inbound_call_twiml customizer > default_twiml_options > host_twiml_options > TAC defaults. - _build_twiml_options: overlay host first, then default_twiml_options - _resolve_action_url: check default_twiml_options.action_url before host - Update all precedence docs (handle_incoming_call, config.py inbound layers, voice.py websocket_url field) - Flip test: default_options now beats host_twiml_options Outbound path (host=None) is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tac/channels/voice/channel.py | 39 +++++++++++++++++++------------ src/tac/channels/voice/config.py | 3 ++- src/tac/models/voice.py | 2 +- tests/test_voice_channel.py | 8 +++---- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 67c795f..5ceeaa2 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -173,10 +173,10 @@ async def handle_incoming_call( 1. Output of the customizer registered via ``VoiceChannel.on_inbound_call_twiml(...)`` if configured and ``twiml_request`` is given. (Application-owned.) - 2. ``host_twiml_options`` — per-call transport facts supplied by the + 2. ``VoiceChannelConfig.default_twiml_options`` — per-channel defaults. + 3. ``host_twiml_options`` — per-call transport facts supplied by the host (the code owning the route), e.g. a per-call ``websocket_url`` with an affinity token. - 3. ``VoiceChannelConfig.default_twiml_options`` — per-channel defaults. 4. TAC defaults: a fixed default ``welcome_greeting``, ``conversation_configuration`` from ``TACConfig``, ``action_url`` resolved via Studio handoff (when @@ -190,13 +190,22 @@ async def handle_incoming_call( wholesale when set at a higher-priority layer. ``websocket_url`` falls back to the ``TACConfig``-derived URL if unset at every layer. + The two arguments are complementary, not alternatives — a custom host + typically passes both on the same call: ``twiml_request`` carries the + inbound call's data (so the application's customizer can run), and + ``host_twiml_options`` carries the host's own per-call overrides. + Args: - twiml_request: Parsed Twilio webhook fields. Passed to the - customizer if one is configured on the channel. - host_twiml_options: Per-call TwiML supplied by a custom in-process - host (e.g. an affinity-routed deployment injecting a per-call - ``websocket_url``), layered above ``default_twiml_options`` and - below the application customizer. + twiml_request: The incoming Twilio voice webhook, parsed into a + framework-neutral form (From, To, CallSid, CallerCountry, …). + Supplied by Twilio; forwarded to the ``on_inbound_call_twiml`` + customizer so the application can produce per-call overrides. + host_twiml_options: Per-call TwiML overrides supplied by the *host* + (the code owning the route — e.g. a custom server), for + transport facts the SDK can't derive, such as a per-call + ``websocket_url`` with an affinity token. Layered below + ``default_twiml_options`` and the application customizer, so a + developer's explicit settings still win. Returns: TwiML XML string for call connection. @@ -221,7 +230,7 @@ def _build_twiml_options( per_call: TwiMLOptions | None, ) -> TwiMLOptions: """Layer TwiML options, lowest precedence first: TAC defaults → - ``default_twiml_options`` → ``host`` (calling host's per-call values) → + ``host`` (calling host's per-call values) → ``default_twiml_options`` → ``per_call`` (application customizer output for inbound, or ``InitiateVoiceConversationOptions.twiml_options`` for outbound). """ @@ -230,10 +239,10 @@ def _build_twiml_options( conversation_configuration=self.tac.config.conversation_configuration_id, action_url=self._resolve_action_url(host, per_call), ) - if self.config.default_twiml_options is not None: - self._overlay_fields(merged, self.config.default_twiml_options) if host is not None: self._overlay_fields(merged, host) + if self.config.default_twiml_options is not None: + self._overlay_fields(merged, self.config.default_twiml_options) if per_call is not None: self._overlay_fields(merged, per_call) return merged @@ -267,8 +276,8 @@ def _resolve_action_url( Precedence (highest to lowest): 1. application customizer - 2. ``host`` (calling host's per-call options) - 3. channel ``default_twiml_options`` + 2. channel ``default_twiml_options`` + 3. ``host`` (calling host's per-call options) 4. Studio handoff (when ``studio_handoff_flow_sid`` is configured) 5. Channel default — derived from ``TACConfig.voice_public_domain`` + ``TACConfig.voice_action_path``. @@ -287,13 +296,13 @@ def _resolve_action_url( """ if customized is not None and "action_url" in customized.model_fields_set: return customized.action_url - if host is not None and "action_url" in host.model_fields_set: - return host.action_url if ( self.config.default_twiml_options is not None and "action_url" in self.config.default_twiml_options.model_fields_set ): return self.config.default_twiml_options.action_url + if host is not None and "action_url" in host.model_fields_set: + return host.action_url if self.tac.config.studio_handoff_flow_sid: return studio_voice_handoff_url( self.tac.config.account_sid, diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index 7443058..295375b 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -21,7 +21,8 @@ class VoiceChannelConfig(BaseModel): 1. Output of the customizer registered via ``VoiceChannel.on_inbound_call_twiml(...)`` [optional] 2. ``default_twiml_options`` [optional] - 3. TAC defaults + 3. ``handle_incoming_call(host_twiml_options=...)`` [optional] + 4. TAC defaults Outbound calls (``initiate_outbound_conversation``): 1. ``InitiateVoiceConversationOptions.twiml_options`` [optional] diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 3436c7f..671a4c0 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -201,7 +201,7 @@ class TwiMLOptions(BaseModel): "per-call URL — e.g. an affinity-routed host that appends a token to the " "upgrade URL, typically via handle_incoming_call's host_twiml_options. Like " "every other field, it layers: on_inbound_call_twiml customizer > " - "host_twiml_options > default_twiml_options > TAC default.", + "default_twiml_options > host_twiml_options > TAC default.", ) # Language, TTS, STT diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 5547654..32ca6ab 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1742,8 +1742,8 @@ async def app_customizer(req: TwiMLRequest) -> TwiMLOptions: assert 'url="wss://example.com/ws?agent_session_id=CA1"' in twiml @pytest.mark.asyncio - async def test_host_twiml_options_beats_default_options(self) -> None: - """host_twiml_options sits above default_twiml_options.""" + async def test_default_options_beats_host_twiml_options(self) -> None: + """default_twiml_options sits above host_twiml_options.""" from tac.channels.voice import VoiceChannelConfig tac = TAC(get_test_config()) @@ -1758,8 +1758,8 @@ async def test_host_twiml_options_beats_default_options(self) -> None: host_twiml_options=TwiMLOptions(welcome_greeting="Host override"), ) - assert 'welcomeGreeting="Host override"' in twiml - assert "Channel default" not in twiml + assert 'welcomeGreeting="Channel default"' in twiml + assert "Host override" not in twiml class TestStaticTwiMLOptions: