fix: improve maintenance and synchronize live conversations
This commit is contained in:
1 parent
bb541bd212
commit
ef86a5d3af
297 files changed
+2983
-1274
No files matched your search
@@ -31,7 +31,7 @@ from app.channels.app_hooks import (
|
||||
from app.channels.base import InboundMessage
|
||||
from app.models.channels import ChannelConversation
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.session_to_agent import SessionToAgent
|
||||
from app.repositories.channels import AgentChannelConfigRepository, ChannelAccountRepository
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.services import agent_rooms, app_first_routing, channel_chat
|
||||
|
||||
@@ -16,7 +16,6 @@ limitations under the License.
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -28,7 +27,6 @@ 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
|
||||
|
||||
@@ -252,7 +252,7 @@ async def test_receipt_reaches_the_handling_agents_inbox(db, account, test_organ
|
||||
organization_id=test_organization.id, channel="whatsapp")
|
||||
session.user_id = test_user.id
|
||||
db.commit()
|
||||
message = ChatRepository(db).create_message({
|
||||
ChatRepository(db).create_message({
|
||||
"message": "Your session is at 5pm", "message_type": "agent",
|
||||
"session_id": str(session.session_id), "organization_id": str(test_organization.id),
|
||||
"external_message_id": "wamid.1",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import asyncio
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agents import chat_agent as chat_agent_module
|
||||
from app.api import widget_chat
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.permission import Permission
|
||||
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||||
from app.services import agent_rooms
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conversation(db, test_agent, test_customer, test_organization):
|
||||
chat = SessionToAgent(
|
||||
session_id=uuid4(), agent_id=test_agent.id, customer_id=test_customer.id,
|
||||
organization_id=test_organization.id, status=SessionStatus.OPEN,
|
||||
)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
return chat
|
||||
|
||||
|
||||
def grant(db, role, name):
|
||||
permission = Permission(name=name)
|
||||
db.add(permission)
|
||||
role.permissions = [*role.permissions, permission]
|
||||
db.commit()
|
||||
|
||||
|
||||
def socket_for(user):
|
||||
return {'user_id': str(user.id), 'organization_id': str(user.organization_id)}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_room_enforces_org_scope_and_current_permissions(db, test_user, test_role, conversation):
|
||||
room = f'chat_watch_{conversation.session_id}'
|
||||
socket = socket_for(test_user)
|
||||
assert not await agent_rooms.may_watch_chat(db, socket, room)
|
||||
grant(db, test_role, 'view_unassigned_chats')
|
||||
assert await agent_rooms.may_watch_chat(db, socket, room)
|
||||
assert not await agent_rooms.may_watch_chat(db, {**socket, 'organization_id': str(uuid4())}, room)
|
||||
assert not await agent_rooms.may_watch_chat(db, socket, 'chat_watch_invalid')
|
||||
|
||||
# A viewer of the AI queue loses access after another person takes it.
|
||||
conversation.user_id = uuid4()
|
||||
db.commit()
|
||||
assert not await agent_rooms.may_watch_chat(db, socket, room)
|
||||
test_user.is_active = False
|
||||
db.commit()
|
||||
assert not await agent_rooms.may_watch_chat(db, socket, room)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assigned_reader_can_watch_own_but_not_another_users_chat(db, test_user, test_role, conversation):
|
||||
grant(db, test_role, 'view_assigned_chats')
|
||||
room = f'chat_watch_{conversation.session_id}'
|
||||
conversation.user_id = test_user.id
|
||||
db.commit()
|
||||
assert await agent_rooms.may_watch_chat(db, socket_for(test_user), room)
|
||||
conversation.user_id = uuid4()
|
||||
db.commit()
|
||||
assert not await agent_rooms.may_watch_chat(db, socket_for(test_user), room)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_join_uses_the_same_scope_check(db, test_user, test_role, conversation, monkeypatch):
|
||||
grant(db, test_role, 'view_unassigned_chats')
|
||||
sio = SimpleNamespace(
|
||||
get_session=AsyncMock(return_value=socket_for(test_user)),
|
||||
enter_room=AsyncMock(), emit=AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(widget_chat, 'sio', sio)
|
||||
monkeypatch.setattr(widget_chat, 'get_db', lambda: iter([db]))
|
||||
room = f'chat_watch_{conversation.session_id}'
|
||||
await widget_chat.handle_join_room('sid', {'session_id': room})
|
||||
sio.enter_room.assert_awaited_once_with('sid', room, namespace='/agent')
|
||||
conversation.user_id = uuid4()
|
||||
db.commit()
|
||||
await widget_chat.handle_join_room('sid', {'session_id': room})
|
||||
assert sio.enter_room.await_count == 1
|
||||
assert sio.emit.await_args.args[0] == 'error'
|
||||
|
||||
|
||||
def test_organization_room_is_limited_to_active_chat_viewers(db, test_user, test_role):
|
||||
room = f'org_chat_updates_{test_user.organization_id}'
|
||||
assert not agent_rooms.may_join_chat_updates(db, socket_for(test_user), room)
|
||||
grant(db, test_role, 'view_assigned_chats')
|
||||
assert agent_rooms.may_join_chat_updates(db, socket_for(test_user), room)
|
||||
assert not agent_rooms.may_join_chat_updates(db, socket_for(test_user), f'org_chat_updates_{uuid4()}')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_contains_no_message_or_session_data_and_broker_failure_does_not_break_chat(monkeypatch):
|
||||
emit = AsyncMock(side_effect=[RuntimeError('broker unavailable'), None])
|
||||
monkeypatch.setattr(agent_rooms.sio, 'emit', emit)
|
||||
monkeypatch.setattr(agent_rooms, '_INBOX_COALESCE_SECONDS', 0.001)
|
||||
await agent_rooms.notify_conversation_changed('org-1', 'session-1')
|
||||
await agent_rooms._pending_inbox_updates['org-1'].task
|
||||
assert emit.await_count == 2
|
||||
assert emit.await_args_list[0].args == ('chat_changed', {})
|
||||
assert emit.await_args_list[0].kwargs == {'room': 'chat_watch_session-1', 'namespace': '/agent'}
|
||||
assert emit.await_args_list[1].args == ('inbox_changed', {})
|
||||
assert emit.await_args_list[1].kwargs == {'room': 'org_chat_updates_org-1', 'namespace': '/agent'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_broadcast_coalesces_a_burst_and_keeps_changes_during_publish(monkeypatch):
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
calls = []
|
||||
|
||||
async def emit(event, data, **kwargs):
|
||||
calls.append(event)
|
||||
if event == 'inbox_changed' and calls.count('inbox_changed') == 1:
|
||||
started.set()
|
||||
await release.wait()
|
||||
|
||||
monkeypatch.setattr(agent_rooms.sio, 'emit', emit)
|
||||
monkeypatch.setattr(agent_rooms, '_INBOX_COALESCE_SECONDS', 0.001)
|
||||
await asyncio.gather(*(agent_rooms.notify_conversation_changed('org-1', 'session-1') for _ in range(1000)))
|
||||
await started.wait()
|
||||
assert calls.count('inbox_changed') == 1
|
||||
await agent_rooms.notify_conversation_changed('org-1', 'session-1')
|
||||
task = agent_rooms._pending_inbox_updates['org-1'].task
|
||||
release.set()
|
||||
await task
|
||||
assert calls.count('chat_changed') == 1001
|
||||
assert calls.count('inbox_changed') == 2
|
||||
assert 'org-1' not in agent_rooms._pending_inbox_updates
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_customer_and_bot_are_notified_after_each_commit_before_any_model_call(
|
||||
db, test_agent, test_customer, test_organization, conversation, monkeypatch
|
||||
):
|
||||
agent = object.__new__(chat_agent_module.ChatAgent)
|
||||
agent.agent = SimpleNamespace(session_id=None)
|
||||
agent._guardrail_ctx = None
|
||||
agent._guardrail_surface = None
|
||||
verdict = SimpleNamespace(block=True, reply='synthetic blocked reply', as_attributes=lambda: {})
|
||||
monkeypatch.setattr(chat_agent_module, 'check_inbound', lambda *args, **kwargs: verdict)
|
||||
monkeypatch.setattr(chat_agent_module, 'SessionLocal', lambda: nullcontext(db))
|
||||
seen = []
|
||||
|
||||
async def on_change(org_id, session_id):
|
||||
seen.append([row.message_type for row in db.query(ChatHistory).order_by(ChatHistory.id).all()])
|
||||
|
||||
monkeypatch.setattr(agent_rooms, 'notify_conversation_changed', on_change)
|
||||
response = await agent.get_response(
|
||||
message='synthetic visitor', session_id=str(conversation.session_id),
|
||||
org_id=str(test_organization.id), agent_id=str(test_agent.id), customer_id=str(test_customer.id),
|
||||
)
|
||||
assert response.message == 'synthetic blocked reply'
|
||||
assert seen == [['user'], ['user', 'bot']]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_customer_is_visible_while_normal_ai_response_is_still_running(
|
||||
db, test_agent, test_customer, test_organization, conversation, monkeypatch
|
||||
):
|
||||
agent = object.__new__(chat_agent_module.ChatAgent)
|
||||
agent.agent = SimpleNamespace(session_id=None)
|
||||
agent._guardrail_ctx = None
|
||||
agent._guardrail_surface = None
|
||||
agent.knowledge_tool = None
|
||||
agent.agent_data = None
|
||||
agent.transfer_to_human = False
|
||||
agent._use_groq_json_tool = False
|
||||
agent._groq_json_capture = {}
|
||||
agent._response_model = chat_agent_module.ChatResponse
|
||||
verdict = SimpleNamespace(block=False, as_attributes=lambda: {})
|
||||
monkeypatch.setattr(chat_agent_module, 'check_inbound', lambda *args, **kwargs: verdict)
|
||||
monkeypatch.setattr(chat_agent_module, 'check_output', lambda message, **kwargs: (message, None))
|
||||
monkeypatch.setattr(chat_agent_module, 'SessionLocal', lambda: nullcontext(db))
|
||||
response = chat_agent_module.ChatResponse(
|
||||
message='synthetic answer', transfer_to_human=False, end_chat=False,
|
||||
request_rating=False, create_ticket=False,
|
||||
)
|
||||
monkeypatch.setattr(chat_agent_module, 'parse_response_content', lambda *args: response)
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def run_model(*args):
|
||||
started.set()
|
||||
await release.wait()
|
||||
return object()
|
||||
|
||||
agent._arun = run_model
|
||||
seen = []
|
||||
|
||||
async def on_change(org_id, session_id):
|
||||
seen.append([row.message_type for row in db.query(ChatHistory).order_by(ChatHistory.id).all()])
|
||||
|
||||
monkeypatch.setattr(agent_rooms, 'notify_conversation_changed', on_change)
|
||||
task = asyncio.create_task(agent.get_response(
|
||||
message='synthetic question', session_id=str(conversation.session_id),
|
||||
org_id=str(test_organization.id), agent_id=str(test_agent.id), customer_id=str(test_customer.id),
|
||||
))
|
||||
await started.wait()
|
||||
assert seen == [['user']]
|
||||
release.set()
|
||||
result = await task
|
||||
assert result.message == 'synthetic answer'
|
||||
assert seen == [['user'], ['user', 'bot']]
|
||||
@@ -17,8 +17,7 @@ Article-mode import tests: link discovery bounds, HTML→Markdown conversion
|
||||
(link absolutization, image placeholder/re-host, data: skip), and the
|
||||
end-to-end job with mocked fetches.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -16,9 +16,7 @@ limitations under the License.
|
||||
|
||||
import pytest
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services.file_upload_service import (
|
||||
@@ -344,7 +342,7 @@ class TestFileUploadServiceUpload:
|
||||
async def test_upload_file_local_write_failure(self, sample_file_data):
|
||||
"""Test handling of local file write failure"""
|
||||
with patch('app.services.file_upload_service.settings') as mock_settings, \
|
||||
patch('os.makedirs') as mock_makedirs, \
|
||||
patch('os.makedirs'), \
|
||||
patch('builtins.open', create=True) as mock_open:
|
||||
|
||||
mock_settings.S3_FILE_STORAGE = False
|
||||
|
||||
@@ -14,15 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
from app.services.sentiment import (
|
||||
analyze_sentiment,
|
||||
compute_session_sentiment,
|
||||
_analyze_with_keywords,
|
||||
_analyze_with_textblob,
|
||||
POSITIVE_KEYWORDS,
|
||||
NEGATIVE_KEYWORDS,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,12 +15,11 @@ limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock, ANY
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app.services.shopify import ShopifyService
|
||||
from app.models.shopify import ShopifyShop
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
|
||||
@@ -18,7 +18,7 @@ import pytest
|
||||
import jwt
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import pytest
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.rating import Rating
|
||||
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||||
from app.models.ticket import Ticket, TicketPriority, TicketSource, TicketStatus
|
||||
from app.models.ticket import Ticket, TicketPriority, TicketSource
|
||||
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||||
from app.repositories.rating import RatingRepository
|
||||
from app.services.ticket import TicketService
|
||||
|
||||
@@ -22,9 +22,8 @@ from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.investigation import InvestigationRun, InvestigationRunType, InvestigationTrigger
|
||||
from app.models.investigation import InvestigationRun, InvestigationRunType
|
||||
from app.models.ticket import Ticket, TicketPriority, TicketSource, TicketStatus
|
||||
from app.models.ticket_activity import TicketActivity
|
||||
from app.repositories.ticket import InvestigationRepository
|
||||
|
||||
@@ -24,8 +24,8 @@ from uuid import uuid4
|
||||
import pytest
|
||||
|
||||
from app.api.ticket_webhooks import _parse_alert
|
||||
from app.models.investigation import ProposalStatus, TicketProposal
|
||||
from app.models.ticket import Ticket, TicketPriority, TicketSource, TicketStatus
|
||||
from app.models.investigation import ProposalStatus
|
||||
from app.models.ticket import TicketSource, TicketStatus
|
||||
from app.models.ticket_activity import TicketActivity, TicketActivityType
|
||||
from app.services.ticket import TicketService
|
||||
from app.services.ticket_jira import should_escalate
|
||||
@@ -214,7 +214,6 @@ class TestRunChaining:
|
||||
enqueueing the next one in the same session must work — the guard
|
||||
has to flush before checking for active runs."""
|
||||
from app.models.investigation import (
|
||||
InvestigationRun,
|
||||
InvestigationRunStatus,
|
||||
InvestigationRunType,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -28,7 +29,7 @@ from app.models.ticket import (
|
||||
TicketSource,
|
||||
TicketStatus,
|
||||
)
|
||||
from app.models.ticket_activity import TicketActivity, TicketActivityType, TicketActorType
|
||||
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||||
from app.services.ticket import NATIVE_INTEGRATION_TYPE, TicketService, render_customer_template
|
||||
|
||||
|
||||
@@ -315,6 +316,34 @@ class TestCustomerEmailPath:
|
||||
row = db.query(ChatHistory).filter_by(session_id=test_session.session_id).one()
|
||||
assert deliver.await_args.kwargs["message_id"] == row.id
|
||||
assert row.attributes["delivery_status"] == "window_expired"
|
||||
assert ticket.first_response_at is None
|
||||
assert TicketActivityType.CUSTOMER_NOTIFIED.value not in [
|
||||
activity.activity_type
|
||||
for activity in service.activity_repo.list_for_ticket(ticket.id)
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refused_channel_reply_preserves_an_existing_first_response(
|
||||
self, service, db, test_organization, test_session
|
||||
):
|
||||
test_session.channel = "whatsapp"
|
||||
db.commit()
|
||||
ticket = make_ticket(service, db, test_organization, session_id=test_session.session_id)
|
||||
prior_response = datetime(2026, 10, 1, tzinfo=timezone.utc)
|
||||
ticket.first_response_at = prior_response
|
||||
db.commit()
|
||||
db.refresh(ticket)
|
||||
prior_response = ticket.first_response_at
|
||||
refused = DeliveryResult(ok=False, reason="window_expired", can_template=True)
|
||||
|
||||
with patch("app.services.ticket.deliver_to_customer", return_value=refused):
|
||||
await service.send_customer_message(ticket, "Your refund is on its way")
|
||||
|
||||
assert ticket.first_response_at == prior_response
|
||||
assert TicketActivityType.CUSTOMER_NOTIFIED.value not in [
|
||||
activity.activity_type
|
||||
for activity in service.activity_repo.list_for_ticket(ticket.id)
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_send_that_raises_is_marked_undelivered(
|
||||
|
||||
@@ -19,7 +19,6 @@ from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
from app.services.workflow_execution import WorkflowExecutionService
|
||||
from app.models.workflow_node import WorkflowNode, NodeType, ExitCondition
|
||||
from app.agents.chat_agent import ChatResponse
|
||||
from app.models.workflow import Workflow, WorkflowStatus
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from uuid import uuid4, UUID
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.workflow_node import WorkflowNodeService, sanitize_utf8_text
|
||||
@@ -587,7 +587,7 @@ class TestWorkflowNodeService:
|
||||
}
|
||||
]
|
||||
|
||||
result = workflow_node_service.replace_workflow_nodes_and_connections(
|
||||
workflow_node_service.replace_workflow_nodes_and_connections(
|
||||
test_workflow.id, nodes_data, [], test_organization_id
|
||||
)
|
||||
|
||||
@@ -635,7 +635,7 @@ class TestWorkflowNodeService:
|
||||
"description": "Updated\udcac description\udd16"
|
||||
}
|
||||
|
||||
result = workflow_node_service.update_single_node(
|
||||
workflow_node_service.update_single_node(
|
||||
test_workflow_node.workflow_id,
|
||||
test_workflow_node.id,
|
||||
update_data,
|
||||
|
||||
@@ -16,9 +16,9 @@ limitations under the License.
|
||||
|
||||
import pytest
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
from app.services.workflow import WorkflowService
|
||||
from app.models.workflow import Workflow, WorkflowStatus
|
||||
from app.models.workflow import WorkflowStatus
|
||||
from app.models.schemas.workflow import WorkflowCreate
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user