First half of the WebSocket/SSE push slice. Paused mid-flight to hand
the branch to Codex for outside-voice review before stacking more
commits on top. See .ai/HANDOFF.md for the full pause context + what
to look at.
What's here:
- backend/app/core/escalation_bus.py — module-level singleton in-memory
pub/sub keyed by account_id. asyncio.Queue per subscriber with
64-event maxsize and drop-on-full semantics. Designed to be swappable
for Redis pub/sub when Railway scales past single-replica.
- backend/app/api/endpoints/session_handoffs.py — GET
/api/v1/ai-sessions/escalations/stream SSE endpoint. Auth via
require_engineer_or_admin. 25s heartbeat. Account-scoped subscribe
bound to current_user.account_id.
- backend/app/services/handoff_manager.py — dispatch_escalation_notifications
now publishes a `handoff_created` event to the bus BEFORE the email
fan-out, in a try/except so a bus failure can't block email delivery.
- backend/tests/test_escalation_bus.py — 7 unit tests, all green
standalone (0.14s). Cross-tenant isolation, drop-on-full, no-subscribers.
- backend/tests/test_handoff_manager.py — +1 dispatcher integration test
(publishes to bus, payload shape).
- backend/tests/test_session_handoffs_api.py — +2 endpoint tests (viewer
blocked, ready event handshake).
[gstack-context]
Decisions:
- SSE over WebSocket (one-way, browser EventSource semantics, fewer
moving parts behind Railway proxy)
- In-memory bus over Redis for v1 pilot (3 MSPs, single replica)
- Drop-on-full subscriber queue rather than back-pressure publishers
- Bus publish ahead of email send, both wrapped in try/except so
neither can break handoff creation
- Frontend will be a fetch-based ReadableStream reader matching the
existing streamDocumentation pattern, not native EventSource
(custom-header auth)
Remaining (post-Codex):
- Frontend SSE subscription in EscalationQueue.tsx (slide-in,
reconnect, tab-title flash, prefers-reduced-motion)
- Magic-moment handoff-context screen
- Re-run the full backend test suite to verify the SSE +
dispatcher integration tests (bus units already green standalone)
Tried:
- Running the full test suite repeatedly without xdist; the per-test
DROP SCHEMA + recreate fixture made wall-clock prohibitive when
multiple stale runs collided on the same Postgres test schema.
Resolution: -n auto next time.
[/gstack-context]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
193 lines
6.3 KiB
Python
193 lines
6.3 KiB
Python
"""API endpoint tests for session handoffs."""
|
|
from uuid import UUID as PyUUID
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
|
|
from app.models.ai_session import AISession
|
|
from app.models.user import User
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_park_handoff_api(client: AsyncClient, test_user, auth_headers, test_db):
|
|
"""POST /ai-sessions/{id}/handoff with intent=park."""
|
|
session = AISession(
|
|
user_id=test_user["user_data"]["id"],
|
|
account_id=test_user["user_data"]["account_id"],
|
|
session_type="guided",
|
|
intake_type="free_text",
|
|
intake_content={"text": "test"},
|
|
status="active",
|
|
confidence_tier="discovery",
|
|
conversation_messages=[],
|
|
)
|
|
test_db.add(session)
|
|
await test_db.commit()
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/handoff",
|
|
headers=auth_headers,
|
|
json={"intent": "park", "engineer_notes": "Waiting for logs"},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["intent"] == "park"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_queue(client: AsyncClient, test_user, auth_headers, test_db):
|
|
"""GET /ai-sessions/queue returns unclaimed handoffs."""
|
|
session = AISession(
|
|
user_id=test_user["user_data"]["id"],
|
|
account_id=test_user["user_data"]["account_id"],
|
|
session_type="guided",
|
|
intake_type="free_text",
|
|
intake_content={"text": "test"},
|
|
status="active",
|
|
confidence_tier="discovery",
|
|
conversation_messages=[],
|
|
)
|
|
test_db.add(session)
|
|
await test_db.commit()
|
|
|
|
# Create a handoff
|
|
await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/handoff",
|
|
headers=auth_headers,
|
|
json={"intent": "escalate", "engineer_notes": "Need help"},
|
|
)
|
|
|
|
resp = await client.get("/api/v1/ai-sessions/queue", headers=auth_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_blocked_for_viewer_role(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
"""POST /handoffs/{id}/claim must 403 for viewer-role users.
|
|
|
|
Codex review flagged the missing role gate as wedge-relevant: the
|
|
race-condition story (two seniors clicking Pick Up simultaneously)
|
|
requires auth gating for audit integrity. Viewers must not be able
|
|
to claim escalations.
|
|
"""
|
|
# Create a session + handoff as the engineer-role test_user (default = owner).
|
|
session = AISession(
|
|
user_id=test_user["user_data"]["id"],
|
|
account_id=test_user["user_data"]["account_id"],
|
|
session_type="guided",
|
|
intake_type="free_text",
|
|
intake_content={"text": "test"},
|
|
status="active",
|
|
confidence_tier="discovery",
|
|
conversation_messages=[],
|
|
)
|
|
test_db.add(session)
|
|
await test_db.commit()
|
|
|
|
create_resp = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/handoff",
|
|
headers=auth_headers,
|
|
json={"intent": "escalate", "engineer_notes": "Need help"},
|
|
)
|
|
assert create_resp.status_code == 201
|
|
handoff_id = create_resp.json()["id"]
|
|
|
|
# Downgrade the user to viewer.
|
|
user_id = PyUUID(test_user["user_data"]["id"])
|
|
user = (
|
|
await test_db.execute(select(User).where(User.id == user_id))
|
|
).scalar_one()
|
|
user.account_role = "viewer"
|
|
await test_db.commit()
|
|
|
|
claim_resp = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff_id}/claim",
|
|
headers=auth_headers,
|
|
)
|
|
assert claim_resp.status_code == 403
|
|
assert "engineer" in claim_resp.json()["detail"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalations_stream_blocked_for_viewer(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
"""SSE stream is role-gated to engineer-or-admin (matches queue/claim)."""
|
|
user_id = PyUUID(test_user["user_data"]["id"])
|
|
user = (
|
|
await test_db.execute(select(User).where(User.id == user_id))
|
|
).scalar_one()
|
|
user.account_role = "viewer"
|
|
await test_db.commit()
|
|
|
|
resp = await client.get(
|
|
"/api/v1/ai-sessions/escalations/stream", headers=auth_headers
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalations_stream_returns_sse_content_type(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
"""Engineer/owner can open the SSE stream and gets text/event-stream
|
|
plus an initial `ready` event. Read just enough bytes to confirm the
|
|
handshake — the full pub/sub flow is covered by the bus + dispatcher
|
|
tests separately."""
|
|
async with client.stream(
|
|
"GET",
|
|
"/api/v1/ai-sessions/escalations/stream",
|
|
headers=auth_headers,
|
|
) as resp:
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"].startswith("text/event-stream")
|
|
# First chunk must contain the ready event.
|
|
first = b""
|
|
async for chunk in resp.aiter_bytes():
|
|
first += chunk
|
|
if b"event: ready" in first and b"\n\n" in first:
|
|
break
|
|
assert b"event: ready" in first
|
|
assert b'"account_id"' in first
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_allowed_for_engineer_role(
|
|
client: AsyncClient, test_user, auth_headers, test_db
|
|
):
|
|
"""POST /handoffs/{id}/claim succeeds for engineer-or-admin roles."""
|
|
session = AISession(
|
|
user_id=test_user["user_data"]["id"],
|
|
account_id=test_user["user_data"]["account_id"],
|
|
session_type="guided",
|
|
intake_type="free_text",
|
|
intake_content={"text": "test"},
|
|
status="active",
|
|
confidence_tier="discovery",
|
|
conversation_messages=[],
|
|
)
|
|
test_db.add(session)
|
|
await test_db.commit()
|
|
|
|
create_resp = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/handoff",
|
|
headers=auth_headers,
|
|
json={"intent": "escalate", "engineer_notes": "Need help"},
|
|
)
|
|
assert create_resp.status_code == 201
|
|
handoff_id = create_resp.json()["id"]
|
|
|
|
# Default test_user role is "owner", which passes engineer-or-admin.
|
|
claim_resp = await client.post(
|
|
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff_id}/claim",
|
|
headers=auth_headers,
|
|
)
|
|
assert claim_resp.status_code == 200
|
|
assert claim_resp.json()["claimed_by"] == test_user["user_data"]["id"]
|
|
assert claim_resp.json()["claimed_at"] is not None
|