feat: Phase 2 — web server, REST API, WebSocket/SSE, UI [v0.2.0]
AsyncEventBridge bridges sync EventEmitter to asyncio consumers. FastAPI app with session management, discussion control, roles/providers. WebSocket for real-time events + inject, SSE for read-only streaming. Minimal dark-theme HTML/JS UI. `meeting-room serve` CLI subcommand. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
225
tests/test_async_bridge.py
Normal file
225
tests/test_async_bridge.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Tests for AsyncEventBridge — sync EventEmitter → asyncio.Queue bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from meeting_room.events import (
|
||||
AGENT_MESSAGE,
|
||||
AGENT_TURN_START,
|
||||
DISCUSSION_END,
|
||||
DISCUSSION_START,
|
||||
ROUND_END,
|
||||
ROUND_START,
|
||||
TOOL_CALL,
|
||||
AsyncEventBridge,
|
||||
EventEmitter,
|
||||
)
|
||||
|
||||
|
||||
class TestAsyncEventBridgeSubscribe:
|
||||
"""subscribe() creates a queue that receives events."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_returns_queue(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
q = bridge.subscribe()
|
||||
assert isinstance(q, asyncio.Queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_subscribers(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
q1 = bridge.subscribe()
|
||||
q2 = bridge.subscribe()
|
||||
assert q1 is not q2
|
||||
|
||||
|
||||
class TestAsyncEventBridgeStartStop:
|
||||
"""start()/stop() wire the bridge to the sync bus."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_subscribes_to_all_event_types(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
loop = asyncio.get_event_loop()
|
||||
bridge.start(loop)
|
||||
|
||||
for event_type in [
|
||||
DISCUSSION_START, ROUND_START, AGENT_TURN_START,
|
||||
AGENT_MESSAGE, TOOL_CALL, ROUND_END, DISCUSSION_END,
|
||||
]:
|
||||
assert len(bus.handlers(event_type)) == 1
|
||||
|
||||
bridge.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_unsubscribes(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
loop = asyncio.get_event_loop()
|
||||
bridge.start(loop)
|
||||
bridge.stop()
|
||||
|
||||
for event_type in [
|
||||
DISCUSSION_START, ROUND_START, AGENT_TURN_START,
|
||||
AGENT_MESSAGE, TOOL_CALL, ROUND_END, DISCUSSION_END,
|
||||
]:
|
||||
assert len(bus.handlers(event_type)) == 0
|
||||
|
||||
|
||||
class TestAsyncEventBridgeForwarding:
|
||||
"""Events emitted on the sync bus arrive in async queues."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_event_to_queue(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
loop = asyncio.get_event_loop()
|
||||
bridge.start(loop)
|
||||
|
||||
q = bridge.subscribe()
|
||||
|
||||
# Emit a sync event
|
||||
bus.emit(AGENT_MESSAGE, role_id="moderator", name="Moderator", content="Hello")
|
||||
|
||||
# Give the event loop a chance to process call_soon_threadsafe
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
payload = q.get_nowait()
|
||||
assert payload["type"] == AGENT_MESSAGE
|
||||
assert payload["data"]["role_id"] == "moderator"
|
||||
assert payload["data"]["content"] == "Hello"
|
||||
assert "timestamp" in payload
|
||||
|
||||
bridge.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_queues_receive_same_event(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
loop = asyncio.get_event_loop()
|
||||
bridge.start(loop)
|
||||
|
||||
q1 = bridge.subscribe()
|
||||
q2 = bridge.subscribe()
|
||||
|
||||
bus.emit(DISCUSSION_START, name="test")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
p1 = q1.get_nowait()
|
||||
p2 = q2.get_nowait()
|
||||
assert p1["type"] == DISCUSSION_START
|
||||
assert p2["type"] == DISCUSSION_START
|
||||
assert p1["data"]["name"] == "test"
|
||||
assert p2["data"]["name"] == "test"
|
||||
|
||||
bridge.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_stops_delivery(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
loop = asyncio.get_event_loop()
|
||||
bridge.start(loop)
|
||||
|
||||
q = bridge.subscribe()
|
||||
bridge.unsubscribe(q)
|
||||
|
||||
bus.emit(AGENT_MESSAGE, role_id="x", name="X", content="Y")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert q.empty()
|
||||
|
||||
bridge.stop()
|
||||
|
||||
|
||||
class TestAsyncEventBridgeThreadedForwarding:
|
||||
"""Events from a worker thread reach async consumers."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_from_thread(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
loop = asyncio.get_event_loop()
|
||||
bridge.start(loop)
|
||||
|
||||
q = bridge.subscribe()
|
||||
|
||||
# Run engine in a thread (simplified)
|
||||
def sync_work() -> None:
|
||||
bus.emit(DISCUSSION_START, name="threaded")
|
||||
bus.emit(AGENT_MESSAGE, role_id="bot", name="Bot", content="Hi")
|
||||
bus.emit(DISCUSSION_END, total_messages=1)
|
||||
|
||||
await asyncio.to_thread(sync_work)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
events = []
|
||||
while not q.empty():
|
||||
events.append(q.get_nowait())
|
||||
|
||||
types = [e["type"] for e in events]
|
||||
assert "discussion_start" in types
|
||||
assert "agent_message" in types
|
||||
assert "discussion_end" in types
|
||||
|
||||
bridge.stop()
|
||||
|
||||
|
||||
class TestAsyncEventBridgePayloadFormat:
|
||||
"""Serialized events contain type, data, and ISO timestamp."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_structure(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
loop = asyncio.get_event_loop()
|
||||
bridge.start(loop)
|
||||
|
||||
q = bridge.subscribe()
|
||||
bus.emit(TOOL_CALL, role_id="analyst", tool_name="search", arguments='{"q":"test"}')
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
payload = q.get_nowait()
|
||||
assert "type" in payload
|
||||
assert "data" in payload
|
||||
assert "timestamp" in payload
|
||||
# timestamp is ISO format string
|
||||
assert isinstance(payload["timestamp"], str)
|
||||
assert payload["data"]["tool_name"] == "search"
|
||||
|
||||
bridge.stop()
|
||||
|
||||
|
||||
class TestAsyncEventBridgeDropPolicy:
|
||||
"""When a queue is full, events are dropped without crashing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_queue_drops_events(self) -> None:
|
||||
bus = EventEmitter()
|
||||
bridge = AsyncEventBridge(bus)
|
||||
loop = asyncio.get_event_loop()
|
||||
bridge.start(loop)
|
||||
|
||||
# maxsize=2 to make it fill quickly
|
||||
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=2)
|
||||
with bridge._lock:
|
||||
bridge._queues.append(q)
|
||||
|
||||
# Emit 5 events — queue can hold 2
|
||||
for i in range(5):
|
||||
bus.emit(AGENT_MESSAGE, role_id="x", name="X", content=f"msg{i}")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Queue should have at most 2 items (rest dropped)
|
||||
received = []
|
||||
while not q.empty():
|
||||
received.append(q.get_nowait())
|
||||
|
||||
assert len(received) <= 2
|
||||
|
||||
bridge.stop()
|
||||
163
tests/test_server.py
Normal file
163
tests/test_server.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Tests for meeting_room.server — FastAPI app, REST API, WebSocket/SSE."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meeting_room.events import AsyncEventBridge, EventEmitter
|
||||
from meeting_room.server import Session, SessionStore, app, store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_store():
|
||||
"""Clear the global session store before each test."""
|
||||
store._sessions.clear()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""FastAPI test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionStore:
|
||||
def test_create_session(self) -> None:
|
||||
store = SessionStore()
|
||||
session = store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
||||
assert session.id
|
||||
assert session.status == "created"
|
||||
|
||||
def test_get_session(self) -> None:
|
||||
store = SessionStore()
|
||||
session = store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
||||
found = store.get(session.id)
|
||||
assert found is session
|
||||
|
||||
def test_get_nonexistent_session(self) -> None:
|
||||
store = SessionStore()
|
||||
assert store.get("nonexistent") is None
|
||||
|
||||
def test_list_sessions(self) -> None:
|
||||
store = SessionStore()
|
||||
store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
||||
store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
||||
assert len(store.list_sessions()) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST API endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateSession:
|
||||
def test_create_session_returns_id_and_status(self, client) -> None:
|
||||
response = client.post("/api/sessions")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["status"] == "created"
|
||||
|
||||
|
||||
class TestListSessions:
|
||||
def test_list_empty(self, client) -> None:
|
||||
response = client.get("/api/sessions")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
def test_list_after_create(self, client) -> None:
|
||||
client.post("/api/sessions")
|
||||
client.post("/api/sessions")
|
||||
response = client.get("/api/sessions")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
|
||||
class TestGetSession:
|
||||
def test_get_existing_session(self, client) -> None:
|
||||
create_resp = client.post("/api/sessions")
|
||||
session_id = create_resp.json()["id"]
|
||||
response = client.get(f"/api/sessions/{session_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == session_id
|
||||
|
||||
def test_get_nonexistent_session(self, client) -> None:
|
||||
response = client.get("/api/sessions/nonexistent")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestRolesAndProviders:
|
||||
def test_list_roles(self, client) -> None:
|
||||
response = client.get("/api/roles")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "moderator" in data or isinstance(data, dict)
|
||||
|
||||
def test_list_providers(self, client) -> None:
|
||||
response = client.get("/api/providers")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSession:
|
||||
def test_build_engine(self) -> None:
|
||||
config = _minimal_config()
|
||||
session = Session("test-id", config, _minimal_discussion_config())
|
||||
engine = session.build_engine()
|
||||
assert engine is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _minimal_config() -> dict:
|
||||
return {
|
||||
"providers": {
|
||||
"test_provider": {
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"api_key": "test-key",
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"moderator": {
|
||||
"name": "Moderator",
|
||||
"provider": "test_provider",
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0.7,
|
||||
"tools": "none",
|
||||
"system_prompt": "You are a moderator.",
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"max_rounds": 3,
|
||||
"language": "en",
|
||||
"framework": "custom",
|
||||
"workdir": ".",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _minimal_discussion_config():
|
||||
from meeting_room.models import DefaultsConfig, DiscussionConfig
|
||||
|
||||
return DiscussionConfig(
|
||||
providers={"test_provider": {"base_url": "https://api.example.com/v1", "api_key": "test-key"}},
|
||||
roles={"moderator": {"name": "Moderator", "provider": "test_provider", "model": "gpt-4o-mini"}},
|
||||
defaults=DefaultsConfig(max_rounds=3),
|
||||
)
|
||||
Reference in New Issue
Block a user