Compare commits
3 Commits
189a0fad34
...
036e12349d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
036e12349d | ||
|
|
e0931daece | ||
|
|
e55ec42ae5 |
21
README.md
21
README.md
@@ -128,11 +128,24 @@ agents:
|
|||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| WS | `/ws` | Main WebSocket chat endpoint |
|
| WS | `/ws` | Main WebSocket chat endpoint |
|
||||||
| GET | `/api/health` | Health check |
|
| GET | `/api/health` | Health check |
|
||||||
| GET | `/api/conversations` | List conversations |
|
| GET | `/api/conversations` | List conversations (paginated) |
|
||||||
| GET | `/api/replay/{thread_id}` | Replay conversation |
|
| GET | `/api/replay/{thread_id}` | Replay conversation steps (paginated) |
|
||||||
| GET | `/api/analytics` | Analytics summary |
|
| GET | `/api/analytics` | Analytics summary (`?range=7d`) |
|
||||||
| POST | `/api/openapi/import` | Import OpenAPI spec |
|
| POST | `/api/openapi/import` | Start OpenAPI import job |
|
||||||
| GET | `/api/openapi/jobs/{id}` | Check import job status |
|
| GET | `/api/openapi/jobs/{id}` | Check import job status |
|
||||||
|
| GET | `/api/openapi/jobs/{id}/classifications` | Get endpoint classifications |
|
||||||
|
| PUT | `/api/openapi/jobs/{id}/classifications/{idx}` | Update a classification |
|
||||||
|
| POST | `/api/openapi/jobs/{id}/approve` | Approve and generate tools |
|
||||||
|
|
||||||
|
## Safety and Confirmation Rules
|
||||||
|
|
||||||
|
Destructive-action confirmation is explicit and auditable (see `backend/app/safety.py`):
|
||||||
|
|
||||||
|
- **Read actions** execute immediately -- no confirmation required.
|
||||||
|
- **Write actions** require human-in-the-loop approval via an interrupt gate.
|
||||||
|
- **OpenAPI-imported endpoints** use the `needs_interrupt` classification flag.
|
||||||
|
- **Multi-intent handling** is sequential: if a write action is blocked by an interrupt, subsequent actions are paused until the interrupt is resolved or rejected.
|
||||||
|
- **MCP errors** are classified into `transient` (retryable, up to 3 attempts), `validation` (not retryable), `auth` (not retryable, escalate), and `unknown` (not retryable, log and escalate).
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["replay"])
|
router = APIRouter(prefix="/api", tags=["replay"])
|
||||||
|
|
||||||
|
_COUNT_CONVERSATIONS_SQL = """
|
||||||
|
SELECT COUNT(*) FROM conversations
|
||||||
|
"""
|
||||||
|
|
||||||
_LIST_CONVERSATIONS_SQL = """
|
_LIST_CONVERSATIONS_SQL = """
|
||||||
SELECT thread_id, created_at, last_activity, status, total_tokens, total_cost_usd
|
SELECT thread_id, created_at, last_activity, status, total_tokens, total_cost_usd
|
||||||
FROM conversations
|
FROM conversations
|
||||||
@@ -48,13 +52,22 @@ async def list_conversations(
|
|||||||
pool = await get_pool(request)
|
pool = await get_pool(request)
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
async with pool.connection() as conn:
|
async with pool.connection() as conn:
|
||||||
|
count_cursor = await conn.execute(_COUNT_CONVERSATIONS_SQL)
|
||||||
|
count_row = await count_cursor.fetchone()
|
||||||
|
total = count_row[0] if count_row else 0
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
_LIST_CONVERSATIONS_SQL,
|
_LIST_CONVERSATIONS_SQL,
|
||||||
{"limit": per_page, "offset": offset},
|
{"limit": per_page, "offset": offset},
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
return _envelope([dict(row) for row in rows])
|
return _envelope({
|
||||||
|
"conversations": [dict(row) for row in rows],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/replay/{thread_id}")
|
@router.get("/replay/{thread_id}")
|
||||||
|
|||||||
131
backend/app/safety.py
Normal file
131
backend/app/safety.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Safety policy for destructive-action confirmation rules.
|
||||||
|
|
||||||
|
This module makes the confirmation rules explicit and auditable. Every tool
|
||||||
|
call passes through ``requires_confirmation`` before execution to decide
|
||||||
|
whether human-in-the-loop approval is needed.
|
||||||
|
|
||||||
|
Policy summary
|
||||||
|
--------------
|
||||||
|
- ``read`` actions: execute immediately, no confirmation required.
|
||||||
|
- ``write`` actions: require human approval via interrupt gate.
|
||||||
|
- OpenAPI-imported endpoints: use ``needs_interrupt`` from classification.
|
||||||
|
- If both the agent permission AND the endpoint classification agree
|
||||||
|
the action is read-only, it executes without confirmation.
|
||||||
|
|
||||||
|
Multi-intent semantics
|
||||||
|
----------------------
|
||||||
|
When a user message contains multiple intents (e.g. "cancel my order and
|
||||||
|
apply a refund"), the supervisor routes them sequentially. Each action is
|
||||||
|
evaluated independently:
|
||||||
|
- If a write action is blocked by an interrupt, subsequent actions in the
|
||||||
|
same message are paused until the interrupt is resolved.
|
||||||
|
- Read actions that follow a blocked write are also paused (sequential,
|
||||||
|
not best-effort) to preserve causal ordering.
|
||||||
|
- If an interrupt is rejected, the remaining actions are skipped and the
|
||||||
|
agent informs the user.
|
||||||
|
|
||||||
|
MCP error taxonomy
|
||||||
|
------------------
|
||||||
|
Tool execution errors are classified into categories for retry decisions:
|
||||||
|
|
||||||
|
- ``transient``: network timeouts, rate limits, 5xx -- retryable up to 3 times.
|
||||||
|
- ``validation``: bad parameters, 4xx -- not retryable, report to user.
|
||||||
|
- ``auth``: 401/403 -- not retryable, escalate.
|
||||||
|
- ``unknown``: unclassified -- not retryable, log and escalate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfirmationPolicy:
|
||||||
|
"""Result of evaluating whether an action needs confirmation."""
|
||||||
|
|
||||||
|
requires_confirmation: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def requires_confirmation(
|
||||||
|
*,
|
||||||
|
agent_permission: Literal["read", "write"],
|
||||||
|
needs_interrupt: bool | None = None,
|
||||||
|
) -> ConfirmationPolicy:
|
||||||
|
"""Determine whether an action requires human confirmation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
agent_permission:
|
||||||
|
The permission level of the agent executing the action.
|
||||||
|
needs_interrupt:
|
||||||
|
Override from OpenAPI classification. When ``None``, the decision
|
||||||
|
is based solely on ``agent_permission``.
|
||||||
|
"""
|
||||||
|
if needs_interrupt is not None:
|
||||||
|
if needs_interrupt:
|
||||||
|
return ConfirmationPolicy(
|
||||||
|
requires_confirmation=True,
|
||||||
|
reason="Endpoint classified as requiring human approval",
|
||||||
|
)
|
||||||
|
return ConfirmationPolicy(
|
||||||
|
requires_confirmation=False,
|
||||||
|
reason="Endpoint classified as safe (no interrupt needed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
if agent_permission == "write":
|
||||||
|
return ConfirmationPolicy(
|
||||||
|
requires_confirmation=True,
|
||||||
|
reason="Write-permission agent actions require confirmation",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConfirmationPolicy(
|
||||||
|
requires_confirmation=False,
|
||||||
|
reason="Read-only agent actions execute immediately",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- MCP Error Taxonomy ---
|
||||||
|
|
||||||
|
|
||||||
|
MCP_ERROR_CATEGORY = Literal["transient", "validation", "auth", "unknown"]
|
||||||
|
|
||||||
|
_TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
|
||||||
|
_AUTH_STATUS_CODES = frozenset({401, 403})
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def classify_mcp_error(
|
||||||
|
*,
|
||||||
|
status_code: int | None = None,
|
||||||
|
error_message: str = "",
|
||||||
|
) -> MCP_ERROR_CATEGORY:
|
||||||
|
"""Classify an MCP tool error for retry decisions."""
|
||||||
|
if status_code is not None:
|
||||||
|
if status_code in _TRANSIENT_STATUS_CODES:
|
||||||
|
return "transient"
|
||||||
|
if status_code in _AUTH_STATUS_CODES:
|
||||||
|
return "auth"
|
||||||
|
if 400 <= status_code < 500:
|
||||||
|
return "validation"
|
||||||
|
|
||||||
|
lower_msg = error_message.lower()
|
||||||
|
if any(kw in lower_msg for kw in ("timeout", "timed out", "rate limit")):
|
||||||
|
return "transient"
|
||||||
|
if any(kw in lower_msg for kw in ("unauthorized", "forbidden")):
|
||||||
|
return "auth"
|
||||||
|
if any(kw in lower_msg for kw in ("invalid", "missing", "bad request")):
|
||||||
|
return "validation"
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def is_retryable(category: MCP_ERROR_CATEGORY) -> bool:
|
||||||
|
"""Return whether a given error category is retryable."""
|
||||||
|
return category == "transient"
|
||||||
|
|
||||||
|
|
||||||
|
def max_retries() -> int:
|
||||||
|
"""Maximum retry attempts for transient errors."""
|
||||||
|
return _MAX_RETRIES
|
||||||
@@ -107,6 +107,9 @@ class FakeCursor:
|
|||||||
async def fetchall(self) -> list[dict]:
|
async def fetchall(self) -> list[dict]:
|
||||||
return self._rows
|
return self._rows
|
||||||
|
|
||||||
|
async def fetchone(self) -> tuple | dict | None:
|
||||||
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
|
||||||
class FakeConnection:
|
class FakeConnection:
|
||||||
"""Fake async connection that returns a FakeCursor."""
|
"""Fake async connection that returns a FakeCursor."""
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class ReplayPool(FakePool):
|
|||||||
async def execute(self, query: str, params=None):
|
async def execute(self, query: str, params=None):
|
||||||
from tests.e2e.conftest import FakeCursor
|
from tests.e2e.conftest import FakeCursor
|
||||||
|
|
||||||
|
if "COUNT" in query and "conversations" in query:
|
||||||
|
return FakeCursor([(len(self._convos),)])
|
||||||
if "conversations" in query and "SELECT" in query:
|
if "conversations" in query and "SELECT" in query:
|
||||||
return FakeCursor(self._convos)
|
return FakeCursor(self._convos)
|
||||||
if "checkpoints" in query:
|
if "checkpoints" in query:
|
||||||
@@ -94,9 +96,11 @@ class TestFlow6ReplayConversation:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["success"] is True
|
assert body["success"] is True
|
||||||
assert len(body["data"]) == 2
|
data = body["data"]
|
||||||
assert body["data"][0]["thread_id"] == "conv-001"
|
assert len(data["conversations"]) == 2
|
||||||
assert body["data"][1]["thread_id"] == "conv-002"
|
assert data["conversations"][0]["thread_id"] == "conv-001"
|
||||||
|
assert data["conversations"][1]["thread_id"] == "conv-002"
|
||||||
|
assert data["total"] == 2
|
||||||
|
|
||||||
def test_list_conversations_pagination(self) -> None:
|
def test_list_conversations_pagination(self) -> None:
|
||||||
conversations = [
|
conversations = [
|
||||||
@@ -206,7 +210,8 @@ class TestFullUserJourney:
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["success"] is True
|
assert body["success"] is True
|
||||||
assert any(
|
assert any(
|
||||||
c["thread_id"] == "e2e-journey-1" for c in body["data"]
|
c["thread_id"] == "e2e-journey-1"
|
||||||
|
for c in body["data"]["conversations"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Health check still works
|
# Step 3: Health check still works
|
||||||
|
|||||||
@@ -145,4 +145,11 @@ class TestPostgresAnalyticsRecorder:
|
|||||||
)
|
)
|
||||||
call_args = mock_conn.execute.call_args
|
call_args = mock_conn.execute.call_args
|
||||||
params = call_args[0][1]
|
params = call_args[0][1]
|
||||||
assert params["metadata"] == {"key": "val"}
|
# PostgresAnalyticsRecorder wraps metadata with psycopg Json() adapter.
|
||||||
|
# Unwrap to compare the inner dict.
|
||||||
|
from psycopg.types.json import Json
|
||||||
|
|
||||||
|
meta = params["metadata"]
|
||||||
|
if isinstance(meta, Json):
|
||||||
|
meta = meta.obj
|
||||||
|
assert meta == {"key": "val"}
|
||||||
|
|||||||
@@ -19,13 +19,35 @@ def _build_app() -> FastAPI:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_pool(fetchall_result: list[dict]) -> MagicMock:
|
def _make_mock_pool(
|
||||||
"""Build a mock pool that returns the given rows from fetchall."""
|
fetchall_result: list[dict],
|
||||||
mock_cursor = AsyncMock()
|
*,
|
||||||
mock_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
count: int | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Build a mock pool that returns the given rows from fetchall.
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
When *count* is provided, the first execute() call returns a cursor
|
||||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
whose fetchone() yields ``(count,)`` (for the COUNT query) and the
|
||||||
|
second call returns the rows via fetchall(). When *count* is None
|
||||||
|
(the default), a single cursor backed by *fetchall_result* is used
|
||||||
|
for all calls.
|
||||||
|
"""
|
||||||
|
if count is not None:
|
||||||
|
count_cursor = AsyncMock()
|
||||||
|
count_cursor.fetchone = AsyncMock(return_value=(count,))
|
||||||
|
|
||||||
|
rows_cursor = AsyncMock()
|
||||||
|
rows_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.execute = AsyncMock(side_effect=[count_cursor, rows_cursor])
|
||||||
|
else:
|
||||||
|
mock_cursor = AsyncMock()
|
||||||
|
mock_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
||||||
|
mock_cursor.fetchone = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||||
|
|
||||||
mock_ctx = AsyncMock()
|
mock_ctx = AsyncMock()
|
||||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_conn)
|
mock_ctx.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
@@ -39,14 +61,17 @@ def _make_mock_pool(fetchall_result: list[dict]) -> MagicMock:
|
|||||||
class TestListConversations:
|
class TestListConversations:
|
||||||
def test_returns_200_with_empty_list(self) -> None:
|
def test_returns_200_with_empty_list(self) -> None:
|
||||||
app = _build_app()
|
app = _build_app()
|
||||||
app.state.pool = _make_mock_pool([])
|
app.state.pool = _make_mock_pool([], count=0)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
resp = client.get("/api/conversations")
|
resp = client.get("/api/conversations")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["success"] is True
|
assert body["success"] is True
|
||||||
assert isinstance(body["data"], list)
|
data = body["data"]
|
||||||
|
assert isinstance(data["conversations"], list)
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["page"] == 1
|
||||||
assert body["error"] is None
|
assert body["error"] is None
|
||||||
|
|
||||||
def test_returns_conversations_list(self) -> None:
|
def test_returns_conversations_list(self) -> None:
|
||||||
@@ -61,18 +86,20 @@ class TestListConversations:
|
|||||||
"total_cost_usd": 0.01,
|
"total_cost_usd": 0.01,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
app.state.pool = _make_mock_pool(mock_rows)
|
app.state.pool = _make_mock_pool(mock_rows, count=1)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
resp = client.get("/api/conversations")
|
resp = client.get("/api/conversations")
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert len(body["data"]) == 1
|
data = body["data"]
|
||||||
assert body["data"][0]["thread_id"] == "t1"
|
assert len(data["conversations"]) == 1
|
||||||
|
assert data["conversations"][0]["thread_id"] == "t1"
|
||||||
|
assert data["total"] == 1
|
||||||
|
|
||||||
def test_pagination_defaults(self) -> None:
|
def test_pagination_defaults(self) -> None:
|
||||||
app = _build_app()
|
app = _build_app()
|
||||||
app.state.pool = _make_mock_pool([])
|
app.state.pool = _make_mock_pool([], count=0)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
resp = client.get("/api/conversations")
|
resp = client.get("/api/conversations")
|
||||||
@@ -80,7 +107,7 @@ class TestListConversations:
|
|||||||
|
|
||||||
def test_pagination_custom_params(self) -> None:
|
def test_pagination_custom_params(self) -> None:
|
||||||
app = _build_app()
|
app = _build_app()
|
||||||
app.state.pool = _make_mock_pool([])
|
app.state.pool = _make_mock_pool([], count=0)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
resp = client.get("/api/conversations?page=2&per_page=10")
|
resp = client.get("/api/conversations?page=2&per_page=10")
|
||||||
@@ -88,7 +115,7 @@ class TestListConversations:
|
|||||||
|
|
||||||
def test_per_page_max_capped_at_100(self) -> None:
|
def test_per_page_max_capped_at_100(self) -> None:
|
||||||
app = _build_app()
|
app = _build_app()
|
||||||
app.state.pool = _make_mock_pool([])
|
app.state.pool = _make_mock_pool([], count=0)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
resp = client.get("/api/conversations?per_page=200")
|
resp = client.get("/api/conversations?per_page=200")
|
||||||
|
|||||||
@@ -7,10 +7,41 @@ import pytest
|
|||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _isolated_settings(**kwargs: object) -> Settings:
|
||||||
|
"""Create a Settings instance that ignores .env files and process env vars.
|
||||||
|
|
||||||
|
pydantic-settings reads from env_file and environment by default, which
|
||||||
|
causes test results to depend on the machine they run on. We override
|
||||||
|
model_config at the class level temporarily so that every test gets
|
||||||
|
deterministic results.
|
||||||
|
"""
|
||||||
|
# Build a throwaway subclass that disables env-file and env-var loading.
|
||||||
|
class _IsolatedSettings(Settings):
|
||||||
|
model_config = Settings.model_config.copy()
|
||||||
|
model_config["env_file"] = None # type: ignore[assignment]
|
||||||
|
model_config["env_ignore_empty"] = True
|
||||||
|
|
||||||
|
# _env_parse_none_str makes pydantic-settings treat missing env vars as
|
||||||
|
# absent rather than empty-string, so required fields will raise.
|
||||||
|
import os
|
||||||
|
|
||||||
|
env_backup = os.environ.copy()
|
||||||
|
# Strip all env vars that Settings knows about so they can't leak in.
|
||||||
|
settings_fields = set(Settings.model_fields)
|
||||||
|
for key in list(os.environ):
|
||||||
|
if key.lower() in settings_fields:
|
||||||
|
del os.environ[key]
|
||||||
|
try:
|
||||||
|
return _IsolatedSettings(**kwargs) # type: ignore[return-value]
|
||||||
|
finally:
|
||||||
|
os.environ.clear()
|
||||||
|
os.environ.update(env_backup)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestSettings:
|
class TestSettings:
|
||||||
def test_default_values(self) -> None:
|
def test_default_values(self) -> None:
|
||||||
settings = Settings(
|
settings = _isolated_settings(
|
||||||
database_url="postgresql://x:x@localhost/db",
|
database_url="postgresql://x:x@localhost/db",
|
||||||
anthropic_api_key="key",
|
anthropic_api_key="key",
|
||||||
)
|
)
|
||||||
@@ -20,7 +51,7 @@ class TestSettings:
|
|||||||
assert settings.interrupt_ttl_minutes == 30
|
assert settings.interrupt_ttl_minutes == 30
|
||||||
|
|
||||||
def test_custom_values(self) -> None:
|
def test_custom_values(self) -> None:
|
||||||
settings = Settings(
|
settings = _isolated_settings(
|
||||||
database_url="postgresql://x:x@localhost/db",
|
database_url="postgresql://x:x@localhost/db",
|
||||||
llm_provider="openai",
|
llm_provider="openai",
|
||||||
llm_model="gpt-4o",
|
llm_model="gpt-4o",
|
||||||
@@ -33,18 +64,18 @@ class TestSettings:
|
|||||||
|
|
||||||
def test_invalid_provider_rejected(self) -> None:
|
def test_invalid_provider_rejected(self) -> None:
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
Settings(
|
_isolated_settings(
|
||||||
database_url="postgresql://x:x@localhost/db",
|
database_url="postgresql://x:x@localhost/db",
|
||||||
llm_provider="invalid",
|
llm_provider="invalid",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_missing_database_url_rejected(self) -> None:
|
def test_missing_database_url_rejected(self) -> None:
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
Settings(anthropic_api_key="key")
|
_isolated_settings(anthropic_api_key="key")
|
||||||
|
|
||||||
def test_empty_api_key_for_provider_rejected(self) -> None:
|
def test_empty_api_key_for_provider_rejected(self) -> None:
|
||||||
with pytest.raises(ValueError, match="API key"):
|
with pytest.raises(ValueError, match="API key"):
|
||||||
Settings(
|
_isolated_settings(
|
||||||
database_url="postgresql://x:x@localhost/db",
|
database_url="postgresql://x:x@localhost/db",
|
||||||
llm_provider="anthropic",
|
llm_provider="anthropic",
|
||||||
anthropic_api_key="",
|
anthropic_api_key="",
|
||||||
@@ -52,7 +83,7 @@ class TestSettings:
|
|||||||
|
|
||||||
def test_wrong_provider_key_rejected(self) -> None:
|
def test_wrong_provider_key_rejected(self) -> None:
|
||||||
with pytest.raises(ValueError, match="API key"):
|
with pytest.raises(ValueError, match="API key"):
|
||||||
Settings(
|
_isolated_settings(
|
||||||
database_url="postgresql://x:x@localhost/db",
|
database_url="postgresql://x:x@localhost/db",
|
||||||
llm_provider="openai",
|
llm_provider="openai",
|
||||||
anthropic_api_key="key",
|
anthropic_api_key="key",
|
||||||
|
|||||||
85
backend/tests/unit/test_safety.py
Normal file
85
backend/tests/unit/test_safety.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for app.safety module -- confirmation rules and MCP error taxonomy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.safety import (
|
||||||
|
classify_mcp_error,
|
||||||
|
is_retryable,
|
||||||
|
max_retries,
|
||||||
|
requires_confirmation,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequiresConfirmation:
|
||||||
|
def test_read_agent_no_override(self) -> None:
|
||||||
|
result = requires_confirmation(agent_permission="read")
|
||||||
|
assert result.requires_confirmation is False
|
||||||
|
|
||||||
|
def test_write_agent_no_override(self) -> None:
|
||||||
|
result = requires_confirmation(agent_permission="write")
|
||||||
|
assert result.requires_confirmation is True
|
||||||
|
|
||||||
|
def test_interrupt_override_true(self) -> None:
|
||||||
|
result = requires_confirmation(
|
||||||
|
agent_permission="read", needs_interrupt=True,
|
||||||
|
)
|
||||||
|
assert result.requires_confirmation is True
|
||||||
|
|
||||||
|
def test_interrupt_override_false(self) -> None:
|
||||||
|
result = requires_confirmation(
|
||||||
|
agent_permission="write", needs_interrupt=False,
|
||||||
|
)
|
||||||
|
assert result.requires_confirmation is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyMcpError:
|
||||||
|
@pytest.mark.parametrize("code", [408, 429, 500, 502, 503, 504])
|
||||||
|
def test_transient_status_codes(self, code: int) -> None:
|
||||||
|
assert classify_mcp_error(status_code=code) == "transient"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", [401, 403])
|
||||||
|
def test_auth_status_codes(self, code: int) -> None:
|
||||||
|
assert classify_mcp_error(status_code=code) == "auth"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", [400, 404, 422])
|
||||||
|
def test_validation_status_codes(self, code: int) -> None:
|
||||||
|
assert classify_mcp_error(status_code=code) == "validation"
|
||||||
|
|
||||||
|
def test_unknown_status_code(self) -> None:
|
||||||
|
assert classify_mcp_error(status_code=200) == "unknown"
|
||||||
|
|
||||||
|
def test_timeout_message(self) -> None:
|
||||||
|
assert classify_mcp_error(error_message="Connection timed out") == "transient"
|
||||||
|
|
||||||
|
def test_rate_limit_message(self) -> None:
|
||||||
|
assert classify_mcp_error(error_message="Rate limit exceeded") == "transient"
|
||||||
|
|
||||||
|
def test_unauthorized_message(self) -> None:
|
||||||
|
assert classify_mcp_error(error_message="Unauthorized access") == "auth"
|
||||||
|
|
||||||
|
def test_invalid_message(self) -> None:
|
||||||
|
assert classify_mcp_error(error_message="Invalid parameter") == "validation"
|
||||||
|
|
||||||
|
def test_unknown_message(self) -> None:
|
||||||
|
assert classify_mcp_error(error_message="Something happened") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryPolicy:
|
||||||
|
def test_transient_is_retryable(self) -> None:
|
||||||
|
assert is_retryable("transient") is True
|
||||||
|
|
||||||
|
def test_validation_not_retryable(self) -> None:
|
||||||
|
assert is_retryable("validation") is False
|
||||||
|
|
||||||
|
def test_auth_not_retryable(self) -> None:
|
||||||
|
assert is_retryable("auth") is False
|
||||||
|
|
||||||
|
def test_unknown_not_retryable(self) -> None:
|
||||||
|
assert is_retryable("unknown") is False
|
||||||
|
|
||||||
|
def test_max_retries_value(self) -> None:
|
||||||
|
assert max_retries() == 3
|
||||||
@@ -10,13 +10,11 @@ export interface ApiResponse<T> {
|
|||||||
|
|
||||||
export interface ConversationSummary {
|
export interface ConversationSummary {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
started_at: string;
|
created_at: string;
|
||||||
last_activity: string;
|
last_activity: string;
|
||||||
turn_count: number;
|
status: string | null;
|
||||||
agents_used: string[];
|
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
total_cost_usd: number;
|
total_cost_usd: number;
|
||||||
resolution_type: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConversationsPage {
|
export interface ConversationsPage {
|
||||||
@@ -39,17 +37,16 @@ export interface ReplayStep {
|
|||||||
|
|
||||||
export interface ReplayPage {
|
export interface ReplayPage {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
steps: ReplayStep[];
|
total_steps: number;
|
||||||
total: number;
|
|
||||||
page: number;
|
page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
|
steps: ReplayStep[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentUsage {
|
export interface AgentUsage {
|
||||||
agent_name: string;
|
agent: string;
|
||||||
message_count: number;
|
count: number;
|
||||||
total_tokens: number;
|
percentage: number;
|
||||||
total_cost_usd: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InterruptStats {
|
export interface InterruptStats {
|
||||||
@@ -60,14 +57,12 @@ export interface InterruptStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalyticsData {
|
export interface AnalyticsData {
|
||||||
|
range: string;
|
||||||
total_conversations: number;
|
total_conversations: number;
|
||||||
resolved_conversations: number;
|
|
||||||
escalated_conversations: number;
|
|
||||||
resolution_rate: number;
|
resolution_rate: number;
|
||||||
escalation_rate: number;
|
escalation_rate: number;
|
||||||
total_tokens: number;
|
|
||||||
total_cost_usd: number;
|
|
||||||
avg_turns_per_conversation: number;
|
avg_turns_per_conversation: number;
|
||||||
|
avg_cost_per_conversation_usd: number;
|
||||||
agent_usage: AgentUsage[];
|
agent_usage: AgentUsage[];
|
||||||
interrupt_stats: InterruptStats;
|
interrupt_stats: InterruptStats;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -658,6 +658,140 @@ body {
|
|||||||
border-color: var(--text-primary);
|
border-color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Shared Data Display Components --- */
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--resolved {
|
||||||
|
background-color: #DEF7EC;
|
||||||
|
color: #03543F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--escalated {
|
||||||
|
background-color: #FDE8E8;
|
||||||
|
color: #9B1C1C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--active {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead tr {
|
||||||
|
border-bottom: 2px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__description {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state__title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand-accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state__description {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--bg-surface-inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Skeleton Loading Animation --- */
|
/* --- Skeleton Loading Animation --- */
|
||||||
@keyframes pulse-skeleton {
|
@keyframes pulse-skeleton {
|
||||||
0% { opacity: 0.5; background-color: var(--bg-hover); }
|
0% { opacity: 0.5; background-color: var(--bg-hover); }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { fetchAnalytics, AnalyticsData } from "../api";
|
||||||
|
|
||||||
const RANGE_OPTIONS = [
|
const RANGE_OPTIONS = [
|
||||||
{ value: "7d", label: "7 days" },
|
{ value: "7d", label: "7 days" },
|
||||||
@@ -6,36 +7,19 @@ const RANGE_OPTIONS = [
|
|||||||
{ value: "30d", label: "30 days" },
|
{ value: "30d", label: "30 days" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock Data
|
|
||||||
const MOCK_DATA = {
|
|
||||||
total_conversations: 4208,
|
|
||||||
resolution_rate: 0.724,
|
|
||||||
escalation_rate: 0.276,
|
|
||||||
avg_turns_per_conversation: 3.4,
|
|
||||||
total_tokens: 1450200,
|
|
||||||
total_cost_usd: 12.45,
|
|
||||||
agent_usage: [
|
|
||||||
{ agent_name: "Order Specialist", message_count: 8540, total_tokens: 854000, total_cost_usd: 7.20 },
|
|
||||||
{ agent_name: "Billing Assistant", message_count: 3120, total_tokens: 412000, total_cost_usd: 3.50 },
|
|
||||||
{ agent_name: "Router & Orchestrator", message_count: 4208, total_tokens: 184200, total_cost_usd: 1.75 },
|
|
||||||
],
|
|
||||||
interrupt_stats: {
|
|
||||||
total: 412,
|
|
||||||
approved: 380,
|
|
||||||
rejected: 28,
|
|
||||||
expired: 4,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const [range, setRange] = useState("30d");
|
const [range, setRange] = useState("30d");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const data = MOCK_DATA;
|
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const timer = setTimeout(() => setIsLoading(false), 1200);
|
setError(null);
|
||||||
return () => clearTimeout(timer);
|
fetchAnalytics(range)
|
||||||
|
.then((result) => setData(result))
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
}, [range]);
|
}, [range]);
|
||||||
|
|
||||||
function pct(value: number): string {
|
function pct(value: number): string {
|
||||||
@@ -80,8 +64,8 @@ export function DashboardPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
|
||||||
{[1, 2, 3, 4, 5].map(i => (
|
{[1, 2, 3, 4].map(i => (
|
||||||
<div key={i} className="skeleton-box" style={{ height: "120px", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", background: "var(--bg-surface)" }}>
|
<div key={i} className="skeleton-box section-card" style={{ height: "120px" }}>
|
||||||
<div className="skeleton-text" style={{ width: "60%", height: "12px", marginBottom: "1.5rem" }}></div>
|
<div className="skeleton-text" style={{ width: "60%", height: "12px", marginBottom: "1.5rem" }}></div>
|
||||||
<div className="skeleton-text" style={{ width: "40%", height: "30px", marginBottom: "1rem" }}></div>
|
<div className="skeleton-text" style={{ width: "40%", height: "30px", marginBottom: "1rem" }}></div>
|
||||||
<div className="skeleton-text" style={{ width: "80%", height: "12px" }}></div>
|
<div className="skeleton-text" style={{ width: "80%", height: "12px" }}></div>
|
||||||
@@ -93,42 +77,56 @@ export function DashboardPage() {
|
|||||||
<div className="skeleton-box" style={{ height: "300px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
|
<div className="skeleton-box" style={{ height: "300px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<div className="error-state">
|
||||||
|
<p className="error-state__title">Failed to load analytics</p>
|
||||||
|
<p className="error-state__description">{error}</p>
|
||||||
|
<button onClick={() => setRange(range)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button>
|
||||||
|
</div>
|
||||||
|
) : !data ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p className="empty-state__title">No analytics data available</p>
|
||||||
|
<p className="empty-state__description">Start some conversations to see metrics here.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
|
||||||
<MetricBox label="Tickets Processed" value={data.total_conversations.toLocaleString()} trend="+12% vs last month" />
|
<MetricBox label="Tickets Processed" value={data.total_conversations.toLocaleString()} trend={`Range: ${data.range}`} />
|
||||||
<MetricBox label="Auto-Resolution Rate" value={pct(data.resolution_rate)} trend="Target: 70%" positive />
|
<MetricBox label="Auto-Resolution Rate" value={pct(data.resolution_rate)} trend="Target: 70%" positive={data.resolution_rate >= 0.7} />
|
||||||
<MetricBox label="Human Escalations" value={pct(data.escalation_rate)} trend="Avg 28%" />
|
<MetricBox label="Human Escalations" value={pct(data.escalation_rate)} trend="Lower is better" />
|
||||||
<MetricBox label="Human-in-the-Loop Prompts" value={data.interrupt_stats.total.toLocaleString()} trend="High Risk Actions Intercepted" />
|
<MetricBox label="Avg Cost / Conversation" value={formatCost(data.avg_cost_per_conversation_usd)} trend={`${data.avg_turns_per_conversation.toFixed(1)} avg turns`} />
|
||||||
<MetricBox label="LLM Intelligence Cost" value={formatCost(data.total_cost_usd)} trend={`${(data.total_tokens / 1000).toLocaleString()}k Tokens`} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}>
|
||||||
{/* Agent Workload Table */}
|
{/* Agent Workload Table */}
|
||||||
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}>
|
<div className="section-card">
|
||||||
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: "0 0 1rem 0" }}>Agent Workload Distribution</h3>
|
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: "0 0 1rem 0" }}>Agent Workload Distribution</h3>
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
|
{data.agent_usage.length === 0 ? (
|
||||||
<thead>
|
<p style={{ color: "var(--text-secondary)", fontSize: "0.875rem" }}>No agent activity recorded yet.</p>
|
||||||
<tr style={{ borderBottom: "2px solid var(--border-light)" }}>
|
) : (
|
||||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Agent Name</th>
|
<table className="data-table">
|
||||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Actions Handled</th>
|
<thead>
|
||||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Cost Footprint</th>
|
<tr>
|
||||||
</tr>
|
<th style={{ paddingLeft: 0 }}>Agent Name</th>
|
||||||
</thead>
|
<th>Message Count</th>
|
||||||
<tbody>
|
<th>Share</th>
|
||||||
{data.agent_usage.map((a) => (
|
|
||||||
<tr key={a.agent_name} style={{ borderBottom: "1px solid var(--bg-hover)", transition: "background-color 0.2s" }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
|
|
||||||
<td style={{ padding: "1rem 0 1rem 1rem", fontWeight: 600, fontSize: "0.9375rem" }}>{a.agent_name}</td>
|
|
||||||
<td style={{ padding: "1rem 0", fontSize: "0.9375rem" }}>{a.message_count.toLocaleString()}</td>
|
|
||||||
<td style={{ padding: "1rem 1rem 1rem 0", fontSize: "0.9375rem" }}>{formatCost(a.total_cost_usd)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{data.agent_usage.map((a) => (
|
||||||
|
<tr key={a.agent}>
|
||||||
|
<td style={{ paddingLeft: 0, fontWeight: 600 }}>{a.agent}</td>
|
||||||
|
<td>{a.count.toLocaleString()}</td>
|
||||||
|
<td>{pct(a.percentage)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Human in the loop card */}
|
{/* Human in the loop card */}
|
||||||
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}>
|
<div className="section-card">
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" }}>
|
||||||
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: 0 }}>Security Approvals</h3>
|
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: 0 }}>Security Approvals</h3>
|
||||||
<span title="Actions requiring human review before proceeding" style={{ cursor: "help", color: "var(--text-secondary)", fontSize: "0.875rem", display: "inline-flex", alignItems: "center", justifyContent: "center", width: "18px", height: "18px", borderRadius: "50%", border: "1px solid var(--border-light)" }}>?</span>
|
<span title="Actions requiring human review before proceeding" style={{ cursor: "help", color: "var(--text-secondary)", fontSize: "0.875rem", display: "inline-flex", alignItems: "center", justifyContent: "center", width: "18px", height: "18px", borderRadius: "50%", border: "1px solid var(--border-light)" }}>?</span>
|
||||||
@@ -137,23 +135,27 @@ export function DashboardPage() {
|
|||||||
Breakdown of supervisor responses to High-Risk Action Cards dynamically requested by Agents.
|
Breakdown of supervisor responses to High-Risk Action Cards dynamically requested by Agents.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
{data.interrupt_stats.total === 0 ? (
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<p style={{ color: "var(--text-secondary)", fontSize: "0.875rem" }}>No interrupt events recorded yet.</p>
|
||||||
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Approved</span>
|
) : (
|
||||||
<span style={{ color: "#059669", fontWeight: 700 }}>{data.interrupt_stats.approved}</span>
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
</div>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
|
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Approved</span>
|
||||||
<div style={{ width: `${(data.interrupt_stats.approved / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#059669" }} />
|
<span style={{ color: "#059669", fontWeight: 700 }}>{data.interrupt_stats.approved}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
|
||||||
|
<div style={{ width: `${(data.interrupt_stats.approved / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#059669" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "0.5rem" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "0.5rem" }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Rejected (Escalated)</span>
|
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Rejected (Escalated)</span>
|
||||||
<span style={{ color: "#DC2626", fontWeight: 700 }}>{data.interrupt_stats.rejected}</span>
|
<span style={{ color: "#DC2626", fontWeight: 700 }}>{data.interrupt_stats.rejected}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
|
||||||
|
<div style={{ width: `${(data.interrupt_stats.rejected / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#DC2626" }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
|
)}
|
||||||
<div style={{ width: `${(data.interrupt_stats.rejected / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#DC2626" }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -165,24 +167,10 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
function MetricBox({ label, value, trend, positive }: { label: string, value: string | number, trend: string, positive?: boolean }) {
|
function MetricBox({ label, value, trend, positive }: { label: string, value: string | number, trend: string, positive?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="section-card" style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
backgroundColor: "var(--bg-surface)",
|
<div className="stat-label">{label}</div>
|
||||||
padding: "1.5rem",
|
<div style={{ fontSize: "2rem", fontWeight: 700, color: "var(--text-primary)" }}>{value}</div>
|
||||||
borderRadius: "var(--radius-xl)",
|
<div style={{ fontSize: "0.8125rem", color: positive ? "#059669" : "var(--text-secondary)", fontWeight: positive ? 600 : 400 }}>{trend}</div>
|
||||||
border: "1px solid var(--border-light)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "0.5rem"
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: "0.8125rem", color: "var(--text-secondary)", textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600 }}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "2rem", fontWeight: 700, color: "var(--text-primary)" }}>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.8125rem", color: positive ? "#059669" : "var(--text-secondary)", fontWeight: positive ? 600 : 400 }}>
|
|
||||||
{trend}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +1,132 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { fetchConversations, ConversationSummary } from "../api";
|
||||||
// Mock Data
|
|
||||||
const MOCK_CONVERSATIONS = [
|
|
||||||
{ thread_id: "th_9281ja8s9", user: "Maria G.", intent: "Cancel Order #8921", date: "2 mins ago", turns: 4, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.02" },
|
|
||||||
{ thread_id: "th_1092jf8u1", user: "David C.", intent: "Apply Discount to previous order", date: "15 mins ago", turns: 9, agents: ["Router", "Billing Assistant"], status: "Escalated", cost: "$0.08", hitl: true },
|
|
||||||
{ thread_id: "th_0099ab7x2", user: "Sarah L.", intent: "Where is my package?", date: "1 hour ago", turns: 2, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.01" },
|
|
||||||
{ thread_id: "th_5518kc3p0", user: "John M.", intent: "Change shipping address", date: "4 hours ago", turns: 6, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.04" },
|
|
||||||
{ thread_id: "th_1102po9m4", user: "Elena P.", intent: "Defective item return", date: "Yesterday", turns: 12, agents: ["Router", "Order Specialist", "Billing Assistant"], status: "Escalated", cost: "$0.15", hitl: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ReplayListPage() {
|
export function ReplayListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const totalPages = 24;
|
const [perPage] = useState(20);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetchConversations(page, perPage)
|
||||||
|
.then((result) => {
|
||||||
|
setConversations(result.conversations);
|
||||||
|
setTotal(result.total);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [page, perPage]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(usd: number): string {
|
||||||
|
return `$${usd.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: string | null): string {
|
||||||
|
if (status === "resolved") return "status-badge status-badge--resolved";
|
||||||
|
if (status === "escalated") return "status-badge status-badge--escalated";
|
||||||
|
return "status-badge status-badge--active";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
|
<div className="page-header">
|
||||||
<div>
|
<h2>Conversation Replay</h2>
|
||||||
<h2>Conversation Replay</h2>
|
<p>Review autonomous agent sessions and audit MCP action execution trails.</p>
|
||||||
<p>Review autonomous agent sessions and audit MCP action execution trails.</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by Order ID, Thread ID..."
|
|
||||||
style={{
|
|
||||||
padding: "0.625rem 1rem",
|
|
||||||
borderRadius: "8px",
|
|
||||||
border: "1px solid var(--border-light)",
|
|
||||||
backgroundColor: "var(--bg-surface)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
width: "280px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)" }}>
|
{error ? (
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
|
<div className="error-state">
|
||||||
<thead>
|
<p className="error-state__title">Failed to load conversations</p>
|
||||||
<tr style={{ backgroundColor: "var(--bg-surface-inner)", borderBottom: "1px solid var(--border-light)" }}>
|
<p className="error-state__description">{error}</p>
|
||||||
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Thread</th>
|
<button onClick={() => setPage(1)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button>
|
||||||
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Detected Intent</th>
|
</div>
|
||||||
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Agents Invoked</th>
|
) : isLoading ? (
|
||||||
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Outcome</th>
|
<div className="section-card" style={{ padding: "2rem" }}>
|
||||||
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Performance</th>
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
</tr>
|
<div key={i} className="skeleton-box" style={{ height: "60px", marginBottom: "1rem", borderRadius: "8px" }}>
|
||||||
</thead>
|
<div className="skeleton-text" style={{ width: "30%", height: "14px", margin: "12px 16px" }}></div>
|
||||||
<tbody>
|
</div>
|
||||||
{MOCK_CONVERSATIONS.map((c, i) => (
|
))}
|
||||||
<tr
|
</div>
|
||||||
key={c.thread_id}
|
) : conversations.length === 0 ? (
|
||||||
onClick={() => navigate(`/replay/${c.thread_id}`)}
|
<div className="empty-state">
|
||||||
style={{
|
<p className="empty-state__title">No conversations yet</p>
|
||||||
borderBottom: i === MOCK_CONVERSATIONS.length - 1 ? "none" : "1px solid var(--border-light)",
|
<p className="empty-state__description">Start a chat session to see conversations here.</p>
|
||||||
cursor: "pointer",
|
</div>
|
||||||
transition: "background-color 0.2s"
|
) : (
|
||||||
}}
|
<div className="section-card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
className="replay-row-hover"
|
<table className="data-table">
|
||||||
>
|
<thead>
|
||||||
<td style={{ padding: "1.25rem 1.5rem" }}>
|
<tr style={{ backgroundColor: "var(--bg-surface-inner)" }}>
|
||||||
<div style={{ fontWeight: 600, color: "var(--text-primary)", fontSize: "0.9375rem" }}>{c.user}</div>
|
<th>Thread</th>
|
||||||
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)", fontFamily: "monospace", marginTop: "4px" }}>{c.thread_id}</div>
|
<th>Created</th>
|
||||||
</td>
|
<th>Last Activity</th>
|
||||||
<td style={{ padding: "1.25rem 1.5rem" }}>
|
<th>Status</th>
|
||||||
<div style={{ fontWeight: 500, color: "var(--text-primary)", fontSize: "0.9375rem" }}>{c.intent}</div>
|
<th>Cost</th>
|
||||||
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginTop: "4px" }}>{c.date}</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "1.25rem 1.5rem" }}>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
|
|
||||||
{c.agents.map(a => (
|
|
||||||
<span key={a} style={{ fontSize: "0.65rem", padding: "2px 8px", backgroundColor: "var(--bg-app)", border: "1px solid var(--border-light)", borderRadius: "99px", color: "var(--text-secondary)", fontWeight: 600 }}>
|
|
||||||
{a}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "1.25rem 1.5rem" }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
padding: "4px 10px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontWeight: 600,
|
|
||||||
backgroundColor: c.status === "Resolved" ? "#DEF7EC" : "#FDE8E8",
|
|
||||||
color: c.status === "Resolved" ? "#03543F" : "#9B1C1C",
|
|
||||||
}}>
|
|
||||||
{c.status}
|
|
||||||
</span>
|
|
||||||
{c.hitl && <span style={{ marginLeft: "8px", fontSize: "1.25rem" }} title="Human in the loop invoked">🔒</span>}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
|
|
||||||
{c.turns} turns • {c.cost}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{conversations.map((c) => (
|
||||||
|
<tr
|
||||||
|
key={c.thread_id}
|
||||||
|
onClick={() => navigate(`/replay/${c.thread_id}`)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600, fontFamily: "monospace" }}>{c.thread_id}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "var(--text-secondary)" }}>{formatDate(c.created_at)}</td>
|
||||||
|
<td style={{ color: "var(--text-secondary)" }}>{formatDate(c.last_activity)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={statusClass(c.status)}>{c.status ?? "active"}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{c.total_tokens.toLocaleString()} tokens / {formatCost(c.total_cost_usd)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<div style={{ padding: "1.25rem 1.5rem", borderTop: "1px solid var(--border-light)", display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "var(--bg-surface-inner)" }}>
|
<div className="pagination-bar">
|
||||||
<span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>Showing 1-5 of 120 sessions</span>
|
<span className="pagination-bar__info">
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
Showing {(page - 1) * perPage + 1}-{Math.min(page * perPage, total)} of {total} sessions
|
||||||
<button
|
</span>
|
||||||
onClick={(e) => { e.stopPropagation(); setPage(p => Math.max(1, p - 1)) }}
|
<div className="pagination-bar__controls">
|
||||||
disabled={page === 1}
|
<button
|
||||||
className="btn btn-secondary"
|
onClick={(e) => { e.stopPropagation(); setPage(p => Math.max(1, p - 1)) }}
|
||||||
>
|
disabled={page === 1}
|
||||||
Previous
|
className="btn btn-secondary"
|
||||||
</button>
|
>
|
||||||
<button
|
Previous
|
||||||
onClick={(e) => { e.stopPropagation(); setPage(p => Math.min(totalPages, p + 1)) }}
|
</button>
|
||||||
disabled={page >= totalPages}
|
<button
|
||||||
className="btn btn-secondary"
|
onClick={(e) => { e.stopPropagation(); setPage(p => Math.min(totalPages, p + 1)) }}
|
||||||
>
|
disabled={page >= totalPages}
|
||||||
Next
|
className="btn btn-secondary"
|
||||||
</button>
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<style>{`
|
|
||||||
.replay-row-hover:hover {
|
|
||||||
background-color: var(--bg-hover) !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,90 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ReplayTimeline } from "../components/ReplayTimeline";
|
import { ReplayTimeline } from "../components/ReplayTimeline";
|
||||||
|
import { fetchReplay, ReplayStep } from "../api";
|
||||||
const MOCK_STEPS = [
|
|
||||||
{ step: 1, type: "message", timestamp: "2026-04-05T10:00:00Z", agent: "Customer", content: "My laptop arrived with a shattered screen. I need a replacement immediately! Order #8921." },
|
|
||||||
{ step: 2, type: "token", timestamp: "2026-04-05T10:00:02Z", agent: "Router", content: "Intent detected: 'return_request'. Routing to Order Specialist." },
|
|
||||||
{ step: 3, type: "tool_call", timestamp: "2026-04-05T10:00:03Z", agent: "Order Specialist", tool: "get_order_details", params: { order_id: "8921" } },
|
|
||||||
{ step: 4, type: "tool_result", timestamp: "2026-04-05T10:00:04Z", tool: "get_order_details", result: { status: "Delivered", items: ["MacBook Pro 16", "USB-C Hub"], total_value: 2499.00 } },
|
|
||||||
{ step: 5, type: "tool_call", timestamp: "2026-04-05T10:00:06Z", agent: "Order Specialist", tool: "initiate_return", params: { order_id: "8921", reason: "Damaged in transit", replacement: true } },
|
|
||||||
{ step: 6, type: "interrupt", timestamp: "2026-04-05T10:00:06Z", agent: "System", content: "SECURITY POLICY TRIGGERED: High-Value Return (>$1000). Human approval required before initiating RMS workflow." },
|
|
||||||
{ step: 7, type: "interrupt_response", timestamp: "2026-04-05T10:15:22Z", agent: "Alex Thompson (Supervisor)", content: "REJECTED. Standard policy for shattered screens requires photo evidence before dispatching replacement unit." },
|
|
||||||
{ step: 8, type: "message", timestamp: "2026-04-05T10:15:25Z", agent: "Order Specialist", content: "I'm so sorry to hear your laptop screen was shattered! Because this is a high-value item, our policy requires a photo of the damage before we can dispatch your replacement unit. Could you please take a quick picture and upload it here?" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ReplayPage() {
|
export function ReplayPage() {
|
||||||
const { threadId } = useParams<{ threadId: string }>();
|
const { threadId } = useParams<{ threadId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [page, setPage] = useState(1);
|
const [steps, setSteps] = useState<ReplayStep[]>([]);
|
||||||
|
const [totalSteps, setTotalSteps] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threadId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetchReplay(threadId, 1, 100)
|
||||||
|
.then((result) => {
|
||||||
|
setSteps(result.steps);
|
||||||
|
setTotalSteps(result.total_steps);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [threadId]);
|
||||||
|
|
||||||
if (!threadId) return null;
|
if (!threadId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
|
<div className="page-header" style={{ marginBottom: "2rem" }}>
|
||||||
<div>
|
<button
|
||||||
<button
|
onClick={() => navigate("/replay")}
|
||||||
onClick={() => navigate("/replay")}
|
style={{ background: "none", border: "none", color: "var(--text-secondary)", fontSize: "0.875rem", cursor: "pointer", padding: "0 0 0.5rem 0", display: "flex", alignItems: "center", gap: "0.25rem" }}
|
||||||
style={{ background: "none", border: "none", color: "var(--text-secondary)", fontSize: "0.875rem", cursor: "pointer", padding: "0 0 0.5rem 0", display: "flex", alignItems: "center", gap: "0.25rem" }}
|
>
|
||||||
>
|
← Back to All Replays
|
||||||
← Back to All Replays
|
</button>
|
||||||
</button>
|
<h2>Audit Trail: <span style={{ fontFamily: "monospace", color: "var(--brand-primary)" }}>{threadId}</span></h2>
|
||||||
<h2>Audit Trail: <span style={{ fontFamily: "monospace", color: "var(--brand-primary)" }}>{threadId}</span></h2>
|
<p>Detailed temporal log of agent reflections, MCP tool calls, and human overrides.</p>
|
||||||
<p>Detailed temporal log of agent reflections, MCP tool calls, and human overrides.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
|
{error ? (
|
||||||
{/* Sidebar Summary Info */}
|
<div className="error-state">
|
||||||
<div style={{ backgroundColor: "var(--bg-surface)", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", alignSelf: "start" }}>
|
<p className="error-state__title">Failed to load replay</p>
|
||||||
<h3 style={{ fontSize: "1rem", marginBottom: "1.25rem", color: "var(--text-primary)" }}>Session Context</h3>
|
<p className="error-state__description">{error}</p>
|
||||||
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
) : isLoading ? (
|
||||||
<div>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
|
||||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Customer</div>
|
<div className="skeleton-box" style={{ height: "250px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
|
||||||
<div style={{ fontWeight: 600, fontSize: "0.9375rem" }}>Maria G.</div>
|
<div className="skeleton-box" style={{ height: "400px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : steps.length === 0 ? (
|
||||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Final Outcome</div>
|
<div className="empty-state">
|
||||||
<div style={{ display: "inline-block", backgroundColor: "#FDE8E8", color: "#9B1C1C", padding: "4px 8px", borderRadius: "6px", fontSize: "0.75rem", fontWeight: 700, marginTop: "4px" }}>ESCALATED 🔒</div>
|
<p className="empty-state__title">No replay steps found</p>
|
||||||
</div>
|
<p className="empty-state__description">This conversation has no recorded checkpoints.</p>
|
||||||
<div>
|
</div>
|
||||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Time Elapsed</div>
|
) : (
|
||||||
<div style={{ fontSize: "0.9375rem" }}>15m 25s</div>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
|
||||||
</div>
|
{/* Sidebar Summary Info */}
|
||||||
<div>
|
<div className="section-card" style={{ alignSelf: "start" }}>
|
||||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Total Tokens</div>
|
<h3 style={{ fontSize: "1rem", marginBottom: "1.25rem", color: "var(--text-primary)" }}>Session Context</h3>
|
||||||
<div style={{ fontSize: "0.9375rem" }}>3,402 ($0.15)</div>
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
<div>
|
||||||
|
<div className="stat-label">Thread ID</div>
|
||||||
|
<div className="stat-value" style={{ fontSize: "0.8125rem", fontFamily: "monospace", wordBreak: "break-all" }}>{threadId}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="stat-label">Total Steps</div>
|
||||||
|
<div className="stat-value">{totalSteps}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="stat-label">Time Range</div>
|
||||||
|
<div style={{ fontSize: "0.8125rem" }}>
|
||||||
|
{steps[0]?.timestamp ? new Date(steps[0].timestamp).toLocaleString() : "N/A"}
|
||||||
|
{" \u2013 "}
|
||||||
|
{steps[steps.length - 1]?.timestamp ? new Date(steps[steps.length - 1].timestamp).toLocaleString() : "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div style={{ backgroundColor: "var(--bg-surface)", padding: "2rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)" }}>
|
<div className="section-card" style={{ padding: "2rem" }}>
|
||||||
<ReplayTimeline steps={MOCK_STEPS as any} />
|
<ReplayTimeline steps={steps as any} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
interface ImportJob {
|
interface ImportJob {
|
||||||
job_id: string;
|
job_id: string;
|
||||||
status: "pending" | "processing" | "done" | "error";
|
status: "pending" | "processing" | "done" | "failed";
|
||||||
error?: string;
|
error_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EndpointClassification {
|
interface EndpointClassification {
|
||||||
@@ -14,16 +14,9 @@ interface EndpointClassification {
|
|||||||
agent_group: string;
|
agent_group: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JobResult {
|
|
||||||
job_id: string;
|
|
||||||
status: string;
|
|
||||||
endpoints: EndpointClassification[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReviewPage() {
|
export function ReviewPage() {
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [job, setJob] = useState<ImportJob | null>(null);
|
const [job, setJob] = useState<ImportJob | null>(null);
|
||||||
const [result, setResult] = useState<JobResult | null>(null);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [classifications, setClassifications] = useState<EndpointClassification[]>([
|
const [classifications, setClassifications] = useState<EndpointClassification[]>([
|
||||||
@@ -45,7 +38,7 @@ export function ReviewPage() {
|
|||||||
path: "/api/v1/payments/{charge_id}/refund",
|
path: "/api/v1/payments/{charge_id}/refund",
|
||||||
method: "post",
|
method: "post",
|
||||||
summary: "Issue a full or partial refund for a charge",
|
summary: "Issue a full or partial refund for a charge",
|
||||||
access_type: "admin",
|
access_type: "write",
|
||||||
agent_group: "Billing Assistant",
|
agent_group: "Billing Assistant",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,14 +71,20 @@ export function ReviewPage() {
|
|||||||
const j: ImportJob = data.data ?? data;
|
const j: ImportJob = data.data ?? data;
|
||||||
setJob(j);
|
setJob(j);
|
||||||
if (j.status === "done") {
|
if (j.status === "done") {
|
||||||
return fetch(`/api/openapi/jobs/${encodeURIComponent(jobId)}/result`)
|
return fetch(`/api/openapi/jobs/${encodeURIComponent(jobId)}/classifications`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((rdata) => {
|
.then((clfs: EndpointClassification[]) => {
|
||||||
const res: JobResult = rdata.data ?? rdata;
|
setClassifications(
|
||||||
setResult(res);
|
clfs.map((c: any) => ({
|
||||||
setClassifications(res.endpoints ?? []);
|
path: c.endpoint?.path ?? c.path ?? "",
|
||||||
|
method: c.endpoint?.method ?? c.method ?? "",
|
||||||
|
summary: c.endpoint?.summary ?? c.summary ?? "",
|
||||||
|
access_type: c.access_type ?? "read",
|
||||||
|
agent_group: c.agent_group ?? "Unassigned",
|
||||||
|
}))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else if (j.status === "error") {
|
} else if (j.status === "failed") {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
pollRef.current = setTimeout(() => pollJob(jobId), 2000);
|
pollRef.current = setTimeout(() => pollJob(jobId), 2000);
|
||||||
@@ -102,7 +101,6 @@ export function ReviewPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setJob(null);
|
setJob(null);
|
||||||
setResult(null);
|
|
||||||
setClassifications([]);
|
setClassifications([]);
|
||||||
|
|
||||||
fetch("/api/openapi/import", {
|
fetch("/api/openapi/import", {
|
||||||
@@ -174,10 +172,10 @@ export function ReviewPage() {
|
|||||||
{job && (
|
{job && (
|
||||||
<div style={{ padding: "1rem", background: "var(--bg-surface)", border: "1px solid var(--border-light)", borderRadius: "var(--radius-md)", marginBottom: "1.5rem" }}>
|
<div style={{ padding: "1rem", background: "var(--bg-surface)", border: "1px solid var(--border-light)", borderRadius: "var(--radius-md)", marginBottom: "1.5rem" }}>
|
||||||
<strong>Job:</strong> {job.job_id} — Status:{" "}
|
<strong>Job:</strong> {job.job_id} — Status:{" "}
|
||||||
<span style={{ fontWeight: 600, color: job.status === "done" ? "#10b981" : job.status === "error" ? "var(--brand-accent)" : "#f59e0b" }}>
|
<span style={{ fontWeight: 600, color: job.status === "done" ? "#10b981" : job.status === "failed" ? "var(--brand-accent)" : "#f59e0b" }}>
|
||||||
{job.status}
|
{job.status}
|
||||||
</span>
|
</span>
|
||||||
{job.error && <div style={{ marginTop: "4px", color: "var(--brand-accent)" }}>{job.error}</div>}
|
{job.error_message && <div style={{ marginTop: "4px", color: "var(--brand-accent)" }}>{job.error_message}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -221,7 +219,6 @@ export function ReviewPage() {
|
|||||||
>
|
>
|
||||||
<option value="read">Read Only</option>
|
<option value="read">Read Only</option>
|
||||||
<option value="write">Write (Confirm)</option>
|
<option value="write">Write (Confirm)</option>
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/components/agentaction.tsx","./src/components/chatinput.tsx","./src/components/chatmessages.tsx","./src/components/errorbanner.tsx","./src/components/interruptprompt.tsx","./src/components/layout.tsx","./src/components/metriccard.tsx","./src/components/navbar.tsx","./src/components/replaytimeline.tsx","./src/hooks/usewebsocket.ts","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/replaylistpage.tsx","./src/pages/replaypage.tsx","./src/pages/reviewpage.tsx"],"version":"5.7.3"}
|
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/agentaction.tsx","./src/components/chatinput.tsx","./src/components/chatmessages.tsx","./src/components/errorbanner.tsx","./src/components/interruptprompt.tsx","./src/components/layout.tsx","./src/components/metriccard.tsx","./src/components/navbar.tsx","./src/components/replaytimeline.tsx","./src/hooks/usewebsocket.ts","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/replaylistpage.tsx","./src/pages/replaypage.tsx","./src/pages/reviewpage.tsx"],"version":"5.7.3"}
|
||||||
Reference in New Issue
Block a user