feat: add ultravox realtime and fix signature issue in telephony

- Add UltraVox realtime
- Fix signature issue on telephony
This commit is contained in:
Abhishek Kumar 2026-05-23 12:34:54 +05:30
parent 9135c2da13
commit ea0cac63cd
24 changed files with 2082 additions and 133 deletions

View file

@ -0,0 +1,185 @@
import base64
import hashlib
import hmac
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlencode
import pytest
from starlette.requests import Request
from api.services.telephony.providers.plivo.provider import PlivoProvider
from api.services.telephony.providers.plivo.routes import (
handle_plivo_hangup_callback,
handle_plivo_xml_webhook,
)
def _provider() -> PlivoProvider:
return PlivoProvider(
{
"auth_id": "MA123",
"auth_token": "plivo-auth-token",
"from_numbers": ["+15551230002"],
}
)
def _request(
*,
path: str,
query: dict[str, str | int],
form_data: dict[str, str],
headers: dict[str, str] | None = None,
) -> Request:
body = urlencode(form_data).encode("utf-8")
query_string = urlencode(query).encode("utf-8")
request_headers = [
(b"content-type", b"application/x-www-form-urlencoded"),
*[
(name.lower().encode("ascii"), value.encode("ascii"))
for name, value in (headers or {}).items()
],
]
async def receive():
return {
"type": "http.request",
"body": body,
"more_body": False,
}
return Request(
{
"type": "http",
"method": "POST",
"scheme": "https",
"server": ("example.test", 443),
"path": path,
"query_string": query_string,
"headers": request_headers,
},
receive,
)
def _signature(
provider: PlivoProvider,
*,
path: str,
query: dict[str, str | int],
form_data: dict[str, str],
nonce: str,
) -> str:
url = f"https://example.test{path}"
if query:
url = f"{url}?{urlencode(query)}"
payload = f"{provider._construct_post_url(url, form_data)}.{nonce}"
return base64.b64encode(
hmac.new(
provider.auth_token.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).digest()
).decode("utf-8")
@pytest.mark.asyncio
async def test_plivo_xml_route_accepts_valid_signature_with_extra_query_param():
provider = _provider()
query = {
"workflow_id": 7,
"user_id": 8,
"workflow_run_id": 123,
"campaign_id": 42,
"organization_id": 11,
}
form_data = {
"CallUUID": "call-123",
"Direction": "outbound",
"From": "15551230001",
"To": "15551230002",
}
nonce = "nonce-123"
request = _request(
path="/api/v1/telephony/plivo-xml",
query=query,
form_data=form_data,
headers={
"x-plivo-signature-v3": _signature(
provider,
path="/api/v1/telephony/plivo-xml",
query=query,
form_data=form_data,
nonce=nonce,
),
"x-plivo-signature-v3-nonce": nonce,
},
)
with (
patch("api.services.telephony.providers.plivo.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.plivo.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch.object(
provider,
"get_webhook_response",
new_callable=AsyncMock,
return_value="<Response/>",
) as get_webhook_response,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(gathered_context={}, workflow_id=7)
)
db_client.update_workflow_run = AsyncMock()
response = await handle_plivo_xml_webhook(
workflow_id=7,
user_id=8,
workflow_run_id=123,
organization_id=11,
request=request,
)
assert response.body == b"<Response/>"
get_webhook_response.assert_awaited_once_with(7, 8, 123)
db_client.update_workflow_run.assert_awaited_once()
@pytest.mark.asyncio
async def test_plivo_status_callback_rejects_missing_signature():
provider = _provider()
request = _request(
path="/api/v1/telephony/plivo/hangup-callback/123",
query={},
form_data={"CallUUID": "call-123", "Event": "hangup"},
)
with (
patch("api.services.telephony.providers.plivo.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.plivo.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.plivo.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
result = await handle_plivo_hangup_callback(
workflow_run_id=123, request=request
)
assert result == {"status": "error", "reason": "invalid_signature"}
process_status.assert_not_awaited()

View file

@ -0,0 +1,253 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlencode
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from twilio.request_validator import RequestValidator
from api.services.telephony.providers.twilio.provider import TwilioProvider
from api.services.telephony.providers.twilio.routes import (
handle_twilio_status_callback,
handle_twiml_webhook,
)
def _provider() -> TwilioProvider:
return TwilioProvider(
{
"account_sid": "AC123",
"auth_token": "twilio-auth-token",
"from_numbers": ["+15551230002"],
}
)
def _request(
*,
path: str,
query: dict[str, str | int],
form_data: dict[str, str],
headers: dict[str, str] | None = None,
) -> Request:
body = urlencode(form_data).encode("utf-8")
query_string = urlencode(query).encode("utf-8")
request_headers = [
(b"content-type", b"application/x-www-form-urlencoded"),
*[
(name.lower().encode("ascii"), value.encode("ascii"))
for name, value in (headers or {}).items()
],
]
async def receive():
return {
"type": "http.request",
"body": body,
"more_body": False,
}
return Request(
{
"type": "http",
"method": "POST",
"scheme": "https",
"server": ("example.test", 443),
"path": path,
"query_string": query_string,
"headers": request_headers,
},
receive,
)
def _signature(
provider: TwilioProvider,
*,
path: str,
query: dict[str, str | int],
form_data: dict[str, str],
) -> str:
url = f"https://example.test{path}"
if query:
url = f"{url}?{urlencode(query)}"
validator = RequestValidator(provider.auth_token)
return validator.compute_signature(url, form_data)
@pytest.mark.asyncio
async def test_twiml_route_accepts_valid_signature_with_extra_query_param():
provider = _provider()
query = {
"workflow_id": 7,
"user_id": 8,
"workflow_run_id": 123,
"campaign_id": 42,
"organization_id": 11,
}
form_data = {"CallSid": "CA123", "CallStatus": "in-progress"}
request = _request(
path="/api/v1/telephony/twiml",
query=query,
form_data=form_data,
headers={
"x-twilio-signature": _signature(
provider,
path="/api/v1/telephony/twiml",
query=query,
form_data=form_data,
)
},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch.object(
provider,
"get_webhook_response",
new_callable=AsyncMock,
return_value="<Response/>",
) as get_webhook_response,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(id=123)
)
response = await handle_twiml_webhook(
workflow_id=7,
user_id=8,
workflow_run_id=123,
organization_id=11,
request=request,
)
assert response.body == b"<Response/>"
get_webhook_response.assert_awaited_once_with(7, 8, 123)
@pytest.mark.asyncio
async def test_twiml_route_rejects_missing_signature():
provider = _provider()
request = _request(
path="/api/v1/telephony/twiml",
query={
"workflow_id": 7,
"user_id": 8,
"workflow_run_id": 123,
"organization_id": 11,
},
form_data={"CallSid": "CA123"},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(id=123)
)
with pytest.raises(HTTPException) as exc_info:
await handle_twiml_webhook(
workflow_id=7,
user_id=8,
workflow_run_id=123,
organization_id=11,
request=request,
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Invalid webhook signature"
@pytest.mark.asyncio
async def test_twilio_status_callback_rejects_legacy_header_name():
provider = _provider()
form_data = {"CallSid": "CA123", "CallStatus": "completed"}
request = _request(
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
headers={"x-webhook-signature": "not-a-twilio-signature"},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.twilio.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
with pytest.raises(HTTPException) as exc_info:
await handle_twilio_status_callback(workflow_run_id=123, request=request)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Invalid webhook signature"
process_status.assert_not_awaited()
@pytest.mark.asyncio
async def test_twilio_status_callback_accepts_valid_signature():
provider = _provider()
form_data = {"CallSid": "CA123", "CallStatus": "completed"}
request = _request(
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
headers={
"x-twilio-signature": _signature(
provider,
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
)
},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.twilio.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
result = await handle_twilio_status_callback(
workflow_run_id=123, request=request
)
assert result == {"status": "success"}
process_status.assert_awaited_once()

View file

@ -0,0 +1,178 @@
import hashlib
import hmac
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlencode
import pytest
from starlette.requests import Request
from api.services.telephony.providers.vobiz.provider import VobizProvider
from api.services.telephony.providers.vobiz.routes import (
handle_vobiz_hangup_callback,
handle_vobiz_ring_callback,
)
def _provider() -> VobizProvider:
return VobizProvider(
{
"auth_id": "MA123",
"auth_token": "vobiz-auth-token",
"from_numbers": ["+15551230002"],
}
)
def _request(
*,
path: str,
form_data: dict[str, str],
headers: dict[str, str] | None = None,
) -> Request:
body = urlencode(form_data).encode("utf-8")
request_headers = [
(b"content-type", b"application/x-www-form-urlencoded"),
*[
(name.lower().encode("ascii"), value.encode("ascii"))
for name, value in (headers or {}).items()
],
]
async def receive():
return {
"type": "http.request",
"body": body,
"more_body": False,
}
return Request(
{
"type": "http",
"method": "POST",
"scheme": "https",
"server": ("example.test", 443),
"path": path,
"query_string": b"",
"headers": request_headers,
},
receive,
)
def _signed_headers(
provider: VobizProvider, *, form_data: dict[str, str]
) -> dict[str, str]:
timestamp = str(int(datetime.now(UTC).timestamp()))
body = urlencode(form_data)
signature = hmac.new(
provider.auth_token.encode("utf-8"),
f"{timestamp}.{body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"x-vobiz-signature": signature,
"x-vobiz-timestamp": timestamp,
}
@pytest.mark.asyncio
async def test_vobiz_hangup_callback_accepts_signed_form_body():
provider = _provider()
form_data = {
"CallUUID": "call-123",
"CallStatus": "completed",
"From": "15551230001",
"To": "15551230002",
"Direction": "outbound",
"Duration": "12",
}
headers = _signed_headers(provider, form_data=form_data)
request = _request(
path="/api/v1/telephony/vobiz/hangup-callback/123",
form_data=form_data,
headers=headers,
)
with (
patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.vobiz.routes.get_backend_endpoints",
new_callable=AsyncMock,
return_value=("https://example.test", "wss://example.test"),
),
patch(
"api.services.telephony.providers.vobiz.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
result = await handle_vobiz_hangup_callback(
workflow_run_id=123,
request=request,
x_vobiz_signature=headers["x-vobiz-signature"],
x_vobiz_timestamp=headers["x-vobiz-timestamp"],
)
assert result == {"status": "success"}
process_status.assert_awaited_once()
@pytest.mark.asyncio
async def test_vobiz_ring_callback_accepts_signed_form_body():
provider = _provider()
form_data = {
"CallUUID": "call-123",
"CallStatus": "ringing",
"From": "15551230001",
"To": "15551230002",
}
headers = _signed_headers(provider, form_data=form_data)
request = _request(
path="/api/v1/telephony/vobiz/ring-callback/123",
form_data=form_data,
headers=headers,
)
workflow_run = SimpleNamespace(workflow_id=7, logs={})
with (
patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.vobiz.routes.get_backend_endpoints",
new_callable=AsyncMock,
return_value=("https://example.test", "wss://example.test"),
),
):
db_client.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
db_client.update_workflow_run = AsyncMock()
result = await handle_vobiz_ring_callback(
workflow_run_id=123,
request=request,
x_vobiz_signature=headers["x-vobiz-signature"],
x_vobiz_timestamp=headers["x-vobiz-timestamp"],
)
assert result == {"status": "success"}
db_client.update_workflow_run.assert_awaited_once()

View file

@ -935,9 +935,11 @@ class TestCustomToolManagerUnit:
# Create a mock engine with a mock LLM
mock_llm = Mock()
registered_handlers = {}
registered_kwargs = {}
def capture_register(name, handler, **kwargs):
registered_handlers[name] = handler
registered_kwargs[name] = kwargs
mock_llm.register_function = capture_register
@ -986,6 +988,7 @@ class TestCustomToolManagerUnit:
# Verify handler was registered
assert "api_call" in registered_handlers
assert registered_kwargs["api_call"]["timeout_secs"] == pytest.approx(5)
# Now test that the handler works
handler = registered_handlers["api_call"]

View file

@ -313,6 +313,13 @@ class TestDispatcherThreadsTelephonyConfig:
f"kwargs={store_kwargs}"
)
assert provider.initiate_call.await_count == 1
webhook_url = provider.initiate_call.await_args.kwargs["webhook_url"]
assert "campaign_id=" not in webhook_url, (
"campaign outbound answer_url should not include campaign_id; "
f"got {webhook_url}"
)
@pytest.mark.asyncio
async def test_release_call_slot_uses_stored_telephony_config(self):
"""When a call completes, release_call_slot must release the from_number

View file

@ -19,7 +19,7 @@ class TestGoogleVertexLLMConfiguration:
config = GoogleVertexLLMConfiguration(project_id="demo-project")
assert config.provider == ServiceProviders.GOOGLE_VERTEX
assert config.model == "gemini-2.5-flash"
assert config.location == "us-east4"
assert config.location == "global"
assert config.credentials is None
assert config.api_key is None

View file

@ -17,6 +17,7 @@ from api.services.configuration.registry import (
GoogleVertexLLMConfiguration,
GrokRealtimeLLMConfiguration,
OpenAILLMService,
UltravoxRealtimeLLMConfiguration,
)
from api.services.configuration.resolve import resolve_effective_config
@ -261,6 +262,22 @@ class TestRealtimeOverride:
assert result.realtime.provider == "grok_realtime"
assert result.realtime.voice == "Sal"
def test_switch_realtime_provider_to_ultravox(self, global_config_realtime):
result = resolve_effective_config(
global_config_realtime,
{
"realtime": {
"provider": "ultravox_realtime",
"api_key": "ultra-key",
"model": "ultravox-v0.7",
"voice": "Mark",
}
},
)
assert isinstance(result.realtime, UltravoxRealtimeLLMConfiguration)
assert result.realtime.provider == "ultravox_realtime"
assert result.realtime.voice == "Mark"
def test_override_is_realtime_only_without_realtime_section(self, global_config):
"""Override is_realtime=True but provide no realtime config.
Should set the flag; realtime section stays None from global."""

View file

@ -0,0 +1,158 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.telephony import router
from api.services.auth.depends import get_user
def _make_test_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
id=7,
selected_organization_id=11,
)
return app
def _workflow(*, workflow_id: int = 33, user_id: int = 99):
return SimpleNamespace(
id=workflow_id,
user_id=user_id,
organization_id=11,
template_context_variables={"template_key": "template-value"},
)
def _provider():
return SimpleNamespace(
PROVIDER_NAME="twilio",
WEBHOOK_ENDPOINT="twilio/voice",
validate_config=Mock(return_value=True),
initiate_call=AsyncMock(
return_value=SimpleNamespace(
caller_number="+15550001111",
provider_metadata={"call_id": "call-123"},
)
),
)
def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
app = _make_test_app()
client = TestClient(app)
workflow = _workflow()
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.telephony.db_client") as mock_db,
patch(
"api.routes.telephony.check_dograh_quota_by_user_id",
new=quota_mock,
),
patch(
"api.routes.telephony.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.telephony.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
mock_db.get_user_configurations = AsyncMock(
return_value=SimpleNamespace(test_phone_number=None)
)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.create_workflow_run = AsyncMock(
return_value=SimpleNamespace(
id=501,
name="WR-TEL-OUT-00000001",
initial_context={"template_key": "template-value"},
)
)
mock_db.update_workflow_run = AsyncMock()
response = client.post(
"/telephony/initiate-call",
json={"workflow_id": workflow.id, "phone_number": "+15551234567"},
)
assert response.status_code == 200
quota_mock.assert_awaited_once_with(workflow.user_id, workflow_id=workflow.id)
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)
create_call = mock_db.create_workflow_run.await_args
create_args = create_call.args
create_kwargs = create_call.kwargs
assert create_args[1] == workflow.id
assert create_kwargs["user_id"] == workflow.user_id
assert create_kwargs["organization_id"] == workflow.organization_id
assert create_kwargs["initial_context"]["template_key"] == "template-value"
initiate_kwargs = provider.initiate_call.await_args.kwargs
assert initiate_kwargs["workflow_id"] == workflow.id
assert initiate_kwargs["user_id"] == workflow.user_id
assert "user_id=99" in initiate_kwargs["webhook_url"]
def test_initiate_call_rejects_existing_run_for_different_workflow():
app = _make_test_app()
client = TestClient(app)
workflow = _workflow()
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.telephony.db_client") as mock_db,
patch(
"api.routes.telephony.check_dograh_quota_by_user_id",
new=quota_mock,
),
patch(
"api.routes.telephony.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
):
mock_db.get_user_configurations = AsyncMock(
return_value=SimpleNamespace(test_phone_number=None)
)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_workflow_run = AsyncMock(
return_value=SimpleNamespace(
id=501,
workflow_id=44,
name="WR-TEL-OUT-00000044",
initial_context={},
)
)
response = client.post(
"/telephony/initiate-call",
json={
"workflow_id": workflow.id,
"workflow_run_id": 501,
"phone_number": "+15551234567",
},
)
assert response.status_code == 400
assert response.json()["detail"] == "workflow_run_workflow_mismatch"
mock_db.get_workflow_run.assert_awaited_once_with(501, organization_id=11)
assert not mock_db.create_workflow_run.called
assert provider.initiate_call.await_count == 0

View file

@ -0,0 +1,459 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, call
import pytest
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import LLMMessagesAppendFrame, TTSSpeakFrame
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from websockets.exceptions import ConnectionClosedError
from websockets.frames import Close
from api.schemas.user_configuration import UserConfiguration
from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration
from api.services.pipecat.realtime.ultravox_realtime import (
_RESUMPTION_USER_MESSAGE,
DograhUltravoxOneShotInputParams,
DograhUltravoxRealtimeLLMService,
)
from api.services.pipecat.service_factory import create_realtime_llm_service
class _ClosingSocket:
def __init__(self, exc):
self._exc = exc
def __aiter__(self):
return self
async def __anext__(self):
raise self._exc
class _MessageSocket:
def __init__(self, messages):
self._messages = iter(messages)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._messages)
except StopIteration:
raise StopAsyncIteration
def _make_service() -> DograhUltravoxRealtimeLLMService:
service = DograhUltravoxRealtimeLLMService(
params=DograhUltravoxOneShotInputParams(
api_key="test-key",
model="ultravox-v0.7",
output_medium="voice",
),
settings=DograhUltravoxRealtimeLLMService.Settings(
model="ultravox-v0.7",
output_medium="voice",
),
)
service.stop_all_metrics = AsyncMock()
service.cancel_task = AsyncMock()
service.push_error = AsyncMock()
return service
def _tool_schema() -> ToolsSchema:
return ToolsSchema(
standard_tools=[
FunctionSchema(
name="transition_to_next_node",
description="Move to the next workflow node",
properties={"reason": {"type": "string"}},
required=[],
)
]
)
@pytest.mark.asyncio
async def test_tts_greeting_triggers_initial_connect():
service = _make_service()
service._connect_call = AsyncMock()
await service.process_frame(
TTSSpeakFrame("Hello there", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
service._connect_call.assert_awaited_once()
assert service._connect_call.await_args.kwargs["greeting_text"] == "Hello there"
assert service._connect_call.await_args.kwargs["agent_speaks_first"] is True
@pytest.mark.asyncio
async def test_initial_context_connects_without_replay():
service = _make_service()
service._connect_call = AsyncMock()
context = LLMContext()
await service._handle_context(context)
service._connect_call.assert_awaited_once()
assert service._connect_call.await_args.kwargs["initial_messages"] is None
assert service._connect_call.await_args.kwargs["agent_speaks_first"] is True
@pytest.mark.asyncio
async def test_system_instruction_update_marks_reconnect_required():
service = _make_service()
service._has_connected_once = True
changed = await service._update_settings(
DograhUltravoxRealtimeLLMService.Settings(system_instruction="new instruction")
)
assert "system_instruction" in changed
assert service._reconnect_required is True
@pytest.mark.asyncio
async def test_system_instruction_change_reconnects_with_full_initial_messages():
service = _make_service()
service._socket = object()
service._has_connected_once = True
service._call_system_instruction = "old instruction"
service._reconnect_required = True
service._settings.system_instruction = "new instruction"
service._reconnect_with_context = AsyncMock()
context = LLMContext(
messages=[
{"role": "user", "content": "I want to hear the pricing."},
{
"role": "assistant",
"content": "Let me check that for you.",
"tool_calls": [
{
"id": "call-transition",
"type": "function",
"function": {
"name": "transition_to_next_node",
"arguments": '{"reason":"pricing requested"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call-transition",
"content": '{"status":"done"}',
},
],
tools=_tool_schema(),
)
await service._handle_context(context)
service._reconnect_with_context.assert_awaited_once()
initial_messages = service._reconnect_with_context.await_args.kwargs[
"initial_messages"
]
assert initial_messages == [
{
"role": "MESSAGE_ROLE_USER",
"text": "I want to hear the pricing.",
},
{
"role": "MESSAGE_ROLE_AGENT",
"text": "Let me check that for you.",
},
{
"role": "MESSAGE_ROLE_TOOL_CALL",
"text": "",
"invocationId": "call-transition",
"toolName": "transition_to_next_node",
},
{
"role": "MESSAGE_ROLE_TOOL_RESULT",
"text": '{"status":"done"}',
"invocationId": "call-transition",
"toolName": "transition_to_next_node",
},
]
assert "call-transition" in service._completed_tool_calls
@pytest.mark.asyncio
async def test_tool_context_update_does_not_reconnect_when_system_instruction_is_unchanged():
service = _make_service()
service._socket = object()
service._call_system_instruction = "same instruction"
service._settings.system_instruction = "same instruction"
service._reconnect_with_context = AsyncMock()
service._send_tool_result = AsyncMock()
context = LLMContext(
messages=[
{
"role": "tool",
"tool_call_id": "call-transition",
"content": '{"status":"done"}',
},
],
tools=_tool_schema(),
)
await service._handle_context(context)
service._reconnect_with_context.assert_not_awaited()
service._send_tool_result.assert_awaited_once_with(
"call-transition",
'{"status":"done"}',
)
@pytest.mark.asyncio
async def test_messages_append_frame_sends_user_text():
service = _make_service()
service._socket = object()
service._call_started = True
service._send_user_text = AsyncMock()
await service._handle_messages_append(
LLMMessagesAppendFrame(
[{"role": "user", "content": "Are you still there?"}],
run_llm=True,
)
)
service._send_user_text.assert_awaited_once_with("Are you still there?")
@pytest.mark.asyncio
async def test_messages_append_frame_queues_user_text_until_call_started():
service = _make_service()
service._socket = object()
service._call_started = False
service._send_user_text = AsyncMock()
await service._handle_messages_append(
LLMMessagesAppendFrame(
[{"role": "user", "content": "Are you still there?"}],
run_llm=True,
)
)
assert service._pending_user_text_messages == ["Are you still there?"]
service._send_user_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_call_started_flushes_pending_user_text_messages():
service = _make_service()
service._pending_user_text_messages = [
"First queued message",
"Second queued message",
]
service._send_user_text = AsyncMock()
service._socket = _MessageSocket(['{"type":"call_started","callId":"call-123"}'])
await service._receive_messages()
assert service._call_started is True
assert service._pending_user_text_messages == []
assert service._send_user_text.await_args_list == [
call("First queued message"),
call("Second queued message"),
]
@pytest.mark.asyncio
async def test_completed_input_transcription_is_broadcast_as_finalized():
service = _make_service()
service.broadcast_frame = AsyncMock()
service._last_user_id = "caller-1"
await service._handle_user_transcript("Hello there")
service.broadcast_frame.assert_awaited_once()
assert service.broadcast_frame.await_args.args[0].__name__ == "TranscriptionFrame"
assert service.broadcast_frame.await_args.kwargs["text"] == "Hello there"
assert service.broadcast_frame.await_args.kwargs["finalized"] is True
def test_build_one_shot_params_uses_explicit_greeting_text():
service = _make_service()
params = service._build_one_shot_params(
greeting_text="Welcome to Dograh",
initial_messages=None,
agent_speaks_first=True,
)
assert params.extra["firstSpeakerSettings"] == {
"agent": {"text": "Welcome to Dograh"}
}
def test_build_one_shot_params_includes_initial_messages():
service = _make_service()
service._settings.system_instruction = "Base instruction"
params = service._build_one_shot_params(
greeting_text=None,
initial_messages=[
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
],
agent_speaks_first=True,
)
assert params.extra["initialMessages"] == [
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
{"role": "MESSAGE_ROLE_USER", "text": _RESUMPTION_USER_MESSAGE},
]
assert params.system_prompt == "Base instruction"
def test_build_one_shot_params_without_tool_result_does_not_add_resumption_user_message():
service = _make_service()
service._settings.system_instruction = "Base instruction"
params = service._build_one_shot_params(
greeting_text=None,
initial_messages=[
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."},
],
agent_speaks_first=False,
)
assert params.system_prompt == "Base instruction"
def test_should_agent_speak_first_when_history_ends_with_tool_result():
service = _make_service()
assert (
service._should_agent_speak_first(
[
{"role": "MESSAGE_ROLE_USER", "text": "Hello"},
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
]
)
is True
)
def test_should_not_force_agent_speaks_first_when_history_ends_with_agent():
service = _make_service()
assert (
service._should_agent_speak_first(
[{"role": "MESSAGE_ROLE_AGENT", "text": "How else can I help?"}]
)
is False
)
def test_should_add_resumption_user_message_only_when_history_ends_with_tool_result():
service = _make_service()
assert (
service._should_add_resumption_user_message(
[{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'}]
)
is True
)
assert (
service._should_add_resumption_user_message(
[{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."}]
)
is False
)
def test_to_selected_tools_includes_registered_timeout():
service = _make_service()
service.register_function(
"transition_to_next_node",
AsyncMock(),
timeout_secs=5.5,
)
selected_tools = service._to_selected_tools(_tool_schema())
assert selected_tools == [
{
"temporaryTool": {
"modelToolName": "transition_to_next_node",
"description": "Move to the next workflow node",
"dynamicParameters": [
{
"name": "reason",
"location": "PARAMETER_LOCATION_BODY",
"schema": {"type": "string"},
"required": False,
}
],
"client": {},
"timeout": "5.5s",
}
}
]
@pytest.mark.asyncio
async def test_receive_messages_ignores_benign_websocket_close():
service = _make_service()
service._socket = _ClosingSocket(
ConnectionClosedError(None, Close(1000, "OK"), None)
)
await service._receive_messages()
service.push_error.assert_not_awaited()
@pytest.mark.asyncio
async def test_receive_messages_reports_unexpected_websocket_close():
service = _make_service()
service._socket = _ClosingSocket(
ConnectionClosedError(None, Close(1011, "internal error"), None)
)
await service._receive_messages()
service.push_error.assert_awaited_once()
def test_factory_creates_dograh_ultravox_realtime_service():
user_config = UserConfiguration(
is_realtime=True,
realtime=UltravoxRealtimeLLMConfiguration(
provider="ultravox_realtime",
api_key="ultra-key",
model="ultravox-v0.7",
voice="Mark",
),
)
service = create_realtime_llm_service(
user_config,
audio_config=SimpleNamespace(),
)
assert isinstance(service, DograhUltravoxRealtimeLLMService)
assert service._params.voice == "Mark"
def test_ultravox_realtime_configuration_defaults_to_mark_voice():
config = UltravoxRealtimeLLMConfiguration(
provider="ultravox_realtime",
api_key="ultra-key",
model="ultravox-v0.7",
)
assert config.voice == "Mark"