Guard rails (mixed_routing.py):
- MAX_AGENTS_PER_MIXED_ROOM (default 5): fail-fast at parse time
- MAX_SLASH_LEN (default 32): reject garbage/injection slash tokens
- Unified rejection reasons: unknown_agent, slash_too_long, no_mapping
- REASON_REJECTED_* constants (separate from success REASON_*)
Ingress (ingress.py):
- per-room-agent concurrency semaphore (MIXED_CONCURRENCY_CAP, default 1)
- active_lock_count property for /health + prometheus
- UNKNOWN_AGENT_BEHAVIOR: "ignore" (silent) | "reply_error" (inform user)
- on_routed(agent_id, reason) callback for routing metrics
- on_route_rejected(room_id, reason) callback for rejection metrics
- matrix.route.rejected audit event on every rejection
Config + main:
- max_agents_per_mixed_room, max_slash_len, unknown_agent_behavior, mixed_concurrency_cap
- matrix_bridge_routed_total{agent_id, reason} counter
- matrix_bridge_route_rejected_total{room_id, reason} counter
- matrix_bridge_active_room_agent_locks gauge
- /health: mixed_guard_rails section + total_agents_in_mixed_rooms
- docker-compose: all 4 new guard rail env vars
Runbook: section 9 — mixed room debug guide (6 acceptance tests, routing metrics, session isolation, lock hang, config guard)
Tests: 108 pass (94 → 108, +14 new tests for guard rails + callbacks + concurrency)
Made-with: Cursor
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""
|
|
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,
|
|
REASON_REJECTED_UNKNOWN_AGENT,
|
|
REASON_REJECTED_SLASH_TOO_LONG,
|
|
REASON_REJECTED_NO_MAPPING,
|
|
)
|
|
|
|
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 reason == REASON_REJECTED_UNKNOWN_AGENT
|
|
|
|
|
|
# ── 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
|
|
|
|
|
|
# ── M2.2: Guard rails ─────────────────────────────────────────────────────────
|
|
|
|
def test_max_agents_per_room_raises():
|
|
"""More agents than max → ValueError at parse time."""
|
|
raw = f"{ROOM_X}=sofiia,helion,druid,nutra,alateya,yaromir" # 6 agents
|
|
allowed_6 = frozenset({"sofiia", "helion", "druid", "nutra", "alateya", "yaromir"})
|
|
with pytest.raises(ValueError, match="MAX_AGENTS_PER_MIXED_ROOM"):
|
|
parse_mixed_room_map(raw, "", allowed_6, max_agents_per_room=5)
|
|
|
|
|
|
def test_max_agents_per_room_exactly_at_limit_ok():
|
|
"""Exactly at limit should succeed."""
|
|
raw = f"{ROOM_X}=sofiia,helion,druid,nutra,alateya" # 5 = default limit
|
|
allowed_5 = frozenset({"sofiia", "helion", "druid", "nutra", "alateya"})
|
|
cfg = parse_mixed_room_map(raw, "", allowed_5, max_agents_per_room=5)
|
|
assert len(cfg.agents_for_room(ROOM_X)) == 5
|
|
|
|
|
|
def test_slash_too_long_returns_rejected_reason():
|
|
"""Slash command token longer than max_slash_len → rejection, no fallthrough."""
|
|
cfg = _make_cfg()
|
|
long_token = "a" * 33 # > default 32
|
|
agent, reason, _ = route_message(
|
|
f"/{long_token} hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}),
|
|
max_slash_len=32,
|
|
)
|
|
assert agent is None
|
|
assert reason == REASON_REJECTED_SLASH_TOO_LONG
|
|
|
|
|
|
def test_slash_exactly_at_max_len_ok():
|
|
"""Slash token exactly at max_slash_len should NOT be rejected."""
|
|
allowed = frozenset({"sofiia", "helion"})
|
|
raw = f"{ROOM_X}=sofiia,helion"
|
|
# Create a 10-char agent name (within limit) — use a mock allowed set
|
|
cfg = parse_mixed_room_map(raw, "", allowed, max_agents_per_room=5)
|
|
agent, reason, _ = route_message("/sofiia hi", ROOM_X, cfg, allowed, max_slash_len=32)
|
|
assert agent == "sofiia"
|
|
assert reason == REASON_SLASH
|
|
|
|
|
|
def test_unknown_slash_returns_rejected_unknown_agent():
|
|
"""Slash with valid-length token but unknown agent → REASON_REJECTED_UNKNOWN_AGENT."""
|
|
cfg = _make_cfg()
|
|
agent, reason, _ = route_message(
|
|
"/druid hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}),
|
|
max_slash_len=32,
|
|
)
|
|
assert agent is None
|
|
assert reason == REASON_REJECTED_UNKNOWN_AGENT
|
|
|
|
|
|
def test_no_mapping_returns_rejected_no_mapping():
|
|
"""Room not in config → REASON_REJECTED_NO_MAPPING."""
|
|
cfg = _make_cfg(room_id=ROOM_X)
|
|
agent, reason, _ = route_message("hello", ROOM_Y, cfg, ALLOWED, max_slash_len=32)
|
|
assert agent is None
|
|
assert reason == REASON_REJECTED_NO_MAPPING
|
|
|
|
|
|
def test_rejection_reasons_are_distinct_constants():
|
|
"""All rejection reason strings must differ from success reasons."""
|
|
success = {REASON_SLASH, REASON_AT_MENTION, REASON_COLON_MENTION, REASON_DEFAULT}
|
|
rejected = {REASON_REJECTED_UNKNOWN_AGENT, REASON_REJECTED_SLASH_TOO_LONG, REASON_REJECTED_NO_MAPPING}
|
|
assert not success.intersection(rejected), "Rejection reasons must not overlap with success reasons"
|