from datetime import UTC, datetime from typing import Any import pytest from app.core.contracts import AgentResult, CoreResult, IntentResult from app.model.conversation import ConversationMessage from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket from app.service.agent_persistence_service import AgentPersistenceService class FakeTransaction: async def __aenter__(self) -> None: return None async def __aexit__( self, exc_type: object, exc_value: object, traceback: object ) -> bool: return False class FakeSession: """仅收集持久化服务在同一事务内计划写入的 ORM 实体。""" def __init__(self, run: AgentRun) -> None: self.run = run self.added: list[Any] = [] self.executed: list[Any] = [] def begin(self) -> FakeTransaction: return FakeTransaction() async def scalar(self, statement: object) -> AgentRun: return self.run def add(self, item: Any) -> None: self.added.append(item) async def flush(self) -> None: for item in self.added: if isinstance(item, ConversationMessage) and item.id is None: item.id = 901 async def execute(self, statement: object) -> None: self.executed.append(statement) def queued_run() -> AgentRun: now = datetime.now(UTC).replace(tzinfo=None) return AgentRun( id=1, run_id="run-transfer-1", idempotency_id=2, session_id="session-transfer-1", user_id=7, agent_type="customer_service", trace_id="trace-transfer-1", request_message_id=800, status="queued", created_at=now, updated_at=now, ) def result(*, transfer_required: bool) -> AgentResult: return AgentResult( run_id="run-transfer-1", result=CoreResult( text="已为您转接人工客服。", intent=IntentResult(intent="human_handover", confidence=1), transfer_required=transfer_required, transfer_reason="user_requested" if transfer_required else None, ), ) def added_of(items: list[Any], model: type[Any]) -> list[Any]: return [item for item in items if isinstance(item, model)] @pytest.mark.asyncio async def test_transfer_required_result_creates_pending_ticket_and_outbox_event() -> None: """Agent 发起的转人工必须在完成运行的事务内留待处理工单和通知事件。""" session = FakeSession(queued_run()) await AgentPersistenceService(session).complete_run( "run-transfer-1", result(transfer_required=True), memory_extraction_requested=False ) tickets = added_of(session.added, HandoverTicket) events = added_of(session.added, DomainEventOutbox) assert len(tickets) == 1 assert tickets[0].status == "pending" assert tickets[0].session_id == "session-transfer-1" assert tickets[0].customer_id == 7 assert tickets[0].source_agent == "customer_service" assert tickets[0].source_message_id == 901 assert tickets[0].reason_code == "user_requested" assert len(events) == 2 # agent.run_completed + conversation.transfer_requested transfer_event = next( event for event in events if event.event_type == "conversation.transfer_requested" ) assert transfer_event.aggregate_type == "conversation" assert transfer_event.aggregate_id == "session-transfer-1" assert transfer_event.payload["ticket_no"] == tickets[0].ticket_no @pytest.mark.asyncio async def test_normal_result_does_not_create_handover_ticket_or_event() -> None: """非转人工回答不得污染管理员待处理队列。""" session = FakeSession(queued_run()) await AgentPersistenceService(session).complete_run( "run-transfer-1", result(transfer_required=False), memory_extraction_requested=False ) assert added_of(session.added, HandoverTicket) == [] assert all( event.event_type != "conversation.transfer_requested" for event in added_of(session.added, DomainEventOutbox) )