- Intent classification with LLM structured output (single/multi/ambiguous) - Discount agent with apply_discount and generate_coupon tools - Interrupt manager with 30-min TTL auto-expiration and retry prompts - Webhook escalation module with exponential backoff retry (max 3) - Three vertical industry templates (e-commerce, SaaS, fintech) - Template loading in AgentRegistry - Enhanced supervisor prompt with dynamic agent descriptions - 153 tests passing, 90.18% coverage
170 lines
5.8 KiB
Python
170 lines
5.8 KiB
Python
"""Tests for app.escalation module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from app.escalation import (
|
|
EscalationPayload,
|
|
EscalationResult,
|
|
NoOpEscalator,
|
|
WebhookEscalator,
|
|
)
|
|
|
|
|
|
def _make_payload(**kwargs) -> EscalationPayload:
|
|
defaults = {
|
|
"thread_id": "t1",
|
|
"reason": "Agent cannot resolve",
|
|
"conversation_summary": "User asked about refund policy",
|
|
}
|
|
defaults.update(kwargs)
|
|
return EscalationPayload(**defaults)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestEscalationPayload:
|
|
def test_frozen(self) -> None:
|
|
payload = _make_payload()
|
|
with pytest.raises(Exception):
|
|
payload.thread_id = "t2" # type: ignore[misc]
|
|
|
|
def test_default_metadata(self) -> None:
|
|
payload = _make_payload()
|
|
assert payload.metadata == {}
|
|
|
|
def test_model_dump(self) -> None:
|
|
payload = _make_payload(metadata={"key": "val"})
|
|
data = payload.model_dump()
|
|
assert data["thread_id"] == "t1"
|
|
assert data["metadata"] == {"key": "val"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestEscalationResult:
|
|
def test_frozen(self) -> None:
|
|
result = EscalationResult(success=True, status_code=200, attempts=1, error=None)
|
|
assert result.success
|
|
assert result.status_code == 200
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWebhookEscalator:
|
|
@pytest.mark.asyncio
|
|
async def test_empty_url_returns_failure(self) -> None:
|
|
escalator = WebhookEscalator(url="", max_retries=3)
|
|
result = await escalator.escalate(_make_payload())
|
|
assert not result.success
|
|
assert result.attempts == 0
|
|
assert "not configured" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_successful_post(self) -> None:
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("app.escalation.httpx.AsyncClient", return_value=mock_client):
|
|
escalator = WebhookEscalator(url="https://example.com/hook")
|
|
result = await escalator.escalate(_make_payload())
|
|
|
|
assert result.success
|
|
assert result.status_code == 200
|
|
assert result.attempts == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_on_server_error(self) -> None:
|
|
fail_response = AsyncMock()
|
|
fail_response.status_code = 500
|
|
success_response = AsyncMock()
|
|
success_response.status_code = 200
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(side_effect=[fail_response, fail_response, success_response])
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with (
|
|
patch("app.escalation.httpx.AsyncClient", return_value=mock_client),
|
|
patch("app.escalation.asyncio.sleep", new_callable=AsyncMock),
|
|
):
|
|
escalator = WebhookEscalator(url="https://example.com/hook", max_retries=3)
|
|
result = await escalator.escalate(_make_payload())
|
|
|
|
assert result.success
|
|
assert result.attempts == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_retries_exhausted(self) -> None:
|
|
fail_response = AsyncMock()
|
|
fail_response.status_code = 500
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=fail_response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with (
|
|
patch("app.escalation.httpx.AsyncClient", return_value=mock_client),
|
|
patch("app.escalation.asyncio.sleep", new_callable=AsyncMock),
|
|
):
|
|
escalator = WebhookEscalator(url="https://example.com/hook", max_retries=3)
|
|
result = await escalator.escalate(_make_payload())
|
|
|
|
assert not result.success
|
|
assert result.attempts == 3
|
|
assert "500" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_error(self) -> None:
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with (
|
|
patch("app.escalation.httpx.AsyncClient", return_value=mock_client),
|
|
patch("app.escalation.asyncio.sleep", new_callable=AsyncMock),
|
|
):
|
|
escalator = WebhookEscalator(url="https://example.com/hook", max_retries=2)
|
|
result = await escalator.escalate(_make_payload())
|
|
|
|
assert not result.success
|
|
assert "timed out" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_error(self) -> None:
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(
|
|
side_effect=httpx.RequestError("connection refused")
|
|
)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with (
|
|
patch("app.escalation.httpx.AsyncClient", return_value=mock_client),
|
|
patch("app.escalation.asyncio.sleep", new_callable=AsyncMock),
|
|
):
|
|
escalator = WebhookEscalator(url="https://example.com/hook", max_retries=1)
|
|
result = await escalator.escalate(_make_payload())
|
|
|
|
assert not result.success
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNoOpEscalator:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_disabled(self) -> None:
|
|
escalator = NoOpEscalator()
|
|
result = await escalator.escalate(_make_payload())
|
|
assert not result.success
|
|
assert result.attempts == 0
|
|
assert "disabled" in result.error.lower()
|