fix: fix agent stream contract with cloudonix (#504)

This commit is contained in:
Abhishek 2026-07-06 23:58:04 +05:30 committed by GitHub
parent 7e2750f83f
commit 5a822232da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 319 additions and 114 deletions

View file

@ -1,19 +1,15 @@
"""Agent-stream WebSocket endpoint.
A single ``/agent-stream/{workflow_uuid}`` socket where a caller can drive
an agent run by passing everything inline in the query string including
provider credentials. The standard ``/telephony/ws/...`` path requires a
``TelephonyConfigurationModel`` row stored in the org; this one does not.
A single ``/agent-stream/{provider_name}/{workflow_uuid}`` socket where a
caller can drive an agent run. The provider is part of the URL path;
provider-specific call metadata is read from that provider's stream protocol.
Auth: the workflow UUID itself acts as the identifier no API key.
Routing: when ``?provider=<registered>`` matches a telephony provider, we
dispatch to that provider's ``handle_external_websocket``. The raw-audio
branch (no provider) is reserved for a future protocol decision and
currently rejects with 1011.
Routing: when ``/{provider_name}`` matches a telephony provider, we
dispatch to that provider's ``handle_external_websocket``.
"""
import uuid
from typing import Optional
from fastapi import APIRouter, WebSocket
from loguru import logger
@ -28,32 +24,20 @@ from api.services.telephony import registry as telephony_registry
router = APIRouter(prefix="/agent-stream")
@router.websocket("/{workflow_uuid}")
@router.websocket("/{provider_name}/{workflow_uuid}")
async def agent_stream_websocket(
websocket: WebSocket,
provider_name: str,
workflow_uuid: str,
):
"""Generic agent-stream WebSocket.
Query params:
provider: registered telephony provider name (e.g. ``cloudonix``)
from / to / callId: call metadata persisted on the workflow run
...: provider-specific credentials/identifiers (e.g. ``session``,
``AccountSid``, ``CallSid`` for cloudonix)
Without ``provider`` the raw-audio branch is currently not implemented.
``provider_name`` is the registered telephony provider name
(e.g. ``cloudonix``).
"""
await websocket.accept()
params = dict(websocket.query_params)
provider_name: Optional[str] = params.get("provider")
if not provider_name:
logger.warning(
f"agent-stream raw audio branch not yet supported "
f"(workflow_uuid={workflow_uuid})"
)
await websocket.close(code=1011, reason="Raw audio stream not yet implemented")
return
params.pop("provider", None)
spec = telephony_registry.get_optional(provider_name)
if spec is None:
@ -69,12 +53,9 @@ async def agent_stream_websocket(
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
workflow_run_name = f"WR-AGS-{numeric_suffix:08d}"
call_id = params.get("callId") or params.get("CallSid")
initial_context = {
**(workflow.template_context_variables or {}),
"provider": provider_name,
"caller_number": params.get("from"),
"called_number": params.get("to"),
"direction": "inbound",
}
workflow_run = await db_client.create_workflow_run(
@ -84,12 +65,6 @@ async def agent_stream_websocket(
user_id=workflow.user_id,
call_type=CallType.INBOUND,
initial_context=initial_context,
gathered_context={"call_id": call_id} if call_id else {},
logs={
"inbound_webhook": {
"domain": params.get("Domain"),
},
},
)
set_current_run_id(workflow_run.id)

View file

@ -776,7 +776,9 @@ class ARIConnection:
bridge_id = ctx.get("bridge_id")
transfer_state = ctx.get("transfer_state")
transfer_bridge_id = ctx.get("transfer_bridge_id") or bridge_id
transfer_caller_channel_id = ctx.get("transfer_caller_channel_id") or call_id
transfer_caller_channel_id = (
ctx.get("transfer_caller_channel_id") or call_id
)
transfer_destination_channel_id = ctx.get("transfer_destination_channel_id")
# Check if this is a call transfer scenario external channel. Skip full teardown if
@ -813,7 +815,8 @@ class ARIConnection:
and transfer_bridge_id
and transfer_caller_channel_id
and transfer_destination_channel_id
and channel_id in (
and channel_id
in (
transfer_caller_channel_id,
transfer_destination_channel_id,
)
@ -884,9 +887,7 @@ class ARIConnection:
# Clean up the Redis marker for external channel
await self._delete_ext_channel(ext_channel_id)
await self._delete_transfer_channel_mapping(
transfer_destination_channel_id
)
await self._delete_transfer_channel_mapping(transfer_destination_channel_id)
logger.info(
f"[ARI org={self.organization_id}] StasisEnd full teardown for "

View file

@ -359,13 +359,12 @@ class TelephonyProvider(ABC):
workflow_run_id: int,
params: Dict[str, str],
) -> None:
"""Handle the agent-stream WebSocket where credentials are passed inline.
"""Handle the provider-specific agent-stream WebSocket.
Used by ``/api/v1/agent-stream/{workflow_uuid}`` when the caller carries
provider credentials in the query string (no stored
``TelephonyConfigurationModel`` row required). ``organization_id`` is
passed so providers can scope any config lookups to the workflow's
org. Default raises so providers that haven't opted in fail loudly.
Used by ``/api/v1/agent-stream/{provider_name}/{workflow_uuid}`` when
the caller carries a provider stream protocol. ``organization_id`` is
passed so providers can scope any config lookups to the workflow's org.
Default raises so providers that haven't opted in fail loudly.
"""
raise NotImplementedError(
f"Agent-stream not supported for provider {self.PROVIDER_NAME}"

View file

@ -459,10 +459,15 @@ class CloudonixProvider(TelephonyProvider):
await websocket.close(code=4400, reason="Expected start event")
return
# Extract Twilio-compatible identifiers
start = start_msg.get("start")
if not isinstance(start, dict):
logger.error("Cloudonix start message missing start object")
await websocket.close(code=4400, reason="Missing start metadata")
return
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
stream_sid = start["streamSid"]
call_sid = start["callSid"]
except KeyError:
logger.error("Missing streamSid or callSid in start message")
await websocket.close(code=4400, reason="Missing stream identifiers")
@ -512,48 +517,21 @@ class CloudonixProvider(TelephonyProvider):
) -> None:
"""Agent-stream entry point.
``Domain`` (domain id) is read from the query string. The bearer
token comes from the stored Cloudonix telephony configuration
matched by ``domain_id`` within the workflow's organization — never
from the URL or stream payload. The websocket handshake (connected
/ start) is identical to the standard inbound flow.
The Cloudonix domain is read from the ``start.accountSid`` field
in the start message. The bearer token comes from the stored
Cloudonix telephony configuration matched by ``domain_id`` within
the workflow's organization — never from the URL or stream payload.
The websocket handshake (connected / start) is identical to the
standard inbound flow.
Before starting the pipeline we (a) require an existing Cloudonix
telephony configuration for the supplied ``domain_id`` and (b)
telephony configuration for the stream's ``domain_id`` and (b)
validate the call session with Cloudonix using the bearer token
from that configuration. Either failure closes the socket with
4400.
"""
from api.services.pipecat.run_pipeline import run_pipeline_telephony
domain_id = self._normalize_domain(params.get("Domain"))
if not domain_id:
logger.error("Cloudonix agent-stream missing required param: Domain")
await websocket.close(code=4400, reason="Missing Domain query param")
return
config = await self._find_config_by_domain(organization_id, domain_id)
if not config:
logger.error(
f"Cloudonix agent-stream: no telephony configuration found "
f"for domain_id={domain_id}"
)
await websocket.close(
code=4400, reason=f"Unknown Cloudonix domain: {domain_id}"
)
return
bearer_token = (config.credentials or {}).get("bearer_token")
if not bearer_token:
logger.error(
f"Cloudonix agent-stream: telephony configuration {config.id} "
f"is missing bearer_token in credentials"
)
await websocket.close(
code=4400, reason="Cloudonix configuration missing bearer_token"
)
return
try:
first_msg = await websocket.receive_text()
msg = json.loads(first_msg)
@ -568,24 +546,100 @@ class CloudonixProvider(TelephonyProvider):
await websocket.close(code=4400, reason="Expected start event")
return
start = start_msg.get("start")
if not isinstance(start, dict):
logger.error(
"Cloudonix agent-stream start message missing start object"
)
await websocket.close(code=4400, reason="Missing start metadata")
return
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
call_session = start_msg["start"]["session"]
stream_sid = start["streamSid"]
call_sid = start["callSid"]
call_session = start["session"]
domain_id = self._normalize_domain(start["accountSid"])
except KeyError:
logger.error("Missing streamSid or callSid or session in start message")
logger.error(
"Missing streamSid, callSid, session, or accountSid in start message"
)
await websocket.close(code=4400, reason="Missing stream identifiers")
return
if not domain_id:
logger.error("Cloudonix agent-stream start message missing accountSid")
await websocket.close(
code=4400, reason="Missing Cloudonix domain in start message"
)
return
config = await self._find_config_by_domain(organization_id, domain_id)
if not config:
logger.error(
f"Cloudonix agent-stream: no telephony configuration found "
f"for domain_id={domain_id}"
)
await websocket.close(
code=4400, reason=f"Unknown Cloudonix domain: {domain_id}"
)
return
bearer_token = (config.credentials or {}).get("bearer_token")
if not bearer_token:
logger.error(
f"Cloudonix agent-stream: telephony configuration {config.id} "
f"is missing bearer_token in credentials"
)
await websocket.close(
code=4400, reason="Cloudonix configuration missing bearer_token"
)
return
if not await self._validate_session(domain_id, call_session, bearer_token):
await websocket.close(
code=4400, reason="Cloudonix session validation failed"
)
return
start_context = start.get("context")
await db_client.update_workflow_run(
run_id=workflow_run_id,
initial_context={
key: value
for key, value in {
"caller_number": start.get("from"),
"called_number": start.get("to"),
"direction": (
"outbound" if start_context == "outbound-api" else "inbound"
),
"cloudonix_context": start_context,
}.items()
if value is not None
},
gathered_context={
"call_id": call_session,
"cloudonix_call_sid": call_sid,
"cloudonix_stream_sid": stream_sid,
},
logs={
"inbound_webhook": {
"domain": domain_id,
"session": call_session,
"callSid": call_sid,
"streamSid": stream_sid,
"from": start.get("from"),
"to": start.get("to"),
"context": start_context,
"tracks": start.get("tracks"),
"mediaFormat": start.get("mediaFormat"),
},
},
)
logger.info(
f"Cloudonix agent-stream connected for workflow_run "
f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} session={call_session}"
f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} "
f"session={call_session} "
f"telephony_configuration_id={config.id}"
)
@ -628,7 +682,7 @@ class CloudonixProvider(TelephonyProvider):
if response.status == 200:
return True
body = await response.text()
logger.error(
logger.warning(
f"Cloudonix session validation failed: "
f"HTTP {response.status} domain_id={domain_id} "
f"call_id={call_session} body={body}"

View file

@ -6,6 +6,8 @@ or a ``null`` ``session`` / ``disposition``) must produce a graceful error
response, not an unhandled ``AttributeError`` (HTTP 500).
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
@ -34,6 +36,116 @@ def _json_request(body: bytes) -> Request:
)
class _FakeWebSocket:
def __init__(self, *messages: str):
self.receive_text = AsyncMock(side_effect=messages)
self.close = AsyncMock()
@pytest.mark.asyncio
async def test_agent_stream_reads_call_metadata_from_start_message():
"""Cloudonix agent-stream uses start metadata, not call query params."""
provider = CloudonixProvider({})
websocket = _FakeWebSocket(
json.dumps({"event": "connected"}),
json.dumps(
{
"event": "start",
"start": {
"streamSid": "stream-123",
"callSid": "call-123",
"session": "session-123",
"accountSid": "acme",
"from": "+15551230001",
"to": "+15551230002",
"context": "inbound",
"tracks": ["inbound"],
"mediaFormat": {
"encoding": "audio/x-mulaw",
"sampleRate": 8000,
"channels": 1,
},
},
}
),
)
config = SimpleNamespace(
id=7,
credentials={
"domain_id": "acme.cloudonix.net",
"bearer_token": "secret-token",
},
)
provider._find_config_by_domain = AsyncMock(return_value=config)
provider._validate_session = AsyncMock(return_value=True)
with (
patch(
"api.services.telephony.providers.cloudonix.provider.db_client"
) as db_client,
patch(
"api.services.pipecat.run_pipeline.run_pipeline_telephony",
new_callable=AsyncMock,
) as run_pipeline,
):
db_client.update_workflow_run = AsyncMock()
await provider.handle_external_websocket(
websocket,
organization_id=44,
workflow_id=101,
user_id=202,
workflow_run_id=303,
params={},
)
provider._find_config_by_domain.assert_awaited_once_with(44, "acme.cloudonix.net")
provider._validate_session.assert_awaited_once_with(
"acme.cloudonix.net", "session-123", "secret-token"
)
db_client.update_workflow_run.assert_awaited_once_with(
run_id=303,
initial_context={
"caller_number": "+15551230001",
"called_number": "+15551230002",
"direction": "inbound",
"cloudonix_context": "inbound",
},
gathered_context={
"call_id": "session-123",
"cloudonix_call_sid": "call-123",
"cloudonix_stream_sid": "stream-123",
},
logs={
"inbound_webhook": {
"domain": "acme.cloudonix.net",
"session": "session-123",
"callSid": "call-123",
"streamSid": "stream-123",
"from": "+15551230001",
"to": "+15551230002",
"context": "inbound",
"tracks": ["inbound"],
"mediaFormat": {
"encoding": "audio/x-mulaw",
"sampleRate": 8000,
"channels": 1,
},
},
},
)
run_pipeline.assert_awaited_once()
_, kwargs = run_pipeline.await_args
assert kwargs["call_id"] == "session-123"
assert kwargs["transport_kwargs"] == {
"call_id": "session-123",
"stream_sid": "stream-123",
"bearer_token": "secret-token",
"domain_id": "acme.cloudonix.net",
}
websocket.close.assert_not_awaited()
@pytest.mark.asyncio
async def test_cdr_route_handles_payload_without_session():
"""A CDR payload missing the ``session`` object returns a graceful error

View file

@ -57,7 +57,9 @@ class _RecordingARIConnection(ARIConnection):
async def _handle_transfer_failed(
self, transfer_id: str, channel_id: str, reason: str
):
raise AssertionError("completed transfer hangup should not publish transfer failure")
raise AssertionError(
"completed transfer hangup should not publish transfer failure"
)
def _completed_transfer_context():
@ -73,7 +75,9 @@ def _completed_transfer_context():
@pytest.mark.asyncio
async def test_completed_transfer_tears_down_destination_when_caller_leaves(monkeypatch):
async def test_completed_transfer_tears_down_destination_when_caller_leaves(
monkeypatch,
):
fake_db = _FakeDbClient(_completed_transfer_context())
monkeypatch.setattr(ari_manager, "db_client", fake_db)
conn = _RecordingARIConnection()
@ -89,7 +93,9 @@ async def test_completed_transfer_tears_down_destination_when_caller_leaves(monk
@pytest.mark.asyncio
async def test_completed_transfer_tears_down_caller_when_destination_leaves(monkeypatch):
async def test_completed_transfer_tears_down_caller_when_destination_leaves(
monkeypatch,
):
fake_db = _FakeDbClient(_completed_transfer_context())
monkeypatch.setattr(ari_manager, "db_client", fake_db)
conn = _RecordingARIConnection()

View file

@ -0,0 +1,64 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
class _FakeWebSocket:
def __init__(self, query_params: dict[str, str] | None = None):
self.query_params = query_params or {}
self.accept = AsyncMock()
self.close = AsyncMock()
@pytest.mark.asyncio
async def test_agent_stream_uses_provider_path_param_not_query_param():
from api.routes.agent_stream import agent_stream_websocket
websocket = _FakeWebSocket(
{
"provider": "twilio",
"custom": "value",
}
)
workflow = SimpleNamespace(
id=11,
user_id=22,
organization_id=33,
template_context_variables={"existing": "context"},
)
workflow_run = SimpleNamespace(id=44)
provider = SimpleNamespace(handle_external_websocket=AsyncMock())
spec = SimpleNamespace(provider_cls=lambda _config: provider)
with (
patch("api.routes.agent_stream.telephony_registry") as registry,
patch("api.routes.agent_stream.db_client") as db_client,
patch(
"api.routes.agent_stream.authorize_workflow_run_start",
new=AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message=None)
),
),
):
registry.get_optional.return_value = spec
db_client.get_workflow_by_uuid_unscoped = AsyncMock(return_value=workflow)
db_client.create_workflow_run = AsyncMock(return_value=workflow_run)
db_client.update_workflow_run = AsyncMock()
await agent_stream_websocket(websocket, "cloudonix", "agent-uuid")
registry.get_optional.assert_called_once_with("cloudonix")
db_client.create_workflow_run.assert_awaited_once()
create_args = db_client.create_workflow_run.await_args.args
create_kwargs = db_client.create_workflow_run.await_args.kwargs
assert create_args[2] == "cloudonix"
assert create_kwargs["initial_context"] == {
"existing": "context",
"provider": "cloudonix",
"direction": "inbound",
}
provider.handle_external_websocket.assert_awaited_once()
_, provider_kwargs = provider.handle_external_websocket.await_args
assert provider_kwargs["params"] == {"custom": "value"}
websocket.close.assert_not_awaited()