""" 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"