feat: add Pydantic models and EventEmitter for v2 core

- models.py: ProviderConfig, RoleConfig, DiscussionConfig, ScenarioConfig,
  Message, ToolCallRecord, Session — compatible with config.yaml format
- events.py: Event constants, Event dataclass, EventEmitter with on/off/emit
- 67 tests passing (37 models + 30 events)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 00:00:21 +03:00
parent 1064276e81
commit d292d32afa
4 changed files with 960 additions and 0 deletions

311
tests/test_events.py Normal file
View File

@@ -0,0 +1,311 @@
"""Tests for meeting_room.events — EventEmitter and event types."""
from __future__ import annotations
from datetime import datetime
import pytest
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
BOSS_TURN,
DISCUSSION_END,
DISCUSSION_START,
Event,
EventEmitter,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
)
# ---------------------------------------------------------------------------
# Event type constants
# ---------------------------------------------------------------------------
class TestEventConstants:
"""All event type constants must be lowercase strings."""
@pytest.mark.parametrize(
"name, value",
[
("DISCUSSION_START", "discussion_start"),
("ROUND_START", "round_start"),
("AGENT_TURN_START", "agent_turn_start"),
("AGENT_MESSAGE", "agent_message"),
("TOOL_CALL", "tool_call"),
("TOOL_RESULT", "tool_result"),
("AGENT_ERROR", "agent_error"),
("ROUND_END", "round_end"),
("DISCUSSION_END", "discussion_end"),
("BOSS_TURN", "boss_turn"),
],
)
def test_constants(self, name: str, value: str) -> None:
import meeting_room.events as mod
assert getattr(mod, name) == value
# ---------------------------------------------------------------------------
# Event dataclass
# ---------------------------------------------------------------------------
class TestEvent:
def test_defaults(self) -> None:
e = Event(type=AGENT_MESSAGE)
assert e.type == AGENT_MESSAGE
assert e.data == {}
assert isinstance(e.timestamp, datetime)
def test_custom_data(self) -> None:
e = Event(type=TOOL_CALL, data={"tool": "search", "query": "test"})
assert e.data["tool"] == "search"
assert e.data["query"] == "test"
def test_timestamp_preserved(self) -> None:
ts = datetime(2025, 1, 1, 12, 0, 0)
e = Event(type=AGENT_ERROR, data={"msg": "fail"}, timestamp=ts)
assert e.timestamp == ts
# ---------------------------------------------------------------------------
# EventEmitter — subscription
# ---------------------------------------------------------------------------
class TestEventEmitterOn:
def test_on_returns_handler(self) -> None:
bus = EventEmitter()
def handler(event: Event) -> None:
pass
result = bus.on(AGENT_MESSAGE, handler)
assert result is handler
def test_on_stores_handler(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
assert len(bus.handlers(AGENT_MESSAGE)) == 1
def test_on_multiple_handlers_same_event(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
bus.on(AGENT_MESSAGE, lambda e: None)
bus.on(AGENT_MESSAGE, lambda e: None)
assert len(bus.handlers(AGENT_MESSAGE)) == 3
def test_on_different_event_types(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
bus.on(TOOL_CALL, lambda e: None)
assert len(bus.handlers(AGENT_MESSAGE)) == 1
assert len(bus.handlers(TOOL_CALL)) == 1
# ---------------------------------------------------------------------------
# EventEmitter — unsubscription
# ---------------------------------------------------------------------------
class TestEventEmitterOff:
def test_off_removes_handler(self) -> None:
bus = EventEmitter()
def handler(event: Event) -> None:
pass
bus.on(AGENT_MESSAGE, handler)
bus.off(AGENT_MESSAGE, handler)
assert len(bus.handlers(AGENT_MESSAGE)) == 0
def test_off_unknown_event_type_is_noop(self) -> None:
bus = EventEmitter()
bus.off("nonexistent_event", lambda e: None) # should not raise
def test_off_missing_handler_is_noop(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
bus.off(AGENT_MESSAGE, lambda e: None) # different lambda, no raise
def test_off_cleans_up_empty_list(self) -> None:
bus = EventEmitter()
def handler(event: Event) -> None:
pass
bus.on(AGENT_MESSAGE, handler)
bus.off(AGENT_MESSAGE, handler)
# Internal dict should not keep empty lists
assert AGENT_MESSAGE not in bus._handlers
# ---------------------------------------------------------------------------
# EventEmitter — emission
# ---------------------------------------------------------------------------
class TestEventEmitterEmit:
def test_emit_calls_handler(self) -> None:
bus = EventEmitter()
received: list[Event] = []
bus.on(AGENT_MESSAGE, lambda e: received.append(e))
bus.emit(AGENT_MESSAGE, text="hello", agent="Alice")
assert len(received) == 1
assert received[0].type == AGENT_MESSAGE
assert received[0].data == {"text": "hello", "agent": "Alice"}
def test_emit_calls_all_handlers(self) -> None:
bus = EventEmitter()
results_a: list[str] = []
results_b: list[str] = []
bus.on(AGENT_MESSAGE, lambda e: results_a.append(e.data["text"]))
bus.on(AGENT_MESSAGE, lambda e: results_b.append(e.data["text"]))
bus.emit(AGENT_MESSAGE, text="hello")
assert results_a == ["hello"]
assert results_b == ["hello"]
def test_emit_no_handlers_is_safe(self) -> None:
bus = EventEmitter()
event = bus.emit(DISCUSSION_START) # no subscribers — no crash
assert event.type == DISCUSSION_START
def test_emit_returns_event(self) -> None:
bus = EventEmitter()
event = bus.emit(ROUND_START, round_number=3)
assert event.type == ROUND_START
assert event.data == {"round_number": 3}
assert isinstance(event.timestamp, datetime)
def test_emit_handler_exception_does_not_crash(self) -> None:
bus = EventEmitter()
call_count = 0
def bad_handler(event: Event) -> None:
nonlocal call_count
call_count += 1
raise RuntimeError("boom")
def good_handler(event: Event) -> None:
nonlocal call_count
call_count += 1
bus.on(AGENT_ERROR, bad_handler)
bus.on(AGENT_ERROR, good_handler)
# bad_handler raises, but good_handler still runs
bus.emit(AGENT_ERROR, msg="fail")
assert call_count == 2 # both handlers were called
def test_emit_multiple_events_independently(self) -> None:
bus = EventEmitter()
msg_log: list[str] = []
tool_log: list[str] = []
bus.on(AGENT_MESSAGE, lambda e: msg_log.append(e.data["text"]))
bus.on(TOOL_CALL, lambda e: tool_log.append(e.data["tool"]))
bus.emit(AGENT_MESSAGE, text="hi")
bus.emit(TOOL_CALL, tool="search")
bus.emit(AGENT_MESSAGE, text="bye")
assert msg_log == ["hi", "bye"]
assert tool_log == ["search"]
# ---------------------------------------------------------------------------
# EventEmitter — clear
# ---------------------------------------------------------------------------
class TestEventEmitterClear:
def test_clear_removes_all_handlers(self) -> None:
bus = EventEmitter()
bus.on(AGENT_MESSAGE, lambda e: None)
bus.on(TOOL_CALL, lambda e: None)
bus.clear()
assert bus.handlers(AGENT_MESSAGE) == []
assert bus.handlers(TOOL_CALL) == []
assert bus._handlers == {}
def test_handlers_returns_copy(self) -> None:
bus = EventEmitter()
def handler(event: Event) -> None:
pass
bus.on(AGENT_MESSAGE, handler)
h = bus.handlers(AGENT_MESSAGE)
h.clear() # mutating the copy should not affect the bus
assert len(bus.handlers(AGENT_MESSAGE)) == 1
# ---------------------------------------------------------------------------
# Integration-style: full discussion lifecycle
# ---------------------------------------------------------------------------
class TestDiscussionLifecycle:
"""Simulate a full discussion lifecycle to exercise all event types."""
def test_full_lifecycle(self) -> None:
bus = EventEmitter()
log: list[str] = []
bus.on(DISCUSSION_START, lambda e: log.append(f"DISCUSSION_START"))
bus.on(ROUND_START, lambda e: log.append(f"ROUND_START:{e.data['round']}"))
bus.on(AGENT_TURN_START, lambda e: log.append(f"TURN:{e.data['agent']}"))
bus.on(AGENT_MESSAGE, lambda e: log.append(f"MSG:{e.data['text']}"))
bus.on(TOOL_CALL, lambda e: log.append(f"TOOL:{e.data['tool']}"))
bus.on(TOOL_RESULT, lambda e: log.append(f"RESULT:{e.data['tool']}"))
bus.on(AGENT_ERROR, lambda e: log.append(f"ERR:{e.data['msg']}"))
bus.on(ROUND_END, lambda e: log.append(f"ROUND_END:{e.data['round']}"))
bus.on(BOSS_TURN, lambda e: log.append(f"BOSS:{e.data['decision']}"))
bus.on(DISCUSSION_END, lambda e: log.append("DISCUSSION_END"))
bus.emit(DISCUSSION_START)
bus.emit(ROUND_START, round=1)
bus.emit(AGENT_TURN_START, agent="Alice")
bus.emit(AGENT_MESSAGE, text="I think we should use Python.")
bus.emit(TOOL_CALL, tool="search")
bus.emit(TOOL_RESULT, tool="search")
bus.emit(ROUND_END, round=1)
bus.emit(BOSS_TURN, decision="proceed")
bus.emit(AGENT_ERROR, msg="timeout")
bus.emit(DISCUSSION_END)
assert log == [
"DISCUSSION_START",
"ROUND_START:1",
"TURN:Alice",
"MSG:I think we should use Python.",
"TOOL:search",
"RESULT:search",
"ROUND_END:1",
"BOSS:proceed",
"ERR:timeout",
"DISCUSSION_END",
]