meshai/work/tests/test_llm_scoping.py
malice a191200a12
feat(meshcore): decouple per-mesh LLM DM gate + mesh-scoped chat context (#19)
- router.should_respond branches on message.transport: MeshCore DMs are
  governed solely by meshcore_context.respond_to_dms (enforced at the
  transport); Meshtastic solely by bot.respond_to_dms. No global gate.
- MeshObservation tagged per-transport; the LLM "recent traffic" block is
  scoped to the originating mesh (keyword override for the other mesh),
  labeled by mesh so the model knows which it is describing.
- MeshCore observe_channels is now opt-in (empty = observe none).
- Chat-context retention 30d -> 14d (both meshes).
- Meshtastic integer channel-index filter no longer misapplied to MeshCore
  observations (their channel is a companion slot index).
- Frontend: relabel DM toggles per-mesh ("Answer direct messages",
  Meshtastic-only / MeshCore-only), remove the false channel-mention
  tooltips, opt-in wording for MeshCore observe-channels.

Tests: +tests/test_llm_scoping.py (10), context-filter updated for opt-in;
0 new failures (34 pre-existing).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:39:26 -06:00

178 lines
7 KiB
Python

"""Tests for per-mesh LLM scoping changes.
Covers:
(a) should_respond DM gate decoupling (MeshCore vs Meshtastic)
(b) get_context_block(transport=...) per-mesh filtering
(c) Meshtastic channel-index filter is skipped for MeshCore observations
(d) ContextConfig.max_age default is 14 days (1_209_600 seconds)
"""
import sys
from unittest.mock import MagicMock
import pytest
# ---------------------------------------------------------------------------
# Stub optional heavy deps so meshai.router can be imported in this env.
# These stubs are set before any meshai.router import in this process;
# they are no-ops in production where the real packages are installed.
# ---------------------------------------------------------------------------
for _mod in ("openai", "aiosqlite", "anthropic", "google", "google.genai"):
sys.modules.setdefault(_mod, MagicMock())
from meshai.config import ( # noqa: E402
BotConfig,
Config,
ContextConfig,
MeshCoreContextConfig,
)
from meshai.connector import MeshMessage # noqa: E402
from meshai.context import MeshContext # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_message(transport="meshtastic", is_dm=True, text="hello"):
return MeshMessage(
sender_id="abc1",
sender_name="Alice",
text=text,
channel=0,
is_dm=is_dm,
transport=transport,
)
def _make_router(respond_to_dms: bool, meshcore_respond_to_dms: bool = True):
"""Build a minimal Router with mocked dependencies."""
from meshai.router import MessageRouter # noqa: PLC0415
config = Config()
config.bot = BotConfig(respond_to_dms=respond_to_dms)
config.meshcore_context = MeshCoreContextConfig(
respond_to_dms=meshcore_respond_to_dms
)
connector = MagicMock()
connector.my_node_id = "zzzz"
router = MessageRouter.__new__(MessageRouter)
router.config = config
router.connector = connector
router.meshmonitor_sync = None
router.continuations = MagicMock()
router.continuations.has_pending.return_value = False
return router
# ---------------------------------------------------------------------------
# (a) should_respond — DM gate decoupling
# ---------------------------------------------------------------------------
def test_meshcore_dm_passes_when_bot_respond_to_dms_false():
"""MeshCore DM should reach should_respond=True even if bot.respond_to_dms=False.
The global bot.respond_to_dms toggle is Meshtastic-only; MeshCore DMs
are pre-filtered at the transport by meshcore_context.respond_to_dms.
"""
router = _make_router(respond_to_dms=False, meshcore_respond_to_dms=True)
msg = _make_message(transport="meshcore", is_dm=True)
assert router.should_respond(msg) is True
def test_meshtastic_dm_blocked_when_bot_respond_to_dms_false():
"""Meshtastic DM must be blocked when bot.respond_to_dms=False."""
router = _make_router(respond_to_dms=False)
msg = _make_message(transport="meshtastic", is_dm=True)
assert router.should_respond(msg) is False
def test_meshtastic_dm_passes_when_bot_respond_to_dms_true():
"""Meshtastic DM passes when bot.respond_to_dms=True."""
router = _make_router(respond_to_dms=True)
msg = _make_message(transport="meshtastic", is_dm=True)
assert router.should_respond(msg) is True
# ---------------------------------------------------------------------------
# (b) get_context_block(transport=...) — per-mesh filtering
# ---------------------------------------------------------------------------
def test_get_context_block_no_transport_filter_returns_all():
ctx = MeshContext()
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
block = ctx.get_context_block(transport=None)
assert "hello from MT" in block
assert "hello from MC" in block
def test_get_context_block_transport_meshtastic_only():
ctx = MeshContext()
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
block = ctx.get_context_block(transport="meshtastic")
assert "hello from MT" in block
assert "hello from MC" not in block
def test_get_context_block_transport_meshcore_only():
ctx = MeshContext()
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
ctx.observe("Bob", "b1", "hello from MC", channel=1, is_dm=False, transport="meshcore")
block = ctx.get_context_block(transport="meshcore")
assert "hello from MC" in block
assert "hello from MT" not in block
def test_get_context_block_empty_when_no_matching_transport():
ctx = MeshContext()
ctx.observe("Alice", "a1", "hello from MT", channel=0, is_dm=False, transport="meshtastic")
block = ctx.get_context_block(transport="meshcore")
assert block == ""
# ---------------------------------------------------------------------------
# (c) Meshtastic channel-index filter is skipped for MeshCore observations
# ---------------------------------------------------------------------------
def test_meshtastic_channel_filter_skipped_for_meshcore():
"""MeshCore observations bypass the Meshtastic _observe_channels index filter.
observe_channels=[0] would normally block channel=5 for Meshtastic, but a
MeshCore observation with channel=5 must still be recorded.
"""
ctx = MeshContext(observe_channels=[0]) # only Meshtastic ch0 allowed
# MeshCore channel slot 5: should NOT be filtered by Meshtastic indices
ctx.observe("MCNode", "mc1", "meshcore msg", channel=5, is_dm=False, transport="meshcore")
# Meshtastic ch5: should be filtered out
ctx.observe("MTNode", "mt1", "meshtastic msg", channel=5, is_dm=False, transport="meshtastic")
assert ctx.count == 1
block = ctx.get_context_block()
assert "meshcore msg" in block
assert "meshtastic msg" not in block
def test_meshtastic_channel_filter_still_applies_to_meshtastic():
"""Meshtastic channel-index filter still blocks unlisted Meshtastic channels."""
ctx = MeshContext(observe_channels=[0])
ctx.observe("MTNode", "mt1", "ch0 msg", channel=0, is_dm=False, transport="meshtastic")
ctx.observe("MTNode", "mt1", "ch1 msg", channel=1, is_dm=False, transport="meshtastic")
assert ctx.count == 1
block = ctx.get_context_block()
assert "ch0 msg" in block
assert "ch1 msg" not in block
# ---------------------------------------------------------------------------
# (d) ContextConfig.max_age default is 14 days
# ---------------------------------------------------------------------------
def test_context_config_max_age_default_is_14_days():
cfg = ContextConfig()
assert cfg.max_age == 1_209_600, (
f"Expected 1_209_600 (14 days) but got {cfg.max_age}"
)