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 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 00:33:07 +03:00
parent 858618b326
commit d0507b3b0c
4 changed files with 722 additions and 201 deletions

View File

@@ -1,16 +1,37 @@
"""Custom backend — round-robin agent discussion with tool support.""" """Legacy backend — delegates to DiscussionEngine.
import json This module is deprecated. Use meeting_room.engine.DiscussionEngine directly.
import os """
import sys
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.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 = { COLORS = {
"moderator": "\033[96m", "moderator": "\033[96m",
"skeptic": "\033[91m", "skeptic": "\033[91m",
@@ -22,150 +43,137 @@ RESET = "\033[0m"
BOLD = "\033[1m" BOLD = "\033[1m"
DIM = "\033[2m" 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.
def _format_conversation(conversation: list[dict]) -> str: This function is deprecated. Use DiscussionEngine directly.
return "\n\n".join(f"[{msg['name']}]: {msg['content']}" for msg in conversation)
Parameters
----------
scenario_path : str
Path to the scenario file (.md or .yaml).
config : dict
Raw config dict (as returned by :func:`load_config`).
def _run_agent_turn(role_config, problem, conversation, is_first_message): Returns
"""Run one agent turn with tool support. Returns final text response.""" -------
system_msg = {"role": "system", "content": role_config["system_prompt"].strip()} list[dict]
Conversation in legacy format ``[{role_id, name, content}, ...]``.
"""
from meeting_room.config import load_discussion_config
if is_first_message: # Build typed config from the raw dict
context_msg = { discussion_config = load_discussion_config()
"role": "user",
"content": ( # Apply workdir from raw config if present
f"Проблема для обсуждения:\n{problem}\n\n" if "defaults" in config and "workdir" in config["defaults"]:
"Обсуди её с другими участниками. Выскажи свою позицию. " discussion_config.defaults.workdir = config["defaults"]["workdir"]
"При необходимости используй инструменты."
), # Build engine components
} providers = {
else: name: ProviderConfig(**vals)
context_msg = { for name, vals in config.get("providers", {}).items()
"role": "user",
"content": (
f"Вот что уже обсудили:\n\n{_format_conversation(conversation)}\n\n"
"Продолжи обсуждение. Ответь на аргументы, предложи своё. "
"При необходимости используй инструменты."
),
} }
api_client = APIClient(providers=providers)
tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir)
event_bus = EventEmitter()
messages = [system_msg, context_msg] # Subscribe event handlers for print output (same format as before)
tool_schemas = get_tool_schemas(role_config.get("tools", "none")) _subscribe_legacy_handlers(event_bus)
has_tools = len(tool_schemas) > 0
for _ in range(MAX_TOOL_ITERATIONS): # Load scenario
if has_tools: scenario_data = load_scenario(scenario_path)
result = chat(
provider=role_config["provider"], # Create and run engine
model=role_config["model"], engine = DiscussionEngine(
messages=messages, config=discussion_config,
temperature=role_config.get("temperature", 0.7), api_client=api_client,
tools=tool_schemas, tool_registry=tool_registry,
events=event_bus,
) )
else: messages = engine.run(scenario_data)
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"]: # Convert Message objects to legacy dict format
for tc in result["tool_calls"]: conversation = [
print(f"\n{TOOL_COLOR} -> {tc['name']}({tc['arguments']}){RESET}") {"role_id": m.role_id, "name": m.name, "content": m.content}
for m in messages
messages.append({ ]
"role": "assistant", return conversation
"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): def _subscribe_legacy_handlers(bus: EventEmitter) -> None:
init_providers(config) """Wire up event handlers that produce the same ANSI output as before."""
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 = []
def on_discussion_start(event) -> None:
data = event.data
print(f"\n{BOLD}{'=' * 60}") print(f"\n{BOLD}{'=' * 60}")
print(f" MEETING ROOM — {scenario['name']}") print(f" MEETING ROOM — {data['name']}")
print(f"{'=' * 60}{RESET}\n") print(f"{'=' * 60}{RESET}\n")
problem = data.get("problem", "")
if problem:
print(f"{BOLD}Problem:{RESET}") print(f"{BOLD}Problem:{RESET}")
for line in problem.split("\n")[:5]: for line in problem.split("\n")[:5]:
print(f" {line}") print(f" {line}")
if problem.count("\n") > 5: if problem.count("\n") > 5:
print(" ...") print(" ...")
print() print()
participants = data.get("participants", [])
if participants:
print(f"{BOLD}Participants:{RESET}") print(f"{BOLD}Participants:{RESET}")
for rid in participants: # Note: we don't have role details in the event, just names
r = roles_config[rid] # The engine emits participant names, not full role info.
print(f" {r['name']}{r['provider']}/{r['model']} [tools: {r.get('tools', 'none')}]") for name in participants:
print(f"\n{BOLD}Rounds:{RESET} {max_rounds}") print(f" {name}")
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})
print() print()
print(f"{BOLD}Rounds:{RESET} {data['rounds']}")
print(f"{DIM}{'' * 60}{RESET}\n") 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 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"\n{BOLD}{'=' * 60}")
print(f" DISCUSSION COMPLETE — {len(conversation)} messages") print(f" DISCUSSION COMPLETE — {data['total_messages']} messages")
print(f"{'=' * 60}{RESET}\n") print(f"{'=' * 60}{RESET}\n")
return conversation 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)

View File

@@ -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 json
import argparse import os
import yaml import sys
from datetime import datetime 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 ──
COLORS = {
def load_config(config_path: str | None = None) -> dict: "moderator": "\033[96m",
"""Load config from file, resolve env vars, apply overrides.""" "skeptic": "\033[91m",
if config_path is None: "idea_generator": "\033[92m",
config_path = os.path.join(PACKAGE_DIR, "config", "config.yaml") "analyst": "\033[93m",
}
with open(config_path) as f: TOOL_COLOR = "\033[90m"
raw = f.read() RESET = "\033[0m"
BOLD = "\033[1m"
# Resolve ${ENV_VAR} placeholders DIM = "\033[2m"
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
def list_scenarios(scenarios_dir: str | None = None): 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: 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) items = _list_scenarios(scenarios_dir)
if not items: if not items:
print("No scenarios found. Create .md or .yaml in scenarios/") 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): def list_roles(config: dict):
"""Print roles and model bindings to stdout."""
roles = config.get("roles", {}) roles = config.get("roles", {})
providers = config.get("providers", {}) providers = config.get("providers", {})
print("\nRoles and model bindings:") print("\nRoles and model bindings:")
@@ -79,6 +85,8 @@ def list_roles(config: dict):
def init_workspace(path: str = "."): def init_workspace(path: str = "."):
"""Initialize a .meeting-room workspace in given directory.""" """Initialize a .meeting-room workspace in given directory."""
import yaml # noqa: avoid top-level import for speed
target = os.path.abspath(path) target = os.path.abspath(path)
ws_dir = os.path.join(target, ".meeting-room") 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 return filepath
def find_workspace_config() -> str | None: # ---------------------------------------------------------------------------
"""Search for .meeting-room/config.yaml starting from CWD upward.""" # Event handlers — produce the same ANSI output as the legacy backend
current = os.getcwd() # ---------------------------------------------------------------------------
while current != "/":
candidate = os.path.join(current, ".meeting-room", "config.yaml")
if os.path.exists(candidate): def _subscribe_cli_handlers(bus: EventEmitter, max_rounds: int) -> None:
return candidate """Wire up event handlers that print ANSI-colored output to stdout.
parent = os.path.dirname(current)
if parent == current: The output format matches the original backends/custom/run.py exactly.
break """
current = parent
return None 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): 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) scenario_path = scenario if os.path.isabs(scenario) else os.path.join(os.getcwd(), scenario)
if not os.path.exists(scenario_path): if not os.path.exists(scenario_path):
# Try bundled scenarios # 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): if not os.path.exists(scenario_path):
print(f"Scenario not found: {scenario}") print(f"Scenario not found: {scenario}")
sys.exit(1) sys.exit(1)
from meeting_room.backends.custom.run import run # Convert raw config to typed DiscussionConfig
conversation = run(scenario_path, config) 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: if save and conversation:
# Determine sessions dir # Determine sessions dir
@@ -243,13 +375,16 @@ def run_discussion(scenario: str, config: dict, save: bool = False):
else: else:
sessions_dir = os.path.join(os.getcwd(), "sessions") sessions_dir = os.path.join(os.getcwd(), "sessions")
scenario_data = load_scenario(scenario_path) scenario_obj = load_scenario(scenario_path)
save_session(conversation, scenario_data.get("name", "discussion"), sessions_dir) save_session(conversation, scenario_obj.get("name", "discussion"), sessions_dir)
return conversation return conversation
def main(): def main():
"""CLI entry point."""
import argparse
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Meeting Room — Multi-Agent Discussion Framework", description="Meeting Room — Multi-Agent Discussion Framework",
) )

115
meeting_room/config.py Normal file
View File

@@ -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,
)

263
tests/test_config.py Normal file
View File

@@ -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_<PROV>_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_<PROV>_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