fix: improve maintenance and synchronize live conversations

This commit is contained in:
WangShen007 committed 2026-10-29 09:46:21 +08:00
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
+1 -5
View File
@@ -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
+1 -1
View File
@@ -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
+2 -3
View File
@@ -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,
)
+30 -1
View File
@@ -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
+3 -3
View File
@@ -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