From 2b240e302c1eabcf25f131a4095e3b4b77e17a92 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 11:47:55 -0400 Subject: [PATCH 01/32] feat(voice): expose full TwiML customization via resolver + widened options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased from twilio-innovation#74 onto current main. Conflict resolutions reconcile with post-fork changes: memory_mode, TwiML signature validation, the newer ConversationRelayCallbackPayload, and studio_voice_handoff_url moving from the server into the voice channel. Widens TwiMLOptions to cover voice, language, transcription_provider, tts_provider, interruptible, dtmf_detection, debug, and adds children via LanguageConfig. websocket_url defaults to "" so the server can fill it in. Adds VoiceChannelConfig.resolve_twiml_options — an optional async callable receiving a framework-neutral TwiMLRequestContext (parsed Twilio webhook fields) and returning TwiMLOptions overrides per call. handle_incoming_call merge precedence (highest to lowest): 1. Resolver output (when configured and request_context given) 2. Caller-supplied options (typically server per-request defaults) 3. TAC defaults: welcome_greeting, conversation_configuration, and action_url resolved via Studio handoff flow if configured, else default_action_url. Only fields explicitly set at a layer (model_fields_set) override lower layers. action_url resolution moved from TACFastAPIServer into VoiceChannel._resolve_action_url so framework-agnostic callers get the Studio-handoff branching for free. In orchestrated mode the server passes default_action_url=None (preserves existing behavior of omitting ); in relay-only mode the server passes its callback URL. Adds getting_started/examples/features/twiml_customization.py showing per-call voice/language selection by caller country with children. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../examples/features/twiml_customization.py | 82 ++++++ src/tac/channels/voice/channel.py | 108 +++++-- src/tac/channels/voice/config.py | 16 + src/tac/channels/voice/twiml.py | 58 ++-- src/tac/models/voice.py | 138 +++++++-- src/tac/server/fastapi_server.py | 53 ++-- tests/test_server.py | 53 +++- tests/test_voice_channel.py | 278 ++++++++++++++++++ tests/test_voice_models.py | 73 +++++ 9 files changed, 771 insertions(+), 88 deletions(-) create mode 100644 getting_started/examples/features/twiml_customization.py diff --git a/getting_started/examples/features/twiml_customization.py b/getting_started/examples/features/twiml_customization.py new file mode 100644 index 0000000..9405a05 --- /dev/null +++ b/getting_started/examples/features/twiml_customization.py @@ -0,0 +1,82 @@ +""" +Feature: Per-call TwiML customization + +Demonstrates using ``VoiceChannelConfig.resolve_twiml_options`` to tailor the +ConversationRelay TwiML on every incoming voice call. The resolver receives a +``TwiMLRequestContext`` parsed from the Twilio webhook (``From``, ``To``, +``CallerCountry``, etc.) and returns ``TwiMLOptions`` overrides. Any field the +resolver explicitly sets replaces TAC's default; everything else (websocket +URL, ``action_url``, ``conversation_configuration``, welcome greeting) +continues to come from TAC/server config. + +This example picks voice and language based on the caller's country and adds +```` children so the caller can switch mid-call. For static +customization (same settings on every call), return constant ``TwiMLOptions`` +from the resolver — no request inspection needed. +""" + +from dotenv import load_dotenv + +from tac import TAC, TACConfig +from tac.channels.voice import VoiceChannel, VoiceChannelConfig +from tac.models.session import ConversationSession +from tac.models.tac import TACMemoryResponse +from tac.models.voice import LanguageConfig, TwiMLOptions, TwiMLRequestContext +from tac.server import TACFastAPIServer + +load_dotenv() + +tac = TAC(config=TACConfig.from_env()) + + +async def handle_message_ready( + user_message: str, + context: ConversationSession, + memory_response: TACMemoryResponse | None, +) -> str: + return f"You said: {user_message}" + + +tac.on_message_ready(handle_message_ready) + + +async def resolve_twiml(ctx: TwiMLRequestContext) -> TwiMLOptions: + """Return TwiMLOptions overrides for this incoming call. + + Only set the fields you want to override — TAC fills in the rest + (websocket URL, action URL, conversation configuration, welcome greeting). + """ + if ctx.caller_country == "MX": + primary_language = "es-MX" + primary_voice = "es-MX-Neural2-A" + welcome = "¡Hola! ¿En qué puedo ayudarte?" + elif ctx.caller_country == "FR": + primary_language = "fr-FR" + primary_voice = "fr-FR-Neural2-A" + welcome = "Bonjour ! Comment puis-je vous aider ?" + else: + primary_language = "en-US" + primary_voice = "en-US-Journey-D" + welcome = "Hello! How can I help?" + + return TwiMLOptions( + language=primary_language, + voice=primary_voice, + tts_provider="google", + transcription_provider="deepgram", + interruptible="speech", + welcome_greeting=welcome, + # children let the caller switch languages mid-call + languages=[ + LanguageConfig(code="en-US", voice="en-US-Journey-D", tts_provider="google"), + LanguageConfig(code="es-MX", voice="es-MX-Neural2-A", tts_provider="google"), + ], + ) + + +voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolve_twiml)) + + +if __name__ == "__main__": + server = TACFastAPIServer(tac=tac, voice_channel=voice_channel) + server.start() diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index f0160d1..d9e0515 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -22,8 +22,10 @@ PromptMessage, SetupMessage, TwiMLOptions, + TwiMLRequestContext, ) from tac.session import SessionState +from tac.tools.handoff import studio_voice_handoff_url from tac.utils.redaction import mask_phone from . import twiml @@ -74,6 +76,7 @@ def __init__( super().__init__(tac, memory_mode=config.memory_mode) self.session_manager = config.session_manager + self._resolve_twiml_options = config.resolve_twiml_options self._websocket_manager = WebSocketManager() self._twilio_client: Client | None = None @@ -98,52 +101,99 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, options: TwiMLOptions | dict[str, Any], + default_action_url: str | None = None, + request_context: TwiMLRequestContext | None = None, ) -> str: """ Generate TwiML response for incoming voice calls. ConversationRelay automatically handles conversation creation and participant - management via the conversation_configuration parameter. + management via the ``conversation_configuration`` parameter. + + Merge precedence (highest to lowest): + + 1. Output of ``VoiceChannelConfig.resolve_twiml_options`` if configured and + ``request_context`` is given. Fields the resolver explicitly sets win. + 2. ``options`` supplied by the caller (typically the server's per-request + defaults — websocket URL, server ``welcome_greeting``). + 3. TAC defaults: ``welcome_greeting``, ``conversation_configuration`` from + ``TACConfig``, and ``action_url`` resolved via Studio handoff flow if + ``studio_handoff_flow_sid`` is set, otherwise ``default_action_url``. + + Lists (``languages``) and nested models (``custom_parameters``) replace + wholesale when set at a higher-priority layer. Args: - options: TwiML generation options (TwiMLOptions or dict) containing: - - websocket_url (required): WebSocket URL for ConversationRelay - - custom_parameters (optional): Additional custom parameters - - welcome_greeting (optional): Initial greeting message - - action_url (optional): URL for call completion webhook + options: TwiML generation options (TwiMLOptions or dict). + default_action_url: Fallback ``action_url`` used when no layer sets one + and ``studio_handoff_flow_sid`` isn't configured. + request_context: Parsed Twilio webhook fields. Passed to the resolver + if one is configured on the channel. Returns: TwiML XML string for call connection - - Example: - >>> twiml = await voice_channel.handle_incoming_call( - ... options={ - ... "websocket_url": "wss://example.com/ws", - ... "custom_parameters": {"session_id": "sess_123"}, - ... "welcome_greeting": "Hello!", - ... "action_url": "https://example.com/callback", - ... }, - ... ) """ # Handle dict input (convert to TwiMLOptions) if isinstance(options, dict): options = TwiMLOptions(**options) - # Set default welcome greeting if not provided - if options.welcome_greeting is None: - options.welcome_greeting = "Hello! How can I assist you today?" - - # ConversationRelay automatically creates conversation and participants - return twiml.generate_twiml( - TwiMLOptions( - websocket_url=options.websocket_url, - custom_parameters=options.custom_parameters, - welcome_greeting=options.welcome_greeting, - action_url=options.action_url, - conversation_configuration=self.tac.config.conversation_configuration_id, - ) + # Invoke the resolver if configured and we have a request context. + resolver_output: TwiMLOptions | None = None + if self._resolve_twiml_options is not None and request_context is not None: + resolver_output = await self._resolve_twiml_options(request_context) + + # Start from TAC defaults. + merged = TwiMLOptions( + welcome_greeting="Hello! How can I assist you today?", + conversation_configuration=self.tac.config.conversation_configuration_id, + action_url=self._resolve_action_url(options, resolver_output, default_action_url), ) + # Overlay caller-supplied options (typically server per-request defaults). + self._overlay_fields(merged, options) + + # Overlay resolver output (highest priority). + if resolver_output is not None: + self._overlay_fields(merged, resolver_output) + + return twiml.generate_twiml(merged) + + @staticmethod + def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: + """Apply fields explicitly set on ``source`` onto ``target``. + + ``action_url`` is handled separately by ``_resolve_action_url``. + """ + for field in source.model_fields_set: + if field == "action_url": + continue + setattr(target, field, getattr(source, field)) + + def _resolve_action_url( + self, + options: TwiMLOptions, + resolver_output: TwiMLOptions | None, + default_action_url: str | None, + ) -> str | None: + """Resolve the TwiML ```` URL. + + Precedence: resolver → caller options → Studio handoff (if configured) → default. + """ + if ( + resolver_output is not None + and "action_url" in resolver_output.model_fields_set + and resolver_output.action_url is not None + ): + return resolver_output.action_url + if "action_url" in options.model_fields_set and options.action_url is not None: + return options.action_url + if self.tac.config.studio_handoff_flow_sid: + return studio_voice_handoff_url( + self.tac.config.account_sid, + self.tac.config.studio_handoff_flow_sid, + ) + return default_action_url + async def handle_conversation_relay_callback( self, payload_dict: dict[str, str], diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index 446a789..f0da2b3 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -1,10 +1,15 @@ """Voice channel configuration.""" +from collections.abc import Awaitable, Callable + from pydantic import BaseModel, Field from tac.models.memory import MemoryMode +from tac.models.voice import TwiMLOptions, TwiMLRequestContext from tac.session import SessionManager, ThreadSafeSessionManager +TwiMLOptionsResolver = Callable[[TwiMLRequestContext], Awaitable[TwiMLOptions]] + class VoiceChannelConfig(BaseModel): """ @@ -19,6 +24,11 @@ class VoiceChannelConfig(BaseModel): - "once": Retrieve memory once at conversation start with empty query and cache it. Cache is invalidated when conversation becomes INACTIVE. - "never": Skip memory retrieval + resolve_twiml_options: Optional async callable that customizes the + ConversationRelay TwiML per call. Receives a framework-neutral + ``TwiMLRequestContext`` (parsed Twilio webhook fields) and returns + ``TwiMLOptions`` overrides. Fields the resolver explicitly sets + override TAC defaults; unset fields keep TAC's defaults. """ model_config = {"arbitrary_types_allowed": True} @@ -34,3 +44,9 @@ class VoiceChannelConfig(BaseModel): default="never", description="Memory retrieval mode for this channel", ) + resolve_twiml_options: TwiMLOptionsResolver | None = Field( + default=None, + description="Optional async callable returning TwiMLOptions overrides per call. " + "Receives a TwiMLRequestContext and returns TwiMLOptions; only fields explicitly " + "set on the returned options override TAC defaults.", + ) diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index 256a6a5..9ef03e6 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -47,41 +47,53 @@ def generate_twiml( if isinstance(options, dict): options = TwiMLOptions(**options) - websocket_url = options.websocket_url - custom_parameters = options.custom_parameters - welcome_greeting = options.welcome_greeting - action_url = options.action_url - conversation_configuration = options.conversation_configuration - # Create VoiceResponse response = VoiceResponse() # Create Connect verb with optional action connect_kwargs: dict[str, str] = {} - if action_url: - connect_kwargs["action"] = action_url + if options.action_url: + connect_kwargs["action"] = options.action_url connect = response.connect(**connect_kwargs) - # Build ConversationRelay kwargs - relay_kwargs: dict[str, str] = {"url": websocket_url} - if welcome_greeting: - relay_kwargs["welcome_greeting"] = welcome_greeting - if conversation_configuration: - relay_kwargs["conversation_configuration"] = conversation_configuration + # 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": options.websocket_url} + optional_attrs = ( + "welcome_greeting", + "conversation_configuration", + "voice", + "language", + "transcription_provider", + "tts_provider", + "interruptible", + "dtmf_detection", + "debug", + ) + for attr in optional_attrs: + value = getattr(options, attr) + if value is not None: + relay_kwargs[attr] = value - # Create ConversationRelay relay = connect.conversation_relay(**relay_kwargs) - # Add custom parameters - if custom_parameters: - # Handle both Pydantic model and dict + # Emit children, if any + if options.languages: + for lang in options.languages: + lang_kwargs: dict[str, Any] = {"code": lang.code} + for attr in ("voice", "tts_provider", "transcription_provider"): + value = getattr(lang, attr) + if value is not None: + lang_kwargs[attr] = value + relay.language(**lang_kwargs) + + # Add custom parameters as children + if options.custom_parameters: params_dict: dict[str, Any] = ( - custom_parameters.model_dump(by_alias=True, exclude_none=True) - if isinstance(custom_parameters, BaseModel) - else custom_parameters + options.custom_parameters.model_dump(by_alias=True, exclude_none=True) + if isinstance(options.custom_parameters, BaseModel) + else options.custom_parameters ) - - # Add each parameter as a child element for name, value in params_dict.items(): if value is not None: relay.parameter(name=name, value=str(value)) diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index ee4d44f..72d4bbd 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -95,10 +95,74 @@ class InterruptMessage(BaseModel): VoiceMessage = SetupMessage | PromptMessage | InterruptMessage +class ConversationRelayCallbackPayload(BaseModel): + """ + Payload received from Twilio ConversationRelay callback webhook. + + Sent when a ConversationRelay session ends or transitions state. + """ + + account_sid: str = Field(..., alias="AccountSid", description="Twilio Account SID") + call_sid: str = Field(..., alias="CallSid", description="Twilio Call SID") + call_status: str = Field( + ..., + alias="CallStatus", + description="Call status (e.g., 'in-progress', 'completed', 'busy', 'no-answer')", + ) + from_number: str = Field(..., alias="From", description="Caller's identifier") + to_number: str = Field(..., alias="To", description="Recipient's identifier") + direction: str = Field(..., alias="Direction", description="Call direction (inbound/outbound)") + application_sid: str | None = Field( + None, alias="ApplicationSid", description="Twilio Application SID" + ) + session_id: str | None = Field( + None, alias="SessionId", description="ConversationRelay Session ID" + ) + session_status: str | None = Field( + None, + alias="SessionStatus", + description="ConversationRelay session status (e.g., 'ended')", + ) + session_duration: str | None = Field( + None, alias="SessionDuration", description="Session duration in seconds" + ) + + model_config = {"populate_by_name": True} + + +class LanguageConfig(BaseModel): + """A single ```` child for multi-language ConversationRelay setups. + + Maps to the ```` element documented at + https://www.twilio.com/docs/voice/twiml/connect/conversationrelay#language-element + """ + + code: str = Field(..., description="Language code, e.g. 'es-MX'") + voice: str | None = Field(None, description="TTS voice name for this language") + tts_provider: str | None = Field(None, description="TTS provider, e.g. 'google'") + transcription_provider: str | None = Field( + None, description="Transcription provider, e.g. 'deepgram'" + ) + + model_config = {"populate_by_name": True} + + class TwiMLOptions(BaseModel): - """Options for generating ConversationRelay TwiML.""" + """Options for generating ConversationRelay TwiML. + + Fields map to the attributes documented at + https://www.twilio.com/docs/voice/twiml/connect/conversationrelay . + All fields except ``websocket_url`` are optional. ``VoiceChannel.handle_incoming_call`` + merges these values over TAC defaults using Pydantic's ``model_fields_set`` — + only fields explicitly set by the caller override TAC's defaults. + """ - websocket_url: str = Field(..., description="WebSocket URL for ConversationRelay") + websocket_url: str = Field( + default="", + description="WebSocket URL for ConversationRelay. Usually set by the server " + "from TACServerConfig.public_domain — resolvers and other overlay callers " + "can leave it empty and the server/channel will fill it in.", + ) custom_parameters: CustomParameters | dict[str, Any] | None = Field( None, description="Custom parameters to pass to ConversationRelay", @@ -116,26 +180,66 @@ class TwiMLOptions(BaseModel): description="Conversation Service SID for ConversationRelay to automatically " "manage conversation creation and participants.", ) + voice: str | None = Field(None, description="Default TTS voice name") + language: str | None = Field(None, description="Default language code, e.g. 'en-US'") + transcription_provider: str | None = Field( + None, description="Default transcription provider, e.g. 'deepgram'" + ) + tts_provider: str | None = Field(None, description="Default TTS provider, e.g. 'elevenlabs'") + interruptible: str | bool | None = Field( + None, description="Interruption behavior (e.g. 'speech', 'dtmf', 'any', True/False)" + ) + dtmf_detection: bool | None = Field(None, description="Enable DTMF detection") + debug: str | None = Field( + None, description="Debug flags, e.g. 'speaker-events' or comma-separated list" + ) + languages: list[LanguageConfig] | None = Field( + None, description="Additional children for multi-language support" + ) model_config = {"populate_by_name": True} -class ConversationRelayCallbackPayload(BaseModel): - """Payload received from Twilio ConversationRelay callback webhook. +class TwiMLRequestContext(BaseModel): + """Framework-neutral view of the Twilio TwiML webhook form. - Sent via the URL when a call ends or transitions state. - Used in relay-only mode to signal conversation completion. + Populated by ``TACFastAPIServer`` from the incoming Twilio webhook, then + passed to an optional ``resolve_twiml_options`` so the application can + produce per-call ``TwiMLOptions`` overrides without depending on FastAPI + types. """ - account_sid: str = Field(..., alias="AccountSid") - call_sid: str = Field(..., alias="CallSid") - call_status: str = Field(..., alias="CallStatus") - from_number: str = Field(..., alias="From") - to_number: str = Field(..., alias="To") - direction: str = Field(..., alias="Direction") - application_sid: str | None = Field(None, alias="ApplicationSid") - session_id: str | None = Field(None, alias="SessionId") - session_status: str | None = Field(None, alias="SessionStatus") - session_duration: str | None = Field(None, alias="SessionDuration") + from_number: str | None = Field(None, alias="From") + to_number: str | None = Field(None, alias="To") + call_sid: str | None = Field(None, alias="CallSid") + caller_country: str | None = Field(None, alias="CallerCountry") + caller_state: str | None = Field(None, alias="CallerState") + caller_city: str | None = Field(None, alias="CallerCity") + direction: str | None = Field(None, alias="Direction") + extra: dict[str, str] = Field( + default_factory=dict, + description="Any other fields from the Twilio webhook not captured above", + ) - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "ignore"} + + @classmethod + def from_form(cls, form: dict[str, str]) -> "TwiMLRequestContext": + """Build a context from a raw Twilio form dict, bucketing unknown keys into ``extra``.""" + known_aliases = { + "From", + "To", + "CallSid", + "CallerCountry", + "CallerState", + "CallerCity", + "Direction", + } + known: dict[str, str] = {} + extra: dict[str, str] = {} + for key, value in form.items(): + if key in known_aliases: + known[key] = value + else: + extra[key] = value + return cls(**known, extra=extra) diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 452caa1..fc49590 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -16,8 +16,8 @@ from tac.channels.websocket_protocol import WebSocketDisconnectError from tac.core.logging import get_logger from tac.core.tac import TAC +from tac.models.voice import TwiMLOptions, TwiMLRequestContext from tac.server.config import TACServerConfig -from tac.tools.handoff import studio_voice_handoff_url if TYPE_CHECKING: from tac.channels.messaging import MessagingChannel @@ -82,6 +82,11 @@ class TACFastAPIServer: - Or mutate ``server.app`` after construction: add middleware, exception handlers, routers, or custom routes — before calling ``start()``. + - To customize TwiML attributes (voice, language, transcription provider, + interruption behavior, ```` children, etc.) set a + ``resolve_twiml_options`` on ``VoiceChannelConfig``. The resolver + receives a framework-neutral ``TwiMLRequestContext`` and returns a + ``TwiMLOptions``; any field it explicitly sets overrides TAC defaults. Example: from fastapi import FastAPI @@ -188,27 +193,39 @@ async def conversation_webhook(request: Request) -> JSONResponse: ) @app.post(config.twiml_path, dependencies=[Depends(http_sig)]) - async def post_twiml() -> Response: + async def post_twiml(request: Request) -> Response: """Generate TwiML for incoming voice calls.""" websocket_url = f"wss://{config.public_domain}{config.websocket_path}" - if self.tac.config.studio_handoff_flow_sid: - action_url = studio_voice_handoff_url( - self.tac.config.account_sid, - self.tac.config.studio_handoff_flow_sid, - ) - elif not self.tac.is_orchestrator_enabled(): - action_url = ( - f"https://{config.public_domain}{config.conversation_relay_callback_path}" - ) - else: - action_url = None + # In relay-only mode the server owns the call-ended callback so + # sessions get cleaned up. In orchestrated mode CO manages + # lifecycle, so no default action_url is needed. The channel + # will override this with the Studio handoff URL if + # studio_handoff_flow_sid is set. + default_action_url = ( + f"https://{config.public_domain}{config.conversation_relay_callback_path}" + if not self.tac.is_orchestrator_enabled() + else None + ) + + # Server-owned defaults: websocket URL and the welcome greeting from + # TACServerConfig. The channel applies TAC defaults under these, and + # any VoiceChannelConfig.resolve_twiml_options output over them. + options = TwiMLOptions( + websocket_url=websocket_url, + welcome_greeting=config.welcome_greeting, + ) + + try: + form = await request.form() + form_dict = {k: str(v) for k, v in form.items()} + except Exception: + form_dict = {} + request_context = TwiMLRequestContext.from_form(form_dict) twiml = await vc.handle_incoming_call( - options={ - "websocket_url": websocket_url, - "action_url": action_url, - "welcome_greeting": config.welcome_greeting, - }, + options, + default_action_url=default_action_url, + request_context=request_context, ) return Response(content=twiml, media_type="application/xml") diff --git a/tests/test_server.py b/tests/test_server.py index e41e9e4..147a661 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -579,7 +579,7 @@ def test_connect_action_omitted_when_no_handoff_flow(self) -> None: ) assert resp.status_code == 200 - # No action URL when no handoff flow configured + # No action URL when no handoff flow configured (orchestrated mode) assert "" in resp.text assert "action=" not in resp.text @@ -791,3 +791,54 @@ def test_websocket_rejects_invalid_signature(self) -> None: with pytest.raises(WebSocketDisconnect): with client.websocket_connect("/ws", headers={"X-Twilio-Signature": "invalid"}): pass + + +class TestTwiMLResolverEndToEnd: + """Smoke test: server parses Twilio form and channel resolver shapes TwiML.""" + + def test_resolver_on_voice_channel_receives_parsed_context_and_overrides_twiml( + self, + ) -> None: + from fastapi.testclient import TestClient + + from tac.channels.voice import VoiceChannel, VoiceChannelConfig + from tac.models.voice import TwiMLOptions, TwiMLRequestContext + from tac.server import TACFastAPIServer + + captured: dict[str, TwiMLRequestContext] = {} + + async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + captured["ctx"] = ctx + return TwiMLOptions(voice="en-US-Journey-D", language="en-US") + + tac = TAC(get_test_config()) + vc = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + server = TACFastAPIServer( + tac=tac, + config=TACServerConfig(public_domain="test.ngrok.io"), + voice_channel=vc, + ) + client = TestClient(server.app) + form_data = { + "From": "+14155551234", + "To": "+15551234567", + "CallerCountry": "US", + "ApiVersion": "2010-04-01", + } + signature = compute_signature("http://testserver/twiml", form_data) + resp = client.post( + "/twiml", + data=form_data, + headers={"X-Twilio-Signature": signature}, + ) + assert resp.status_code == 200 + # Resolver saw the parsed context with known fields + extras. + ctx = captured["ctx"] + assert ctx.from_number == "+14155551234" + assert ctx.caller_country == "US" + assert ctx.extra == {"ApiVersion": "2010-04-01"} + # Resolver output flowed through to TwiML; server defaults still present. + body = resp.text + assert 'voice="en-US-Journey-D"' in body + assert 'language="en-US"' in body + assert 'url="wss://test.ngrok.io/ws"' in body diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index d3f1501..f61d95f 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1199,6 +1199,284 @@ async def test_handle_incoming_call_without_additional_parameters(self) -> None: assert "user_language" not in twiml +class TestGenerateTwiMLConversationRelayAttrs: + """The widened TwiMLOptions surface should emit every documented attribute.""" + + def test_voice_and_language_attrs(self) -> None: + twiml = generate_twiml( + TwiMLOptions( + websocket_url="wss://example.com/ws", + voice="en-US-Journey-D", + language="en-US", + transcription_provider="deepgram", + tts_provider="elevenlabs", + ) + ) + assert 'voice="en-US-Journey-D"' in twiml + assert 'language="en-US"' in twiml + assert 'transcriptionProvider="deepgram"' in twiml + assert 'ttsProvider="elevenlabs"' in twiml + + def test_interruptible_dtmf_debug(self) -> None: + twiml = generate_twiml( + TwiMLOptions( + websocket_url="wss://example.com/ws", + interruptible="speech", + dtmf_detection=True, + debug="speaker-events", + ) + ) + assert 'interruptible="speech"' in twiml + assert 'dtmfDetection="true"' in twiml + assert 'debug="speaker-events"' in twiml + + def test_language_children_emitted(self) -> None: + from tac.models.voice import LanguageConfig + + twiml = generate_twiml( + TwiMLOptions( + websocket_url="wss://example.com/ws", + languages=[ + LanguageConfig( + code="es-MX", + voice="es-MX-Neural2-A", + tts_provider="google", + transcription_provider="google", + ), + LanguageConfig(code="fr-FR"), + ], + ) + ) + assert '' in twiml + + def test_omitted_fields_absent_from_output(self) -> None: + twiml = generate_twiml(TwiMLOptions(websocket_url="wss://example.com/ws")) + for attr in ( + "voice=", + "language=", + "transcriptionProvider=", + "ttsProvider=", + "interruptible=", + "dtmfDetection=", + "debug=", + " None: + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"} + ) + assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml + assert 'conversationConfiguration="conv_configuration_test123"' in twiml + + @pytest.mark.asyncio + async def test_caller_overrides_welcome_greeting(self) -> None: + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={ + "websocket_url": "wss://example.com/ws", + "welcome_greeting": "Hola!", + } + ) + assert 'welcomeGreeting="Hola!"' in twiml + # TAC-provided conversation_configuration still present + assert 'conversationConfiguration="conv_configuration_test123"' in twiml + + @pytest.mark.asyncio + async def test_caller_overrides_conversation_configuration(self) -> None: + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={ + "websocket_url": "wss://example.com/ws", + "conversation_configuration": "conv_configuration_custom", + } + ) + assert 'conversationConfiguration="conv_configuration_custom"' in twiml + assert "conv_configuration_test123" not in twiml + + @pytest.mark.asyncio + async def test_caller_provides_new_conversation_relay_attrs(self) -> None: + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={ + "websocket_url": "wss://example.com/ws", + "voice": "en-US-Journey-D", + "interruptible": "speech", + "dtmf_detection": True, + } + ) + assert 'voice="en-US-Journey-D"' in twiml + assert 'interruptible="speech"' in twiml + assert 'dtmfDetection="true"' in twiml + + @pytest.mark.asyncio + async def test_action_url_caller_wins(self) -> None: + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={ + "websocket_url": "wss://example.com/ws", + "action_url": "https://caller.example.com/end", + }, + default_action_url="https://ignored.example.com/end", + ) + assert 'action="https://caller.example.com/end"' in twiml + + @pytest.mark.asyncio + async def test_action_url_studio_handoff_when_flow_sid_set(self) -> None: + flow_sid = "FW" + "a" * 32 + tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + default_action_url="https://ignored.example.com/end", + ) + expected = ( + f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' + f'/Flows/{flow_sid}?Trigger=incomingCall"' + ) + assert expected in twiml + + @pytest.mark.asyncio + async def test_action_url_falls_back_to_default(self) -> None: + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + default_action_url="https://fallback.example.com/end", + ) + assert 'action="https://fallback.example.com/end"' in twiml + + @pytest.mark.asyncio + async def test_action_url_omitted_when_no_default(self) -> None: + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + ) + assert "action=" not in twiml + + +class TestTwiMLOptionsResolver: + """Resolver layer runs on top of caller-supplied options and TAC defaults.""" + + @pytest.mark.asyncio + async def test_resolver_skipped_without_request_context(self) -> None: + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequestContext + + called = False + + async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + nonlocal called + called = True + return TwiMLOptions(voice="en-US-Journey-D") + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + ) + assert called is False + assert "voice=" not in twiml + + @pytest.mark.asyncio + async def test_resolver_invoked_with_request_context(self) -> None: + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequestContext + + seen: dict[str, TwiMLRequestContext] = {} + + async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + seen["ctx"] = ctx + return TwiMLOptions(voice="en-US-Journey-D", interruptible="speech") + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + ctx = TwiMLRequestContext(from_number="+14155551234", caller_country="US") + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + request_context=ctx, + ) + assert seen["ctx"] is ctx + assert 'voice="en-US-Journey-D"' in twiml + assert 'interruptible="speech"' in twiml + + @pytest.mark.asyncio + async def test_resolver_output_beats_caller_options(self) -> None: + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequestContext + + async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + return TwiMLOptions(welcome_greeting="Hola!") + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + twiml = await channel.handle_incoming_call( + options={ + "websocket_url": "wss://example.com/ws", + "welcome_greeting": "Server default", + }, + request_context=TwiMLRequestContext(), + ) + # Resolver's value wins. + assert 'welcomeGreeting="Hola!"' in twiml + + @pytest.mark.asyncio + async def test_resolver_unset_fields_keep_caller_options(self) -> None: + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequestContext + + async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + return TwiMLOptions(voice="en-US-Journey-D") + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + twiml = await channel.handle_incoming_call( + options={ + "websocket_url": "wss://example.com/ws", + "welcome_greeting": "Server default", + }, + request_context=TwiMLRequestContext(), + ) + # Resolver didn't set welcome_greeting; server's default survives. + assert 'welcomeGreeting="Server default"' in twiml + # Resolver's voice still wins where it did set. + assert 'voice="en-US-Journey-D"' in twiml + + @pytest.mark.asyncio + async def test_resolver_action_url_wins_over_studio_handoff(self) -> None: + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequestContext + + flow_sid = "FW" + "a" * 32 + + async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + return TwiMLOptions(action_url="https://resolver.example.com/end") + + tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) + channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + request_context=TwiMLRequestContext(), + ) + assert 'action="https://resolver.example.com/end"' in twiml + + class TestConversationInitializationFlow: """Test new conversation initialization flow with ConversationRelay.""" diff --git a/tests/test_voice_models.py b/tests/test_voice_models.py index e415e33..d01315a 100644 --- a/tests/test_voice_models.py +++ b/tests/test_voice_models.py @@ -3,8 +3,11 @@ from tac.models.voice import ( CustomParameters, InterruptMessage, + LanguageConfig, PromptMessage, SetupMessage, + TwiMLOptions, + TwiMLRequestContext, ) @@ -212,3 +215,73 @@ def test_interrupt_aliases(self) -> None: assert msg.utterance_until_interrupt == "Test utterance" assert msg.duration_until_interrupt_ms == 2000 + + +class TestTwiMLRequestContext: + """TwiMLRequestContext parses Twilio webhook form fields.""" + + def test_from_form_known_fields(self) -> None: + ctx = TwiMLRequestContext.from_form( + { + "From": "+14155551234", + "To": "+15551234567", + "CallSid": "CA" + "1" * 32, + "CallerCountry": "US", + "CallerState": "CA", + "CallerCity": "San Francisco", + "Direction": "inbound", + } + ) + assert ctx.from_number == "+14155551234" + assert ctx.to_number == "+15551234567" + assert ctx.call_sid == "CA" + "1" * 32 + assert ctx.caller_country == "US" + assert ctx.caller_state == "CA" + assert ctx.caller_city == "San Francisco" + assert ctx.direction == "inbound" + assert ctx.extra == {} + + def test_from_form_unknown_fields_bucketed_into_extra(self) -> None: + ctx = TwiMLRequestContext.from_form( + { + "From": "+14155551234", + "ApiVersion": "2010-04-01", + "ForwardedFrom": "+15559999999", + } + ) + assert ctx.from_number == "+14155551234" + assert ctx.extra == { + "ApiVersion": "2010-04-01", + "ForwardedFrom": "+15559999999", + } + + def test_from_form_empty(self) -> None: + ctx = TwiMLRequestContext.from_form({}) + assert ctx.from_number is None + assert ctx.extra == {} + + +class TestTwiMLOptionsFieldsSet: + """Merge semantics rely on Pydantic's model_fields_set.""" + + def test_unset_scalar_fields_are_none(self) -> None: + options = TwiMLOptions(websocket_url="wss://x") + assert options.voice is None + assert "voice" not in options.model_fields_set + + def test_explicitly_set_fields_tracked(self) -> None: + options = TwiMLOptions( + websocket_url="wss://x", + voice="en-US-Journey-D", + dtmf_detection=False, + ) + assert "voice" in options.model_fields_set + assert "dtmf_detection" in options.model_fields_set + # Unset fields not tracked + assert "interruptible" not in options.model_fields_set + + def test_language_config_optional_fields(self) -> None: + lang = LanguageConfig(code="es-MX") + assert lang.voice is None + assert lang.tts_provider is None + assert lang.transcription_provider is None From 23f67a6d9925384cf4b025b308ac7e1c9eddd5b7 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 12:13:09 -0400 Subject: [PATCH 02/32] =?UTF-8?q?refactor(voice):=20static=20twiml=5Foptio?= =?UTF-8?q?ns=20layer,=20rename=20resolver=20=E2=86=92=20customize,=20fix?= =?UTF-8?q?=20action=5Furl=20precedence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design refinements after PR review: 1. Add `VoiceChannelConfig.twiml_options` for static per-channel TwiML defaults (voice, language, etc.) — no callback needed for the common case of "same ConversationRelay config on every call." 2. Add `VoiceChannelConfig.welcome_greeting` shortcut so the 80% case doesn't require constructing a TwiMLOptions. 3. Rename `resolve_twiml_options` → `customize_twiml_options`. `resolve` was overloaded jargon; `customize` accurately describes what the user's function does (modify defaults, not replace them) and pairs cleanly with the noun field `twiml_options`. 4. Deprecate `TACServerConfig.welcome_greeting` — it belongs on the voice channel, not server plumbing. Emits DeprecationWarning when set, forwards the value to VoiceChannelConfig only when the channel didn't set its own. One release from removal. 5. Fix action_url precedence: resolver → caller → channel static → default → Studio handoff. `default_action_url` now beats Studio handoff so relay-only session cleanup callbacks always fire, even when a Studio flow is configured for handoff in other contexts. New merge precedence in VoiceChannel.handle_incoming_call (highest → lowest): 1. customize_twiml_options output 2. caller-supplied options (server plumbing: websocket_url) 3. VoiceChannelConfig.twiml_options (static) 4. TAC defaults (welcome_greeting, conversation_configuration, action_url) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../examples/features/twiml_customization.py | 27 ++-- src/tac/channels/voice/channel.py | 89 ++++++++---- src/tac/channels/voice/config.py | 37 +++-- src/tac/models/voice.py | 2 +- src/tac/server/config.py | 22 ++- src/tac/server/fastapi_server.py | 24 ++-- tests/test_server.py | 59 ++++++-- tests/test_voice_channel.py | 136 +++++++++++++----- 8 files changed, 287 insertions(+), 109 deletions(-) diff --git a/getting_started/examples/features/twiml_customization.py b/getting_started/examples/features/twiml_customization.py index 9405a05..cd68a92 100644 --- a/getting_started/examples/features/twiml_customization.py +++ b/getting_started/examples/features/twiml_customization.py @@ -1,18 +1,19 @@ """ Feature: Per-call TwiML customization -Demonstrates using ``VoiceChannelConfig.resolve_twiml_options`` to tailor the -ConversationRelay TwiML on every incoming voice call. The resolver receives a -``TwiMLRequestContext`` parsed from the Twilio webhook (``From``, ``To``, -``CallerCountry``, etc.) and returns ``TwiMLOptions`` overrides. Any field the -resolver explicitly sets replaces TAC's default; everything else (websocket -URL, ``action_url``, ``conversation_configuration``, welcome greeting) -continues to come from TAC/server config. +Demonstrates using ``VoiceChannelConfig.customize_twiml_options`` to tailor the +ConversationRelay TwiML on every incoming voice call. The customizer receives a +framework-neutral ``TwiMLRequestContext`` parsed from the Twilio webhook +(``From``, ``To``, ``CallerCountry``, etc.) and returns ``TwiMLOptions`` +overrides. Any field it explicitly sets replaces TAC's defaults; everything +else (websocket URL, ``action_url``, ``conversation_configuration``) continues +to come from TAC config. + +For same-on-every-call customization, set ``twiml_options`` on +``VoiceChannelConfig`` directly — no function needed. This example picks voice and language based on the caller's country and adds -```` children so the caller can switch mid-call. For static -customization (same settings on every call), return constant ``TwiMLOptions`` -from the resolver — no request inspection needed. +```` children so the caller can switch mid-call. """ from dotenv import load_dotenv @@ -40,7 +41,7 @@ async def handle_message_ready( tac.on_message_ready(handle_message_ready) -async def resolve_twiml(ctx: TwiMLRequestContext) -> TwiMLOptions: +async def customize_twiml(ctx: TwiMLRequestContext) -> TwiMLOptions: """Return TwiMLOptions overrides for this incoming call. Only set the fields you want to override — TAC fills in the rest @@ -74,7 +75,9 @@ async def resolve_twiml(ctx: TwiMLRequestContext) -> TwiMLOptions: ) -voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolve_twiml)) +voice_channel = VoiceChannel( + tac, config=VoiceChannelConfig(customize_twiml_options=customize_twiml) +) if __name__ == "__main__": diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index d9e0515..4d9dc02 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -75,8 +75,11 @@ def __init__( config = VoiceChannelConfig() super().__init__(tac, memory_mode=config.memory_mode) + self.config = config self.session_manager = config.session_manager - self._resolve_twiml_options = config.resolve_twiml_options + self._welcome_greeting = config.welcome_greeting + self._static_twiml_options = config.twiml_options + self._customize_twiml_options = config.customize_twiml_options self._websocket_manager = WebSocketManager() self._twilio_client: Client | None = None @@ -100,7 +103,7 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, - options: TwiMLOptions | dict[str, Any], + options: TwiMLOptions | dict[str, Any] | None = None, default_action_url: str | None = None, request_context: TwiMLRequestContext | None = None, ) -> str: @@ -112,49 +115,60 @@ async def handle_incoming_call( Merge precedence (highest to lowest): - 1. Output of ``VoiceChannelConfig.resolve_twiml_options`` if configured and - ``request_context`` is given. Fields the resolver explicitly sets win. + 1. Output of ``VoiceChannelConfig.customize_twiml_options`` if configured + and ``request_context`` is given. Fields it explicitly sets win. 2. ``options`` supplied by the caller (typically the server's per-request - defaults — websocket URL, server ``welcome_greeting``). - 3. TAC defaults: ``welcome_greeting``, ``conversation_configuration`` from - ``TACConfig``, and ``action_url`` resolved via Studio handoff flow if - ``studio_handoff_flow_sid`` is set, otherwise ``default_action_url``. + plumbing — websocket URL). + 3. ``VoiceChannelConfig.twiml_options`` — static per-channel defaults. + 4. TAC defaults: ``welcome_greeting`` from ``VoiceChannelConfig``, + ``conversation_configuration`` from ``TACConfig``, and ``action_url`` + resolved via ``default_action_url`` if set, otherwise via Studio + handoff flow if ``studio_handoff_flow_sid`` is configured. Lists (``languages``) and nested models (``custom_parameters``) replace wholesale when set at a higher-priority layer. Args: - options: TwiML generation options (TwiMLOptions or dict). - default_action_url: Fallback ``action_url`` used when no layer sets one - and ``studio_handoff_flow_sid`` isn't configured. - request_context: Parsed Twilio webhook fields. Passed to the resolver - if one is configured on the channel. + options: TwiML generation options (TwiMLOptions or dict). Typically + used by the server to inject ``websocket_url``. + default_action_url: Fallback ``action_url`` used when no layer sets + one. When passed (e.g. by ``TACFastAPIServer`` in relay-only + mode for session cleanup), it takes precedence over Studio + handoff so the relay callback always fires. + request_context: Parsed Twilio webhook fields. Passed to the + customizer if one is configured on the channel. Returns: TwiML XML string for call connection """ # Handle dict input (convert to TwiMLOptions) - if isinstance(options, dict): + if options is None: + options = TwiMLOptions() + elif isinstance(options, dict): options = TwiMLOptions(**options) - # Invoke the resolver if configured and we have a request context. - resolver_output: TwiMLOptions | None = None - if self._resolve_twiml_options is not None and request_context is not None: - resolver_output = await self._resolve_twiml_options(request_context) + # Invoke the customizer if configured and we have a request context. + customized: TwiMLOptions | None = None + if self._customize_twiml_options is not None and request_context is not None: + customized = await self._customize_twiml_options(request_context) # Start from TAC defaults. merged = TwiMLOptions( - welcome_greeting="Hello! How can I assist you today?", + welcome_greeting=self._welcome_greeting, conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_action_url(options, resolver_output, default_action_url), + action_url=self._resolve_action_url(options, customized, default_action_url), ) - # Overlay caller-supplied options (typically server per-request defaults). + # Overlay channel-static twiml_options. + if self._static_twiml_options is not None: + self._overlay_fields(merged, self._static_twiml_options) + + # Overlay caller-supplied options (server per-request plumbing). self._overlay_fields(merged, options) - # Overlay resolver output (highest priority). - if resolver_output is not None: - self._overlay_fields(merged, resolver_output) + # Overlay customizer output (highest priority). + if customized is not None: + self._overlay_fields(merged, customized) return twiml.generate_twiml(merged) @@ -172,27 +186,40 @@ def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: def _resolve_action_url( self, options: TwiMLOptions, - resolver_output: TwiMLOptions | None, + customized: TwiMLOptions | None, default_action_url: str | None, ) -> str | None: """Resolve the TwiML ```` URL. - Precedence: resolver → caller options → Studio handoff (if configured) → default. + Precedence: customizer → caller options → channel-static twiml_options + → ``default_action_url`` → Studio handoff (if configured). + + ``default_action_url`` beats Studio handoff because the server passes it + for session cleanup in relay-only mode, and leaking sessions is worse + than skipping the Studio flow for that call. """ if ( - resolver_output is not None - and "action_url" in resolver_output.model_fields_set - and resolver_output.action_url is not None + customized is not None + and "action_url" in customized.model_fields_set + and customized.action_url is not None ): - return resolver_output.action_url + return customized.action_url if "action_url" in options.model_fields_set and options.action_url is not None: return options.action_url + if ( + self._static_twiml_options is not None + and "action_url" in self._static_twiml_options.model_fields_set + and self._static_twiml_options.action_url is not None + ): + return self._static_twiml_options.action_url + if default_action_url is not None: + return default_action_url if self.tac.config.studio_handoff_flow_sid: return studio_voice_handoff_url( self.tac.config.account_sid, self.tac.config.studio_handoff_flow_sid, ) - return default_action_url + return None async def handle_conversation_relay_callback( self, diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index f0da2b3..8b9709b 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -8,7 +8,7 @@ from tac.models.voice import TwiMLOptions, TwiMLRequestContext from tac.session import SessionManager, ThreadSafeSessionManager -TwiMLOptionsResolver = Callable[[TwiMLRequestContext], Awaitable[TwiMLOptions]] +TwiMLOptionsCustomizer = Callable[[TwiMLRequestContext], Awaitable[TwiMLOptions]] class VoiceChannelConfig(BaseModel): @@ -24,11 +24,18 @@ class VoiceChannelConfig(BaseModel): - "once": Retrieve memory once at conversation start with empty query and cache it. Cache is invalidated when conversation becomes INACTIVE. - "never": Skip memory retrieval - resolve_twiml_options: Optional async callable that customizes the - ConversationRelay TwiML per call. Receives a framework-neutral - ``TwiMLRequestContext`` (parsed Twilio webhook fields) and returns - ``TwiMLOptions`` overrides. Fields the resolver explicitly sets - override TAC defaults; unset fields keep TAC's defaults. + welcome_greeting: Default greeting spoken at the start of every call. + Equivalent to setting ``twiml_options=TwiMLOptions(welcome_greeting=...)`` + but shorter for the common case. + twiml_options: Static ``TwiMLOptions`` applied to every call (voice, + language, transcription provider, ```` children, etc.). + Use this when the same ConversationRelay configuration is correct + for every call. For per-call customization see ``customize_twiml_options``. + customize_twiml_options: Optional async callable producing per-call + ``TwiMLOptions`` overrides. Receives a framework-neutral + ``TwiMLRequestContext`` (parsed Twilio webhook fields). Any field the + function explicitly sets wins over ``twiml_options`` and TAC defaults; + unset fields fall through. """ model_config = {"arbitrary_types_allowed": True} @@ -44,9 +51,19 @@ class VoiceChannelConfig(BaseModel): default="never", description="Memory retrieval mode for this channel", ) - resolve_twiml_options: TwiMLOptionsResolver | None = Field( + welcome_greeting: str = Field( + default="Hello! How can I assist you today?", + description="Default greeting spoken at the start of every call. " + "Shortcut for TwiMLOptions(welcome_greeting=...).", + ) + twiml_options: TwiMLOptions | None = Field( + default=None, + description="Static TwiMLOptions applied to every call. Use for same-on-every-call " + "configuration; use customize_twiml_options for per-call logic.", + ) + customize_twiml_options: TwiMLOptionsCustomizer | None = Field( default=None, - description="Optional async callable returning TwiMLOptions overrides per call. " - "Receives a TwiMLRequestContext and returns TwiMLOptions; only fields explicitly " - "set on the returned options override TAC defaults.", + description="Optional async callable returning per-call TwiMLOptions overrides. " + "Receives a TwiMLRequestContext; only fields explicitly set on the returned " + "options override lower layers.", ) diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 72d4bbd..8a43907 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -204,7 +204,7 @@ class TwiMLRequestContext(BaseModel): """Framework-neutral view of the Twilio TwiML webhook form. Populated by ``TACFastAPIServer`` from the incoming Twilio webhook, then - passed to an optional ``resolve_twiml_options`` so the application can + passed to an optional ``customize_twiml_options`` so the application can produce per-call ``TwiMLOptions`` overrides without depending on FastAPI types. """ diff --git a/src/tac/server/config.py b/src/tac/server/config.py index 37b83a9..cd447ef 100644 --- a/src/tac/server/config.py +++ b/src/tac/server/config.py @@ -1,8 +1,9 @@ """Configuration for TAC server implementations.""" import os +import warnings -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class TACServerConfig(BaseModel): @@ -17,11 +18,24 @@ class TACServerConfig(BaseModel): public_domain: str = Field( default="", description="Public domain for WebSocket URL (e.g., 'example.ngrok.io')" ) - welcome_greeting: str = Field( - default="Hello! How can I assist you today?", - description="Initial greeting message for callers", + welcome_greeting: str | None = Field( + default=None, + description="DEPRECATED: set welcome_greeting on VoiceChannelConfig instead. " + "When set here, it is forwarded to the voice channel as a default; it will be " + "removed in a future release.", ) + @model_validator(mode="after") + def _warn_deprecated_welcome_greeting(self) -> "TACServerConfig": + if self.welcome_greeting is not None: + warnings.warn( + "TACServerConfig.welcome_greeting is deprecated and will be removed " + "in a future release. Set welcome_greeting on VoiceChannelConfig instead.", + DeprecationWarning, + stacklevel=2, + ) + return self + conversation_webhook_path: str = Field( default="/webhook", description="Path for conversation webhook endpoint (for all channels)" ) diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index fc49590..d156b4b 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -84,9 +84,11 @@ class TACFastAPIServer: ``start()``. - To customize TwiML attributes (voice, language, transcription provider, interruption behavior, ```` children, etc.) set a - ``resolve_twiml_options`` on ``VoiceChannelConfig``. The resolver + ``customize_twiml_options`` on ``VoiceChannelConfig``. The customizer receives a framework-neutral ``TwiMLRequestContext`` and returns a ``TwiMLOptions``; any field it explicitly sets overrides TAC defaults. + For same-on-every-call settings, set ``twiml_options`` on + ``VoiceChannelConfig`` directly. Example: from fastapi import FastAPI @@ -117,6 +119,16 @@ def __init__( self.voice_channel = voice_channel self.messaging_channels: list[MessagingChannel] = messaging_channels or [] + # Forward deprecated TACServerConfig.welcome_greeting to the voice channel + # if the channel wasn't given its own. Drop this forwarding when the + # field is removed from TACServerConfig. + if ( + self.config.welcome_greeting is not None + and self.voice_channel is not None + and "welcome_greeting" not in self.voice_channel.config.model_fields_set + ): + self.voice_channel._welcome_greeting = self.config.welcome_greeting + # Gather all channels that need webhook processing self.webhook_channels: list[BaseChannel] = [] if self.voice_channel: @@ -207,13 +219,9 @@ async def post_twiml(request: Request) -> Response: else None ) - # Server-owned defaults: websocket URL and the welcome greeting from - # TACServerConfig. The channel applies TAC defaults under these, and - # any VoiceChannelConfig.resolve_twiml_options output over them. - options = TwiMLOptions( - websocket_url=websocket_url, - welcome_greeting=config.welcome_greeting, - ) + # Server-owned plumbing: only websocket_url. Greeting and other + # TwiML attributes live on VoiceChannelConfig. + options = TwiMLOptions(websocket_url=websocket_url) try: form = await request.form() diff --git a/tests/test_server.py b/tests/test_server.py index 147a661..5e93afa 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -35,7 +35,7 @@ def test_defaults(self) -> None: assert config.host == "0.0.0.0" assert config.port == 8000 assert config.public_domain == "example.ngrok.io" - assert config.welcome_greeting == "Hello! How can I assist you today?" + assert config.welcome_greeting is None assert config.conversation_webhook_path == "/webhook" assert config.twiml_path == "/twiml" assert config.websocket_path == "/ws" @@ -793,10 +793,10 @@ def test_websocket_rejects_invalid_signature(self) -> None: pass -class TestTwiMLResolverEndToEnd: - """Smoke test: server parses Twilio form and channel resolver shapes TwiML.""" +class TestTwiMLCustomizerEndToEnd: + """Smoke test: server parses Twilio form and channel customizer shapes TwiML.""" - def test_resolver_on_voice_channel_receives_parsed_context_and_overrides_twiml( + def test_customizer_on_voice_channel_receives_parsed_context_and_overrides_twiml( self, ) -> None: from fastapi.testclient import TestClient @@ -807,12 +807,12 @@ def test_resolver_on_voice_channel_receives_parsed_context_and_overrides_twiml( captured: dict[str, TwiMLRequestContext] = {} - async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: captured["ctx"] = ctx return TwiMLOptions(voice="en-US-Journey-D", language="en-US") tac = TAC(get_test_config()) - vc = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + vc = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) server = TACFastAPIServer( tac=tac, config=TACServerConfig(public_domain="test.ngrok.io"), @@ -832,13 +832,56 @@ async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: headers={"X-Twilio-Signature": signature}, ) assert resp.status_code == 200 - # Resolver saw the parsed context with known fields + extras. ctx = captured["ctx"] assert ctx.from_number == "+14155551234" assert ctx.caller_country == "US" assert ctx.extra == {"ApiVersion": "2010-04-01"} - # Resolver output flowed through to TwiML; server defaults still present. body = resp.text assert 'voice="en-US-Journey-D"' in body assert 'language="en-US"' in body assert 'url="wss://test.ngrok.io/ws"' in body + + +class TestDeprecatedWelcomeGreetingForwarding: + """TACServerConfig.welcome_greeting is deprecated; verify it still reaches the channel.""" + + def test_deprecated_field_emits_warning(self) -> None: + with pytest.warns(DeprecationWarning, match="welcome_greeting"): + TACServerConfig(public_domain="test.ngrok.io", welcome_greeting="Legacy!") + + def test_forwarded_when_channel_did_not_set_greeting(self) -> None: + from fastapi.testclient import TestClient + + from tac.channels.voice import VoiceChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + vc = VoiceChannel(tac) # no welcome_greeting on channel + with pytest.warns(DeprecationWarning): + server_config = TACServerConfig( + public_domain="test.ngrok.io", welcome_greeting="Legacy!" + ) + server = TACFastAPIServer(tac=tac, config=server_config, voice_channel=vc) + client = TestClient(server.app) + signature = compute_signature("http://testserver/twiml") + resp = client.post("/twiml", headers={"X-Twilio-Signature": signature}) + assert 'welcomeGreeting="Legacy!"' in resp.text + + def test_channel_greeting_wins_over_deprecated_server_field(self) -> None: + from fastapi.testclient import TestClient + + from tac.channels.voice import VoiceChannel, VoiceChannelConfig + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + vc = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Channel!")) + with pytest.warns(DeprecationWarning): + server_config = TACServerConfig( + public_domain="test.ngrok.io", welcome_greeting="Legacy!" + ) + server = TACFastAPIServer(tac=tac, config=server_config, voice_channel=vc) + client = TestClient(server.app) + signature = compute_signature("http://testserver/twiml") + resp = client.post("/twiml", headers={"X-Twilio-Signature": signature}) + assert 'welcomeGreeting="Channel!"' in resp.text + assert "Legacy!" not in resp.text diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index f61d95f..b6c3289 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1337,13 +1337,13 @@ async def test_action_url_caller_wins(self) -> None: assert 'action="https://caller.example.com/end"' in twiml @pytest.mark.asyncio - async def test_action_url_studio_handoff_when_flow_sid_set(self) -> None: + async def test_action_url_studio_handoff_when_flow_sid_set_and_no_default(self) -> None: + """Studio handoff URL is the last-resort fallback when no default is passed.""" flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( options={"websocket_url": "wss://example.com/ws"}, - default_action_url="https://ignored.example.com/end", ) expected = ( f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' @@ -1351,6 +1351,20 @@ async def test_action_url_studio_handoff_when_flow_sid_set(self) -> None: ) assert expected in twiml + @pytest.mark.asyncio + async def test_default_action_url_beats_studio_handoff(self) -> None: + """default_action_url wins over Studio handoff so relay-only session cleanup + callbacks aren't silently swallowed by a configured Studio flow.""" + flow_sid = "FW" + "a" * 32 + tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + default_action_url="https://cleanup.example.com/end", + ) + assert 'action="https://cleanup.example.com/end"' in twiml + assert "webhooks.twilio.com" not in twiml + @pytest.mark.asyncio async def test_action_url_falls_back_to_default(self) -> None: tac = TAC(get_test_config()) @@ -1371,23 +1385,71 @@ async def test_action_url_omitted_when_no_default(self) -> None: assert "action=" not in twiml -class TestTwiMLOptionsResolver: - """Resolver layer runs on top of caller-supplied options and TAC defaults.""" +class TestStaticTwiMLOptions: + """VoiceChannelConfig.twiml_options applies to every call without a callback.""" + + @pytest.mark.asyncio + async def test_static_options_applied(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(voice="en-US-Journey-D", language="en-US"), + ), + ) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + ) + assert 'voice="en-US-Journey-D"' in twiml + assert 'language="en-US"' in twiml @pytest.mark.asyncio - async def test_resolver_skipped_without_request_context(self) -> None: + async def test_welcome_greeting_shortcut(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Bonjour!")) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws"}, + ) + assert 'welcomeGreeting="Bonjour!"' in twiml + + @pytest.mark.asyncio + async def test_caller_options_beat_static(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(voice="en-US-Journey-D"), + ), + ) + twiml = await channel.handle_incoming_call( + options={"websocket_url": "wss://example.com/ws", "voice": "es-MX-Neural2-A"}, + ) + assert 'voice="es-MX-Neural2-A"' in twiml + + +class TestCustomizeTwiMLOptions: + """Per-call customizer runs on top of static options and TAC defaults.""" + + @pytest.mark.asyncio + async def test_customizer_skipped_without_request_context(self) -> None: from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequestContext called = False - async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: nonlocal called called = True return TwiMLOptions(voice="en-US-Journey-D") tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) twiml = await channel.handle_incoming_call( options={"websocket_url": "wss://example.com/ws"}, ) @@ -1395,18 +1457,18 @@ async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: assert "voice=" not in twiml @pytest.mark.asyncio - async def test_resolver_invoked_with_request_context(self) -> None: + async def test_customizer_invoked_with_request_context(self) -> None: from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequestContext seen: dict[str, TwiMLRequestContext] = {} - async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: seen["ctx"] = ctx return TwiMLOptions(voice="en-US-Journey-D", interruptible="speech") tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) ctx = TwiMLRequestContext(from_number="+14155551234", caller_country="US") twiml = await channel.handle_incoming_call( options={"websocket_url": "wss://example.com/ws"}, @@ -1417,64 +1479,68 @@ async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: assert 'interruptible="speech"' in twiml @pytest.mark.asyncio - async def test_resolver_output_beats_caller_options(self) -> None: + async def test_customizer_output_beats_static(self) -> None: from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequestContext - async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: - return TwiMLOptions(welcome_greeting="Hola!") + async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: + return TwiMLOptions(voice="es-MX-Neural2-A") tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(voice="en-US-Journey-D"), + customize_twiml_options=customizer, + ), + ) twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.com/ws", - "welcome_greeting": "Server default", - }, + options={"websocket_url": "wss://example.com/ws"}, request_context=TwiMLRequestContext(), ) - # Resolver's value wins. - assert 'welcomeGreeting="Hola!"' in twiml + assert 'voice="es-MX-Neural2-A"' in twiml @pytest.mark.asyncio - async def test_resolver_unset_fields_keep_caller_options(self) -> None: + async def test_customizer_unset_fields_keep_lower_layers(self) -> None: from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequestContext - async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: return TwiMLOptions(voice="en-US-Journey-D") tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + welcome_greeting="Channel default", + customize_twiml_options=customizer, + ), + ) twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.com/ws", - "welcome_greeting": "Server default", - }, + options={"websocket_url": "wss://example.com/ws"}, request_context=TwiMLRequestContext(), ) - # Resolver didn't set welcome_greeting; server's default survives. - assert 'welcomeGreeting="Server default"' in twiml - # Resolver's voice still wins where it did set. + # Customizer didn't set welcome_greeting; channel default survives. + assert 'welcomeGreeting="Channel default"' in twiml assert 'voice="en-US-Journey-D"' in twiml @pytest.mark.asyncio - async def test_resolver_action_url_wins_over_studio_handoff(self) -> None: + async def test_customizer_action_url_wins_over_studio_handoff(self) -> None: from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequestContext flow_sid = "FW" + "a" * 32 - async def resolver(ctx: TwiMLRequestContext) -> TwiMLOptions: - return TwiMLOptions(action_url="https://resolver.example.com/end") + async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: + return TwiMLOptions(action_url="https://customizer.example.com/end") tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) - channel = VoiceChannel(tac, config=VoiceChannelConfig(resolve_twiml_options=resolver)) + channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) twiml = await channel.handle_incoming_call( options={"websocket_url": "wss://example.com/ws"}, request_context=TwiMLRequestContext(), ) - assert 'action="https://resolver.example.com/end"' in twiml + assert 'action="https://customizer.example.com/end"' in twiml class TestConversationInitializationFlow: From 7addb9eeee7982836fb3ba49d3ee85debe31120d Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 12:26:40 -0400 Subject: [PATCH 03/32] refactor(voice): extract VoiceServerURLs; drop websocket_url from TwiMLOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the server → channel boundary explicit. TwiMLOptions was the user-facing TwiML override model but also carried websocket_url with default="" so the server could inject it later — a design lie that would silently produce url="" in TwiML if anyone constructed TwiMLOptions manually. New shape: class VoiceServerURLs(BaseModel): websocket_url: str # required conversation_relay_callback_url: str | None = None # relay-only cleanup async def handle_incoming_call( server_urls: VoiceServerURLs, request_context: TwiMLRequestContext | None = None, ) -> str: ... Server builds VoiceServerURLs from public_domain + path. TwiMLOptions now only holds fields users legitimately set (voice, language, greeting, action_url, etc.). websocket_url is no longer a field on it. generate_twiml takes websocket_url as a separate positional arg. Merge layers collapse from four to three — caller-supplied options is gone: 1. customize_twiml_options output 2. VoiceChannelConfig.twiml_options (static) 3. TAC defaults (welcome_greeting, conversation_configuration, action_url from server_urls.conversation_relay_callback_url or Studio handoff) Custom adapters for other web frameworks (Flask, Django, …) now build VoiceServerURLs themselves instead of reconstructing an f-string for the websocket URL — the server → channel contract is the struct, not a field on an unrelated model. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 65 +++---- src/tac/channels/voice/twiml.py | 49 +++--- src/tac/models/voice.py | 37 +++- src/tac/server/fastapi_server.py | 30 ++-- tests/test_relay_only_mode.py | 15 +- tests/test_voice_channel.py | 282 +++++++++++++----------------- tests/test_voice_models.py | 27 ++- 7 files changed, 246 insertions(+), 259 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 4d9dc02..f22a26d 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -23,6 +23,7 @@ SetupMessage, TwiMLOptions, TwiMLRequestContext, + VoiceServerURLs, ) from tac.session import SessionState from tac.tools.handoff import studio_voice_handoff_url @@ -103,8 +104,7 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, - options: TwiMLOptions | dict[str, Any] | None = None, - default_action_url: str | None = None, + server_urls: VoiceServerURLs, request_context: TwiMLRequestContext | None = None, ) -> str: """ @@ -117,36 +117,28 @@ async def handle_incoming_call( 1. Output of ``VoiceChannelConfig.customize_twiml_options`` if configured and ``request_context`` is given. Fields it explicitly sets win. - 2. ``options`` supplied by the caller (typically the server's per-request - plumbing — websocket URL). - 3. ``VoiceChannelConfig.twiml_options`` — static per-channel defaults. - 4. TAC defaults: ``welcome_greeting`` from ``VoiceChannelConfig``, + 2. ``VoiceChannelConfig.twiml_options`` — static per-channel defaults. + 3. TAC defaults: ``welcome_greeting`` from ``VoiceChannelConfig``, ``conversation_configuration`` from ``TACConfig``, and ``action_url`` - resolved via ``default_action_url`` if set, otherwise via Studio - handoff flow if ``studio_handoff_flow_sid`` is configured. + resolved via ``server_urls.conversation_relay_callback_url`` in + relay-only mode, otherwise via Studio handoff flow if + ``studio_handoff_flow_sid`` is configured. Lists (``languages``) and nested models (``custom_parameters``) replace wholesale when set at a higher-priority layer. Args: - options: TwiML generation options (TwiMLOptions or dict). Typically - used by the server to inject ``websocket_url``. - default_action_url: Fallback ``action_url`` used when no layer sets - one. When passed (e.g. by ``TACFastAPIServer`` in relay-only - mode for session cleanup), it takes precedence over Studio - handoff so the relay callback always fires. + server_urls: Absolute URLs Twilio calls back to. The server (or a + custom adapter) builds these from its public domain. The + ``conversation_relay_callback_url`` is used as the default + ```` in relay-only mode so session + cleanup fires when calls end. request_context: Parsed Twilio webhook fields. Passed to the customizer if one is configured on the channel. Returns: - TwiML XML string for call connection + TwiML XML string for call connection. """ - # Handle dict input (convert to TwiMLOptions) - if options is None: - options = TwiMLOptions() - elif isinstance(options, dict): - options = TwiMLOptions(**options) - # Invoke the customizer if configured and we have a request context. customized: TwiMLOptions | None = None if self._customize_twiml_options is not None and request_context is not None: @@ -156,21 +148,18 @@ async def handle_incoming_call( merged = TwiMLOptions( welcome_greeting=self._welcome_greeting, conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_action_url(options, customized, default_action_url), + action_url=self._resolve_action_url(customized, server_urls), ) # Overlay channel-static twiml_options. if self._static_twiml_options is not None: self._overlay_fields(merged, self._static_twiml_options) - # Overlay caller-supplied options (server per-request plumbing). - self._overlay_fields(merged, options) - # Overlay customizer output (highest priority). if customized is not None: self._overlay_fields(merged, customized) - return twiml.generate_twiml(merged) + return twiml.generate_twiml(server_urls.websocket_url, merged) @staticmethod def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: @@ -185,18 +174,18 @@ def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: def _resolve_action_url( self, - options: TwiMLOptions, customized: TwiMLOptions | None, - default_action_url: str | None, + server_urls: VoiceServerURLs, ) -> str | None: """Resolve the TwiML ```` URL. - Precedence: customizer → caller options → channel-static twiml_options - → ``default_action_url`` → Studio handoff (if configured). + Precedence: customizer → channel-static ``twiml_options`` → + ``server_urls.conversation_relay_callback_url`` (set in relay-only mode) + → Studio handoff (if configured). - ``default_action_url`` beats Studio handoff because the server passes it - for session cleanup in relay-only mode, and leaking sessions is worse - than skipping the Studio flow for that call. + The server's callback URL beats Studio handoff because it is what drives + session cleanup in relay-only mode; leaking sessions is worse than + skipping the Studio flow for that call. """ if ( customized is not None @@ -204,16 +193,14 @@ def _resolve_action_url( and customized.action_url is not None ): return customized.action_url - if "action_url" in options.model_fields_set and options.action_url is not None: - return options.action_url if ( self._static_twiml_options is not None and "action_url" in self._static_twiml_options.model_fields_set and self._static_twiml_options.action_url is not None ): return self._static_twiml_options.action_url - if default_action_url is not None: - return default_action_url + if server_urls.conversation_relay_callback_url is not None: + return server_urls.conversation_relay_callback_url if self.tac.config.studio_handoff_flow_sid: return studio_voice_handoff_url( self.tac.config.account_sid, @@ -439,13 +426,13 @@ async def initiate_outbound_conversation( try: twiml_xml = twiml.generate_twiml( + options.websocket_url, TwiMLOptions( - websocket_url=options.websocket_url, welcome_greeting=options.welcome_greeting, action_url=options.action_url, conversation_configuration=self.tac.config.conversation_configuration_id, custom_parameters=options.custom_parameters, - ) + ), ) client = self._get_twilio_client() diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index 9ef03e6..6d18a32 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -9,45 +9,42 @@ def generate_twiml( - options: TwiMLOptions | dict[str, Any], + websocket_url: str, + options: TwiMLOptions | dict[str, Any] | None = None, ) -> str: """ - Generate TwiML XML for ConversationRelay with custom parameters. + Generate TwiML XML for ConversationRelay. - This is a low-level function for generating TwiML with arbitrary custom - parameters. For automatic conversation creation and participant management, - use VoiceChannel.handle_incoming_call() instead. + This is a low-level function. Most users should call + ``VoiceChannel.handle_incoming_call`` instead — it layers in TAC defaults, + static ``twiml_options`` from ``VoiceChannelConfig``, and any per-call + customizer output. Args: - options: TwiML generation options (TwiMLOptions model or dict with: - - websocket_url (required): WebSocket URL for ConversationRelay - - custom_parameters (optional): Dict of custom parameters - - welcome_greeting (optional): Initial greeting message - - action_url (optional): URL for call end webhook - - conversation_configuration (optional): Conversation Service SID for - automatic conversation creation + websocket_url: Public WebSocket URL for ConversationRelay + (e.g. ``'wss://example.ngrok.app/ws'``). + options: Optional ``TwiMLOptions`` (or dict) with any combination of + custom_parameters, welcome_greeting, action_url, + conversation_configuration, voice, language, transcription_provider, + tts_provider, interruptible, dtmf_detection, debug, or languages. Returns: - TwiML XML string ready to return to Twilio + TwiML XML string ready to return to Twilio. Example: >>> twiml = generate_twiml( - ... { - ... "websocket_url": "wss://example.com/voice", - ... "custom_parameters": { - ... "session_id": "sess_abc123", - ... "user_language": "es", - ... }, - ... "welcome_greeting": "Hello!", - ... "conversation_configuration": "conv_configuration_xxxx", - ... } + ... "wss://example.com/voice", + ... TwiMLOptions( + ... welcome_greeting="Hello!", + ... conversation_configuration="conv_configuration_xxxx", + ... ), ... ) """ - # Handle dict input (convert to TwiMLOptions) - if isinstance(options, dict): + if options is None: + options = TwiMLOptions() + elif isinstance(options, dict): options = TwiMLOptions(**options) - # Create VoiceResponse response = VoiceResponse() # Create Connect verb with optional action @@ -58,7 +55,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": options.websocket_url} + relay_kwargs: dict[str, Any] = {"url": websocket_url} optional_attrs = ( "welcome_greeting", "conversation_configuration", diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 8a43907..4cda619 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -147,22 +147,41 @@ class LanguageConfig(BaseModel): model_config = {"populate_by_name": True} +class VoiceServerURLs(BaseModel): + """Absolute URLs Twilio calls back to for a given voice deployment. + + These are server-owned — they depend on where the app is hosted + (``public_domain`` plus path). The voice channel accepts them as input + rather than building them itself, so adapters for other web frameworks + (FastAPI, Flask, Django, …) can supply their own and the channel stays + framework-agnostic. + """ + + websocket_url: str = Field( + ..., + description="Public WebSocket URL for ConversationRelay, e.g. " + "'wss://example.ngrok.app/ws'.", + ) + conversation_relay_callback_url: str | None = Field( + default=None, + description="Public HTTPS URL for the ConversationRelay action callback. " + "Required in relay-only mode so session cleanup fires when the call ends; " + "ignored in orchestrated mode (Conversation Orchestrator handles lifecycle).", + ) + + model_config = {"populate_by_name": True} + + class TwiMLOptions(BaseModel): """Options for generating ConversationRelay TwiML. Fields map to the attributes documented at https://www.twilio.com/docs/voice/twiml/connect/conversationrelay . - All fields except ``websocket_url`` are optional. ``VoiceChannel.handle_incoming_call`` - merges these values over TAC defaults using Pydantic's ``model_fields_set`` — - only fields explicitly set by the caller override TAC's defaults. + All fields are optional. ``VoiceChannel.handle_incoming_call`` merges these + values over TAC defaults using Pydantic's ``model_fields_set`` — only + fields explicitly set by the caller override TAC's defaults. """ - websocket_url: str = Field( - default="", - description="WebSocket URL for ConversationRelay. Usually set by the server " - "from TACServerConfig.public_domain — resolvers and other overlay callers " - "can leave it empty and the server/channel will fill it in.", - ) custom_parameters: CustomParameters | dict[str, Any] | None = Field( None, description="Custom parameters to pass to ConversationRelay", diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index d156b4b..9b01045 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -16,7 +16,7 @@ from tac.channels.websocket_protocol import WebSocketDisconnectError from tac.core.logging import get_logger from tac.core.tac import TAC -from tac.models.voice import TwiMLOptions, TwiMLRequestContext +from tac.models.voice import TwiMLRequestContext, VoiceServerURLs from tac.server.config import TACServerConfig if TYPE_CHECKING: @@ -207,22 +207,19 @@ async def conversation_webhook(request: Request) -> JSONResponse: @app.post(config.twiml_path, dependencies=[Depends(http_sig)]) async def post_twiml(request: Request) -> Response: """Generate TwiML for incoming voice calls.""" - websocket_url = f"wss://{config.public_domain}{config.websocket_path}" - # In relay-only mode the server owns the call-ended callback so - # sessions get cleaned up. In orchestrated mode CO manages - # lifecycle, so no default action_url is needed. The channel - # will override this with the Studio handoff URL if - # studio_handoff_flow_sid is set. - default_action_url = ( - f"https://{config.public_domain}{config.conversation_relay_callback_path}" - if not self.tac.is_orchestrator_enabled() - else None + # Server-owned URLs Twilio will call back to. In relay-only mode + # the server owns the call-ended callback so sessions get + # cleaned up; in orchestrated mode CO manages lifecycle so the + # callback URL is omitted. + server_urls = VoiceServerURLs( + websocket_url=f"wss://{config.public_domain}{config.websocket_path}", + conversation_relay_callback_url=( + f"https://{config.public_domain}{config.conversation_relay_callback_path}" + if not self.tac.is_orchestrator_enabled() + else None + ), ) - # Server-owned plumbing: only websocket_url. Greeting and other - # TwiML attributes live on VoiceChannelConfig. - options = TwiMLOptions(websocket_url=websocket_url) - try: form = await request.form() form_dict = {k: str(v) for k, v in form.items()} @@ -231,8 +228,7 @@ async def post_twiml(request: Request) -> Response: request_context = TwiMLRequestContext.from_form(form_dict) twiml = await vc.handle_incoming_call( - options, - default_action_url=default_action_url, + server_urls, request_context=request_context, ) return Response(content=twiml, media_type="application/xml") diff --git a/tests/test_relay_only_mode.py b/tests/test_relay_only_mode.py index 9ea021c..05cb039 100644 --- a/tests/test_relay_only_mode.py +++ b/tests/test_relay_only_mode.py @@ -17,10 +17,7 @@ from tac.channels.sms import SMSChannel from tac.channels.voice import VoiceChannel from tac.models.session import ConversationSession -from tac.models.voice import ( - ConversationRelayCallbackPayload, - TwiMLOptions, -) +from tac.models.voice import ConversationRelayCallbackPayload def relay_only_config() -> dict: @@ -90,14 +87,14 @@ async def test_retrieve_memory_returns_empty_in_relay_only(self) -> None: @pytest.mark.asyncio async def test_handle_incoming_call_twiml_omits_conversation_configuration(self) -> None: """TwiML does not include conversationConfiguration in relay-only mode.""" + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import VoiceServerURLs + tac = TAC(relay_only_config()) - channel = VoiceChannel(tac) + channel = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Hello")) twiml = await channel.handle_incoming_call( - TwiMLOptions( - websocket_url="wss://example.com/ws", - welcome_greeting="Hello", - ) + VoiceServerURLs(websocket_url="wss://example.com/ws"), ) assert "conversationConfiguration" not in twiml diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index b6c3289..057a7cf 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -17,6 +17,7 @@ InterruptMessage, PromptMessage, TwiMLOptions, + VoiceServerURLs, ) @@ -451,19 +452,21 @@ async def message_callback( @pytest.mark.asyncio async def test_handle_incoming_call(self) -> None: """Test handle_incoming_call generates valid TwiML with conversation_configuration.""" + from tac.channels.voice import VoiceChannelConfig + tac = TAC(get_test_config()) - channel = VoiceChannel(tac) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig(welcome_greeting="Welcome!"), + ) - # Generate TwiML (no need to mock - ConversationRelay handles conversation creation) twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.ngrok.io/ws", - "action_url": "https://example.ngrok.io/flex_handoff", - "welcome_greeting": "Welcome!", - }, + VoiceServerURLs( + websocket_url="wss://example.ngrok.io/ws", + conversation_relay_callback_url="https://example.ngrok.io/flex_handoff", + ), ) - # Verify TwiML contains expected elements assert '' in twiml assert "" in twiml assert '' in twiml @@ -480,15 +483,13 @@ async def test_handle_incoming_call_default_greeting(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) - # Generate TwiML without custom greeting (uses default) twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://test.ngrok.io/ws", - "action_url": "https://example.ngrok.io/flex_handoff", - }, + VoiceServerURLs( + websocket_url="wss://test.ngrok.io/ws", + conversation_relay_callback_url="https://example.ngrok.io/flex_handoff", + ), ) - # Verify default greeting is used assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml assert 'conversationConfiguration="conv_configuration_test123"' in twiml @@ -969,7 +970,7 @@ async def stream_response() -> AsyncGenerator[str, None]: def test_generate_twiml_minimal(self) -> None: """Test TwiML generation with only websocket URL.""" - twiml = generate_twiml(TwiMLOptions(websocket_url="wss://example.com/voice")) + twiml = generate_twiml("wss://example.com/voice") assert '' in twiml assert "" in twiml @@ -983,10 +984,10 @@ def test_generate_twiml_minimal(self) -> None: def test_generate_twiml_with_welcome_greeting(self) -> None: """Test TwiML generation with welcome greeting.""" twiml = generate_twiml( + "wss://example.com/voice", TwiMLOptions( - websocket_url="wss://example.com/voice", welcome_greeting="Hello! How can I help you?", - ) + ), ) assert 'welcomeGreeting="Hello! How can I help you?"' in twiml @@ -994,10 +995,10 @@ def test_generate_twiml_with_welcome_greeting(self) -> None: def test_generate_twiml_with_action_url(self) -> None: """Test TwiML generation with action URL.""" twiml = generate_twiml( + "wss://example.com/voice", TwiMLOptions( - websocket_url="wss://example.com/voice", action_url="https://example.com/callback", - ) + ), ) assert '' in twiml @@ -1005,15 +1006,15 @@ def test_generate_twiml_with_action_url(self) -> None: def test_generate_twiml_with_standard_custom_parameters(self) -> None: """Test TwiML generation with standard TAC custom parameters.""" twiml = generate_twiml( + "wss://example.com/voice", TwiMLOptions( - websocket_url="wss://example.com/voice", custom_parameters={ "conversationId": "CH123", "profileId": "mem_profile_123", "customerParticipantId": "PA_cust", "aiAgentParticipantId": "PA_agent", }, - ) + ), ) assert '' in twiml @@ -1024,14 +1025,14 @@ def test_generate_twiml_with_standard_custom_parameters(self) -> None: def test_generate_twiml_with_arbitrary_custom_parameters(self) -> None: """Test TwiML generation with arbitrary custom parameters.""" twiml = generate_twiml( + "wss://example.com/voice", TwiMLOptions( - websocket_url="wss://example.com/voice", custom_parameters={ "custom_field_1": "value1", "custom_field_2": "value2", "session_id": "sess_123", }, - ) + ), ) assert '' in twiml @@ -1043,10 +1044,10 @@ def test_generate_twiml_with_pydantic_model(self) -> None: custom_params = CustomParameters(conversationId="CH123", profileId="mem_profile_123") twiml = generate_twiml( + "wss://example.com/voice", TwiMLOptions( - websocket_url="wss://example.com/voice", custom_parameters=custom_params, - ) + ), ) # Should use camelCase aliases @@ -1056,11 +1057,11 @@ def test_generate_twiml_with_pydantic_model(self) -> None: def test_generate_twiml_with_dict_options(self) -> None: """Test TwiML generation accepting plain dict instead of TwiMLOptions.""" twiml = generate_twiml( + "wss://example.com/voice", { - "websocket_url": "wss://example.com/voice", "custom_parameters": {"key": "value"}, "welcome_greeting": "Hi there!", - } + }, ) assert 'url="wss://example.com/voice"' in twiml @@ -1070,14 +1071,14 @@ def test_generate_twiml_with_dict_options(self) -> None: def test_generate_twiml_filters_none_values(self) -> None: """Test that None values are excluded from parameters.""" twiml = generate_twiml( + "wss://example.com/voice", TwiMLOptions( - websocket_url="wss://example.com/voice", custom_parameters={ "field1": "value1", "field2": None, "field3": "value3", }, - ) + ), ) assert '' in twiml @@ -1087,12 +1088,12 @@ def test_generate_twiml_filters_none_values(self) -> None: def test_generate_twiml_escapes_xml_special_chars(self) -> None: """Test XML character escaping in parameter values.""" twiml = generate_twiml( + "wss://example.com/voice", TwiMLOptions( - websocket_url="wss://example.com/voice", custom_parameters={ "field": 'value with "quotes" & ampersand', }, - ) + ), ) # Twilio SDK automatically escapes XML special characters @@ -1107,8 +1108,8 @@ def test_generate_twiml_escapes_xml_special_chars(self) -> None: def test_generate_twiml_complete_example(self) -> None: """Test complete TwiML generation with all options.""" twiml = generate_twiml( + "wss://example.ngrok.io/voice", TwiMLOptions( - websocket_url="wss://example.ngrok.io/voice", custom_parameters={ "conversationId": "CH_abc123", "profileId": "mem_profile_xyz", @@ -1116,7 +1117,7 @@ def test_generate_twiml_complete_example(self) -> None: }, welcome_greeting="Welcome to our support line!", action_url="https://example.com/call-ended", - ) + ), ) # Verify all components present @@ -1131,10 +1132,10 @@ def test_generate_twiml_complete_example(self) -> None: def test_generate_twiml_with_conversation_configuration(self) -> None: """Test TwiML generation with conversation_configuration.""" twiml = generate_twiml( + "wss://example.com/voice", TwiMLOptions( - websocket_url="wss://example.com/voice", conversation_configuration="conv_configuration_test_service_123", - ) + ), ) assert 'conversationConfiguration="conv_configuration_test_service_123"' in twiml @@ -1142,59 +1143,54 @@ def test_generate_twiml_with_conversation_configuration(self) -> None: def test_generate_twiml_without_conversation_configuration(self) -> None: """Test TwiML generation without conversation_configuration.""" - twiml = generate_twiml( - TwiMLOptions( - websocket_url="wss://example.com/voice", - ) - ) + twiml = generate_twiml("wss://example.com/voice", TwiMLOptions()) # Should not have conversation_configuration in output assert "conversationConfiguration" not in twiml @pytest.mark.asyncio async def test_handle_incoming_call_with_additional_parameters(self) -> None: - """Test handle_incoming_call includes additional custom parameters.""" + """Static twiml_options on VoiceChannelConfig passes custom_parameters through.""" + from tac.channels.voice import VoiceChannelConfig + tac = TAC(get_test_config()) - channel = VoiceChannel(tac) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + welcome_greeting="Welcome!", + twiml_options=TwiMLOptions( + custom_parameters={ + "session_id": "sess_abc123", + "user_language": "es", + "priority": "high", + }, + ), + ), + ) - # Generate TwiML with additional parameters twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.ngrok.io/ws", - "action_url": "https://example.ngrok.io/callback", - "welcome_greeting": "Welcome!", - "custom_parameters": { - "session_id": "sess_abc123", - "user_language": "es", - "priority": "high", - }, - }, + VoiceServerURLs( + websocket_url="wss://example.ngrok.io/ws", + conversation_relay_callback_url="https://example.ngrok.io/callback", + ), ) - # Verify conversation_configuration is present assert 'conversationConfiguration="conv_configuration_test123"' in twiml - - # Verify additional custom parameters are present assert '' in twiml assert '' in twiml assert '' in twiml @pytest.mark.asyncio async def test_handle_incoming_call_without_additional_parameters(self) -> None: - """Test handle_incoming_call works without additional parameters.""" + """Test handle_incoming_call works with only server URLs.""" tac = TAC(get_test_config()) channel = VoiceChannel(tac) - # Generate TwiML without additional parameters twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.ngrok.io/ws", - }, + VoiceServerURLs(websocket_url="wss://example.ngrok.io/ws"), ) - # Verify conversation_configuration is present assert 'conversationConfiguration="conv_configuration_test123"' in twiml - # Verify no custom parameters assert "session_id" not in twiml assert "user_language" not in twiml @@ -1204,13 +1200,13 @@ class TestGenerateTwiMLConversationRelayAttrs: def test_voice_and_language_attrs(self) -> None: twiml = generate_twiml( + "wss://example.com/ws", TwiMLOptions( - websocket_url="wss://example.com/ws", voice="en-US-Journey-D", language="en-US", transcription_provider="deepgram", tts_provider="elevenlabs", - ) + ), ) assert 'voice="en-US-Journey-D"' in twiml assert 'language="en-US"' in twiml @@ -1219,12 +1215,12 @@ def test_voice_and_language_attrs(self) -> None: def test_interruptible_dtmf_debug(self) -> None: twiml = generate_twiml( + "wss://example.com/ws", TwiMLOptions( - websocket_url="wss://example.com/ws", interruptible="speech", dtmf_detection=True, debug="speaker-events", - ) + ), ) assert 'interruptible="speech"' in twiml assert 'dtmfDetection="true"' in twiml @@ -1234,8 +1230,8 @@ def test_language_children_emitted(self) -> None: from tac.models.voice import LanguageConfig twiml = generate_twiml( + "wss://example.com/ws", TwiMLOptions( - websocket_url="wss://example.com/ws", languages=[ LanguageConfig( code="es-MX", @@ -1245,7 +1241,7 @@ def test_language_children_emitted(self) -> None: ), LanguageConfig(code="fr-FR"), ], - ) + ), ) assert ' None: assert '' in twiml def test_omitted_fields_absent_from_output(self) -> None: - twiml = generate_twiml(TwiMLOptions(websocket_url="wss://example.com/ws")) + twiml = generate_twiml("wss://example.com/ws") for attr in ( "voice=", "language=", @@ -1268,82 +1264,45 @@ def test_omitted_fields_absent_from_output(self) -> None: class TestHandleIncomingCallMerge: - """TAC defaults overlay with caller-supplied options via model_fields_set.""" + """Merge layers: customizer → static twiml_options → TAC defaults.""" @pytest.mark.asyncio - async def test_tac_defaults_applied_when_fields_omitted(self) -> None: + async def test_tac_defaults_applied(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"} + VoiceServerURLs(websocket_url="wss://example.com/ws"), ) assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml assert 'conversationConfiguration="conv_configuration_test123"' in twiml @pytest.mark.asyncio - async def test_caller_overrides_welcome_greeting(self) -> None: - tac = TAC(get_test_config()) - channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.com/ws", - "welcome_greeting": "Hola!", - } - ) - assert 'welcomeGreeting="Hola!"' in twiml - # TAC-provided conversation_configuration still present - assert 'conversationConfiguration="conv_configuration_test123"' in twiml + async def test_static_options_override_conversation_configuration(self) -> None: + from tac.channels.voice import VoiceChannelConfig - @pytest.mark.asyncio - async def test_caller_overrides_conversation_configuration(self) -> None: tac = TAC(get_test_config()) - channel = VoiceChannel(tac) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(conversation_configuration="conv_configuration_custom"), + ), + ) twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.com/ws", - "conversation_configuration": "conv_configuration_custom", - } + VoiceServerURLs(websocket_url="wss://example.com/ws"), ) assert 'conversationConfiguration="conv_configuration_custom"' in twiml assert "conv_configuration_test123" not in twiml @pytest.mark.asyncio - async def test_caller_provides_new_conversation_relay_attrs(self) -> None: - tac = TAC(get_test_config()) - channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.com/ws", - "voice": "en-US-Journey-D", - "interruptible": "speech", - "dtmf_detection": True, - } - ) - assert 'voice="en-US-Journey-D"' in twiml - assert 'interruptible="speech"' in twiml - assert 'dtmfDetection="true"' in twiml - - @pytest.mark.asyncio - async def test_action_url_caller_wins(self) -> None: - tac = TAC(get_test_config()) - channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - options={ - "websocket_url": "wss://example.com/ws", - "action_url": "https://caller.example.com/end", - }, - default_action_url="https://ignored.example.com/end", - ) - assert 'action="https://caller.example.com/end"' in twiml - - @pytest.mark.asyncio - async def test_action_url_studio_handoff_when_flow_sid_set_and_no_default(self) -> None: - """Studio handoff URL is the last-resort fallback when no default is passed.""" + async def test_action_url_studio_handoff_when_flow_sid_set_and_no_callback_url( + self, + ) -> None: + """Studio handoff URL is the last-resort fallback when no callback URL is passed.""" flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), ) expected = ( f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' @@ -1352,38 +1311,63 @@ async def test_action_url_studio_handoff_when_flow_sid_set_and_no_default(self) assert expected in twiml @pytest.mark.asyncio - async def test_default_action_url_beats_studio_handoff(self) -> None: - """default_action_url wins over Studio handoff so relay-only session cleanup - callbacks aren't silently swallowed by a configured Studio flow.""" + async def test_callback_url_beats_studio_handoff(self) -> None: + """server_urls.conversation_relay_callback_url wins over Studio handoff so + relay-only session cleanup callbacks aren't silently swallowed.""" flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, - default_action_url="https://cleanup.example.com/end", + VoiceServerURLs( + websocket_url="wss://example.com/ws", + conversation_relay_callback_url="https://cleanup.example.com/end", + ), ) assert 'action="https://cleanup.example.com/end"' in twiml assert "webhooks.twilio.com" not in twiml @pytest.mark.asyncio - async def test_action_url_falls_back_to_default(self) -> None: + async def test_action_url_uses_callback_url(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, - default_action_url="https://fallback.example.com/end", + VoiceServerURLs( + websocket_url="wss://example.com/ws", + conversation_relay_callback_url="https://fallback.example.com/end", + ), ) assert 'action="https://fallback.example.com/end"' in twiml @pytest.mark.asyncio - async def test_action_url_omitted_when_no_default(self) -> None: + async def test_action_url_omitted_when_no_callback_url(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), ) assert "action=" not in twiml + @pytest.mark.asyncio + async def test_static_options_action_url_beats_callback_url(self) -> None: + """A static action_url on VoiceChannelConfig.twiml_options is treated as an + explicit user override and wins over the server's cleanup callback URL.""" + from tac.channels.voice import VoiceChannelConfig + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(action_url="https://static.example.com/end"), + ), + ) + twiml = await channel.handle_incoming_call( + VoiceServerURLs( + websocket_url="wss://example.com/ws", + conversation_relay_callback_url="https://callback.example.com/end", + ), + ) + assert 'action="https://static.example.com/end"' in twiml + class TestStaticTwiMLOptions: """VoiceChannelConfig.twiml_options applies to every call without a callback.""" @@ -1400,7 +1384,7 @@ async def test_static_options_applied(self) -> None: ), ) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), ) assert 'voice="en-US-Journey-D"' in twiml assert 'language="en-US"' in twiml @@ -1412,26 +1396,10 @@ async def test_welcome_greeting_shortcut(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Bonjour!")) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), ) assert 'welcomeGreeting="Bonjour!"' in twiml - @pytest.mark.asyncio - async def test_caller_options_beat_static(self) -> None: - from tac.channels.voice import VoiceChannelConfig - - tac = TAC(get_test_config()) - channel = VoiceChannel( - tac, - config=VoiceChannelConfig( - twiml_options=TwiMLOptions(voice="en-US-Journey-D"), - ), - ) - twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws", "voice": "es-MX-Neural2-A"}, - ) - assert 'voice="es-MX-Neural2-A"' in twiml - class TestCustomizeTwiMLOptions: """Per-call customizer runs on top of static options and TAC defaults.""" @@ -1451,7 +1419,7 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: tac = TAC(get_test_config()) channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), ) assert called is False assert "voice=" not in twiml @@ -1471,7 +1439,7 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) ctx = TwiMLRequestContext(from_number="+14155551234", caller_country="US") twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), request_context=ctx, ) assert seen["ctx"] is ctx @@ -1495,7 +1463,7 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: ), ) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), request_context=TwiMLRequestContext(), ) assert 'voice="es-MX-Neural2-A"' in twiml @@ -1517,7 +1485,7 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: ), ) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), request_context=TwiMLRequestContext(), ) # Customizer didn't set welcome_greeting; channel default survives. @@ -1537,7 +1505,7 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) twiml = await channel.handle_incoming_call( - options={"websocket_url": "wss://example.com/ws"}, + VoiceServerURLs(websocket_url="wss://example.com/ws"), request_context=TwiMLRequestContext(), ) assert 'action="https://customizer.example.com/end"' in twiml diff --git a/tests/test_voice_models.py b/tests/test_voice_models.py index d01315a..98952f2 100644 --- a/tests/test_voice_models.py +++ b/tests/test_voice_models.py @@ -1,5 +1,8 @@ """Tests for Voice WebSocket message models.""" +import pytest +from pydantic import ValidationError + from tac.models.voice import ( CustomParameters, InterruptMessage, @@ -8,6 +11,7 @@ SetupMessage, TwiMLOptions, TwiMLRequestContext, + VoiceServerURLs, ) @@ -265,13 +269,12 @@ class TestTwiMLOptionsFieldsSet: """Merge semantics rely on Pydantic's model_fields_set.""" def test_unset_scalar_fields_are_none(self) -> None: - options = TwiMLOptions(websocket_url="wss://x") + options = TwiMLOptions() assert options.voice is None assert "voice" not in options.model_fields_set def test_explicitly_set_fields_tracked(self) -> None: options = TwiMLOptions( - websocket_url="wss://x", voice="en-US-Journey-D", dtmf_detection=False, ) @@ -285,3 +288,23 @@ def test_language_config_optional_fields(self) -> None: assert lang.voice is None assert lang.tts_provider is None assert lang.transcription_provider is None + + +class TestVoiceServerURLs: + """VoiceServerURLs is the server → channel handoff for absolute URLs.""" + + def test_websocket_url_required(self) -> None: + with pytest.raises(ValidationError): + VoiceServerURLs() # type: ignore[call-arg] + + def test_conversation_relay_callback_url_optional(self) -> None: + urls = VoiceServerURLs(websocket_url="wss://example.com/ws") + assert urls.conversation_relay_callback_url is None + + def test_both_urls(self) -> None: + urls = VoiceServerURLs( + websocket_url="wss://example.com/ws", + conversation_relay_callback_url="https://example.com/end", + ) + assert urls.websocket_url == "wss://example.com/ws" + assert urls.conversation_relay_callback_url == "https://example.com/end" From 4f886d1a050f7c2739af95e95f0c73720f9066a1 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 12:32:10 -0400 Subject: [PATCH 04/32] refactor(server): drop defensive try/except around request.form() in /twiml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit request.form() on a POST with the body shapes Twilio sends doesn't fail in any normal path. Silently falling back to an empty dict meant the customizer would run on zero context with no diagnostic — real failures (framework bug, buffer errors) should surface as 500s, not get swallowed. Also filter non-string values instead of str()-coercing them. FastAPI's form parser can return UploadFile for multipart uploads; Twilio doesn't send those, but if one ever appeared we'd rather skip it than str() a file handle into the context. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/server/fastapi_server.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 9b01045..b1d4da0 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -220,11 +220,8 @@ async def post_twiml(request: Request) -> Response: ), ) - try: - form = await request.form() - form_dict = {k: str(v) for k, v in form.items()} - except Exception: - form_dict = {} + form = await request.form() + form_dict = {k: v for k, v in form.items() if isinstance(v, str)} request_context = TwiMLRequestContext.from_form(form_dict) twiml = await vc.handle_incoming_call( From d2d2572acf83b5ec2c8ad3bf4d3c5ac870c8fca7 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 12:46:37 -0400 Subject: [PATCH 05/32] =?UTF-8?q?refactor(voice):=20rename=20VoiceServerUR?= =?UTF-8?q?Ls.conversation=5Frelay=5Fcallback=5Furl=20=E2=86=92=20action?= =?UTF-8?q?=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twilio calls this the "action URL" (the attribute), and we already have TwiMLOptions.action_url that maps to the same TwiML attribute. The old name was describing the server's route path (/conversation-relay-callback) rather than the TwiML concept. The two fields don't collide at the call site because they live on different types — VoiceServerURLs.action_url (server-supplied default) vs TwiMLOptions.action_url (user-set override). Also retighten the server comment at the construction site: the reason we omit action_url in orchestrated mode isn't "CO manages lifecycle," it's that passing it would shadow Studio handoff (when configured). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 17 ++++++++--------- src/tac/models/voice.py | 9 +++++---- src/tac/server/fastapi_server.py | 10 +++++----- tests/test_voice_channel.py | 14 +++++++------- tests/test_voice_models.py | 8 ++++---- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index f22a26d..bdc2640 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -120,9 +120,9 @@ async def handle_incoming_call( 2. ``VoiceChannelConfig.twiml_options`` — static per-channel defaults. 3. TAC defaults: ``welcome_greeting`` from ``VoiceChannelConfig``, ``conversation_configuration`` from ``TACConfig``, and ``action_url`` - resolved via ``server_urls.conversation_relay_callback_url`` in - relay-only mode, otherwise via Studio handoff flow if - ``studio_handoff_flow_sid`` is configured. + resolved via ``server_urls.action_url`` (set by the server in + relay-only mode for session cleanup), otherwise via Studio handoff + flow if ``studio_handoff_flow_sid`` is configured. Lists (``languages``) and nested models (``custom_parameters``) replace wholesale when set at a higher-priority layer. @@ -130,7 +130,7 @@ async def handle_incoming_call( Args: server_urls: Absolute URLs Twilio calls back to. The server (or a custom adapter) builds these from its public domain. The - ``conversation_relay_callback_url`` is used as the default + ``action_url`` is used as the default ```` in relay-only mode so session cleanup fires when calls end. request_context: Parsed Twilio webhook fields. Passed to the @@ -180,10 +180,9 @@ def _resolve_action_url( """Resolve the TwiML ```` URL. Precedence: customizer → channel-static ``twiml_options`` → - ``server_urls.conversation_relay_callback_url`` (set in relay-only mode) - → Studio handoff (if configured). + ``server_urls.action_url`` → Studio handoff (if configured). - The server's callback URL beats Studio handoff because it is what drives + The server-supplied URL beats Studio handoff because it is what drives session cleanup in relay-only mode; leaking sessions is worse than skipping the Studio flow for that call. """ @@ -199,8 +198,8 @@ def _resolve_action_url( and self._static_twiml_options.action_url is not None ): return self._static_twiml_options.action_url - if server_urls.conversation_relay_callback_url is not None: - return server_urls.conversation_relay_callback_url + if server_urls.action_url is not None: + return server_urls.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/models/voice.py b/src/tac/models/voice.py index 4cda619..b473eba 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -162,11 +162,12 @@ class VoiceServerURLs(BaseModel): description="Public WebSocket URL for ConversationRelay, e.g. " "'wss://example.ngrok.app/ws'.", ) - conversation_relay_callback_url: str | None = Field( + action_url: str | None = Field( default=None, - description="Public HTTPS URL for the ConversationRelay action callback. " - "Required in relay-only mode so session cleanup fires when the call ends; " - "ignored in orchestrated mode (Conversation Orchestrator handles lifecycle).", + description="Public HTTPS URL for the TwiML . " + "The server supplies this in relay-only mode so session cleanup fires " + "when the call ends; leave None in orchestrated mode so Studio handoff " + "(when configured) isn't shadowed.", ) model_config = {"populate_by_name": True} diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index b1d4da0..f629e4c 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -207,13 +207,13 @@ async def conversation_webhook(request: Request) -> JSONResponse: @app.post(config.twiml_path, dependencies=[Depends(http_sig)]) async def post_twiml(request: Request) -> Response: """Generate TwiML for incoming voice calls.""" - # Server-owned URLs Twilio will call back to. In relay-only mode - # the server owns the call-ended callback so sessions get - # cleaned up; in orchestrated mode CO manages lifecycle so the - # callback URL is omitted. + # action_url is the server's session-cleanup URL. Only pass it + # when there's no Conversation Orchestrator to clean up for us — + # otherwise leave action_url resolution to the channel so Studio + # handoff (when configured) isn't shadowed. server_urls = VoiceServerURLs( websocket_url=f"wss://{config.public_domain}{config.websocket_path}", - conversation_relay_callback_url=( + action_url=( f"https://{config.public_domain}{config.conversation_relay_callback_path}" if not self.tac.is_orchestrator_enabled() else None diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 057a7cf..d5e1679 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -463,7 +463,7 @@ async def test_handle_incoming_call(self) -> None: twiml = await channel.handle_incoming_call( VoiceServerURLs( websocket_url="wss://example.ngrok.io/ws", - conversation_relay_callback_url="https://example.ngrok.io/flex_handoff", + action_url="https://example.ngrok.io/flex_handoff", ), ) @@ -486,7 +486,7 @@ async def test_handle_incoming_call_default_greeting(self) -> None: twiml = await channel.handle_incoming_call( VoiceServerURLs( websocket_url="wss://test.ngrok.io/ws", - conversation_relay_callback_url="https://example.ngrok.io/flex_handoff", + action_url="https://example.ngrok.io/flex_handoff", ), ) @@ -1171,7 +1171,7 @@ async def test_handle_incoming_call_with_additional_parameters(self) -> None: twiml = await channel.handle_incoming_call( VoiceServerURLs( websocket_url="wss://example.ngrok.io/ws", - conversation_relay_callback_url="https://example.ngrok.io/callback", + action_url="https://example.ngrok.io/callback", ), ) @@ -1312,7 +1312,7 @@ async def test_action_url_studio_handoff_when_flow_sid_set_and_no_callback_url( @pytest.mark.asyncio async def test_callback_url_beats_studio_handoff(self) -> None: - """server_urls.conversation_relay_callback_url wins over Studio handoff so + """server_urls.action_url wins over Studio handoff so relay-only session cleanup callbacks aren't silently swallowed.""" flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) @@ -1320,7 +1320,7 @@ async def test_callback_url_beats_studio_handoff(self) -> None: twiml = await channel.handle_incoming_call( VoiceServerURLs( websocket_url="wss://example.com/ws", - conversation_relay_callback_url="https://cleanup.example.com/end", + action_url="https://cleanup.example.com/end", ), ) assert 'action="https://cleanup.example.com/end"' in twiml @@ -1333,7 +1333,7 @@ async def test_action_url_uses_callback_url(self) -> None: twiml = await channel.handle_incoming_call( VoiceServerURLs( websocket_url="wss://example.com/ws", - conversation_relay_callback_url="https://fallback.example.com/end", + action_url="https://fallback.example.com/end", ), ) assert 'action="https://fallback.example.com/end"' in twiml @@ -1363,7 +1363,7 @@ async def test_static_options_action_url_beats_callback_url(self) -> None: twiml = await channel.handle_incoming_call( VoiceServerURLs( websocket_url="wss://example.com/ws", - conversation_relay_callback_url="https://callback.example.com/end", + action_url="https://callback.example.com/end", ), ) assert 'action="https://static.example.com/end"' in twiml diff --git a/tests/test_voice_models.py b/tests/test_voice_models.py index 98952f2..0a32797 100644 --- a/tests/test_voice_models.py +++ b/tests/test_voice_models.py @@ -297,14 +297,14 @@ def test_websocket_url_required(self) -> None: with pytest.raises(ValidationError): VoiceServerURLs() # type: ignore[call-arg] - def test_conversation_relay_callback_url_optional(self) -> None: + def test_action_url_optional(self) -> None: urls = VoiceServerURLs(websocket_url="wss://example.com/ws") - assert urls.conversation_relay_callback_url is None + assert urls.action_url is None def test_both_urls(self) -> None: urls = VoiceServerURLs( websocket_url="wss://example.com/ws", - conversation_relay_callback_url="https://example.com/end", + action_url="https://example.com/end", ) assert urls.websocket_url == "wss://example.com/ws" - assert urls.conversation_relay_callback_url == "https://example.com/end" + assert urls.action_url == "https://example.com/end" From 0ae56f96ba4f81c8cf4e7760a36eb31586e7e3cc Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 12:52:43 -0400 Subject: [PATCH 06/32] =?UTF-8?q?refactor(voice):=20flip=20action=5Furl=20?= =?UTF-8?q?precedence=20=E2=80=94=20user=20intent=20beats=20SDK=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server used to conditionally omit action_url in orchestrated mode so it wouldn't shadow Studio handoff. That meant server behavior silently changed based on a field on an unrelated config (TACConfig.studio_handoff_flow_sid), and the reason why was hidden in a comment. Flip the precedence instead: customizer → static twiml_options → Studio handoff → server_urls.action_url User-expressed intent (Studio handoff set explicitly) beats the SDK's generated cleanup default. Server unconditionally passes its cleanup URL; channel picks the right one based on what the user configured. Trade-off: a user who sets both studio_handoff_flow_sid and runs in relay-only mode will have Studio win (not cleanup). That combination is contradictory — if they wanted cleanup, they shouldn't configure Studio handoff. They can opt out by returning the cleanup URL from a customizer. The server's /twiml handler drops its orchestrator check and the misleading comment about why it was there. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 27 ++++++++++++++---------- src/tac/server/fastapi_server.py | 6 ------ tests/test_server.py | 9 ++++---- tests/test_voice_channel.py | 34 +++++++++++++++++-------------- 4 files changed, 40 insertions(+), 36 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index bdc2640..bc61978 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -120,9 +120,9 @@ async def handle_incoming_call( 2. ``VoiceChannelConfig.twiml_options`` — static per-channel defaults. 3. TAC defaults: ``welcome_greeting`` from ``VoiceChannelConfig``, ``conversation_configuration`` from ``TACConfig``, and ``action_url`` - resolved via ``server_urls.action_url`` (set by the server in - relay-only mode for session cleanup), otherwise via Studio handoff - flow if ``studio_handoff_flow_sid`` is configured. + resolved via Studio handoff (when ``studio_handoff_flow_sid`` is + configured), else ``server_urls.action_url`` (the SDK-generated + session-cleanup default). Lists (``languages``) and nested models (``custom_parameters``) replace wholesale when set at a higher-priority layer. @@ -179,12 +179,17 @@ def _resolve_action_url( ) -> str | None: """Resolve the TwiML ```` URL. - Precedence: customizer → channel-static ``twiml_options`` → - ``server_urls.action_url`` → Studio handoff (if configured). - - The server-supplied URL beats Studio handoff because it is what drives - session cleanup in relay-only mode; leaking sessions is worse than - skipping the Studio flow for that call. + Precedence (highest to lowest): + 1. customizer + 2. channel-static ``twiml_options`` + 3. Studio handoff (when ``studio_handoff_flow_sid`` is configured) + 4. ``server_urls.action_url`` (SDK-generated session-cleanup default) + + User-expressed intent (Studio handoff is configured explicitly on + ``TACConfig``) beats the SDK's generated cleanup default. If a user + sets both Studio handoff and runs in relay-only mode, Studio wins + for that call — the session-cleanup URL is skipped, same as if they + had set any other action_url via customizer or static options. """ if ( customized is not None @@ -198,13 +203,13 @@ def _resolve_action_url( and self._static_twiml_options.action_url is not None ): return self._static_twiml_options.action_url - if server_urls.action_url is not None: - return server_urls.action_url if self.tac.config.studio_handoff_flow_sid: return studio_voice_handoff_url( self.tac.config.account_sid, self.tac.config.studio_handoff_flow_sid, ) + if server_urls.action_url is not None: + return server_urls.action_url return None async def handle_conversation_relay_callback( diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index f629e4c..a0eba1e 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -207,16 +207,10 @@ async def conversation_webhook(request: Request) -> JSONResponse: @app.post(config.twiml_path, dependencies=[Depends(http_sig)]) async def post_twiml(request: Request) -> Response: """Generate TwiML for incoming voice calls.""" - # action_url is the server's session-cleanup URL. Only pass it - # when there's no Conversation Orchestrator to clean up for us — - # otherwise leave action_url resolution to the channel so Studio - # handoff (when configured) isn't shadowed. server_urls = VoiceServerURLs( websocket_url=f"wss://{config.public_domain}{config.websocket_path}", action_url=( f"https://{config.public_domain}{config.conversation_relay_callback_path}" - if not self.tac.is_orchestrator_enabled() - else None ), ) diff --git a/tests/test_server.py b/tests/test_server.py index 5e93afa..56e52d5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -571,7 +571,10 @@ def test_connect_action_uses_studio_webhook_when_flow_sid_set(self) -> None: ) assert expected in resp.text - def test_connect_action_omitted_when_no_handoff_flow(self) -> None: + def test_connect_action_uses_cleanup_url_when_no_handoff_flow(self) -> None: + """Without Studio handoff, action_url falls back to the server's + session-cleanup URL — no-op in orchestrated mode, drives cleanup in + relay-only mode.""" client = self._build_server() # no studio_handoff_flow_sid resp = client.post( # type: ignore[attr-defined] "/twiml", @@ -579,9 +582,7 @@ def test_connect_action_omitted_when_no_handoff_flow(self) -> None: ) assert resp.status_code == 200 - # No action URL when no handoff flow configured (orchestrated mode) - assert "" in resp.text - assert "action=" not in resp.text + assert 'action="https://test.ngrok.io/conversation-relay-callback"' in resp.text class TestSignatureValidation: diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index d5e1679..82e47d0 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1294,10 +1294,8 @@ async def test_static_options_override_conversation_configuration(self) -> None: assert "conv_configuration_test123" not in twiml @pytest.mark.asyncio - async def test_action_url_studio_handoff_when_flow_sid_set_and_no_callback_url( - self, - ) -> None: - """Studio handoff URL is the last-resort fallback when no callback URL is passed.""" + async def test_studio_handoff_used_when_flow_sid_set(self) -> None: + """Studio handoff URL is used when configured and no higher layer set action_url.""" flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) @@ -1311,9 +1309,11 @@ async def test_action_url_studio_handoff_when_flow_sid_set_and_no_callback_url( assert expected in twiml @pytest.mark.asyncio - async def test_callback_url_beats_studio_handoff(self) -> None: - """server_urls.action_url wins over Studio handoff so - relay-only session cleanup callbacks aren't silently swallowed.""" + async def test_studio_handoff_beats_server_action_url(self) -> None: + """Studio handoff is a user-expressed intent (explicit config) and + wins over server_urls.action_url (the SDK's generated cleanup + default). Setting both is a user choice — if they want cleanup, + they don't set studio_handoff_flow_sid.""" flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) @@ -1323,11 +1323,15 @@ async def test_callback_url_beats_studio_handoff(self) -> None: action_url="https://cleanup.example.com/end", ), ) - assert 'action="https://cleanup.example.com/end"' in twiml - assert "webhooks.twilio.com" not in twiml + expected = ( + f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' + f'/Flows/{flow_sid}?Trigger=incomingCall"' + ) + assert expected in twiml + assert "cleanup.example.com" not in twiml @pytest.mark.asyncio - async def test_action_url_uses_callback_url(self) -> None: + async def test_action_url_uses_server_url(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( @@ -1339,7 +1343,7 @@ async def test_action_url_uses_callback_url(self) -> None: assert 'action="https://fallback.example.com/end"' in twiml @pytest.mark.asyncio - async def test_action_url_omitted_when_no_callback_url(self) -> None: + async def test_action_url_omitted_when_no_server_url(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( @@ -1348,9 +1352,9 @@ async def test_action_url_omitted_when_no_callback_url(self) -> None: assert "action=" not in twiml @pytest.mark.asyncio - async def test_static_options_action_url_beats_callback_url(self) -> None: - """A static action_url on VoiceChannelConfig.twiml_options is treated as an - explicit user override and wins over the server's cleanup callback URL.""" + async def test_static_options_action_url_beats_server_url(self) -> None: + """A static action_url on VoiceChannelConfig.twiml_options is an + explicit user override and wins over the server's cleanup URL.""" from tac.channels.voice import VoiceChannelConfig tac = TAC(get_test_config()) @@ -1363,7 +1367,7 @@ async def test_static_options_action_url_beats_callback_url(self) -> None: twiml = await channel.handle_incoming_call( VoiceServerURLs( websocket_url="wss://example.com/ws", - action_url="https://callback.example.com/end", + action_url="https://cleanup.example.com/end", ), ) assert 'action="https://static.example.com/end"' in twiml From 6e601cf20e665def6413c2f0b8c9ff87a5c10651 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 13:03:21 -0400 Subject: [PATCH 07/32] =?UTF-8?q?refactor(voice):=20rename=20TwiMLRequestC?= =?UTF-8?q?ontext=20=E2=86=92=20TwiMLRequest,=20request=5Fcontext=20?= =?UTF-8?q?=E2=86=92=20twiml=5Frequest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Request context" was vague and mixed directions ("TwiML" is output, "Request" is input). The model represents the Twilio TwiML request — what Twilio POSTs to your /twiml endpoint asking for TwiML — so name it for that. Pairs cleanly with TwiMLOptions: TwiMLRequest (what came in) → TwiMLOptions (what goes out) The field on handle_incoming_call is now `twiml_request`, and customizer signatures read naturally: `async def customize(req: TwiMLRequest) ...`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../examples/features/twiml_customization.py | 6 ++-- src/tac/channels/voice/channel.py | 12 +++---- src/tac/channels/voice/config.py | 8 ++--- src/tac/models/voice.py | 4 +-- src/tac/server/fastapi_server.py | 8 ++--- tests/test_server.py | 6 ++-- tests/test_voice_channel.py | 36 +++++++++---------- tests/test_voice_models.py | 12 +++---- 8 files changed, 46 insertions(+), 46 deletions(-) diff --git a/getting_started/examples/features/twiml_customization.py b/getting_started/examples/features/twiml_customization.py index cd68a92..8da10e6 100644 --- a/getting_started/examples/features/twiml_customization.py +++ b/getting_started/examples/features/twiml_customization.py @@ -3,7 +3,7 @@ Demonstrates using ``VoiceChannelConfig.customize_twiml_options`` to tailor the ConversationRelay TwiML on every incoming voice call. The customizer receives a -framework-neutral ``TwiMLRequestContext`` parsed from the Twilio webhook +framework-neutral ``TwiMLRequest`` parsed from the Twilio webhook (``From``, ``To``, ``CallerCountry``, etc.) and returns ``TwiMLOptions`` overrides. Any field it explicitly sets replaces TAC's defaults; everything else (websocket URL, ``action_url``, ``conversation_configuration``) continues @@ -22,7 +22,7 @@ from tac.channels.voice import VoiceChannel, VoiceChannelConfig from tac.models.session import ConversationSession from tac.models.tac import TACMemoryResponse -from tac.models.voice import LanguageConfig, TwiMLOptions, TwiMLRequestContext +from tac.models.voice import LanguageConfig, TwiMLOptions, TwiMLRequest from tac.server import TACFastAPIServer load_dotenv() @@ -41,7 +41,7 @@ async def handle_message_ready( tac.on_message_ready(handle_message_ready) -async def customize_twiml(ctx: TwiMLRequestContext) -> TwiMLOptions: +async def customize_twiml(ctx: TwiMLRequest) -> TwiMLOptions: """Return TwiMLOptions overrides for this incoming call. Only set the fields you want to override — TAC fills in the rest diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index bc61978..02ac577 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -22,7 +22,7 @@ PromptMessage, SetupMessage, TwiMLOptions, - TwiMLRequestContext, + TwiMLRequest, VoiceServerURLs, ) from tac.session import SessionState @@ -105,7 +105,7 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, server_urls: VoiceServerURLs, - request_context: TwiMLRequestContext | None = None, + twiml_request: TwiMLRequest | None = None, ) -> str: """ Generate TwiML response for incoming voice calls. @@ -116,7 +116,7 @@ async def handle_incoming_call( Merge precedence (highest to lowest): 1. Output of ``VoiceChannelConfig.customize_twiml_options`` if configured - and ``request_context`` is given. Fields it explicitly sets win. + and ``twiml_request`` is given. Fields it explicitly sets win. 2. ``VoiceChannelConfig.twiml_options`` — static per-channel defaults. 3. TAC defaults: ``welcome_greeting`` from ``VoiceChannelConfig``, ``conversation_configuration`` from ``TACConfig``, and ``action_url`` @@ -133,7 +133,7 @@ async def handle_incoming_call( ``action_url`` is used as the default ```` in relay-only mode so session cleanup fires when calls end. - request_context: Parsed Twilio webhook fields. Passed to the + twiml_request: Parsed Twilio webhook fields. Passed to the customizer if one is configured on the channel. Returns: @@ -141,8 +141,8 @@ async def handle_incoming_call( """ # Invoke the customizer if configured and we have a request context. customized: TwiMLOptions | None = None - if self._customize_twiml_options is not None and request_context is not None: - customized = await self._customize_twiml_options(request_context) + if self._customize_twiml_options is not None and twiml_request is not None: + customized = await self._customize_twiml_options(twiml_request) # Start from TAC defaults. merged = TwiMLOptions( diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index 8b9709b..565b6ff 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -5,10 +5,10 @@ from pydantic import BaseModel, Field from tac.models.memory import MemoryMode -from tac.models.voice import TwiMLOptions, TwiMLRequestContext +from tac.models.voice import TwiMLOptions, TwiMLRequest from tac.session import SessionManager, ThreadSafeSessionManager -TwiMLOptionsCustomizer = Callable[[TwiMLRequestContext], Awaitable[TwiMLOptions]] +TwiMLOptionsCustomizer = Callable[[TwiMLRequest], Awaitable[TwiMLOptions]] class VoiceChannelConfig(BaseModel): @@ -33,7 +33,7 @@ class VoiceChannelConfig(BaseModel): for every call. For per-call customization see ``customize_twiml_options``. customize_twiml_options: Optional async callable producing per-call ``TwiMLOptions`` overrides. Receives a framework-neutral - ``TwiMLRequestContext`` (parsed Twilio webhook fields). Any field the + ``TwiMLRequest`` (parsed Twilio webhook fields). Any field the function explicitly sets wins over ``twiml_options`` and TAC defaults; unset fields fall through. """ @@ -64,6 +64,6 @@ class VoiceChannelConfig(BaseModel): customize_twiml_options: TwiMLOptionsCustomizer | None = Field( default=None, description="Optional async callable returning per-call TwiMLOptions overrides. " - "Receives a TwiMLRequestContext; only fields explicitly set on the returned " + "Receives a TwiMLRequest; only fields explicitly set on the returned " "options override lower layers.", ) diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index b473eba..ba4ef01 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -220,7 +220,7 @@ class TwiMLOptions(BaseModel): model_config = {"populate_by_name": True} -class TwiMLRequestContext(BaseModel): +class TwiMLRequest(BaseModel): """Framework-neutral view of the Twilio TwiML webhook form. Populated by ``TACFastAPIServer`` from the incoming Twilio webhook, then @@ -244,7 +244,7 @@ class TwiMLRequestContext(BaseModel): model_config = {"populate_by_name": True, "extra": "ignore"} @classmethod - def from_form(cls, form: dict[str, str]) -> "TwiMLRequestContext": + def from_form(cls, form: dict[str, str]) -> "TwiMLRequest": """Build a context from a raw Twilio form dict, bucketing unknown keys into ``extra``.""" known_aliases = { "From", diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index a0eba1e..0615977 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -16,7 +16,7 @@ from tac.channels.websocket_protocol import WebSocketDisconnectError from tac.core.logging import get_logger from tac.core.tac import TAC -from tac.models.voice import TwiMLRequestContext, VoiceServerURLs +from tac.models.voice import TwiMLRequest, VoiceServerURLs from tac.server.config import TACServerConfig if TYPE_CHECKING: @@ -85,7 +85,7 @@ class TACFastAPIServer: - To customize TwiML attributes (voice, language, transcription provider, interruption behavior, ```` children, etc.) set a ``customize_twiml_options`` on ``VoiceChannelConfig``. The customizer - receives a framework-neutral ``TwiMLRequestContext`` and returns a + receives a framework-neutral ``TwiMLRequest`` and returns a ``TwiMLOptions``; any field it explicitly sets overrides TAC defaults. For same-on-every-call settings, set ``twiml_options`` on ``VoiceChannelConfig`` directly. @@ -216,11 +216,11 @@ async def post_twiml(request: Request) -> Response: form = await request.form() form_dict = {k: v for k, v in form.items() if isinstance(v, str)} - request_context = TwiMLRequestContext.from_form(form_dict) + twiml_request = TwiMLRequest.from_form(form_dict) twiml = await vc.handle_incoming_call( server_urls, - request_context=request_context, + twiml_request=twiml_request, ) return Response(content=twiml, media_type="application/xml") diff --git a/tests/test_server.py b/tests/test_server.py index 56e52d5..4920545 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -803,12 +803,12 @@ def test_customizer_on_voice_channel_receives_parsed_context_and_overrides_twiml from fastapi.testclient import TestClient from tac.channels.voice import VoiceChannel, VoiceChannelConfig - from tac.models.voice import TwiMLOptions, TwiMLRequestContext + from tac.models.voice import TwiMLOptions, TwiMLRequest from tac.server import TACFastAPIServer - captured: dict[str, TwiMLRequestContext] = {} + captured: dict[str, TwiMLRequest] = {} - async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: captured["ctx"] = ctx return TwiMLOptions(voice="en-US-Journey-D", language="en-US") diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 82e47d0..8db6f11 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1409,13 +1409,13 @@ class TestCustomizeTwiMLOptions: """Per-call customizer runs on top of static options and TAC defaults.""" @pytest.mark.asyncio - async def test_customizer_skipped_without_request_context(self) -> None: + async def test_customizer_skipped_without_twiml_request(self) -> None: from tac.channels.voice import VoiceChannelConfig - from tac.models.voice import TwiMLRequestContext + from tac.models.voice import TwiMLRequest called = False - async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: nonlocal called called = True return TwiMLOptions(voice="en-US-Journey-D") @@ -1429,22 +1429,22 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: assert "voice=" not in twiml @pytest.mark.asyncio - async def test_customizer_invoked_with_request_context(self) -> None: + async def test_customizer_invoked_with_twiml_request(self) -> None: from tac.channels.voice import VoiceChannelConfig - from tac.models.voice import TwiMLRequestContext + from tac.models.voice import TwiMLRequest - seen: dict[str, TwiMLRequestContext] = {} + seen: dict[str, TwiMLRequest] = {} - async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: seen["ctx"] = ctx return TwiMLOptions(voice="en-US-Journey-D", interruptible="speech") tac = TAC(get_test_config()) channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) - ctx = TwiMLRequestContext(from_number="+14155551234", caller_country="US") + ctx = TwiMLRequest(from_number="+14155551234", caller_country="US") twiml = await channel.handle_incoming_call( VoiceServerURLs(websocket_url="wss://example.com/ws"), - request_context=ctx, + twiml_request=ctx, ) assert seen["ctx"] is ctx assert 'voice="en-US-Journey-D"' in twiml @@ -1453,9 +1453,9 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: @pytest.mark.asyncio async def test_customizer_output_beats_static(self) -> None: from tac.channels.voice import VoiceChannelConfig - from tac.models.voice import TwiMLRequestContext + from tac.models.voice import TwiMLRequest - async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(voice="es-MX-Neural2-A") tac = TAC(get_test_config()) @@ -1468,16 +1468,16 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: ) twiml = await channel.handle_incoming_call( VoiceServerURLs(websocket_url="wss://example.com/ws"), - request_context=TwiMLRequestContext(), + twiml_request=TwiMLRequest(), ) assert 'voice="es-MX-Neural2-A"' in twiml @pytest.mark.asyncio async def test_customizer_unset_fields_keep_lower_layers(self) -> None: from tac.channels.voice import VoiceChannelConfig - from tac.models.voice import TwiMLRequestContext + from tac.models.voice import TwiMLRequest - async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(voice="en-US-Journey-D") tac = TAC(get_test_config()) @@ -1490,7 +1490,7 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: ) twiml = await channel.handle_incoming_call( VoiceServerURLs(websocket_url="wss://example.com/ws"), - request_context=TwiMLRequestContext(), + twiml_request=TwiMLRequest(), ) # Customizer didn't set welcome_greeting; channel default survives. assert 'welcomeGreeting="Channel default"' in twiml @@ -1499,18 +1499,18 @@ async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: @pytest.mark.asyncio async def test_customizer_action_url_wins_over_studio_handoff(self) -> None: from tac.channels.voice import VoiceChannelConfig - from tac.models.voice import TwiMLRequestContext + from tac.models.voice import TwiMLRequest flow_sid = "FW" + "a" * 32 - async def customizer(ctx: TwiMLRequestContext) -> TwiMLOptions: + async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(action_url="https://customizer.example.com/end") tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) twiml = await channel.handle_incoming_call( VoiceServerURLs(websocket_url="wss://example.com/ws"), - request_context=TwiMLRequestContext(), + twiml_request=TwiMLRequest(), ) assert 'action="https://customizer.example.com/end"' in twiml diff --git a/tests/test_voice_models.py b/tests/test_voice_models.py index 0a32797..0ed66b7 100644 --- a/tests/test_voice_models.py +++ b/tests/test_voice_models.py @@ -10,7 +10,7 @@ PromptMessage, SetupMessage, TwiMLOptions, - TwiMLRequestContext, + TwiMLRequest, VoiceServerURLs, ) @@ -221,11 +221,11 @@ def test_interrupt_aliases(self) -> None: assert msg.duration_until_interrupt_ms == 2000 -class TestTwiMLRequestContext: - """TwiMLRequestContext parses Twilio webhook form fields.""" +class TestTwiMLRequest: + """TwiMLRequest parses Twilio webhook form fields.""" def test_from_form_known_fields(self) -> None: - ctx = TwiMLRequestContext.from_form( + ctx = TwiMLRequest.from_form( { "From": "+14155551234", "To": "+15551234567", @@ -246,7 +246,7 @@ def test_from_form_known_fields(self) -> None: assert ctx.extra == {} def test_from_form_unknown_fields_bucketed_into_extra(self) -> None: - ctx = TwiMLRequestContext.from_form( + ctx = TwiMLRequest.from_form( { "From": "+14155551234", "ApiVersion": "2010-04-01", @@ -260,7 +260,7 @@ def test_from_form_unknown_fields_bucketed_into_extra(self) -> None: } def test_from_form_empty(self) -> None: - ctx = TwiMLRequestContext.from_form({}) + ctx = TwiMLRequest.from_form({}) assert ctx.from_number is None assert ctx.extra == {} From 0300a569923979451bc7254b926aa6bfd7671783 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 13:07:08 -0400 Subject: [PATCH 08/32] docs(examples): show the three TwiML customization layers in one file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example previously showed only customize_twiml_options, which is the most advanced path. Most users want the static case — same voice/language on every call — and should reach for welcome_greeting or twiml_options first. Restructure the example so the default active code demonstrates the static path (twiml_options + welcome_greeting shortcut), with the per-call customizer as a commented-out block showing localization by caller country. Add a header summarizing all three layers and when to pick which. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../examples/features/twiml_customization.py | 119 +++++++++++------- 1 file changed, 72 insertions(+), 47 deletions(-) diff --git a/getting_started/examples/features/twiml_customization.py b/getting_started/examples/features/twiml_customization.py index 8da10e6..db6272b 100644 --- a/getting_started/examples/features/twiml_customization.py +++ b/getting_started/examples/features/twiml_customization.py @@ -1,19 +1,20 @@ """ -Feature: Per-call TwiML customization +Feature: ConversationRelay TwiML customization -Demonstrates using ``VoiceChannelConfig.customize_twiml_options`` to tailor the -ConversationRelay TwiML on every incoming voice call. The customizer receives a -framework-neutral ``TwiMLRequest`` parsed from the Twilio webhook -(``From``, ``To``, ``CallerCountry``, etc.) and returns ``TwiMLOptions`` -overrides. Any field it explicitly sets replaces TAC's defaults; everything -else (websocket URL, ``action_url``, ``conversation_configuration``) continues -to come from TAC config. +TAC exposes three layers of TwiML customization on VoiceChannelConfig, +each for a different use case: -For same-on-every-call customization, set ``twiml_options`` on -``VoiceChannelConfig`` directly — no function needed. +1. ``welcome_greeting`` — shortcut for the most common override. +2. ``twiml_options`` — static TwiMLOptions applied to every call. +3. ``customize_twiml_options`` — async callable for per-call logic, receives + a TwiMLRequest (parsed Twilio webhook fields: From, To, CallerCountry, …). -This example picks voice and language based on the caller's country and adds -```` children so the caller can switch mid-call. +Higher layers override lower ones, and TAC fills anything you didn't set +(websocket URL, action URL, conversation_configuration). + +This example shows the static path (voice + language the same for every +call). The customizer version for per-call localization is below in a +commented block — uncomment if you need it. """ from dotenv import load_dotenv @@ -22,7 +23,7 @@ from tac.channels.voice import VoiceChannel, VoiceChannelConfig from tac.models.session import ConversationSession from tac.models.tac import TACMemoryResponse -from tac.models.voice import LanguageConfig, TwiMLOptions, TwiMLRequest +from tac.models.voice import LanguageConfig, TwiMLOptions from tac.server import TACFastAPIServer load_dotenv() @@ -41,45 +42,69 @@ async def handle_message_ready( tac.on_message_ready(handle_message_ready) -async def customize_twiml(ctx: TwiMLRequest) -> TwiMLOptions: - """Return TwiMLOptions overrides for this incoming call. - - Only set the fields you want to override — TAC fills in the rest - (websocket URL, action URL, conversation configuration, welcome greeting). - """ - if ctx.caller_country == "MX": - primary_language = "es-MX" - primary_voice = "es-MX-Neural2-A" - welcome = "¡Hola! ¿En qué puedo ayudarte?" - elif ctx.caller_country == "FR": - primary_language = "fr-FR" - primary_voice = "fr-FR-Neural2-A" - welcome = "Bonjour ! Comment puis-je vous aider ?" - else: - primary_language = "en-US" - primary_voice = "en-US-Journey-D" - welcome = "Hello! How can I help?" - - return TwiMLOptions( - language=primary_language, - voice=primary_voice, - tts_provider="google", - transcription_provider="deepgram", - interruptible="speech", - welcome_greeting=welcome, - # children let the caller switch languages mid-call - languages=[ - LanguageConfig(code="en-US", voice="en-US-Journey-D", tts_provider="google"), - LanguageConfig(code="es-MX", voice="es-MX-Neural2-A", tts_provider="google"), - ], - ) - +# ---- Static TwiML (same settings on every call) ------------------------------ +# +# Set ``twiml_options`` on VoiceChannelConfig for attributes that don't depend +# on who's calling. Use ``welcome_greeting`` as a shortcut for just the +# greeting. TAC fills in websocket_url, action_url, and conversation_configuration. voice_channel = VoiceChannel( - tac, config=VoiceChannelConfig(customize_twiml_options=customize_twiml) + tac, + config=VoiceChannelConfig( + welcome_greeting="Hello! How can I help?", + twiml_options=TwiMLOptions( + voice="en-US-Journey-D", + language="en-US", + tts_provider="google", + transcription_provider="deepgram", + interruptible="speech", + # children let the caller switch languages mid-call. + languages=[ + LanguageConfig(code="en-US", voice="en-US-Journey-D", tts_provider="google"), + LanguageConfig(code="es-MX", voice="es-MX-Neural2-A", tts_provider="google"), + ], + ), + ), ) +# ---- Per-call TwiML (customize_twiml_options) -------------------------------- +# +# Use this when the TwiML depends on who's calling — e.g., localization by +# caller country, per-tenant voice, A/B tests. The customizer returns +# TwiMLOptions overrides; anything it doesn't set falls through to +# ``twiml_options`` (above) and then to TAC defaults. +# +# Uncomment the block below to replace the static setup with per-call logic. +# +# from tac.models.voice import TwiMLRequest +# +# +# async def customize_twiml(req: TwiMLRequest) -> TwiMLOptions: +# if req.caller_country == "MX": +# return TwiMLOptions( +# language="es-MX", +# voice="es-MX-Neural2-A", +# welcome_greeting="¡Hola! ¿En qué puedo ayudarte?", +# ) +# if req.caller_country == "FR": +# return TwiMLOptions( +# language="fr-FR", +# voice="fr-FR-Neural2-A", +# welcome_greeting="Bonjour ! Comment puis-je vous aider ?", +# ) +# return TwiMLOptions() # fall through to static twiml_options + TAC defaults +# +# +# voice_channel = VoiceChannel( +# tac, +# config=VoiceChannelConfig( +# welcome_greeting="Hello! How can I help?", +# customize_twiml_options=customize_twiml, +# ), +# ) + + if __name__ == "__main__": server = TACFastAPIServer(tac=tac, voice_channel=voice_channel) server.start() From 7f8d4b0f441ac0817191af50bd8d3e0db508d346 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 13:14:39 -0400 Subject: [PATCH 09/32] =?UTF-8?q?docs(examples):=20rename=20twiml=5Fcustom?= =?UTF-8?q?ization.py=20=E2=86=92=20voice=5Ftwiml=5Fcustomization.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit voice_ prefix groups voice-related examples alongside voice_streaming.py without a directory reshuffle. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../{twiml_customization.py => voice_twiml_customization.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename getting_started/examples/features/{twiml_customization.py => voice_twiml_customization.py} (100%) diff --git a/getting_started/examples/features/twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py similarity index 100% rename from getting_started/examples/features/twiml_customization.py rename to getting_started/examples/features/voice_twiml_customization.py From 7eab73a1a1e3ecbdd63e43a19c1398d4d501ebe1 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 13:18:31 -0400 Subject: [PATCH 10/32] =?UTF-8?q?refactor(voice):=20rename=20VoiceServerUR?= =?UTF-8?q?Ls=20=E2=86=92=20VoiceEndpoints,=20server=5Furls=20=E2=86=92=20?= =?UTF-8?q?endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Endpoint" carries domain meaning (a URL something calls to do X); "ServerURLs" was a data-type description. Endpoints also covers future extension cleanly — if we add, say, a status_callback_url, it's still obviously an endpoint. Call site reads naturally: await channel.handle_incoming_call(endpoints, twiml_request=req) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 20 ++++++++-------- src/tac/models/voice.py | 2 +- src/tac/server/fastapi_server.py | 6 ++--- tests/test_relay_only_mode.py | 4 ++-- tests/test_voice_channel.py | 40 +++++++++++++++---------------- tests/test_voice_models.py | 12 +++++----- 6 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 02ac577..2efc0cc 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -23,7 +23,7 @@ SetupMessage, TwiMLOptions, TwiMLRequest, - VoiceServerURLs, + VoiceEndpoints, ) from tac.session import SessionState from tac.tools.handoff import studio_voice_handoff_url @@ -104,7 +104,7 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, - server_urls: VoiceServerURLs, + endpoints: VoiceEndpoints, twiml_request: TwiMLRequest | None = None, ) -> str: """ @@ -121,14 +121,14 @@ async def handle_incoming_call( 3. TAC defaults: ``welcome_greeting`` from ``VoiceChannelConfig``, ``conversation_configuration`` from ``TACConfig``, and ``action_url`` resolved via Studio handoff (when ``studio_handoff_flow_sid`` is - configured), else ``server_urls.action_url`` (the SDK-generated + configured), else ``endpoints.action_url`` (the SDK-generated session-cleanup default). Lists (``languages``) and nested models (``custom_parameters``) replace wholesale when set at a higher-priority layer. Args: - server_urls: Absolute URLs Twilio calls back to. The server (or a + endpoints: Absolute URLs Twilio calls back to. The server (or a custom adapter) builds these from its public domain. The ``action_url`` is used as the default ```` in relay-only mode so session @@ -148,7 +148,7 @@ async def handle_incoming_call( merged = TwiMLOptions( welcome_greeting=self._welcome_greeting, conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_action_url(customized, server_urls), + action_url=self._resolve_action_url(customized, endpoints), ) # Overlay channel-static twiml_options. @@ -159,7 +159,7 @@ async def handle_incoming_call( if customized is not None: self._overlay_fields(merged, customized) - return twiml.generate_twiml(server_urls.websocket_url, merged) + return twiml.generate_twiml(endpoints.websocket_url, merged) @staticmethod def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: @@ -175,7 +175,7 @@ def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: def _resolve_action_url( self, customized: TwiMLOptions | None, - server_urls: VoiceServerURLs, + endpoints: VoiceEndpoints, ) -> str | None: """Resolve the TwiML ```` URL. @@ -183,7 +183,7 @@ def _resolve_action_url( 1. customizer 2. channel-static ``twiml_options`` 3. Studio handoff (when ``studio_handoff_flow_sid`` is configured) - 4. ``server_urls.action_url`` (SDK-generated session-cleanup default) + 4. ``endpoints.action_url`` (SDK-generated session-cleanup default) User-expressed intent (Studio handoff is configured explicitly on ``TACConfig``) beats the SDK's generated cleanup default. If a user @@ -208,8 +208,8 @@ def _resolve_action_url( self.tac.config.account_sid, self.tac.config.studio_handoff_flow_sid, ) - if server_urls.action_url is not None: - return server_urls.action_url + if endpoints.action_url is not None: + return endpoints.action_url return None async def handle_conversation_relay_callback( diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index ba4ef01..9d4d4c4 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -147,7 +147,7 @@ class LanguageConfig(BaseModel): model_config = {"populate_by_name": True} -class VoiceServerURLs(BaseModel): +class VoiceEndpoints(BaseModel): """Absolute URLs Twilio calls back to for a given voice deployment. These are server-owned — they depend on where the app is hosted diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 0615977..8110091 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -16,7 +16,7 @@ from tac.channels.websocket_protocol import WebSocketDisconnectError from tac.core.logging import get_logger from tac.core.tac import TAC -from tac.models.voice import TwiMLRequest, VoiceServerURLs +from tac.models.voice import TwiMLRequest, VoiceEndpoints from tac.server.config import TACServerConfig if TYPE_CHECKING: @@ -207,7 +207,7 @@ async def conversation_webhook(request: Request) -> JSONResponse: @app.post(config.twiml_path, dependencies=[Depends(http_sig)]) async def post_twiml(request: Request) -> Response: """Generate TwiML for incoming voice calls.""" - server_urls = VoiceServerURLs( + endpoints = VoiceEndpoints( websocket_url=f"wss://{config.public_domain}{config.websocket_path}", action_url=( f"https://{config.public_domain}{config.conversation_relay_callback_path}" @@ -219,7 +219,7 @@ async def post_twiml(request: Request) -> Response: twiml_request = TwiMLRequest.from_form(form_dict) twiml = await vc.handle_incoming_call( - server_urls, + endpoints, twiml_request=twiml_request, ) return Response(content=twiml, media_type="application/xml") diff --git a/tests/test_relay_only_mode.py b/tests/test_relay_only_mode.py index 05cb039..3937d0b 100644 --- a/tests/test_relay_only_mode.py +++ b/tests/test_relay_only_mode.py @@ -88,13 +88,13 @@ async def test_retrieve_memory_returns_empty_in_relay_only(self) -> None: async def test_handle_incoming_call_twiml_omits_conversation_configuration(self) -> None: """TwiML does not include conversationConfiguration in relay-only mode.""" from tac.channels.voice import VoiceChannelConfig - from tac.models.voice import VoiceServerURLs + from tac.models.voice import VoiceEndpoints tac = TAC(relay_only_config()) channel = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Hello")) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), ) assert "conversationConfiguration" not in twiml diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 8db6f11..1e1cb0e 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -17,7 +17,7 @@ InterruptMessage, PromptMessage, TwiMLOptions, - VoiceServerURLs, + VoiceEndpoints, ) @@ -461,7 +461,7 @@ async def test_handle_incoming_call(self) -> None: ) twiml = await channel.handle_incoming_call( - VoiceServerURLs( + VoiceEndpoints( websocket_url="wss://example.ngrok.io/ws", action_url="https://example.ngrok.io/flex_handoff", ), @@ -484,7 +484,7 @@ async def test_handle_incoming_call_default_greeting(self) -> None: channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - VoiceServerURLs( + VoiceEndpoints( websocket_url="wss://test.ngrok.io/ws", action_url="https://example.ngrok.io/flex_handoff", ), @@ -1169,7 +1169,7 @@ async def test_handle_incoming_call_with_additional_parameters(self) -> None: ) twiml = await channel.handle_incoming_call( - VoiceServerURLs( + VoiceEndpoints( websocket_url="wss://example.ngrok.io/ws", action_url="https://example.ngrok.io/callback", ), @@ -1187,7 +1187,7 @@ async def test_handle_incoming_call_without_additional_parameters(self) -> None: channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.ngrok.io/ws"), + VoiceEndpoints(websocket_url="wss://example.ngrok.io/ws"), ) assert 'conversationConfiguration="conv_configuration_test123"' in twiml @@ -1271,7 +1271,7 @@ async def test_tac_defaults_applied(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), ) assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml assert 'conversationConfiguration="conv_configuration_test123"' in twiml @@ -1288,7 +1288,7 @@ async def test_static_options_override_conversation_configuration(self) -> None: ), ) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), ) assert 'conversationConfiguration="conv_configuration_custom"' in twiml assert "conv_configuration_test123" not in twiml @@ -1300,7 +1300,7 @@ async def test_studio_handoff_used_when_flow_sid_set(self) -> None: tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), ) expected = ( f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' @@ -1311,14 +1311,14 @@ async def test_studio_handoff_used_when_flow_sid_set(self) -> None: @pytest.mark.asyncio async def test_studio_handoff_beats_server_action_url(self) -> None: """Studio handoff is a user-expressed intent (explicit config) and - wins over server_urls.action_url (the SDK's generated cleanup + wins over endpoints.action_url (the SDK's generated cleanup default). Setting both is a user choice — if they want cleanup, they don't set studio_handoff_flow_sid.""" flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - VoiceServerURLs( + VoiceEndpoints( websocket_url="wss://example.com/ws", action_url="https://cleanup.example.com/end", ), @@ -1335,7 +1335,7 @@ async def test_action_url_uses_server_url(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - VoiceServerURLs( + VoiceEndpoints( websocket_url="wss://example.com/ws", action_url="https://fallback.example.com/end", ), @@ -1347,7 +1347,7 @@ async def test_action_url_omitted_when_no_server_url(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), ) assert "action=" not in twiml @@ -1365,7 +1365,7 @@ async def test_static_options_action_url_beats_server_url(self) -> None: ), ) twiml = await channel.handle_incoming_call( - VoiceServerURLs( + VoiceEndpoints( websocket_url="wss://example.com/ws", action_url="https://cleanup.example.com/end", ), @@ -1388,7 +1388,7 @@ async def test_static_options_applied(self) -> None: ), ) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), ) assert 'voice="en-US-Journey-D"' in twiml assert 'language="en-US"' in twiml @@ -1400,7 +1400,7 @@ async def test_welcome_greeting_shortcut(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Bonjour!")) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), ) assert 'welcomeGreeting="Bonjour!"' in twiml @@ -1423,7 +1423,7 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: tac = TAC(get_test_config()) channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), ) assert called is False assert "voice=" not in twiml @@ -1443,7 +1443,7 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) ctx = TwiMLRequest(from_number="+14155551234", caller_country="US") twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), twiml_request=ctx, ) assert seen["ctx"] is ctx @@ -1467,7 +1467,7 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: ), ) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), twiml_request=TwiMLRequest(), ) assert 'voice="es-MX-Neural2-A"' in twiml @@ -1489,7 +1489,7 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: ), ) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), twiml_request=TwiMLRequest(), ) # Customizer didn't set welcome_greeting; channel default survives. @@ -1509,7 +1509,7 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) twiml = await channel.handle_incoming_call( - VoiceServerURLs(websocket_url="wss://example.com/ws"), + VoiceEndpoints(websocket_url="wss://example.com/ws"), twiml_request=TwiMLRequest(), ) assert 'action="https://customizer.example.com/end"' in twiml diff --git a/tests/test_voice_models.py b/tests/test_voice_models.py index 0ed66b7..a01d391 100644 --- a/tests/test_voice_models.py +++ b/tests/test_voice_models.py @@ -11,7 +11,7 @@ SetupMessage, TwiMLOptions, TwiMLRequest, - VoiceServerURLs, + VoiceEndpoints, ) @@ -290,19 +290,19 @@ def test_language_config_optional_fields(self) -> None: assert lang.transcription_provider is None -class TestVoiceServerURLs: - """VoiceServerURLs is the server → channel handoff for absolute URLs.""" +class TestVoiceEndpoints: + """VoiceEndpoints is the server → channel handoff for absolute URLs.""" def test_websocket_url_required(self) -> None: with pytest.raises(ValidationError): - VoiceServerURLs() # type: ignore[call-arg] + VoiceEndpoints() # type: ignore[call-arg] def test_action_url_optional(self) -> None: - urls = VoiceServerURLs(websocket_url="wss://example.com/ws") + urls = VoiceEndpoints(websocket_url="wss://example.com/ws") assert urls.action_url is None def test_both_urls(self) -> None: - urls = VoiceServerURLs( + urls = VoiceEndpoints( websocket_url="wss://example.com/ws", action_url="https://example.com/end", ) From 787c8ec069ff4c69c1451f02105a2489ad9c3139 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 13:29:05 -0400 Subject: [PATCH 11/32] feat(voice): add TwiMLOptions.extra escape hatch for newly-added ConversationRelay attrs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every time Twilio ships a new ConversationRelay attribute, users had to wait for a TAC release. The typed path is still the default (autocomplete, validation, merge semantics), but extra lets users pass through anything the SDK hasn't caught up with yet: TwiMLOptions(extra={"new_feature": "on"}) The twilio SDK still does snake_case → camelCase conversion, so users write Python conventions and get valid TwiML out. If a user puts a typed-field name in extra (e.g., extra={"voice": ...}), the typed field wins and a warning is logged — this is almost always a mistake. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/twiml.py | 46 +++++++++++++++++++++------------ src/tac/models/voice.py | 7 +++++ tests/test_voice_channel.py | 44 +++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index 6d18a32..bdcc96e 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -5,8 +5,23 @@ from pydantic import BaseModel from twilio.twiml.voice_response import VoiceResponse +from tac.core.logging import get_logger from tac.models.voice import TwiMLOptions +logger = get_logger(__name__) + +_OPTIONAL_RELAY_ATTRS = ( + "welcome_greeting", + "conversation_configuration", + "voice", + "language", + "transcription_provider", + "tts_provider", + "interruptible", + "dtmf_detection", + "debug", +) + def generate_twiml( websocket_url: str, @@ -23,10 +38,9 @@ def generate_twiml( Args: websocket_url: Public WebSocket URL for ConversationRelay (e.g. ``'wss://example.ngrok.app/ws'``). - options: Optional ``TwiMLOptions`` (or dict) with any combination of - custom_parameters, welcome_greeting, action_url, - conversation_configuration, voice, language, transcription_provider, - tts_provider, interruptible, dtmf_detection, debug, or languages. + 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``. Returns: TwiML XML string ready to return to Twilio. @@ -56,22 +70,22 @@ 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} - optional_attrs = ( - "welcome_greeting", - "conversation_configuration", - "voice", - "language", - "transcription_provider", - "tts_provider", - "interruptible", - "dtmf_detection", - "debug", - ) - for attr in optional_attrs: + for attr in _OPTIONAL_RELAY_ATTRS: value = getattr(options, attr) if value is not None: relay_kwargs[attr] = value + if options.extra: + typed_keys = set(_OPTIONAL_RELAY_ATTRS) + for key, value in options.extra.items(): + if key in typed_keys: + logger.warning( + "TwiMLOptions.extra shadows a typed field; typed field wins.", + shadowed_key=key, + ) + continue + relay_kwargs[key] = value + relay = connect.conversation_relay(**relay_kwargs) # Emit children, if any diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 9d4d4c4..4b3d071 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -216,6 +216,13 @@ class TwiMLOptions(BaseModel): languages: list[LanguageConfig] | None = Field( None, description="Additional children for multi-language support" ) + extra: dict[str, str | bool | int] | None = Field( + None, + description="Escape hatch for ConversationRelay attributes not yet typed on " + "this model. Keys are emitted as-is on ; Twilio's SDK " + "converts snake_case to camelCase. Prefer a typed field when one exists — " + "use ``extra`` only for newly-added Twilio attributes not yet in this SDK.", + ) model_config = {"populate_by_name": True} diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 1e1cb0e..8573629 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1263,6 +1263,50 @@ def test_omitted_fields_absent_from_output(self) -> None: assert attr not in twiml +class TestTwiMLOptionsExtra: + """`extra` lets users pass through ConversationRelay attributes not yet typed.""" + + def test_extra_attrs_emitted(self) -> None: + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions(extra={"future_feature": "on", "another_attr": True}), + ) + # Twilio SDK snake_case → camelCase + assert 'futureFeature="on"' in twiml + assert 'anotherAttr="true"' in twiml + + def test_extra_does_not_shadow_typed_field(self, caplog: pytest.LogCaptureFixture) -> None: + """If a user puts a typed-field name in extra, the typed value wins and + a warning is logged.""" + import logging + + # TAC's setup_logging (called from TAC()) sets propagate=False on the + # "tac" logger so pytest's caplog doesn't see its records. Flip it for + # this test and restore after. + tac_logger = logging.getLogger("tac") + original_propagate = tac_logger.propagate + tac_logger.propagate = True + try: + with caplog.at_level("WARNING"): + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions( + voice="en-US-Journey-D", + extra={"voice": "should-not-appear"}, + ), + ) + finally: + tac_logger.propagate = original_propagate + assert 'voice="en-US-Journey-D"' in twiml + assert "should-not-appear" not in twiml + assert any("shadows a typed field" in r.message for r in caplog.records) + + def test_extra_none_emits_nothing(self) -> None: + twiml = generate_twiml("wss://example.com/ws", TwiMLOptions()) + # Sanity: no trailing garbage from extra handling when it's unset. + assert " Date: Thu, 14 May 2026 13:38:09 -0400 Subject: [PATCH 12/32] refactor(voice): remove VoiceChannelConfig.welcome_greeting shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shortcut duplicated TwiMLOptions.welcome_greeting and set a precedent we couldn't keep — why not shortcut `voice` or `language` too? Two-ways-to-do-the-same-thing is a smell, and it created a hidden precedence question (shortcut vs twiml_options) users shouldn't have to reason about. Now welcome_greeting lives where other TwiML attributes live — TwiMLOptions. One place, one concept. Precedence for the greeting is cleaner too: customizer > twiml_options > deprecated server value > SDK default The deprecated TACServerConfig.welcome_greeting forwards to a separate channel slot (_deprecated_server_welcome_greeting) used only as a fallback — so a user who has twiml_options.welcome_greeting set always wins over the legacy server setting, as expected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../features/voice_twiml_customization.py | 20 +++++++++---------- src/tac/channels/voice/channel.py | 16 +++++++++++---- src/tac/channels/voice/config.py | 15 ++++---------- src/tac/server/fastapi_server.py | 15 ++++++-------- tests/test_relay_only_mode.py | 9 +++++++-- tests/test_server.py | 10 ++++++++-- tests/test_voice_channel.py | 15 ++++++++++---- 7 files changed, 57 insertions(+), 43 deletions(-) diff --git a/getting_started/examples/features/voice_twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py index db6272b..f270ad2 100644 --- a/getting_started/examples/features/voice_twiml_customization.py +++ b/getting_started/examples/features/voice_twiml_customization.py @@ -1,16 +1,14 @@ """ Feature: ConversationRelay TwiML customization -TAC exposes three layers of TwiML customization on VoiceChannelConfig, -each for a different use case: +TAC exposes two layers of TwiML customization on VoiceChannelConfig: -1. ``welcome_greeting`` — shortcut for the most common override. -2. ``twiml_options`` — static TwiMLOptions applied to every call. -3. ``customize_twiml_options`` — async callable for per-call logic, receives +1. ``twiml_options`` — static TwiMLOptions applied to every call. +2. ``customize_twiml_options`` — async callable for per-call logic, receives a TwiMLRequest (parsed Twilio webhook fields: From, To, CallerCountry, …). -Higher layers override lower ones, and TAC fills anything you didn't set -(websocket URL, action URL, conversation_configuration). +The customizer wins over static options, and TAC fills anything you didn't +set (websocket URL, action URL, conversation_configuration). This example shows the static path (voice + language the same for every call). The customizer version for per-call localization is below in a @@ -45,14 +43,14 @@ async def handle_message_ready( # ---- Static TwiML (same settings on every call) ------------------------------ # # Set ``twiml_options`` on VoiceChannelConfig for attributes that don't depend -# on who's calling. Use ``welcome_greeting`` as a shortcut for just the -# greeting. TAC fills in websocket_url, action_url, and conversation_configuration. +# on who's calling. TAC fills in websocket_url, action_url, and +# conversation_configuration. voice_channel = VoiceChannel( tac, config=VoiceChannelConfig( - welcome_greeting="Hello! How can I help?", twiml_options=TwiMLOptions( + welcome_greeting="Hello! How can I help?", voice="en-US-Journey-D", language="en-US", tts_provider="google", @@ -99,7 +97,7 @@ async def handle_message_ready( # voice_channel = VoiceChannel( # tac, # config=VoiceChannelConfig( -# welcome_greeting="Hello! How can I help?", +# twiml_options=TwiMLOptions(welcome_greeting="Hello! How can I help?"), # customize_twiml_options=customize_twiml, # ), # ) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 2efc0cc..1ca0f92 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -35,6 +35,8 @@ _POLL_ATTEMPTS = 5 _POLL_BASE_DELAY = 0.25 +DEFAULT_WELCOME_GREETING = "Hello! How can I assist you today?" + class VoiceChannel(BaseChannel): """ @@ -78,9 +80,13 @@ def __init__( super().__init__(tac, memory_mode=config.memory_mode) self.config = config self.session_manager = config.session_manager - self._welcome_greeting = config.welcome_greeting self._static_twiml_options = config.twiml_options self._customize_twiml_options = config.customize_twiml_options + # Populated by TACFastAPIServer from the deprecated + # TACServerConfig.welcome_greeting. Used only as a fallback when + # twiml_options/customizer don't set welcome_greeting. Remove when + # the deprecated field is deleted. + self._deprecated_server_welcome_greeting: str | None = None self._websocket_manager = WebSocketManager() self._twilio_client: Client | None = None @@ -118,7 +124,7 @@ async def handle_incoming_call( 1. Output of ``VoiceChannelConfig.customize_twiml_options`` if configured and ``twiml_request`` is given. Fields it explicitly sets win. 2. ``VoiceChannelConfig.twiml_options`` — static per-channel defaults. - 3. TAC defaults: ``welcome_greeting`` from ``VoiceChannelConfig``, + 3. TAC defaults: a fixed default ``welcome_greeting``, ``conversation_configuration`` from ``TACConfig``, and ``action_url`` resolved via Studio handoff (when ``studio_handoff_flow_sid`` is configured), else ``endpoints.action_url`` (the SDK-generated @@ -144,9 +150,11 @@ async def handle_incoming_call( if self._customize_twiml_options is not None and twiml_request is not None: customized = await self._customize_twiml_options(twiml_request) - # Start from TAC defaults. + # Start from TAC defaults. welcome_greeting prefers (in order): + # deprecated server value → SDK default. + # twiml_options and customizer can still override on top. merged = TwiMLOptions( - welcome_greeting=self._welcome_greeting, + welcome_greeting=(self._deprecated_server_welcome_greeting or DEFAULT_WELCOME_GREETING), conversation_configuration=self.tac.config.conversation_configuration_id, action_url=self._resolve_action_url(customized, endpoints), ) diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index 565b6ff..04d29ca 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -24,13 +24,11 @@ class VoiceChannelConfig(BaseModel): - "once": Retrieve memory once at conversation start with empty query and cache it. Cache is invalidated when conversation becomes INACTIVE. - "never": Skip memory retrieval - welcome_greeting: Default greeting spoken at the start of every call. - Equivalent to setting ``twiml_options=TwiMLOptions(welcome_greeting=...)`` - but shorter for the common case. twiml_options: Static ``TwiMLOptions`` applied to every call (voice, - language, transcription provider, ```` children, etc.). - Use this when the same ConversationRelay configuration is correct - for every call. For per-call customization see ``customize_twiml_options``. + language, transcription provider, welcome_greeting, ```` + children, etc.). Use this when the same ConversationRelay + configuration is correct for every call. For per-call customization + see ``customize_twiml_options``. customize_twiml_options: Optional async callable producing per-call ``TwiMLOptions`` overrides. Receives a framework-neutral ``TwiMLRequest`` (parsed Twilio webhook fields). Any field the @@ -51,11 +49,6 @@ class VoiceChannelConfig(BaseModel): default="never", description="Memory retrieval mode for this channel", ) - welcome_greeting: str = Field( - default="Hello! How can I assist you today?", - description="Default greeting spoken at the start of every call. " - "Shortcut for TwiMLOptions(welcome_greeting=...).", - ) twiml_options: TwiMLOptions | None = Field( default=None, description="Static TwiMLOptions applied to every call. Use for same-on-every-call " diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 8110091..b7547d3 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -119,15 +119,12 @@ def __init__( self.voice_channel = voice_channel self.messaging_channels: list[MessagingChannel] = messaging_channels or [] - # Forward deprecated TACServerConfig.welcome_greeting to the voice channel - # if the channel wasn't given its own. Drop this forwarding when the - # field is removed from TACServerConfig. - if ( - self.config.welcome_greeting is not None - and self.voice_channel is not None - and "welcome_greeting" not in self.voice_channel.config.model_fields_set - ): - self.voice_channel._welcome_greeting = self.config.welcome_greeting + # Forward deprecated TACServerConfig.welcome_greeting to the voice + # channel as a fallback default. twiml_options.welcome_greeting (if set) + # still wins over this. Drop this forwarding when the field is removed + # from TACServerConfig. + if self.config.welcome_greeting is not None and self.voice_channel is not None: + self.voice_channel._deprecated_server_welcome_greeting = self.config.welcome_greeting # Gather all channels that need webhook processing self.webhook_channels: list[BaseChannel] = [] diff --git a/tests/test_relay_only_mode.py b/tests/test_relay_only_mode.py index 3937d0b..71a6361 100644 --- a/tests/test_relay_only_mode.py +++ b/tests/test_relay_only_mode.py @@ -88,10 +88,15 @@ async def test_retrieve_memory_returns_empty_in_relay_only(self) -> None: async def test_handle_incoming_call_twiml_omits_conversation_configuration(self) -> None: """TwiML does not include conversationConfiguration in relay-only mode.""" from tac.channels.voice import VoiceChannelConfig - from tac.models.voice import VoiceEndpoints + from tac.models.voice import TwiMLOptions, VoiceEndpoints tac = TAC(relay_only_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Hello")) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(welcome_greeting="Hello"), + ), + ) twiml = await channel.handle_incoming_call( VoiceEndpoints(websocket_url="wss://example.com/ws"), diff --git a/tests/test_server.py b/tests/test_server.py index 4920545..4cc4e4d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -868,14 +868,20 @@ def test_forwarded_when_channel_did_not_set_greeting(self) -> None: resp = client.post("/twiml", headers={"X-Twilio-Signature": signature}) assert 'welcomeGreeting="Legacy!"' in resp.text - def test_channel_greeting_wins_over_deprecated_server_field(self) -> None: + def test_twiml_options_greeting_wins_over_deprecated_server_field(self) -> None: from fastapi.testclient import TestClient from tac.channels.voice import VoiceChannel, VoiceChannelConfig + from tac.models.voice import TwiMLOptions from tac.server import TACFastAPIServer tac = TAC(get_test_config()) - vc = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Channel!")) + vc = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(welcome_greeting="Channel!"), + ), + ) with pytest.warns(DeprecationWarning): server_config = TACServerConfig( public_domain="test.ngrok.io", welcome_greeting="Legacy!" diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 8573629..5806e03 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -457,7 +457,9 @@ async def test_handle_incoming_call(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel( tac, - config=VoiceChannelConfig(welcome_greeting="Welcome!"), + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(welcome_greeting="Welcome!"), + ), ) twiml = await channel.handle_incoming_call( @@ -1438,11 +1440,16 @@ async def test_static_options_applied(self) -> None: assert 'language="en-US"' in twiml @pytest.mark.asyncio - async def test_welcome_greeting_shortcut(self) -> None: + async def test_welcome_greeting_via_twiml_options(self) -> None: from tac.channels.voice import VoiceChannelConfig tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(welcome_greeting="Bonjour!")) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(welcome_greeting="Bonjour!"), + ), + ) twiml = await channel.handle_incoming_call( VoiceEndpoints(websocket_url="wss://example.com/ws"), ) @@ -1528,7 +1535,7 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: channel = VoiceChannel( tac, config=VoiceChannelConfig( - welcome_greeting="Channel default", + twiml_options=TwiMLOptions(welcome_greeting="Channel default"), customize_twiml_options=customizer, ), ) From 47f02658f861302ae60460127d633ba105c56651 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 13:46:42 -0400 Subject: [PATCH 13/32] feat(voice): full ConversationRelay attribute coverage on TwiMLOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the model against Twilio's ConversationRelay docs and added the 14 attributes we were missing, plus speech_model on : welcome_greeting_interruptible tts_language transcription_language speech_model elevenlabs_text_normalization eot_threshold partial_prompts deepgram_smart_format speech_timeout interrupt_sensitivity report_input_during_agent_speech ignore_backchannel preemptible hints events intelligence_service Typed where Twilio documents enums (Literal): welcome_greeting_interruptible, interruptible, interrupt_sensitivity, report_input_during_agent_speech, elevenlabs_text_normalization. Pydantic validates range constraints for eot_threshold (0.5-0.9) and speech_timeout (600-5000ms). Also fixed the stale `debug` docstring — Twilio moved speaker-events and tokens-played to a new `events` attribute; the old docstring told users to pass them via `debug`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/twiml.py | 25 +++++- src/tac/models/voice.py | 144 +++++++++++++++++++++++++++++--- tests/test_voice_channel.py | 115 +++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 16 deletions(-) diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index bdcc96e..f5d10b5 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -12,14 +12,33 @@ _OPTIONAL_RELAY_ATTRS = ( "welcome_greeting", + "welcome_greeting_interruptible", "conversation_configuration", - "voice", + # language / TTS / STT "language", - "transcription_provider", + "tts_language", + "transcription_language", + "voice", "tts_provider", + "transcription_provider", + "speech_model", + "elevenlabs_text_normalization", + # turn detection / interruption + "eot_threshold", + "partial_prompts", + "deepgram_smart_format", + "speech_timeout", "interruptible", + "interrupt_sensitivity", + "report_input_during_agent_speech", + "ignore_backchannel", + "preemptible", "dtmf_detection", + # hints / events / debug / intelligence + "hints", + "events", "debug", + "intelligence_service", ) @@ -92,7 +111,7 @@ def generate_twiml( if options.languages: for lang in options.languages: lang_kwargs: dict[str, Any] = {"code": lang.code} - for attr in ("voice", "tts_provider", "transcription_provider"): + for attr in ("voice", "tts_provider", "transcription_provider", "speech_model"): value = getattr(lang, attr) if value is not None: lang_kwargs[attr] = value diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 4b3d071..2244bc7 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -4,6 +4,10 @@ from pydantic import BaseModel, Field +# Twilio uses the same four-value enum for several attributes that control +# what caller input (DTMF, speech, both, neither) triggers a given behavior. +InterruptMode = Literal["none", "dtmf", "speech", "any"] + class CustomParameters(BaseModel): """ @@ -137,12 +141,21 @@ class LanguageConfig(BaseModel): https://www.twilio.com/docs/voice/twiml/connect/conversationrelay#language-element """ - code: str = Field(..., description="Language code, e.g. 'es-MX'") + code: str = Field( + ..., + description="Language code, e.g. 'es-MX'. Can be 'multi' for automatic language " + "detection (requires ElevenLabs TTS and Deepgram STT).", + ) voice: str | None = Field(None, description="TTS voice name for this language") tts_provider: str | None = Field(None, description="TTS provider, e.g. 'google'") transcription_provider: str | None = Field( None, description="Transcription provider, e.g. 'deepgram'" ) + speech_model: str | None = Field( + None, + description="Speech model for STT. Choices vary by transcription_provider; " + "see the provider's documentation.", + ) model_config = {"populate_by_name": True} @@ -164,10 +177,9 @@ class VoiceEndpoints(BaseModel): ) action_url: str | None = Field( default=None, - description="Public HTTPS URL for the TwiML . " - "The server supplies this in relay-only mode so session cleanup fires " - "when the call ends; leave None in orchestrated mode so Studio handoff " - "(when configured) isn't shadowed.", + description="Public HTTPS URL for the TwiML used as " + "the SDK-generated session-cleanup default. Channel-level overrides " + "(customizer, static twiml_options, Studio handoff) take precedence.", ) model_config = {"populate_by_name": True} @@ -191,6 +203,11 @@ class TwiMLOptions(BaseModel): None, description="Initial greeting message for caller", ) + welcome_greeting_interruptible: InterruptMode | None = Field( + None, + description="What caller input can interrupt the welcome greeting. " + "Defaults to 'any' on Twilio.", + ) action_url: str | None = Field( None, description="URL for Twilio to request when call ends", @@ -200,22 +217,123 @@ class TwiMLOptions(BaseModel): description="Conversation Service SID for ConversationRelay to automatically " "manage conversation creation and participants.", ) - voice: str | None = Field(None, description="Default TTS voice name") - language: str | None = Field(None, description="Default language code, e.g. 'en-US'") + + # Language, TTS, STT + language: str | None = Field( + None, + description="Language for both STT and TTS, e.g. 'en-US'. Equivalent to setting " + "both tts_language and transcription_language.", + ) + tts_language: str | None = Field( + None, + description="TTS language code; overrides `language` for TTS.", + ) + transcription_language: str | None = Field( + None, + description="STT language code; overrides `language` for transcription. " + "Can be 'multi' for automatic language detection (Deepgram only).", + ) + voice: str | None = Field(None, description="TTS voice name (choices vary by tts_provider)") + tts_provider: str | None = Field( + None, + description="TTS provider: 'Google', 'Amazon', or 'ElevenLabs'. Defaults to 'ElevenLabs'.", + ) transcription_provider: str | None = Field( - None, description="Default transcription provider, e.g. 'deepgram'" + None, + description="STT provider: 'Google' or 'Deepgram'. Defaults to 'Deepgram' (or 'Google' " + "for accounts that used ConversationRelay before 2025-09-12).", ) - tts_provider: str | None = Field(None, description="Default TTS provider, e.g. 'elevenlabs'") - interruptible: str | bool | None = Field( - None, description="Interruption behavior (e.g. 'speech', 'dtmf', 'any', True/False)" + speech_model: str | None = Field( + None, + description="Speech model for STT. Choices vary by transcription_provider.", + ) + elevenlabs_text_normalization: Literal["on", "auto", "off"] | None = Field( + None, + description="Text normalization for ElevenLabs TTS. Defaults to 'off'. " + "'auto' behaves like 'off' for ConversationRelay calls.", + ) + + # Turn detection / interruption + eot_threshold: float | None = Field( + None, + ge=0.5, + le=0.9, + description="Confidence required to finish a turn (0.5 - 0.9). " + "Only applies with Deepgram + flux speech model.", + ) + partial_prompts: bool | None = Field( + None, + description="Send unfinalized prompts and eager end-of-turn events " + "(last=False). Only applies with Deepgram + flux speech model.", + ) + deepgram_smart_format: bool | None = Field( + None, + description="Use Deepgram Smart Format for transcription output. " + "Defaults to true when transcription_provider='Deepgram'.", + ) + speech_timeout: int | None = Field( + None, + ge=600, + le=5000, + description="Silence (ms) after speech before finalizing the prompt. " + "Integer in [600, 5000]. Defaults to 'auto' on Twilio.", + ) + interruptible: InterruptMode | bool | None = Field( + None, + description="What caller input interrupts TTS playback. Boolean accepted " + "for backward compat: True='any', False='none'. Defaults to 'any'.", + ) + interrupt_sensitivity: Literal["high", "medium", "low"] | None = Field( + None, + description="How easily caller speech triggers an interrupt. Defaults to 'high'.", + ) + report_input_during_agent_speech: InterruptMode | None = Field( + None, + description="What caller input gets reported while the agent is speaking " + "(independent of whether playback is interrupted). Defaults to 'none' since May 2025.", + ) + ignore_backchannel: bool | None = Field( + None, + description="Filter short conversational feedback ('yeah', 'uh-huh', …) " + "so it doesn't interrupt the agent. Defaults to false.", + ) + preemptible: bool | None = Field( + None, + description="Allow text tokens from the next talk cycle to interrupt the current one. " + "Defaults to false.", + ) + dtmf_detection: bool | None = Field( + None, + description="Emit DTMF keypress events over the WebSocket.", + ) + + # Recognition hints / events / debug / intelligence + hints: str | None = Field( + None, + description="Comma-separated words/phrases likely to appear in speech. " + "Capitalize proper nouns.", + ) + events: str | None = Field( + None, + description="Space-separated event subscriptions, e.g. 'speaker-events tokens-played'.", ) - dtmf_detection: bool | None = Field(None, description="Enable DTMF detection") debug: str | None = Field( - None, description="Debug flags, e.g. 'speaker-events' or comma-separated list" + None, + description="Debug subscription, e.g. 'debugging'. Note: 'speaker-events' and " + "'tokens-played' have moved to the `events` attribute — only use them here " + "for backward compatibility.", + ) + intelligence_service: str | None = Field( + None, + description="Conversation Intelligence (classic) Service SID or unique name for " + "persisting transcripts and running Language Operators.", ) + + # Nested children languages: list[LanguageConfig] | None = Field( None, description="Additional children for multi-language support" ) + extra: dict[str, str | bool | int] | None = Field( None, description="Escape hatch for ConversationRelay attributes not yet typed on " diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 5806e03..ce38b98 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1240,6 +1240,7 @@ def test_language_children_emitted(self) -> None: voice="es-MX-Neural2-A", tts_provider="google", transcription_provider="google", + speech_model="long", ), LanguageConfig(code="fr-FR"), ], @@ -1248,8 +1249,106 @@ def test_language_children_emitted(self) -> None: assert '' in twiml + def test_welcome_greeting_interruptible(self) -> None: + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions( + welcome_greeting="Hi", + welcome_greeting_interruptible="dtmf", + ), + ) + assert 'welcomeGreetingInterruptible="dtmf"' in twiml + + def test_language_override_attrs(self) -> None: + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions( + tts_language="en-US", + transcription_language="fr-FR", + ), + ) + assert 'ttsLanguage="en-US"' in twiml + assert 'transcriptionLanguage="fr-FR"' in twiml + + def test_speech_model_and_elevenlabs_normalization(self) -> None: + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions( + speech_model="nova-3-general", + elevenlabs_text_normalization="on", + ), + ) + assert 'speechModel="nova-3-general"' in twiml + assert 'elevenlabsTextNormalization="on"' in twiml + + def test_turn_detection_attrs(self) -> None: + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions( + eot_threshold=0.75, + partial_prompts=True, + deepgram_smart_format=False, + speech_timeout=1500, + ), + ) + assert 'eotThreshold="0.75"' in twiml + assert 'partialPrompts="true"' in twiml + assert 'deepgramSmartFormat="false"' in twiml + assert 'speechTimeout="1500"' in twiml + + def test_interrupt_sensitivity_and_report_input(self) -> None: + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions( + interrupt_sensitivity="medium", + report_input_during_agent_speech="speech", + ), + ) + assert 'interruptSensitivity="medium"' in twiml + assert 'reportInputDuringAgentSpeech="speech"' in twiml + + def test_ignore_backchannel_and_preemptible(self) -> None: + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions(ignore_backchannel=True, preemptible=True), + ) + assert 'ignoreBackchannel="true"' in twiml + assert 'preemptible="true"' in twiml + + def test_hints_events_intelligence_service(self) -> None: + twiml = generate_twiml( + "wss://example.com/ws", + TwiMLOptions( + hints="TwiML,ConversationRelay", + events="speaker-events tokens-played", + intelligence_service="GAaabbcc", + ), + ) + assert 'hints="TwiML,ConversationRelay"' in twiml + assert 'events="speaker-events tokens-played"' in twiml + assert 'intelligenceService="GAaabbcc"' in twiml + + def test_eot_threshold_validation(self) -> None: + import pytest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + TwiMLOptions(eot_threshold=0.4) + with pytest.raises(ValidationError): + TwiMLOptions(eot_threshold=0.95) + + def test_speech_timeout_validation(self) -> None: + import pytest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + TwiMLOptions(speech_timeout=500) + with pytest.raises(ValidationError): + TwiMLOptions(speech_timeout=6000) + def test_omitted_fields_absent_from_output(self) -> None: twiml = generate_twiml("wss://example.com/ws") for attr in ( @@ -1260,6 +1359,22 @@ def test_omitted_fields_absent_from_output(self) -> None: "interruptible=", "dtmfDetection=", "debug=", + "welcomeGreetingInterruptible=", + "ttsLanguage=", + "transcriptionLanguage=", + "speechModel=", + "elevenlabsTextNormalization=", + "eotThreshold=", + "partialPrompts=", + "deepgramSmartFormat=", + "speechTimeout=", + "interruptSensitivity=", + "reportInputDuringAgentSpeech=", + "ignoreBackchannel=", + "preemptible=", + "hints=", + "events=", + "intelligenceService=", " Date: Thu, 14 May 2026 13:52:25 -0400 Subject: [PATCH 14/32] =?UTF-8?q?refactor(voice):=20address=20review=20fee?= =?UTF-8?q?dback=20=E2=80=94=20forbid=20unknown=20kwargs,=20clarify=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix dead welcome_greeting kwarg on VoiceChannelConfig in a test that survived the shortcut removal because Pydantic silently ignored it. Move the greeting into the adjacent TwiMLOptions where it belongs. - Set extra="forbid" on VoiceChannelConfig so typos and stale field names fail loudly instead of silently no-opping. Zero test churn (full suite still passes), so no other dead kwargs lurking. - Tighten the example docstring to say defaults sit under twiml_options, and add a forward-looking note on _overlay_fields that nested models and lists replace wholesale by design. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../examples/features/voice_twiml_customization.py | 7 ++++--- src/tac/channels/voice/channel.py | 5 +++++ src/tac/channels/voice/config.py | 2 +- tests/test_voice_channel.py | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/getting_started/examples/features/voice_twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py index f270ad2..36dd61a 100644 --- a/getting_started/examples/features/voice_twiml_customization.py +++ b/getting_started/examples/features/voice_twiml_customization.py @@ -1,14 +1,15 @@ """ Feature: ConversationRelay TwiML customization -TAC exposes two layers of TwiML customization on VoiceChannelConfig: +TAC exposes two user-facing layers of TwiML customization on +VoiceChannelConfig (under these, TAC fills in the websocket URL, action URL, +conversation_configuration, and a default welcome greeting): 1. ``twiml_options`` — static TwiMLOptions applied to every call. 2. ``customize_twiml_options`` — async callable for per-call logic, receives a TwiMLRequest (parsed Twilio webhook fields: From, To, CallerCountry, …). -The customizer wins over static options, and TAC fills anything you didn't -set (websocket URL, action URL, conversation_configuration). +The customizer wins over static options, which win over TAC defaults. This example shows the static path (voice + language the same for every call). The customizer version for per-call localization is below in a diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 1ca0f92..868e699 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -173,6 +173,11 @@ async def handle_incoming_call( def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: """Apply fields explicitly set on ``source`` onto ``target``. + Nested models (``custom_parameters``) and lists (``languages``, + ``extra``) replace wholesale — there's no per-key merging. If you add + a field that should merge (e.g. a dict of headers), special-case it + here instead of getting the default overwrite behavior. + ``action_url`` is handled separately by ``_resolve_action_url``. """ for field in source.model_fields_set: diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index 04d29ca..5842f0b 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -36,7 +36,7 @@ class VoiceChannelConfig(BaseModel): unset fields fall through. """ - model_config = {"arbitrary_types_allowed": True} + model_config = {"arbitrary_types_allowed": True, "extra": "forbid"} session_manager: SessionManager | None = Field( default_factory=ThreadSafeSessionManager, diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index ce38b98..c71bf71 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1159,8 +1159,8 @@ async def test_handle_incoming_call_with_additional_parameters(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - welcome_greeting="Welcome!", twiml_options=TwiMLOptions( + welcome_greeting="Welcome!", custom_parameters={ "session_id": "sess_abc123", "user_language": "es", From 40b16abfabd44a102fe55360b77a0e70ae4efb46 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 15:13:05 -0400 Subject: [PATCH 15/32] feat(voice): outbound calls honor VoiceChannelConfig.twiml_options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outbound TwiML previously ignored the channel's TwiML config and only accepted three flat fields (welcome_greeting, action_url, custom_parameters) on InitiateVoiceConversationOptions. Users who set voice, language, interruptible, children, etc. on the channel got those settings on inbound calls but not outbound — the same user configuring the same channel got different behavior depending on who initiated the call. Outbound now uses the same merge machinery as inbound: 1. per-call options.twiml_options (new field) 2. VoiceChannelConfig.twiml_options (channel-wide) 3. TAC defaults (welcome greeting, conversation_configuration, action_url from Studio handoff if configured) No customizer layer on outbound — customizers receive a TwiMLRequest from an inbound Twilio webhook, which doesn't exist for outbound. The three flat fields (welcome_greeting, action_url, custom_parameters) are deprecated with DeprecationWarning and forwarded into twiml_options at validation time. Explicit twiml_options values win over flat-field fallbacks. Same pattern as TACServerConfig.welcome_greeting deprecation. Co-Authored-By: Claude Opus 4.7 (1M context) --- getting_started/examples/features/outbound.py | 7 +- src/tac/channels/voice/channel.py | 55 +++++-- src/tac/models/outbound.py | 83 +++++++++- tests/test_outbound.py | 152 +++++++++++++++++- 4 files changed, 279 insertions(+), 18 deletions(-) diff --git a/getting_started/examples/features/outbound.py b/getting_started/examples/features/outbound.py index a693249..44b2d77 100644 --- a/getting_started/examples/features/outbound.py +++ b/getting_started/examples/features/outbound.py @@ -41,6 +41,7 @@ ) from tac.models.session import ConversationSession from tac.models.tac import TACMemoryResponse +from tac.models.voice import TwiMLOptions from tac.server import TACFastAPIServer from tac.server.config import TACServerConfig @@ -172,8 +173,10 @@ async def initiate_outbound(args: argparse.Namespace) -> None: InitiateVoiceConversationOptions( to=args.to, websocket_url=f"wss://{public_domain}/ws", - welcome_greeting=args.welcome_greeting, - action_url=f"https://{public_domain}/conversation-relay-callback", + twiml_options=TwiMLOptions( + welcome_greeting=args.welcome_greeting, + action_url=f"https://{public_domain}/conversation-relay-callback", + ), ) ) print(f"Call placed to {args.to} (SID: {voice_result.call_sid})") diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 868e699..84bba2c 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -428,6 +428,12 @@ async def initiate_outbound_conversation( conversation during passive hydration. The session is initialized lazily on the first prompt when the conversation is discovered by callSid. + TwiML merge precedence (highest to lowest): + 1. ``options.twiml_options`` — per-call overrides + 2. ``VoiceChannelConfig.twiml_options`` — channel-wide defaults + 3. TAC defaults: welcome greeting, ``conversation_configuration`` from + ``TACConfig``, and ``action_url`` from Studio handoff (if configured). + ``options.websocket_url`` must be the publicly accessible WebSocket endpoint (e.g., ``wss://your-domain.ngrok.app/ws``). Unlike inbound calls where TACServer sets this automatically, outbound calls require it @@ -441,16 +447,21 @@ 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 = TwiMLOptions( + welcome_greeting=(self._deprecated_server_welcome_greeting or DEFAULT_WELCOME_GREETING), + conversation_configuration=self.tac.config.conversation_configuration_id, + action_url=self._resolve_outbound_action_url(options.twiml_options), + ) + if self._static_twiml_options is not None: + self._overlay_fields(merged, self._static_twiml_options) + if options.twiml_options is not None: + self._overlay_fields(merged, options.twiml_options) + try: - twiml_xml = twiml.generate_twiml( - options.websocket_url, - TwiMLOptions( - welcome_greeting=options.welcome_greeting, - action_url=options.action_url, - conversation_configuration=self.tac.config.conversation_configuration_id, - custom_parameters=options.custom_parameters, - ), - ) + twiml_xml = twiml.generate_twiml(options.websocket_url, merged) client = self._get_twilio_client() call = await asyncio.to_thread( @@ -474,6 +485,32 @@ async def initiate_outbound_conversation( ) raise + def _resolve_outbound_action_url(self, per_call: TwiMLOptions | None) -> str | None: + """Resolve action_url for outbound calls. + + Precedence: per-call twiml_options → channel-static twiml_options → + Studio handoff. There's no server-generated cleanup URL for outbound + because the call is initiated programmatically. + """ + if ( + per_call is not None + and "action_url" in per_call.model_fields_set + and per_call.action_url is not None + ): + return per_call.action_url + if ( + self._static_twiml_options is not None + and "action_url" in self._static_twiml_options.model_fields_set + and self._static_twiml_options.action_url is not None + ): + return self._static_twiml_options.action_url + if self.tac.config.studio_handoff_flow_sid: + return studio_voice_handoff_url( + self.tac.config.account_sid, + self.tac.config.studio_handoff_flow_sid, + ) + return None + async def _handle_prompt_async( self, conv_id: str, diff --git a/src/tac/models/outbound.py b/src/tac/models/outbound.py index a131d0a..eaf3673 100644 --- a/src/tac/models/outbound.py +++ b/src/tac/models/outbound.py @@ -1,10 +1,12 @@ """Models for outbound conversation initiation.""" +import warnings from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from tac.models.session import ConversationSession +from tac.models.voice import TwiMLOptions class InitiateMessagingConversationOptions(BaseModel): @@ -52,16 +54,87 @@ class InitiateVoiceConversationOptions(BaseModel): The caller identity is always TAC's configured ``config.phone_number``. Multi-number deployments should use one TAC instance per line. + + TwiML for the outbound call is built by layering: + 1. This call's ``twiml_options`` (highest priority) + 2. ``VoiceChannelConfig.twiml_options`` (channel-wide defaults) + 3. TAC defaults (welcome greeting, conversation_configuration, + action_url resolved via Studio handoff if configured) + + Set ``voice``, ``language``, ``interruptible``, etc. on the channel's + ``VoiceChannelConfig.twiml_options`` to apply them to every call (both + inbound and outbound). Use this model's ``twiml_options`` for per-call + overrides (e.g. campaign-specific ``custom_parameters``). """ to: str = Field(..., min_length=1) - websocket_url: str = Field(...) - welcome_greeting: str | None = Field(default=None) - action_url: str | None = Field(default=None) - custom_parameters: dict[str, str | int | bool] | None = Field(default=None) + websocket_url: str = Field( + ..., + description="Public WebSocket URL for ConversationRelay, e.g. " + "'wss://your-domain.ngrok.app/ws'. Required because outbound calls " + "have no inbound HTTP request to derive the host from.", + ) + twiml_options: TwiMLOptions | None = Field( + default=None, + description="Per-call TwiMLOptions overrides. Merged over " + "VoiceChannelConfig.twiml_options and TAC defaults.", + ) + + # Deprecated flat fields. Forwarded into twiml_options in the validator + # below. Remove in a future release. + welcome_greeting: str | None = Field( + default=None, + description="DEPRECATED: set welcome_greeting on twiml_options or " + "VoiceChannelConfig.twiml_options instead.", + ) + action_url: str | None = Field( + default=None, + description="DEPRECATED: set action_url on twiml_options or " + "VoiceChannelConfig.twiml_options instead.", + ) + custom_parameters: dict[str, str | int | bool] | None = Field( + default=None, + description="DEPRECATED: set custom_parameters on twiml_options or " + "VoiceChannelConfig.twiml_options instead.", + ) model_config = {"populate_by_name": True} + @model_validator(mode="after") + def _forward_deprecated_fields(self) -> "InitiateVoiceConversationOptions": + """Forward deprecated flat fields into twiml_options with a warning. + + If both the flat field and twiml_options. are set, the explicit + twiml_options value wins. + """ + deprecated = { + "welcome_greeting": self.welcome_greeting, + "action_url": self.action_url, + "custom_parameters": self.custom_parameters, + } + set_fields = {k: v for k, v in deprecated.items() if v is not None} + if not set_fields: + return self + + warnings.warn( + "InitiateVoiceConversationOptions flat fields " + f"({', '.join(set_fields)}) are deprecated. Pass them on " + "twiml_options or configure them on VoiceChannelConfig.twiml_options.", + DeprecationWarning, + stacklevel=2, + ) + + if self.twiml_options is None: + self.twiml_options = TwiMLOptions(**set_fields) + else: + # twiml_options explicit fields win; fill in only fields the user + # didn't set on twiml_options. + already_set = self.twiml_options.model_fields_set + for key, value in set_fields.items(): + if key not in already_set: + setattr(self.twiml_options, key, value) + return self + class InitiateVoiceConversationResult(BaseModel): """Result of initiating an outbound voice conversation.""" diff --git a/tests/test_outbound.py b/tests/test_outbound.py index 51cfa4f..7c51e1a 100644 --- a/tests/test_outbound.py +++ b/tests/test_outbound.py @@ -662,6 +662,8 @@ async def test_reraises_twilio_rest_error(self) -> None: @pytest.mark.asyncio async def test_custom_parameters_in_twiml(self) -> None: + from tac.models.voice import TwiMLOptions + tac = TAC(get_test_config()) channel = VoiceChannel(tac) @@ -675,7 +677,7 @@ async def test_custom_parameters_in_twiml(self) -> None: InitiateVoiceConversationOptions( to="+15559876543", websocket_url="wss://example.com/ws", - custom_parameters={"foo": "bar"}, + twiml_options=TwiMLOptions(custom_parameters={"foo": "bar"}), ) ) @@ -685,6 +687,8 @@ async def test_custom_parameters_in_twiml(self) -> None: @pytest.mark.asyncio async def test_welcome_greeting_in_twiml(self) -> None: + from tac.models.voice import TwiMLOptions + tac = TAC(get_test_config()) channel = VoiceChannel(tac) @@ -698,13 +702,157 @@ async def test_welcome_greeting_in_twiml(self) -> None: InitiateVoiceConversationOptions( to="+15559876543", websocket_url="wss://example.com/ws", - welcome_greeting="Hi there!", + twiml_options=TwiMLOptions(welcome_greeting="Hi there!"), ) ) call_kwargs = mock_client.calls.create.call_args.kwargs assert "Hi there!" in call_kwargs["twiml"] + @pytest.mark.asyncio + async def test_channel_twiml_options_applied(self) -> None: + """VoiceChannelConfig.twiml_options flows into outbound TwiML.""" + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLOptions + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(voice="en-US-Journey-D", interruptible="speech"), + ), + ) + + mock_call = MagicMock() + mock_call.sid = "CAchan" + mock_client = MagicMock() + mock_client.calls.create.return_value = mock_call + + with patch.object(channel, "_get_twilio_client", return_value=mock_client): + await channel.initiate_outbound_conversation( + InitiateVoiceConversationOptions( + to="+15559876543", + websocket_url="wss://example.com/ws", + ) + ) + + twiml_xml = mock_client.calls.create.call_args.kwargs["twiml"] + assert 'voice="en-US-Journey-D"' in twiml_xml + assert 'interruptible="speech"' in twiml_xml + + @pytest.mark.asyncio + async def test_per_call_twiml_options_override_channel(self) -> None: + """Per-call twiml_options win over channel-static twiml_options.""" + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLOptions + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + twiml_options=TwiMLOptions(voice="en-US-Journey-D"), + ), + ) + + mock_call = MagicMock() + mock_call.sid = "CApercall" + mock_client = MagicMock() + mock_client.calls.create.return_value = mock_call + + with patch.object(channel, "_get_twilio_client", return_value=mock_client): + await channel.initiate_outbound_conversation( + InitiateVoiceConversationOptions( + to="+15559876543", + websocket_url="wss://example.com/ws", + twiml_options=TwiMLOptions(voice="es-MX-Neural2-A"), + ) + ) + + twiml_xml = mock_client.calls.create.call_args.kwargs["twiml"] + assert 'voice="es-MX-Neural2-A"' in twiml_xml + assert "en-US-Journey-D" not in twiml_xml + + @pytest.mark.asyncio + async def test_studio_handoff_used_when_no_action_url(self) -> None: + """Studio handoff URL drives action_url on outbound when no override.""" + flow_sid = "FW" + "a" * 32 + tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) + channel = VoiceChannel(tac) + + mock_call = MagicMock() + mock_call.sid = "CAstudio" + mock_client = MagicMock() + mock_client.calls.create.return_value = mock_call + + with patch.object(channel, "_get_twilio_client", return_value=mock_client): + await channel.initiate_outbound_conversation( + InitiateVoiceConversationOptions( + to="+15559876543", + websocket_url="wss://example.com/ws", + ) + ) + + twiml_xml = mock_client.calls.create.call_args.kwargs["twiml"] + assert f"Flows/{flow_sid}" in twiml_xml + + @pytest.mark.asyncio + async def test_deprecated_flat_fields_emit_warning_and_forward(self) -> None: + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + + mock_call = MagicMock() + mock_call.sid = "CAdepr" + mock_client = MagicMock() + mock_client.calls.create.return_value = mock_call + + with ( + patch.object(channel, "_get_twilio_client", return_value=mock_client), + pytest.warns(DeprecationWarning, match="flat fields"), + ): + await channel.initiate_outbound_conversation( + InitiateVoiceConversationOptions( + to="+15559876543", + websocket_url="wss://example.com/ws", + welcome_greeting="Legacy greeting", + custom_parameters={"legacy": "true"}, + ) + ) + + twiml_xml = mock_client.calls.create.call_args.kwargs["twiml"] + assert "Legacy greeting" in twiml_xml + assert 'name="legacy"' in twiml_xml + + @pytest.mark.asyncio + async def test_deprecated_flat_fields_lose_to_explicit_twiml_options(self) -> None: + """If both flat welcome_greeting and twiml_options.welcome_greeting are + set, the twiml_options value wins (the user's explicit modern call).""" + from tac.models.voice import TwiMLOptions + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + + mock_call = MagicMock() + mock_call.sid = "CAboth" + mock_client = MagicMock() + mock_client.calls.create.return_value = mock_call + + with ( + patch.object(channel, "_get_twilio_client", return_value=mock_client), + pytest.warns(DeprecationWarning), + ): + await channel.initiate_outbound_conversation( + InitiateVoiceConversationOptions( + to="+15559876543", + websocket_url="wss://example.com/ws", + welcome_greeting="Legacy", + twiml_options=TwiMLOptions(welcome_greeting="Modern"), + ) + ) + + twiml_xml = mock_client.calls.create.call_args.kwargs["twiml"] + assert "Modern" in twiml_xml + assert "Legacy" not in twiml_xml + # ============================================================================= # RCS outbound From a937b8bf5c91859b5e1e6e63d5f386fe3ce2a5a3 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 15:17:18 -0400 Subject: [PATCH 16/32] docs(voice): clarify per-field merge semantics on outbound twiml_options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make it explicit that twiml_options layers merge per-field, not wholesale — setting twiml_options=TwiMLOptions(voice="X") on a call overrides only voice; other fields still inherit from VoiceChannelConfig.twiml_options. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 6 +++++- src/tac/models/outbound.py | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 84bba2c..c3fc521 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -428,12 +428,16 @@ async def initiate_outbound_conversation( conversation during passive hydration. The session is initialized lazily on the first prompt when the conversation is discovered by callSid. - TwiML merge precedence (highest to lowest): + TwiML fields are merged per-field, highest precedence first: 1. ``options.twiml_options`` — per-call overrides 2. ``VoiceChannelConfig.twiml_options`` — channel-wide defaults 3. TAC defaults: welcome greeting, ``conversation_configuration`` from ``TACConfig``, and ``action_url`` from Studio handoff (if configured). + 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. + ``options.websocket_url`` must be the publicly accessible WebSocket endpoint (e.g., ``wss://your-domain.ngrok.app/ws``). Unlike inbound calls where TACServer sets this automatically, outbound calls require it diff --git a/src/tac/models/outbound.py b/src/tac/models/outbound.py index eaf3673..bd69b3f 100644 --- a/src/tac/models/outbound.py +++ b/src/tac/models/outbound.py @@ -55,12 +55,18 @@ class InitiateVoiceConversationOptions(BaseModel): The caller identity is always TAC's configured ``config.phone_number``. Multi-number deployments should use one TAC instance per line. - TwiML for the outbound call is built by layering: - 1. This call's ``twiml_options`` (highest priority) + TwiML for the outbound call is built by merging per-field, highest + precedence first: + 1. This call's ``twiml_options`` (per-call overrides) 2. ``VoiceChannelConfig.twiml_options`` (channel-wide defaults) 3. TAC defaults (welcome greeting, conversation_configuration, action_url resolved via Studio handoff if configured) + Fields you don't set at a layer fall through to lower layers — so + ``twiml_options=TwiMLOptions(voice="es-MX-Neural2-A")`` on this call + overrides only ``voice``; ``language``, ``interruptible``, etc. from the + channel config still apply. + Set ``voice``, ``language``, ``interruptible``, etc. on the channel's ``VoiceChannelConfig.twiml_options`` to apply them to every call (both inbound and outbound). Use this model's ``twiml_options`` for per-call From fc75195f5e6dcf47ce550ed9137c49ab33046656 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 15:19:19 -0400 Subject: [PATCH 17/32] docs(examples): call out per-call vs channel-wide TwiML customization The outbound example was passing twiml_options inline with no explanation of why at the call site vs. on the channel. Add a comment pointing at both layers and when to use which (per-recipient values per-call; stable voice/language on the channel). The TwiML customization example didn't mention that channel twiml_options applies to outbound too, or that the customizer is inbound-only. Add a one-sentence note so readers know which tool fits their call direction. Co-Authored-By: Claude Opus 4.7 (1M context) --- getting_started/examples/features/outbound.py | 9 +++++++++ .../examples/features/voice_twiml_customization.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/getting_started/examples/features/outbound.py b/getting_started/examples/features/outbound.py index 44b2d77..750b413 100644 --- a/getting_started/examples/features/outbound.py +++ b/getting_started/examples/features/outbound.py @@ -169,6 +169,15 @@ async def initiate_outbound(args: argparse.Namespace) -> None: print("TWILIO_VOICE_PUBLIC_DOMAIN is required for voice calls.") sys.exit(1) + # TwiML can be customized at two layers (merged per-field, + # highest precedence first): + # - Per-call: pass `twiml_options=` here (used below for the + # greeting, which varies per recipient/campaign, and the + # action_url, which depends on the server's public domain). + # - Channel-wide: set `twiml_options=` on VoiceChannelConfig + # for settings that don't vary per call — voice, language, + # interruptible, children, etc. Those apply to + # both inbound and outbound calls. voice_result = await voice_channel.initiate_outbound_conversation( InitiateVoiceConversationOptions( to=args.to, diff --git a/getting_started/examples/features/voice_twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py index 36dd61a..00bd963 100644 --- a/getting_started/examples/features/voice_twiml_customization.py +++ b/getting_started/examples/features/voice_twiml_customization.py @@ -11,6 +11,11 @@ The customizer wins over static options, which win over TAC defaults. +Channel ``twiml_options`` applies to both inbound and outbound calls +(``initiate_outbound_conversation``). The customizer only runs for inbound +calls — outbound calls receive per-call TwiML via +``InitiateVoiceConversationOptions.twiml_options`` at each call site instead. + This example shows the static path (voice + language the same for every call). The customizer version for per-call localization is below in a commented block — uncomment if you need it. From f078b965d33c11a1dd557bd5cb4a90cb8d37acce Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 14 May 2026 15:25:31 -0400 Subject: [PATCH 18/32] =?UTF-8?q?docs(examples):=20trim=20outbound=20TwiML?= =?UTF-8?q?=20comment=20=E2=80=94=20point=20at=20inbound=20equivalent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment had grown into a design doc. Users reading an example just need to know per-call exists, inbound has its own equivalent, and channel-wide covers the rest. Co-Authored-By: Claude Opus 4.7 (1M context) --- getting_started/examples/features/outbound.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/getting_started/examples/features/outbound.py b/getting_started/examples/features/outbound.py index 750b413..cade93a 100644 --- a/getting_started/examples/features/outbound.py +++ b/getting_started/examples/features/outbound.py @@ -169,15 +169,10 @@ async def initiate_outbound(args: argparse.Namespace) -> None: print("TWILIO_VOICE_PUBLIC_DOMAIN is required for voice calls.") sys.exit(1) - # TwiML can be customized at two layers (merged per-field, - # highest precedence first): - # - Per-call: pass `twiml_options=` here (used below for the - # greeting, which varies per recipient/campaign, and the - # action_url, which depends on the server's public domain). - # - Channel-wide: set `twiml_options=` on VoiceChannelConfig - # for settings that don't vary per call — voice, language, - # interruptible, children, etc. Those apply to - # both inbound and outbound calls. + # Per-call TwiML for outbound calls. Inbound's equivalent is + # `customize_twiml_options` on VoiceChannelConfig (see + # voice_twiml_customization.py). Use VoiceChannelConfig.twiml_options + # for settings that don't vary per call (voice, language, etc.). voice_result = await voice_channel.initiate_outbound_conversation( InitiateVoiceConversationOptions( to=args.to, From 93e3ce3ad1ef6d4924462f746fccd124c5ca1c24 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Mon, 18 May 2026 12:16:19 -0400 Subject: [PATCH 19/32] refactor(voice): centralize TwiML config on VoiceChannelConfig; rename for scope clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related cleanups, committed together because the call-site changes overlap. 1. Move WebSocket and action URLs from per-call VoiceEndpoints onto VoiceChannelConfig. The voice channel now reads its URLs from self.config (set once at startup), not from arguments threaded through handle_incoming_call. TACFastAPIServer populates them in __init__ from public_domain + paths and now raises if public_domain is missing when a voice channel is configured (was a soft warning). - VoiceEndpoints model removed entirely. - handle_incoming_call(twiml_request=...) — no more endpoints arg. - initiate_outbound_conversation: websocket_url is now optional on InitiateVoiceConversationOptions, falling back to the channel's configured URL. - Custom adapters (Flask, Django, etc.) set websocket_url/action_url on VoiceChannelConfig directly — same field, one place. 2. Rename for scope-explicit naming on VoiceChannelConfig: twiml_options → default_twiml_options customize_twiml_options → customize_inbound_twiml The duplicate `twiml_options` name on VoiceChannelConfig vs. InitiateVoiceConversationOptions was confusing — same name, different scope. `default_twiml_options` makes the channel-wide default explicit; `customize_inbound_twiml` makes the inbound-only scope visible at the call site (and the absence of a `customize_outbound_twiml` becomes a deliberate-looking gap rather than oversight). Add a top-of-class merge-pipeline summary on VoiceChannelConfig so the full layering is documented in one place instead of scattered across four method docstrings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../features/voice_twiml_customization.py | 6 +- src/tac/channels/voice/channel.py | 136 ++++++++---------- src/tac/channels/voice/config.py | 76 +++++++--- src/tac/models/outbound.py | 11 +- src/tac/models/voice.py | 27 +--- src/tac/server/fastapi_server.py | 69 +++++---- tests/test_outbound.py | 4 +- tests/test_relay_only_mode.py | 9 +- tests/test_server.py | 4 +- tests/test_voice_channel.py | 131 +++++++---------- tests/test_voice_models.py | 24 ---- 11 files changed, 228 insertions(+), 269 deletions(-) diff --git a/getting_started/examples/features/voice_twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py index 00bd963..b762413 100644 --- a/getting_started/examples/features/voice_twiml_customization.py +++ b/getting_started/examples/features/voice_twiml_customization.py @@ -55,7 +55,7 @@ async def handle_message_ready( voice_channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions( + default_twiml_options=TwiMLOptions( welcome_greeting="Hello! How can I help?", voice="en-US-Journey-D", language="en-US", @@ -103,8 +103,8 @@ async def handle_message_ready( # voice_channel = VoiceChannel( # tac, # config=VoiceChannelConfig( -# twiml_options=TwiMLOptions(welcome_greeting="Hello! How can I help?"), -# customize_twiml_options=customize_twiml, +# default_twiml_options=TwiMLOptions(welcome_greeting="Hello! How can I help?"), +# customize_inbound_twiml=customize_twiml, # ), # ) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index c3fc521..eb541ce 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -23,7 +23,6 @@ SetupMessage, TwiMLOptions, TwiMLRequest, - VoiceEndpoints, ) from tac.session import SessionState from tac.tools.handoff import studio_voice_handoff_url @@ -80,8 +79,8 @@ def __init__( super().__init__(tac, memory_mode=config.memory_mode) self.config = config self.session_manager = config.session_manager - self._static_twiml_options = config.twiml_options - self._customize_twiml_options = config.customize_twiml_options + self._default_twiml_options = config.default_twiml_options + self._customize_inbound_twiml = config.customize_inbound_twiml # Populated by TACFastAPIServer from the deprecated # TACServerConfig.welcome_greeting. Used only as a fallback when # twiml_options/customizer don't set welcome_greeting. Remove when @@ -90,6 +89,18 @@ def __init__( self._websocket_manager = WebSocketManager() self._twilio_client: Client | None = None + def _require_websocket_url(self, action: str) -> str: + """Return the channel's configured websocket_url, or raise with a clear + message naming the action that needed it. + """ + if not self.config.websocket_url: + raise ValueError( + f"{action} requires VoiceChannelConfig.websocket_url to be set. " + "TACFastAPIServer configures this automatically from its " + "public_domain; custom adapters must set it on VoiceChannelConfig." + ) + return self.config.websocket_url + @staticmethod def _caller_address(setup_msg: SetupMessage) -> str | None: """Return the phone number of the remote caller/callee from the setup message.""" @@ -110,7 +121,6 @@ def _get_twilio_client(self) -> Client: async def handle_incoming_call( self, - endpoints: VoiceEndpoints, twiml_request: TwiMLRequest | None = None, ) -> str: """ @@ -119,36 +129,38 @@ async def handle_incoming_call( ConversationRelay automatically handles conversation creation and participant management via the ``conversation_configuration`` parameter. - Merge precedence (highest to lowest): + The WebSocket URL and default session-cleanup action URL come from + ``VoiceChannelConfig`` (``websocket_url`` / ``action_url``). + ``TACFastAPIServer`` sets them automatically; custom adapters must set + them on the config before calling. - 1. Output of ``VoiceChannelConfig.customize_twiml_options`` if configured - and ``twiml_request`` is given. Fields it explicitly sets win. - 2. ``VoiceChannelConfig.twiml_options`` — static per-channel defaults. - 3. TAC defaults: a fixed default ``welcome_greeting``, - ``conversation_configuration`` from ``TACConfig``, and ``action_url`` - resolved via Studio handoff (when ``studio_handoff_flow_sid`` is - configured), else ``endpoints.action_url`` (the SDK-generated - session-cleanup default). + TwiML fields are merged per-field, highest precedence first: + 1. Output of ``VoiceChannelConfig.customize_inbound_twiml`` if + configured and ``twiml_request`` is given. Fields it explicitly + set win. + 2. ``VoiceChannelConfig.default_twiml_options`` — per-channel defaults. + 3. TAC defaults: a fixed default ``welcome_greeting``, + ``conversation_configuration`` from ``TACConfig``, and ``action_url`` + resolved via Studio handoff (when ``studio_handoff_flow_sid`` is + configured), else ``VoiceChannelConfig.action_url``. - Lists (``languages``) and nested models (``custom_parameters``) replace + 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. Args: - endpoints: Absolute URLs Twilio calls back to. The server (or a - custom adapter) builds these from its public domain. The - ``action_url`` is used as the default - ```` in relay-only mode so session - cleanup fires when calls end. twiml_request: Parsed Twilio webhook fields. Passed to the customizer if one is configured on the channel. Returns: TwiML XML string for call connection. """ + websocket_url = self._require_websocket_url("handle_incoming_call") + # Invoke the customizer if configured and we have a request context. customized: TwiMLOptions | None = None - if self._customize_twiml_options is not None and twiml_request is not None: - customized = await self._customize_twiml_options(twiml_request) + if self._customize_inbound_twiml is not None and twiml_request is not None: + customized = await self._customize_inbound_twiml(twiml_request) # Start from TAC defaults. welcome_greeting prefers (in order): # deprecated server value → SDK default. @@ -156,18 +168,18 @@ async def handle_incoming_call( merged = TwiMLOptions( welcome_greeting=(self._deprecated_server_welcome_greeting or DEFAULT_WELCOME_GREETING), conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_action_url(customized, endpoints), + action_url=self._resolve_action_url(customized), ) - # Overlay channel-static twiml_options. - if self._static_twiml_options is not None: - self._overlay_fields(merged, self._static_twiml_options) + # Overlay channel default_twiml_options. + if self._default_twiml_options is not None: + self._overlay_fields(merged, self._default_twiml_options) # Overlay customizer output (highest priority). if customized is not None: self._overlay_fields(merged, customized) - return twiml.generate_twiml(endpoints.websocket_url, merged) + return twiml.generate_twiml(websocket_url, merged) @staticmethod def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: @@ -185,18 +197,15 @@ def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: continue setattr(target, field, getattr(source, field)) - def _resolve_action_url( - self, - customized: TwiMLOptions | None, - endpoints: VoiceEndpoints, - ) -> str | None: + def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: """Resolve the TwiML ```` URL. Precedence (highest to lowest): 1. customizer - 2. channel-static ``twiml_options`` + 2. channel ``default_twiml_options`` 3. Studio handoff (when ``studio_handoff_flow_sid`` is configured) - 4. ``endpoints.action_url`` (SDK-generated session-cleanup default) + 4. ``VoiceChannelConfig.action_url`` (SDK-generated session-cleanup + default, usually set by ``TACFastAPIServer``). User-expressed intent (Studio handoff is configured explicitly on ``TACConfig``) beats the SDK's generated cleanup default. If a user @@ -211,19 +220,17 @@ def _resolve_action_url( ): return customized.action_url if ( - self._static_twiml_options is not None - and "action_url" in self._static_twiml_options.model_fields_set - and self._static_twiml_options.action_url is not None + self._default_twiml_options is not None + and "action_url" in self._default_twiml_options.model_fields_set + and self._default_twiml_options.action_url is not None ): - return self._static_twiml_options.action_url + return self._default_twiml_options.action_url if self.tac.config.studio_handoff_flow_sid: return studio_voice_handoff_url( self.tac.config.account_sid, self.tac.config.studio_handoff_flow_sid, ) - if endpoints.action_url is not None: - return endpoints.action_url - return None + return self.config.action_url async def handle_conversation_relay_callback( self, @@ -430,19 +437,22 @@ async def initiate_outbound_conversation( TwiML fields are merged per-field, highest precedence first: 1. ``options.twiml_options`` — per-call overrides - 2. ``VoiceChannelConfig.twiml_options`` — channel-wide defaults + 2. ``VoiceChannelConfig.default_twiml_options`` — channel-wide defaults 3. TAC defaults: welcome greeting, ``conversation_configuration`` from - ``TACConfig``, and ``action_url`` from Studio handoff (if configured). + ``TACConfig``, and ``action_url`` from Studio handoff (if configured), + else ``VoiceChannelConfig.action_url``. 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. - ``options.websocket_url`` must be the publicly accessible WebSocket - endpoint (e.g., ``wss://your-domain.ngrok.app/ws``). Unlike inbound calls - where TACServer sets this automatically, outbound calls require it - explicitly since there is no incoming HTTP request to derive the host from. + The WebSocket URL is read from ``VoiceChannelConfig.websocket_url`` + (set automatically by ``TACFastAPIServer``) unless overridden per-call + via ``options.websocket_url``. """ + websocket_url = options.websocket_url or self._require_websocket_url( + "initiate_outbound_conversation" + ) from_number = self.tac.config.phone_number self.logger.info( @@ -457,15 +467,15 @@ async def initiate_outbound_conversation( merged = TwiMLOptions( welcome_greeting=(self._deprecated_server_welcome_greeting or DEFAULT_WELCOME_GREETING), conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_outbound_action_url(options.twiml_options), + action_url=self._resolve_action_url(options.twiml_options), ) - if self._static_twiml_options is not None: - self._overlay_fields(merged, self._static_twiml_options) + if self._default_twiml_options is not None: + self._overlay_fields(merged, self._default_twiml_options) if options.twiml_options is not None: self._overlay_fields(merged, options.twiml_options) try: - twiml_xml = twiml.generate_twiml(options.websocket_url, merged) + twiml_xml = twiml.generate_twiml(websocket_url, merged) client = self._get_twilio_client() call = await asyncio.to_thread( @@ -489,32 +499,6 @@ async def initiate_outbound_conversation( ) raise - def _resolve_outbound_action_url(self, per_call: TwiMLOptions | None) -> str | None: - """Resolve action_url for outbound calls. - - Precedence: per-call twiml_options → channel-static twiml_options → - Studio handoff. There's no server-generated cleanup URL for outbound - because the call is initiated programmatically. - """ - if ( - per_call is not None - and "action_url" in per_call.model_fields_set - and per_call.action_url is not None - ): - return per_call.action_url - if ( - self._static_twiml_options is not None - and "action_url" in self._static_twiml_options.model_fields_set - and self._static_twiml_options.action_url is not None - ): - return self._static_twiml_options.action_url - if self.tac.config.studio_handoff_flow_sid: - return studio_voice_handoff_url( - self.tac.config.account_sid, - self.tac.config.studio_handoff_flow_sid, - ) - return None - async def _handle_prompt_async( self, conv_id: str, diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index 5842f0b..b63043b 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -8,13 +8,29 @@ from tac.models.voice import TwiMLOptions, TwiMLRequest from tac.session import SessionManager, ThreadSafeSessionManager -TwiMLOptionsCustomizer = Callable[[TwiMLRequest], Awaitable[TwiMLOptions]] +InboundTwiMLCustomizer = Callable[[TwiMLRequest], Awaitable[TwiMLOptions]] class VoiceChannelConfig(BaseModel): """ Configuration for Voice channel. + TwiML configuration layers (highest precedence first): + + Inbound calls (``handle_incoming_call``): + 1. ``customize_inbound_twiml(twiml_request)`` output [optional] + 2. ``default_twiml_options`` [optional] + 3. TAC defaults + + Outbound calls (``initiate_outbound_conversation``): + 1. ``InitiateVoiceConversationOptions.twiml_options`` [optional] + 2. ``default_twiml_options`` [optional] + 3. TAC defaults + + All layers merge per-field via Pydantic's ``model_fields_set`` — only + fields a layer explicitly sets override lower layers. Lists (``languages``) + and nested models (``custom_parameters``) replace wholesale when set. + Attributes: session_manager: SessionManager for tracking and canceling in-flight tasks. Defaults to ThreadSafeSessionManager for automatic task cancellation on @@ -24,16 +40,29 @@ class VoiceChannelConfig(BaseModel): - "once": Retrieve memory once at conversation start with empty query and cache it. Cache is invalidated when conversation becomes INACTIVE. - "never": Skip memory retrieval - twiml_options: Static ``TwiMLOptions`` applied to every call (voice, - language, transcription provider, welcome_greeting, ```` - children, etc.). Use this when the same ConversationRelay - configuration is correct for every call. For per-call customization - see ``customize_twiml_options``. - customize_twiml_options: Optional async callable producing per-call - ``TwiMLOptions`` overrides. Receives a framework-neutral - ``TwiMLRequest`` (parsed Twilio webhook fields). Any field the - function explicitly sets wins over ``twiml_options`` and TAC defaults; - unset fields fall through. + websocket_url: Public WebSocket URL for ConversationRelay (e.g. + ``wss://example.ngrok.app/ws``). Required for outbound calls and + any call made via ``handle_incoming_call``. ``TACFastAPIServer`` + builds and sets this automatically from its ``public_domain`` + + ``websocket_path``; custom adapters (Flask, Django, …) must set + it themselves. + action_url: Public HTTPS URL for the TwiML ````, + used as the default session-cleanup callback. ``TACFastAPIServer`` + builds and sets this from ``public_domain`` + + ``conversation_relay_callback_path``. Higher-priority layers + (customizer, per-call ``twiml_options.action_url``, Studio + handoff) still override. + default_twiml_options: Static ``TwiMLOptions`` applied to every call + (inbound and outbound) — voice, language, transcription provider, + welcome_greeting, ```` children, etc. Use this when the + same ConversationRelay configuration is correct for every call. + customize_inbound_twiml: Optional async callable producing per-call + ``TwiMLOptions`` overrides for inbound calls. Receives a + framework-neutral ``TwiMLRequest`` (parsed Twilio webhook fields). + Outbound calls don't use this — they pass per-call TwiML via + ``InitiateVoiceConversationOptions.twiml_options`` directly, + because outbound is initiated from user code that already has + per-call context in scope. """ model_config = {"arbitrary_types_allowed": True, "extra": "forbid"} @@ -49,14 +78,25 @@ class VoiceChannelConfig(BaseModel): default="never", description="Memory retrieval mode for this channel", ) - twiml_options: TwiMLOptions | None = Field( + websocket_url: str | None = Field( + default=None, + description="Public WebSocket URL for ConversationRelay. Set by " + "TACFastAPIServer automatically; custom adapters must provide it.", + ) + action_url: str | None = Field( + default=None, + description="Public HTTPS URL for session cleanup. " + "Set by TACFastAPIServer automatically; overridable by customizer, " + "per-call twiml_options.action_url, or Studio handoff.", + ) + default_twiml_options: TwiMLOptions | None = Field( default=None, - description="Static TwiMLOptions applied to every call. Use for same-on-every-call " - "configuration; use customize_twiml_options for per-call logic.", + description="Static TwiMLOptions applied to every call (inbound and " + "outbound). For per-call customization see customize_inbound_twiml " + "or InitiateVoiceConversationOptions.twiml_options.", ) - customize_twiml_options: TwiMLOptionsCustomizer | None = Field( + customize_inbound_twiml: InboundTwiMLCustomizer | None = Field( default=None, - description="Optional async callable returning per-call TwiMLOptions overrides. " - "Receives a TwiMLRequest; only fields explicitly set on the returned " - "options override lower layers.", + description="Optional async callable returning per-call TwiMLOptions " + "overrides on inbound calls. Not invoked on outbound calls.", ) diff --git a/src/tac/models/outbound.py b/src/tac/models/outbound.py index bd69b3f..b386c27 100644 --- a/src/tac/models/outbound.py +++ b/src/tac/models/outbound.py @@ -74,11 +74,12 @@ class InitiateVoiceConversationOptions(BaseModel): """ to: str = Field(..., min_length=1) - websocket_url: str = Field( - ..., - description="Public WebSocket URL for ConversationRelay, e.g. " - "'wss://your-domain.ngrok.app/ws'. Required because outbound calls " - "have no inbound HTTP request to derive the host from.", + websocket_url: str | None = Field( + default=None, + description="Public WebSocket URL for ConversationRelay (e.g. " + "'wss://your-domain.ngrok.app/ws'). Optional — defaults to " + "``VoiceChannelConfig.websocket_url`` when not provided. Pass it here " + "only to override the channel's URL for a specific call.", ) twiml_options: TwiMLOptions | None = Field( default=None, diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 2244bc7..8492ccb 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -160,31 +160,6 @@ class LanguageConfig(BaseModel): model_config = {"populate_by_name": True} -class VoiceEndpoints(BaseModel): - """Absolute URLs Twilio calls back to for a given voice deployment. - - These are server-owned — they depend on where the app is hosted - (``public_domain`` plus path). The voice channel accepts them as input - rather than building them itself, so adapters for other web frameworks - (FastAPI, Flask, Django, …) can supply their own and the channel stays - framework-agnostic. - """ - - websocket_url: str = Field( - ..., - description="Public WebSocket URL for ConversationRelay, e.g. " - "'wss://example.ngrok.app/ws'.", - ) - action_url: str | None = Field( - default=None, - description="Public HTTPS URL for the TwiML used as " - "the SDK-generated session-cleanup default. Channel-level overrides " - "(customizer, static twiml_options, Studio handoff) take precedence.", - ) - - model_config = {"populate_by_name": True} - - class TwiMLOptions(BaseModel): """Options for generating ConversationRelay TwiML. @@ -349,7 +324,7 @@ class TwiMLRequest(BaseModel): """Framework-neutral view of the Twilio TwiML webhook form. Populated by ``TACFastAPIServer`` from the incoming Twilio webhook, then - passed to an optional ``customize_twiml_options`` so the application can + passed to an optional ``customize_inbound_twiml`` so the application can produce per-call ``TwiMLOptions`` overrides without depending on FastAPI types. """ diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index b7547d3..42d3e8f 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -16,7 +16,7 @@ from tac.channels.websocket_protocol import WebSocketDisconnectError from tac.core.logging import get_logger from tac.core.tac import TAC -from tac.models.voice import TwiMLRequest, VoiceEndpoints +from tac.models.voice import TwiMLRequest from tac.server.config import TACServerConfig if TYPE_CHECKING: @@ -83,12 +83,11 @@ class TACFastAPIServer: exception handlers, routers, or custom routes — before calling ``start()``. - To customize TwiML attributes (voice, language, transcription provider, - interruption behavior, ```` children, etc.) set a - ``customize_twiml_options`` on ``VoiceChannelConfig``. The customizer - receives a framework-neutral ``TwiMLRequest`` and returns a - ``TwiMLOptions``; any field it explicitly sets overrides TAC defaults. - For same-on-every-call settings, set ``twiml_options`` on - ``VoiceChannelConfig`` directly. + interruption behavior, ```` children, etc.) set + ``default_twiml_options`` on ``VoiceChannelConfig`` for same-on-every-call + settings. For per-call inbound customization, set + ``customize_inbound_twiml`` — an async callable that receives a + framework-neutral ``TwiMLRequest`` and returns a ``TwiMLOptions``. Example: from fastapi import FastAPI @@ -119,12 +118,16 @@ def __init__( self.voice_channel = voice_channel self.messaging_channels: list[MessagingChannel] = messaging_channels or [] - # Forward deprecated TACServerConfig.welcome_greeting to the voice - # channel as a fallback default. twiml_options.welcome_greeting (if set) - # still wins over this. Drop this forwarding when the field is removed - # from TACServerConfig. - if self.config.welcome_greeting is not None and self.voice_channel is not None: - self.voice_channel._deprecated_server_welcome_greeting = self.config.welcome_greeting + if self.voice_channel is not None: + self._configure_voice_channel_urls() + # Forward deprecated TACServerConfig.welcome_greeting to the voice + # channel as a fallback default. twiml_options.welcome_greeting + # (if set) still wins over this. Drop when the field is removed + # from TACServerConfig. + if self.config.welcome_greeting is not None: + self.voice_channel._deprecated_server_welcome_greeting = ( + self.config.welcome_greeting + ) # Gather all channels that need webhook processing self.webhook_channels: list[BaseChannel] = [] @@ -135,6 +138,28 @@ def __init__( self.app: FastAPI = app if app is not None else FastAPI(title="TAC Server") self._register_routes(self.app) + def _configure_voice_channel_urls(self) -> None: + """Populate the voice channel's websocket_url and action_url from + server config, if the user didn't already set them on + ``VoiceChannelConfig``. Requires ``public_domain`` to be set. + """ + assert self.voice_channel is not None # checked by caller + if not self.config.public_domain: + raise ValueError( + "TACFastAPIServer requires public_domain when a voice_channel " + "is configured. Set TWILIO_VOICE_PUBLIC_DOMAIN or pass " + "public_domain=... on TACServerConfig." + ) + vc_config = self.voice_channel.config + if vc_config.websocket_url is None: + vc_config.websocket_url = ( + f"wss://{self.config.public_domain}{self.config.websocket_path}" + ) + if vc_config.action_url is None: + vc_config.action_url = ( + f"https://{self.config.public_domain}{self.config.conversation_relay_callback_path}" + ) + def _register_routes(self, app: FastAPI) -> None: """Register TAC routes (conversation webhook, voice, CI) onto the given FastAPI app.""" config = self.config @@ -195,30 +220,14 @@ async def conversation_webhook(request: Request) -> JSONResponse: if self.voice_channel is not None: vc = self.voice_channel - if not config.public_domain: - logger.warning( - "public_domain is not set — voice URLs will be malformed. " - "Set TWILIO_VOICE_PUBLIC_DOMAIN environment variable." - ) - @app.post(config.twiml_path, dependencies=[Depends(http_sig)]) async def post_twiml(request: Request) -> Response: """Generate TwiML for incoming voice calls.""" - endpoints = VoiceEndpoints( - websocket_url=f"wss://{config.public_domain}{config.websocket_path}", - action_url=( - f"https://{config.public_domain}{config.conversation_relay_callback_path}" - ), - ) - form = await request.form() form_dict = {k: v for k, v in form.items() if isinstance(v, str)} twiml_request = TwiMLRequest.from_form(form_dict) - twiml = await vc.handle_incoming_call( - endpoints, - twiml_request=twiml_request, - ) + twiml = await vc.handle_incoming_call(twiml_request=twiml_request) return Response(content=twiml, media_type="application/xml") @app.websocket(config.websocket_path) diff --git a/tests/test_outbound.py b/tests/test_outbound.py index 7c51e1a..4220ecc 100644 --- a/tests/test_outbound.py +++ b/tests/test_outbound.py @@ -719,7 +719,7 @@ async def test_channel_twiml_options_applied(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(voice="en-US-Journey-D", interruptible="speech"), + default_twiml_options=TwiMLOptions(voice="en-US-Journey-D", interruptible="speech"), ), ) @@ -750,7 +750,7 @@ async def test_per_call_twiml_options_override_channel(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(voice="en-US-Journey-D"), + default_twiml_options=TwiMLOptions(voice="en-US-Journey-D"), ), ) diff --git a/tests/test_relay_only_mode.py b/tests/test_relay_only_mode.py index 71a6361..2e3a91a 100644 --- a/tests/test_relay_only_mode.py +++ b/tests/test_relay_only_mode.py @@ -88,19 +88,18 @@ async def test_retrieve_memory_returns_empty_in_relay_only(self) -> None: async def test_handle_incoming_call_twiml_omits_conversation_configuration(self) -> None: """TwiML does not include conversationConfiguration in relay-only mode.""" from tac.channels.voice import VoiceChannelConfig - from tac.models.voice import TwiMLOptions, VoiceEndpoints + from tac.models.voice import TwiMLOptions tac = TAC(relay_only_config()) channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(welcome_greeting="Hello"), + default_twiml_options=TwiMLOptions(welcome_greeting="Hello"), ), ) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), - ) + channel.config.websocket_url = "wss://example.com/ws" + twiml = await channel.handle_incoming_call() assert "conversationConfiguration" not in twiml diff --git a/tests/test_server.py b/tests/test_server.py index 4cc4e4d..54a03d3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -813,7 +813,7 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(voice="en-US-Journey-D", language="en-US") tac = TAC(get_test_config()) - vc = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) + vc = VoiceChannel(tac, config=VoiceChannelConfig(customize_inbound_twiml=customizer)) server = TACFastAPIServer( tac=tac, config=TACServerConfig(public_domain="test.ngrok.io"), @@ -879,7 +879,7 @@ def test_twiml_options_greeting_wins_over_deprecated_server_field(self) -> None: vc = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(welcome_greeting="Channel!"), + default_twiml_options=TwiMLOptions(welcome_greeting="Channel!"), ), ) with pytest.warns(DeprecationWarning): diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index c71bf71..2ebf20e 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -17,7 +17,6 @@ InterruptMessage, PromptMessage, TwiMLOptions, - VoiceEndpoints, ) @@ -458,16 +457,13 @@ async def test_handle_incoming_call(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(welcome_greeting="Welcome!"), + default_twiml_options=TwiMLOptions(welcome_greeting="Welcome!"), ), ) - twiml = await channel.handle_incoming_call( - VoiceEndpoints( - websocket_url="wss://example.ngrok.io/ws", - action_url="https://example.ngrok.io/flex_handoff", - ), - ) + channel.config.websocket_url = "wss://example.ngrok.io/ws" + channel.config.action_url = "https://example.ngrok.io/flex_handoff" + twiml = await channel.handle_incoming_call() assert '' in twiml assert "" in twiml @@ -485,12 +481,9 @@ async def test_handle_incoming_call_default_greeting(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - VoiceEndpoints( - websocket_url="wss://test.ngrok.io/ws", - action_url="https://example.ngrok.io/flex_handoff", - ), - ) + channel.config.websocket_url = "wss://test.ngrok.io/ws" + channel.config.action_url = "https://example.ngrok.io/flex_handoff" + twiml = await channel.handle_incoming_call() assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml assert 'conversationConfiguration="conv_configuration_test123"' in twiml @@ -1159,7 +1152,7 @@ async def test_handle_incoming_call_with_additional_parameters(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions( + default_twiml_options=TwiMLOptions( welcome_greeting="Welcome!", custom_parameters={ "session_id": "sess_abc123", @@ -1170,12 +1163,9 @@ async def test_handle_incoming_call_with_additional_parameters(self) -> None: ), ) - twiml = await channel.handle_incoming_call( - VoiceEndpoints( - websocket_url="wss://example.ngrok.io/ws", - action_url="https://example.ngrok.io/callback", - ), - ) + channel.config.websocket_url = "wss://example.ngrok.io/ws" + channel.config.action_url = "https://example.ngrok.io/callback" + twiml = await channel.handle_incoming_call() assert 'conversationConfiguration="conv_configuration_test123"' in twiml assert '' in twiml @@ -1188,9 +1178,8 @@ async def test_handle_incoming_call_without_additional_parameters(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.ngrok.io/ws"), - ) + channel.config.websocket_url = "wss://example.ngrok.io/ws" + twiml = await channel.handle_incoming_call() assert 'conversationConfiguration="conv_configuration_test123"' in twiml assert "session_id" not in twiml @@ -1431,9 +1420,8 @@ class TestHandleIncomingCallMerge: async def test_tac_defaults_applied(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), - ) + channel.config.websocket_url = "wss://example.com/ws" + twiml = await channel.handle_incoming_call() assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml assert 'conversationConfiguration="conv_configuration_test123"' in twiml @@ -1445,12 +1433,13 @@ async def test_static_options_override_conversation_configuration(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(conversation_configuration="conv_configuration_custom"), + default_twiml_options=TwiMLOptions( + conversation_configuration="conv_configuration_custom" + ), ), ) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), - ) + channel.config.websocket_url = "wss://example.com/ws" + twiml = await channel.handle_incoming_call() assert 'conversationConfiguration="conv_configuration_custom"' in twiml assert "conv_configuration_test123" not in twiml @@ -1460,9 +1449,8 @@ async def test_studio_handoff_used_when_flow_sid_set(self) -> None: flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), - ) + channel.config.websocket_url = "wss://example.com/ws" + twiml = await channel.handle_incoming_call() expected = ( f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' f'/Flows/{flow_sid}?Trigger=incomingCall"' @@ -1478,12 +1466,9 @@ async def test_studio_handoff_beats_server_action_url(self) -> None: flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - VoiceEndpoints( - websocket_url="wss://example.com/ws", - action_url="https://cleanup.example.com/end", - ), - ) + channel.config.websocket_url = "wss://example.com/ws" + channel.config.action_url = "https://cleanup.example.com/end" + twiml = await channel.handle_incoming_call() expected = ( f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' f'/Flows/{flow_sid}?Trigger=incomingCall"' @@ -1495,21 +1480,17 @@ async def test_studio_handoff_beats_server_action_url(self) -> None: async def test_action_url_uses_server_url(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - VoiceEndpoints( - websocket_url="wss://example.com/ws", - action_url="https://fallback.example.com/end", - ), - ) + channel.config.websocket_url = "wss://example.com/ws" + channel.config.action_url = "https://fallback.example.com/end" + twiml = await channel.handle_incoming_call() assert 'action="https://fallback.example.com/end"' in twiml @pytest.mark.asyncio async def test_action_url_omitted_when_no_server_url(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), - ) + channel.config.websocket_url = "wss://example.com/ws" + twiml = await channel.handle_incoming_call() assert "action=" not in twiml @pytest.mark.asyncio @@ -1522,15 +1503,12 @@ async def test_static_options_action_url_beats_server_url(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(action_url="https://static.example.com/end"), - ), - ) - twiml = await channel.handle_incoming_call( - VoiceEndpoints( - websocket_url="wss://example.com/ws", - action_url="https://cleanup.example.com/end", + default_twiml_options=TwiMLOptions(action_url="https://static.example.com/end"), ), ) + channel.config.websocket_url = "wss://example.com/ws" + channel.config.action_url = "https://cleanup.example.com/end" + twiml = await channel.handle_incoming_call() assert 'action="https://static.example.com/end"' in twiml @@ -1545,12 +1523,11 @@ async def test_static_options_applied(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(voice="en-US-Journey-D", language="en-US"), + default_twiml_options=TwiMLOptions(voice="en-US-Journey-D", language="en-US"), ), ) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), - ) + channel.config.websocket_url = "wss://example.com/ws" + twiml = await channel.handle_incoming_call() assert 'voice="en-US-Journey-D"' in twiml assert 'language="en-US"' in twiml @@ -1562,12 +1539,11 @@ async def test_welcome_greeting_via_twiml_options(self) -> None: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(welcome_greeting="Bonjour!"), + default_twiml_options=TwiMLOptions(welcome_greeting="Bonjour!"), ), ) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), - ) + channel.config.websocket_url = "wss://example.com/ws" + twiml = await channel.handle_incoming_call() assert 'welcomeGreeting="Bonjour!"' in twiml @@ -1587,10 +1563,9 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(voice="en-US-Journey-D") tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) - twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), - ) + channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_inbound_twiml=customizer)) + channel.config.websocket_url = "wss://example.com/ws" + twiml = await channel.handle_incoming_call() assert called is False assert "voice=" not in twiml @@ -1606,10 +1581,10 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(voice="en-US-Journey-D", interruptible="speech") tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) + channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_inbound_twiml=customizer)) ctx = TwiMLRequest(from_number="+14155551234", caller_country="US") + channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), twiml_request=ctx, ) assert seen["ctx"] is ctx @@ -1628,12 +1603,12 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(voice="en-US-Journey-D"), - customize_twiml_options=customizer, + default_twiml_options=TwiMLOptions(voice="en-US-Journey-D"), + customize_inbound_twiml=customizer, ), ) + channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), twiml_request=TwiMLRequest(), ) assert 'voice="es-MX-Neural2-A"' in twiml @@ -1650,12 +1625,12 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: channel = VoiceChannel( tac, config=VoiceChannelConfig( - twiml_options=TwiMLOptions(welcome_greeting="Channel default"), - customize_twiml_options=customizer, + default_twiml_options=TwiMLOptions(welcome_greeting="Channel default"), + customize_inbound_twiml=customizer, ), ) + channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), twiml_request=TwiMLRequest(), ) # Customizer didn't set welcome_greeting; channel default survives. @@ -1673,9 +1648,9 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(action_url="https://customizer.example.com/end") tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) - channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_twiml_options=customizer)) + channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_inbound_twiml=customizer)) + channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( - VoiceEndpoints(websocket_url="wss://example.com/ws"), twiml_request=TwiMLRequest(), ) assert 'action="https://customizer.example.com/end"' in twiml diff --git a/tests/test_voice_models.py b/tests/test_voice_models.py index a01d391..3831b36 100644 --- a/tests/test_voice_models.py +++ b/tests/test_voice_models.py @@ -1,8 +1,5 @@ """Tests for Voice WebSocket message models.""" -import pytest -from pydantic import ValidationError - from tac.models.voice import ( CustomParameters, InterruptMessage, @@ -11,7 +8,6 @@ SetupMessage, TwiMLOptions, TwiMLRequest, - VoiceEndpoints, ) @@ -288,23 +284,3 @@ def test_language_config_optional_fields(self) -> None: assert lang.voice is None assert lang.tts_provider is None assert lang.transcription_provider is None - - -class TestVoiceEndpoints: - """VoiceEndpoints is the server → channel handoff for absolute URLs.""" - - def test_websocket_url_required(self) -> None: - with pytest.raises(ValidationError): - VoiceEndpoints() # type: ignore[call-arg] - - def test_action_url_optional(self) -> None: - urls = VoiceEndpoints(websocket_url="wss://example.com/ws") - assert urls.action_url is None - - def test_both_urls(self) -> None: - urls = VoiceEndpoints( - websocket_url="wss://example.com/ws", - action_url="https://example.com/end", - ) - assert urls.websocket_url == "wss://example.com/ws" - assert urls.action_url == "https://example.com/end" From 16b16d4427a95efc60223962a834d7efc9f304cf Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Mon, 18 May 2026 12:24:47 -0400 Subject: [PATCH 20/32] =?UTF-8?q?docs(voice):=20address=20review=20feedbac?= =?UTF-8?q?k=20=E2=80=94=20stale=20names,=20exports,=20deprecation=20hygie?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update docstrings, comments, and deprecation messages still using the pre-rename names (twiml_options / customize_twiml_options) in outbound.py and the voice_twiml_customization example. Note that InitiateVoiceConversationOptions.twiml_options keeps its name (it's per-call on a different type); only VoiceChannelConfig fields renamed. - Re-export TwiMLOptions, TwiMLRequest, LanguageConfig, InterruptMode, and InboundTwiMLCustomizer from tac.channels.voice so users have one import path for voice-feature types instead of two. - Replace the cross-component poke voice_channel._deprecated_server_welcome_greeting = ... with an intentional internal API _set_deprecated_server_welcome_greeting(). The contract is now explicit and the method docstring names what to remove when the deprecated TACServerConfig.welcome_greeting field is deleted. - Align /twiml and /conversation-relay-callback form parsing: both now filter non-string values via isinstance(v, str) instead of one filtering and the other str()-coercing. - Add a clarifying note on TwiMLRequest.extra explaining why its dict values are str-only (webhook form fields) while TwiMLOptions.extra accepts str | bool | int (emitted TwiML attributes). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../features/voice_twiml_customization.py | 16 ++++++++-------- src/tac/channels/voice/__init__.py | 19 +++++++++++++++++-- src/tac/channels/voice/channel.py | 9 +++++++++ src/tac/models/outbound.py | 19 ++++++++++--------- src/tac/models/voice.py | 5 ++++- src/tac/server/fastapi_server.py | 4 ++-- 6 files changed, 50 insertions(+), 22 deletions(-) diff --git a/getting_started/examples/features/voice_twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py index b762413..cb3a6f3 100644 --- a/getting_started/examples/features/voice_twiml_customization.py +++ b/getting_started/examples/features/voice_twiml_customization.py @@ -5,13 +5,13 @@ VoiceChannelConfig (under these, TAC fills in the websocket URL, action URL, conversation_configuration, and a default welcome greeting): -1. ``twiml_options`` — static TwiMLOptions applied to every call. -2. ``customize_twiml_options`` — async callable for per-call logic, receives +1. ``default_twiml_options`` — static TwiMLOptions applied to every call. +2. ``customize_inbound_twiml`` — async callable for per-call logic, receives a TwiMLRequest (parsed Twilio webhook fields: From, To, CallerCountry, …). The customizer wins over static options, which win over TAC defaults. -Channel ``twiml_options`` applies to both inbound and outbound calls +Channel ``default_twiml_options`` applies to both inbound and outbound calls (``initiate_outbound_conversation``). The customizer only runs for inbound calls — outbound calls receive per-call TwiML via ``InitiateVoiceConversationOptions.twiml_options`` at each call site instead. @@ -48,8 +48,8 @@ async def handle_message_ready( # ---- Static TwiML (same settings on every call) ------------------------------ # -# Set ``twiml_options`` on VoiceChannelConfig for attributes that don't depend -# on who's calling. TAC fills in websocket_url, action_url, and +# Set ``default_twiml_options`` on VoiceChannelConfig for attributes that don't +# depend on who's calling. TAC fills in websocket_url, action_url, and # conversation_configuration. voice_channel = VoiceChannel( @@ -72,12 +72,12 @@ async def handle_message_ready( ) -# ---- Per-call TwiML (customize_twiml_options) -------------------------------- +# ---- Per-call TwiML (customize_inbound_twiml) -------------------------------- # # Use this when the TwiML depends on who's calling — e.g., localization by # caller country, per-tenant voice, A/B tests. The customizer returns # TwiMLOptions overrides; anything it doesn't set falls through to -# ``twiml_options`` (above) and then to TAC defaults. +# ``default_twiml_options`` (above) and then to TAC defaults. # # Uncomment the block below to replace the static setup with per-call logic. # @@ -97,7 +97,7 @@ async def handle_message_ready( # voice="fr-FR-Neural2-A", # welcome_greeting="Bonjour ! Comment puis-je vous aider ?", # ) -# return TwiMLOptions() # fall through to static twiml_options + TAC defaults +# return TwiMLOptions() # fall through to default_twiml_options + TAC defaults # # # voice_channel = VoiceChannel( diff --git a/src/tac/channels/voice/__init__.py b/src/tac/channels/voice/__init__.py index 5d26eb0..ed07221 100644 --- a/src/tac/channels/voice/__init__.py +++ b/src/tac/channels/voice/__init__.py @@ -1,7 +1,22 @@ """Voice channel for handling voice-based conversations.""" from tac.channels.voice.channel import VoiceChannel -from tac.channels.voice.config import VoiceChannelConfig +from tac.channels.voice.config import InboundTwiMLCustomizer, VoiceChannelConfig from tac.channels.voice.twiml import generate_twiml +from tac.models.voice import ( + InterruptMode, + LanguageConfig, + TwiMLOptions, + TwiMLRequest, +) -__all__ = ["VoiceChannel", "VoiceChannelConfig", "generate_twiml"] +__all__ = [ + "InboundTwiMLCustomizer", + "InterruptMode", + "LanguageConfig", + "TwiMLOptions", + "TwiMLRequest", + "VoiceChannel", + "VoiceChannelConfig", + "generate_twiml", +] diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index eb541ce..2139303 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -89,6 +89,15 @@ def __init__( self._websocket_manager = WebSocketManager() self._twilio_client: Client | None = None + def _set_deprecated_server_welcome_greeting(self, greeting: str) -> None: + """Internal API for TACFastAPIServer to forward the deprecated + ``TACServerConfig.welcome_greeting``. Used as a fallback when neither + ``default_twiml_options`` nor the customizer sets a greeting. + + Remove this method when ``TACServerConfig.welcome_greeting`` is deleted. + """ + self._deprecated_server_welcome_greeting = greeting + def _require_websocket_url(self, action: str) -> str: """Return the channel's configured websocket_url, or raise with a clear message naming the action that needed it. diff --git a/src/tac/models/outbound.py b/src/tac/models/outbound.py index b386c27..37fc8b4 100644 --- a/src/tac/models/outbound.py +++ b/src/tac/models/outbound.py @@ -58,7 +58,7 @@ class InitiateVoiceConversationOptions(BaseModel): TwiML for the outbound call is built by merging per-field, highest precedence first: 1. This call's ``twiml_options`` (per-call overrides) - 2. ``VoiceChannelConfig.twiml_options`` (channel-wide defaults) + 2. ``VoiceChannelConfig.default_twiml_options`` (channel-wide defaults) 3. TAC defaults (welcome greeting, conversation_configuration, action_url resolved via Studio handoff if configured) @@ -68,9 +68,9 @@ class InitiateVoiceConversationOptions(BaseModel): channel config still apply. Set ``voice``, ``language``, ``interruptible``, etc. on the channel's - ``VoiceChannelConfig.twiml_options`` to apply them to every call (both - inbound and outbound). Use this model's ``twiml_options`` for per-call - overrides (e.g. campaign-specific ``custom_parameters``). + ``VoiceChannelConfig.default_twiml_options`` to apply them to every call + (both inbound and outbound). Use this model's ``twiml_options`` for + per-call overrides (e.g. campaign-specific ``custom_parameters``). """ to: str = Field(..., min_length=1) @@ -84,7 +84,7 @@ class InitiateVoiceConversationOptions(BaseModel): twiml_options: TwiMLOptions | None = Field( default=None, description="Per-call TwiMLOptions overrides. Merged over " - "VoiceChannelConfig.twiml_options and TAC defaults.", + "VoiceChannelConfig.default_twiml_options and TAC defaults.", ) # Deprecated flat fields. Forwarded into twiml_options in the validator @@ -92,17 +92,17 @@ class InitiateVoiceConversationOptions(BaseModel): welcome_greeting: str | None = Field( default=None, description="DEPRECATED: set welcome_greeting on twiml_options or " - "VoiceChannelConfig.twiml_options instead.", + "VoiceChannelConfig.default_twiml_options instead.", ) action_url: str | None = Field( default=None, description="DEPRECATED: set action_url on twiml_options or " - "VoiceChannelConfig.twiml_options instead.", + "VoiceChannelConfig.default_twiml_options instead.", ) custom_parameters: dict[str, str | int | bool] | None = Field( default=None, description="DEPRECATED: set custom_parameters on twiml_options or " - "VoiceChannelConfig.twiml_options instead.", + "VoiceChannelConfig.default_twiml_options instead.", ) model_config = {"populate_by_name": True} @@ -126,7 +126,8 @@ def _forward_deprecated_fields(self) -> "InitiateVoiceConversationOptions": warnings.warn( "InitiateVoiceConversationOptions flat fields " f"({', '.join(set_fields)}) are deprecated. Pass them on " - "twiml_options or configure them on VoiceChannelConfig.twiml_options.", + "twiml_options or configure them on " + "VoiceChannelConfig.default_twiml_options.", DeprecationWarning, stacklevel=2, ) diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 8492ccb..3c0064e 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -338,7 +338,10 @@ class TwiMLRequest(BaseModel): direction: str | None = Field(None, alias="Direction") extra: dict[str, str] = Field( default_factory=dict, - description="Any other fields from the Twilio webhook not captured above", + description="Any other fields from the Twilio webhook not captured above. " + "Values are always strings here (webhook form fields are url-encoded), " + "unlike TwiMLOptions.extra which accepts str | bool | int for emitted " + "TwiML attributes.", ) model_config = {"populate_by_name": True, "extra": "ignore"} diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 42d3e8f..7fc7e56 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -125,7 +125,7 @@ def __init__( # (if set) still wins over this. Drop when the field is removed # from TACServerConfig. if self.config.welcome_greeting is not None: - self.voice_channel._deprecated_server_welcome_greeting = ( + self.voice_channel._set_deprecated_server_welcome_greeting( self.config.welcome_greeting ) @@ -241,7 +241,7 @@ async def conversation_relay_callback(request: Request) -> Response: """Handle ConversationRelay action callback (call ended).""" try: form_data = await request.form() - payload_dict = {k: str(v) for k, v in form_data.items()} + payload_dict = {k: v for k, v in form_data.items() if isinstance(v, str)} await vc.handle_conversation_relay_callback(payload_dict) except Exception: logger.error("Failed to process ConversationRelay callback", exc_info=True) From 779c43793d7173370812bdab238e63aecc6d4ba4 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Mon, 18 May 2026 12:33:23 -0400 Subject: [PATCH 21/32] fix(voice): normalize bool interruptible; doc edge cases; guard attr drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: - Normalize `interruptible=True/False` to the documented enum values ("any"/"none") before emitting TwiML. Twilio accepts the booleans for backward-compat but the documented values are the four-element enum. Twilio's SDK was emitting interruptible="true"/"false" verbatim; now we convert in generate_twiml. - Add an import-time check (_verify_attrs_in_sync) that fails loudly if TwiMLOptions grows a field that twiml.py doesn't account for — either in _OPTIONAL_RELAY_ATTRS (emitted as a ConversationRelay attribute) or _HANDLED_OUTSIDE_LOOP (action_url/languages/custom_parameters/extra). - Document explicit-None semantics on _resolve_action_url: setting action_url=None on a layer falls through to the next layer; suppressing requires unsetting everywhere (no Studio handoff, no channel default). - Add a load-bearing comment on _overlay_fields explaining why action_url is skipped — letting it through would let a higher-priority layer that didn't set action_url silently clobber a lower layer that did. - Cover the three-layer action_url interaction with a new test (customizer with no action_url + default_twiml_options.action_url + channel.config.action_url → default wins). - Document that TwiMLOptions.extra coerces values: bool → "true"/"false", int → stringified, snake_case keys → camelCase via the Twilio SDK. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 11 ++++++- src/tac/channels/voice/twiml.py | 48 +++++++++++++++++++++++++++++-- src/tac/models/voice.py | 5 ++-- tests/test_voice_channel.py | 34 ++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 2139303..5a43d37 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -199,7 +199,11 @@ def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: a field that should merge (e.g. a dict of headers), special-case it here instead of getting the default overwrite behavior. - ``action_url`` is handled separately by ``_resolve_action_url``. + ``action_url`` is skipped here on purpose — it's resolved once via + ``_resolve_action_url`` looking at every layer at once, and that + resolved value is written into ``target`` before this overlay runs. + Letting it through here would let a higher-priority layer that didn't + set action_url silently clobber a lower layer that did. """ for field in source.model_fields_set: if field == "action_url": @@ -221,6 +225,11 @@ def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: sets both Studio handoff and runs in relay-only mode, Studio wins for that call — the session-cleanup URL is skipped, same as if they had set any other action_url via customizer or static options. + + Note: explicit ``action_url=None`` on a layer does not suppress + ```` — it falls through to the next layer. + Suppressing requires unsetting the action_url everywhere (no Studio + handoff, no channel default, etc). """ if ( customized is not None diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index f5d10b5..eea7020 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -10,6 +10,9 @@ logger = get_logger(__name__) +# Fields on TwiMLOptions that map to attributes and are +# emitted via the snake_case → camelCase conversion done by twilio's SDK. +# Must stay in sync with TwiMLOptions field declarations; see _verify_attrs_in_sync. _OPTIONAL_RELAY_ATTRS = ( "welcome_greeting", "welcome_greeting_interruptible", @@ -41,6 +44,41 @@ "intelligence_service", ) +# 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. +_HANDLED_OUTSIDE_LOOP = { + "action_url", + "languages", + "custom_parameters", + "extra", +} + + +def _verify_attrs_in_sync() -> None: + """Fail fast at import time if TwiMLOptions grows a field that isn't + accounted for here — either it's a new ConversationRelay attribute that + needs to go in _OPTIONAL_RELAY_ATTRS, or it's special-cased and should + be added to _HANDLED_OUTSIDE_LOOP. + """ + declared = set(TwiMLOptions.model_fields) + accounted = set(_OPTIONAL_RELAY_ATTRS) | _HANDLED_OUTSIDE_LOOP + missing = declared - accounted + extra = accounted - declared + if missing: + raise RuntimeError( + f"TwiMLOptions field(s) {sorted(missing)} not handled by twiml.py — " + "add to _OPTIONAL_RELAY_ATTRS or _HANDLED_OUTSIDE_LOOP." + ) + if extra: + raise RuntimeError( + f"twiml.py references TwiMLOptions field(s) {sorted(extra)} that " + "no longer exist on the model." + ) + + +_verify_attrs_in_sync() + def generate_twiml( websocket_url: str, @@ -91,8 +129,14 @@ def generate_twiml( relay_kwargs: dict[str, Any] = {"url": websocket_url} for attr in _OPTIONAL_RELAY_ATTRS: value = getattr(options, attr) - if value is not None: - relay_kwargs[attr] = value + if value is None: + continue + # Twilio accepts True/False on `interruptible` for backward-compat + # but the documented enum is none|dtmf|speech|any. Normalize so we + # emit canonical values regardless of Twilio SDK's bool serialization. + if attr == "interruptible" and isinstance(value, bool): + value = "any" if value else "none" + relay_kwargs[attr] = value if options.extra: typed_keys = set(_OPTIONAL_RELAY_ATTRS) diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 3c0064e..ec6a3a0 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -313,8 +313,9 @@ class TwiMLOptions(BaseModel): None, description="Escape hatch for ConversationRelay attributes not yet typed on " "this model. Keys are emitted as-is on ; Twilio's SDK " - "converts snake_case to camelCase. Prefer a typed field when one exists — " - "use ``extra`` only for newly-added Twilio attributes not yet in this SDK.", + "converts snake_case to camelCase, lowercases bools to 'true'/'false', " + "and stringifies ints. Prefer a typed field when one exists — use " + "``extra`` only for newly-added Twilio attributes not yet in this SDK.", ) model_config = {"populate_by_name": True} diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 2ebf20e..4555283 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1217,6 +1217,14 @@ def test_interruptible_dtmf_debug(self) -> None: assert 'dtmfDetection="true"' in twiml assert 'debug="speaker-events"' in twiml + def test_interruptible_bool_normalized_to_enum(self) -> None: + """Twilio accepts True/False on interruptible for backward-compat, + but the documented enum values are 'any'/'none'. Normalize.""" + twiml_true = generate_twiml("wss://example.com/ws", TwiMLOptions(interruptible=True)) + twiml_false = generate_twiml("wss://example.com/ws", TwiMLOptions(interruptible=False)) + assert 'interruptible="any"' in twiml_true + assert 'interruptible="none"' in twiml_false + def test_language_children_emitted(self) -> None: from tac.models.voice import LanguageConfig @@ -1511,6 +1519,32 @@ async def test_static_options_action_url_beats_server_url(self) -> None: twiml = await channel.handle_incoming_call() assert 'action="https://static.example.com/end"' in twiml + @pytest.mark.asyncio + async def test_action_url_three_layer_resolution(self) -> None: + """Customizer setting only `voice` (no action_url) must not clobber a + default_twiml_options.action_url. Verifies the _overlay_fields skip + invariant — every layer's action_url is funneled through + _resolve_action_url, never via the field overlay.""" + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequest + + async def customizer(req: TwiMLRequest) -> TwiMLOptions: + return TwiMLOptions(voice="en-US-Journey-D") # no action_url + + tac = TAC(get_test_config()) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + default_twiml_options=TwiMLOptions(action_url="https://static.example.com/end"), + customize_inbound_twiml=customizer, + ), + ) + channel.config.websocket_url = "wss://example.com/ws" + channel.config.action_url = "https://server.example.com/end" + twiml = await channel.handle_incoming_call(twiml_request=TwiMLRequest()) + assert 'action="https://static.example.com/end"' in twiml + assert 'voice="en-US-Journey-D"' in twiml + class TestStaticTwiMLOptions: """VoiceChannelConfig.twiml_options applies to every call without a callback.""" From 547ef84d7aac8b408e095879fe29225340223624 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Mon, 18 May 2026 12:38:26 -0400 Subject: [PATCH 22/32] fix(server): require Twilio signature on /conversation-relay-callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route was previously unprotected — pre-existing miss, but newly relevant because this PR makes the route reachable on every voice call (action_url is no longer omitted in orchestrated mode). Add the same http_sig dependency every other Twilio webhook route uses, with two tests covering missing/valid signatures. Also addressed two doc/precision nits: - _overlay_fields docstring called extra a "list"; it's a dict. - _deprecated_server_welcome_greeting falls back via `is not None` instead of truthiness, so an explicit empty string isn't silently replaced by DEFAULT_WELCOME_GREETING. Edge case but free precision. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 20 +++++++++----- src/tac/server/fastapi_server.py | 5 +++- tests/test_server.py | 45 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 5a43d37..9465be4 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -175,7 +175,11 @@ async def handle_incoming_call( # deprecated server value → SDK default. # twiml_options and customizer can still override on top. merged = TwiMLOptions( - welcome_greeting=(self._deprecated_server_welcome_greeting or DEFAULT_WELCOME_GREETING), + welcome_greeting=( + self._deprecated_server_welcome_greeting + if self._deprecated_server_welcome_greeting is not None + else DEFAULT_WELCOME_GREETING + ), conversation_configuration=self.tac.config.conversation_configuration_id, action_url=self._resolve_action_url(customized), ) @@ -194,10 +198,10 @@ async def handle_incoming_call( def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: """Apply fields explicitly set on ``source`` onto ``target``. - Nested models (``custom_parameters``) and lists (``languages``, - ``extra``) replace wholesale — there's no per-key merging. If you add - a field that should merge (e.g. a dict of headers), special-case it - here instead of getting the default overwrite behavior. + Nested models (``custom_parameters``), lists (``languages``), and + dicts (``extra``) replace wholesale — there's no per-key merging. + If you add a field that should merge (e.g. a dict of headers), + special-case it here instead of getting the default overwrite behavior. ``action_url`` is skipped here on purpose — it's resolved once via ``_resolve_action_url`` looking at every layer at once, and that @@ -483,7 +487,11 @@ async def initiate_outbound_conversation( # (customizers receive a TwiMLRequest from an inbound webhook; there # is no equivalent for outbound). merged = TwiMLOptions( - welcome_greeting=(self._deprecated_server_welcome_greeting or DEFAULT_WELCOME_GREETING), + welcome_greeting=( + self._deprecated_server_welcome_greeting + if self._deprecated_server_welcome_greeting is not None + else DEFAULT_WELCOME_GREETING + ), conversation_configuration=self.tac.config.conversation_configuration_id, action_url=self._resolve_action_url(options.twiml_options), ) diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 7fc7e56..a635514 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -236,7 +236,10 @@ async def websocket_endpoint(websocket: WebSocket, _: None = Depends(ws_sig)) -> adapter = FastAPIWebSocketAdapter(websocket) await vc.handle_websocket(adapter) - @app.post(config.conversation_relay_callback_path) + @app.post( + config.conversation_relay_callback_path, + dependencies=[Depends(http_sig)], + ) async def conversation_relay_callback(request: Request) -> Response: """Handle ConversationRelay action callback (call ended).""" try: diff --git a/tests/test_server.py b/tests/test_server.py index 54a03d3..709b5ec 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -672,6 +672,51 @@ def test_twiml_rejects_missing_signature(self) -> None: resp = client.post("/twiml") assert resp.status_code == 403 + def test_conversation_relay_callback_rejects_missing_signature(self) -> None: + from fastapi.testclient import TestClient + + from tac.channels.voice import VoiceChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + server = TACFastAPIServer( + tac=tac, + config=TACServerConfig(public_domain="test.ngrok.io"), + voice_channel=VoiceChannel(tac), + ) + client = TestClient(server.app) + resp = client.post("/conversation-relay-callback") + assert resp.status_code == 403 + + def test_conversation_relay_callback_accepts_valid_signature(self) -> None: + from fastapi.testclient import TestClient + + from tac.channels.voice import VoiceChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + server = TACFastAPIServer( + tac=tac, + config=TACServerConfig(public_domain="test.ngrok.io"), + voice_channel=VoiceChannel(tac), + ) + client = TestClient(server.app) + form_data = { + "AccountSid": "ACtest123", + "CallSid": "CA123", + "CallStatus": "completed", + "From": "+15551234567", + "To": "+15559876543", + "Direction": "inbound", + } + signature = compute_signature("http://testserver/conversation-relay-callback", form_data) + resp = client.post( + "/conversation-relay-callback", + data=form_data, + headers={"X-Twilio-Signature": signature}, + ) + assert resp.status_code == 200 + def test_twiml_accepts_valid_form_signature(self) -> None: from fastapi.testclient import TestClient From 0fd34f29fb7eeafdb86fbae90c2d2124e5a2edea Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Mon, 18 May 2026 12:50:19 -0400 Subject: [PATCH 23/32] fix(voice): address remaining inline review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update outbound.py example comment to reference the current API names (customize_inbound_twiml, default_twiml_options) instead of the pre-rename ones. - Treat empty-string websocket_url/action_url on VoiceChannelConfig as unset in TACFastAPIServer._configure_voice_channel_urls, matching VoiceChannel._require_websocket_url's truthiness check. Previously a user who passed empty strings would skip auto-population and hit the channel's missing-URL error at request time. - Add a regression test asserting that deprecated flat fields forwarded via the model_validator end up in twiml_options.model_fields_set — load-bearing because the merge layer treats unset fields as fallthrough. (Pydantic v2's setattr does correctly mutate model_fields_set, so this works today; the test prevents regression.) Co-Authored-By: Claude Opus 4.7 (1M context) --- getting_started/examples/features/outbound.py | 4 +-- src/tac/server/fastapi_server.py | 6 +++-- tests/test_outbound.py | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/getting_started/examples/features/outbound.py b/getting_started/examples/features/outbound.py index cade93a..1e91013 100644 --- a/getting_started/examples/features/outbound.py +++ b/getting_started/examples/features/outbound.py @@ -170,8 +170,8 @@ async def initiate_outbound(args: argparse.Namespace) -> None: sys.exit(1) # Per-call TwiML for outbound calls. Inbound's equivalent is - # `customize_twiml_options` on VoiceChannelConfig (see - # voice_twiml_customization.py). Use VoiceChannelConfig.twiml_options + # `customize_inbound_twiml` on VoiceChannelConfig (see + # voice_twiml_customization.py). Use VoiceChannelConfig.default_twiml_options # for settings that don't vary per call (voice, language, etc.). voice_result = await voice_channel.initiate_outbound_conversation( InitiateVoiceConversationOptions( diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index a635514..dbc7209 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -151,11 +151,13 @@ def _configure_voice_channel_urls(self) -> None: "public_domain=... on TACServerConfig." ) vc_config = self.voice_channel.config - if vc_config.websocket_url is None: + # Treat empty strings as unset — matches VoiceChannel._require_websocket_url + # which checks truthiness, not just None. + if not vc_config.websocket_url: vc_config.websocket_url = ( f"wss://{self.config.public_domain}{self.config.websocket_path}" ) - if vc_config.action_url is None: + if not vc_config.action_url: vc_config.action_url = ( f"https://{self.config.public_domain}{self.config.conversation_relay_callback_path}" ) diff --git a/tests/test_outbound.py b/tests/test_outbound.py index 4220ecc..d428c6d 100644 --- a/tests/test_outbound.py +++ b/tests/test_outbound.py @@ -822,6 +822,32 @@ async def test_deprecated_flat_fields_emit_warning_and_forward(self) -> None: assert "Legacy greeting" in twiml_xml assert 'name="legacy"' in twiml_xml + def test_deprecated_flat_fields_marked_as_explicitly_set_after_forwarding( + self, + ) -> None: + """Forwarded deprecated values must end up in model_fields_set on the + twiml_options object, otherwise the merge layer in handle_incoming_call + / initiate_outbound_conversation would treat them as 'not set' and the + fallback default would override them.""" + import warnings + + from tac.models.voice import TwiMLOptions + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + opts = InitiateVoiceConversationOptions( + to="+15559876543", + websocket_url="wss://example.com/ws", + twiml_options=TwiMLOptions(voice="en-US-Journey-D"), + welcome_greeting="Legacy", + ) + assert opts.twiml_options is not None + assert opts.twiml_options.welcome_greeting == "Legacy" + # Critical: must be in model_fields_set so the merge layer treats it + # as an explicit override, not a fallthrough. + assert "welcome_greeting" in opts.twiml_options.model_fields_set + assert "voice" in opts.twiml_options.model_fields_set + @pytest.mark.asyncio async def test_deprecated_flat_fields_lose_to_explicit_twiml_options(self) -> None: """If both flat welcome_greeting and twiml_options.welcome_greeting are From 99c90463ad7dcd9284871ad5d3575c40aacb1209 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Mon, 18 May 2026 13:00:26 -0400 Subject: [PATCH 24/32] refactor(voice): simplify deprecation forwarding, extract merge helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small simplifications driven by reading the diff with a "what's overcomplicated" lens: 1. Forward the deprecated TACServerConfig.welcome_greeting through the channel's default_twiml_options.welcome_greeting instead of carrying a separate _deprecated_server_welcome_greeting state on the channel. The deprecated value flows through the normal merge pipeline like any other channel-wide default. Removes a private setter, an instance attribute, and conditional logic at two call sites. The deletion path when the deprecated field goes away is now a single _forward_deprecated_welcome_greeting() method on the server. 2. Extract _build_twiml_options(per_call) — handle_incoming_call and initiate_outbound_conversation built the same merged TwiMLOptions structure with ~15 lines of identical code. Inbound now invokes the customizer first, then both call into the helper. 3. Derive TwiMLRequest.from_form's known_aliases set from model_fields instead of maintaining a parallel literal that could drift if a field is added or removed. Side effect of (1): the channel now reads default_twiml_options and customize_inbound_twiml from self.config at use-time instead of snapshotting them at __init__. This is correct — the config is the source of truth, and the snapshot would have been stale after the server's deprecation forwarding. Net: -29 lines, fewer private surfaces, simpler deletion path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 76 +++++++++---------------------- src/tac/models/voice.py | 10 +--- src/tac/server/fastapi_server.py | 29 ++++++++---- 3 files changed, 43 insertions(+), 72 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 9465be4..42233d9 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -79,25 +79,9 @@ def __init__( super().__init__(tac, memory_mode=config.memory_mode) self.config = config self.session_manager = config.session_manager - self._default_twiml_options = config.default_twiml_options - self._customize_inbound_twiml = config.customize_inbound_twiml - # Populated by TACFastAPIServer from the deprecated - # TACServerConfig.welcome_greeting. Used only as a fallback when - # twiml_options/customizer don't set welcome_greeting. Remove when - # the deprecated field is deleted. - self._deprecated_server_welcome_greeting: str | None = None self._websocket_manager = WebSocketManager() self._twilio_client: Client | None = None - def _set_deprecated_server_welcome_greeting(self, greeting: str) -> None: - """Internal API for TACFastAPIServer to forward the deprecated - ``TACServerConfig.welcome_greeting``. Used as a fallback when neither - ``default_twiml_options`` nor the customizer sets a greeting. - - Remove this method when ``TACServerConfig.welcome_greeting`` is deleted. - """ - self._deprecated_server_welcome_greeting = greeting - def _require_websocket_url(self, action: str) -> str: """Return the channel's configured websocket_url, or raise with a clear message naming the action that needed it. @@ -168,31 +152,27 @@ async def handle_incoming_call( # Invoke the customizer if configured and we have a request context. customized: TwiMLOptions | None = None - if self._customize_inbound_twiml is not None and twiml_request is not None: - customized = await self._customize_inbound_twiml(twiml_request) + if self.config.customize_inbound_twiml is not None and twiml_request is not None: + customized = await self.config.customize_inbound_twiml(twiml_request) - # Start from TAC defaults. welcome_greeting prefers (in order): - # deprecated server value → SDK default. - # twiml_options and customizer can still override on top. + merged = self._build_twiml_options(customized) + 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 + ``InitiateVoiceConversationOptions.twiml_options`` for outbound). + """ merged = TwiMLOptions( - welcome_greeting=( - self._deprecated_server_welcome_greeting - if self._deprecated_server_welcome_greeting is not None - else DEFAULT_WELCOME_GREETING - ), + welcome_greeting=DEFAULT_WELCOME_GREETING, conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_action_url(customized), + action_url=self._resolve_action_url(per_call), ) - - # Overlay channel default_twiml_options. - if self._default_twiml_options is not None: - self._overlay_fields(merged, self._default_twiml_options) - - # Overlay customizer output (highest priority). - if customized is not None: - self._overlay_fields(merged, customized) - - return twiml.generate_twiml(websocket_url, merged) + 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 @staticmethod def _overlay_fields(target: TwiMLOptions, source: TwiMLOptions) -> None: @@ -242,11 +222,11 @@ def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: ): return customized.action_url if ( - self._default_twiml_options is not None - and "action_url" in self._default_twiml_options.model_fields_set - and self._default_twiml_options.action_url is not None + self.config.default_twiml_options is not None + and "action_url" in self.config.default_twiml_options.model_fields_set + and self.config.default_twiml_options.action_url is not None ): - return self._default_twiml_options.action_url + return self.config.default_twiml_options.action_url if self.tac.config.studio_handoff_flow_sid: return studio_voice_handoff_url( self.tac.config.account_sid, @@ -486,19 +466,7 @@ async def initiate_outbound_conversation( # Same layering as handle_incoming_call, minus the customizer # (customizers receive a TwiMLRequest from an inbound webhook; there # is no equivalent for outbound). - merged = TwiMLOptions( - welcome_greeting=( - self._deprecated_server_welcome_greeting - if self._deprecated_server_welcome_greeting is not None - else DEFAULT_WELCOME_GREETING - ), - conversation_configuration=self.tac.config.conversation_configuration_id, - action_url=self._resolve_action_url(options.twiml_options), - ) - if self._default_twiml_options is not None: - self._overlay_fields(merged, self._default_twiml_options) - if options.twiml_options is not None: - self._overlay_fields(merged, options.twiml_options) + merged = self._build_twiml_options(options.twiml_options) try: twiml_xml = twiml.generate_twiml(websocket_url, merged) diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index ec6a3a0..c587d54 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -350,15 +350,7 @@ class TwiMLRequest(BaseModel): @classmethod def from_form(cls, form: dict[str, str]) -> "TwiMLRequest": """Build a context from a raw Twilio form dict, bucketing unknown keys into ``extra``.""" - known_aliases = { - "From", - "To", - "CallSid", - "CallerCountry", - "CallerState", - "CallerCity", - "Direction", - } + known_aliases = {f.alias for f in cls.model_fields.values() if f.alias} known: dict[str, str] = {} extra: dict[str, str] = {} for key, value in form.items(): diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index dbc7209..a189df8 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -16,7 +16,7 @@ from tac.channels.websocket_protocol import WebSocketDisconnectError from tac.core.logging import get_logger from tac.core.tac import TAC -from tac.models.voice import TwiMLRequest +from tac.models.voice import TwiMLOptions, TwiMLRequest from tac.server.config import TACServerConfig if TYPE_CHECKING: @@ -120,14 +120,7 @@ def __init__( if self.voice_channel is not None: self._configure_voice_channel_urls() - # Forward deprecated TACServerConfig.welcome_greeting to the voice - # channel as a fallback default. twiml_options.welcome_greeting - # (if set) still wins over this. Drop when the field is removed - # from TACServerConfig. - if self.config.welcome_greeting is not None: - self.voice_channel._set_deprecated_server_welcome_greeting( - self.config.welcome_greeting - ) + self._forward_deprecated_welcome_greeting() # Gather all channels that need webhook processing self.webhook_channels: list[BaseChannel] = [] @@ -162,6 +155,24 @@ def _configure_voice_channel_urls(self) -> None: f"https://{self.config.public_domain}{self.config.conversation_relay_callback_path}" ) + def _forward_deprecated_welcome_greeting(self) -> None: + """Forward the deprecated ``TACServerConfig.welcome_greeting`` into + the voice channel's ``default_twiml_options.welcome_greeting`` so it + flows through the normal merge pipeline. Only fills in the field if + the user didn't already set it. Drop this method when the deprecated + field is removed from ``TACServerConfig``. + """ + assert self.voice_channel is not None # checked by caller + if self.config.welcome_greeting is None: + return + vc_config = self.voice_channel.config + if vc_config.default_twiml_options is None: + vc_config.default_twiml_options = TwiMLOptions( + welcome_greeting=self.config.welcome_greeting + ) + elif "welcome_greeting" not in vc_config.default_twiml_options.model_fields_set: + vc_config.default_twiml_options.welcome_greeting = self.config.welcome_greeting + def _register_routes(self, app: FastAPI) -> None: """Register TAC routes (conversation webhook, voice, CI) onto the given FastAPI app.""" config = self.config From c53bbea368209b3d38129b0f9886cf01a58d705f Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Mon, 18 May 2026 13:18:12 -0400 Subject: [PATCH 25/32] docs(examples): teach the layering, drop redundant URL plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit outbound.py: - Add `default_twiml_options` on VoiceChannelConfig so the example shows channel-wide defaults applied to every outbound call. Per-call twiml_options on the call site demonstrates the layering (per-call wins). - Drop the websocket_url and action_url that were threaded through the call site — TACFastAPIServer populates both from public_domain at startup, so passing them again teaches a bad pattern. voice_twiml_customization.py: - Uncomment the customizer block so the active code shows both layers together (channel-wide defaults + per-call inbound customizer). That's the realistic shape; "either/or" framing was misleading. - Strip the example to attributes that work consistently across accounts (welcome_greeting, interruptible, language). Dropped the voice and tts_provider/transcription_provider settings — those rely on provider/voice combos that aren't always valid on every Twilio account configuration and obscure what the example is teaching. Co-Authored-By: Claude Opus 4.7 (1M context) --- getting_started/examples/features/outbound.py | 29 +++-- .../features/voice_twiml_customization.py | 100 ++++++------------ 2 files changed, 44 insertions(+), 85 deletions(-) diff --git a/getting_started/examples/features/outbound.py b/getting_started/examples/features/outbound.py index 1e91013..7b7dc8b 100644 --- a/getting_started/examples/features/outbound.py +++ b/getting_started/examples/features/outbound.py @@ -43,7 +43,6 @@ from tac.models.tac import TACMemoryResponse from tac.models.voice import TwiMLOptions from tac.server import TACFastAPIServer -from tac.server.config import TACServerConfig load_dotenv() set_tracing_disabled(True) @@ -92,7 +91,17 @@ def parse_args() -> argparse.Namespace: if tac.config.whatsapp_number else None ) -voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always")) +voice_channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + memory_mode="once", + # Channel-wide TwiML applied to every call (inbound + outbound). + default_twiml_options=TwiMLOptions( + voice="en-US-Journey-D", + welcome_greeting="Hi! How can I help?", + ), + ), +) async def handle_message_ready( @@ -163,24 +172,10 @@ async def initiate_outbound(args: argparse.Namespace) -> None: print("\nWaiting for replies... (Ctrl+C to exit)\n") elif args.channel == "voice": - server_config = TACServerConfig.from_env() - public_domain = server_config.public_domain - if not public_domain: - print("TWILIO_VOICE_PUBLIC_DOMAIN is required for voice calls.") - sys.exit(1) - - # Per-call TwiML for outbound calls. Inbound's equivalent is - # `customize_inbound_twiml` on VoiceChannelConfig (see - # voice_twiml_customization.py). Use VoiceChannelConfig.default_twiml_options - # for settings that don't vary per call (voice, language, etc.). voice_result = await voice_channel.initiate_outbound_conversation( InitiateVoiceConversationOptions( to=args.to, - websocket_url=f"wss://{public_domain}/ws", - twiml_options=TwiMLOptions( - welcome_greeting=args.welcome_greeting, - action_url=f"https://{public_domain}/conversation-relay-callback", - ), + twiml_options=TwiMLOptions(welcome_greeting=args.welcome_greeting), ) ) print(f"Call placed to {args.to} (SID: {voice_result.call_sid})") diff --git a/getting_started/examples/features/voice_twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py index cb3a6f3..cafc6ca 100644 --- a/getting_started/examples/features/voice_twiml_customization.py +++ b/getting_started/examples/features/voice_twiml_customization.py @@ -1,24 +1,21 @@ """ Feature: ConversationRelay TwiML customization -TAC exposes two user-facing layers of TwiML customization on -VoiceChannelConfig (under these, TAC fills in the websocket URL, action URL, -conversation_configuration, and a default welcome greeting): +Two layers on VoiceChannelConfig (highest precedence first): -1. ``default_twiml_options`` — static TwiMLOptions applied to every call. -2. ``customize_inbound_twiml`` — async callable for per-call logic, receives - a TwiMLRequest (parsed Twilio webhook fields: From, To, CallerCountry, …). +1. ``customize_inbound_twiml`` — async callable receiving a TwiMLRequest + (parsed Twilio webhook fields: From, To, CallerCountry, …). Inbound only. + For outbound, pass per-call TwiMLOptions on InitiateVoiceConversationOptions. +2. ``default_twiml_options`` — static TwiMLOptions applied to every call + (inbound and outbound). -The customizer wins over static options, which win over TAC defaults. +Layers merge per-field: the customizer overrides only the fields it +explicitly sets; everything else falls through to ``default_twiml_options`` +and then to TAC defaults (websocket URL, action URL, conversation_configuration). -Channel ``default_twiml_options`` applies to both inbound and outbound calls -(``initiate_outbound_conversation``). The customizer only runs for inbound -calls — outbound calls receive per-call TwiML via -``InitiateVoiceConversationOptions.twiml_options`` at each call site instead. - -This example shows the static path (voice + language the same for every -call). The customizer version for per-call localization is below in a -commented block — uncomment if you need it. +This example shows both layers together — channel-wide defaults plus a +country-based customizer that overrides language/voice/greeting for +specific callers. """ from dotenv import load_dotenv @@ -27,7 +24,7 @@ from tac.channels.voice import VoiceChannel, VoiceChannelConfig from tac.models.session import ConversationSession from tac.models.tac import TACMemoryResponse -from tac.models.voice import LanguageConfig, TwiMLOptions +from tac.models.voice import TwiMLOptions, TwiMLRequest from tac.server import TACFastAPIServer load_dotenv() @@ -46,69 +43,36 @@ async def handle_message_ready( tac.on_message_ready(handle_message_ready) -# ---- Static TwiML (same settings on every call) ------------------------------ -# -# Set ``default_twiml_options`` on VoiceChannelConfig for attributes that don't -# depend on who's calling. TAC fills in websocket_url, action_url, and -# conversation_configuration. +async def customize_twiml(req: TwiMLRequest) -> TwiMLOptions: + """Per-call overrides for inbound calls. Only the fields you set here + override the channel default; the rest fall through.""" + if req.caller_country == "MX": + return TwiMLOptions( + language="es-MX", + welcome_greeting="¡Hola! ¿En qué puedo ayudarte?", + ) + if req.caller_country == "FR": + return TwiMLOptions( + language="fr-FR", + welcome_greeting="Bonjour ! Comment puis-je vous aider ?", + ) + return TwiMLOptions() # fall through to default_twiml_options + voice_channel = VoiceChannel( tac, config=VoiceChannelConfig( + # Channel-wide defaults — apply to every call (inbound + outbound). default_twiml_options=TwiMLOptions( - welcome_greeting="Hello! How can I help?", - voice="en-US-Journey-D", - language="en-US", - tts_provider="google", - transcription_provider="deepgram", + welcome_greeting="Hello! This is a default greeting.", interruptible="speech", - # children let the caller switch languages mid-call. - languages=[ - LanguageConfig(code="en-US", voice="en-US-Journey-D", tts_provider="google"), - LanguageConfig(code="es-MX", voice="es-MX-Neural2-A", tts_provider="google"), - ], ), + # Per-call inbound overrides — runs once per inbound call. + customize_inbound_twiml=customize_twiml, ), ) -# ---- Per-call TwiML (customize_inbound_twiml) -------------------------------- -# -# Use this when the TwiML depends on who's calling — e.g., localization by -# caller country, per-tenant voice, A/B tests. The customizer returns -# TwiMLOptions overrides; anything it doesn't set falls through to -# ``default_twiml_options`` (above) and then to TAC defaults. -# -# Uncomment the block below to replace the static setup with per-call logic. -# -# from tac.models.voice import TwiMLRequest -# -# -# async def customize_twiml(req: TwiMLRequest) -> TwiMLOptions: -# if req.caller_country == "MX": -# return TwiMLOptions( -# language="es-MX", -# voice="es-MX-Neural2-A", -# welcome_greeting="¡Hola! ¿En qué puedo ayudarte?", -# ) -# if req.caller_country == "FR": -# return TwiMLOptions( -# language="fr-FR", -# voice="fr-FR-Neural2-A", -# welcome_greeting="Bonjour ! Comment puis-je vous aider ?", -# ) -# return TwiMLOptions() # fall through to default_twiml_options + TAC defaults -# -# -# voice_channel = VoiceChannel( -# tac, -# config=VoiceChannelConfig( -# default_twiml_options=TwiMLOptions(welcome_greeting="Hello! How can I help?"), -# customize_inbound_twiml=customize_twiml, -# ), -# ) - - if __name__ == "__main__": server = TACFastAPIServer(tac=tac, voice_channel=voice_channel) server.start() From d7eb8ca8fcfa42e7a460185b5bd09170ec88dfbb Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Mon, 18 May 2026 14:27:12 -0400 Subject: [PATCH 26/32] refactor(voice): centralize voice URLs on TACConfig + register customizer on channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related shifts that simplify the voice channel's contract. 1. URL ownership moves from server to TAC/channel config TACFastAPIServer used to reach into voice_channel.config and mutate its URL fields at construction. That implicit coupling created an undocumented contract for custom adapters: "you must populate these URLs before the first call or you'll fail at request time." Now: - TACConfig.voice_public_domain holds the public domain (read from TWILIO_VOICE_PUBLIC_DOMAIN at TACConfig.from_env). Deployment fact, used by voice regardless of which web framework registers routes. - VoiceChannelConfig owns websocket_path / action_path (defaults /ws and /conversation-relay-callback) plus optional websocket_url / action_url overrides for cross-domain or proxy setups. - VoiceChannel resolves URLs at use-time: override → derive from voice_public_domain + path → raise (websocket) or None (action_url falls through to higher layers). - TACFastAPIServer drops _configure_voice_channel_urls. It registers routes at TACServerConfig.websocket_path / conversation_relay_callback_path (its own concern). The channel constructs URLs from VoiceChannelConfig.websocket_path / action_path (its own concern). Defaults match so the common case "just works"; users who customize one keep the other in sync, or use the websocket_url override for proxy setups where they diverge. - TACServerConfig.public_domain is deprecated and forwarded to TACConfig.voice_public_domain. 2. Per-call inbound customizer moves to a method on VoiceChannel VoiceChannelConfig.customize_inbound_twiml = callable was awkward — behavior on a config model. Pydantic models hold data; functions are behavior. Splitting fits TAC's existing handler vocabulary (tac.on_message_ready, tac.on_interrupt, etc.) and prepares for future ConversationRelay event hooks (on_dtmf, on_prompt, …) that will follow the same on_X-method-on-channel pattern. API: voice_channel = VoiceChannel(tac, config=VoiceChannelConfig( default_twiml_options=TwiMLOptions(...), )) async def by_country(req: TwiMLRequest) -> TwiMLOptions: ... voice_channel.on_inbound_call_twiml(by_country) The static layer (default_twiml_options) stays on VoiceChannelConfig — it's data. The dynamic layer (on_inbound_call_twiml) is a method — it's a handler. Co-Authored-By: Claude Opus 4.7 (1M context) --- getting_started/examples/features/outbound.py | 1 + .../features/voice_twiml_customization.py | 18 ++-- src/tac/channels/voice/__init__.py | 4 +- src/tac/channels/voice/channel.py | 95 ++++++++++++++----- src/tac/channels/voice/config.py | 74 ++++++++------- src/tac/core/config.py | 10 ++ src/tac/models/voice.py | 3 +- src/tac/server/config.py | 39 +++++--- src/tac/server/fastapi_server.py | 37 +++----- tests/test_server.py | 87 ++++++++--------- tests/test_voice_channel.py | 21 ++-- 11 files changed, 228 insertions(+), 161 deletions(-) diff --git a/getting_started/examples/features/outbound.py b/getting_started/examples/features/outbound.py index 7b7dc8b..12ca8c3 100644 --- a/getting_started/examples/features/outbound.py +++ b/getting_started/examples/features/outbound.py @@ -175,6 +175,7 @@ async def initiate_outbound(args: argparse.Namespace) -> None: voice_result = await voice_channel.initiate_outbound_conversation( InitiateVoiceConversationOptions( to=args.to, + # Per-call TwiML overrides for this outbound call. Overrides channel defaults twiml_options=TwiMLOptions(welcome_greeting=args.welcome_greeting), ) ) diff --git a/getting_started/examples/features/voice_twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py index cafc6ca..adc93f2 100644 --- a/getting_started/examples/features/voice_twiml_customization.py +++ b/getting_started/examples/features/voice_twiml_customization.py @@ -1,13 +1,15 @@ """ Feature: ConversationRelay TwiML customization -Two layers on VoiceChannelConfig (highest precedence first): +Two layers (highest precedence first): -1. ``customize_inbound_twiml`` — async callable receiving a TwiMLRequest - (parsed Twilio webhook fields: From, To, CallerCountry, …). Inbound only. - For outbound, pass per-call TwiMLOptions on InitiateVoiceConversationOptions. -2. ``default_twiml_options`` — static TwiMLOptions applied to every call - (inbound and outbound). +1. Per-call customizer — register on the channel via + ``voice_channel.on_inbound_call_twiml(...)``. Async callable that + receives a TwiMLRequest (parsed Twilio webhook fields: From, To, + CallerCountry, …). Inbound only. For outbound, pass per-call + TwiMLOptions on InitiateVoiceConversationOptions. +2. ``VoiceChannelConfig.default_twiml_options`` — static TwiMLOptions + applied to every call (inbound and outbound). Layers merge per-field: the customizer overrides only the fields it explicitly sets; everything else falls through to ``default_twiml_options`` @@ -67,10 +69,10 @@ async def customize_twiml(req: TwiMLRequest) -> TwiMLOptions: welcome_greeting="Hello! This is a default greeting.", interruptible="speech", ), - # Per-call inbound overrides — runs once per inbound call. - customize_inbound_twiml=customize_twiml, ), ) +# Register the per-call inbound customizer. +voice_channel.on_inbound_call_twiml(customize_twiml) if __name__ == "__main__": diff --git a/src/tac/channels/voice/__init__.py b/src/tac/channels/voice/__init__.py index ed07221..3df4f1d 100644 --- a/src/tac/channels/voice/__init__.py +++ b/src/tac/channels/voice/__init__.py @@ -1,7 +1,7 @@ """Voice channel for handling voice-based conversations.""" from tac.channels.voice.channel import VoiceChannel -from tac.channels.voice.config import InboundTwiMLCustomizer, VoiceChannelConfig +from tac.channels.voice.config import InboundCallTwiMLHandler, VoiceChannelConfig from tac.channels.voice.twiml import generate_twiml from tac.models.voice import ( InterruptMode, @@ -11,7 +11,7 @@ ) __all__ = [ - "InboundTwiMLCustomizer", + "InboundCallTwiMLHandler", "InterruptMode", "LanguageConfig", "TwiMLOptions", diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 42233d9..b4460d7 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -29,7 +29,7 @@ from tac.utils.redaction import mask_phone from . import twiml -from .config import VoiceChannelConfig +from .config import InboundCallTwiMLHandler, VoiceChannelConfig _POLL_ATTEMPTS = 5 _POLL_BASE_DELAY = 0.25 @@ -79,20 +79,65 @@ def __init__( super().__init__(tac, memory_mode=config.memory_mode) self.config = config self.session_manager = config.session_manager + self._on_inbound_call_twiml: InboundCallTwiMLHandler | None = None self._websocket_manager = WebSocketManager() self._twilio_client: Client | None = None - def _require_websocket_url(self, action: str) -> str: - """Return the channel's configured websocket_url, or raise with a clear - message naming the action that needed it. + def on_inbound_call_twiml(self, callback: InboundCallTwiMLHandler) -> None: + """Register a callback that produces per-call TwiML overrides for + inbound calls. + + The callback receives a framework-neutral ``TwiMLRequest`` (parsed + from the Twilio webhook form) and returns a ``TwiMLOptions``. Fields + the callback explicitly sets override ``default_twiml_options`` and + TAC defaults; unset fields fall through. + + Example: + ```python + async def by_country(req: TwiMLRequest) -> TwiMLOptions: + if req.caller_country == "MX": + return TwiMLOptions(language="es-MX", welcome_greeting="¡Hola!") + return TwiMLOptions() + + + voice_channel.on_inbound_call_twiml(by_country) + ``` + + Outbound calls don't use this — pass per-call TwiML via + ``InitiateVoiceConversationOptions.twiml_options`` directly. """ - if not self.config.websocket_url: - raise ValueError( - f"{action} requires VoiceChannelConfig.websocket_url to be set. " - "TACFastAPIServer configures this automatically from its " - "public_domain; custom adapters must set it on VoiceChannelConfig." - ) - return self.config.websocket_url + self._on_inbound_call_twiml = callback + + def _resolve_websocket_url(self, action: str) -> str: + """Resolve the public WebSocket URL. + + Order: explicit ``VoiceChannelConfig.websocket_url`` override → + derived from ``TACConfig.voice_public_domain`` + ``websocket_path``. + Raises if neither is available. + """ + if self.config.websocket_url: + return self.config.websocket_url + if self.tac.config.voice_public_domain: + return f"wss://{self.tac.config.voice_public_domain}{self.config.websocket_path}" + raise ValueError( + f"{action} needs a WebSocket URL. Set TWILIO_VOICE_PUBLIC_DOMAIN " + "(or TACConfig.voice_public_domain), or pass websocket_url on " + "VoiceChannelConfig as an override." + ) + + def _resolve_default_action_url(self) -> str | None: + """Resolve the default ```` cleanup URL — same + derivation as ``_resolve_websocket_url`` but for the action URL. + + Returns None if neither override nor derivation source is set; that's + fine because action_url has higher-priority layers (customizer, + twiml_options, Studio handoff) above this fallback. + """ + if self.config.action_url: + return self.config.action_url + if self.tac.config.voice_public_domain: + return f"https://{self.tac.config.voice_public_domain}{self.config.action_path}" + return None @staticmethod def _caller_address(setup_msg: SetupMessage) -> str | None: @@ -128,9 +173,9 @@ async def handle_incoming_call( them on the config before calling. TwiML fields are merged per-field, highest precedence first: - 1. Output of ``VoiceChannelConfig.customize_inbound_twiml`` if - configured and ``twiml_request`` is given. Fields it explicitly - set win. + 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``, ``conversation_configuration`` from ``TACConfig``, and ``action_url`` @@ -148,12 +193,12 @@ async def handle_incoming_call( Returns: TwiML XML string for call connection. """ - websocket_url = self._require_websocket_url("handle_incoming_call") + websocket_url = self._resolve_websocket_url("handle_incoming_call") # Invoke the customizer if configured and we have a request context. customized: TwiMLOptions | None = None - if self.config.customize_inbound_twiml is not None and twiml_request is not None: - customized = await self.config.customize_inbound_twiml(twiml_request) + 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) return twiml.generate_twiml(websocket_url, merged) @@ -201,8 +246,9 @@ def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: 1. customizer 2. channel ``default_twiml_options`` 3. Studio handoff (when ``studio_handoff_flow_sid`` is configured) - 4. ``VoiceChannelConfig.action_url`` (SDK-generated session-cleanup - default, usually set by ``TACFastAPIServer``). + 4. Channel default — ``VoiceChannelConfig.action_url`` if set, + else derived from ``TACConfig.voice_public_domain`` + + ``VoiceChannelConfig.action_path``. User-expressed intent (Studio handoff is configured explicitly on ``TACConfig``) beats the SDK's generated cleanup default. If a user @@ -232,7 +278,7 @@ def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: self.tac.config.account_sid, self.tac.config.studio_handoff_flow_sid, ) - return self.config.action_url + return self._resolve_default_action_url() async def handle_conversation_relay_callback( self, @@ -448,11 +494,12 @@ async def initiate_outbound_conversation( (``languages``) and nested models (``custom_parameters``) replace wholesale when set at a higher-priority layer. - The WebSocket URL is read from ``VoiceChannelConfig.websocket_url`` - (set automatically by ``TACFastAPIServer``) unless overridden per-call - via ``options.websocket_url``. + The WebSocket URL is derived from ``TACConfig.voice_public_domain`` + + ``VoiceChannelConfig.websocket_path``, or read from a + ``VoiceChannelConfig.websocket_url`` override, unless overridden + per-call via ``options.websocket_url``. """ - websocket_url = options.websocket_url or self._require_websocket_url( + websocket_url = options.websocket_url or self._resolve_websocket_url( "initiate_outbound_conversation" ) from_number = self.tac.config.phone_number diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index b63043b..688407f 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -8,7 +8,7 @@ from tac.models.voice import TwiMLOptions, TwiMLRequest from tac.session import SessionManager, ThreadSafeSessionManager -InboundTwiMLCustomizer = Callable[[TwiMLRequest], Awaitable[TwiMLOptions]] +InboundCallTwiMLHandler = Callable[[TwiMLRequest], Awaitable[TwiMLOptions]] class VoiceChannelConfig(BaseModel): @@ -18,8 +18,9 @@ class VoiceChannelConfig(BaseModel): TwiML configuration layers (highest precedence first): Inbound calls (``handle_incoming_call``): - 1. ``customize_inbound_twiml(twiml_request)`` output [optional] - 2. ``default_twiml_options`` [optional] + 1. Output of the customizer registered via + ``VoiceChannel.on_inbound_call_twiml(...)`` [optional] + 2. ``default_twiml_options`` [optional] 3. TAC defaults Outbound calls (``initiate_outbound_conversation``): @@ -40,29 +41,30 @@ class VoiceChannelConfig(BaseModel): - "once": Retrieve memory once at conversation start with empty query and cache it. Cache is invalidated when conversation becomes INACTIVE. - "never": Skip memory retrieval - websocket_url: Public WebSocket URL for ConversationRelay (e.g. - ``wss://example.ngrok.app/ws``). Required for outbound calls and - any call made via ``handle_incoming_call``. ``TACFastAPIServer`` - builds and sets this automatically from its ``public_domain`` + - ``websocket_path``; custom adapters (Flask, Django, …) must set - it themselves. - action_url: Public HTTPS URL for the TwiML ````, - used as the default session-cleanup callback. ``TACFastAPIServer`` - builds and sets this from ``public_domain`` + - ``conversation_relay_callback_path``. Higher-priority layers - (customizer, per-call ``twiml_options.action_url``, Studio - handoff) still override. + websocket_path: Path the voice WebSocket is served at (e.g. ``/ws``). + Combined with ``TACConfig.voice_public_domain`` to build the + public WebSocket URL. ``TACFastAPIServer`` registers its + WebSocket route at this path. Override only if you mount the + route at a non-default path. + action_path: Path the ConversationRelay action callback is served at + (e.g. ``/conversation-relay-callback``). Combined with + ``TACConfig.voice_public_domain`` to build the public action URL. + ``TACFastAPIServer`` registers its callback route at this path. + websocket_url: Override for the public WebSocket URL. Useful for + cross-domain or proxy setups where the URL doesn't follow the + standard ``wss://{voice_public_domain}{websocket_path}`` + template. When set, takes precedence over the derived value. + action_url: Override for the public action URL — same role as + ``websocket_url`` but for the ```` cleanup + callback. Higher-priority layers (customizer, per-call + ``twiml_options.action_url``, Studio handoff) still override. default_twiml_options: Static ``TwiMLOptions`` applied to every call (inbound and outbound) — voice, language, transcription provider, welcome_greeting, ```` children, etc. Use this when the same ConversationRelay configuration is correct for every call. - customize_inbound_twiml: Optional async callable producing per-call - ``TwiMLOptions`` overrides for inbound calls. Receives a - framework-neutral ``TwiMLRequest`` (parsed Twilio webhook fields). - Outbound calls don't use this — they pass per-call TwiML via - ``InitiateVoiceConversationOptions.twiml_options`` directly, - because outbound is initiated from user code that already has - per-call context in scope. + + Per-call inbound customization is registered via + ``VoiceChannel.on_inbound_call_twiml(...)`` (not on this config). """ model_config = {"arbitrary_types_allowed": True, "extra": "forbid"} @@ -78,25 +80,29 @@ class VoiceChannelConfig(BaseModel): default="never", description="Memory retrieval mode for this channel", ) + websocket_path: str = Field( + default="/ws", + description="Path the voice WebSocket is served at. Combined with " + "TACConfig.voice_public_domain to build the WebSocket URL.", + ) + action_path: str = Field( + default="/conversation-relay-callback", + description="Path the ConversationRelay action callback is served at. " + "Combined with TACConfig.voice_public_domain to build the action URL.", + ) websocket_url: str | None = Field( default=None, - description="Public WebSocket URL for ConversationRelay. Set by " - "TACFastAPIServer automatically; custom adapters must provide it.", + description="Override for the public WebSocket URL. Defaults to " + "wss://{voice_public_domain}{websocket_path}.", ) action_url: str | None = Field( default=None, - description="Public HTTPS URL for session cleanup. " - "Set by TACFastAPIServer automatically; overridable by customizer, " - "per-call twiml_options.action_url, or Studio handoff.", + description="Override for the public URL. Defaults " + "to https://{voice_public_domain}{action_path}.", ) default_twiml_options: TwiMLOptions | None = Field( default=None, description="Static TwiMLOptions applied to every call (inbound and " - "outbound). For per-call customization see customize_inbound_twiml " - "or InitiateVoiceConversationOptions.twiml_options.", - ) - customize_inbound_twiml: InboundTwiMLCustomizer | None = Field( - default=None, - description="Optional async callable returning per-call TwiMLOptions " - "overrides on inbound calls. Not invoked on outbound calls.", + "outbound). Per-call inbound customization is registered via " + "VoiceChannel.on_inbound_call_twiml(...).", ) diff --git a/src/tac/core/config.py b/src/tac/core/config.py index 7653d34..e93c778 100644 --- a/src/tac/core/config.py +++ b/src/tac/core/config.py @@ -277,6 +277,14 @@ def _normalize_and_validate_region(cls, v: object) -> str | None: "from this SID.", ) + voice_public_domain: str | None = Field( + default=None, + description="Public domain where voice routes are reachable (e.g. " + "'example.ngrok.app'). Used by VoiceChannel to construct the public " + "WebSocket URL and ConversationRelay action URL. Required when using " + "the Voice channel.", + ) + conversation_intelligence_config: ConversationIntelligenceConfig | None = Field( default=None, description="Optional Conversation Intelligence configuration for filtering webhook " @@ -330,6 +338,7 @@ def from_env(cls) -> "TACConfig": Default: INFO - TWILIO_REGION: Twilio region for data residency (e.g., 'au1', 'ie1') - TWILIO_STUDIO_HANDOFF_FLOW_SID: Studio Flow SID (FWxxx...) for handoff tool + - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice) Memory Configuration: - TWILIO_MEMORY_PROFILE_TRAIT_GROUPS: Trait groups to include @@ -368,6 +377,7 @@ def from_env(cls) -> "TACConfig": log_level=os.environ.get("TWILIO_LOG_LEVEL", "INFO"), region=os.environ.get("TWILIO_REGION"), studio_handoff_flow_sid=os.environ.get("TWILIO_STUDIO_HANDOFF_FLOW_SID"), + voice_public_domain=os.environ.get("TWILIO_VOICE_PUBLIC_DOMAIN"), memory_config=memory_config, conversation_intelligence_config=conversation_intelligence_config, ) diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index c587d54..b6dcfc3 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -325,7 +325,8 @@ class TwiMLRequest(BaseModel): """Framework-neutral view of the Twilio TwiML webhook form. Populated by ``TACFastAPIServer`` from the incoming Twilio webhook, then - passed to an optional ``customize_inbound_twiml`` so the application can + passed to a customizer registered via + ``VoiceChannel.on_inbound_call_twiml(...)`` so the application can produce per-call ``TwiMLOptions`` overrides without depending on FastAPI types. """ diff --git a/src/tac/server/config.py b/src/tac/server/config.py index cd447ef..f254f5f 100644 --- a/src/tac/server/config.py +++ b/src/tac/server/config.py @@ -9,14 +9,20 @@ class TACServerConfig(BaseModel): """Configuration for TAC server implementations. - Controls host/port binding, public domain for WebSocket URLs, - and customizable webhook paths. + Controls host/port binding and webhook paths registered by the server. + Voice-specific settings — the public domain, WebSocket path, and + ConversationRelay action path — live on ``TACConfig`` and + ``VoiceChannelConfig``, since they're consumed by the voice channel + regardless of which web framework is used. """ host: str = Field(default="0.0.0.0", description="Host to bind the server to") port: int = Field(default=8000, description="Port to bind the server to") + public_domain: str = Field( - default="", description="Public domain for WebSocket URL (e.g., 'example.ngrok.io')" + default="", + description="DEPRECATED: set TACConfig.voice_public_domain instead. " + "When set here, it is forwarded; will be removed in a future release.", ) welcome_greeting: str | None = Field( default=None, @@ -26,7 +32,7 @@ class TACServerConfig(BaseModel): ) @model_validator(mode="after") - def _warn_deprecated_welcome_greeting(self) -> "TACServerConfig": + def _warn_deprecated_fields(self) -> "TACServerConfig": if self.welcome_greeting is not None: warnings.warn( "TACServerConfig.welcome_greeting is deprecated and will be removed " @@ -34,16 +40,30 @@ def _warn_deprecated_welcome_greeting(self) -> "TACServerConfig": DeprecationWarning, stacklevel=2, ) + if self.public_domain: + warnings.warn( + "TACServerConfig.public_domain is deprecated and will be removed in " + "a future release. Set TACConfig.voice_public_domain (or " + "TWILIO_VOICE_PUBLIC_DOMAIN env var) instead.", + DeprecationWarning, + stacklevel=2, + ) return self conversation_webhook_path: str = Field( default="/webhook", description="Path for conversation webhook endpoint (for all channels)" ) twiml_path: str = Field(default="/twiml", description="Path for TwiML generation endpoint") - websocket_path: str = Field(default="/ws", description="Path for voice WebSocket endpoint") + websocket_path: str = Field( + default="/ws", + description="Path to register the voice WebSocket route at. " + "VoiceChannelConfig.websocket_path builds the public URL — keep them " + "in sync, or set VoiceChannelConfig.websocket_url directly.", + ) conversation_relay_callback_path: str = Field( default="/conversation-relay-callback", - description="Path for ConversationRelay action callback endpoint", + description="Path to register the ConversationRelay action callback route at. " + "Same pairing rule as websocket_path with VoiceChannelConfig.action_path.", ) cintel_webhook_path: str | None = Field( default=None, @@ -56,16 +76,13 @@ def from_env(cls) -> "TACServerConfig": """Create config from environment variables. Environment variables: - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for WebSocket URLs (required for voice) TWILIO_SERVER_HOST: Host to bind to (default: 0.0.0.0) TWILIO_SERVER_PORT: Port to bind to (default: 8000) + + TWILIO_VOICE_PUBLIC_DOMAIN is read by ``TACConfig.from_env`` instead. """ kwargs: dict[str, object] = {} - public_domain = os.environ.get("TWILIO_VOICE_PUBLIC_DOMAIN") - if public_domain: - kwargs["public_domain"] = public_domain - host = os.environ.get("TWILIO_SERVER_HOST") if host: kwargs["host"] = host diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index a189df8..942e136 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -85,9 +85,10 @@ class TACFastAPIServer: - To customize TwiML attributes (voice, language, transcription provider, interruption behavior, ```` children, etc.) set ``default_twiml_options`` on ``VoiceChannelConfig`` for same-on-every-call - settings. For per-call inbound customization, set - ``customize_inbound_twiml`` — an async callable that receives a - framework-neutral ``TwiMLRequest`` and returns a ``TwiMLOptions``. + settings. For per-call inbound customization, register a callback + via ``voice_channel.on_inbound_call_twiml(...)`` — an async + callable that receives a framework-neutral ``TwiMLRequest`` and + returns a ``TwiMLOptions``. Example: from fastapi import FastAPI @@ -119,7 +120,7 @@ def __init__( self.messaging_channels: list[MessagingChannel] = messaging_channels or [] if self.voice_channel is not None: - self._configure_voice_channel_urls() + self._forward_deprecated_public_domain() self._forward_deprecated_welcome_greeting() # Gather all channels that need webhook processing @@ -131,29 +132,13 @@ def __init__( self.app: FastAPI = app if app is not None else FastAPI(title="TAC Server") self._register_routes(self.app) - def _configure_voice_channel_urls(self) -> None: - """Populate the voice channel's websocket_url and action_url from - server config, if the user didn't already set them on - ``VoiceChannelConfig``. Requires ``public_domain`` to be set. + def _forward_deprecated_public_domain(self) -> None: + """Forward the deprecated ``TACServerConfig.public_domain`` into + ``TACConfig.voice_public_domain`` so the voice channel can use it. + Drop this method when ``TACServerConfig.public_domain`` is removed. """ - assert self.voice_channel is not None # checked by caller - if not self.config.public_domain: - raise ValueError( - "TACFastAPIServer requires public_domain when a voice_channel " - "is configured. Set TWILIO_VOICE_PUBLIC_DOMAIN or pass " - "public_domain=... on TACServerConfig." - ) - vc_config = self.voice_channel.config - # Treat empty strings as unset — matches VoiceChannel._require_websocket_url - # which checks truthiness, not just None. - if not vc_config.websocket_url: - vc_config.websocket_url = ( - f"wss://{self.config.public_domain}{self.config.websocket_path}" - ) - if not vc_config.action_url: - vc_config.action_url = ( - f"https://{self.config.public_domain}{self.config.conversation_relay_callback_path}" - ) + if self.config.public_domain and not self.tac.config.voice_public_domain: + self.tac.config.voice_public_domain = self.config.public_domain def _forward_deprecated_welcome_greeting(self) -> None: """Forward the deprecated ``TACServerConfig.welcome_greeting`` into diff --git a/tests/test_server.py b/tests/test_server.py index 709b5ec..832360a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,6 +19,7 @@ def get_test_config() -> dict: "api_secret": "test_api_token", "conversation_configuration_id": "conv_configuration_test123", "phone_number": "+15551234567", + "voice_public_domain": "test.ngrok.io", } @@ -31,24 +32,24 @@ class TestTACServerConfig: """Test TACServerConfig.""" def test_defaults(self) -> None: - config = TACServerConfig(public_domain="example.ngrok.io") + config = TACServerConfig() assert config.host == "0.0.0.0" assert config.port == 8000 - assert config.public_domain == "example.ngrok.io" assert config.welcome_greeting is None assert config.conversation_webhook_path == "/webhook" assert config.twiml_path == "/twiml" assert config.websocket_path == "/ws" + assert config.conversation_relay_callback_path == "/conversation-relay-callback" assert config.cintel_webhook_path is None def test_custom_paths(self) -> None: config = TACServerConfig( - public_domain="my.domain.com", host="127.0.0.1", port=3000, conversation_webhook_path="/conversations", twiml_path="/voice/twiml", websocket_path="/voice/ws", + conversation_relay_callback_path="/voice/cleanup", cintel_webhook_path="/ci", ) assert config.host == "127.0.0.1" @@ -56,26 +57,27 @@ def test_custom_paths(self) -> None: assert config.conversation_webhook_path == "/conversations" assert config.twiml_path == "/voice/twiml" assert config.websocket_path == "/voice/ws" + assert config.conversation_relay_callback_path == "/voice/cleanup" assert config.cintel_webhook_path == "/ci" def test_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("TWILIO_VOICE_PUBLIC_DOMAIN", "my.ngrok.io") monkeypatch.setenv("TWILIO_SERVER_HOST", "127.0.0.1") monkeypatch.setenv("TWILIO_SERVER_PORT", "3000") config = TACServerConfig.from_env() - assert config.public_domain == "my.ngrok.io" assert config.host == "127.0.0.1" assert config.port == 3000 def test_from_env_defaults(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("TWILIO_VOICE_PUBLIC_DOMAIN", raising=False) monkeypatch.delenv("TWILIO_SERVER_HOST", raising=False) monkeypatch.delenv("TWILIO_SERVER_PORT", raising=False) config = TACServerConfig.from_env() - assert config.public_domain == "" assert config.host == "0.0.0.0" assert config.port == 8000 + def test_deprecated_public_domain_warns(self) -> None: + with pytest.warns(DeprecationWarning, match="public_domain"): + TACServerConfig(public_domain="example.ngrok.io") + class TestTACConfigStudioHandoffFlowSid: """Test TACConfig studio_handoff_flow_sid field.""" @@ -220,7 +222,7 @@ async def test_messaging_webhook_fanout(self) -> None: server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), messaging_channels=[sms, chat], ) app = server.app @@ -270,7 +272,7 @@ async def test_conversation_webhook_handles_channel_errors(self) -> None: server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), messaging_channels=[sms, chat], ) app = server.app @@ -316,7 +318,7 @@ def test_create_app_voice_only(self) -> None: vc = VoiceChannel(tac) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=vc, ) app = server.app @@ -335,7 +337,7 @@ def test_create_app_messaging_only(self) -> None: sms = SMSChannel(tac) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), messaging_channels=[sms], ) app = server.app @@ -352,9 +354,7 @@ def test_create_app_with_cintel(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig( - public_domain="test.ngrok.io", cintel_webhook_path="/ci-webhook" - ), + config=TACServerConfig(cintel_webhook_path="/ci-webhook"), ) app = server.app @@ -370,7 +370,6 @@ def test_create_app_custom_paths(self) -> None: server = TACFastAPIServer( tac=tac, config=TACServerConfig( - public_domain="test.ngrok.io", conversation_webhook_path="/conversations", twiml_path="/voice/twiml", websocket_path="/voice/ws", @@ -395,7 +394,7 @@ def test_custom_app_is_used(self) -> None: custom_app = FastAPI(title="My Custom Service", version="9.9.9") server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), app=custom_app, ) assert server.app is custom_app @@ -413,7 +412,7 @@ def test_custom_app_has_tac_routes(self) -> None: custom_app = FastAPI() server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), messaging_channels=[SMSChannel(tac)], app=custom_app, ) @@ -429,7 +428,7 @@ def test_default_app_created(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), ) assert isinstance(server.app, FastAPI) assert server.app.title == "TAC Server" @@ -444,7 +443,7 @@ async def test_can_add_custom_route_post_construction(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), ) @server.app.get("/health") @@ -468,7 +467,7 @@ async def test_can_add_middleware_post_construction(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), ) class HeaderMiddleware(BaseHTTPMiddleware): @@ -500,7 +499,7 @@ async def test_can_add_exception_handler(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), ) class MyError(Exception): @@ -528,7 +527,7 @@ def test_routes_registered_exactly_once(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), messaging_channels=[SMSChannel(tac)], ) route_paths = [r.path for r in server.app.routes if hasattr(r, "path")] @@ -547,7 +546,7 @@ def _build_server(self, **tac_overrides: object) -> object: tac = TAC({**get_test_config(), **tac_overrides}) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=VoiceChannel(tac), ) return TestClient(server.app) @@ -598,7 +597,7 @@ async def test_webhook_rejects_missing_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), messaging_channels=[SMSChannel(tac)], ) transport = ASGITransport(app=server.app) @@ -616,7 +615,7 @@ async def test_webhook_rejects_invalid_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), messaging_channels=[SMSChannel(tac)], ) transport = ASGITransport(app=server.app) @@ -641,7 +640,7 @@ async def test_webhook_accepts_valid_signature(self) -> None: sms = SMSChannel(tac) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), messaging_channels=[sms], ) with patch.object(sms, "process_webhook", new_callable=AsyncMock): @@ -665,7 +664,7 @@ def test_twiml_rejects_missing_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=VoiceChannel(tac), ) client = TestClient(server.app) @@ -681,7 +680,7 @@ def test_conversation_relay_callback_rejects_missing_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=VoiceChannel(tac), ) client = TestClient(server.app) @@ -697,7 +696,7 @@ def test_conversation_relay_callback_accepts_valid_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=VoiceChannel(tac), ) client = TestClient(server.app) @@ -726,7 +725,7 @@ def test_twiml_accepts_valid_form_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=VoiceChannel(tac), ) client = TestClient(server.app) @@ -748,9 +747,7 @@ async def test_cintel_webhook_rejects_missing_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig( - public_domain="test.ngrok.io", cintel_webhook_path="/ci-webhook" - ), + config=TACServerConfig(cintel_webhook_path="/ci-webhook"), ) transport = ASGITransport(app=server.app) async with AsyncClient(transport=transport, base_url="http://test") as client: @@ -766,7 +763,7 @@ async def test_custom_routes_not_affected(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), ) @server.app.get("/health") @@ -812,7 +809,7 @@ def test_websocket_rejects_missing_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=VoiceChannel(tac), ) client = TestClient(server.app) @@ -830,7 +827,7 @@ def test_websocket_rejects_invalid_signature(self) -> None: tac = TAC(get_test_config()) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=VoiceChannel(tac), ) client = TestClient(server.app) @@ -847,7 +844,7 @@ def test_customizer_on_voice_channel_receives_parsed_context_and_overrides_twiml ) -> None: from fastapi.testclient import TestClient - from tac.channels.voice import VoiceChannel, VoiceChannelConfig + from tac.channels.voice import VoiceChannel from tac.models.voice import TwiMLOptions, TwiMLRequest from tac.server import TACFastAPIServer @@ -858,10 +855,12 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(voice="en-US-Journey-D", language="en-US") tac = TAC(get_test_config()) - vc = VoiceChannel(tac, config=VoiceChannelConfig(customize_inbound_twiml=customizer)) + vc = VoiceChannel(tac) + + vc.on_inbound_call_twiml(customizer) server = TACFastAPIServer( tac=tac, - config=TACServerConfig(public_domain="test.ngrok.io"), + config=TACServerConfig(), voice_channel=vc, ) client = TestClient(server.app) @@ -893,7 +892,7 @@ class TestDeprecatedWelcomeGreetingForwarding: def test_deprecated_field_emits_warning(self) -> None: with pytest.warns(DeprecationWarning, match="welcome_greeting"): - TACServerConfig(public_domain="test.ngrok.io", welcome_greeting="Legacy!") + TACServerConfig(welcome_greeting="Legacy!") def test_forwarded_when_channel_did_not_set_greeting(self) -> None: from fastapi.testclient import TestClient @@ -904,9 +903,7 @@ def test_forwarded_when_channel_did_not_set_greeting(self) -> None: tac = TAC(get_test_config()) vc = VoiceChannel(tac) # no welcome_greeting on channel with pytest.warns(DeprecationWarning): - server_config = TACServerConfig( - public_domain="test.ngrok.io", welcome_greeting="Legacy!" - ) + server_config = TACServerConfig(welcome_greeting="Legacy!") server = TACFastAPIServer(tac=tac, config=server_config, voice_channel=vc) client = TestClient(server.app) signature = compute_signature("http://testserver/twiml") @@ -928,9 +925,7 @@ def test_twiml_options_greeting_wins_over_deprecated_server_field(self) -> None: ), ) with pytest.warns(DeprecationWarning): - server_config = TACServerConfig( - public_domain="test.ngrok.io", welcome_greeting="Legacy!" - ) + server_config = TACServerConfig(welcome_greeting="Legacy!") server = TACFastAPIServer(tac=tac, config=server_config, voice_channel=vc) client = TestClient(server.app) signature = compute_signature("http://testserver/twiml") diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 4555283..d950681 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1536,9 +1536,9 @@ async def customizer(req: TwiMLRequest) -> TwiMLOptions: tac, config=VoiceChannelConfig( default_twiml_options=TwiMLOptions(action_url="https://static.example.com/end"), - customize_inbound_twiml=customizer, ), ) + channel.on_inbound_call_twiml(customizer) channel.config.websocket_url = "wss://example.com/ws" channel.config.action_url = "https://server.example.com/end" twiml = await channel.handle_incoming_call(twiml_request=TwiMLRequest()) @@ -1586,7 +1586,6 @@ class TestCustomizeTwiMLOptions: @pytest.mark.asyncio async def test_customizer_skipped_without_twiml_request(self) -> None: - from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequest called = False @@ -1597,7 +1596,9 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(voice="en-US-Journey-D") tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_inbound_twiml=customizer)) + channel = VoiceChannel(tac) + + channel.on_inbound_call_twiml(customizer) channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call() assert called is False @@ -1605,7 +1606,6 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: @pytest.mark.asyncio async def test_customizer_invoked_with_twiml_request(self) -> None: - from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequest seen: dict[str, TwiMLRequest] = {} @@ -1615,7 +1615,9 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(voice="en-US-Journey-D", interruptible="speech") tac = TAC(get_test_config()) - channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_inbound_twiml=customizer)) + channel = VoiceChannel(tac) + + channel.on_inbound_call_twiml(customizer) ctx = TwiMLRequest(from_number="+14155551234", caller_country="US") channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( @@ -1638,9 +1640,9 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: tac, config=VoiceChannelConfig( default_twiml_options=TwiMLOptions(voice="en-US-Journey-D"), - customize_inbound_twiml=customizer, ), ) + channel.on_inbound_call_twiml(customizer) channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( twiml_request=TwiMLRequest(), @@ -1660,9 +1662,9 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: tac, config=VoiceChannelConfig( default_twiml_options=TwiMLOptions(welcome_greeting="Channel default"), - customize_inbound_twiml=customizer, ), ) + channel.on_inbound_call_twiml(customizer) channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( twiml_request=TwiMLRequest(), @@ -1673,7 +1675,6 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: @pytest.mark.asyncio async def test_customizer_action_url_wins_over_studio_handoff(self) -> None: - from tac.channels.voice import VoiceChannelConfig from tac.models.voice import TwiMLRequest flow_sid = "FW" + "a" * 32 @@ -1682,7 +1683,9 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: return TwiMLOptions(action_url="https://customizer.example.com/end") tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) - channel = VoiceChannel(tac, config=VoiceChannelConfig(customize_inbound_twiml=customizer)) + channel = VoiceChannel(tac) + + channel.on_inbound_call_twiml(customizer) channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( twiml_request=TwiMLRequest(), From 1bef7f5dc306adde6b298041de180651a3856093 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Tue, 19 May 2026 13:21:26 -0400 Subject: [PATCH 27/32] refactor(voice): remove deprecation plumbing for old voice config fields Drops TACServerConfig.public_domain and welcome_greeting, the flat welcome_greeting/action_url/custom_parameters on InitiateVoiceConversationOptions, and the forwarding/warning paths that translated them into the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/models/outbound.py | 57 +--------------------- src/tac/server/config.py | 34 +------------ src/tac/server/fastapi_server.py | 32 +----------- tests/test_outbound.py | 84 -------------------------------- tests/test_server.py | 52 -------------------- 5 files changed, 3 insertions(+), 256 deletions(-) diff --git a/src/tac/models/outbound.py b/src/tac/models/outbound.py index 37fc8b4..b6d555c 100644 --- a/src/tac/models/outbound.py +++ b/src/tac/models/outbound.py @@ -1,9 +1,8 @@ """Models for outbound conversation initiation.""" -import warnings from typing import Any -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field from tac.models.session import ConversationSession from tac.models.voice import TwiMLOptions @@ -87,62 +86,8 @@ class InitiateVoiceConversationOptions(BaseModel): "VoiceChannelConfig.default_twiml_options and TAC defaults.", ) - # Deprecated flat fields. Forwarded into twiml_options in the validator - # below. Remove in a future release. - welcome_greeting: str | None = Field( - default=None, - description="DEPRECATED: set welcome_greeting on twiml_options or " - "VoiceChannelConfig.default_twiml_options instead.", - ) - action_url: str | None = Field( - default=None, - description="DEPRECATED: set action_url on twiml_options or " - "VoiceChannelConfig.default_twiml_options instead.", - ) - custom_parameters: dict[str, str | int | bool] | None = Field( - default=None, - description="DEPRECATED: set custom_parameters on twiml_options or " - "VoiceChannelConfig.default_twiml_options instead.", - ) - model_config = {"populate_by_name": True} - @model_validator(mode="after") - def _forward_deprecated_fields(self) -> "InitiateVoiceConversationOptions": - """Forward deprecated flat fields into twiml_options with a warning. - - If both the flat field and twiml_options. are set, the explicit - twiml_options value wins. - """ - deprecated = { - "welcome_greeting": self.welcome_greeting, - "action_url": self.action_url, - "custom_parameters": self.custom_parameters, - } - set_fields = {k: v for k, v in deprecated.items() if v is not None} - if not set_fields: - return self - - warnings.warn( - "InitiateVoiceConversationOptions flat fields " - f"({', '.join(set_fields)}) are deprecated. Pass them on " - "twiml_options or configure them on " - "VoiceChannelConfig.default_twiml_options.", - DeprecationWarning, - stacklevel=2, - ) - - if self.twiml_options is None: - self.twiml_options = TwiMLOptions(**set_fields) - else: - # twiml_options explicit fields win; fill in only fields the user - # didn't set on twiml_options. - already_set = self.twiml_options.model_fields_set - for key, value in set_fields.items(): - if key not in already_set: - setattr(self.twiml_options, key, value) - return self - class InitiateVoiceConversationResult(BaseModel): """Result of initiating an outbound voice conversation.""" diff --git a/src/tac/server/config.py b/src/tac/server/config.py index f254f5f..5b51297 100644 --- a/src/tac/server/config.py +++ b/src/tac/server/config.py @@ -1,9 +1,8 @@ """Configuration for TAC server implementations.""" import os -import warnings -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field class TACServerConfig(BaseModel): @@ -19,37 +18,6 @@ class TACServerConfig(BaseModel): host: str = Field(default="0.0.0.0", description="Host to bind the server to") port: int = Field(default=8000, description="Port to bind the server to") - public_domain: str = Field( - default="", - description="DEPRECATED: set TACConfig.voice_public_domain instead. " - "When set here, it is forwarded; will be removed in a future release.", - ) - welcome_greeting: str | None = Field( - default=None, - description="DEPRECATED: set welcome_greeting on VoiceChannelConfig instead. " - "When set here, it is forwarded to the voice channel as a default; it will be " - "removed in a future release.", - ) - - @model_validator(mode="after") - def _warn_deprecated_fields(self) -> "TACServerConfig": - if self.welcome_greeting is not None: - warnings.warn( - "TACServerConfig.welcome_greeting is deprecated and will be removed " - "in a future release. Set welcome_greeting on VoiceChannelConfig instead.", - DeprecationWarning, - stacklevel=2, - ) - if self.public_domain: - warnings.warn( - "TACServerConfig.public_domain is deprecated and will be removed in " - "a future release. Set TACConfig.voice_public_domain (or " - "TWILIO_VOICE_PUBLIC_DOMAIN env var) instead.", - DeprecationWarning, - stacklevel=2, - ) - return self - conversation_webhook_path: str = Field( default="/webhook", description="Path for conversation webhook endpoint (for all channels)" ) diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 942e136..f0a7860 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -16,7 +16,7 @@ from tac.channels.websocket_protocol import WebSocketDisconnectError from tac.core.logging import get_logger from tac.core.tac import TAC -from tac.models.voice import TwiMLOptions, TwiMLRequest +from tac.models.voice import TwiMLRequest from tac.server.config import TACServerConfig if TYPE_CHECKING: @@ -119,10 +119,6 @@ def __init__( self.voice_channel = voice_channel self.messaging_channels: list[MessagingChannel] = messaging_channels or [] - if self.voice_channel is not None: - self._forward_deprecated_public_domain() - self._forward_deprecated_welcome_greeting() - # Gather all channels that need webhook processing self.webhook_channels: list[BaseChannel] = [] if self.voice_channel: @@ -132,32 +128,6 @@ def __init__( self.app: FastAPI = app if app is not None else FastAPI(title="TAC Server") self._register_routes(self.app) - def _forward_deprecated_public_domain(self) -> None: - """Forward the deprecated ``TACServerConfig.public_domain`` into - ``TACConfig.voice_public_domain`` so the voice channel can use it. - Drop this method when ``TACServerConfig.public_domain`` is removed. - """ - if self.config.public_domain and not self.tac.config.voice_public_domain: - self.tac.config.voice_public_domain = self.config.public_domain - - def _forward_deprecated_welcome_greeting(self) -> None: - """Forward the deprecated ``TACServerConfig.welcome_greeting`` into - the voice channel's ``default_twiml_options.welcome_greeting`` so it - flows through the normal merge pipeline. Only fills in the field if - the user didn't already set it. Drop this method when the deprecated - field is removed from ``TACServerConfig``. - """ - assert self.voice_channel is not None # checked by caller - if self.config.welcome_greeting is None: - return - vc_config = self.voice_channel.config - if vc_config.default_twiml_options is None: - vc_config.default_twiml_options = TwiMLOptions( - welcome_greeting=self.config.welcome_greeting - ) - elif "welcome_greeting" not in vc_config.default_twiml_options.model_fields_set: - vc_config.default_twiml_options.welcome_greeting = self.config.welcome_greeting - def _register_routes(self, app: FastAPI) -> None: """Register TAC routes (conversation webhook, voice, CI) onto the given FastAPI app.""" config = self.config diff --git a/tests/test_outbound.py b/tests/test_outbound.py index d428c6d..1f2b638 100644 --- a/tests/test_outbound.py +++ b/tests/test_outbound.py @@ -795,90 +795,6 @@ async def test_studio_handoff_used_when_no_action_url(self) -> None: twiml_xml = mock_client.calls.create.call_args.kwargs["twiml"] assert f"Flows/{flow_sid}" in twiml_xml - @pytest.mark.asyncio - async def test_deprecated_flat_fields_emit_warning_and_forward(self) -> None: - tac = TAC(get_test_config()) - channel = VoiceChannel(tac) - - mock_call = MagicMock() - mock_call.sid = "CAdepr" - mock_client = MagicMock() - mock_client.calls.create.return_value = mock_call - - with ( - patch.object(channel, "_get_twilio_client", return_value=mock_client), - pytest.warns(DeprecationWarning, match="flat fields"), - ): - await channel.initiate_outbound_conversation( - InitiateVoiceConversationOptions( - to="+15559876543", - websocket_url="wss://example.com/ws", - welcome_greeting="Legacy greeting", - custom_parameters={"legacy": "true"}, - ) - ) - - twiml_xml = mock_client.calls.create.call_args.kwargs["twiml"] - assert "Legacy greeting" in twiml_xml - assert 'name="legacy"' in twiml_xml - - def test_deprecated_flat_fields_marked_as_explicitly_set_after_forwarding( - self, - ) -> None: - """Forwarded deprecated values must end up in model_fields_set on the - twiml_options object, otherwise the merge layer in handle_incoming_call - / initiate_outbound_conversation would treat them as 'not set' and the - fallback default would override them.""" - import warnings - - from tac.models.voice import TwiMLOptions - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - opts = InitiateVoiceConversationOptions( - to="+15559876543", - websocket_url="wss://example.com/ws", - twiml_options=TwiMLOptions(voice="en-US-Journey-D"), - welcome_greeting="Legacy", - ) - assert opts.twiml_options is not None - assert opts.twiml_options.welcome_greeting == "Legacy" - # Critical: must be in model_fields_set so the merge layer treats it - # as an explicit override, not a fallthrough. - assert "welcome_greeting" in opts.twiml_options.model_fields_set - assert "voice" in opts.twiml_options.model_fields_set - - @pytest.mark.asyncio - async def test_deprecated_flat_fields_lose_to_explicit_twiml_options(self) -> None: - """If both flat welcome_greeting and twiml_options.welcome_greeting are - set, the twiml_options value wins (the user's explicit modern call).""" - from tac.models.voice import TwiMLOptions - - tac = TAC(get_test_config()) - channel = VoiceChannel(tac) - - mock_call = MagicMock() - mock_call.sid = "CAboth" - mock_client = MagicMock() - mock_client.calls.create.return_value = mock_call - - with ( - patch.object(channel, "_get_twilio_client", return_value=mock_client), - pytest.warns(DeprecationWarning), - ): - await channel.initiate_outbound_conversation( - InitiateVoiceConversationOptions( - to="+15559876543", - websocket_url="wss://example.com/ws", - welcome_greeting="Legacy", - twiml_options=TwiMLOptions(welcome_greeting="Modern"), - ) - ) - - twiml_xml = mock_client.calls.create.call_args.kwargs["twiml"] - assert "Modern" in twiml_xml - assert "Legacy" not in twiml_xml - # ============================================================================= # RCS outbound diff --git a/tests/test_server.py b/tests/test_server.py index 832360a..b3c3f12 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -35,7 +35,6 @@ def test_defaults(self) -> None: config = TACServerConfig() assert config.host == "0.0.0.0" assert config.port == 8000 - assert config.welcome_greeting is None assert config.conversation_webhook_path == "/webhook" assert config.twiml_path == "/twiml" assert config.websocket_path == "/ws" @@ -74,10 +73,6 @@ def test_from_env_defaults(self, monkeypatch: pytest.MonkeyPatch) -> None: assert config.host == "0.0.0.0" assert config.port == 8000 - def test_deprecated_public_domain_warns(self) -> None: - with pytest.warns(DeprecationWarning, match="public_domain"): - TACServerConfig(public_domain="example.ngrok.io") - class TestTACConfigStudioHandoffFlowSid: """Test TACConfig studio_handoff_flow_sid field.""" @@ -885,50 +880,3 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: assert 'voice="en-US-Journey-D"' in body assert 'language="en-US"' in body assert 'url="wss://test.ngrok.io/ws"' in body - - -class TestDeprecatedWelcomeGreetingForwarding: - """TACServerConfig.welcome_greeting is deprecated; verify it still reaches the channel.""" - - def test_deprecated_field_emits_warning(self) -> None: - with pytest.warns(DeprecationWarning, match="welcome_greeting"): - TACServerConfig(welcome_greeting="Legacy!") - - def test_forwarded_when_channel_did_not_set_greeting(self) -> None: - from fastapi.testclient import TestClient - - from tac.channels.voice import VoiceChannel - from tac.server import TACFastAPIServer - - tac = TAC(get_test_config()) - vc = VoiceChannel(tac) # no welcome_greeting on channel - with pytest.warns(DeprecationWarning): - server_config = TACServerConfig(welcome_greeting="Legacy!") - server = TACFastAPIServer(tac=tac, config=server_config, voice_channel=vc) - client = TestClient(server.app) - signature = compute_signature("http://testserver/twiml") - resp = client.post("/twiml", headers={"X-Twilio-Signature": signature}) - assert 'welcomeGreeting="Legacy!"' in resp.text - - def test_twiml_options_greeting_wins_over_deprecated_server_field(self) -> None: - from fastapi.testclient import TestClient - - from tac.channels.voice import VoiceChannel, VoiceChannelConfig - from tac.models.voice import TwiMLOptions - from tac.server import TACFastAPIServer - - tac = TAC(get_test_config()) - vc = VoiceChannel( - tac, - config=VoiceChannelConfig( - default_twiml_options=TwiMLOptions(welcome_greeting="Channel!"), - ), - ) - with pytest.warns(DeprecationWarning): - server_config = TACServerConfig(welcome_greeting="Legacy!") - server = TACFastAPIServer(tac=tac, config=server_config, voice_channel=vc) - client = TestClient(server.app) - signature = compute_signature("http://testserver/twiml") - resp = client.post("/twiml", headers={"X-Twilio-Signature": signature}) - assert 'welcomeGreeting="Channel!"' in resp.text - assert "Legacy!" not in resp.text From 8c80073ba1bd4ffa06559c6aec94a94fb11f03f4 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Tue, 19 May 2026 13:39:25 -0400 Subject: [PATCH 28/32] =?UTF-8?q?fix(voice):=20tighten=20URL=20config=20?= =?UTF-8?q?=E2=80=94=20suppression,=20normalization,=20fail-fast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _resolve_action_url now respects explicit action_url=None on a layer as suppression intent (using model_fields_set), instead of falling through to lower layers. Set action_url=None on the customizer or default_twiml_options to disable for that scope. - TACConfig.voice_public_domain strips whitespace, schemes (https://, wss://, etc.), and trailing slashes at parse time so a copy-pasted https://example.ngrok.app/ doesn't produce wss://https://example.ngrok.app//ws. - VoiceChannelConfig.websocket_url and action_url validate their schemes (ws/wss and http/https respectively) — bare domains now fail at construction with a clear message. - TACFastAPIServer raises at __init__ if a voice channel is attached but neither voice_public_domain nor websocket_url is set, rather than letting the misconfiguration surface as a 500 on the first inbound call. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 16 ++-- src/tac/channels/voice/config.py | 29 ++++++- src/tac/core/config.py | 23 +++++- src/tac/server/fastapi_server.py | 27 +++++++ tests/test_server.py | 50 ++++++++++++ tests/test_voice_channel.py | 129 ++++++++++++++++++++++++++++++ 6 files changed, 262 insertions(+), 12 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index b4460d7..8af5117 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -256,21 +256,17 @@ def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: for that call — the session-cleanup URL is skipped, same as if they had set any other action_url via customizer or static options. - Note: explicit ``action_url=None`` on a layer does not suppress - ```` — it falls through to the next layer. - Suppressing requires unsetting the action_url everywhere (no Studio - handoff, no channel default, etc). + Explicit ``action_url=None`` on a layer suppresses + ```` entirely — all lower layers are skipped. + Use this to disable the cleanup callback for a specific call (e.g. + from a customizer) or channel-wide. ``action_url`` left unset (not + in ``model_fields_set``) falls through to the next layer. """ - if ( - customized is not None - and "action_url" in customized.model_fields_set - and customized.action_url is not None - ): + if customized is not None and "action_url" in customized.model_fields_set: return customized.action_url if ( self.config.default_twiml_options is not None and "action_url" in self.config.default_twiml_options.model_fields_set - and self.config.default_twiml_options.action_url is not None ): return self.config.default_twiml_options.action_url if self.tac.config.studio_handoff_flow_sid: diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index 688407f..3c18221 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from tac.models.memory import MemoryMode from tac.models.voice import TwiMLOptions, TwiMLRequest @@ -106,3 +106,30 @@ class VoiceChannelConfig(BaseModel): "outbound). Per-call inbound customization is registered via " "VoiceChannel.on_inbound_call_twiml(...).", ) + + @field_validator("websocket_url") + @classmethod + def _validate_websocket_url(cls, v: str | None) -> str | None: + if v is None: + return v + v = v.strip() + if not v: + return None + if not v.lower().startswith(("ws://", "wss://")): + raise ValueError( + f"websocket_url must start with ws:// or wss:// (got {v!r}). " + "If you only have a domain, set TACConfig.voice_public_domain instead." + ) + return v + + @field_validator("action_url") + @classmethod + def _validate_action_url(cls, v: str | None) -> str | None: + if v is None: + return v + v = v.strip() + if not v: + return None + if not v.lower().startswith(("http://", "https://")): + raise ValueError(f"action_url must start with http:// or https:// (got {v!r}).") + return v diff --git a/src/tac/core/config.py b/src/tac/core/config.py index e93c778..e69294a 100644 --- a/src/tac/core/config.py +++ b/src/tac/core/config.py @@ -282,9 +282,30 @@ def _normalize_and_validate_region(cls, v: object) -> str | None: description="Public domain where voice routes are reachable (e.g. " "'example.ngrok.app'). Used by VoiceChannel to construct the public " "WebSocket URL and ConversationRelay action URL. Required when using " - "the Voice channel.", + "the Voice channel. Schemes (https://, wss://) and trailing slashes " + "are stripped automatically.", ) + @field_validator("voice_public_domain", mode="before") + @classmethod + def _normalize_voice_public_domain(cls, v: str | None) -> str | None: + """Strip whitespace, schemes, and trailing slashes from voice_public_domain. + + A naive copy-paste from a browser address bar produces values like + ``https://example.ngrok.app/`` which would otherwise concatenate into + ``wss://https://example.ngrok.app//ws`` — clean them up at parse time. + """ + if v is None: + return v + v = v.strip() + if not v: + return None + for scheme in ("https://", "http://", "wss://", "ws://"): + if v.lower().startswith(scheme): + v = v[len(scheme) :] + break + return v.rstrip("/") or None + conversation_intelligence_config: ConversationIntelligenceConfig | None = Field( default=None, description="Optional Conversation Intelligence configuration for filtering webhook " diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index f0a7860..cbcb6b8 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -119,6 +119,9 @@ def __init__( self.voice_channel = voice_channel self.messaging_channels: list[MessagingChannel] = messaging_channels or [] + if self.voice_channel is not None: + self._validate_voice_url_config() + # Gather all channels that need webhook processing self.webhook_channels: list[BaseChannel] = [] if self.voice_channel: @@ -128,6 +131,30 @@ def __init__( self.app: FastAPI = app if app is not None else FastAPI(title="TAC Server") self._register_routes(self.app) + def _validate_voice_url_config(self) -> None: + """Fail fast at server construction if the voice channel can't build a + WebSocket URL. + + Without this check, the misconfiguration would only surface on the + first inbound call as a 500 — an easy thing to miss in CI or smoke + tests that don't hit voice. Failing here means the server doesn't + start at all if the voice channel isn't wired up. + + Custom adapters (Flask/Django/etc.) constructing ``VoiceChannel`` + directly still get the runtime ``ValueError`` on first request — we + can't know what URL plumbing they're doing outside this server. + """ + assert self.voice_channel is not None # checked by caller + if self.voice_channel.config.websocket_url: + return + if self.tac.config.voice_public_domain: + return + raise ValueError( + "Voice channel is configured but no public URL is set. " + "Set TACConfig.voice_public_domain (or TWILIO_VOICE_PUBLIC_DOMAIN env " + "var), or pass websocket_url on VoiceChannelConfig as an override." + ) + def _register_routes(self, app: FastAPI) -> None: """Register TAC routes (conversation webhook, voice, CI) onto the given FastAPI app.""" config = self.config diff --git a/tests/test_server.py b/tests/test_server.py index b3c3f12..b16075d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -87,6 +87,56 @@ def test_studio_handoff_flow_sid_accepts_value(self) -> None: assert tac.config.studio_handoff_flow_sid == flow_sid +class TestVoiceUrlConfigValidationAtStartup: + """TACFastAPIServer fails fast at construction if a voice channel is + attached but no public URL is configured. Catches the misconfiguration + before the first inbound call rather than returning a 500.""" + + def test_raises_when_voice_channel_without_url_or_domain(self) -> None: + from tac.channels.voice import VoiceChannel + from tac.server import TACFastAPIServer + + # Config without voice_public_domain + cfg = {**get_test_config()} + cfg.pop("voice_public_domain", None) + tac = TAC(cfg) + vc = VoiceChannel(tac) + + with pytest.raises(ValueError, match="voice_public_domain"): + TACFastAPIServer(tac=tac, voice_channel=vc) + + def test_passes_when_voice_public_domain_set(self) -> None: + from tac.channels.voice import VoiceChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) # has voice_public_domain + vc = VoiceChannel(tac) + TACFastAPIServer(tac=tac, voice_channel=vc) # no raise + + def test_passes_when_websocket_url_override_set(self) -> None: + from tac.channels.voice import VoiceChannel, VoiceChannelConfig + from tac.server import TACFastAPIServer + + cfg = {**get_test_config()} + cfg.pop("voice_public_domain", None) + tac = TAC(cfg) + vc = VoiceChannel( + tac, + config=VoiceChannelConfig(websocket_url="wss://override.example.com/ws"), + ) + TACFastAPIServer(tac=tac, voice_channel=vc) # no raise + + def test_passes_without_voice_channel(self) -> None: + """No voice channel attached: validation skipped, server starts fine + even without voice_public_domain.""" + from tac.server import TACFastAPIServer + + cfg = {**get_test_config()} + cfg.pop("voice_public_domain", None) + tac = TAC(cfg) + TACFastAPIServer(tac=tac) # no raise + + class TestWebSocketDisconnectError: """Test WebSocketDisconnectError.""" diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index d950681..4db10f4 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1545,6 +1545,50 @@ async def customizer(req: TwiMLRequest) -> TwiMLOptions: assert 'action="https://static.example.com/end"' in twiml assert 'voice="en-US-Journey-D"' in twiml + @pytest.mark.asyncio + async def test_customizer_action_url_none_suppresses(self) -> None: + """Explicit action_url=None on the customizer suppresses the + attribute, even if Studio handoff or a channel + default would otherwise populate it.""" + from tac.channels.voice import VoiceChannelConfig + from tac.models.voice import TwiMLRequest + + async def customizer(req: TwiMLRequest) -> TwiMLOptions: + return TwiMLOptions(action_url=None) + + flow_sid = "FW" + "a" * 32 + tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + default_twiml_options=TwiMLOptions(action_url="https://static.example.com/end"), + ), + ) + channel.on_inbound_call_twiml(customizer) + channel.config.websocket_url = "wss://example.com/ws" + channel.config.action_url = "https://server.example.com/end" + twiml = await channel.handle_incoming_call(twiml_request=TwiMLRequest()) + assert "action=" not in twiml + + @pytest.mark.asyncio + async def test_default_options_action_url_none_suppresses(self) -> None: + """Explicit action_url=None on default_twiml_options suppresses + channel-wide, even with Studio handoff configured.""" + from tac.channels.voice import VoiceChannelConfig + + flow_sid = "FW" + "a" * 32 + tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) + channel = VoiceChannel( + tac, + config=VoiceChannelConfig( + default_twiml_options=TwiMLOptions(action_url=None), + ), + ) + channel.config.websocket_url = "wss://example.com/ws" + channel.config.action_url = "https://server.example.com/end" + twiml = await channel.handle_incoming_call() + assert "action=" not in twiml + class TestStaticTwiMLOptions: """VoiceChannelConfig.twiml_options applies to every call without a callback.""" @@ -2100,3 +2144,88 @@ async def running_callback(message, context, memory): # Verify task was cancelled assert task_cancelled == [True] assert session_state.stream_task.done() + + +class TestVoicePublicDomainNormalization: + """TACConfig.voice_public_domain strips schemes/whitespace/trailing slashes.""" + + def test_strips_https_scheme(self) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": "https://example.ngrok.app"}) + assert tac.config.voice_public_domain == "example.ngrok.app" + + def test_strips_http_scheme(self) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": "http://example.ngrok.app"}) + assert tac.config.voice_public_domain == "example.ngrok.app" + + def test_strips_wss_scheme(self) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": "wss://example.ngrok.app"}) + assert tac.config.voice_public_domain == "example.ngrok.app" + + def test_strips_trailing_slash(self) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": "example.ngrok.app/"}) + assert tac.config.voice_public_domain == "example.ngrok.app" + + def test_strips_scheme_and_trailing_slash(self) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": "https://example.ngrok.app/"}) + assert tac.config.voice_public_domain == "example.ngrok.app" + + def test_strips_whitespace(self) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": " example.ngrok.app "}) + assert tac.config.voice_public_domain == "example.ngrok.app" + + def test_empty_string_becomes_none(self) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": ""}) + assert tac.config.voice_public_domain is None + + def test_passes_through_clean_value(self) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": "example.ngrok.app"}) + assert tac.config.voice_public_domain == "example.ngrok.app" + + @pytest.mark.asyncio + async def test_normalized_value_produces_valid_twiml(self) -> None: + """Regression: a sloppy https://... / value used to concatenate to + wss://https://example.ngrok.app//ws.""" + tac = TAC({**get_test_config(), "voice_public_domain": "https://example.ngrok.app/"}) + channel = VoiceChannel(tac) + twiml = await channel.handle_incoming_call() + assert 'url="wss://example.ngrok.app/ws"' in twiml + + +class TestVoiceChannelConfigUrlValidation: + """VoiceChannelConfig validates websocket_url and action_url shapes.""" + + def test_websocket_url_rejects_missing_scheme(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + with pytest.raises(ValueError, match="ws://"): + VoiceChannelConfig(websocket_url="example.ngrok.app/ws") + + def test_websocket_url_rejects_https_scheme(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + with pytest.raises(ValueError, match="ws://"): + VoiceChannelConfig(websocket_url="https://example.ngrok.app/ws") + + def test_websocket_url_accepts_wss(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + cfg = VoiceChannelConfig(websocket_url="wss://example.ngrok.app/ws") + assert cfg.websocket_url == "wss://example.ngrok.app/ws" + + def test_action_url_rejects_missing_scheme(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + with pytest.raises(ValueError, match="http://"): + VoiceChannelConfig(action_url="example.ngrok.app/end") + + def test_action_url_rejects_wss_scheme(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + with pytest.raises(ValueError, match="http://"): + VoiceChannelConfig(action_url="wss://example.ngrok.app/end") + + def test_action_url_accepts_https(self) -> None: + from tac.channels.voice import VoiceChannelConfig + + cfg = VoiceChannelConfig(action_url="https://example.ngrok.app/end") + assert cfg.action_url == "https://example.ngrok.app/end" From 27ff56d81446c8424d829c97cd5f657fc88f78c6 Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Tue, 19 May 2026 14:02:52 -0400 Subject: [PATCH 29/32] docs(examples): hint at TwiMLOptions.extra escape hatch Co-Authored-By: Claude Opus 4.7 (1M context) --- getting_started/examples/features/voice_twiml_customization.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/getting_started/examples/features/voice_twiml_customization.py b/getting_started/examples/features/voice_twiml_customization.py index adc93f2..5194ae6 100644 --- a/getting_started/examples/features/voice_twiml_customization.py +++ b/getting_started/examples/features/voice_twiml_customization.py @@ -68,6 +68,8 @@ async def customize_twiml(req: TwiMLRequest) -> TwiMLOptions: default_twiml_options=TwiMLOptions( welcome_greeting="Hello! This is a default greeting.", interruptible="speech", + # Escape hatch for ConversationRelay attributes TAC hasn't typed yet: + # extra={"newRelayAttribute": "value"}, ), ), ) From 1e0f97c02266f28384fe99e2a2891abd9abb272a Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 21 May 2026 13:56:54 -0400 Subject: [PATCH 30/32] refactor(voice): centralize voice URL paths on TACConfig, drop overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice URL construction now has a single source of truth. Paths (voice_websocket_path, voice_action_path) live on TACConfig alongside voice_public_domain — both VoiceChannel (for URL construction) and TACFastAPIServer (for route registration) read from the same fields, so drift is structurally impossible. Drops the speculative websocket_url / action_url overrides on VoiceChannelConfig (introduced earlier in this PR; never existed on main). Per-call outbound override (InitiateVoiceConversationOptions.websocket_url) stays — it's the only way to get dynamic-per-call URLs and predates this PR. Also tightens TwiMLOptions docstrings to clarify scope: configures the TwiML inside (plus ), not the verbs around it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 67 +++++++-------- src/tac/channels/voice/config.py | 75 ++--------------- src/tac/core/config.py | 28 +++++++ src/tac/models/outbound.py | 11 +-- src/tac/models/voice.py | 3 +- src/tac/server/config.py | 20 +---- src/tac/server/fastapi_server.py | 11 +-- tests/test_relay_only_mode.py | 2 +- tests/test_server.py | 34 +++----- tests/test_voice_channel.py | 133 +++++++++++------------------- 10 files changed, 142 insertions(+), 242 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 8af5117..90f643e 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -84,8 +84,8 @@ def __init__( self._twilio_client: Client | None = None def on_inbound_call_twiml(self, callback: InboundCallTwiMLHandler) -> None: - """Register a callback that produces per-call TwiML overrides for - inbound calls. + """Register a callback that produces per-call overrides for the + TwiML inside ```` on inbound calls. The callback receives a framework-neutral ``TwiMLRequest`` (parsed from the Twilio webhook form) and returns a ``TwiMLOptions``. Fields @@ -109,34 +109,30 @@ async def by_country(req: TwiMLRequest) -> TwiMLOptions: self._on_inbound_call_twiml = callback def _resolve_websocket_url(self, action: str) -> str: - """Resolve the public WebSocket URL. - - Order: explicit ``VoiceChannelConfig.websocket_url`` override → - derived from ``TACConfig.voice_public_domain`` + ``websocket_path``. - Raises if neither is available. + """Resolve the public WebSocket URL from + ``TACConfig.voice_public_domain`` + ``TACConfig.voice_websocket_path``. + Raises if ``voice_public_domain`` isn't set. """ - if self.config.websocket_url: - return self.config.websocket_url if self.tac.config.voice_public_domain: - return f"wss://{self.tac.config.voice_public_domain}{self.config.websocket_path}" + return ( + f"wss://{self.tac.config.voice_public_domain}{self.tac.config.voice_websocket_path}" + ) raise ValueError( f"{action} needs a WebSocket URL. Set TWILIO_VOICE_PUBLIC_DOMAIN " - "(or TACConfig.voice_public_domain), or pass websocket_url on " - "VoiceChannelConfig as an override." + "(or TACConfig.voice_public_domain)." ) def _resolve_default_action_url(self) -> str | None: - """Resolve the default ```` cleanup URL — same - derivation as ``_resolve_websocket_url`` but for the action URL. + """Resolve the default ```` cleanup URL. - Returns None if neither override nor derivation source is set; that's - fine because action_url has higher-priority layers (customizer, - twiml_options, Studio handoff) above this fallback. + Returns None if ``voice_public_domain`` isn't set; that's fine because + action_url has higher-priority layers (customizer, twiml_options, + Studio handoff) above this fallback. """ - if self.config.action_url: - return self.config.action_url if self.tac.config.voice_public_domain: - return f"https://{self.tac.config.voice_public_domain}{self.config.action_path}" + return ( + f"https://{self.tac.config.voice_public_domain}{self.tac.config.voice_action_path}" + ) return None @staticmethod @@ -167,10 +163,9 @@ async def handle_incoming_call( ConversationRelay automatically handles conversation creation and participant management via the ``conversation_configuration`` parameter. - The WebSocket URL and default session-cleanup action URL come from - ``VoiceChannelConfig`` (``websocket_url`` / ``action_url``). - ``TACFastAPIServer`` sets them automatically; custom adapters must set - them on the config before calling. + The WebSocket URL and default session-cleanup action URL are derived + from ``TACConfig.voice_public_domain`` + ``TACConfig.voice_websocket_path`` + / ``voice_action_path``. TwiML fields are merged per-field, highest precedence first: 1. Output of the customizer registered via @@ -178,9 +173,10 @@ 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 ``action_url`` - resolved via Studio handoff (when ``studio_handoff_flow_sid`` is - configured), else ``VoiceChannelConfig.action_url``. + ``conversation_configuration`` from ``TACConfig``, and + ``action_url`` resolved via Studio handoff (when + ``studio_handoff_flow_sid`` is configured), else derived from + ``TACConfig.voice_public_domain`` + ``voice_action_path``. Fields not set at a layer fall through to lower layers. Lists (``languages``) and nested models (``custom_parameters``) replace @@ -246,9 +242,8 @@ def _resolve_action_url(self, customized: TwiMLOptions | None) -> str | None: 1. customizer 2. channel ``default_twiml_options`` 3. Studio handoff (when ``studio_handoff_flow_sid`` is configured) - 4. Channel default — ``VoiceChannelConfig.action_url`` if set, - else derived from ``TACConfig.voice_public_domain`` + - ``VoiceChannelConfig.action_path``. + 4. Channel default — derived from ``TACConfig.voice_public_domain`` + + ``TACConfig.voice_action_path``. User-expressed intent (Studio handoff is configured explicitly on ``TACConfig``) beats the SDK's generated cleanup default. If a user @@ -482,18 +477,18 @@ async def initiate_outbound_conversation( TwiML fields are merged per-field, highest precedence first: 1. ``options.twiml_options`` — per-call overrides 2. ``VoiceChannelConfig.default_twiml_options`` — channel-wide defaults - 3. TAC defaults: welcome greeting, ``conversation_configuration`` from - ``TACConfig``, and ``action_url`` from Studio handoff (if configured), - else ``VoiceChannelConfig.action_url``. + 3. TAC defaults: welcome greeting, ``conversation_configuration`` + from ``TACConfig``, and ``action_url`` from Studio handoff (if + configured), else derived from ``TACConfig.voice_public_domain`` + + ``voice_action_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. The WebSocket URL is derived from ``TACConfig.voice_public_domain`` + - ``VoiceChannelConfig.websocket_path``, or read from a - ``VoiceChannelConfig.websocket_url`` override, unless overridden - per-call via ``options.websocket_url``. + ``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" diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index 3c18221..b816946 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field from tac.models.memory import MemoryMode from tac.models.voice import TwiMLOptions, TwiMLRequest @@ -41,25 +41,9 @@ class VoiceChannelConfig(BaseModel): - "once": Retrieve memory once at conversation start with empty query and cache it. Cache is invalidated when conversation becomes INACTIVE. - "never": Skip memory retrieval - websocket_path: Path the voice WebSocket is served at (e.g. ``/ws``). - Combined with ``TACConfig.voice_public_domain`` to build the - public WebSocket URL. ``TACFastAPIServer`` registers its - WebSocket route at this path. Override only if you mount the - route at a non-default path. - action_path: Path the ConversationRelay action callback is served at - (e.g. ``/conversation-relay-callback``). Combined with - ``TACConfig.voice_public_domain`` to build the public action URL. - ``TACFastAPIServer`` registers its callback route at this path. - websocket_url: Override for the public WebSocket URL. Useful for - cross-domain or proxy setups where the URL doesn't follow the - standard ``wss://{voice_public_domain}{websocket_path}`` - template. When set, takes precedence over the derived value. - action_url: Override for the public action URL — same role as - ``websocket_url`` but for the ```` cleanup - callback. Higher-priority layers (customizer, per-call - ``twiml_options.action_url``, Studio handoff) still override. default_twiml_options: Static ``TwiMLOptions`` applied to every call - (inbound and outbound) — voice, language, transcription provider, + (inbound and outbound). Controls the TwiML inside + ```` — voice, language, transcription provider, welcome_greeting, ```` children, etc. Use this when the same ConversationRelay configuration is correct for every call. @@ -80,56 +64,9 @@ class VoiceChannelConfig(BaseModel): default="never", description="Memory retrieval mode for this channel", ) - websocket_path: str = Field( - default="/ws", - description="Path the voice WebSocket is served at. Combined with " - "TACConfig.voice_public_domain to build the WebSocket URL.", - ) - action_path: str = Field( - default="/conversation-relay-callback", - description="Path the ConversationRelay action callback is served at. " - "Combined with TACConfig.voice_public_domain to build the action URL.", - ) - websocket_url: str | None = Field( - default=None, - description="Override for the public WebSocket URL. Defaults to " - "wss://{voice_public_domain}{websocket_path}.", - ) - action_url: str | None = Field( - default=None, - description="Override for the public URL. Defaults " - "to https://{voice_public_domain}{action_path}.", - ) default_twiml_options: TwiMLOptions | None = Field( default=None, - description="Static TwiMLOptions applied to every call (inbound and " - "outbound). Per-call inbound customization is registered via " - "VoiceChannel.on_inbound_call_twiml(...).", + description="Static TwiMLOptions for the TwiML inside , " + "applied to every call (inbound and outbound). Per-call inbound " + "customization is registered via VoiceChannel.on_inbound_call_twiml(...).", ) - - @field_validator("websocket_url") - @classmethod - def _validate_websocket_url(cls, v: str | None) -> str | None: - if v is None: - return v - v = v.strip() - if not v: - return None - if not v.lower().startswith(("ws://", "wss://")): - raise ValueError( - f"websocket_url must start with ws:// or wss:// (got {v!r}). " - "If you only have a domain, set TACConfig.voice_public_domain instead." - ) - return v - - @field_validator("action_url") - @classmethod - def _validate_action_url(cls, v: str | None) -> str | None: - if v is None: - return v - v = v.strip() - if not v: - return None - if not v.lower().startswith(("http://", "https://")): - raise ValueError(f"action_url must start with http:// or https:// (got {v!r}).") - return v diff --git a/src/tac/core/config.py b/src/tac/core/config.py index e69294a..536f433 100644 --- a/src/tac/core/config.py +++ b/src/tac/core/config.py @@ -286,6 +286,22 @@ def _normalize_and_validate_region(cls, v: object) -> str | None: "are stripped automatically.", ) + voice_websocket_path: str = Field( + default="/ws", + description="Path the voice WebSocket is served at. Combined with " + "voice_public_domain to build the public WebSocket URL the voice " + "channel hands to Twilio in TwiML; TACFastAPIServer also registers " + "its WebSocket route at this path. Override only if you mount the " + "route at a non-default path.", + ) + + voice_action_path: str = Field( + default="/conversation-relay-callback", + description="Path the ConversationRelay action callback is served at. " + "Same role as voice_websocket_path but for the " + "cleanup callback.", + ) + @field_validator("voice_public_domain", mode="before") @classmethod def _normalize_voice_public_domain(cls, v: str | None) -> str | None: @@ -360,6 +376,9 @@ def from_env(cls) -> "TACConfig": - TWILIO_REGION: Twilio region for data residency (e.g., 'au1', 'ie1') - TWILIO_STUDIO_HANDOFF_FLOW_SID: Studio Flow SID (FWxxx...) for handoff tool - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice) + - TWILIO_VOICE_WEBSOCKET_PATH: Path for voice WebSocket (default: /ws) + - TWILIO_VOICE_ACTION_PATH: Path for ConversationRelay action callback + (default: /conversation-relay-callback) Memory Configuration: - TWILIO_MEMORY_PROFILE_TRAIT_GROUPS: Trait groups to include @@ -385,6 +404,14 @@ def from_env(cls) -> "TACConfig": # Load optional conversation intelligence configuration conversation_intelligence_config = ConversationIntelligenceConfig.from_env() + # Path overrides: only forward to the constructor when the env var is + # set, so the field defaults take effect otherwise. + path_overrides: dict[str, str] = {} + if "TWILIO_VOICE_WEBSOCKET_PATH" in os.environ: + path_overrides["voice_websocket_path"] = os.environ["TWILIO_VOICE_WEBSOCKET_PATH"] + if "TWILIO_VOICE_ACTION_PATH" in os.environ: + path_overrides["voice_action_path"] = os.environ["TWILIO_VOICE_ACTION_PATH"] + return cls( conversation_configuration_id=os.environ.get("TWILIO_CONVERSATION_CONFIGURATION_ID"), account_sid=os.environ["TWILIO_ACCOUNT_SID"], @@ -401,4 +428,5 @@ def from_env(cls) -> "TACConfig": voice_public_domain=os.environ.get("TWILIO_VOICE_PUBLIC_DOMAIN"), memory_config=memory_config, conversation_intelligence_config=conversation_intelligence_config, + **path_overrides, ) diff --git a/src/tac/models/outbound.py b/src/tac/models/outbound.py index b6d555c..1ef71aa 100644 --- a/src/tac/models/outbound.py +++ b/src/tac/models/outbound.py @@ -76,14 +76,15 @@ class InitiateVoiceConversationOptions(BaseModel): websocket_url: str | None = Field( default=None, description="Public WebSocket URL for ConversationRelay (e.g. " - "'wss://your-domain.ngrok.app/ws'). Optional — defaults to " - "``VoiceChannelConfig.websocket_url`` when not provided. Pass it here " - "only to override the channel's URL for a specific call.", + "'wss://your-domain.ngrok.app/ws'). 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.", ) twiml_options: TwiMLOptions | None = Field( default=None, - description="Per-call TwiMLOptions overrides. Merged over " - "VoiceChannelConfig.default_twiml_options and TAC defaults.", + description="Per-call overrides for the TwiML inside . " + "Merged over VoiceChannelConfig.default_twiml_options and TAC defaults.", ) model_config = {"populate_by_name": True} diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index b6dcfc3..6b37b11 100644 --- a/src/tac/models/voice.py +++ b/src/tac/models/voice.py @@ -161,7 +161,8 @@ class LanguageConfig(BaseModel): class TwiMLOptions(BaseModel): - """Options for generating ConversationRelay TwiML. + """Options for the TwiML inside ```` (plus the + ```` URL). Fields map to the attributes documented at https://www.twilio.com/docs/voice/twiml/connect/conversationrelay . diff --git a/src/tac/server/config.py b/src/tac/server/config.py index 5b51297..44cf9bd 100644 --- a/src/tac/server/config.py +++ b/src/tac/server/config.py @@ -8,11 +8,10 @@ class TACServerConfig(BaseModel): """Configuration for TAC server implementations. - Controls host/port binding and webhook paths registered by the server. - Voice-specific settings — the public domain, WebSocket path, and - ConversationRelay action path — live on ``TACConfig`` and - ``VoiceChannelConfig``, since they're consumed by the voice channel - regardless of which web framework is used. + Controls host/port binding and the server-only webhook paths. Voice paths + (WebSocket and ConversationRelay action) live on ``TACConfig`` because + they're consumed by the voice channel regardless of which web framework + is used; this server reads them from there to register its routes. """ host: str = Field(default="0.0.0.0", description="Host to bind the server to") @@ -22,17 +21,6 @@ class TACServerConfig(BaseModel): default="/webhook", description="Path for conversation webhook endpoint (for all channels)" ) twiml_path: str = Field(default="/twiml", description="Path for TwiML generation endpoint") - websocket_path: str = Field( - default="/ws", - description="Path to register the voice WebSocket route at. " - "VoiceChannelConfig.websocket_path builds the public URL — keep them " - "in sync, or set VoiceChannelConfig.websocket_url directly.", - ) - conversation_relay_callback_path: str = Field( - default="/conversation-relay-callback", - description="Path to register the ConversationRelay action callback route at. " - "Same pairing rule as websocket_path with VoiceChannelConfig.action_path.", - ) cintel_webhook_path: str | None = Field( default=None, description="Path for Conversation Intelligence webhook endpoint. " diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index cbcb6b8..7a6c338 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -145,14 +145,11 @@ def _validate_voice_url_config(self) -> None: can't know what URL plumbing they're doing outside this server. """ assert self.voice_channel is not None # checked by caller - if self.voice_channel.config.websocket_url: - return if self.tac.config.voice_public_domain: return raise ValueError( - "Voice channel is configured but no public URL is set. " - "Set TACConfig.voice_public_domain (or TWILIO_VOICE_PUBLIC_DOMAIN env " - "var), or pass websocket_url on VoiceChannelConfig as an override." + "Voice channel is configured but TACConfig.voice_public_domain is " + "not set. Set it directly or via the TWILIO_VOICE_PUBLIC_DOMAIN env var." ) def _register_routes(self, app: FastAPI) -> None: @@ -225,14 +222,14 @@ async def post_twiml(request: Request) -> Response: twiml = await vc.handle_incoming_call(twiml_request=twiml_request) return Response(content=twiml, media_type="application/xml") - @app.websocket(config.websocket_path) + @app.websocket(self.tac.config.voice_websocket_path) async def websocket_endpoint(websocket: WebSocket, _: None = Depends(ws_sig)) -> None: """Handle voice WebSocket connections.""" adapter = FastAPIWebSocketAdapter(websocket) await vc.handle_websocket(adapter) @app.post( - config.conversation_relay_callback_path, + self.tac.config.voice_action_path, dependencies=[Depends(http_sig)], ) async def conversation_relay_callback(request: Request) -> Response: diff --git a/tests/test_relay_only_mode.py b/tests/test_relay_only_mode.py index 2e3a91a..214df26 100644 --- a/tests/test_relay_only_mode.py +++ b/tests/test_relay_only_mode.py @@ -28,6 +28,7 @@ def relay_only_config() -> dict: "api_key": "SK123", "api_secret": "test_api_secret", "phone_number": "+15551234567", + "voice_public_domain": "example.com", } @@ -98,7 +99,6 @@ async def test_handle_incoming_call_twiml_omits_conversation_configuration(self) ), ) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call() assert "conversationConfiguration" not in twiml diff --git a/tests/test_server.py b/tests/test_server.py index b16075d..7a95784 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -37,8 +37,6 @@ def test_defaults(self) -> None: assert config.port == 8000 assert config.conversation_webhook_path == "/webhook" assert config.twiml_path == "/twiml" - assert config.websocket_path == "/ws" - assert config.conversation_relay_callback_path == "/conversation-relay-callback" assert config.cintel_webhook_path is None def test_custom_paths(self) -> None: @@ -47,16 +45,12 @@ def test_custom_paths(self) -> None: port=3000, conversation_webhook_path="/conversations", twiml_path="/voice/twiml", - websocket_path="/voice/ws", - conversation_relay_callback_path="/voice/cleanup", cintel_webhook_path="/ci", ) assert config.host == "127.0.0.1" assert config.port == 3000 assert config.conversation_webhook_path == "/conversations" assert config.twiml_path == "/voice/twiml" - assert config.websocket_path == "/voice/ws" - assert config.conversation_relay_callback_path == "/voice/cleanup" assert config.cintel_webhook_path == "/ci" def test_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -92,7 +86,7 @@ class TestVoiceUrlConfigValidationAtStartup: attached but no public URL is configured. Catches the misconfiguration before the first inbound call rather than returning a 500.""" - def test_raises_when_voice_channel_without_url_or_domain(self) -> None: + def test_raises_when_voice_channel_without_public_domain(self) -> None: from tac.channels.voice import VoiceChannel from tac.server import TACFastAPIServer @@ -113,19 +107,6 @@ def test_passes_when_voice_public_domain_set(self) -> None: vc = VoiceChannel(tac) TACFastAPIServer(tac=tac, voice_channel=vc) # no raise - def test_passes_when_websocket_url_override_set(self) -> None: - from tac.channels.voice import VoiceChannel, VoiceChannelConfig - from tac.server import TACFastAPIServer - - cfg = {**get_test_config()} - cfg.pop("voice_public_domain", None) - tac = TAC(cfg) - vc = VoiceChannel( - tac, - config=VoiceChannelConfig(websocket_url="wss://override.example.com/ws"), - ) - TACFastAPIServer(tac=tac, voice_channel=vc) # no raise - def test_passes_without_voice_channel(self) -> None: """No voice channel attached: validation skipped, server starts fine even without voice_public_domain.""" @@ -407,17 +388,25 @@ def test_create_app_with_cintel(self) -> None: assert "/ci-webhook" in route_paths def test_create_app_custom_paths(self) -> None: + """Custom server paths come from TACServerConfig (server-only paths) + and TACConfig (voice paths, which are also consumed by the channel + for URL construction).""" from tac.channels import SMSChannel from tac.channels.voice import VoiceChannel from tac.server import TACFastAPIServer - tac = TAC(get_test_config()) + tac = TAC( + { + **get_test_config(), + "voice_websocket_path": "/voice/ws", + "voice_action_path": "/voice/cleanup", + } + ) server = TACFastAPIServer( tac=tac, config=TACServerConfig( conversation_webhook_path="/conversations", twiml_path="/voice/twiml", - websocket_path="/voice/ws", ), voice_channel=VoiceChannel(tac), messaging_channels=[SMSChannel(tac)], @@ -428,6 +417,7 @@ def test_create_app_custom_paths(self) -> None: assert "/conversations" in route_paths assert "/voice/twiml" in route_paths assert "/voice/ws" in route_paths + assert "/voice/cleanup" in route_paths def test_custom_app_is_used(self) -> None: """User-supplied FastAPI instance is used directly and metadata preserved.""" diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index 4db10f4..deeb36c 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -29,6 +29,7 @@ def get_test_config() -> dict: "api_secret": "test_api_token", "conversation_configuration_id": "conv_configuration_test123", "phone_number": "+15551234567", + "voice_public_domain": "example.com", } @@ -461,15 +462,13 @@ async def test_handle_incoming_call(self) -> None: ), ) - channel.config.websocket_url = "wss://example.ngrok.io/ws" - channel.config.action_url = "https://example.ngrok.io/flex_handoff" twiml = await channel.handle_incoming_call() assert '' in twiml assert "" in twiml - assert '' in twiml + assert '' in twiml assert "" in twiml @@ -481,8 +480,6 @@ async def test_handle_incoming_call_default_greeting(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) - channel.config.websocket_url = "wss://test.ngrok.io/ws" - channel.config.action_url = "https://example.ngrok.io/flex_handoff" twiml = await channel.handle_incoming_call() assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml @@ -1163,8 +1160,6 @@ async def test_handle_incoming_call_with_additional_parameters(self) -> None: ), ) - channel.config.websocket_url = "wss://example.ngrok.io/ws" - channel.config.action_url = "https://example.ngrok.io/callback" twiml = await channel.handle_incoming_call() assert 'conversationConfiguration="conv_configuration_test123"' in twiml @@ -1174,11 +1169,10 @@ async def test_handle_incoming_call_with_additional_parameters(self) -> None: @pytest.mark.asyncio async def test_handle_incoming_call_without_additional_parameters(self) -> None: - """Test handle_incoming_call works with only server URLs.""" + """Test handle_incoming_call works with only TAC defaults.""" tac = TAC(get_test_config()) channel = VoiceChannel(tac) - channel.config.websocket_url = "wss://example.ngrok.io/ws" twiml = await channel.handle_incoming_call() assert 'conversationConfiguration="conv_configuration_test123"' in twiml @@ -1428,7 +1422,6 @@ class TestHandleIncomingCallMerge: async def test_tac_defaults_applied(self) -> None: tac = TAC(get_test_config()) channel = VoiceChannel(tac) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call() assert 'welcomeGreeting="Hello! How can I assist you today?"' in twiml assert 'conversationConfiguration="conv_configuration_test123"' in twiml @@ -1446,7 +1439,6 @@ async def test_static_options_override_conversation_configuration(self) -> None: ), ), ) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call() assert 'conversationConfiguration="conv_configuration_custom"' in twiml assert "conv_configuration_test123" not in twiml @@ -1457,7 +1449,6 @@ async def test_studio_handoff_used_when_flow_sid_set(self) -> None: flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call() expected = ( f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' @@ -1466,45 +1457,35 @@ async def test_studio_handoff_used_when_flow_sid_set(self) -> None: assert expected in twiml @pytest.mark.asyncio - async def test_studio_handoff_beats_server_action_url(self) -> None: + async def test_studio_handoff_beats_default_action_url(self) -> None: """Studio handoff is a user-expressed intent (explicit config) and - wins over endpoints.action_url (the SDK's generated cleanup - default). Setting both is a user choice — if they want cleanup, - they don't set studio_handoff_flow_sid.""" + wins over the derived cleanup URL. Setting Studio handoff is the + signal that the user wants Studio's cleanup, not the SDK's default.""" flow_sid = "FW" + "a" * 32 tac = TAC({**get_test_config(), "studio_handoff_flow_sid": flow_sid}) channel = VoiceChannel(tac) - channel.config.websocket_url = "wss://example.com/ws" - channel.config.action_url = "https://cleanup.example.com/end" twiml = await channel.handle_incoming_call() expected = ( f'action="https://webhooks.twilio.com/v1/Accounts/ACtest123' f'/Flows/{flow_sid}?Trigger=incomingCall"' ) assert expected in twiml - assert "cleanup.example.com" not in twiml + # Default cleanup URL must not also appear. + assert "conversation-relay-callback" not in twiml @pytest.mark.asyncio - async def test_action_url_uses_server_url(self) -> None: + async def test_action_url_falls_back_to_derived_default(self) -> None: + """With nothing else configured, action_url is derived from + TACConfig.voice_public_domain + voice_action_path.""" tac = TAC(get_test_config()) channel = VoiceChannel(tac) - channel.config.websocket_url = "wss://example.com/ws" - channel.config.action_url = "https://fallback.example.com/end" twiml = await channel.handle_incoming_call() - assert 'action="https://fallback.example.com/end"' in twiml + assert 'action="https://example.com/conversation-relay-callback"' in twiml @pytest.mark.asyncio - async def test_action_url_omitted_when_no_server_url(self) -> None: - tac = TAC(get_test_config()) - channel = VoiceChannel(tac) - channel.config.websocket_url = "wss://example.com/ws" - twiml = await channel.handle_incoming_call() - assert "action=" not in twiml - - @pytest.mark.asyncio - async def test_static_options_action_url_beats_server_url(self) -> None: - """A static action_url on VoiceChannelConfig.twiml_options is an - explicit user override and wins over the server's cleanup URL.""" + async def test_static_options_action_url_beats_derived_default(self) -> None: + """A static action_url on default_twiml_options is an explicit user + choice and wins over the derived cleanup URL.""" from tac.channels.voice import VoiceChannelConfig tac = TAC(get_test_config()) @@ -1514,10 +1495,9 @@ async def test_static_options_action_url_beats_server_url(self) -> None: default_twiml_options=TwiMLOptions(action_url="https://static.example.com/end"), ), ) - channel.config.websocket_url = "wss://example.com/ws" - channel.config.action_url = "https://cleanup.example.com/end" twiml = await channel.handle_incoming_call() assert 'action="https://static.example.com/end"' in twiml + assert "conversation-relay-callback" not in twiml @pytest.mark.asyncio async def test_action_url_three_layer_resolution(self) -> None: @@ -1539,8 +1519,6 @@ async def customizer(req: TwiMLRequest) -> TwiMLOptions: ), ) channel.on_inbound_call_twiml(customizer) - channel.config.websocket_url = "wss://example.com/ws" - channel.config.action_url = "https://server.example.com/end" twiml = await channel.handle_incoming_call(twiml_request=TwiMLRequest()) assert 'action="https://static.example.com/end"' in twiml assert 'voice="en-US-Journey-D"' in twiml @@ -1565,8 +1543,6 @@ async def customizer(req: TwiMLRequest) -> TwiMLOptions: ), ) channel.on_inbound_call_twiml(customizer) - channel.config.websocket_url = "wss://example.com/ws" - channel.config.action_url = "https://server.example.com/end" twiml = await channel.handle_incoming_call(twiml_request=TwiMLRequest()) assert "action=" not in twiml @@ -1584,8 +1560,6 @@ async def test_default_options_action_url_none_suppresses(self) -> None: default_twiml_options=TwiMLOptions(action_url=None), ), ) - channel.config.websocket_url = "wss://example.com/ws" - channel.config.action_url = "https://server.example.com/end" twiml = await channel.handle_incoming_call() assert "action=" not in twiml @@ -1604,7 +1578,6 @@ async def test_static_options_applied(self) -> None: default_twiml_options=TwiMLOptions(voice="en-US-Journey-D", language="en-US"), ), ) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call() assert 'voice="en-US-Journey-D"' in twiml assert 'language="en-US"' in twiml @@ -1620,7 +1593,6 @@ async def test_welcome_greeting_via_twiml_options(self) -> None: default_twiml_options=TwiMLOptions(welcome_greeting="Bonjour!"), ), ) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call() assert 'welcomeGreeting="Bonjour!"' in twiml @@ -1643,7 +1615,6 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: channel = VoiceChannel(tac) channel.on_inbound_call_twiml(customizer) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call() assert called is False assert "voice=" not in twiml @@ -1663,7 +1634,6 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: channel.on_inbound_call_twiml(customizer) ctx = TwiMLRequest(from_number="+14155551234", caller_country="US") - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( twiml_request=ctx, ) @@ -1687,7 +1657,6 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: ), ) channel.on_inbound_call_twiml(customizer) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( twiml_request=TwiMLRequest(), ) @@ -1709,7 +1678,6 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: ), ) channel.on_inbound_call_twiml(customizer) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( twiml_request=TwiMLRequest(), ) @@ -1730,7 +1698,6 @@ async def customizer(ctx: TwiMLRequest) -> TwiMLOptions: channel = VoiceChannel(tac) channel.on_inbound_call_twiml(customizer) - channel.config.websocket_url = "wss://example.com/ws" twiml = await channel.handle_incoming_call( twiml_request=TwiMLRequest(), ) @@ -2191,41 +2158,37 @@ async def test_normalized_value_produces_valid_twiml(self) -> None: assert 'url="wss://example.ngrok.app/ws"' in twiml -class TestVoiceChannelConfigUrlValidation: - """VoiceChannelConfig validates websocket_url and action_url shapes.""" - - def test_websocket_url_rejects_missing_scheme(self) -> None: - from tac.channels.voice import VoiceChannelConfig - - with pytest.raises(ValueError, match="ws://"): - VoiceChannelConfig(websocket_url="example.ngrok.app/ws") - - def test_websocket_url_rejects_https_scheme(self) -> None: - from tac.channels.voice import VoiceChannelConfig - - with pytest.raises(ValueError, match="ws://"): - VoiceChannelConfig(websocket_url="https://example.ngrok.app/ws") - - def test_websocket_url_accepts_wss(self) -> None: - from tac.channels.voice import VoiceChannelConfig - - cfg = VoiceChannelConfig(websocket_url="wss://example.ngrok.app/ws") - assert cfg.websocket_url == "wss://example.ngrok.app/ws" - - def test_action_url_rejects_missing_scheme(self) -> None: - from tac.channels.voice import VoiceChannelConfig - - with pytest.raises(ValueError, match="http://"): - VoiceChannelConfig(action_url="example.ngrok.app/end") +class TestVoicePathsOnTACConfig: + """Voice paths live on TACConfig (one source of truth for both the + channel's URL construction and the server's route registration).""" - def test_action_url_rejects_wss_scheme(self) -> None: - from tac.channels.voice import VoiceChannelConfig - - with pytest.raises(ValueError, match="http://"): - VoiceChannelConfig(action_url="wss://example.ngrok.app/end") + def test_default_paths(self) -> None: + tac = TAC(get_test_config()) + assert tac.config.voice_websocket_path == "/ws" + assert tac.config.voice_action_path == "/conversation-relay-callback" - def test_action_url_accepts_https(self) -> None: - from tac.channels.voice import VoiceChannelConfig + def test_paths_flow_into_websocket_url(self) -> None: + """voice_websocket_path is consumed by the channel for URL construction.""" + tac = TAC( + { + **get_test_config(), + "voice_public_domain": "test.ngrok.io", + "voice_websocket_path": "/voice/ws", + } + ) + channel = VoiceChannel(tac) + url = channel._resolve_websocket_url("test") + assert url == "wss://test.ngrok.io/voice/ws" - cfg = VoiceChannelConfig(action_url="https://example.ngrok.app/end") - assert cfg.action_url == "https://example.ngrok.app/end" + def test_paths_flow_into_action_url(self) -> None: + """voice_action_path is consumed by the channel for URL construction.""" + tac = TAC( + { + **get_test_config(), + "voice_public_domain": "test.ngrok.io", + "voice_action_path": "/voice/cleanup", + } + ) + channel = VoiceChannel(tac) + url = channel._resolve_default_action_url() + assert url == "https://test.ngrok.io/voice/cleanup" From 53bf02e62a2cbf9693b01bc2317bf477c0216a2e Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Thu, 21 May 2026 14:10:03 -0400 Subject: [PATCH 31/32] refactor(voice): trim post-iteration cruft and forbid extras on outbound options Three small cleanups discovered during audit: - Forbid extra fields on InitiateVoiceConversationOptions. Pydantic's default extra="ignore" silently dropped the fields removed earlier in this PR (welcome_greeting, action_url, custom_parameters); forbidding turns those into ValidationError so callers upgrading from older TAC get a clear migration signal. Tests cover all three. - Drop the TwiML-customization paragraph from TACFastAPIServer's docstring. It duplicated docs that already live on VoiceChannelConfig and on_inbound_call_twiml, where they belong. - Inline the env-var path overrides on TACConfig.from_env using os.environ.get(name, default), matching the existing log_level pattern. Removes the temp dict and **spread. - Remove a stale WHAT comment ("Invoke the customizer if configured") whose code is self-evident. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/channel.py | 1 - src/tac/core/config.py | 13 ++++--------- src/tac/models/outbound.py | 2 +- src/tac/server/fastapi_server.py | 7 ------- tests/test_outbound.py | 26 ++++++++++++++++++++++++++ 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 90f643e..dec5f72 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -191,7 +191,6 @@ async def handle_incoming_call( """ websocket_url = self._resolve_websocket_url("handle_incoming_call") - # Invoke the customizer if configured and we have a request context. 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) diff --git a/src/tac/core/config.py b/src/tac/core/config.py index 536f433..c9ab8b8 100644 --- a/src/tac/core/config.py +++ b/src/tac/core/config.py @@ -404,14 +404,6 @@ def from_env(cls) -> "TACConfig": # Load optional conversation intelligence configuration conversation_intelligence_config = ConversationIntelligenceConfig.from_env() - # Path overrides: only forward to the constructor when the env var is - # set, so the field defaults take effect otherwise. - path_overrides: dict[str, str] = {} - if "TWILIO_VOICE_WEBSOCKET_PATH" in os.environ: - path_overrides["voice_websocket_path"] = os.environ["TWILIO_VOICE_WEBSOCKET_PATH"] - if "TWILIO_VOICE_ACTION_PATH" in os.environ: - path_overrides["voice_action_path"] = os.environ["TWILIO_VOICE_ACTION_PATH"] - return cls( conversation_configuration_id=os.environ.get("TWILIO_CONVERSATION_CONFIGURATION_ID"), account_sid=os.environ["TWILIO_ACCOUNT_SID"], @@ -426,7 +418,10 @@ def from_env(cls) -> "TACConfig": region=os.environ.get("TWILIO_REGION"), studio_handoff_flow_sid=os.environ.get("TWILIO_STUDIO_HANDOFF_FLOW_SID"), voice_public_domain=os.environ.get("TWILIO_VOICE_PUBLIC_DOMAIN"), + voice_websocket_path=os.environ.get("TWILIO_VOICE_WEBSOCKET_PATH", "/ws"), + voice_action_path=os.environ.get( + "TWILIO_VOICE_ACTION_PATH", "/conversation-relay-callback" + ), memory_config=memory_config, conversation_intelligence_config=conversation_intelligence_config, - **path_overrides, ) diff --git a/src/tac/models/outbound.py b/src/tac/models/outbound.py index 1ef71aa..7041c7b 100644 --- a/src/tac/models/outbound.py +++ b/src/tac/models/outbound.py @@ -87,7 +87,7 @@ class InitiateVoiceConversationOptions(BaseModel): "Merged over VoiceChannelConfig.default_twiml_options and TAC defaults.", ) - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "forbid"} class InitiateVoiceConversationResult(BaseModel): diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 7a6c338..5b86c50 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -82,13 +82,6 @@ class TACFastAPIServer: - Or mutate ``server.app`` after construction: add middleware, exception handlers, routers, or custom routes — before calling ``start()``. - - To customize TwiML attributes (voice, language, transcription provider, - interruption behavior, ```` children, etc.) set - ``default_twiml_options`` on ``VoiceChannelConfig`` for same-on-every-call - settings. For per-call inbound customization, register a callback - via ``voice_channel.on_inbound_call_twiml(...)`` — an async - callable that receives a framework-neutral ``TwiMLRequest`` and - returns a ``TwiMLOptions``. Example: from fastapi import FastAPI diff --git a/tests/test_outbound.py b/tests/test_outbound.py index 1f2b638..b278581 100644 --- a/tests/test_outbound.py +++ b/tests/test_outbound.py @@ -443,6 +443,32 @@ async def test_returns_call_sid(self) -> None: assert result.call_sid == "CAsid789" +class TestInitiateVoiceConversationOptionsForbidsExtra: + """Migration safety: removed fields raise ValidationError instead of + being silently dropped, so callers upgrading from older TAC versions + get a clear signal that their code needs updating.""" + + def test_removed_welcome_greeting_raises(self) -> None: + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="welcome_greeting"): + InitiateVoiceConversationOptions(to="+15551234567", welcome_greeting="Hi!") # type: ignore[call-arg] + + def test_removed_action_url_raises(self) -> None: + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="action_url"): + InitiateVoiceConversationOptions( + to="+15551234567", action_url="https://example.com/end" + ) # type: ignore[call-arg] + + def test_removed_custom_parameters_raises(self) -> None: + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="custom_parameters"): + InitiateVoiceConversationOptions(to="+15551234567", custom_parameters={"k": "v"}) # type: ignore[call-arg] + + # ============================================================================= # isOwnMessage 2-tier # ============================================================================= From a807509e69afbcc3e66d35f98d650849a026297e Mon Sep 17 00:00:00 2001 From: Ryan Rouleau Date: Tue, 26 May 2026 11:20:45 -0400 Subject: [PATCH 32/32] refactor(voice): tighten TwiMLOptions surface and trim test cruft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - speech_timeout: accept int | Literal["auto"] | None to allow restoring the platform default from a higher merge layer; drop the client-side range check that drifts when Twilio changes its bounds (let Twilio be the source of truth) - eot_threshold: same — drop the ge=0.5/le=0.9 client-side gate - TwiMLOptions.extra: raise at construction when a key shadows a typed field instead of silently dropping it; covers both the "typed value set" and "typed value unset" cases. Drops the warn-and-discard branch in twiml.py - VoiceChannelConfig.default_twiml_options: short docstring pointer to _overlay_fields for the wholesale-replace gotcha - Trim 14 redundant tests (~150 LOC) that either tested the Twilio SDK, duplicated coverage, or asserted f-string interpolation Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tac/channels/voice/config.py | 4 +- src/tac/channels/voice/twiml.py | 15 +-- src/tac/models/voice.py | 33 +++-- tests/test_voice_channel.py | 219 +++++-------------------------- 4 files changed, 66 insertions(+), 205 deletions(-) diff --git a/src/tac/channels/voice/config.py b/src/tac/channels/voice/config.py index b816946..7443058 100644 --- a/src/tac/channels/voice/config.py +++ b/src/tac/channels/voice/config.py @@ -68,5 +68,7 @@ class VoiceChannelConfig(BaseModel): default=None, description="Static TwiMLOptions for the TwiML inside , " "applied to every call (inbound and outbound). Per-call inbound " - "customization is registered via VoiceChannel.on_inbound_call_twiml(...).", + "customization is registered via VoiceChannel.on_inbound_call_twiml(...). " + "Note: ``custom_parameters`` and ``languages`` replace wholesale when a " + "higher-priority layer sets them — see VoiceChannel._overlay_fields.", ) diff --git a/src/tac/channels/voice/twiml.py b/src/tac/channels/voice/twiml.py index eea7020..1eab08c 100644 --- a/src/tac/channels/voice/twiml.py +++ b/src/tac/channels/voice/twiml.py @@ -5,11 +5,8 @@ from pydantic import BaseModel from twilio.twiml.voice_response import VoiceResponse -from tac.core.logging import get_logger from tac.models.voice import TwiMLOptions -logger = get_logger(__name__) - # Fields on TwiMLOptions that map to attributes and are # emitted via the snake_case → camelCase conversion done by twilio's SDK. # Must stay in sync with TwiMLOptions field declarations; see _verify_attrs_in_sync. @@ -138,16 +135,10 @@ def generate_twiml( value = "any" if value else "none" relay_kwargs[attr] = value + # TwiMLOptions' validator already rejects extra keys that shadow typed + # fields, so we can pass everything through here as-is. if options.extra: - typed_keys = set(_OPTIONAL_RELAY_ATTRS) - for key, value in options.extra.items(): - if key in typed_keys: - logger.warning( - "TwiMLOptions.extra shadows a typed field; typed field wins.", - shadowed_key=key, - ) - continue - relay_kwargs[key] = value + relay_kwargs.update(options.extra) relay = connect.conversation_relay(**relay_kwargs) diff --git a/src/tac/models/voice.py b/src/tac/models/voice.py index 6b37b11..f3de5e1 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 +from pydantic import BaseModel, Field, 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. @@ -232,10 +232,9 @@ class TwiMLOptions(BaseModel): # Turn detection / interruption eot_threshold: float | None = Field( None, - ge=0.5, - le=0.9, - description="Confidence required to finish a turn (0.5 - 0.9). " - "Only applies with Deepgram + flux speech model.", + description="Confidence required to finish a turn. " + "Only applies with Deepgram + flux speech model. " + "Twilio enforces the accepted range — see ConversationRelay docs.", ) partial_prompts: bool | None = Field( None, @@ -247,12 +246,11 @@ class TwiMLOptions(BaseModel): description="Use Deepgram Smart Format for transcription output. " "Defaults to true when transcription_provider='Deepgram'.", ) - speech_timeout: int | None = Field( + speech_timeout: int | Literal["auto"] | None = Field( None, - ge=600, - le=5000, description="Silence (ms) after speech before finalizing the prompt. " - "Integer in [600, 5000]. Defaults to 'auto' on Twilio.", + "Integer milliseconds or the literal 'auto' (the platform default). " + "Twilio enforces the accepted range — see ConversationRelay docs.", ) interruptible: InterruptMode | bool | None = Field( None, @@ -321,6 +319,23 @@ class TwiMLOptions(BaseModel): model_config = {"populate_by_name": True} + @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 + this model. Without this, the user's value would be silently dropped + by the TwiML serializer in favor of the typed default — a footgun. + """ + if not self.extra: + return self + typed = set(type(self).model_fields) - {"extra"} + shadowed = sorted(k for k in self.extra if k in typed) + if shadowed: + raise ValueError( + f"TwiMLOptions.extra keys {shadowed} shadow typed fields. " + "Set the typed field directly instead of using ``extra``." + ) + return self + class TwiMLRequest(BaseModel): """Framework-neutral view of the Twilio TwiML webhook form. diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index deeb36c..83778b8 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -13,7 +13,6 @@ from tac.models.session import ConversationSession from tac.models.tac import TACMemoryResponse from tac.models.voice import ( - CustomParameters, InterruptMessage, PromptMessage, TwiMLOptions, @@ -1031,35 +1030,6 @@ def test_generate_twiml_with_arbitrary_custom_parameters(self) -> None: assert '' in twiml assert '' in twiml - def test_generate_twiml_with_pydantic_model(self) -> None: - """Test TwiML generation using Pydantic CustomParameters model.""" - custom_params = CustomParameters(conversationId="CH123", profileId="mem_profile_123") - - twiml = generate_twiml( - "wss://example.com/voice", - TwiMLOptions( - custom_parameters=custom_params, - ), - ) - - # Should use camelCase aliases - assert '' in twiml - assert '' in twiml - - def test_generate_twiml_with_dict_options(self) -> None: - """Test TwiML generation accepting plain dict instead of TwiMLOptions.""" - twiml = generate_twiml( - "wss://example.com/voice", - { - "custom_parameters": {"key": "value"}, - "welcome_greeting": "Hi there!", - }, - ) - - assert 'url="wss://example.com/voice"' in twiml - assert '' in twiml - assert 'welcomeGreeting="Hi there!"' in twiml - def test_generate_twiml_filters_none_values(self) -> None: """Test that None values are excluded from parameters.""" twiml = generate_twiml( @@ -1077,26 +1047,6 @@ def test_generate_twiml_filters_none_values(self) -> None: assert "field2" not in twiml # None should be filtered assert '' in twiml - def test_generate_twiml_escapes_xml_special_chars(self) -> None: - """Test XML character escaping in parameter values.""" - twiml = generate_twiml( - "wss://example.com/voice", - TwiMLOptions( - custom_parameters={ - "field": 'value with "quotes" & ampersand', - }, - ), - ) - - # Twilio SDK automatically escapes XML special characters - assert "&" in twiml - assert """ in twiml - # Verify the full escaped parameter is present - expected_param = ( - '' - ) - assert expected_param in twiml - def test_generate_twiml_complete_example(self) -> None: """Test complete TwiML generation with all options.""" twiml = generate_twiml( @@ -1140,45 +1090,6 @@ def test_generate_twiml_without_conversation_configuration(self) -> None: # Should not have conversation_configuration in output assert "conversationConfiguration" not in twiml - @pytest.mark.asyncio - async def test_handle_incoming_call_with_additional_parameters(self) -> None: - """Static twiml_options on VoiceChannelConfig passes custom_parameters through.""" - from tac.channels.voice import VoiceChannelConfig - - tac = TAC(get_test_config()) - channel = VoiceChannel( - tac, - config=VoiceChannelConfig( - default_twiml_options=TwiMLOptions( - welcome_greeting="Welcome!", - custom_parameters={ - "session_id": "sess_abc123", - "user_language": "es", - "priority": "high", - }, - ), - ), - ) - - twiml = await channel.handle_incoming_call() - - assert 'conversationConfiguration="conv_configuration_test123"' in twiml - assert '' in twiml - assert '' in twiml - assert '' in twiml - - @pytest.mark.asyncio - async def test_handle_incoming_call_without_additional_parameters(self) -> None: - """Test handle_incoming_call works with only TAC defaults.""" - tac = TAC(get_test_config()) - channel = VoiceChannel(tac) - - twiml = await channel.handle_incoming_call() - - assert 'conversationConfiguration="conv_configuration_test123"' in twiml - assert "session_id" not in twiml - assert "user_language" not in twiml - class TestGenerateTwiMLConversationRelayAttrs: """The widened TwiMLOptions surface should emit every documented attribute.""" @@ -1322,23 +1233,18 @@ def test_hints_events_intelligence_service(self) -> None: assert 'events="speaker-events tokens-played"' in twiml assert 'intelligenceService="GAaabbcc"' in twiml - def test_eot_threshold_validation(self) -> None: - import pytest - from pydantic import ValidationError + def test_speech_timeout_accepts_auto(self) -> None: + opts = TwiMLOptions(speech_timeout="auto") + assert opts.speech_timeout == "auto" + twiml = generate_twiml("wss://example.com/ws", opts) + assert 'speechTimeout="auto"' in twiml - with pytest.raises(ValidationError): - TwiMLOptions(eot_threshold=0.4) - with pytest.raises(ValidationError): - TwiMLOptions(eot_threshold=0.95) - - def test_speech_timeout_validation(self) -> None: + def test_speech_timeout_rejects_other_strings(self) -> None: import pytest from pydantic import ValidationError with pytest.raises(ValidationError): - TwiMLOptions(speech_timeout=500) - with pytest.raises(ValidationError): - TwiMLOptions(speech_timeout=6000) + TwiMLOptions(speech_timeout="fast") # type: ignore[arg-type] def test_omitted_fields_absent_from_output(self) -> None: twiml = generate_twiml("wss://example.com/ws") @@ -1383,31 +1289,19 @@ def test_extra_attrs_emitted(self) -> None: assert 'futureFeature="on"' in twiml assert 'anotherAttr="true"' in twiml - def test_extra_does_not_shadow_typed_field(self, caplog: pytest.LogCaptureFixture) -> None: - """If a user puts a typed-field name in extra, the typed value wins and - a warning is logged.""" - import logging - - # TAC's setup_logging (called from TAC()) sets propagate=False on the - # "tac" logger so pytest's caplog doesn't see its records. Flip it for - # this test and restore after. - tac_logger = logging.getLogger("tac") - original_propagate = tac_logger.propagate - tac_logger.propagate = True - try: - with caplog.at_level("WARNING"): - twiml = generate_twiml( - "wss://example.com/ws", - TwiMLOptions( - voice="en-US-Journey-D", - extra={"voice": "should-not-appear"}, - ), - ) - finally: - tac_logger.propagate = original_propagate - assert 'voice="en-US-Journey-D"' in twiml - assert "should-not-appear" not in twiml - assert any("shadows a typed field" in r.message for r in caplog.records) + def test_extra_shadowing_typed_field_raises(self) -> None: + """A typed-field name in ``extra`` raises at construction. Silent + drop-and-warn would be a footgun: the user explicitly set the field + via ``extra`` and got nothing back.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="shadow typed fields"): + TwiMLOptions(voice="en-US-Journey-D", extra={"voice": "should-not-appear"}) + + with pytest.raises(ValidationError, match="shadow typed fields"): + # Even when the typed field is unset, a shadow key must use the + # typed field directly so validators / type coercion run. + TwiMLOptions(extra={"speech_timeout": 800}) def test_extra_none_emits_nothing(self) -> None: twiml = generate_twiml("wss://example.com/ws", TwiMLOptions()) @@ -2116,37 +2010,22 @@ async def running_callback(message, context, memory): class TestVoicePublicDomainNormalization: """TACConfig.voice_public_domain strips schemes/whitespace/trailing slashes.""" - def test_strips_https_scheme(self) -> None: - tac = TAC({**get_test_config(), "voice_public_domain": "https://example.ngrok.app"}) - assert tac.config.voice_public_domain == "example.ngrok.app" - - def test_strips_http_scheme(self) -> None: - tac = TAC({**get_test_config(), "voice_public_domain": "http://example.ngrok.app"}) - assert tac.config.voice_public_domain == "example.ngrok.app" - - def test_strips_wss_scheme(self) -> None: - tac = TAC({**get_test_config(), "voice_public_domain": "wss://example.ngrok.app"}) - assert tac.config.voice_public_domain == "example.ngrok.app" - - def test_strips_trailing_slash(self) -> None: - tac = TAC({**get_test_config(), "voice_public_domain": "example.ngrok.app/"}) - assert tac.config.voice_public_domain == "example.ngrok.app" - - def test_strips_scheme_and_trailing_slash(self) -> None: - tac = TAC({**get_test_config(), "voice_public_domain": "https://example.ngrok.app/"}) - assert tac.config.voice_public_domain == "example.ngrok.app" - - def test_strips_whitespace(self) -> None: - tac = TAC({**get_test_config(), "voice_public_domain": " example.ngrok.app "}) - assert tac.config.voice_public_domain == "example.ngrok.app" - - def test_empty_string_becomes_none(self) -> None: - tac = TAC({**get_test_config(), "voice_public_domain": ""}) - assert tac.config.voice_public_domain is None - - def test_passes_through_clean_value(self) -> None: - tac = TAC({**get_test_config(), "voice_public_domain": "example.ngrok.app"}) - assert tac.config.voice_public_domain == "example.ngrok.app" + @pytest.mark.parametrize( + "raw,expected", + [ + ("https://example.ngrok.app", "example.ngrok.app"), + ("http://example.ngrok.app", "example.ngrok.app"), + ("wss://example.ngrok.app", "example.ngrok.app"), + ("example.ngrok.app/", "example.ngrok.app"), + ("https://example.ngrok.app/", "example.ngrok.app"), + (" example.ngrok.app ", "example.ngrok.app"), + ("example.ngrok.app", "example.ngrok.app"), + ("", None), + ], + ) + def test_normalizes(self, raw: str, expected: str | None) -> None: + tac = TAC({**get_test_config(), "voice_public_domain": raw}) + assert tac.config.voice_public_domain == expected @pytest.mark.asyncio async def test_normalized_value_produces_valid_twiml(self) -> None: @@ -2166,29 +2045,3 @@ def test_default_paths(self) -> None: tac = TAC(get_test_config()) assert tac.config.voice_websocket_path == "/ws" assert tac.config.voice_action_path == "/conversation-relay-callback" - - def test_paths_flow_into_websocket_url(self) -> None: - """voice_websocket_path is consumed by the channel for URL construction.""" - tac = TAC( - { - **get_test_config(), - "voice_public_domain": "test.ngrok.io", - "voice_websocket_path": "/voice/ws", - } - ) - channel = VoiceChannel(tac) - url = channel._resolve_websocket_url("test") - assert url == "wss://test.ngrok.io/voice/ws" - - def test_paths_flow_into_action_url(self) -> None: - """voice_action_path is consumed by the channel for URL construction.""" - tac = TAC( - { - **get_test_config(), - "voice_public_domain": "test.ngrok.io", - "voice_action_path": "/voice/cleanup", - } - ) - channel = VoiceChannel(tac) - url = channel._resolve_default_action_url() - assert url == "https://test.ngrok.io/voice/cleanup"