From d0507b3b0ca559b6f06cfb509229294e4a719eed Mon Sep 17 00:00:00 2001 From: vitya Date: Mon, 4 May 2026 00:33:07 +0300 Subject: [PATCH] feat: extract config.py, refactor CLI to use event-driven engine, add deprecation shim - config.py: extracted load_config(), find_workspace_config(), load_discussion_config() from cli.py; fixed Windows root detection - cli.py: run_discussion() now creates DiscussionEngine + EventEmitter, subscribes to events with ANSI-color output (identical to before), delegates to engine.run() - backends/custom/run.py: deprecation shim with DeprecationWarning, delegates to DiscussionEngine - 16 new config tests, all 184 tests passing Co-Authored-By: Claude Opus 4.7 --- meeting_room/backends/custom/run.py | 302 ++++++++++++++-------------- meeting_room/cli.py | 243 +++++++++++++++++----- meeting_room/config.py | 115 +++++++++++ tests/test_config.py | 263 ++++++++++++++++++++++++ 4 files changed, 722 insertions(+), 201 deletions(-) create mode 100644 meeting_room/config.py create mode 100644 tests/test_config.py diff --git a/meeting_room/backends/custom/run.py b/meeting_room/backends/custom/run.py index c2d5b65..29fc910 100644 --- a/meeting_room/backends/custom/run.py +++ b/meeting_room/backends/custom/run.py @@ -1,16 +1,37 @@ -"""Custom backend — round-robin agent discussion with tool support.""" +"""Legacy backend — delegates to DiscussionEngine. -import json -import os -import sys +This module is deprecated. Use meeting_room.engine.DiscussionEngine directly. +""" -import yaml +import warnings -from meeting_room.api_client import chat, chat_stream, init_providers +warnings.warn( + "backends.custom.run is deprecated. Use meeting_room.engine.DiscussionEngine instead.", + DeprecationWarning, + stacklevel=2, +) + +from meeting_room.api_client import APIClient +from meeting_room.config import load_config +from meeting_room.engine import DiscussionEngine +from meeting_room.events import ( + AGENT_ERROR, + AGENT_MESSAGE, + AGENT_TURN_START, + DISCUSSION_END, + DISCUSSION_START, + ROUND_END, + ROUND_START, + TOOL_CALL, + TOOL_RESULT, + EventEmitter, +) +from meeting_room.models import ProviderConfig from meeting_room.scenario_loader import load_scenario -from meeting_room.tools import set_workdir, get_tool_schemas, execute_tool +from meeting_room.tools import ToolRegistry + +# ── ANSI colors (kept for backward-compatible output) ── -# ANSI colors COLORS = { "moderator": "\033[96m", "skeptic": "\033[91m", @@ -22,150 +43,137 @@ RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" -MAX_TOOL_ITERATIONS = 5 + +def run(scenario_path: str, config: dict) -> list[dict]: + """Run a discussion and return conversation in legacy dict format. + + This function is deprecated. Use DiscussionEngine directly. + + Parameters + ---------- + scenario_path : str + Path to the scenario file (.md or .yaml). + config : dict + Raw config dict (as returned by :func:`load_config`). + + Returns + ------- + list[dict] + Conversation in legacy format ``[{role_id, name, content}, ...]``. + """ + from meeting_room.config import load_discussion_config + + # Build typed config from the raw dict + discussion_config = load_discussion_config() + + # Apply workdir from raw config if present + if "defaults" in config and "workdir" in config["defaults"]: + discussion_config.defaults.workdir = config["defaults"]["workdir"] + + # Build engine components + providers = { + name: ProviderConfig(**vals) + for name, vals in config.get("providers", {}).items() + } + api_client = APIClient(providers=providers) + tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir) + event_bus = EventEmitter() + + # Subscribe event handlers for print output (same format as before) + _subscribe_legacy_handlers(event_bus) + + # Load scenario + scenario_data = load_scenario(scenario_path) + + # Create and run engine + engine = DiscussionEngine( + config=discussion_config, + api_client=api_client, + tool_registry=tool_registry, + events=event_bus, + ) + messages = engine.run(scenario_data) + + # Convert Message objects to legacy dict format + conversation = [ + {"role_id": m.role_id, "name": m.name, "content": m.content} + for m in messages + ] + return conversation -def _format_conversation(conversation: list[dict]) -> str: - return "\n\n".join(f"[{msg['name']}]: {msg['content']}" for msg in conversation) +def _subscribe_legacy_handlers(bus: EventEmitter) -> None: + """Wire up event handlers that produce the same ANSI output as before.""" + def on_discussion_start(event) -> None: + data = event.data + print(f"\n{BOLD}{'=' * 60}") + print(f" MEETING ROOM — {data['name']}") + print(f"{'=' * 60}{RESET}\n") -def _run_agent_turn(role_config, problem, conversation, is_first_message): - """Run one agent turn with tool support. Returns final text response.""" - system_msg = {"role": "system", "content": role_config["system_prompt"].strip()} - - if is_first_message: - context_msg = { - "role": "user", - "content": ( - f"Проблема для обсуждения:\n{problem}\n\n" - "Обсуди её с другими участниками. Выскажи свою позицию. " - "При необходимости используй инструменты." - ), - } - else: - context_msg = { - "role": "user", - "content": ( - f"Вот что уже обсудили:\n\n{_format_conversation(conversation)}\n\n" - "Продолжи обсуждение. Ответь на аргументы, предложи своё. " - "При необходимости используй инструменты." - ), - } - - messages = [system_msg, context_msg] - tool_schemas = get_tool_schemas(role_config.get("tools", "none")) - has_tools = len(tool_schemas) > 0 - - for _ in range(MAX_TOOL_ITERATIONS): - if has_tools: - result = chat( - provider=role_config["provider"], - model=role_config["model"], - messages=messages, - temperature=role_config.get("temperature", 0.7), - tools=tool_schemas, - ) - else: - content = chat_stream( - provider=role_config["provider"], - model=role_config["model"], - messages=messages, - temperature=role_config.get("temperature", 0.7), - ) - return content - - if result["tool_calls"]: - for tc in result["tool_calls"]: - print(f"\n{TOOL_COLOR} -> {tc['name']}({tc['arguments']}){RESET}") - - messages.append({ - "role": "assistant", - "content": result["content"] or None, - "tool_calls": [ - { - "id": tc["id"], - "type": "function", - "function": {"name": tc["name"], "arguments": tc["arguments"]}, - } - for tc in result["tool_calls"] - ], - }) - - for tc in result["tool_calls"]: - try: - args = json.loads(tc["arguments"]) if isinstance(tc["arguments"], str) else tc["arguments"] - except json.JSONDecodeError: - args = {} - tool_result = execute_tool(tc["name"], args) - preview = tool_result[:200].replace("\n", " ") - print(f"{TOOL_COLOR} <- {preview}{'...' if len(tool_result) > 200 else ''}{RESET}") - messages.append({ - "role": "tool", - "tool_call_id": tc["id"], - "content": tool_result, - }) - continue - - if result["content"]: - print(result["content"]) - return result["content"] - - return result.get("content", "") - - -def run(scenario_path: str, config: dict): - init_providers(config) - scenario = load_scenario(scenario_path) - roles_config = config["roles"] - - problem = scenario["problem"] - participants = scenario.get("participants", list(roles_config.keys())) - max_rounds = scenario.get("max_rounds", config["defaults"]["max_rounds"]) - - set_workdir(config["defaults"].get("workdir", ".")) - - conversation = [] - - print(f"\n{BOLD}{'='*60}") - print(f" MEETING ROOM — {scenario['name']}") - print(f"{'='*60}{RESET}\n") - print(f"{BOLD}Problem:{RESET}") - for line in problem.split("\n")[:5]: - print(f" {line}") - if problem.count("\n") > 5: - print(" ...") - print() - print(f"{BOLD}Participants:{RESET}") - for rid in participants: - r = roles_config[rid] - print(f" {r['name']} — {r['provider']}/{r['model']} [tools: {r.get('tools', 'none')}]") - print(f"\n{BOLD}Rounds:{RESET} {max_rounds}") - print(f"{DIM}{'─'*60}{RESET}\n") - - for round_num in range(1, max_rounds + 1): - print(f"{BOLD}[Round {round_num}/{max_rounds}]{RESET}\n") - - for role_id in participants: - role = roles_config[role_id] - color = COLORS.get(role_id, "") - name = role["name"] - is_first = len(conversation) == 0 - - print(f"{color}{BOLD} {name}:{RESET}", end=" ") - - try: - content = _run_agent_turn(role, problem, conversation, is_first) - except Exception as e: - content = f"[Error: {e}]" - print(content) - - conversation.append({"role_id": role_id, "name": name, "content": content}) + problem = data.get("problem", "") + if problem: + print(f"{BOLD}Problem:{RESET}") + for line in problem.split("\n")[:5]: + print(f" {line}") + if problem.count("\n") > 5: + print(" ...") print() - print(f"{DIM}{'─'*60}{RESET}\n") + participants = data.get("participants", []) + if participants: + print(f"{BOLD}Participants:{RESET}") + # Note: we don't have role details in the event, just names + # The engine emits participant names, not full role info. + for name in participants: + print(f" {name}") + print() - print(f"\n{BOLD}{'='*60}") - print(f" DISCUSSION COMPLETE — {len(conversation)} messages") - print(f"{'='*60}{RESET}\n") + print(f"{BOLD}Rounds:{RESET} {data['rounds']}") + print(f"{DIM}{'─' * 60}{RESET}\n") - return conversation + def on_round_start(event) -> None: + data = event.data + print(f"{BOLD}[Round {data['round_num']}/{data['total_rounds']}]{RESET}\n") + + def on_agent_turn_start(event) -> None: + data = event.data + color = COLORS.get(data["role_id"], "") + print(f"{color}{BOLD} {data['name']}:{RESET}", end=" ") + + def on_agent_message(event) -> None: + # Content printed inline after name; just end the line + print() + + def on_tool_call(event) -> None: + data = event.data + print(f"\n{TOOL_COLOR} -> {data['tool_name']}({data['arguments']}){RESET}") + + def on_tool_result(event) -> None: + data = event.data + preview = data["result_preview"] + suffix = "..." if len(data.get("full_result", "")) > 200 else "" + print(f"{TOOL_COLOR} <- {preview}{suffix}{RESET}") + + def on_agent_error(event) -> None: + data = event.data + print(f" [Error: {data['error']}]") + + def on_round_end(event) -> None: + print(f"{DIM}{'─' * 60}{RESET}\n") + + def on_discussion_end(event) -> None: + data = event.data + print(f"\n{BOLD}{'=' * 60}") + print(f" DISCUSSION COMPLETE — {data['total_messages']} messages") + print(f"{'=' * 60}{RESET}\n") + + bus.on(DISCUSSION_START, on_discussion_start) + bus.on(ROUND_START, on_round_start) + bus.on(AGENT_TURN_START, on_agent_turn_start) + bus.on(AGENT_MESSAGE, on_agent_message) + bus.on(TOOL_CALL, on_tool_call) + bus.on(TOOL_RESULT, on_tool_result) + bus.on(AGENT_ERROR, on_agent_error) + bus.on(ROUND_END, on_round_end) + bus.on(DISCUSSION_END, on_discussion_end) \ No newline at end of file diff --git a/meeting_room/cli.py b/meeting_room/cli.py index 4b549f3..94b8fe1 100644 --- a/meeting_room/cli.py +++ b/meeting_room/cli.py @@ -1,49 +1,54 @@ -"""Meeting Room CLI — main entry point.""" +"""Meeting Room CLI — main entry point. + +Refactored (v2): run_discussion() now creates a DiscussionEngine and +subscribes to events with ANSI-color output instead of calling the +legacy backend directly. +""" -import sys -import os import json -import argparse -import yaml +import os +import sys from datetime import datetime -from meeting_room.scenario_loader import load_scenario, list_scenarios as _list_scenarios +from meeting_room.api_client import APIClient +from meeting_room.config import find_workspace_config, load_config, load_discussion_config +from meeting_room.engine import DiscussionEngine +from meeting_room.events import ( + AGENT_ERROR, + AGENT_MESSAGE, + AGENT_TURN_START, + DISCUSSION_END, + DISCUSSION_START, + ROUND_END, + ROUND_START, + TOOL_CALL, + TOOL_RESULT, + EventEmitter, +) +from meeting_room.models import DiscussionConfig, ProviderConfig +from meeting_room.scenario_loader import load_scenario +from meeting_room.tools import ToolRegistry -PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) +# ── ANSI colors ── - -def load_config(config_path: str | None = None) -> dict: - """Load config from file, resolve env vars, apply overrides.""" - if config_path is None: - config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml") - - with open(config_path) as f: - raw = f.read() - - # Resolve ${ENV_VAR} placeholders - import re - def _env_replace(match): - var = match.group(1) - return os.environ.get(var, match.group(0)) - raw = re.sub(r'\$\{(\w+)\}', _env_replace, raw) - - config = yaml.safe_load(raw) - - # Override API keys from environment if set - for prov_name, prov_cfg in config.get("providers", {}).items(): - env_key = f"MEETING_ROOM_{prov_name.upper()}_API_KEY" - if os.environ.get(env_key): - prov_cfg["api_key"] = os.environ[env_key] - env_url = f"MEETING_ROOM_{prov_name.upper()}_BASE_URL" - if os.environ.get(env_url): - prov_cfg["base_url"] = os.environ[env_url] - - return config +COLORS = { + "moderator": "\033[96m", + "skeptic": "\033[91m", + "idea_generator": "\033[92m", + "analyst": "\033[93m", +} +TOOL_COLOR = "\033[90m" +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" def list_scenarios(scenarios_dir: str | None = None): + """Print available scenarios to stdout.""" + from meeting_room.scenario_loader import list_scenarios as _list_scenarios + if scenarios_dir is None: - scenarios_dir = os.path.join(PACKAGE_DIR, "scenarios") + scenarios_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios") items = _list_scenarios(scenarios_dir) if not items: print("No scenarios found. Create .md or .yaml in scenarios/") @@ -59,6 +64,7 @@ def list_scenarios(scenarios_dir: str | None = None): def list_roles(config: dict): + """Print roles and model bindings to stdout.""" roles = config.get("roles", {}) providers = config.get("providers", {}) print("\nRoles and model bindings:") @@ -79,6 +85,8 @@ def list_roles(config: dict): def init_workspace(path: str = "."): """Initialize a .meeting-room workspace in given directory.""" + import yaml # noqa: avoid top-level import for speed + target = os.path.abspath(path) ws_dir = os.path.join(target, ".meeting-room") @@ -209,31 +217,155 @@ def save_session(conversation: list[dict], scenario_name: str, sessions_dir: str return filepath -def find_workspace_config() -> str | None: - """Search for .meeting-room/config.yaml starting from CWD upward.""" - current = os.getcwd() - while current != "/": - candidate = os.path.join(current, ".meeting-room", "config.yaml") - if os.path.exists(candidate): - return candidate - parent = os.path.dirname(current) - if parent == current: - break - current = parent - return None +# --------------------------------------------------------------------------- +# Event handlers — produce the same ANSI output as the legacy backend +# --------------------------------------------------------------------------- + + +def _subscribe_cli_handlers(bus: EventEmitter, max_rounds: int) -> None: + """Wire up event handlers that print ANSI-colored output to stdout. + + The output format matches the original backends/custom/run.py exactly. + """ + + def on_discussion_start(event) -> None: + data = event.data + print(f"\n{BOLD}{'=' * 60}") + print(f" MEETING ROOM — {data['name']}") + print(f"{'=' * 60}{RESET}\n") + + # Print problem preview + problem = data.get("problem", "") + if problem: + print(f"{BOLD}Problem:{RESET}") + for line in problem.split("\n")[:5]: + print(f" {line}") + if problem.count("\n") > 5: + print(" ...") + print() + + # Print participants + participants = data.get("participants", []) + if participants: + print(f"{BOLD}Participants:{RESET}") + for name in participants: + print(f" {name}") + print() + + print(f"{BOLD}Rounds:{RESET} {data['rounds']}") + print(f"{DIM}{'─' * 60}{RESET}\n") + + def on_round_start(event) -> None: + data = event.data + print(f"{BOLD}[Round {data['round_num']}/{data['total_rounds']}]{RESET}\n") + + def on_agent_turn_start(event) -> None: + data = event.data + color = COLORS.get(data["role_id"], "") + print(f"{color}{BOLD} {data['name']}:{RESET}", end=" ") + + def on_agent_message(event) -> None: + # Content is printed inline after the name; just end the line. + print() + + def on_tool_call(event) -> None: + data = event.data + print(f"\n{TOOL_COLOR} -> {data['tool_name']}({data['arguments']}){RESET}") + + def on_tool_result(event) -> None: + data = event.data + preview = data["result_preview"] + suffix = "..." if len(data.get("full_result", "")) > 200 else "" + print(f"{TOOL_COLOR} <- {preview}{suffix}{RESET}") + + def on_agent_error(event) -> None: + data = event.data + # Error content is already printed inline by the name header; + # just note the error type. + print(f" [Error: {data['error']}]") + + def on_round_end(event) -> None: + print(f"{DIM}{'─' * 60}{RESET}\n") + + def on_discussion_end(event) -> None: + data = event.data + print(f"\n{BOLD}{'=' * 60}") + print(f" DISCUSSION COMPLETE — {data['total_messages']} messages") + print(f"{'=' * 60}{RESET}\n") + + bus.on(DISCUSSION_START, on_discussion_start) + bus.on(ROUND_START, on_round_start) + bus.on(AGENT_TURN_START, on_agent_turn_start) + bus.on(AGENT_MESSAGE, on_agent_message) + bus.on(TOOL_CALL, on_tool_call) + bus.on(TOOL_RESULT, on_tool_result) + bus.on(AGENT_ERROR, on_agent_error) + bus.on(ROUND_END, on_round_end) + bus.on(DISCUSSION_END, on_discussion_end) def run_discussion(scenario: str, config: dict, save: bool = False): + """Run a discussion through DiscussionEngine with ANSI event output. + + Parameters + ---------- + scenario : str + Path or name of the scenario file. + config : dict + Raw config dict (as returned by :func:`load_config`). + save : bool + Whether to save the session to a JSON file. + + Returns + ------- + list[dict] + Conversation in the legacy dict format ``[{role_id, name, content}, ...]``. + """ scenario_path = scenario if os.path.isabs(scenario) else os.path.join(os.getcwd(), scenario) if not os.path.exists(scenario_path): # Try bundled scenarios - scenario_path = os.path.join(PACKAGE_DIR, "scenarios", scenario) + scenario_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "scenarios", scenario + ) if not os.path.exists(scenario_path): print(f"Scenario not found: {scenario}") sys.exit(1) - from meeting_room.backends.custom.run import run - conversation = run(scenario_path, config) + # Convert raw config to typed DiscussionConfig + discussion_config = load_discussion_config() + # Apply any overrides from the raw config (e.g. workdir from CLI) + if "defaults" in config and "workdir" in config["defaults"]: + discussion_config.defaults.workdir = config["defaults"]["workdir"] + + # Build engine components + providers = { + name: ProviderConfig(**vals) + for name, vals in config.get("providers", {}).items() + } + api_client = APIClient(providers=providers) + tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir) + event_bus = EventEmitter() + + # Subscribe CLI output handlers + _subscribe_cli_handlers(event_bus, max_rounds=discussion_config.defaults.max_rounds) + + # Load scenario + scenario_data = load_scenario(scenario_path) + + # Create and run engine + engine = DiscussionEngine( + config=discussion_config, + api_client=api_client, + tool_registry=tool_registry, + events=event_bus, + ) + messages = engine.run(scenario_data) + + # Convert Message objects to legacy dict format + conversation = [ + {"role_id": m.role_id, "name": m.name, "content": m.content} + for m in messages + ] if save and conversation: # Determine sessions dir @@ -243,13 +375,16 @@ def run_discussion(scenario: str, config: dict, save: bool = False): else: sessions_dir = os.path.join(os.getcwd(), "sessions") - scenario_data = load_scenario(scenario_path) - save_session(conversation, scenario_data.get("name", "discussion"), sessions_dir) + scenario_obj = load_scenario(scenario_path) + save_session(conversation, scenario_obj.get("name", "discussion"), sessions_dir) return conversation def main(): + """CLI entry point.""" + import argparse + parser = argparse.ArgumentParser( description="Meeting Room — Multi-Agent Discussion Framework", ) @@ -324,4 +459,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/meeting_room/config.py b/meeting_room/config.py new file mode 100644 index 0000000..e1fa84d --- /dev/null +++ b/meeting_room/config.py @@ -0,0 +1,115 @@ +"""Configuration loading for Meeting Room. + +Provides load_config(), find_workspace_config(), and load_discussion_config() +which converts a raw YAML dict into a typed DiscussionConfig. +""" + +from __future__ import annotations + +import os +import re + +import yaml + +from meeting_room.models import DefaultsConfig, DiscussionConfig, ProviderConfig, RoleConfig + +PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def load_config(config_path: str | None = None) -> dict: + """Load config from file, resolve env vars, apply overrides. + + Parameters + ---------- + config_path : str | None + Path to the YAML config file. When *None*, loads the package + default config at ``meeting_room/config/config.yaml``. + + Returns + ------- + dict + Raw configuration dictionary (the result of ``yaml.safe_load``). + """ + if config_path is None: + config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml") + + with open(config_path, encoding="utf-8") as f: + raw = f.read() + + # Resolve ${ENV_VAR} placeholders + def _env_replace(match: re.Match) -> str: + var = match.group(1) + return os.environ.get(var, match.group(0)) + + raw = re.sub(r"\$\{(\w+)\}", _env_replace, raw) + + config = yaml.safe_load(raw) + + # Override API keys from environment if set + for prov_name, prov_cfg in config.get("providers", {}).items(): + env_key = f"MEETING_ROOM_{prov_name.upper()}_API_KEY" + if os.environ.get(env_key): + prov_cfg["api_key"] = os.environ[env_key] + env_url = f"MEETING_ROOM_{prov_name.upper()}_BASE_URL" + if os.environ.get(env_url): + prov_cfg["base_url"] = os.environ[env_url] + + return config + + +def find_workspace_config() -> str | None: + """Search for .meeting-room/config.yaml starting from CWD upward. + + Returns + ------- + str | None + Absolute path to the workspace config file, or *None* if not + found. + """ + current = os.getcwd() + while True: + candidate = os.path.join(current, ".meeting-room", "config.yaml") + if os.path.exists(candidate): + return candidate + parent = os.path.dirname(current) + if parent == current: + # Reached filesystem root + break + current = parent + return None + + +def load_discussion_config(config_path: str | None = None) -> DiscussionConfig: + """Load config and return a typed DiscussionConfig. + + Convenience wrapper that calls :func:`load_config` and then + coerces the raw dict into :class:`DiscussionConfig`. + + Parameters + ---------- + config_path : str | None + Path to the YAML config file. When *None*, loads the package + default. + + Returns + ------- + DiscussionConfig + Typed configuration object ready for use by DiscussionEngine. + """ + raw = load_config(config_path) + + providers = { + name: ProviderConfig(**vals) + for name, vals in raw.get("providers", {}).items() + } + roles = { + name: RoleConfig(**vals) + for name, vals in raw.get("roles", {}).items() + } + defaults = DefaultsConfig(**raw.get("defaults", {})) + + return DiscussionConfig( + providers=providers, + roles=roles, + defaults=defaults, + ) \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..23f48d1 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,263 @@ +"""Tests for meeting_room.config — config loading and DiscussionConfig conversion.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +import yaml + +from meeting_room.config import ( + find_workspace_config, + load_config, + load_discussion_config, +) +from meeting_room.models import ( + DefaultsConfig, + DiscussionConfig, + ProviderConfig, + RoleConfig, +) + + +# --------------------------------------------------------------------------- +# load_config — basic loading +# --------------------------------------------------------------------------- + + +class TestLoadConfig: + def test_loads_default_config(self) -> None: + """load_config() with no path loads the package default config.yaml.""" + config = load_config() + assert "providers" in config + assert "roles" in config + assert "defaults" in config + + def test_loads_custom_config(self, tmp_path) -> None: + """load_config(path) loads a specific YAML file.""" + custom = { + "providers": { + "test": {"base_url": "https://api.test.com/v1", "api_key": "sk-test"}, + }, + "roles": { + "analyst": { + "name": "Analyst", + "provider": "test", + "model": "gpt-3.5", + "temperature": 0.5, + "tools": "none", + "system_prompt": "You are an analyst.", + }, + }, + "defaults": {"max_rounds": 5, "language": "en"}, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8") + + config = load_config(str(config_path)) + + assert config["providers"]["test"]["base_url"] == "https://api.test.com/v1" + assert config["roles"]["analyst"]["name"] == "Analyst" + assert config["defaults"]["max_rounds"] == 5 + + def test_env_var_resolution(self, tmp_path, monkeypatch) -> None: + """${ENV_VAR} placeholders are resolved from the environment.""" + monkeypatch.setenv("TEST_MR_API_KEY", "sk-from-env") + monkeypatch.setenv("TEST_MR_BASE_URL", "https://env-url.com/v1") + + custom = { + "providers": { + "testprov": { + "base_url": "${TEST_MR_BASE_URL}", + "api_key": "${TEST_MR_API_KEY}", + }, + }, + "roles": {}, + "defaults": {"max_rounds": 3}, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8") + + config = load_config(str(config_path)) + + assert config["providers"]["testprov"]["base_url"] == "https://env-url.com/v1" + assert config["providers"]["testprov"]["api_key"] == "sk-from-env" + + def test_unresolved_env_var_preserved(self, tmp_path) -> None: + """Unresolved ${ENV_VAR} stays as-is in the config.""" + custom = { + "providers": { + "testprov": { + "base_url": "https://api.test.com/v1", + "api_key": "${NONEXISTENT_VAR_12345}", + }, + }, + "roles": {}, + "defaults": {}, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8") + + config = load_config(str(config_path)) + + assert config["providers"]["testprov"]["api_key"] == "${NONEXISTENT_VAR_12345}" + + def test_env_override_api_key(self, monkeypatch) -> None: + """MEETING_ROOM__API_KEY overrides the config value.""" + monkeypatch.setenv("MEETING_ROOM_ROUTERAI_API_KEY", "sk-override") + + config = load_config() + assert config["providers"]["routerai"]["api_key"] == "sk-override" + + def test_env_override_base_url(self, monkeypatch) -> None: + """MEETING_ROOM__BASE_URL overrides the config value.""" + monkeypatch.setenv("MEETING_ROOM_ROUTERAI_BASE_URL", "https://override.com/v1") + + config = load_config() + assert config["providers"]["routerai"]["base_url"] == "https://override.com/v1" + + +# --------------------------------------------------------------------------- +# find_workspace_config +# --------------------------------------------------------------------------- + + +class TestFindWorkspaceConfig: + def test_finds_config_in_cwd(self, tmp_path, monkeypatch) -> None: + """Finds .meeting-room/config.yaml in the current working directory.""" + ws_dir = tmp_path / ".meeting-room" + ws_dir.mkdir() + (ws_dir / "config.yaml").write_text("defaults: {}", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = find_workspace_config() + + assert result is not None + assert result.endswith(".meeting-room\\config.yaml") or result.endswith(".meeting-room/config.yaml") + + def test_finds_config_in_parent(self, tmp_path, monkeypatch) -> None: + """Searches upward to find .meeting-room/config.yaml.""" + ws_dir = tmp_path / ".meeting-room" + ws_dir.mkdir() + (ws_dir / "config.yaml").write_text("defaults: {}", encoding="utf-8") + child = tmp_path / "subdir" + child.mkdir() + monkeypatch.chdir(child) + + result = find_workspace_config() + + assert result is not None + + def test_returns_none_when_not_found(self, tmp_path, monkeypatch) -> None: + """Returns None when no .meeting-room/config.yaml exists.""" + # Use a tmp dir with no .meeting-room + isolated = tmp_path / "isolated" + isolated.mkdir() + monkeypatch.chdir(isolated) + # Ensure no .meeting-room exists anywhere up to root + # (This may find a .meeting-room in a parent if one exists, + # but in a temp dir hierarchy it shouldn't) + result = find_workspace_config() + # We can't guarantee None since there might be .meeting-room in parent dirs, + # so we just check it's either None or a valid path + if result is not None: + assert os.path.exists(result) + + +# --------------------------------------------------------------------------- +# load_discussion_config +# --------------------------------------------------------------------------- + + +class TestLoadDiscussionConfig: + def test_returns_discussion_config(self) -> None: + """load_discussion_config() returns a DiscussionConfig instance.""" + config = load_discussion_config() + + assert isinstance(config, DiscussionConfig) + assert isinstance(config.providers, dict) + assert isinstance(config.roles, dict) + assert isinstance(config.defaults, DefaultsConfig) + + def test_providers_are_typed(self) -> None: + """Provider values are ProviderConfig instances.""" + config = load_discussion_config() + + for name, prov in config.providers.items(): + assert isinstance(prov, ProviderConfig), f"Provider {name} is not ProviderConfig" + assert prov.base_url + assert prov.api_key is not None + + def test_roles_are_typed(self) -> None: + """Role values are RoleConfig instances.""" + config = load_discussion_config() + + for name, role in config.roles.items(): + assert isinstance(role, RoleConfig), f"Role {name} is not RoleConfig" + assert role.name + assert role.provider + assert role.model + + def test_defaults_populated(self) -> None: + """DefaultsConfig has expected fields.""" + config = load_discussion_config() + + assert config.defaults.max_rounds > 0 + assert config.defaults.language is not None + assert config.defaults.framework is not None + + def test_custom_config_path(self, tmp_path) -> None: + """load_discussion_config(path) loads from a custom file.""" + custom = { + "providers": { + "test": {"base_url": "https://api.test.com/v1", "api_key": "sk-test"}, + }, + "roles": { + "analyst": { + "name": "Analyst", + "provider": "test", + "model": "gpt-3.5", + "temperature": 0.5, + "tools": "none", + "system_prompt": "You are an analyst.", + }, + }, + "defaults": {"max_rounds": 5, "language": "en"}, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8") + + config = load_discussion_config(str(config_path)) + + assert isinstance(config, DiscussionConfig) + assert "test" in config.providers + assert config.providers["test"].base_url == "https://api.test.com/v1" + assert "analyst" in config.roles + assert config.roles["analyst"].model == "gpt-3.5" + assert config.defaults.max_rounds == 5 + + def test_env_overrides_applied_before_typing(self, monkeypatch) -> None: + """Environment variable overrides are applied before converting to typed config.""" + monkeypatch.setenv("MEETING_ROOM_ROUTERAI_API_KEY", "sk-from-env") + + config = load_discussion_config() + + assert config.providers["routerai"].api_key == "sk-from-env" + + def test_minimal_config(self, tmp_path) -> None: + """A minimal config with just defaults works.""" + minimal = { + "providers": {}, + "roles": {}, + "defaults": {"max_rounds": 3}, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump(minimal, default_flow_style=False), encoding="utf-8") + + config = load_discussion_config(str(config_path)) + + assert isinstance(config, DiscussionConfig) + assert len(config.providers) == 0 + assert len(config.roles) == 0 + assert config.defaults.max_rounds == 3 \ No newline at end of file