Files
replypier/backend/tests/services/test_channel_chat.py
T

686 lines
32 KiB
Python
Raw Normal View History

"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from app.channels.base import InboundMessage, InteractiveReply, InteractiveReplyType
from app.channels.interactive_limits import TELEGRAM_LIMITS, WHATSAPP_LIMITS
from app.models.channels import ChannelConversation
from app.models.chat_history import ChatHistory
from app.models.agent import AgentType
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.repositories.channels import (
ChannelAccountRepository,
ChannelConversationRepository,
AgentChannelConfigRepository,
)
from app.repositories.agent import AgentRepository
from app.repositories.chat import ChatRepository
from app.services import agent_rooms, channel_chat
from app.services.human_routing import HUMAN_ONLY_ACK
from app.services.business_message import remember_business_message
from app.services.channel_chat import process_channel_message
from app.services.message_delivery import DeliveryResult
def make_inbound(text="hello"):
return InboundMessage(
external_account_id="bot123",
external_conversation_id="222",
external_user_id="111",
external_message_id="42",
text=text,
profile={"name": "Ada Lovelace"},
)
@pytest.fixture
def account(db, test_organization):
return ChannelAccountRepository(db).create_account(
organization_id=test_organization.id,
channel_type="telegram",
external_account_id="bot123",
credentials={"bot_token": "123:abc"},
display_name="@testbot",
)
@pytest.fixture
def routed_account(db, account, test_agent):
AgentChannelConfigRepository(db).set_agent(account.id, test_agent.id)
return account
@pytest.fixture
def use_test_db(db, monkeypatch):
"""process_channel_message opens its own SessionLocal; bind it to the test db."""
class _NonClosing:
def __init__(self, inner):
self._inner = inner
def __getattr__(self, name):
return getattr(self._inner, name)
def close(self):
pass # keep the fixture-owned session usable after processing
monkeypatch.setattr(channel_chat, "SessionLocal", lambda: _NonClosing(db))
return db
@pytest.fixture
def mock_sio(monkeypatch):
sio = MagicMock()
sio.emit = AsyncMock()
monkeypatch.setattr(agent_rooms, "sio", sio)
return sio
@pytest.mark.asyncio
async def test_drops_message_when_no_agent_routed(db, account, use_test_db, test_ai_config):
with patch.object(channel_chat, "ChatAgent") as mock_agent:
await process_channel_message(account.id, make_inbound())
mock_agent.create_async.assert_not_called()
# No session created
assert db.query(ChannelConversation).count() == 0
@pytest.mark.asyncio
async def test_ai_path_creates_session_and_delivers(db, routed_account, use_test_db, test_ai_config, test_agent, mock_sio):
response = SimpleNamespace(message="hi there", transfer_to_human=False,
end_chat=False, request_lead_capture=False)
fake_agent = MagicMock()
fake_agent.get_response = AsyncMock(return_value=response)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
patch.object(channel_chat, "deliver_to_customer", AsyncMock()) as deliver:
await process_channel_message(routed_account.id, make_inbound())
assert create_async.await_args.kwargs["channel"] == "telegram"
fake_agent.get_response.assert_awaited_once()
fake_agent.safe_cleanup_mcp_tools.assert_awaited()
deliver.assert_awaited_once()
payload = deliver.await_args.args[2]
assert payload["message"] == "hi there"
# Session + conversation persisted with the right channel
conversation = db.query(ChannelConversation).one()
session = db.query(SessionToAgent).filter_by(session_id=conversation.session_id).one()
assert session.channel == "telegram"
assert str(session.agent_id) == str(test_agent.id)
def _agent_persisting_reply(db, text="hi there"):
"""A ChatAgent stub that persists its reply the way the real one does and
reports the row id, so delivery results land on that row."""
response = SimpleNamespace(message=text, transfer_to_human=False,
end_chat=False, request_lead_capture=False)
fake_agent = MagicMock()
async def _respond(**kwargs):
fake_agent.last_bot_message_id = ChatRepository(db).create_message({
"message": text,
"message_type": "bot",
"session_id": kwargs["session_id"],
"organization_id": kwargs["org_id"],
"agent_id": kwargs["agent_id"],
"customer_id": kwargs["customer_id"],
}).id
return response
fake_agent.get_response = AsyncMock(side_effect=_respond)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
return fake_agent
@pytest.mark.asyncio
async def test_bot_reply_records_delivery_failure(db, routed_account, use_test_db, test_ai_config, mock_sio):
"""Outside the messaging window the customer receives nothing, so the stored
reply must record that instead of looking delivered."""
fake_agent = _agent_persisting_reply(db)
failed = DeliveryResult(ok=False, reason="window_expired", can_template=True)
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
patch.object(channel_chat, "deliver_to_customer", AsyncMock(return_value=failed)):
await process_channel_message(routed_account.id, make_inbound())
message = db.query(ChatHistory).filter_by(message_type="bot").one()
assert message.attributes["delivery_status"] == "window_expired"
@pytest.mark.asyncio
async def test_bot_reply_marks_nothing_when_delivered(db, routed_account, use_test_db, test_ai_config, mock_sio):
fake_agent = _agent_persisting_reply(db)
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
patch.object(channel_chat, "deliver_to_customer", AsyncMock(return_value=DeliveryResult(ok=True))):
await process_channel_message(routed_account.id, make_inbound())
message = db.query(ChatHistory).filter_by(message_type="bot").one()
assert "delivery_status" not in (message.attributes or {})
@pytest.mark.asyncio
async def test_reuses_open_session_for_same_conversation(db, routed_account, use_test_db, test_ai_config, mock_sio):
response = SimpleNamespace(message="ok", transfer_to_human=False,
end_chat=False, request_lead_capture=False)
fake_agent = MagicMock()
fake_agent.get_response = AsyncMock(return_value=response)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
await process_channel_message(routed_account.id, make_inbound("first"))
await process_channel_message(routed_account.id, make_inbound("second"))
assert db.query(ChannelConversation).count() == 1
class TestAgentChangeStartsANewSession:
"""A session belongs to one agent: its history, and the AI runtime's memory
for that session id, are that agent's. Reusing it after the channel is
pointed at a different agent made the new agent answer out of the previous
one's retrieved knowledge, and split the inbox in two — the conversations
list groups by agent *and* session while the detail view keys on session
alone, so one thread showed twice and neither row opened the other."""
@staticmethod
async def _send(routed_account, text):
response = SimpleNamespace(message="ok", transfer_to_human=False,
end_chat=False, request_lead_capture=False)
fake_agent = MagicMock()
fake_agent.get_response = AsyncMock(return_value=response)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
await process_channel_message(routed_account.id, make_inbound(text))
@pytest.mark.asyncio
async def test_a_new_agent_gets_a_new_session(self, db, routed_account, use_test_db,
test_ai_config, test_organization, mock_sio):
await self._send(routed_account, "first")
first = db.query(ChannelConversation).one()
other = AgentRepository(db).create_agent(
name="Other Agent", agent_type=AgentType.CUSTOMER_SUPPORT,
org_id=test_organization.id, instructions=["help"])
AgentChannelConfigRepository(db).set_agent(routed_account.id, other.id)
await self._send(routed_account, "second")
# Same platform thread, but a second conversation and a second session.
conversations = db.query(ChannelConversation).order_by(
ChannelConversation.created_at).all()
assert len(conversations) == 2
assert conversations[0].session_id != conversations[1].session_id
assert {c.external_conversation_id for c in conversations} == {"222"}
# The retired session is closed, so it stops being picked up, and each
# session is bound to exactly one agent.
old = db.query(SessionToAgent).filter_by(session_id=first.session_id).one()
new = db.query(SessionToAgent).filter_by(session_id=conversations[1].session_id).one()
assert old.status == SessionStatus.CLOSED
assert str(new.agent_id) == str(other.id)
@pytest.mark.asyncio
async def test_the_same_agent_keeps_its_session(self, db, routed_account, use_test_db,
test_ai_config, mock_sio):
"""The guard must not fire on every message — only on a real change."""
await self._send(routed_account, "first")
await self._send(routed_account, "second")
assert db.query(ChannelConversation).count() == 1
assert db.query(SessionToAgent).filter_by(
status=SessionStatus.CLOSED).count() == 0
@pytest.mark.asyncio
async def test_a_human_handled_chat_is_not_rotated(self, db, routed_account, use_test_db,
test_ai_config, test_organization,
test_user, mock_sio):
"""Rotating here would drop the human agent out of a live conversation."""
await self._send(routed_account, "first")
conversation = db.query(ChannelConversation).one()
session = db.query(SessionToAgent).filter_by(
session_id=conversation.session_id).one()
session.user_id = test_user.id
db.commit()
other = AgentRepository(db).create_agent(
name="Other Agent 2", agent_type=AgentType.CUSTOMER_SUPPORT,
org_id=test_organization.id, instructions=["help"])
AgentChannelConfigRepository(db).set_agent(routed_account.id, other.id)
await self._send(routed_account, "while a human is on it")
assert db.query(ChannelConversation).count() == 1
assert db.query(SessionToAgent).filter_by(
session_id=conversation.session_id).one().status != SessionStatus.CLOSED
@pytest.mark.asyncio
async def test_human_handling_relays_and_skips_bot(db, routed_account, use_test_db, test_ai_config, test_user, mock_sio):
# First message creates the session via the AI path
response = SimpleNamespace(message="ok", transfer_to_human=False,
end_chat=False, request_lead_capture=False)
fake_agent = MagicMock()
fake_agent.get_response = AsyncMock(return_value=response)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
await process_channel_message(routed_account.id, make_inbound("first"))
# A human takes over the session
conversation = db.query(ChannelConversation).one()
session = db.query(SessionToAgent).filter_by(session_id=conversation.session_id).one()
session.user_id = test_user.id
db.commit()
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock()) as create_async:
await process_channel_message(routed_account.id, make_inbound("for the human"))
create_async.assert_not_awaited()
# Relayed to the handling agent's rooms
rooms = [call.kwargs.get("room") for call in mock_sio.emit.await_args_list]
assert f"user_{test_user.id}" in rooms
assert str(session.session_id) in rooms
@pytest.mark.asyncio
async def test_human_only_agent_never_runs_the_bot(db, routed_account, use_test_db,
test_ai_config, test_agent, mock_sio):
"""AI switched off on the agent: relay to the dashboard, never model.
No transfer either — the chat simply waits for someone to pick it up, so
the customer gets no automated reply.
"""
test_agent.ai_replies_enabled = False
db.commit()
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock()) as create_async, \
patch("app.services.human_routing.deliver_to_customer",
AsyncMock(return_value=DeliveryResult(ok=True))) as ack:
await process_channel_message(routed_account.id, make_inbound("hello"))
create_async.assert_not_awaited()
# One acknowledgement so the number doesn't look dead, nothing more.
assert ack.await_count == 1
assert ack.await_args.args[2]['message'] == HUMAN_ONLY_ACK
# A follow-up message is not acknowledged again.
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock()), \
patch("app.services.human_routing.deliver_to_customer",
AsyncMock(return_value=DeliveryResult(ok=True))) as ack:
await process_channel_message(routed_account.id, make_inbound("still there?"))
assert not ack.called
conversation = db.query(ChannelConversation).one()
session = db.query(SessionToAgent).filter_by(
session_id=conversation.session_id).one()
# A plain open chat waiting in the inbox, not a transfer.
assert session.status == SessionStatus.OPEN
assert session.user_id is None
@pytest.mark.asyncio
async def test_email_channel_uses_real_customer_email(db, test_organization):
"""Email sender address is used verbatim as the customer email (unifies
the person across channels + widget) — not the synthetic @email.channel."""
from app.services.channel_chat import _get_or_create_customer
from app.models.channels import ChannelAccount
from app.models.customer import Customer
from app.channels.base import InboundMessage
import uuid as _uuid
account = MagicMock(spec=ChannelAccount)
account.channel_type = "email"
inbound = InboundMessage(
external_account_id="", external_conversation_id="jane@acme.com",
external_user_id="jane@acme.com", external_message_id="m1", text="hi",
profile={"name": "Jane Roe", "email": "Jane@Acme.com"})
customer_id = _get_or_create_customer(db, account, inbound, str(test_organization.id))
customer = db.query(Customer).filter(Customer.id == _uuid.UUID(customer_id)).one()
assert customer.email == "jane@acme.com" # lowercased real address, no suffix
@pytest.mark.asyncio
async def test_non_email_channel_synthesizes_address(db, test_organization):
from app.services.channel_chat import _get_or_create_customer
from app.models.channels import ChannelAccount
from app.models.customer import Customer
from app.channels.base import InboundMessage
import uuid as _uuid
account = MagicMock(spec=ChannelAccount)
account.channel_type = "telegram"
inbound = InboundMessage(
external_account_id="", external_conversation_id="555",
external_user_id="555", external_message_id="m1", text="hi",
profile={"name": "Bob"})
customer_id = _get_or_create_customer(db, account, inbound, str(test_organization.id))
customer = db.query(Customer).filter(Customer.id == _uuid.UUID(customer_id)).one()
assert customer.email == "555@telegram.channel"
@pytest.mark.asyncio
async def test_placeholder_name_upgraded_when_real_name_resolved(db, account, test_organization):
"""An existing customer created with a '<Channel> user xxxx' placeholder is
renamed once fetch_profile resolves a real name."""
from app.services.channel_chat import _get_or_create_customer
from app.channels.base import InboundMessage
from app.models.customer import Customer
import uuid as _uuid
account.channel_type = "slack"
# First message: no real name yet → placeholder
inbound1 = InboundMessage(external_account_id="T1", external_conversation_id="D7",
external_user_id="U09UPKP7", external_message_id="e1", text="hi")
cid = _get_or_create_customer(db, account, inbound1, str(test_organization.id))
cust = db.query(Customer).filter(Customer.id == _uuid.UUID(cid)).one()
assert cust.full_name == "Slack user U09UPKP7"
# Second message: profile now carries the real name → upgraded in place
inbound2 = InboundMessage(external_account_id="T1", external_conversation_id="D7",
external_user_id="U09UPKP7", external_message_id="e2", text="hi again",
profile={"name": "Ada Lovelace"})
cid2 = _get_or_create_customer(db, account, inbound2, str(test_organization.id))
assert cid2 == cid # same customer
db.refresh(cust)
assert cust.full_name == "Ada Lovelace"
@pytest.mark.asyncio
async def test_enrich_customer_name_only_fetches_while_placeholder(db, account, test_organization):
"""fetch_profile is called to resolve a placeholder name, but skipped once
the customer already has a real name (no per-message API call)."""
from unittest.mock import AsyncMock
from app.services.channel_chat import _get_or_create_customer, _enrich_customer_name
from app.channels.base import InboundMessage
from app.models.customer import Customer
import uuid as _uuid
account.channel_type = "slack"
inbound = InboundMessage(external_account_id="T1", external_conversation_id="D8",
external_user_id="U0ENRICH", external_message_id="e1", text="hi")
cid = _get_or_create_customer(db, account, inbound, str(test_organization.id))
cust = db.query(Customer).filter(Customer.id == _uuid.UUID(cid)).one()
assert cust.full_name == "Slack user U0ENRICH"
# Placeholder present → adapter is queried and the name is upgraded
adapter = AsyncMock()
adapter.fetch_profile = AsyncMock(return_value={"name": "Grace Hopper"})
await _enrich_customer_name(db, adapter, account, inbound, cid)
adapter.fetch_profile.assert_awaited_once()
db.refresh(cust)
assert cust.full_name == "Grace Hopper"
# Real name already set → no further API call on subsequent messages
adapter.fetch_profile.reset_mock()
await _enrich_customer_name(db, adapter, account, inbound, cid)
adapter.fetch_profile.assert_not_awaited()
@pytest.mark.asyncio
async def test_whatsapp_inbound_stores_the_phone(db, test_organization):
"""The WhatsApp adapter declares profile['phone']; identity stores it."""
from app.services.channel_chat import _get_or_create_customer
from app.models.channels import ChannelAccount
from app.models.customer import Customer
from app.channels.base import InboundMessage
import uuid as _uuid
account = MagicMock(spec=ChannelAccount)
account.channel_type = "whatsapp"
inbound = InboundMessage(
external_account_id="PN1", external_conversation_id="911234567890",
external_user_id="911234567890", external_message_id="m1", text="hi",
profile={"name": "Priya", "phone": "+911234567890"})
cid = _get_or_create_customer(db, account, inbound, str(test_organization.id))
customer = db.query(Customer).filter(Customer.id == _uuid.UUID(cid)).one()
assert customer.phone == "+911234567890"
assert customer.email == "911234567890@whatsapp.channel"
@pytest.mark.asyncio
async def test_phone_unifies_one_human_across_channels(db, test_organization):
"""A widget lead with a captured phone and a WhatsApp inbound from that
number resolve to ONE customer — the point of the phone identity key."""
from app.services.channel_chat import _get_or_create_customer
from app.models.channels import ChannelAccount
from app.repositories.customer import CustomerRepository
from app.channels.base import InboundMessage
widget_person = CustomerRepository(db).create_customer(
email="priya@example.com", organization_id=test_organization.id,
full_name="Priya", phone="+911234567890")
account = MagicMock(spec=ChannelAccount)
account.channel_type = "whatsapp"
inbound = InboundMessage(
external_account_id="PN1", external_conversation_id="911234567890",
external_user_id="911234567890", external_message_id="m2", text="hello",
profile={"phone": "+911234567890"})
cid = _get_or_create_customer(db, account, inbound, str(test_organization.id))
assert cid == str(widget_person.id)
@pytest.mark.asyncio
async def test_channels_without_phone_keep_a_null_column(db, test_organization):
from app.services.channel_chat import _get_or_create_customer
from app.models.channels import ChannelAccount
from app.models.customer import Customer
from app.channels.base import InboundMessage
import uuid as _uuid
account = MagicMock(spec=ChannelAccount)
account.channel_type = "messenger"
inbound = InboundMessage(
external_account_id="PG1", external_conversation_id="PSID9",
external_user_id="PSID9", external_message_id="m1", text="hi")
cid = _get_or_create_customer(db, account, inbound, str(test_organization.id))
customer = db.query(Customer).filter(Customer.id == _uuid.UUID(cid)).one()
assert customer.phone is None
@pytest.mark.asyncio
async def test_outbound_context_reaches_the_agent(db, routed_account, use_test_db, test_ai_config, mock_sio):
"""A reply to an outbound template is a fragment ("yes") — the agent gets
told what was sent, on every turn, via extra_context."""
response = SimpleNamespace(message="ok", transfer_to_human=False,
end_chat=False, request_lead_capture=False)
fake_agent = MagicMock()
fake_agent.get_response = AsyncMock(return_value=response)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
# First inbound creates the conversation; stamp the outbound marker on
# it the way start_outbound_conversation does, then send the reply.
await process_channel_message(routed_account.id, make_inbound())
conversation = db.query(ChannelConversation).one()
remember_business_message(db, conversation, "Hi Priya, your order A-12 shipped.", "wamid.T")
await process_channel_message(routed_account.id, make_inbound(text="yes"))
extra = create_async.await_args.kwargs["extra_context"]
assert "Hi Priya, your order A-12 shipped." in extra
assert "Your business sent this customer" in extra
@pytest.mark.asyncio
async def test_ordinary_inbound_conversations_carry_no_outbound_context(db, routed_account, use_test_db, test_ai_config, mock_sio):
response = SimpleNamespace(message="ok", transfer_to_human=False,
end_chat=False, request_lead_capture=False)
fake_agent = MagicMock()
fake_agent.get_response = AsyncMock(return_value=response)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
await process_channel_message(routed_account.id, make_inbound())
assert create_async.await_args.kwargs["extra_context"] is None
@pytest.fixture
def whatsapp_account(db, test_organization, test_agent):
account = ChannelAccountRepository(db).create_account(
organization_id=test_organization.id,
channel_type="whatsapp",
external_account_id="PN123",
credentials={"access_token": "tok"},
display_name="+1 555",
)
AgentChannelConfigRepository(db).set_agent(account.id, test_agent.id)
return account
def _wa_tap():
return InboundMessage(
external_account_id="PN123", external_conversation_id="447",
external_user_id="447", external_message_id="wamid.IN", text="Tomorrow",
profile={"phone": "447"},
interactive=InteractiveReply(type=InteractiveReplyType.BUTTON_REPLY,
id="qr_1", title="Tomorrow"),
)
class TestChoices:
@pytest.mark.asyncio
async def test_whatsapp_agent_is_told_it_can_offer_choices(
self, db, whatsapp_account, use_test_db, test_ai_config, mock_sio, test_agent):
test_agent.channel_quick_replies = True
db.commit()
response = SimpleNamespace(message="When?", transfer_to_human=False, end_chat=False,
request_lead_capture=False, quick_replies=["Today", "Tomorrow"])
fake_agent = MagicMock()
fake_agent.get_response = AsyncMock(return_value=response)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
fake_agent.last_bot_message_id = 7
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
patch.object(channel_chat, "deliver_to_customer",
AsyncMock(return_value=DeliveryResult(ok=True))) as deliver:
await process_channel_message(whatsapp_account.id, _wa_tap())
assert create_async.await_args.kwargs["choice_limits"] is WHATSAPP_LIMITS
assert deliver.await_args.kwargs["message_id"] == 7
sent = deliver.await_args.kwargs["interactive"]
assert [b.title for b in sent.buttons] == ["Today", "Tomorrow"]
user_attributes = fake_agent.get_response.await_args.kwargs["user_attributes"]
assert user_attributes["interactive_reply"] == {"type": "button_reply", "id": "qr_1"}
@pytest.mark.asyncio
async def test_buttons_are_off_until_the_agent_turns_them_on(
self, db, whatsapp_account, use_test_db, test_ai_config, mock_sio):
response = SimpleNamespace(message="When?", transfer_to_human=False, end_chat=False,
request_lead_capture=False, quick_replies=["Today", "Tomorrow"])
fake_agent = MagicMock()
fake_agent.get_response = AsyncMock(return_value=response)
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
fake_agent.last_bot_message_id = 7
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
patch.object(channel_chat, "deliver_to_customer",
AsyncMock(return_value=DeliveryResult(ok=True))) as deliver:
await process_channel_message(whatsapp_account.id, _wa_tap())
assert create_async.await_args.kwargs["choice_limits"] is None
assert deliver.await_args.kwargs["interactive"] is None
@pytest.mark.asyncio
async def test_telegram_agents_get_buttons_when_turned_on(
self, db, routed_account, use_test_db, test_ai_config, mock_sio, test_agent):
test_agent.channel_quick_replies = True
db.commit()
fake_agent = _agent_persisting_reply(db)
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
patch.object(channel_chat, "deliver_to_customer", AsyncMock(return_value=DeliveryResult(ok=True))):
await process_channel_message(routed_account.id, make_inbound())
assert create_async.await_args.kwargs["choice_limits"] is TELEGRAM_LIMITS
@pytest.mark.asyncio
async def test_a_channel_without_buttons_offers_none_even_when_turned_on(
self, db, routed_account, use_test_db, test_ai_config, mock_sio, test_agent, monkeypatch):
test_agent.channel_quick_replies = True
db.commit()
monkeypatch.setattr(type(channel_chat.get_adapter("telegram")), "interactive_limits", None)
fake_agent = _agent_persisting_reply(db)
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
patch.object(channel_chat, "deliver_to_customer", AsyncMock(return_value=DeliveryResult(ok=True))):
await process_channel_message(routed_account.id, make_inbound())
assert create_async.await_args.kwargs["choice_limits"] is None
@pytest.mark.asyncio
async def test_human_path_stores_and_relays_the_tap(self, db, whatsapp_account, use_test_db, mock_sio):
with patch.object(channel_chat.AgentRepository, "get_agent",
return_value=SimpleNamespace(ai_replies_enabled=False, channel_quick_replies=False)), \
patch.object(channel_chat, "notify_customer", AsyncMock()):
await process_channel_message(whatsapp_account.id, _wa_tap())
stored = db.query(ChatHistory).filter_by(message_type="user").one()
assert stored.attributes["interactive_reply"] == {"type": "button_reply", "id": "qr_1"}
relayed = mock_sio.emit.await_args.args[1]
assert relayed["message_id"] == stored.id
assert relayed["attributes"]["interactive_reply"]["id"] == "qr_1"
@pytest.mark.asyncio
async def test_concurrent_turns_each_deliver_against_their_own_reply(
db, routed_account, use_test_db, test_ai_config, mock_sio):
"""Two messages processed at once on one conversation: each reply's
delivery must point at that reply's row, never the other's (the old
'latest bot message' read-back could swap them)."""
import asyncio
def _agent():
agent = MagicMock()
agent.safe_cleanup_mcp_tools = AsyncMock()
async def _respond(**kwargs):
await asyncio.sleep(0) # let the other turn interleave
text = f"re: {kwargs['message']}"
agent.last_bot_message_id = ChatRepository(db).create_message({
"message": text, "message_type": "bot",
"session_id": kwargs["session_id"], "organization_id": kwargs["org_id"],
"agent_id": kwargs["agent_id"], "customer_id": kwargs["customer_id"],
}).id
await asyncio.sleep(0)
return SimpleNamespace(message=text, transfer_to_human=False,
end_chat=False, request_lead_capture=False)
agent.get_response = AsyncMock(side_effect=_respond)
return agent
deliveries = []
async def _deliver(db_, session, payload, message_id=None, interactive=None, **_):
deliveries.append((payload["message"], message_id))
return DeliveryResult(ok=True)
first, second = make_inbound("first"), make_inbound("second")
second.external_message_id = "43"
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(side_effect=lambda **_: _agent())), \
patch.object(channel_chat, "deliver_to_customer", side_effect=_deliver):
await asyncio.gather(process_channel_message(routed_account.id, first),
process_channel_message(routed_account.id, second))
assert len(deliveries) == 2
for text, message_id in deliveries:
assert db.get(ChatHistory, message_id).message == text