Skip to content

refactor(voice): decouple TwiML options from VoiceChannel/VoiceProvider - #117

Open
xinghaohuang91 wants to merge 2 commits into
feat/conversation-relay-provider-implfrom
refactor/voice-provider-host-options-type
Open

refactor(voice): decouple TwiML options from VoiceChannel/VoiceProvider#117
xinghaohuang91 wants to merge 2 commits into
feat/conversation-relay-provider-implfrom
refactor/voice-provider-host-options-type

Conversation

@xinghaohuang91

@xinghaohuang91 xinghaohuang91 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

TwiMLOptions models <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).

  • Adds VoiceTwiMLOptions, an empty base for a provider's TwiML options.
  • Renames the concrete class to VoiceTwiMLOptionsConversationRelay(VoiceTwiMLOptions); TwiMLOptions is kept as a back-compat alias (same pattern as VoiceChannelConfig / ConversationRelayProviderConfig).
  • Widens every provider-neutral surface to the VoiceTwiMLOptions base:
    • VoiceProvider.handle_incoming_call's host_twiml_options
    • InboundCallTwiMLHandler (the on_inbound_call_twiml customizer's return type)
    • InitiateVoiceConversationOptions.twiml_options
    • ConversationRelayProvider's overrides widen to match (required for LSP — mypy strict flags a narrower override param type) and do a runtime isinstance check 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) and config.py (ConversationRelayProviderConfig) updated to reference the real name throughout, since they're entirely ConversationRelay-specific.
  • Moved the four Callable handler type aliases (InboundCallTwiMLHandler, CallStatusHandler, AmdHandler, RecordingHandler) from config.py to channel.py — the only place that actually uses them; config.py had no use for them itself.
  • VoiceChannel.__init__'s config: dict shorthand is explicitly documented as VoiceChannelConfig-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 — TwiMLOptions still works everywhere it's used today.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring
  • Release / version bump

Checklist

  • Tests added/updated (879 passed, including 3 new tests for the runtime type-boundary checks this introduced)
  • Documentation updated (docstrings)
  • Tested E2E (manual call against local server + ngrok tunnel, twice)

SDK Parity

This is the Python SDK. If this change affects shared functionality, ensure the TypeScript SDK is updated as well.

  • Change is Python-specific (no TypeScript update needed)

…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.
…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.
Copilot AI lite review requested due to automatic review settings August 27, 2026 03:13
@xinghaohuang91 xinghaohuang91 changed the title refactor(voice): rename TwiMLOptions to VoiceTwiMLOptionsConversationRelay refactor(voice): decouple TwiML options from VoiceChannel/VoiceProvider Aug 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 VoiceTwiMLOptions base model and renames the ConversationRelay-specific options model to VoiceTwiMLOptionsConversationRelay, keeping TwiMLOptions as 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 in ConversationRelayProvider.
  • Moves voice handler type aliases from config.py to channel.py and 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.",
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants