feat: add DiscussionEngine with event-driven architecture

- engine.py: DiscussionEngine class with run() and inject_message()
- All print() calls replaced with EventEmitter.emit()
- Event mapping: DISCUSSION_START, ROUND_START, AGENT_TURN_START,
  AGENT_MESSAGE, TOOL_CALL, TOOL_RESULT, AGENT_ERROR, ROUND_END,
  DISCUSSION_END, BOSS_TURN
- Accepts typed dependencies (APIClient, ToolRegistry, EventEmitter)
- 27 engine tests, all 168 total tests passing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 00:19:32 +03:00
parent 6bbbefaa6f
commit 858618b326
2 changed files with 983 additions and 0 deletions

337
meeting_room/engine.py Normal file
View File

@@ -0,0 +1,337 @@
"""DiscussionEngine — event-driven round-robin discussion runner.
Replaces the monolithic backends/custom/run.py with a clean, testable
architecture that emits typed events instead of printing to stdout.
"""
from __future__ import annotations
import json
import logging
from typing import Union
from meeting_room.api_client import APIClient
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
BOSS_TURN,
DISCUSSION_END,
DISCUSSION_START,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
EventEmitter,
)
from meeting_room.models import (
DiscussionConfig,
Message,
RoleConfig,
ScenarioConfig,
ToolCallRecord,
)
from meeting_room.tools import ToolRegistry
logger = logging.getLogger(__name__)
MAX_TOOL_ITERATIONS = 5
class DiscussionEngine:
"""Orchestrates a multi-agent discussion using events for output.
Parameters
----------
config : DiscussionConfig
Top-level configuration (providers, roles, defaults).
api_client : APIClient
Client for making LLM API calls.
tool_registry : ToolRegistry
Registry for tool schema lookup and execution.
events : EventEmitter
Event bus for emitting lifecycle events.
"""
def __init__(
self,
config: DiscussionConfig,
api_client: APIClient,
tool_registry: ToolRegistry,
events: EventEmitter,
) -> None:
self.config = config
self.api = api_client
self.tools = tool_registry
self.events = events
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def run(
self,
scenario: Union[ScenarioConfig, dict],
) -> list[Message]:
"""Run a full discussion and return the conversation as Message objects.
Parameters
----------
scenario : ScenarioConfig or dict
The scenario to run. A dict is accepted for backward
compatibility and is coerced to ScenarioConfig.
Returns
-------
list[Message]
The ordered list of messages produced during the discussion.
"""
if isinstance(scenario, dict):
scenario = ScenarioConfig(**scenario)
participants = scenario.participants or list(self.config.roles.keys())
max_rounds = scenario.max_rounds or self.config.defaults.max_rounds
# Eagerly resolve role configs so missing role_id fails fast.
roles: dict[str, RoleConfig] = {}
for rid in participants:
if rid not in self.config.roles:
raise ValueError(f"Unknown role_id '{rid}' — not in config.roles")
roles[rid] = self.config.roles[rid]
conversation: list[Message] = []
self.events.emit(
DISCUSSION_START,
name=scenario.name,
participants=[roles[rid].name for rid in participants],
rounds=max_rounds,
problem=scenario.problem,
)
for round_num in range(1, max_rounds + 1):
self.events.emit(ROUND_START, round_num=round_num, total_rounds=max_rounds)
for role_id in participants:
role = roles[role_id]
is_first = len(conversation) == 0
self.events.emit(
AGENT_TURN_START,
role_id=role_id,
name=role.name,
)
try:
content = self._run_agent_turn(
role=role,
role_id=role_id,
problem=scenario.problem,
conversation=conversation,
is_first_message=is_first,
)
except Exception as exc:
content = f"[Error: {exc}]"
self.events.emit(
AGENT_ERROR,
role_id=role_id,
name=role.name,
error=str(exc),
)
conversation.append(
Message(role_id=role_id, name=role.name, content=content)
)
self.events.emit(
AGENT_MESSAGE,
role_id=role_id,
name=role.name,
content=content,
)
self.events.emit(ROUND_END, round_num=round_num, total_rounds=max_rounds)
self.events.emit(
DISCUSSION_END,
total_messages=len(conversation),
)
return conversation
def inject_message(self, role_id: str, content: str) -> Message:
"""Inject an arbitrary message from a Boss/CSO role.
This does **not** make an LLM call — it simply appends a message
and emits a BOSS_TURN event so subscribers can log or relay it.
Parameters
----------
role_id : str
Identifier of the injecting role (e.g. "boss", "cso").
content : str
The message content to inject.
Returns
-------
Message
The created Message object.
Raises
------
ValueError
If *role_id* is not in config.roles.
"""
if role_id not in self.config.roles:
raise ValueError(f"Unknown role_id '{role_id}' — not in config.roles")
role = self.config.roles[role_id]
msg = Message(role_id=role_id, name=role.name, content=content)
self.events.emit(
BOSS_TURN,
role_id=role_id,
name=role.name,
content=content,
)
return msg
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _format_conversation(conversation: list[Message]) -> str:
"""Format a list of Message objects into a prompt-friendly string."""
return "\n\n".join(
f"[{msg.name}]: {msg.content}" for msg in conversation
)
def _build_messages(
self,
role: RoleConfig,
problem: str,
conversation: list[Message],
is_first_message: bool,
) -> list[dict]:
"""Build the API message list for one agent turn."""
system_msg = {"role": "system", "content": role.system_prompt.strip()}
if is_first_message:
context_msg = {
"role": "user",
"content": (
f"Problem for discussion:\n{problem}\n\n"
"Discuss it with other participants. Express your position. "
"Use tools if necessary."
),
}
else:
context_msg = {
"role": "user",
"content": (
f"Here is what has been discussed so far:\n\n"
f"{self._format_conversation(conversation)}\n\n"
"Continue the discussion. Respond to arguments, offer your own. "
"Use tools if necessary."
),
}
return [system_msg, context_msg]
def _run_agent_turn(
self,
role: RoleConfig,
role_id: str,
problem: str,
conversation: list[Message],
is_first_message: bool,
) -> str:
"""Run one agent turn with tool support.
Returns the final text content produced by the agent.
"""
messages = self._build_messages(role, problem, conversation, is_first_message)
tool_schemas = self.tools.get_tool_schemas(role.tools)
has_tools = len(tool_schemas) > 0
for _ in range(MAX_TOOL_ITERATIONS):
if has_tools:
result = self.api.chat(
provider=role.provider,
model=role.model,
messages=messages,
temperature=role.temperature,
tools=tool_schemas,
)
else:
content = self.api.chat_stream(
provider=role.provider,
model=role.model,
messages=messages,
temperature=role.temperature,
)
return content
if result["tool_calls"]:
# Append assistant message with tool calls
assistant_msg: dict = {
"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"]
],
}
messages.append(assistant_msg)
# Execute each tool call and append results
for tc in result["tool_calls"]:
self.events.emit(
TOOL_CALL,
role_id=role_id,
tool_name=tc["name"],
arguments=tc["arguments"],
)
try:
args = (
json.loads(tc["arguments"])
if isinstance(tc["arguments"], str)
else tc["arguments"]
)
except json.JSONDecodeError:
args = {}
tool_result = self.tools.execute_tool(tc["name"], args)
preview = tool_result[:200].replace("\n", " ")
self.events.emit(
TOOL_RESULT,
role_id=role_id,
tool_name=tc["name"],
result_preview=preview,
full_result=tool_result,
)
messages.append(
{
"role": "tool",
"tool_call_id": tc["id"],
"content": tool_result,
}
)
continue
# No tool calls — return the content
if result["content"]:
return result["content"]
# Exhausted tool iterations — return whatever we have
return result.get("content", "")

646
tests/test_engine.py Normal file
View File

@@ -0,0 +1,646 @@
"""Tests for meeting_room.engine — DiscussionEngine."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from meeting_room.api_client import APIClient
from meeting_room.engine import DiscussionEngine
from meeting_room.events import (
AGENT_ERROR,
AGENT_MESSAGE,
AGENT_TURN_START,
BOSS_TURN,
DISCUSSION_END,
DISCUSSION_START,
ROUND_END,
ROUND_START,
TOOL_CALL,
TOOL_RESULT,
Event,
EventEmitter,
)
from meeting_room.models import (
DefaultsConfig,
DiscussionConfig,
ProviderConfig,
RoleConfig,
ScenarioConfig,
)
from meeting_room.tools import ToolRegistry
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
PROVIDERS = {
"openai": ProviderConfig(base_url="https://api.openai.com/v1", api_key="sk-test"),
}
ROLES = {
"analyst": RoleConfig(
name="Analyst",
provider="openai",
model="gpt-4o-mini",
temperature=0.7,
tools="none",
system_prompt="You are an analyst.",
),
"skeptic": RoleConfig(
name="Skeptic",
provider="openai",
model="gpt-4o-mini",
temperature=0.5,
tools="none",
system_prompt="You are a skeptic.",
),
"researcher": RoleConfig(
name="Researcher",
provider="openai",
model="gpt-4o-mini",
temperature=0.7,
tools="all",
system_prompt="You research things.",
),
}
BASIC_CONFIG = DiscussionConfig(
providers=PROVIDERS,
roles=ROLES,
defaults=DefaultsConfig(max_rounds=3),
)
BASIC_SCENARIO = ScenarioConfig(
name="Test Discussion",
participants=["analyst", "skeptic"],
max_rounds=2,
problem="Should we use Python or Rust?",
)
def _make_mock_api(
stream_responses: dict[str, str] | None = None,
chat_responses: dict[str, dict] | None = None,
) -> MagicMock:
"""Create a mock APIClient.
stream_responses: maps model -> text response for chat_stream
chat_responses: maps model -> dict response for chat
"""
api = MagicMock(spec=APIClient)
default_stream = stream_responses or {"gpt-4o-mini": "I think Python is great."}
default_chat = chat_responses or {}
def chat_side_effect(provider, model, messages, temperature=0.7, tools=None):
if model in default_chat:
return default_chat[model]
return {"content": "Chat response", "tool_calls": None}
def stream_side_effect(provider, model, messages, temperature=0.7):
return default_stream.get(model, "Stream response")
api.chat.side_effect = chat_side_effect
api.chat_stream.side_effect = stream_side_effect
return api
def _make_engine(
config: DiscussionConfig | None = None,
api: APIClient | None = None,
events: EventEmitter | None = None,
) -> tuple[DiscussionEngine, EventEmitter]:
"""Create a DiscussionEngine with sensible defaults. Returns (engine, event_bus)."""
cfg = config or BASIC_CONFIG
bus = events or EventEmitter()
api_client = api or _make_mock_api()
tools = ToolRegistry(workdir=".")
engine = DiscussionEngine(
config=cfg,
api_client=api_client,
tool_registry=tools,
events=bus,
)
return engine, bus
# ---------------------------------------------------------------------------
# DiscussionEngine — construction
# ---------------------------------------------------------------------------
class TestDiscussionEngineInit:
def test_stores_config(self) -> None:
engine, _ = _make_engine()
assert engine.config is BASIC_CONFIG
def test_stores_dependencies(self) -> None:
api = _make_mock_api()
bus = EventEmitter()
tools = ToolRegistry()
engine = DiscussionEngine(BASIC_CONFIG, api, tools, bus)
assert engine.api is api
assert engine.tools is tools
assert engine.events is bus
# ---------------------------------------------------------------------------
# DiscussionEngine.run — basic lifecycle
# ---------------------------------------------------------------------------
class TestDiscussionEngineRunLifecycle:
def test_emits_discussion_start(self) -> None:
engine, bus = _make_engine()
events: list[Event] = []
bus.on(DISCUSSION_START, lambda e: events.append(e))
engine.run(BASIC_SCENARIO)
assert len(events) == 1
assert events[0].data["name"] == "Test Discussion"
assert events[0].data["rounds"] == 2
def test_emits_round_start_and_end(self) -> None:
engine, bus = _make_engine()
starts: list[Event] = []
ends: list[Event] = []
bus.on(ROUND_START, lambda e: starts.append(e))
bus.on(ROUND_END, lambda e: ends.append(e))
engine.run(BASIC_SCENARIO)
assert len(starts) == 2 # 2 rounds
assert len(ends) == 2
assert starts[0].data["round_num"] == 1
assert starts[1].data["round_num"] == 2
assert ends[0].data["total_rounds"] == 2
def test_emits_agent_turn_start_for_each_participant(self) -> None:
engine, bus = _make_engine()
turn_starts: list[Event] = []
bus.on(AGENT_TURN_START, lambda e: turn_starts.append(e))
engine.run(BASIC_SCENARIO)
# 2 participants * 2 rounds = 4 turns
assert len(turn_starts) == 4
assert turn_starts[0].data["role_id"] == "analyst"
assert turn_starts[0].data["name"] == "Analyst"
assert turn_starts[1].data["role_id"] == "skeptic"
def test_emits_agent_message_for_each_turn(self) -> None:
engine, bus = _make_engine()
messages: list[Event] = []
bus.on(AGENT_MESSAGE, lambda e: messages.append(e))
engine.run(BASIC_SCENARIO)
assert len(messages) == 4 # 2 participants * 2 rounds
def test_emits_discussion_end(self) -> None:
engine, bus = _make_engine()
end_events: list[Event] = []
bus.on(DISCUSSION_END, lambda e: end_events.append(e))
engine.run(BASIC_SCENARIO)
assert len(end_events) == 1
assert end_events[0].data["total_messages"] == 4
def test_returns_conversation(self) -> None:
engine, _ = _make_engine()
result = engine.run(BASIC_SCENARIO)
assert len(result) == 4
assert all(isinstance(m, __import__("meeting_room.models", fromlist=["Message"]).Message) for m in result)
assert result[0].role_id == "analyst"
assert result[1].role_id == "skeptic"
def test_full_event_order(self) -> None:
"""Events must be emitted in the correct order for a 1-round, 2-participant discussion."""
config = DiscussionConfig(
providers=PROVIDERS,
roles=ROLES,
defaults=DefaultsConfig(max_rounds=3),
)
scenario = ScenarioConfig(
name="Order Test",
participants=["analyst", "skeptic"],
max_rounds=1,
problem="Test problem",
)
engine, bus = _make_engine(config=config)
log: list[str] = []
bus.on(DISCUSSION_START, lambda e: log.append("DISCUSSION_START"))
bus.on(ROUND_START, lambda e: log.append(f"ROUND_START:{e.data['round_num']}"))
bus.on(AGENT_TURN_START, lambda e: log.append(f"TURN_START:{e.data['role_id']}"))
bus.on(AGENT_MESSAGE, lambda e: log.append(f"AGENT_MESSAGE:{e.data['role_id']}"))
bus.on(ROUND_END, lambda e: log.append(f"ROUND_END:{e.data['round_num']}"))
bus.on(DISCUSSION_END, lambda e: log.append("DISCUSSION_END"))
engine.run(scenario)
assert log == [
"DISCUSSION_START",
"ROUND_START:1",
"TURN_START:analyst",
"AGENT_MESSAGE:analyst",
"TURN_START:skeptic",
"AGENT_MESSAGE:skeptic",
"ROUND_END:1",
"DISCUSSION_END",
]
# ---------------------------------------------------------------------------
# DiscussionEngine.run — backward compatibility
# ---------------------------------------------------------------------------
class TestDiscussionEngineRunDict:
def test_accepts_dict_scenario(self) -> None:
engine, bus = _make_engine()
events: list[Event] = []
bus.on(DISCUSSION_START, lambda e: events.append(e))
scenario_dict = {
"name": "Dict Test",
"participants": ["analyst"],
"max_rounds": 1,
"problem": "Dict problem?",
}
result = engine.run(scenario_dict)
assert events[0].data["name"] == "Dict Test"
assert len(result) == 1
def test_uses_default_participants_when_empty(self) -> None:
"""If scenario.participants is empty, use all roles from config."""
engine, bus = _make_engine()
turn_starts: list[Event] = []
bus.on(AGENT_TURN_START, lambda e: turn_starts.append(e))
scenario = ScenarioConfig(
name="Default Participants",
participants=[], # empty
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
# Should use all 3 roles from config: analyst, skeptic, researcher
# (but researcher has tools="all" — mock the chat call)
assert len(turn_starts) == 3
assert len(result) == 3
def test_uses_config_default_max_rounds_when_zero(self) -> None:
engine, bus = _make_engine()
end_events: list[Event] = []
bus.on(DISCUSSION_END, lambda e: end_events.append(e))
scenario = ScenarioConfig(
name="Rounds Test",
participants=["analyst"],
max_rounds=0, # zero → should fall back to config default (3)
problem="Test",
)
engine.run(scenario)
assert end_events[0].data["total_messages"] == 3 # 1 participant * 3 rounds
# ---------------------------------------------------------------------------
# DiscussionEngine.run — unknown role
# ---------------------------------------------------------------------------
class TestDiscussionEngineRunUnknownRole:
def test_unknown_role_id_raises(self) -> None:
engine, _ = _make_engine()
scenario = ScenarioConfig(
name="Bad Role",
participants=["nonexistent_role"],
max_rounds=1,
problem="Test",
)
with pytest.raises(ValueError, match="Unknown role_id"):
engine.run(scenario)
# ---------------------------------------------------------------------------
# DiscussionEngine._run_agent_turn — tool calls
# ---------------------------------------------------------------------------
class TestDiscussionEngineToolCalls:
def test_tool_call_events_are_emitted(self) -> None:
"""When an agent makes a tool call, TOOL_CALL and TOOL_RESULT events must fire."""
api = _make_mock_api()
# First call returns a tool call, second call returns text
api.chat.side_effect = None
api.chat.side_effect = [
{
"content": None,
"tool_calls": [
{
"id": "call_001",
"name": "read_file",
"arguments": '{"path": "/tmp/test.txt"}',
}
],
},
{"content": "I read the file.", "tool_calls": None},
]
engine, bus = _make_engine(api=api)
tool_calls: list[Event] = []
tool_results: list[Event] = []
bus.on(TOOL_CALL, lambda e: tool_calls.append(e))
bus.on(TOOL_RESULT, lambda e: tool_results.append(e))
scenario = ScenarioConfig(
name="Tool Test",
participants=["researcher"], # has tools="all"
max_rounds=1,
problem="Read a file",
)
result = engine.run(scenario)
assert len(tool_calls) == 1
assert tool_calls[0].data["role_id"] == "researcher"
assert tool_calls[0].data["tool_name"] == "read_file"
assert len(tool_results) == 1
assert tool_results[0].data["role_id"] == "researcher"
assert tool_results[0].data["tool_name"] == "read_file"
# The final message should contain the text response
assert "I read the file." in result[0].content
def test_multiple_tool_calls_in_single_turn(self) -> None:
"""Multiple tool calls in one response should all emit events."""
api = _make_mock_api()
api.chat.side_effect = None
api.chat.side_effect = [
{
"content": None,
"tool_calls": [
{"id": "call_001", "name": "read_file", "arguments": '{"path": "/a"}'},
{"id": "call_002", "name": "list_files", "arguments": '{"path": "/b"}'},
],
},
{"content": "Done researching.", "tool_calls": None},
]
engine, bus = _make_engine(api=api)
tool_calls: list[Event] = []
bus.on(TOOL_CALL, lambda e: tool_calls.append(e))
scenario = ScenarioConfig(
name="Multi-Tool",
participants=["researcher"],
max_rounds=1,
problem="Research",
)
engine.run(scenario)
assert len(tool_calls) == 2
assert tool_calls[0].data["tool_name"] == "read_file"
assert tool_calls[1].data["tool_name"] == "list_files"
def test_tool_result_preview_truncated(self) -> None:
"""Tool results longer than 200 chars should have truncated preview in event."""
long_result = "x" * 500
api = _make_mock_api()
api.chat.side_effect = None
# We patch execute_tool to return a long string
engine, bus = _make_engine(api=api)
tool_results: list[Event] = []
bus.on(TOOL_RESULT, lambda e: tool_results.append(e))
original_execute = engine.tools.execute_tool
def mock_execute(name, args):
if name == "read_file":
return long_result
return original_execute(name, args)
engine.tools.execute_tool = mock_execute
api.chat.side_effect = [
{
"content": None,
"tool_calls": [
{"id": "call_001", "name": "read_file", "arguments": '{"path": "/x"}'},
],
},
{"content": "Got it.", "tool_calls": None},
]
scenario = ScenarioConfig(
name="Truncation Test",
participants=["researcher"],
max_rounds=1,
problem="Read big file",
)
engine.run(scenario)
# Preview is first 200 chars with newlines replaced by spaces
assert len(tool_results[0].data["result_preview"]) <= 200
assert "\n" not in tool_results[0].data["result_preview"]
# Full result should be preserved
assert tool_results[0].data["full_result"] == long_result
# ---------------------------------------------------------------------------
# DiscussionEngine.run — error handling
# ---------------------------------------------------------------------------
class TestDiscussionEngineErrors:
def test_api_error_emits_agent_error(self) -> None:
"""If the API call raises, the engine should emit AGENT_ERROR and continue."""
api = _make_mock_api()
api.chat_stream.side_effect = None
api.chat_stream.side_effect = RuntimeError("API timeout")
engine, bus = _make_engine(api=api)
error_events: list[Event] = []
bus.on(AGENT_ERROR, lambda e: error_events.append(e))
scenario = ScenarioConfig(
name="Error Test",
participants=["analyst"], # no tools, uses chat_stream
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
assert len(error_events) == 1
assert "API timeout" in error_events[0].data["error"]
# The message content should contain the error
assert "[Error:" in result[0].content
def test_all_agents_error_still_completes_discussion(self) -> None:
"""Even if every agent errors, the discussion should complete with DISCUSSION_END."""
api = _make_mock_api()
api.chat_stream.side_effect = None
api.chat_stream.side_effect = RuntimeError("fail")
engine, bus = _make_engine(api=api)
end_events: list[Event] = []
bus.on(DISCUSSION_END, lambda e: end_events.append(e))
scenario = ScenarioConfig(
name="All Error",
participants=["analyst", "skeptic"],
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
assert len(end_events) == 1
assert end_events[0].data["total_messages"] == 2
assert all("[Error:" in m.content for m in result)
# ---------------------------------------------------------------------------
# DiscussionEngine.inject_message
# ---------------------------------------------------------------------------
class TestDiscussionEngineInjectMessage:
def test_inject_emits_boss_turn(self) -> None:
engine, bus = _make_engine()
boss_events: list[Event] = []
bus.on(BOSS_TURN, lambda e: boss_events.append(e))
msg = engine.inject_message("analyst", "Stop discussing and wrap up.")
assert len(boss_events) == 1
assert boss_events[0].data["role_id"] == "analyst"
assert boss_events[0].data["content"] == "Stop discussing and wrap up."
assert boss_events[0].data["name"] == "Analyst"
def test_inject_returns_message(self) -> None:
engine, _ = _make_engine()
msg = engine.inject_message("skeptic", "I disagree.")
assert msg.role_id == "skeptic"
assert msg.name == "Skeptic"
assert msg.content == "I disagree."
def test_inject_unknown_role_raises(self) -> None:
engine, _ = _make_engine()
with pytest.raises(ValueError, match="Unknown role_id"):
engine.inject_message("nonexistent", "test")
# ---------------------------------------------------------------------------
# DiscussionEngine._format_conversation
# ---------------------------------------------------------------------------
class TestFormatConversation:
def test_formats_messages(self) -> None:
conversation = [
__import__("meeting_room.models", fromlist=["Message"]).Message(
role_id="a", name="Alice", content="Hello"
),
__import__("meeting_room.models", fromlist=["Message"]).Message(
role_id="b", name="Bob", content="Hi there"
),
]
result = DiscussionEngine._format_conversation(conversation)
assert "[Alice]: Hello" in result
assert "[Bob]: Hi there" in result
def test_empty_conversation(self) -> None:
result = DiscussionEngine._format_conversation([])
assert result == ""
# ---------------------------------------------------------------------------
# DiscussionEngine._build_messages
# ---------------------------------------------------------------------------
class TestBuildMessages:
def test_first_message_contains_problem(self) -> None:
engine, _ = _make_engine()
role = ROLES["analyst"]
messages = engine._build_messages(role, "Should we use Go?", [], is_first_message=True)
assert messages[0]["role"] == "system"
assert messages[0]["content"] == role.system_prompt.strip()
assert "Should we use Go?" in messages[1]["content"]
def test_subsequent_message_contains_conversation(self) -> None:
engine, _ = _make_engine()
role = ROLES["analyst"]
conversation = [
__import__("meeting_room.models", fromlist=["Message"]).Message(
role_id="analyst", name="Analyst", content="I think Python is great."
),
]
messages = engine._build_messages(role, "The problem", conversation, is_first_message=False)
assert "I think Python is great." in messages[1]["content"]
assert "Continue the discussion" in messages[1]["content"]
# ---------------------------------------------------------------------------
# Integration: streaming vs non-streaming path
# ---------------------------------------------------------------------------
class TestStreamingPath:
def test_no_tools_uses_chat_stream(self) -> None:
"""Agents with tools='none' should use chat_stream (streaming)."""
api = _make_mock_api(stream_responses={"gpt-4o-mini": "Streamed response"})
engine, bus = _make_engine(api=api)
msg_events: list[Event] = []
bus.on(AGENT_MESSAGE, lambda e: msg_events.append(e))
scenario = ScenarioConfig(
name="Stream Test",
participants=["analyst"], # tools="none"
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
api.chat_stream.assert_called_once()
api.chat.assert_not_called()
assert result[0].content == "Streamed response"
def test_with_tools_uses_chat(self) -> None:
"""Agents with tools should use chat (non-streaming)."""
api = _make_mock_api()
api.chat.side_effect = None
api.chat.return_value = {"content": "Research done.", "tool_calls": None}
engine, bus = _make_engine(api=api)
scenario = ScenarioConfig(
name="Chat Test",
participants=["researcher"], # tools="all"
max_rounds=1,
problem="Test",
)
result = engine.run(scenario)
api.chat.assert_called_once()
api.chat_stream.assert_not_called()
assert result[0].content == "Research done."