diff --git a/meeting_room/events.py b/meeting_room/events.py new file mode 100644 index 0000000..a520261 --- /dev/null +++ b/meeting_room/events.py @@ -0,0 +1,129 @@ +"""EventEmitter and event types for the Meeting Room discussion engine. + +Synchronous event bus. In Phase 2, an AsyncEventBridge will forward +events to WebSocket clients. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Event type constants +# --------------------------------------------------------------------------- + +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" + +# --------------------------------------------------------------------------- +# Event dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class Event: + """A single event emitted by the discussion engine.""" + + type: str + data: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=datetime.now) + + +# --------------------------------------------------------------------------- +# EventEmitter +# --------------------------------------------------------------------------- + +# Type alias for handler callbacks +Handler = Callable[[Event], None] + + +class EventEmitter: + """Synchronous pub/sub event bus. + + Usage:: + + bus = EventEmitter() + + def on_message(event: Event): + print(event.data) + + bus.on(AGENT_MESSAGE, on_message) + bus.emit(AGENT_MESSAGE, text="Hello", agent="Alice") + bus.off(AGENT_MESSAGE, on_message) + """ + + def __init__(self) -> None: + self._handlers: dict[str, list[Handler]] = {} + + # -- subscription -------------------------------------------------------- + + def on(self, event_type: str, handler: Handler) -> Handler: + """Subscribe *handler* to *event_type*. + + Returns the handler so callers can use ``off`` with the same + reference, or chain calls. + """ + self._handlers.setdefault(event_type, []).append(handler) + return handler + + def off(self, event_type: str, handler: Handler) -> None: + """Unsubscribe *handler* from *event_type*. + + Silently ignores unknown event types or missing handlers. + """ + handlers = self._handlers.get(event_type) + if handlers is None: + return + try: + handlers.remove(handler) + except ValueError: + pass + # Clean up empty lists to avoid unbounded growth + if not handlers: + del self._handlers[event_type] + + # -- emission ------------------------------------------------------------ + + def emit(self, event_type: str, **data: Any) -> Event: + """Create an Event and call every subscribed handler synchronously. + + Handlers that raise exceptions are caught and logged; the emitter + never crashes due to a bad handler. + + Returns the Event that was emitted (useful for testing). + """ + event = Event(type=event_type, data=data) + handlers = self._handlers.get(event_type, []) + for handler in handlers: + try: + handler(event) + except Exception: + logger.exception( + "Handler %r raised for event %s", + handler, + event_type, + ) + return event + + # -- introspection ------------------------------------------------------- + + def handlers(self, event_type: str) -> list[Handler]: + """Return a *copy* of the handler list for *event_type*.""" + return list(self._handlers.get(event_type, [])) + + def clear(self) -> None: + """Remove all subscriptions.""" + self._handlers.clear() \ No newline at end of file diff --git a/meeting_room/models.py b/meeting_room/models.py new file mode 100644 index 0000000..1a335ea --- /dev/null +++ b/meeting_room/models.py @@ -0,0 +1,124 @@ +"""Pydantic v2 data models for Meeting Room.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +# --------------------------------------------------------------------------- +# Configuration models — must be compatible with config.yaml parsed by +# yaml.safe_load() so that DiscussionConfig(**yaml_config) works unchanged. +# --------------------------------------------------------------------------- + + +class ProviderConfig(BaseModel): + """A single API provider (OpenAI-compatible endpoint).""" + + model_config = ConfigDict(extra="forbid") + + base_url: str + api_key: str + + +class RoleConfig(BaseModel): + """A discussion participant role bound to a provider/model.""" + + model_config = ConfigDict(extra="forbid") + + name: str + provider: str + model: str + temperature: float = 0.7 + tools: Union[str, list[str]] = "none" + system_prompt: str = "" + + @field_validator("tools", mode="before") + @classmethod + def _coerce_tools(cls, v: object) -> Union[str, list[str]]: + if isinstance(v, str): + return v + if isinstance(v, list): + return [str(item) for item in v] + raise ValueError(f"tools must be a string or list of strings, got {type(v).__name__}") + + +class DefaultsConfig(BaseModel): + """Default settings inherited by scenarios.""" + + model_config = ConfigDict(extra="ignore") + + max_rounds: int = 8 + language: str = "ru" + framework: str = "custom" + workdir: str = "." + + +class DiscussionConfig(BaseModel): + """Top-level config — the direct counterpart of config.yaml.""" + + model_config = ConfigDict(extra="ignore") + + providers: dict[str, ProviderConfig] = Field(default_factory=dict) + roles: dict[str, RoleConfig] = Field(default_factory=dict) + defaults: DefaultsConfig = Field(default_factory=DefaultsConfig) + + +# --------------------------------------------------------------------------- +# Scenario model +# --------------------------------------------------------------------------- + + +class ScenarioConfig(BaseModel): + """A discussion scenario (loaded from .md / .yaml files).""" + + model_config = ConfigDict(extra="ignore") + + name: str = "Untitled" + participants: list[str] = Field(default_factory=list) + max_rounds: int = 8 + problem: str = "" + + +# --------------------------------------------------------------------------- +# Runtime models +# --------------------------------------------------------------------------- + + +class Message(BaseModel): + """A single message in a discussion session.""" + + model_config = ConfigDict(extra="forbid") + + role_id: str + name: str + content: str + + +class ToolCallRecord(BaseModel): + """Record of a single tool call made by an agent.""" + + model_config = ConfigDict(extra="forbid") + + id: str + name: str + arguments: str + result: str = "" + + +class Session(BaseModel): + """A complete discussion session (scenario + messages + timestamps).""" + + model_config = ConfigDict(extra="ignore") + + scenario: ScenarioConfig = Field(default_factory=ScenarioConfig) + messages: list[Message] = Field(default_factory=list) + tool_calls: list[ToolCallRecord] = Field(default_factory=list) + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + ) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + ) \ No newline at end of file diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..9a1826f --- /dev/null +++ b/tests/test_events.py @@ -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", + ] \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..a5e95c2 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,396 @@ +"""Tests for meeting_room.models — Pydantic v2 data models.""" + +import copy +from datetime import datetime, timezone + +import pytest +import yaml + +from meeting_room.models import ( + DefaultsConfig, + DiscussionConfig, + Message, + ProviderConfig, + RoleConfig, + ScenarioConfig, + Session, + ToolCallRecord, +) + +# --------------------------------------------------------------------------- +# Fixtures — sample data matching config.yaml structure +# --------------------------------------------------------------------------- + +SAMPLE_YAML = """\ +providers: + routerai: + base_url: "https://routerai.ru/api/v1" + api_key: "sk-test-key" + ollama_cloud: + api_key: "02a8f6e9f9744088885982ac645f1ce1.HB1MFh4AlTpyic9oJGxQjNuA" + base_url: "https://ollama.com/v1" + +roles: + moderator: + name: "Moderator" + provider: ollama_cloud + model: "gpt-4o-mini" + temperature: 0.7 + tools: "none" + system_prompt: | + You are the moderator of a discussion. + skeptic: + name: "Skeptic" + provider: ollama_cloud + model: "gpt-4o-mini" + temperature: 0.9 + tools: "readonly" + system_prompt: | + You are a skeptic and critic. + +defaults: + max_rounds: 8 + language: "ru" + framework: "custom" + workdir: "." +""" + + +@pytest.fixture +def yaml_config() -> dict: + """Parse SAMPLE_YAML the same way cli.py does.""" + return yaml.safe_load(SAMPLE_YAML) + + +# =========================================================================== +# ProviderConfig +# =========================================================================== + + +class TestProviderConfig: + def test_basic(self): + p = ProviderConfig(base_url="https://api.example.com/v1", api_key="sk-123") + assert p.base_url == "https://api.example.com/v1" + assert p.api_key == "sk-123" + + def test_extra_fields_forbidden(self): + with pytest.raises(Exception): + ProviderConfig(base_url="https://x", api_key="k", unknown_field="oops") + + def test_missing_required(self): + with pytest.raises(Exception): + ProviderConfig(base_url="https://x") # missing api_key + + +# =========================================================================== +# RoleConfig +# =========================================================================== + + +class TestRoleConfig: + def test_basic(self): + r = RoleConfig( + name="Moderator", + provider="ollama_cloud", + model="gpt-4o-mini", + temperature=0.7, + tools="none", + system_prompt="You moderate.", + ) + assert r.name == "Moderator" + assert r.temperature == 0.7 + assert r.tools == "none" + + def test_defaults(self): + r = RoleConfig(name="Bot", provider="p", model="m") + assert r.temperature == 0.7 + assert r.tools == "none" + assert r.system_prompt == "" + + def test_tools_string_values(self): + for val in ("all", "none", "readonly", "web", "full", "files"): + r = RoleConfig(name="X", provider="p", model="m", tools=val) + assert r.tools == val + + def test_tools_list(self): + r = RoleConfig(name="X", provider="p", model="m", tools=["read_file", "web_search"]) + assert r.tools == ["read_file", "web_search"] + + def test_tools_coercion_list_items_to_str(self): + r = RoleConfig(name="X", provider="p", model="m", tools=["read_file"]) + assert isinstance(r.tools[0], str) + + def test_tools_invalid_type(self): + with pytest.raises(Exception): + RoleConfig(name="X", provider="p", model="m", tools=42) + + def test_extra_fields_forbidden(self): + with pytest.raises(Exception): + RoleConfig(name="X", provider="p", model="m", rogue_field=True) + + +# =========================================================================== +# DefaultsConfig +# =========================================================================== + + +class TestDefaultsConfig: + def test_defaults(self): + d = DefaultsConfig() + assert d.max_rounds == 8 + assert d.language == "ru" + assert d.framework == "custom" + assert d.workdir == "." + + def test_custom(self): + d = DefaultsConfig(max_rounds=12, language="en", framework="autogen", workdir="/tmp") + assert d.max_rounds == 12 + + def test_extra_ignored(self): + # DefaultsConfig uses extra="ignore" — unknown keys are silently dropped + d = DefaultsConfig(max_rounds=5, unknown_key="oops") + assert d.max_rounds == 5 + assert not hasattr(d, "unknown_key") + + +# =========================================================================== +# DiscussionConfig — the critical one +# =========================================================================== + + +class TestDiscussionConfig: + def test_from_yaml_config(self, yaml_config: dict): + """DiscussionConfig(**yaml_config) must work without changes.""" + cfg = DiscussionConfig(**yaml_config) + assert "routerai" in cfg.providers + assert "ollama_cloud" in cfg.providers + assert cfg.providers["routerai"].base_url == "https://routerai.ru/api/v1" + assert "moderator" in cfg.roles + assert "skeptic" in cfg.roles + assert cfg.roles["moderator"].name == "Moderator" + assert cfg.roles["skeptic"].tools == "readonly" + assert cfg.defaults.max_rounds == 8 + + def test_roundtrip_serialization(self, yaml_config: dict): + """Model can be built from yaml, exported, and rebuilt.""" + cfg = DiscussionConfig(**yaml_config) + exported = cfg.model_dump() + rebuilt = DiscussionConfig(**exported) + assert rebuilt.providers.keys() == cfg.providers.keys() + assert rebuilt.roles.keys() == cfg.roles.keys() + + def test_empty_config(self): + cfg = DiscussionConfig() + assert cfg.providers == {} + assert cfg.roles == {} + assert cfg.defaults.max_rounds == 8 + + def test_extra_keys_ignored(self): + # DiscussionConfig uses extra="ignore" + cfg = DiscussionConfig( + providers={}, + roles={}, + defaults={}, + unknown_top_level="should be dropped", + ) + assert not hasattr(cfg, "unknown_top_level") + + def test_nested_provider_is_typed(self, yaml_config: dict): + cfg = DiscussionConfig(**yaml_config) + prov = cfg.providers["routerai"] + assert isinstance(prov, ProviderConfig) + + def test_nested_role_is_typed(self, yaml_config: dict): + cfg = DiscussionConfig(**yaml_config) + role = cfg.roles["moderator"] + assert isinstance(role, RoleConfig) + + +# =========================================================================== +# ScenarioConfig +# =========================================================================== + + +class TestScenarioConfig: + def test_defaults(self): + s = ScenarioConfig() + assert s.name == "Untitled" + assert s.participants == [] + assert s.max_rounds == 8 + assert s.problem == "" + + def test_from_frontmatter(self): + data = { + "name": "SaaS Pricing", + "participants": ["moderator", "skeptic"], + "max_rounds": 6, + "problem": "How should we price our SaaS?", + } + s = ScenarioConfig(**data) + assert s.name == "SaaS Pricing" + assert s.participants == ["moderator", "skeptic"] + assert s.max_rounds == 6 + + def test_extra_ignored(self): + s = ScenarioConfig(name="X", surprise="drop me") + assert s.name == "X" + assert not hasattr(s, "surprise") + + +# =========================================================================== +# Message +# =========================================================================== + + +class TestMessage: + def test_basic(self): + m = Message(role_id="moderator", name="Moderator", content="Hello everyone!") + assert m.role_id == "moderator" + assert m.name == "Moderator" + assert m.content == "Hello everyone!" + + def test_extra_forbidden(self): + with pytest.raises(Exception): + Message(role_id="mod", name="Mod", content="hi", extra=True) + + def test_missing_required(self): + with pytest.raises(Exception): + Message(role_id="mod", name="Mod") # missing content + + +# =========================================================================== +# ToolCallRecord +# =========================================================================== + + +class TestToolCallRecord: + def test_basic(self): + t = ToolCallRecord(id="call_abc", name="read_file", arguments='{"path": "/tmp/x"}', result="file contents") + assert t.id == "call_abc" + assert t.name == "read_file" + assert t.result == "file contents" + + def test_default_result(self): + t = ToolCallRecord(id="call_1", name="web_search", arguments='{"q": "test"}') + assert t.result == "" + + def test_extra_forbidden(self): + with pytest.raises(Exception): + ToolCallRecord(id="1", name="n", arguments="{}", extra="bad") + + +# =========================================================================== +# Session +# =========================================================================== + + +class TestSession: + def test_defaults(self): + s = Session() + assert s.scenario.name == "Untitled" + assert s.messages == [] + assert s.tool_calls == [] + assert isinstance(s.created_at, datetime) + assert isinstance(s.updated_at, datetime) + + def test_with_messages(self): + msg = Message(role_id="moderator", name="Moderator", content="Let's begin.") + s = Session(messages=[msg]) + assert len(s.messages) == 1 + assert s.messages[0].content == "Let's begin." + + def test_timestamps_auto_set(self): + before = datetime.now(timezone.utc) + s = Session() + after = datetime.now(timezone.utc) + assert before <= s.created_at <= after + assert before <= s.updated_at <= after + + def test_explicit_timestamps(self): + t = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + s = Session(created_at=t, updated_at=t) + assert s.created_at.year == 2025 + + def test_serialization_roundtrip(self): + msg = Message(role_id="mod", name="Mod", content="Hi") + tc = ToolCallRecord(id="c1", name="search", arguments='{}', result="ok") + s = Session( + scenario=ScenarioConfig(name="Test"), + messages=[msg], + tool_calls=[tc], + ) + data = s.model_dump() + rebuilt = Session(**data) + assert rebuilt.messages[0].content == "Hi" + assert rebuilt.tool_calls[0].name == "search" + assert rebuilt.scenario.name == "Test" + + def test_extra_ignored(self): + s = Session(unknown_field="drop me") + assert not hasattr(s, "unknown_field") + + +# =========================================================================== +# Edge cases with real config.yaml structure +# =========================================================================== + + +class TestConfigYamlCompatibility: + """Verify that the models accept exactly what yaml.safe_load produces + from the actual config.yaml in the package. + """ + + def test_full_config_yaml_structure(self): + """Build DiscussionConfig from the full config.yaml structure + (minus env-var resolution) and verify every field.""" + raw = yaml.safe_load(SAMPLE_YAML) + cfg = DiscussionConfig(**raw) + + # Providers + assert len(cfg.providers) == 2 + assert cfg.providers["routerai"].base_url == "https://routerai.ru/api/v1" + assert cfg.providers["routerai"].api_key == "sk-test-key" + assert cfg.providers["ollama_cloud"].base_url == "https://ollama.com/v1" + + # Roles + assert len(cfg.roles) == 2 + mod = cfg.roles["moderator"] + assert mod.name == "Moderator" + assert mod.provider == "ollama_cloud" + assert mod.model == "gpt-4o-mini" + assert mod.temperature == 0.7 + assert mod.tools == "none" + assert "moderator" in mod.system_prompt.lower() or "moderator" in mod.system_prompt + + sk = cfg.roles["skeptic"] + assert sk.name == "Skeptic" + assert sk.temperature == 0.9 + assert sk.tools == "readonly" + + # Defaults + assert cfg.defaults.max_rounds == 8 + assert cfg.defaults.language == "ru" + + def test_tools_as_list_in_role(self): + """Roles can specify tools as a list of tool names.""" + cfg = DiscussionConfig( + roles={ + "analyst": RoleConfig( + name="Analyst", + provider="ollama_cloud", + model="gpt-4o-mini", + tools=["read_file", "web_search", "list_directory"], + ), + }, + ) + assert cfg.roles["analyst"].tools == ["read_file", "web_search", "list_directory"] + + def test_minimal_config(self): + """Minimal valid config with just one provider and one role.""" + cfg = DiscussionConfig( + providers={"local": ProviderConfig(base_url="http://localhost:11434/v1", api_key="ollama")}, + roles={"bot": RoleConfig(name="Bot", provider="local", model="llama3")}, + ) + assert cfg.providers["local"].api_key == "ollama" + assert cfg.roles["bot"].model == "llama3" + assert cfg.defaults.max_rounds == 8 # default \ No newline at end of file