import json import pytest import services.asterisk_bridge_service.voice_ai as voice_ai from services.routing_service import engine as routing_engine from services.shared.core import new_id, utc_now_iso from services.shared.db import get_session from services.shared.sql_init import init_sql_schema from services.shared.sql_models import AgentRow, AsteriskCallLinkRow, EscalationRow def _make_agent(session, *, extension: str, status: str = "AVAILABLE"): now = utc_now_iso() row = AgentRow( agent_id=new_id("agt"), tenant_ids_json="[]", extension=extension, endpoint=None, display_name=f"Agent {extension}", level="L2", skills_json="[]", status=status, max_concurrent_calls=1, enabled=True, created_at=now, updated_at=now, ) session.add(row) session.commit() session.refresh(row) return row def _make_call_link(session, *, call_id: str): now = utc_now_iso() link = AsteriskCallLinkRow( call_id=call_id, linked_id=call_id, queue_code="voice_lab_ai", queue_id="que_test", interaction_id="int_test", status="active", telephony_status="ringing", channel_name=f"PJSIP/tele2-kazgaz-{call_id}", current_level="L2", started_at=now, updated_at=now, ) session.add(link) session.commit() session.refresh(link) return link def _make_ringing_escalation(session, *, call_id: str, agent: AgentRow): now = utc_now_iso() escalation = EscalationRow( escalation_id=new_id("esc"), call_id=call_id, tenant_id=None, from_level="L1", to_level="L2", reason_code="AI_UNABLE_TO_RESOLVE", required_skills_json="[]", priority=3, status="ringing", assigned_agent_id=agent.extension, real_agent_id=agent.agent_id, attempted_agent_ids_json=json.dumps([agent.agent_id]), requested_at=now, ) session.add(escalation) session.commit() session.refresh(escalation) return escalation def _patch_routing_over_http(monkeypatch, session): """Make voice_ai's HTTP-facing routing helpers operate on the same test session directly.""" def fake_reserve(*, call_id, level, tenant_id, required_skills=None, exclude_agent_ids=None): agent = routing_engine.reserve_agent( session, call_id=call_id, level=level, tenant_id=tenant_id, required_skills=required_skills, exclude_agent_ids=exclude_agent_ids, ) if agent is None: return None return { "agent_id": agent.agent_id, "extension": agent.extension, "endpoint": agent.endpoint, "display_name": agent.display_name, } def fake_release_by_agent_id(agent_id, *, next_status="AVAILABLE"): routing_engine.release_agent_by_id(session, agent_id=agent_id, next_status=next_status) def fake_set_status(agent_id, status): routing_engine.set_agent_status(session, agent_id=agent_id, status=status) monkeypatch.setattr(voice_ai, "_reserve_routing_agent", fake_reserve) monkeypatch.setattr(voice_ai, "routing_release_by_agent_id", fake_release_by_agent_id) monkeypatch.setattr(voice_ai, "set_routing_agent_status", fake_set_status) monkeypatch.setattr(voice_ai, "_append_escalation_timeline", lambda *a, **k: None) monkeypatch.setattr(voice_ai, "_emit_escalation_event", lambda *a, **k: None) monkeypatch.setattr(voice_ai, "_resolve_handoff_channel", lambda session, link: link.channel_name) def test_retry_escalation_no_answer_moves_to_next_available_agent(monkeypatch): init_sql_schema() redirected_to: list[str] = [] session = get_session() try: _patch_routing_over_http(monkeypatch, session) monkeypatch.setattr( voice_ai, "_redirect_channel_to_agent", lambda *, channel, extension: redirected_to.append(extension), ) agent_a = _make_agent(session, extension="2001") agent_b = _make_agent(session, extension="2002") routing_engine.reserve_agent(session, call_id="call-retry-1", level="L2", tenant_id=None, required_skills=[]) link = _make_call_link(session, call_id="call-retry-1") escalation = _make_ringing_escalation(session, call_id="call-retry-1", agent=agent_a) voice_ai.retry_escalation_no_answer(session, call_id="call-retry-1", dial_outcome="NOANSWER") session.refresh(escalation) session.refresh(agent_a) session.refresh(agent_b) assert escalation.attempt_count == 1 assert escalation.status == "ringing" assert escalation.real_agent_id == agent_b.agent_id assert escalation.assigned_agent_id == agent_b.extension assert json.loads(escalation.attempted_agent_ids_json) == [agent_a.agent_id, agent_b.agent_id] assert agent_a.status == "AVAILABLE" assert agent_a.current_call_id is None assert agent_b.status == "RINGING" assert agent_b.current_call_id == "call-retry-1" assert redirected_to == [agent_b.extension] finally: session.close() def test_retry_escalation_no_answer_exhausts_pool_marks_failed(monkeypatch): init_sql_schema() session = get_session() try: _patch_routing_over_http(monkeypatch, session) monkeypatch.setattr(voice_ai, "_redirect_channel_to_agent", lambda **kwargs: None) agent_a = _make_agent(session, extension="3001") routing_engine.reserve_agent(session, call_id="call-retry-2", level="L2", tenant_id=None, required_skills=[]) link = _make_call_link(session, call_id="call-retry-2") escalation = _make_ringing_escalation(session, call_id="call-retry-2", agent=agent_a) voice_ai.retry_escalation_no_answer(session, call_id="call-retry-2", dial_outcome="NOANSWER") session.refresh(escalation) session.refresh(agent_a) assert escalation.attempt_count == 1 assert escalation.status == "failed" assert agent_a.status == "AVAILABLE" finally: session.close() def test_retry_escalation_no_answer_ignores_calls_without_ringing_escalation(monkeypatch): init_sql_schema() called = [] session = get_session() try: _patch_routing_over_http(monkeypatch, session) monkeypatch.setattr(voice_ai, "_redirect_channel_to_agent", lambda **kwargs: called.append(kwargs)) _make_call_link(session, call_id="call-no-escalation") voice_ai.retry_escalation_no_answer(session, call_id="call-no-escalation", dial_outcome="NOANSWER") assert called == [] finally: session.close()