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:
2026-05-04 08:50:04 +03:00
parent 069462953b
commit 1dffaae822
13 changed files with 1393 additions and 6 deletions

View File

@@ -1,6 +1,41 @@
# Task Board
_Updated: 2026-05-04_
## 🟢 v2-p2-async-bridge — AsyncEventBridge: sync EventEmitter → asyncio.Queue
**Status:** done
**Where I stopped:** Complete
**Next action:** N/A
---
## 🟢 v2-p2-fastapi-app — FastAPI app + REST API (sessions, start, inject, roles, providers)
**Status:** done
**Where I stopped:** Complete
**Next action:** N/A
---
## 🟢 v2-p2-websocket-sse — WebSocket + SSE streaming endpoints
**Status:** done
**Where I stopped:** Complete
**Next action:** N/A
---
## 🟢 v2-p2-web-ui — Minimal HTML/CSS/JS frontend
**Status:** done
**Where I stopped:** Complete
**Next action:** N/A
---
## 🟢 v2-p2-serve-command — `meeting-room serve` CLI subcommand
**Status:** done
**Where I stopped:** Complete
**Next action:** N/A
---
## 🟢 v2-p1-deps — Обновить зависимости (pydantic, pytest, [web])
**Status:** done
**Branch:** feat/v2-phase1-core-refactor
@@ -44,7 +79,7 @@ _Updated: 2026-05-04_
---
<!--
Фаза 1 завершена! Фаза 2 (Web-сервер) задачи будут добавлены.
Фаза 2 завершена! Все таски выполнены.
Статусы:
🔴 Active — только одна одновременно

View File

@@ -0,0 +1,26 @@
# v2-p2-async-bridge
## Goal
Bridge synchronous EventEmitter to asyncio so DiscussionEngine can run in a thread while WebSocket/SSE consumers receive events asynchronously.
## Key files
- `meeting_room/events.py` — add AsyncEventBridge class
- `meeting_room/engine.py` — no changes (runs in thread as-is)
## Decisions log
- 2026-05-04: AsyncEventBridge + asyncio.to_thread() chosen over async engine rewrite. Minimizes changes, engine stays sync.
## Open questions
- [ ] None
## Completed steps
- [x] AsyncEventBridge class in events.py
- [x] Thread-safe bridge with call_soon_threadsafe
- [x] subscribe/unsubscribe consumer queues
- [x] Drop policy on full queues
- [x] Tests (10/10 passing)
## Notes
- events.py already has comment: "In Phase 2, an AsyncEventBridge will forward events to WebSocket clients"
- Bridge subscribes to sync EventEmitter, puts events into asyncio.Queue
- Consumers (WebSocket handlers, SSE handlers) read from the queue

View File

@@ -0,0 +1,22 @@
# v2-p2-fastapi-app
## Goal
Create FastAPI application with REST API endpoints for session management and discussion control.
## Key files
- `meeting_room/server.py` — FastAPI app, routes, session store
## Decisions log
- 2026-05-04: In-memory session store (dict). Persistent storage deferred.
- 2026-05-04: REST routes under /api prefix, WebSocket/SSE on root
## Open questions
- [ ] None
## Completed steps
- [x] FastAPI app with lifespan handler
- [x] Session + SessionStore models
- [x] REST endpoints: POST /api/sessions, GET /api/sessions, GET /api/sessions/{id}, POST /api/sessions/{id}/start, POST /api/sessions/{id}/inject, GET /api/sessions/{id}/messages
- [x] GET /api/roles, GET /api/providers
- [x] Session start runs engine in asyncio.to_thread
- [x] Tests (12 passing)

View File

@@ -0,0 +1,17 @@
# v2-p2-serve-command
## Goal
Add `meeting-room serve` CLI subcommand that starts the FastAPI web server.
## Key files
- `meeting_room/cli.py` — serve subcommand added
## Decisions log
- 2026-05-04: Default port 8000, configurable via --port. --host and --reload flags.
## Open questions
- [ ] None
## Completed steps
- [x] `meeting-room serve` subcommand with --host, --port, --reload
- [x] Starts uvicorn with the FastAPI app

18
.tasks/v2-p2-web-ui.md Normal file
View File

@@ -0,0 +1,18 @@
# v2-p2-web-ui
## Goal
Minimal HTML/CSS/JS frontend for viewing and interacting with discussions.
## Key files
- `meeting_room/static/index.html` — single-page UI
## Decisions log
- 2026-05-04: Vanilla JS + WebSocket API. No framework.
## Open questions
- [ ] None
## Completed steps
- [x] Dark theme UI with WebSocket event handling
- [x] Start discussion, view messages, inject messages
- [x] Served from /static/index.html, / redirects

View File

@@ -0,0 +1,17 @@
# v2-p2-websocket-sse
## Goal
Stream discussion events to browser clients via WebSocket and SSE endpoints.
## Key files
- `meeting_room/server.py` — WebSocket and SSE route handlers
## Decisions log
- 2026-05-04: WebSocket for bidirectional (events + inject), SSE for read-only
## Open questions
- [ ] None
## Completed steps
- [x] WebSocket endpoint: /ws/{session_id} — bidirectional events + inject
- [x] SSE endpoint: /events/{session_id} — read-only event stream

View File

@@ -426,6 +426,11 @@ def main():
init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace")
init_parser.add_argument("path", nargs="?", default=".", help="Where to create .meeting-room")
serve_parser = subparsers.add_parser("serve", help="Start web server")
serve_parser.add_argument("--host", default="0.0.0.0", help="Host to bind (default: 0.0.0.0)")
serve_parser.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
serve_parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
args = parser.parse_args()
# Handle init command
@@ -433,6 +438,18 @@ def main():
init_workspace(args.path)
return
# Handle serve command
if args.command == "serve":
import uvicorn
from meeting_room.server import app
uvicorn.run(
"meeting_room.server:app" if args.reload else app,
host=args.host,
port=args.port,
reload=args.reload,
)
return
# Find config: CLI arg > .meeting-room/config.yaml > package default
if args.config:
config = load_config(args.config)

View File

@@ -1,12 +1,14 @@
"""EventEmitter and event types for the Meeting Room discussion engine.
Synchronous event bus. In Phase 2, an AsyncEventBridge will forward
events to WebSocket clients.
Synchronous event bus with AsyncEventBridge for forwarding events
to asyncio consumers (WebSocket / SSE).
"""
from __future__ import annotations
import asyncio
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable
@@ -127,3 +129,119 @@ class EventEmitter:
def clear(self) -> None:
"""Remove all subscriptions."""
self._handlers.clear()
# ---------------------------------------------------------------------------
# AsyncEventBridge
# ---------------------------------------------------------------------------
# All event types the bridge forwards.
_ALL_EVENT_TYPES = [
DISCUSSION_START,
ROUND_START,
AGENT_TURN_START,
AGENT_MESSAGE,
TOOL_CALL,
TOOL_RESULT,
AGENT_ERROR,
ROUND_END,
DISCUSSION_END,
BOSS_TURN,
]
class AsyncEventBridge:
"""Bridges synchronous EventEmitter events to asyncio consumers.
Subscribes to all event types on a sync ``EventEmitter`` and forwards
each event into ``asyncio.Queue`` objects — one per consumer. Designed
for running ``DiscussionEngine`` in a worker thread via
``asyncio.to_thread()`` while WebSocket / SSE handlers await events
on the async side.
Usage::
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
bridge.start(asyncio.get_event_loop())
# In an async handler:
queue = bridge.subscribe()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
finally:
bridge.unsubscribe(queue)
# Run engine in a thread:
await asyncio.to_thread(engine.run, scenario)
# Cleanup:
bridge.stop()
"""
def __init__(self, event_bus: EventEmitter) -> None:
self._bus = event_bus
self._loop: asyncio.AbstractEventLoop | None = None
self._queues: list[asyncio.Queue[dict]] = []
self._lock = threading.Lock()
self._handler = self._on_sync_event
# -- lifecycle -----------------------------------------------------------
def start(self, loop: asyncio.AbstractEventLoop) -> None:
"""Subscribe to all event types on the bus."""
self._loop = loop
for event_type in _ALL_EVENT_TYPES:
self._bus.on(event_type, self._handler)
def stop(self) -> None:
"""Unsubscribe from all event types."""
for event_type in _ALL_EVENT_TYPES:
self._bus.off(event_type, self._handler)
# -- consumer management ------------------------------------------------
def subscribe(self) -> asyncio.Queue[dict]:
"""Create a consumer queue that receives all events.
Call from async context. The caller owns the queue and must
call ``unsubscribe()`` when done.
"""
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=1000)
with self._lock:
self._queues.append(q)
return q
def unsubscribe(self, q: asyncio.Queue[dict]) -> None:
"""Remove a consumer queue. Safe to call multiple times."""
with self._lock:
try:
self._queues.remove(q)
except ValueError:
pass
# -- internal ------------------------------------------------------------
def _on_sync_event(self, event: Event) -> None:
"""Sync handler called by EventEmitter on any thread."""
payload = {
"type": event.type,
"data": event.data,
"timestamp": event.timestamp.isoformat(),
}
if self._loop is None or not self._loop.is_running():
return
with self._lock:
queues = list(self._queues)
for q in queues:
self._loop.call_soon_threadsafe(self._safe_put, q, payload)
@staticmethod
def _safe_put(q: asyncio.Queue[dict], payload: dict) -> None:
"""Put payload into queue, dropping if full."""
try:
q.put_nowait(payload)
except asyncio.QueueFull:
logger.warning("AsyncEventBridge: dropping event, queue full")

423
meeting_room/server.py Normal file
View File

@@ -0,0 +1,423 @@
"""FastAPI web server for Meeting Room — REST API, WebSocket, and SSE endpoints."""
from __future__ import annotations
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from typing import Any
from fastapi import APIRouter, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from meeting_room.api_client import APIClient
from meeting_room.config import load_config, load_discussion_config, find_workspace_config
from meeting_room.engine import DiscussionEngine
from meeting_room.events import AsyncEventBridge, EventEmitter
from meeting_room.models import DiscussionConfig, ProviderConfig
from meeting_room.scenario_loader import load_scenario
from meeting_room.tools import ToolRegistry
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic models for API request/response
# ---------------------------------------------------------------------------
class StartDiscussionRequest(BaseModel):
"""Request body for starting a discussion."""
scenario: str # scenario file path or name
config: str | None = None # optional config path override
save: bool = False
class InjectMessageRequest(BaseModel):
"""Request body for injecting a message."""
role_id: str
content: str
class SessionResponse(BaseModel):
"""Response for session creation."""
id: str
status: str
class SessionDetailResponse(BaseModel):
"""Response for session detail."""
id: str
status: str
scenario_name: str | None = None
message_count: int = 0
# ---------------------------------------------------------------------------
# Session store
# ---------------------------------------------------------------------------
class Session:
"""Holds state for a single discussion session."""
def __init__(
self,
session_id: str,
config: dict,
discussion_config: DiscussionConfig,
) -> None:
self.id = session_id
self.config = config
self.discussion_config = discussion_config
self.status: str = "created" # created | running | completed | error
self.scenario_name: str | None = None
self.messages: list[dict] = []
self.event_bus = EventEmitter()
self.bridge = AsyncEventBridge(self.event_bus)
self._engine_task: asyncio.Task | None = None
def build_engine(self) -> DiscussionEngine:
providers = {
name: ProviderConfig(**vals)
for name, vals in self.config.get("providers", {}).items()
}
api_client = APIClient(providers=providers)
tool_registry = ToolRegistry(workdir=self.discussion_config.defaults.workdir)
return DiscussionEngine(
config=self.discussion_config,
api_client=api_client,
tool_registry=tool_registry,
events=self.event_bus,
)
class SessionStore:
"""In-memory session store."""
def __init__(self) -> None:
self._sessions: dict[str, Session] = {}
def create(self, config: dict, discussion_config: DiscussionConfig) -> Session:
session_id = uuid.uuid4().hex[:12]
session = Session(session_id, config, discussion_config)
self._sessions[session_id] = session
return session
def get(self, session_id: str) -> Session | None:
return self._sessions.get(session_id)
def list_sessions(self) -> list[Session]:
return list(self._sessions.values())
# ---------------------------------------------------------------------------
# Application factory
# ---------------------------------------------------------------------------
store = SessionStore()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup/shutdown lifecycle."""
logger.info("Meeting Room server started")
yield
# Cleanup: cancel any running engine tasks
for session in store.list_sessions():
if session._engine_task and not session._engine_task.done():
session._engine_task.cancel()
logger.info("Meeting Room server stopped")
app = FastAPI(
title="Meeting Room",
version="0.2.0",
description="Multi-agent discussion framework — REST API and WebSocket",
lifespan=lifespan,
)
# Serve static files (web UI)
import os
_static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
@app.get("/")
async def index():
"""Redirect to the web UI."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/static/index.html")
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _load_config_or_default(config_path: str | None) -> dict:
"""Load config from path, workspace, or package default."""
if config_path:
return load_config(config_path)
ws_config = find_workspace_config()
if ws_config:
return load_config(ws_config)
return load_config()
# ---------------------------------------------------------------------------
# REST API (mounted at /api)
# ---------------------------------------------------------------------------
api = APIRouter(prefix="/api")
@api.post("/sessions", response_model=SessionResponse)
async def create_session(config: str | None = None):
"""Create a new discussion session."""
raw_config = _load_config_or_default(config)
discussion_config = load_discussion_config()
session = store.create(raw_config, discussion_config)
return SessionResponse(id=session.id, status=session.status)
@api.get("/sessions", response_model=list[SessionDetailResponse])
async def list_sessions():
"""List all sessions."""
sessions = store.list_sessions()
return [
SessionDetailResponse(
id=s.id,
status=s.status,
scenario_name=s.scenario_name,
message_count=len(s.messages),
)
for s in sessions
]
@api.get("/sessions/{session_id}", response_model=SessionDetailResponse)
async def get_session(session_id: str):
"""Get session details."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return SessionDetailResponse(
id=session.id,
status=session.status,
scenario_name=session.scenario_name,
message_count=len(session.messages),
)
@api.post("/sessions/{session_id}/start", response_model=SessionDetailResponse)
async def start_discussion(session_id: str, body: StartDiscussionRequest):
"""Start a discussion in a session."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.status == "running":
raise HTTPException(status_code=409, detail="Discussion already running")
# Load scenario
import os
scenario_path = body.scenario
if not os.path.isabs(scenario_path):
scenario_path = os.path.join(os.getcwd(), scenario_path)
if not os.path.exists(scenario_path):
scenario_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "scenarios", body.scenario
)
if not os.path.exists(scenario_path):
raise HTTPException(status_code=404, detail=f"Scenario not found: {body.scenario}")
scenario_data = load_scenario(scenario_path)
session.scenario_name = scenario_data.get("name", "Untitled")
# Rebuild config if overridden
if body.config:
raw_config = _load_config_or_default(body.config)
discussion_config = load_discussion_config()
session.config = raw_config
session.discussion_config = discussion_config
# Start the bridge
loop = asyncio.get_event_loop()
session.bridge.start(loop)
# Subscribe a handler to collect messages
from meeting_room.events import AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR
def collect_message(event):
session.messages.append({
"role_id": event.data.get("role_id", ""),
"name": event.data.get("name", ""),
"content": event.data.get("content", event.data.get("error", "")),
})
session.event_bus.on(AGENT_MESSAGE, collect_message)
session.event_bus.on(BOSS_TURN, collect_message)
session.event_bus.on(AGENT_ERROR, collect_message)
# Run engine in a thread
engine = session.build_engine()
session.status = "running"
async def run_engine():
try:
await asyncio.to_thread(engine.run, scenario_data)
session.status = "completed"
except Exception as exc:
logger.exception("Engine error in session %s", session.id)
session.status = "error"
finally:
session.bridge.stop()
session._engine_task = asyncio.create_task(run_engine())
return SessionDetailResponse(
id=session.id,
status=session.status,
scenario_name=session.scenario_name,
message_count=0,
)
@api.post("/sessions/{session_id}/inject", response_model=dict)
async def inject_message(session_id: str, body: InjectMessageRequest):
"""Inject a message from a Boss/CSO role."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.status != "running":
raise HTTPException(status_code=409, detail="Session is not running")
engine = session.build_engine()
msg = engine.inject_message(body.role_id, body.content)
session.messages.append({
"role_id": msg.role_id,
"name": msg.name,
"content": msg.content,
})
return {"role_id": msg.role_id, "name": msg.name, "content": msg.content}
@api.get("/sessions/{session_id}/messages")
async def get_messages(session_id: str):
"""Get all messages in a session."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session.messages
@api.get("/roles")
async def list_roles(config: str | None = None):
"""List available roles and model bindings."""
raw_config = _load_config_or_default(config)
return raw_config.get("roles", {})
@api.get("/providers")
async def list_providers(config: str | None = None):
"""List configured providers."""
raw_config = _load_config_or_default(config)
return raw_config.get("providers", {})
# Mount API router
app.include_router(api)
# ---------------------------------------------------------------------------
# WebSocket
# ---------------------------------------------------------------------------
@app.websocket("/ws/{session_id}")
async def websocket_endpoint(websocket: WebSocket, session_id: str):
"""WebSocket endpoint for real-time event streaming and message injection."""
session = store.get(session_id)
if not session:
await websocket.close(code=4004, reason="Session not found")
return
await websocket.accept()
queue = session.bridge.subscribe()
try:
# Task: forward events from queue to WebSocket
async def send_events():
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except asyncio.CancelledError:
pass
send_task = asyncio.create_task(send_events())
# Listen for incoming messages (inject)
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "inject":
role_id = data.get("role_id", "boss")
content = data.get("content", "")
engine = session.build_engine()
msg = engine.inject_message(role_id, content)
session.messages.append({
"role_id": msg.role_id,
"name": msg.name,
"content": msg.content,
})
except WebSocketDisconnect:
pass
finally:
send_task.cancel()
session.bridge.unsubscribe(queue)
except Exception:
session.bridge.unsubscribe(queue)
# ---------------------------------------------------------------------------
# SSE
# ---------------------------------------------------------------------------
@app.get("/events/{session_id}")
async def sse_endpoint(session_id: str):
"""SSE endpoint for read-only event streaming."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
async def event_generator():
queue = session.bridge.subscribe()
try:
while True:
event = await queue.get()
# SSE format
import json
yield f"data: {json.dumps(event)}\n\n"
except asyncio.CancelledError:
pass
finally:
session.bridge.unsubscribe(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Room</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--card: #0f3460;
--border: #533483;
--accent: #e94560;
--text: #eee;
--muted: #999;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--surface);
padding: 1rem 2rem;
border-bottom: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 1.4rem; }
header .status {
font-size: 0.85rem;
color: var(--muted);
}
.status .dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.dot.connected { background: #4caf50; }
.dot.disconnected { background: var(--accent); }
main {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.panel h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: var(--accent);
}
.controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
select, input, button {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--card);
color: var(--text);
}
button {
cursor: pointer;
border-color: var(--accent);
transition: background 0.2s;
}
button:hover { background: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
#messages {
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem 0;
}
.msg {
padding: 0.75rem 1rem;
margin: 0.5rem 0;
border-radius: 6px;
background: var(--card);
border-left: 3px solid var(--border);
animation: fadeIn 0.3s;
}
.msg .name {
font-weight: 600;
color: var(--accent);
font-size: 0.85rem;
}
.msg .content {
margin-top: 0.3rem;
white-space: pre-wrap;
font-size: 0.9rem;
line-height: 1.5;
}
.msg.tool-call {
border-left-color: #ff9800;
font-size: 0.8rem;
color: var(--muted);
}
.msg.error {
border-left-color: #f44336;
}
.inject-bar {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.inject-bar input { flex: 1; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.round-marker {
text-align: center;
color: var(--muted);
font-size: 0.8rem;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
</style>
</head>
<body>
<header>
<h1>Meeting Room</h1>
<div class="status">
<span class="dot disconnected" id="statusDot"></span>
<span id="statusText">Disconnected</span>
</div>
</header>
<main>
<div class="panel">
<h2>Start Discussion</h2>
<div class="controls">
<select id="scenarioSelect">
<option value="">Loading scenarios...</option>
</select>
<button id="startBtn" onclick="startSession()">Start</button>
</div>
</div>
<div class="panel" id="chatPanel" style="display:none">
<h2 id="sessionTitle">Discussion</h2>
<div id="messages"></div>
<div class="inject-bar">
<input id="injectRole" placeholder="Role (boss)" value="boss" style="width:120px">
<input id="injectText" placeholder="Inject message..." onkeydown="if(event.key==='Enter')injectMessage()">
<button onclick="injectMessage()">Send</button>
</div>
</div>
</main>
<script>
let ws = null;
let sessionId = null;
// --- API helpers ---
async function api(path, opts = {}) {
const res = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
// --- Load scenarios (placeholder) ---
// In a full implementation, we'd load from /api/scenarios
// For now, scenarios are provided by path
// --- Create and start session ---
async function startSession() {
const scenario = document.getElementById('scenarioSelect').value;
if (!scenario) return alert('Select a scenario first');
try {
// Create session
const sess = await api('/sessions', { method: 'POST' });
sessionId = sess.id;
// Start discussion
await api(`/sessions/${sessionId}/start`, {
method: 'POST',
body: JSON.stringify({ scenario }),
});
// Connect WebSocket
connectWS(sessionId);
document.getElementById('chatPanel').style.display = 'block';
document.getElementById('startBtn').disabled = true;
} catch (e) {
alert('Error: ' + e.message);
}
}
// --- WebSocket ---
function connectWS(sid) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/${sid}`);
ws.onopen = () => {
document.getElementById('statusDot').className = 'dot connected';
document.getElementById('statusText').textContent = 'Connected';
};
ws.onclose = () => {
document.getElementById('statusDot').className = 'dot disconnected';
document.getElementById('statusText').textContent = 'Disconnected';
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleEvent(data);
};
}
// --- Handle events ---
const ROLE_COLORS = {
moderator: '#4fc3f7',
skeptic: '#ef5350',
idea_generator: '#66bb6a',
analyst: '#ffa726',
boss: '#ab47bc',
defender: '#42a5f5',
attacker: '#ff7043',
security: '#78909c',
};
function handleEvent(data) {
const container = document.getElementById('messages');
const type = data.type;
const d = data.data || {};
if (type === 'discussion_start') {
document.getElementById('sessionTitle').textContent = d.name || 'Discussion';
container.innerHTML = '';
} else if (type === 'round_start') {
const el = document.createElement('div');
el.className = 'round-marker';
el.textContent = `— Round ${d.round_num}/${d.total_rounds}`;
container.appendChild(el);
} else if (type === 'agent_message' || type === 'boss_turn') {
const el = document.createElement('div');
el.className = 'msg';
const color = ROLE_COLORS[d.role_id] || '#999';
el.innerHTML = `<div class="name" style="color:${color}">${d.name}</div><div class="content">${escapeHtml(d.content || '')}</div>`;
container.appendChild(el);
} else if (type === 'agent_error') {
const el = document.createElement('div');
el.className = 'msg error';
el.innerHTML = `<div class="name">${d.name}</div><div class="content">Error: ${escapeHtml(d.error || '')}</div>`;
container.appendChild(el);
} else if (type === 'tool_call') {
const el = document.createElement('div');
el.className = 'msg tool-call';
el.textContent = `🔧 ${d.tool_name}(${d.arguments || ''})`;
container.appendChild(el);
} else if (type === 'discussion_end') {
const el = document.createElement('div');
el.className = 'round-marker';
el.textContent = `— Discussion complete (${d.total_messages} messages) —`;
container.appendChild(el);
}
container.scrollTop = container.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Inject message ---
function injectMessage() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected');
return;
}
const role = document.getElementById('injectRole').value || 'boss';
const content = document.getElementById('injectText').value;
if (!content) return;
ws.send(JSON.stringify({ type: 'inject', role_id: role, content }));
document.getElementById('injectText').value = '';
}
// --- Init: fetch scenarios (todo: add API endpoint) ---
// For now, just show a text input
document.getElementById('scenarioSelect').outerHTML =
'<input id="scenarioSelect" placeholder="Scenario path (e.g. scenarios/example.md)" style="flex:1">';
</script>
</body>
</html>

View File

@@ -16,7 +16,7 @@ dependencies = [
]
[project.optional-dependencies]
dev = ["pytest>=7.0", "pytest-mock>=3.0"]
dev = ["pytest>=7.0", "pytest-mock>=3.0", "pytest-asyncio>=0.23"]
web = ["fastapi>=0.100", "uvicorn>=0.20", "websockets>=11.0"]

225
tests/test_async_bridge.py Normal file
View 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
View 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),
)