From 22e99172b9e158312b8d927215d69f4c81c1dc51 Mon Sep 17 00:00:00 2001 From: dee-at-twilio <61046964+dee-at-twilio@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:00:41 +0100 Subject: [PATCH 1/7] fixing participant error in outbound call --- src/tac/channels/voice/channel.py | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index f0160d1..23bca96 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -84,6 +84,51 @@ def _caller_address(setup_msg: SetupMessage) -> str | None: return setup_msg.to_number return setup_msg.from_number + async def _fix_outbound_participant_roles( + self, + conv_id: str, + participants: list[Any], + agent_phone: str, + ) -> list[Any]: + """Correct participant roles for outbound calls. + + ConversationRelay assigns participant types based on call leg direction, + which inverts CUSTOMER/AI_AGENT for outbound calls (the agent's number + becomes CUSTOMER). Fix by comparing each participant's VOICE address to + the agent's phone number and updating any mismatched types via CO API. + """ + co_client = self.tac.conversation_orchestrator_client + if co_client is None: + return participants + fixed = [] + for p in participants: + voice_addr = next( + (a.address for a in (p.addresses or []) if a.channel == "VOICE"), + None, + ) + if voice_addr is None: + fixed.append(p) + continue + expected_type = "AI_AGENT" if voice_addr == agent_phone else "CUSTOMER" + if p.type != expected_type: + self.logger.debug( + "Fixing participant role for outbound call", + participant_id=p.id, + from_type=p.type, + to_type=expected_type, + ) + updated = await co_client.update_participant( + conversation_id=conv_id, + participant_id=p.id, + participant_type=expected_type, + addresses=p.addresses or [], + ) + fixed.append(updated) + else: + fixed.append(p) + + return fixed + def _get_twilio_client(self) -> Client: if self._twilio_client is None: from twilio.rest import Client @@ -227,6 +272,13 @@ async def _initialize_conversation( participants = await conversation_orchestrator_client.list_participants(conv_id) + if setup_msg.direction and setup_msg.direction.upper() == "OUTBOUND-API": + agent_phone = setup_msg.from_number + if agent_phone: + participants = await self._fix_outbound_participant_roles( + conv_id, participants, agent_phone + ) + customer_participant = next( (p for p in participants if p.type == "CUSTOMER"), None, From 9d45e91a5b95143d5d09043bce9e54c01b12927a Mon Sep 17 00:00:00 2001 From: dee-at-twilio <61046964+dee-at-twilio@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:12:56 +0100 Subject: [PATCH 2/7] enqueue for outbound handoff --- src/tac/server/fastapi_server.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 452caa1..b4fdec9 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -10,6 +10,8 @@ from __future__ import annotations import asyncio +import json +import os from typing import TYPE_CHECKING, Any from tac.channels.base import BaseChannel @@ -220,14 +222,16 @@ async def websocket_endpoint(websocket: WebSocket, _: None = Depends(ws_sig)) -> @app.post(config.conversation_relay_callback_path) 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()} - await vc.handle_conversation_relay_callback(payload_dict) - except Exception: - logger.error("Failed to process ConversationRelay callback", exc_info=True) - return Response(content="", media_type="text/plain", status_code=400) + form_data = await request.form() + payload_dict = {k: str(v) for k, v in form_data.items()} + await vc.handle_conversation_relay_callback(payload_dict) + + workflow_sid = os.environ.get("TWILIO_TASKROUTER_WORKFLOW_SID", "") + if workflow_sid and payload_dict.get("HandoffData"): + task_attrs = json.dumps({"handoffData": payload_dict["HandoffData"]}) + twiml = f'{task_attrs}' + return Response(content=twiml, media_type="application/xml") + return Response(content="", media_type="text/plain", status_code=200) if config.cintel_webhook_path is not None: From 285ad3315e75c6c95fcad9fb53f83dfa2ac327ea Mon Sep 17 00:00:00 2001 From: dee-at-twilio <61046964+dee-at-twilio@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:28:17 +0100 Subject: [PATCH 3/7] instructions to make outbound calls --- src/tac/tools/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/tac/tools/README.md b/src/tac/tools/README.md index 2d6950f..bda070e 100644 --- a/src/tac/tools/README.md +++ b/src/tac/tools/README.md @@ -1,3 +1,24 @@ +### Forked repo to enable outgoing TAC calls and then handover to Flex, if needed. + +## Make an outbound call +```python +from tac import TAC, TACConfig + +tac = TAC(config=TACConfig.from_env()) +voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always")) + +tac_voice_domain = os.getenv("TAC_SERVER_DOMAIN", "") + +opts = InitiateVoiceConversationOptions( + to="+4498767667", + websocket_url=f"wss://{tac_voice_domain}/ws", + welcome_greeting="Hello", + action_url=f"https://{tac_voice_domain}/conversation-relay-callback", +) +voice_channel.initiate_outbound_conversation(opts) +``` + + # TAC Tools Tool framework for LLM function calling that works with OpenAI and Anthropic APIs. Supports dependency injection to hide runtime values like API keys and client instances from LLM schemas. From 5d93fa12d54465ba5fe59c31aa785edf7b07c09b Mon Sep 17 00:00:00 2001 From: dee-at-twilio <61046964+dee-at-twilio@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:33:48 +0100 Subject: [PATCH 4/7] instructions for outbound calls --- README.md | 20 ++++++++++++++++++++ src/tac/tools/README.md | 21 --------------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bebe009..de4e36b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,23 @@ +### Forked repo to enable outgoing TAC calls and then handover to Flex, if needed. + +## Make an outbound call +```python +from tac import TAC, TACConfig + +tac = TAC(config=TACConfig.from_env()) +voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always")) + +tac_voice_domain = os.getenv("TAC_SERVER_DOMAIN", "") + +opts = InitiateVoiceConversationOptions( + to="+4498767667", + websocket_url=f"wss://{tac_voice_domain}/ws", + welcome_greeting="Hello", + action_url=f"https://{tac_voice_domain}/conversation-relay-callback", +) +voice_channel.initiate_outbound_conversation(opts) +``` +
TAC Logo diff --git a/src/tac/tools/README.md b/src/tac/tools/README.md index bda070e..2d6950f 100644 --- a/src/tac/tools/README.md +++ b/src/tac/tools/README.md @@ -1,24 +1,3 @@ -### Forked repo to enable outgoing TAC calls and then handover to Flex, if needed. - -## Make an outbound call -```python -from tac import TAC, TACConfig - -tac = TAC(config=TACConfig.from_env()) -voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always")) - -tac_voice_domain = os.getenv("TAC_SERVER_DOMAIN", "") - -opts = InitiateVoiceConversationOptions( - to="+4498767667", - websocket_url=f"wss://{tac_voice_domain}/ws", - welcome_greeting="Hello", - action_url=f"https://{tac_voice_domain}/conversation-relay-callback", -) -voice_channel.initiate_outbound_conversation(opts) -``` - - # TAC Tools Tool framework for LLM function calling that works with OpenAI and Anthropic APIs. Supports dependency injection to hide runtime values like API keys and client instances from LLM schemas. From 40a34c853d51b946c117aac461359b22a0a54440 Mon Sep 17 00:00:00 2001 From: dee-at-twilio <61046964+dee-at-twilio@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:31:45 +0100 Subject: [PATCH 5/7] instructions for outbound --- README.md | 20 -------------------- getting_started/README.md | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index de4e36b..bebe009 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,3 @@ -### Forked repo to enable outgoing TAC calls and then handover to Flex, if needed. - -## Make an outbound call -```python -from tac import TAC, TACConfig - -tac = TAC(config=TACConfig.from_env()) -voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always")) - -tac_voice_domain = os.getenv("TAC_SERVER_DOMAIN", "") - -opts = InitiateVoiceConversationOptions( - to="+4498767667", - websocket_url=f"wss://{tac_voice_domain}/ws", - welcome_greeting="Hello", - action_url=f"https://{tac_voice_domain}/conversation-relay-callback", -) -voice_channel.initiate_outbound_conversation(opts) -``` -
TAC Logo diff --git a/getting_started/README.md b/getting_started/README.md index 3ce3b24..b9ad7eb 100644 --- a/getting_started/README.md +++ b/getting_started/README.md @@ -132,6 +132,24 @@ See `examples/.env.example` for all available configuration options. Key variabl - `TWILIO_WHATSAPP_NUMBER`: WhatsApp-enabled phone number in format `whatsapp:+1234567890` (required for `features/whatsapp.py`) - `TWILIO_CONVERSATIONS_SERVICE_SID`: Conversations Service SID (required for Chat channel examples) +## Make an outbound call +```python +from tac import TAC, TACConfig + +tac = TAC(config=TACConfig.from_env()) +voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always")) + +tac_voice_domain = os.getenv("TAC_SERVER_DOMAIN", "") + +opts = InitiateVoiceConversationOptions( + to="+4498767667", + websocket_url=f"wss://{tac_voice_domain}/ws", + welcome_greeting="Hello", + action_url=f"https://{tac_voice_domain}/conversation-relay-callback", +) +voice_channel.initiate_outbound_conversation(opts) +``` + ## Next Steps - Start with `examples/overview.py` to learn the core memory injection pattern From 74af05d2244bd2b3c99aaad14fa96f27b132e919 Mon Sep 17 00:00:00 2001 From: dee-at-twilio <61046964+dee-at-twilio@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:14:42 +0100 Subject: [PATCH 6/7] fixed lint errors --- src/tac/channels/voice/channel.py | 6 ++++-- src/tac/server/fastapi_server.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tac/channels/voice/channel.py b/src/tac/channels/voice/channel.py index 23bca96..945c100 100644 --- a/src/tac/channels/voice/channel.py +++ b/src/tac/channels/voice/channel.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from typing import Literal from twilio.rest import Client from pydantic import ValidationError @@ -109,7 +110,9 @@ async def _fix_outbound_participant_roles( if voice_addr is None: fixed.append(p) continue - expected_type = "AI_AGENT" if voice_addr == agent_phone else "CUSTOMER" + expected_type: Literal["AI_AGENT", "CUSTOMER"] = ( + "AI_AGENT" if voice_addr == agent_phone else "CUSTOMER" + ) if p.type != expected_type: self.logger.debug( "Fixing participant role for outbound call", @@ -126,7 +129,6 @@ async def _fix_outbound_participant_roles( fixed.append(updated) else: fixed.append(p) - return fixed def _get_twilio_client(self) -> Client: diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index b4fdec9..b2a018b 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -229,7 +229,10 @@ async def conversation_relay_callback(request: Request) -> Response: workflow_sid = os.environ.get("TWILIO_TASKROUTER_WORKFLOW_SID", "") if workflow_sid and payload_dict.get("HandoffData"): task_attrs = json.dumps({"handoffData": payload_dict["HandoffData"]}) - twiml = f'{task_attrs}' + twiml = ( + f'' + f"{task_attrs}" + ) return Response(content=twiml, media_type="application/xml") return Response(content="", media_type="text/plain", status_code=200) From 6f971ee2b590ef039421255b38fb81c2df84126a Mon Sep 17 00:00:00 2001 From: dee-at-twilio <61046964+dee-at-twilio@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:08:06 +0100 Subject: [PATCH 7/7] tests for outbound call changes --- tests/test_server.py | 135 ++++++++++++++++++++ tests/test_voice_channel.py | 243 ++++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) diff --git a/tests/test_server.py b/tests/test_server.py index e41e9e4..91c3ea0 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -584,6 +584,141 @@ def test_connect_action_omitted_when_no_handoff_flow(self) -> None: assert "action=" not in resp.text +class TestConversationRelayCallback: + """Test the /conversation-relay-callback route.""" + + def _build_server_and_channel(self): # type: ignore[no-untyped-def] + from tac.channels.voice import VoiceChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + vc = VoiceChannel(tac) + server = TACFastAPIServer( + tac=tac, + config=TACServerConfig(public_domain="test.ngrok.io"), + voice_channel=vc, + ) + return server.app, vc + + @pytest.mark.asyncio + async def test_callback_without_handoff_returns_200(self) -> None: + """Callback with no HandoffData returns 200 plain text and calls the channel handler.""" + from unittest.mock import AsyncMock, patch + + from httpx import ASGITransport, AsyncClient + + app, vc = self._build_server_and_channel() + + with patch.object(vc, "handle_conversation_relay_callback", new_callable=AsyncMock) as mock: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + "/conversation-relay-callback", + data={"CallSid": "CA123", "AccountSid": "ACtest123", "CallStatus": "completed"}, + ) + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/plain") + mock.assert_called_once_with( + {"CallSid": "CA123", "AccountSid": "ACtest123", "CallStatus": "completed"} + ) + + @pytest.mark.asyncio + async def test_callback_with_handoff_but_no_workflow_sid_returns_200( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """HandoffData present but no TWILIO_TASKROUTER_WORKFLOW_SID → plain 200, no TwiML.""" + from unittest.mock import AsyncMock, patch + + from httpx import ASGITransport, AsyncClient + + monkeypatch.delenv("TWILIO_TASKROUTER_WORKFLOW_SID", raising=False) + + app, vc = self._build_server_and_channel() + + with patch.object(vc, "handle_conversation_relay_callback", new_callable=AsyncMock): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + "/conversation-relay-callback", + data={ + "CallSid": "CA123", + "AccountSid": "ACtest123", + "CallStatus": "completed", + "HandoffData": '{"someKey": "someValue"}', + }, + ) + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/plain") + assert " None: + """HandoffData + TWILIO_TASKROUTER_WORKFLOW_SID → TwiML response.""" + from unittest.mock import AsyncMock, patch + + from httpx import ASGITransport, AsyncClient + + monkeypatch.setenv("TWILIO_TASKROUTER_WORKFLOW_SID", "WF123") + + app, vc = self._build_server_and_channel() + + handoff_data = '{"conversationId": "CH_test", "profileId": "PR_test"}' + + with patch.object(vc, "handle_conversation_relay_callback", new_callable=AsyncMock): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + "/conversation-relay-callback", + data={ + "CallSid": "CA123", + "AccountSid": "ACtest123", + "CallStatus": "completed", + "HandoffData": handoff_data, + }, + ) + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("application/xml") + assert '' in resp.text + + @pytest.mark.asyncio + async def test_callback_enqueue_twiml_wraps_handoff_data_in_task( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ element contains {\"handoffData\": }.""" + import json + from unittest.mock import AsyncMock, patch + + from httpx import ASGITransport, AsyncClient + + monkeypatch.setenv("TWILIO_TASKROUTER_WORKFLOW_SID", "WF456") + + app, vc = self._build_server_and_channel() + + handoff_data = '{"conversationId": "CH_abc", "profileId": "PR_xyz"}' + + with patch.object(vc, "handle_conversation_relay_callback", new_callable=AsyncMock): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + "/conversation-relay-callback", + data={ + "CallSid": "CA456", + "AccountSid": "ACtest123", + "CallStatus": "completed", + "HandoffData": handoff_data, + }, + ) + + assert resp.status_code == 200 + expected_task = json.dumps({"handoffData": handoff_data}) + assert f"{expected_task}" in resp.text + + class TestSignatureValidation: """Test that webhook signature validation is enforced on all TAC routes.""" diff --git a/tests/test_voice_channel.py b/tests/test_voice_channel.py index d3f1501..b86cb76 100644 --- a/tests/test_voice_channel.py +++ b/tests/test_voice_channel.py @@ -1528,6 +1528,249 @@ async def on_message(user_message, context, memory_response): ) co_client.list_participants.assert_called_once_with("CH_reuse_test") + # --- _fix_outbound_participant_roles unit tests --- + @pytest.mark.asyncio + async def test_fix_outbound_roles_skips_correct_types(self) -> None: + """Participants already correctly typed are returned unchanged without any API call.""" + from tac.models.conversation import ParticipantAddress, ParticipantResponse + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + co_client = tac.conversation_orchestrator_client + co_client.update_participant = AsyncMock() + + agent_phone = "+15551234567" + agent_participant = ParticipantResponse( + id="PA_agent", + conversation_id="CH_test", + account_id="ACtest123", + name="Agent", + type="AI_AGENT", # already correct + addresses=[ParticipantAddress(channel="VOICE", address=agent_phone)], + ) + customer_participant = ParticipantResponse( + id="PA_customer", + conversation_id="CH_test", + account_id="ACtest123", + name="Customer", + type="CUSTOMER", # already correct + addresses=[ParticipantAddress(channel="VOICE", address="+15559876543")], + ) + result = await channel._fix_outbound_participant_roles( + "CH_test", [agent_participant, customer_participant], agent_phone + ) + co_client.update_participant.assert_not_called() + assert result == [agent_participant, customer_participant] + + @pytest.mark.asyncio + async def test_fix_outbound_roles_skips_participant_without_voice_address(self) -> None: + """Participants with no VOICE-channel address are passed through untouched.""" + from tac.models.conversation import ParticipantAddress, ParticipantResponse + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + co_client = tac.conversation_orchestrator_client + co_client.update_participant = AsyncMock() + + no_voice = ParticipantResponse( + id="PA_no_voice", + conversation_id="CH_test", + account_id="ACtest123", + name="SMS Only", + type="CUSTOMER", + addresses=[ParticipantAddress(channel="SMS", address="+15551234567")], + ) + no_addresses = ParticipantResponse( + id="PA_no_addr", + conversation_id="CH_test", + account_id="ACtest123", + name="No Addresses", + type="CUSTOMER", + addresses=[], + ) + + result = await channel._fix_outbound_participant_roles( + "CH_test", [no_voice, no_addresses], "+15551234567" + ) + co_client.update_participant.assert_not_called() + assert result == [no_voice, no_addresses] + + @pytest.mark.asyncio + async def test_fix_outbound_roles_returns_unchanged_when_no_co_client(self) -> None: + """Returns participants unmodified when conversation_orchestrator_client is None.""" + from tac.models.conversation import ParticipantAddress, ParticipantResponse + + config = get_test_config() + del config["conversation_configuration_id"] + tac = TAC(config) + channel = VoiceChannel(tac) + + assert tac.conversation_orchestrator_client is None + + participants = [ + ParticipantResponse( + id="PA_test", + conversation_id="CH_test", + account_id="ACtest123", + name="Test", + type="CUSTOMER", + addresses=[ParticipantAddress(channel="VOICE", address="+15551234567")], + ) + ] + result = await channel._fix_outbound_participant_roles( + "CH_test", participants, "+15551234567" + ) + assert result is participants + + # --- integration tests through handle_websocket --- + + @pytest.mark.asyncio + async def test_outbound_api_call_fixes_participant_roles_via_websocket(self) -> None: + """Outbound-API setup triggers role correction; callback receives fixed conversation.""" + from tac.channels.websocket_protocol import WebSocketDisconnectError + from tac.models.conversation import ParticipantAddress, ParticipantResponse + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + + initialized_conversations: list[str] = [] + + async def on_message(user_message, context, memory_response): + initialized_conversations.append(context.conversation_id) + + tac.on_message_ready(on_message) + + mock_conversation = ConversationResponse( + id="CH_outbound_test", + accountId="ACtest123", + configuration_id="conv_configuration_test123", + status="ACTIVE", + ) + co_client = tac.conversation_orchestrator_client + co_client.list_conversations = AsyncMock(return_value=[mock_conversation]) + + agent_phone = "+15551234567" # matches phone_number in get_test_config() + # Inverted participants as ConversationRelay produces for outbound-API calls + agent_participant = ParticipantResponse( + id="PA_agent", + conversation_id="CH_outbound_test", + account_id="ACtest123", + name="Agent", + type="CUSTOMER", # inverted + addresses=[ParticipantAddress(channel="VOICE", address=agent_phone)], + ) + customer_participant = ParticipantResponse( + id="PA_customer", + conversation_id="CH_outbound_test", + account_id="ACtest123", + name="Customer", + type="AI_AGENT", # inverted + addresses=[ParticipantAddress(channel="VOICE", address="+15559876543")], + ) + fixed_agent = ParticipantResponse( + id="PA_agent", + conversation_id="CH_outbound_test", + account_id="ACtest123", + name="Agent", + type="AI_AGENT", + addresses=[ParticipantAddress(channel="VOICE", address=agent_phone)], + ) + fixed_customer = ParticipantResponse( + id="PA_customer", + conversation_id="CH_outbound_test", + account_id="ACtest123", + name="Customer", + type="CUSTOMER", + addresses=[ParticipantAddress(channel="VOICE", address="+15559876543")], + ) + co_client.list_participants = AsyncMock( + return_value=[agent_participant, customer_participant] + ) + + async def fix_side_effect(**kwargs): + return fixed_agent if kwargs["participant_id"] == "PA_agent" else fixed_customer + + co_client.update_participant = AsyncMock(side_effect=fix_side_effect) + + mock_websocket = AsyncMock() + # direction lowercase to confirm .upper() comparison works + setup_data = { + "type": "setup", + "callSid": "CA_outbound_test", + "from": agent_phone, + "direction": "outbound-api", + } + prompt_data = {"type": "prompt", "voicePrompt": "Hello"} + mock_websocket.receive_json = AsyncMock( + side_effect=[setup_data, prompt_data, WebSocketDisconnectError()] + ) + + await channel.handle_websocket(mock_websocket) + + assert initialized_conversations == ["CH_outbound_test"] + assert co_client.update_participant.call_count == 2 + co_client.update_participant.assert_any_call( + conversation_id="CH_outbound_test", + participant_id="PA_agent", + participant_type="AI_AGENT", + addresses=agent_participant.addresses, + ) + co_client.update_participant.assert_any_call( + conversation_id="CH_outbound_test", + participant_id="PA_customer", + participant_type="CUSTOMER", + addresses=customer_participant.addresses, + ) + + @pytest.mark.asyncio + async def test_inbound_call_does_not_fix_participant_roles(self) -> None: + """Inbound calls skip role correction entirely — update_participant is never called.""" + from tac.channels.websocket_protocol import WebSocketDisconnectError + from tac.models.conversation import ParticipantAddress, ParticipantResponse + + tac = TAC(get_test_config()) + channel = VoiceChannel(tac) + + tac.on_message_ready(AsyncMock()) + + mock_conversation = ConversationResponse( + id="CH_inbound_test", + accountId="ACtest123", + configuration_id="conv_configuration_test123", + status="ACTIVE", + ) + co_client = tac.conversation_orchestrator_client + co_client.list_conversations = AsyncMock(return_value=[mock_conversation]) + co_client.list_participants = AsyncMock( + return_value=[ + ParticipantResponse( + id="PA_customer", + conversation_id="CH_inbound_test", + account_id="ACtest123", + name="Customer", + type="CUSTOMER", + addresses=[ParticipantAddress(channel="VOICE", address="+15559876543")], + ) + ] + ) + co_client.update_participant = AsyncMock() + + mock_websocket = AsyncMock() + setup_data = { + "type": "setup", + "callSid": "CA_inbound_test", + "from": "+15559876543", + # no direction field — inbound + } + prompt_data = {"type": "prompt", "voicePrompt": "Hello"} + mock_websocket.receive_json = AsyncMock( + side_effect=[setup_data, prompt_data, WebSocketDisconnectError()] + ) + + await channel.handle_websocket(mock_websocket) + + co_client.update_participant.assert_not_called() + class TestSessionManagerDefaults: """Test session_manager default behavior in VoiceChannelConfig."""