refactor(voice): decouple TwiML options from VoiceChannel/VoiceProvider - #117
Open
xinghaohuang91 wants to merge 2 commits into
Open
Conversation
…Relay TwiMLOptions is ConversationRelay-specific (models <ConversationRelay> attributes), but VoiceProvider.handle_incoming_call's host_twiml_options was typed against it directly, leaking a CR-specific type into the provider-neutral base interface. Adds VoiceTwiMLOptions as an empty base for a provider's inbound-call TwiML options (a future Media Streams provider gets its own subclass), renames TwiMLOptions to VoiceTwiMLOptionsConversationRelay(VoiceTwiMLOptions), and keeps TwiMLOptions as a back-compat alias. VoiceProvider's base signature now takes the base type; ConversationRelayProvider widens its override to match (required for LSP) and does a runtime isinstance check before narrowing to the concrete type it actually needs.
xinghaohuang91
requested review from
ryanrishi,
ryanrouleau and
wenzhu1587
as code owners
August 26, 2026 22:00
…hannel InitiateVoiceConversationOptions.twiml_options was typed against the ConversationRelay-specific TwiMLOptions even though InitiateVoiceConversationOptions is otherwise provider-neutral; widens it to the VoiceTwiMLOptions base, with ConversationRelayProvider validating the concrete type at runtime before use. Also moves the four Callable handler type aliases (InboundCallTwiMLHandler, CallStatusHandler, AmdHandler, RecordingHandler) from config.py to channel.py, the only place that actually uses them, and widens InboundCallTwiMLHandler's return type to VoiceTwiMLOptions for the same reason as host_twiml_options. Adds tests for the three runtime type-boundary checks this introduced (host_twiml_options, the on_inbound_call_twiml customizer's return value, and options.twiml_options), none of which had coverage before.
There was a problem hiding this comment.
Pull request overview
This PR refactors the voice channel’s TwiML customization surface to avoid leaking ConversationRelay-specific types into provider-neutral APIs, enabling future voice providers (e.g., Media Streams) without forcing them into a <ConversationRelay>-shaped options model.
Changes:
- Introduces a provider-neutral
VoiceTwiMLOptionsbase model and renames the ConversationRelay-specific options model toVoiceTwiMLOptionsConversationRelay, keepingTwiMLOptionsas a back-compat alias. - Widens provider-neutral interfaces (
VoiceProvider.handle_incoming_call, inbound TwiML handler alias, outbound initiate options) to accept the base type and adds runtime type checks inConversationRelayProvider. - Moves voice handler type aliases from
config.pytochannel.pyand adds tests covering the new runtime type-boundary checks.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
tests/test_voice_channel.py |
Adds tests ensuring inbound host/customizer TwiML options of the wrong base type are rejected at runtime. |
tests/test_outbound.py |
Adds a test ensuring outbound twiml_options of the wrong base type raises and does not place a call. |
src/tac/models/voice.py |
Adds provider-neutral options base class, renames CR options class, keeps TwiMLOptions alias for compatibility. |
src/tac/models/outbound.py |
Widens outbound twiml_options typing to the provider-neutral base and updates docs accordingly. |
src/tac/models/__init__.py |
Re-exports the new TwiML option types through the public models surface. |
src/tac/channels/voice/twiml.py |
Updates TwiML generation/building to reference the ConversationRelay-specific options class explicitly. |
src/tac/channels/voice/provider.py |
Widens the provider interface to accept provider-neutral VoiceTwiMLOptions. |
src/tac/channels/voice/conversation_relay.py |
Adds runtime narrowing/type checks to enforce CR-specific options where required (inbound + outbound). |
src/tac/channels/voice/config.py |
Removes handler type aliases (moved to channel.py) and updates config typing/docs to CR-specific options. |
src/tac/channels/voice/channel.py |
Moves handler type aliases here, widens inbound host options type, and documents dict-config limitation. |
src/tac/channels/voice/__init__.py |
Updates re-exports to reflect moved handler aliases and new TwiML option types. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+204
to
209
| twiml_options: VoiceTwiMLOptions | None = Field( | ||
| default=None, | ||
| description="Per-call overrides for the TwiML inside <ConversationRelay>. " | ||
| description="Per-call overrides for the outbound TwiML — a " | ||
| "VoiceTwiMLOptionsConversationRelay for the default ConversationRelay provider. " | ||
| "Merged over VoiceChannelConfig.default_twiml_options and TAC defaults.", | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
TwiMLOptionsmodels<ConversationRelay>-specific attributes, but several provider-neutral surfaces (VoiceProvider.handle_incoming_call,VoiceChannel's handler type aliases,InitiateVoiceConversationOptions.twiml_options) were typed directly against it — leaking a CR-specific type into interfaces meant to host future providers (e.g. Media Streams).VoiceTwiMLOptions, an empty base for a provider's TwiML options.VoiceTwiMLOptionsConversationRelay(VoiceTwiMLOptions);TwiMLOptionsis kept as a back-compat alias (same pattern asVoiceChannelConfig/ConversationRelayProviderConfig).VoiceTwiMLOptionsbase:VoiceProvider.handle_incoming_call'shost_twiml_optionsInboundCallTwiMLHandler(theon_inbound_call_twimlcustomizer's return type)InitiateVoiceConversationOptions.twiml_optionsConversationRelayProvider's overrides widen to match (required for LSP — mypy strict flags a narrower override param type) and do a runtimeisinstancecheck before narrowing to the concrete type they actually build TwiML with. Added tests for all three of these checks (none existed before).twiml.py(generate_twiml,TwiMLBuilderConversationRelay) andconfig.py(ConversationRelayProviderConfig) updated to reference the real name throughout, since they're entirely ConversationRelay-specific.Callablehandler type aliases (InboundCallTwiMLHandler,CallStatusHandler,AmdHandler,RecordingHandler) fromconfig.pytochannel.py— the only place that actually uses them;config.pyhad no use for them itself.VoiceChannel.__init__'sconfig: dictshorthand is explicitly documented asVoiceChannelConfig-only (not a generic provider-config constructor) — considered dropping it for a cleaner multi-provider story, but that's a breaking change (would require a 3.0.0 bump), so kept as-is for now with the limitation spelled out.No behavior change for existing callers —
TwiMLOptionsstill works everywhere it's used today.Type of Change
Checklist
SDK Parity
This is the Python SDK. If this change affects shared functionality, ensure the TypeScript SDK is updated as well.