feat(matrix-bridge-dagi): add mixed-room routing by slash/mention (M2.1)
- mixed_routing.py: parse BRIDGE_MIXED_ROOM_MAP, route by /slash > @mention > name: > default
- ingress.py: _try_enqueue_mixed for mixed rooms, session isolation {room}:{agent}, reply tagging
- config.py: bridge_mixed_room_map + bridge_mixed_defaults fields
- main.py: parse mixed config, pass to MatrixIngressLoop, expose in /health + /bridge/mappings
- docker-compose: BRIDGE_MIXED_ROOM_MAP / BRIDGE_MIXED_DEFAULTS env vars, BRIDGE_ALLOWED_AGENTS multi-value
- tests: 25 routing unit tests + 10 ingress integration tests (94 total pass)
Made-with: Cursor
This commit is contained in:
338
tests/test_matrix_bridge_mixed_ingress.py
Normal file
338
tests/test_matrix_bridge_mixed_ingress.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Tests for mixed-room routing in MatrixIngressLoop (M2.1).
|
||||
|
||||
Covers:
|
||||
- Slash command routes to correct agent in mixed room
|
||||
- @mention routes to correct agent in mixed room
|
||||
- Default fallback routes to first agent
|
||||
- Unknown /slash returns no invoke + audit error
|
||||
- Reply is prefixed with agent name in mixed room
|
||||
- Session isolation: different agents get different session_ids
|
||||
- Multi-room: regular room and mixed room coexist correctly
|
||||
- Rate-limited message in mixed room is dropped
|
||||
- Direct (single-agent) room reply has no prefix
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_BRIDGE = Path(__file__).parent.parent / "services" / "matrix-bridge-dagi"
|
||||
if str(_BRIDGE) not in sys.path:
|
||||
sys.path.insert(0, str(_BRIDGE))
|
||||
|
||||
from app.ingress import MatrixIngressLoop, _QueueEntry # noqa: E402
|
||||
from app.mixed_routing import parse_mixed_room_map # noqa: E402
|
||||
from app.room_mapping import parse_room_map # noqa: E402
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
ROOM_MIXED = "!mixedRoom:daarion.space"
|
||||
ROOM_DIRECT = "!directRoom:daarion.space"
|
||||
ALLOWED = frozenset({"sofiia", "helion", "druid"})
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _make_event(body: str, event_id: str = "evt1", sender: str = "@user:test") -> Dict[str, Any]:
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"sender": sender,
|
||||
"type": "m.room.message",
|
||||
"content": {"msgtype": "m.text", "body": body},
|
||||
}
|
||||
|
||||
|
||||
def _make_ingress(
|
||||
mixed_raw: str = "",
|
||||
direct_raw: str = "",
|
||||
allowed: frozenset = ALLOWED,
|
||||
) -> MatrixIngressLoop:
|
||||
room_map = parse_room_map(direct_raw, allowed) if direct_raw else parse_room_map("", allowed)
|
||||
mixed_cfg = parse_mixed_room_map(mixed_raw, "", allowed) if mixed_raw else None
|
||||
return MatrixIngressLoop(
|
||||
matrix_homeserver_url="https://matrix.test",
|
||||
matrix_access_token="tok_test",
|
||||
matrix_user_id="@bridge:test",
|
||||
router_url="http://router:8000",
|
||||
node_id="test_node",
|
||||
room_map=room_map,
|
||||
mixed_room_config=mixed_cfg,
|
||||
queue_max_events=50,
|
||||
worker_concurrency=1,
|
||||
)
|
||||
|
||||
|
||||
def _fake_client(room_events: Dict[str, List[Dict[str, Any]]]) -> MagicMock:
|
||||
"""Return a mock MatrixClient that yields pre-set events per room."""
|
||||
c = MagicMock()
|
||||
c.extract_room_messages.side_effect = lambda sync_resp, room_id: room_events.get(room_id, [])
|
||||
c.mark_seen = MagicMock()
|
||||
c.send_text = AsyncMock(return_value=None)
|
||||
return c
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_slash_command_routes_to_helion():
|
||||
"""/helion in mixed room → agent=helion, body stripped."""
|
||||
ingress = _make_ingress(mixed_raw=f"{ROOM_MIXED}=sofiia,helion")
|
||||
client = _fake_client({ROOM_MIXED: [_make_event("/helion what is the weather?", event_id="e1")]})
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
|
||||
ingress._queue = queue
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
await ingress._enqueue_from_sync(client, queue, AsyncMock(), {})
|
||||
|
||||
run(_run())
|
||||
|
||||
assert queue.qsize() == 1
|
||||
entry: _QueueEntry = queue.get_nowait()
|
||||
assert entry.agent_id == "helion"
|
||||
assert entry.is_mixed is True
|
||||
assert entry.routing_reason == "slash_command"
|
||||
assert entry.event["content"]["body"] == "what is the weather?"
|
||||
|
||||
|
||||
def test_at_mention_routes_to_sofiia():
|
||||
"""@sofiia in mixed room → agent=sofiia."""
|
||||
ingress = _make_ingress(mixed_raw=f"{ROOM_MIXED}=sofiia,helion")
|
||||
client = _fake_client({ROOM_MIXED: [_make_event("@sofiia check status", event_id="e2")]})
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
|
||||
ingress._queue = queue
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
await ingress._enqueue_from_sync(client, queue, AsyncMock(), {})
|
||||
|
||||
run(_run())
|
||||
|
||||
entry: _QueueEntry = queue.get_nowait()
|
||||
assert entry.agent_id == "sofiia"
|
||||
assert entry.routing_reason == "at_mention"
|
||||
|
||||
|
||||
def test_colon_mention_routes_to_sofiia():
|
||||
"""'sofiia: help' in mixed room → agent=sofiia."""
|
||||
ingress = _make_ingress(mixed_raw=f"{ROOM_MIXED}=sofiia,helion")
|
||||
client = _fake_client({ROOM_MIXED: [_make_event("sofiia: can you help?", event_id="e3")]})
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
|
||||
ingress._queue = queue
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
await ingress._enqueue_from_sync(client, queue, AsyncMock(), {})
|
||||
|
||||
run(_run())
|
||||
|
||||
entry: _QueueEntry = queue.get_nowait()
|
||||
assert entry.agent_id == "sofiia"
|
||||
assert entry.routing_reason == "colon_mention"
|
||||
|
||||
|
||||
def test_default_fallback_routes_to_first_agent():
|
||||
"""Plain text with no routing token → default (first in list = helion)."""
|
||||
ingress = _make_ingress(mixed_raw=f"{ROOM_MIXED}=helion,sofiia")
|
||||
client = _fake_client({ROOM_MIXED: [_make_event("plain message", event_id="e4")]})
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
|
||||
ingress._queue = queue
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
await ingress._enqueue_from_sync(client, queue, AsyncMock(), {})
|
||||
|
||||
run(_run())
|
||||
|
||||
entry: _QueueEntry = queue.get_nowait()
|
||||
assert entry.agent_id == "helion"
|
||||
assert entry.routing_reason == "default"
|
||||
|
||||
|
||||
def test_unknown_slash_not_enqueued_and_audited():
|
||||
"""/unknownbot in mixed room → NOT enqueued, audit error written."""
|
||||
ingress = _make_ingress(mixed_raw=f"{ROOM_MIXED}=sofiia,helion")
|
||||
client = _fake_client({ROOM_MIXED: [_make_event("/unknownbot hello", event_id="e5")]})
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
|
||||
ingress._queue = queue
|
||||
audit_calls: List[str] = []
|
||||
|
||||
async def fake_audit(*args, **kwargs):
|
||||
audit_calls.append(kwargs.get("event", ""))
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._write_audit", side_effect=fake_audit):
|
||||
await ingress._enqueue_from_sync(client, queue, AsyncMock(), {})
|
||||
|
||||
run(_run())
|
||||
|
||||
assert queue.qsize() == 0
|
||||
assert len(audit_calls) >= 1
|
||||
assert any("matrix" in e for e in audit_calls)
|
||||
|
||||
|
||||
def test_reply_prefixed_with_agent_name_in_mixed_room():
|
||||
"""Reply in mixed room must start with 'Helion: '."""
|
||||
ingress = _make_ingress(mixed_raw=f"{ROOM_MIXED}=sofiia,helion")
|
||||
sent_texts: List[str] = []
|
||||
|
||||
async def fake_send(room_id, text, txn_id=None):
|
||||
sent_texts.append(text)
|
||||
|
||||
async def fake_invoke(http_client, router_url, agent_id, node_id, prompt, session_id):
|
||||
return "The weather is sunny"
|
||||
|
||||
entry = _QueueEntry(
|
||||
event=_make_event("/helion weather", event_id="e6"),
|
||||
room_id=ROOM_MIXED,
|
||||
agent_id="helion",
|
||||
enqueue_time=0.0,
|
||||
routing_reason="slash_command",
|
||||
is_mixed=True,
|
||||
)
|
||||
fake_client = MagicMock()
|
||||
fake_client.send_text = AsyncMock(side_effect=fake_send)
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._invoke_router", side_effect=fake_invoke), \
|
||||
patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
await ingress._process_entry(fake_client, AsyncMock(), entry)
|
||||
|
||||
run(_run())
|
||||
|
||||
assert len(sent_texts) == 1
|
||||
assert sent_texts[0].startswith("Helion: ")
|
||||
assert "The weather is sunny" in sent_texts[0]
|
||||
|
||||
|
||||
def test_session_isolation_per_agent():
|
||||
"""Two agents in same mixed room must get different session_ids."""
|
||||
ingress = _make_ingress(mixed_raw=f"{ROOM_MIXED}=sofiia,helion")
|
||||
sessions: List[str] = []
|
||||
|
||||
async def fake_invoke(http_client, router_url, agent_id, node_id, prompt, session_id):
|
||||
sessions.append(session_id)
|
||||
return f"reply from {agent_id}"
|
||||
|
||||
entries = [
|
||||
_QueueEntry(
|
||||
event=_make_event("msg", event_id="s1"),
|
||||
room_id=ROOM_MIXED, agent_id="sofiia",
|
||||
enqueue_time=0.0, routing_reason="default", is_mixed=True,
|
||||
),
|
||||
_QueueEntry(
|
||||
event=_make_event("msg", event_id="h1"),
|
||||
room_id=ROOM_MIXED, agent_id="helion",
|
||||
enqueue_time=0.0, routing_reason="slash_command", is_mixed=True,
|
||||
),
|
||||
]
|
||||
fake_client = MagicMock()
|
||||
fake_client.send_text = AsyncMock()
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._invoke_router", side_effect=fake_invoke), \
|
||||
patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
for e in entries:
|
||||
await ingress._process_entry(fake_client, AsyncMock(), e)
|
||||
|
||||
run(_run())
|
||||
|
||||
assert len(sessions) == 2
|
||||
assert sessions[0] != sessions[1], "Session IDs must differ per agent"
|
||||
assert "sofiia" in sessions[0]
|
||||
assert "helion" in sessions[1]
|
||||
|
||||
|
||||
def test_direct_room_and_mixed_room_coexist():
|
||||
"""Regular direct room and mixed room both processed in same sync."""
|
||||
ingress = _make_ingress(
|
||||
direct_raw=f"druid:{ROOM_DIRECT}",
|
||||
mixed_raw=f"{ROOM_MIXED}=sofiia,helion",
|
||||
allowed=frozenset({"sofiia", "helion", "druid"}),
|
||||
)
|
||||
client = _fake_client({
|
||||
ROOM_DIRECT: [_make_event("direct msg", event_id="d1")],
|
||||
ROOM_MIXED: [_make_event("/helion mixed msg", event_id="m1")],
|
||||
})
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
|
||||
ingress._queue = queue
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
await ingress._enqueue_from_sync(client, queue, AsyncMock(), {})
|
||||
|
||||
run(_run())
|
||||
|
||||
assert queue.qsize() == 2
|
||||
entries_got = [queue.get_nowait() for _ in range(2)]
|
||||
agents = {e.agent_id for e in entries_got}
|
||||
assert agents == {"druid", "helion"}
|
||||
mixed_map = {e.agent_id: e.is_mixed for e in entries_got}
|
||||
assert mixed_map["druid"] is False
|
||||
assert mixed_map["helion"] is True
|
||||
|
||||
|
||||
def test_rate_limited_mixed_room_event_dropped():
|
||||
"""Rate-limited sender in mixed room: only first message passes."""
|
||||
from app.rate_limit import InMemoryRateLimiter
|
||||
|
||||
ingress = _make_ingress(mixed_raw=f"{ROOM_MIXED}=sofiia,helion")
|
||||
ingress._rate_limiter = InMemoryRateLimiter(room_rpm=100, sender_rpm=1)
|
||||
|
||||
events = [
|
||||
_make_event("hello", event_id=f"rl{i}", sender="@spammer:test")
|
||||
for i in range(3)
|
||||
]
|
||||
client = _fake_client({ROOM_MIXED: events})
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
|
||||
ingress._queue = queue
|
||||
|
||||
dropped: List[str] = []
|
||||
ingress._on_rate_limited = lambda room, agent, kind: dropped.append(kind)
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
await ingress._enqueue_from_sync(client, queue, AsyncMock(), {})
|
||||
|
||||
run(_run())
|
||||
|
||||
assert queue.qsize() == 1 # only first passes
|
||||
assert len(dropped) == 2 # two dropped by rate limiter
|
||||
|
||||
|
||||
def test_direct_room_reply_has_no_prefix():
|
||||
"""Reply in single-agent (direct) room must NOT have a prefix."""
|
||||
ingress = _make_ingress(direct_raw=f"druid:{ROOM_DIRECT}", allowed=frozenset({"druid"}))
|
||||
sent_texts: List[str] = []
|
||||
|
||||
async def fake_send(room_id, text, txn_id=None):
|
||||
sent_texts.append(text)
|
||||
|
||||
async def fake_invoke(http_client, router_url, agent_id, node_id, prompt, session_id):
|
||||
return "direct reply no prefix"
|
||||
|
||||
entry = _QueueEntry(
|
||||
event=_make_event("hello", event_id="dr1"),
|
||||
room_id=ROOM_DIRECT, agent_id="druid",
|
||||
enqueue_time=0.0, routing_reason="direct", is_mixed=False,
|
||||
)
|
||||
fake_client = MagicMock()
|
||||
fake_client.send_text = AsyncMock(side_effect=fake_send)
|
||||
|
||||
async def _run():
|
||||
with patch("app.ingress._invoke_router", side_effect=fake_invoke), \
|
||||
patch("app.ingress._write_audit", new=AsyncMock()):
|
||||
await ingress._process_entry(fake_client, AsyncMock(), entry)
|
||||
|
||||
run(_run())
|
||||
|
||||
assert len(sent_texts) == 1
|
||||
assert sent_texts[0] == "direct reply no prefix"
|
||||
227
tests/test_matrix_bridge_mixed_routing.py
Normal file
227
tests/test_matrix_bridge_mixed_routing.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Tests for services/matrix-bridge-dagi/app/mixed_routing.py
|
||||
|
||||
Covers:
|
||||
- parse_mixed_room_map: valid, errors, defaults
|
||||
- route_message: slash, @mention, colon-mention, fallback, unknown agent
|
||||
- reply_prefix: mixed vs single-agent rooms
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_BRIDGE = Path(__file__).parent.parent / "services" / "matrix-bridge-dagi"
|
||||
if str(_BRIDGE) not in sys.path:
|
||||
sys.path.insert(0, str(_BRIDGE))
|
||||
|
||||
from app.mixed_routing import ( # noqa: E402
|
||||
MixedRoomConfig,
|
||||
MixedRoom,
|
||||
parse_mixed_room_map,
|
||||
route_message,
|
||||
reply_prefix,
|
||||
REASON_SLASH,
|
||||
REASON_AT_MENTION,
|
||||
REASON_COLON_MENTION,
|
||||
REASON_DEFAULT,
|
||||
)
|
||||
|
||||
ROOM_X = "!roomX:daarion.space"
|
||||
ROOM_Y = "!roomY:daarion.space"
|
||||
ALLOWED = frozenset({"sofiia", "helion", "druid", "nutra"})
|
||||
|
||||
|
||||
# ── Parsing ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_single_mixed_room():
|
||||
raw = f"{ROOM_X}=sofiia,helion"
|
||||
cfg = parse_mixed_room_map(raw, "", ALLOWED)
|
||||
assert cfg.total_rooms == 1
|
||||
assert cfg.agents_for_room(ROOM_X) == ["sofiia", "helion"]
|
||||
assert cfg.default_agent(ROOM_X) == "sofiia" # first in list
|
||||
|
||||
|
||||
def test_parse_two_mixed_rooms():
|
||||
raw = f"{ROOM_X}=sofiia,helion;{ROOM_Y}=druid,nutra"
|
||||
cfg = parse_mixed_room_map(raw, "", ALLOWED)
|
||||
assert cfg.total_rooms == 2
|
||||
assert cfg.agents_for_room(ROOM_Y) == ["druid", "nutra"]
|
||||
assert cfg.default_agent(ROOM_Y) == "druid"
|
||||
|
||||
|
||||
def test_parse_explicit_default():
|
||||
raw = f"{ROOM_X}=sofiia,helion"
|
||||
defaults = f"{ROOM_X}=helion"
|
||||
cfg = parse_mixed_room_map(raw, defaults, ALLOWED)
|
||||
assert cfg.default_agent(ROOM_X) == "helion"
|
||||
|
||||
|
||||
def test_parse_explicit_default_not_in_agents_raises():
|
||||
raw = f"{ROOM_X}=sofiia,helion"
|
||||
defaults = f"{ROOM_X}=druid" # druid not in agents for ROOM_X
|
||||
with pytest.raises(ValueError, match="Default agent"):
|
||||
parse_mixed_room_map(raw, defaults, ALLOWED)
|
||||
|
||||
|
||||
def test_parse_duplicate_room_raises():
|
||||
raw = f"{ROOM_X}=sofiia;{ROOM_X}=helion"
|
||||
with pytest.raises(ValueError, match="Duplicate room_id"):
|
||||
parse_mixed_room_map(raw, "", ALLOWED)
|
||||
|
||||
|
||||
def test_parse_unknown_agent_raises():
|
||||
raw = f"{ROOM_X}=sofiia,unknown_bot"
|
||||
with pytest.raises(ValueError, match="not in allowed_agents"):
|
||||
parse_mixed_room_map(raw, "", ALLOWED)
|
||||
|
||||
|
||||
def test_parse_bad_room_id_raises():
|
||||
raw = "not-a-room-id=sofiia"
|
||||
with pytest.raises(ValueError, match="Invalid room_id"):
|
||||
parse_mixed_room_map(raw, "", ALLOWED)
|
||||
|
||||
|
||||
def test_parse_empty_map_returns_empty():
|
||||
cfg = parse_mixed_room_map("", "", ALLOWED)
|
||||
assert cfg.total_rooms == 0
|
||||
|
||||
|
||||
def test_parse_semicolons_with_spaces():
|
||||
raw = f" {ROOM_X}=sofiia,helion ; {ROOM_Y}=druid "
|
||||
cfg = parse_mixed_room_map(raw, "", ALLOWED)
|
||||
assert cfg.total_rooms == 2
|
||||
|
||||
|
||||
def test_is_mixed_true_false():
|
||||
raw = f"{ROOM_X}=sofiia,helion"
|
||||
cfg = parse_mixed_room_map(raw, "", ALLOWED)
|
||||
assert cfg.is_mixed(ROOM_X) is True
|
||||
assert cfg.is_mixed(ROOM_Y) is False
|
||||
|
||||
|
||||
def test_as_summary_shape():
|
||||
raw = f"{ROOM_X}=sofiia,helion;{ROOM_Y}=druid"
|
||||
cfg = parse_mixed_room_map(raw, "", ALLOWED)
|
||||
summary = cfg.as_summary()
|
||||
assert len(summary) == 2
|
||||
for entry in summary:
|
||||
assert "room_id" in entry
|
||||
assert "agents" in entry
|
||||
assert "default_agent" in entry
|
||||
|
||||
|
||||
# ── Routing — slash command ────────────────────────────────────────────────────
|
||||
|
||||
def _make_cfg(room_id: str = ROOM_X, agents=("sofiia", "helion")) -> MixedRoomConfig:
|
||||
raw = f"{room_id}={','.join(agents)}"
|
||||
return parse_mixed_room_map(raw, "", frozenset(agents))
|
||||
|
||||
|
||||
def test_slash_routes_to_correct_agent():
|
||||
cfg = _make_cfg()
|
||||
agent, reason, body = route_message("/helion tell me the weather", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent == "helion"
|
||||
assert reason == REASON_SLASH
|
||||
assert body == "tell me the weather"
|
||||
|
||||
|
||||
def test_slash_case_insensitive():
|
||||
cfg = _make_cfg()
|
||||
agent, reason, _ = route_message("/Sofiia hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent == "sofiia"
|
||||
assert reason == REASON_SLASH
|
||||
|
||||
|
||||
def test_slash_empty_body_keeps_original():
|
||||
cfg = _make_cfg()
|
||||
agent, reason, body = route_message("/helion", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent == "helion"
|
||||
# body fallback: original text
|
||||
assert "/helion" in body
|
||||
|
||||
|
||||
def test_slash_unknown_agent_returns_none():
|
||||
cfg = _make_cfg()
|
||||
agent, reason, _ = route_message("/druid hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent is None
|
||||
assert "unknown_slash_druid" in reason
|
||||
|
||||
|
||||
# ── Routing — @mention ────────────────────────────────────────────────────────
|
||||
|
||||
def test_at_mention_routes_correctly():
|
||||
cfg = _make_cfg()
|
||||
agent, reason, body = route_message("@sofiia what is the status?", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent == "sofiia"
|
||||
assert reason == REASON_AT_MENTION
|
||||
assert body == "what is the status?"
|
||||
|
||||
|
||||
def test_at_mention_unknown_falls_through_to_default():
|
||||
cfg = _make_cfg()
|
||||
# @unknown_bot — not in agents → falls through to colon check, then default
|
||||
agent, reason, _ = route_message("@unknown_bot hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent == "sofiia" # default
|
||||
assert reason == REASON_DEFAULT
|
||||
|
||||
|
||||
# ── Routing — colon mention ───────────────────────────────────────────────────
|
||||
|
||||
def test_colon_mention_routes_correctly():
|
||||
cfg = _make_cfg()
|
||||
agent, reason, body = route_message("sofiia: can you help?", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent == "sofiia"
|
||||
assert reason == REASON_COLON_MENTION
|
||||
assert body == "can you help?"
|
||||
|
||||
|
||||
def test_colon_mention_unknown_falls_to_default():
|
||||
cfg = _make_cfg()
|
||||
agent, reason, _ = route_message("druid: hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent == "sofiia"
|
||||
assert reason == REASON_DEFAULT
|
||||
|
||||
|
||||
# ── Routing — priority order ──────────────────────────────────────────────────
|
||||
|
||||
def test_slash_beats_at_mention():
|
||||
"""If text starts with slash, it should be slash-routed even if it also mentions @."""
|
||||
cfg = _make_cfg()
|
||||
agent, reason, _ = route_message("/helion @sofiia hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert reason == REASON_SLASH
|
||||
assert agent == "helion"
|
||||
|
||||
|
||||
# ── Routing — default fallback ────────────────────────────────────────────────
|
||||
|
||||
def test_plain_message_routes_to_default():
|
||||
cfg = _make_cfg()
|
||||
agent, reason, body = route_message("plain message no routing token", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
|
||||
assert agent == "sofiia"
|
||||
assert reason == REASON_DEFAULT
|
||||
assert body == "plain message no routing token"
|
||||
|
||||
|
||||
def test_no_mapping_for_room_returns_none():
|
||||
cfg = _make_cfg(room_id=ROOM_X)
|
||||
agent, reason, _ = route_message("hello", ROOM_Y, cfg, ALLOWED) # ROOM_Y not in config
|
||||
assert agent is None
|
||||
assert reason == "no_mapping"
|
||||
|
||||
|
||||
# ── Reply prefix ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_reply_prefix_mixed_room():
|
||||
assert reply_prefix("sofiia", is_mixed=True) == "Sofiia: "
|
||||
assert reply_prefix("helion", is_mixed=True) == "Helion: "
|
||||
|
||||
|
||||
def test_reply_prefix_single_room_empty():
|
||||
assert reply_prefix("sofiia", is_mixed=False) == ""
|
||||
|
||||
|
||||
def test_reply_prefix_capitalises_first_letter():
|
||||
assert reply_prefix("druid", is_mixed=True) == "Druid: "
|
||||
assert reply_prefix("NUTRA", is_mixed=True) == "Nutra: " # capitalize() normalises case
|
||||
Reference in New Issue
Block a user