feat: complete phase 4 -- conversation replay API + analytics dashboard
- Replay models: StepType enum, ReplayStep, ReplayPage frozen dataclasses
- Checkpoint transformer: PostgresSaver JSONB -> structured timeline steps
- Replay API: GET /api/conversations (paginated), GET /api/replay/{thread_id}
- Analytics models: AgentUsage, InterruptStats, AnalyticsResult
- Analytics event recorder: Protocol + PostgresAnalyticsRecorder + NoOp
- Analytics queries: resolution_rate, agent_usage, escalation_rate, cost, interrupts
- Analytics API: GET /api/analytics?range=Xd with envelope response
- DB migration: analytics_events table + conversations column additions
- 74 new tests, 399 total passing, 92.87% coverage
This commit is contained in:
134
backend/tests/unit/replay/test_models.py
Normal file
134
backend/tests/unit/replay/test_models.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Unit tests for app.replay.models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestStepType:
|
||||
def test_all_step_types_exist(self) -> None:
|
||||
from app.replay.models import StepType
|
||||
|
||||
assert StepType.user_message
|
||||
assert StepType.supervisor_routing
|
||||
assert StepType.tool_call
|
||||
assert StepType.tool_result
|
||||
assert StepType.agent_response
|
||||
assert StepType.interrupt
|
||||
|
||||
def test_step_type_values(self) -> None:
|
||||
from app.replay.models import StepType
|
||||
|
||||
assert StepType.user_message.value == "user_message"
|
||||
assert StepType.tool_call.value == "tool_call"
|
||||
assert StepType.agent_response.value == "agent_response"
|
||||
|
||||
|
||||
class TestReplayStep:
|
||||
def test_minimal_replay_step(self) -> None:
|
||||
from app.replay.models import ReplayStep, StepType
|
||||
|
||||
step = ReplayStep(step=1, type=StepType.user_message, timestamp="2026-01-01T00:00:00Z")
|
||||
assert step.step == 1
|
||||
assert step.type == StepType.user_message
|
||||
assert step.timestamp == "2026-01-01T00:00:00Z"
|
||||
assert step.content == ""
|
||||
assert step.agent is None
|
||||
assert step.tool is None
|
||||
assert step.params is None
|
||||
assert step.result is None
|
||||
assert step.reasoning is None
|
||||
assert step.tokens is None
|
||||
assert step.duration_ms is None
|
||||
|
||||
def test_full_replay_step(self) -> None:
|
||||
from app.replay.models import ReplayStep, StepType
|
||||
|
||||
step = ReplayStep(
|
||||
step=2,
|
||||
type=StepType.tool_call,
|
||||
timestamp="2026-01-01T00:00:01Z",
|
||||
content="calling get_order",
|
||||
agent="order_agent",
|
||||
tool="get_order_status",
|
||||
params={"order_id": "ORD-123"},
|
||||
result={"status": "shipped"},
|
||||
reasoning="user asked about order",
|
||||
tokens=50,
|
||||
duration_ms=200,
|
||||
)
|
||||
assert step.step == 2
|
||||
assert step.agent == "order_agent"
|
||||
assert step.tool == "get_order_status"
|
||||
assert step.params == {"order_id": "ORD-123"}
|
||||
assert step.tokens == 50
|
||||
|
||||
def test_replay_step_is_frozen(self) -> None:
|
||||
from app.replay.models import ReplayStep, StepType
|
||||
|
||||
step = ReplayStep(step=1, type=StepType.user_message, timestamp="2026-01-01T00:00:00Z")
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
step.step = 99 # type: ignore[misc]
|
||||
|
||||
def test_replay_step_params_is_immutable_copy(self) -> None:
|
||||
from app.replay.models import ReplayStep, StepType
|
||||
|
||||
params = {"key": "value"}
|
||||
step = ReplayStep(
|
||||
step=1,
|
||||
type=StepType.tool_call,
|
||||
timestamp="2026-01-01T00:00:00Z",
|
||||
params=params,
|
||||
)
|
||||
# Modifying original dict should not affect step
|
||||
params["new_key"] = "new_value"
|
||||
assert "new_key" not in (step.params or {})
|
||||
|
||||
|
||||
class TestReplayPage:
|
||||
def test_replay_page_construction(self) -> None:
|
||||
from app.replay.models import ReplayPage, ReplayStep, StepType
|
||||
|
||||
steps = (
|
||||
ReplayStep(step=1, type=StepType.user_message, timestamp="2026-01-01T00:00:00Z"),
|
||||
ReplayStep(step=2, type=StepType.agent_response, timestamp="2026-01-01T00:00:01Z"),
|
||||
)
|
||||
page = ReplayPage(
|
||||
thread_id="thread-123",
|
||||
total_steps=2,
|
||||
page=1,
|
||||
per_page=20,
|
||||
steps=steps,
|
||||
)
|
||||
assert page.thread_id == "thread-123"
|
||||
assert page.total_steps == 2
|
||||
assert page.page == 1
|
||||
assert page.per_page == 20
|
||||
assert len(page.steps) == 2
|
||||
|
||||
def test_replay_page_is_frozen(self) -> None:
|
||||
from app.replay.models import ReplayPage
|
||||
|
||||
page = ReplayPage(
|
||||
thread_id="t1",
|
||||
total_steps=0,
|
||||
page=1,
|
||||
per_page=20,
|
||||
steps=(),
|
||||
)
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
page.page = 2 # type: ignore[misc]
|
||||
|
||||
def test_replay_page_empty_steps(self) -> None:
|
||||
from app.replay.models import ReplayPage
|
||||
|
||||
page = ReplayPage(
|
||||
thread_id="t1",
|
||||
total_steps=0,
|
||||
page=1,
|
||||
per_page=20,
|
||||
steps=(),
|
||||
)
|
||||
assert page.steps == ()
|
||||
Reference in New Issue
Block a user