fix(meshcore): deliver DM replies (flood/ACK), save meshcore_context, test-llm, inbound logging (#21)

Four fixes surfaced by live testing (a MeshCore DM got no reply):

- DM REPLY DELIVERY (root cause): reply used the meshcore lib's fire-and-forget
  send_msg (MSG_SENT != delivered, no flood, no ACK) so replies to nodes without
  an established direct path silently vanished. Switch to send_msg_with_retry
  (contact resolve + flood fallback + ACK wait); a None return (no ACK) is now a
  real failure, not silent success. _run_coro timeout raised to 40s for the ACK cycle.
- 422 on save: register meshcore_context in config_loader SECTION_TO_FILE
  (config.yaml) — it was in VALID_SECTIONS but not the save-routing table.
- test-llm endpoint: called backend.generate() with (str, []) instead of
  (messages:list, system_prompt:str) → "string indices" error; fixed the call.
- Inbound observability + robustness: subscribe to CONTACT_MSG_RECV BEFORE
  start_auto_message_fetching (+ ensure_contacts) so a DM queued at connect isn't
  drained before the handler registers; add INFO/DEBUG logging across the inbound
  DM + dispatch + send path (was entirely unlogged).

Tests: +test_meshcore_dm_delivery, +test_fix_meshcore_save_and_llm_test; 0 new failures.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-03 19:54:16 -06:00 committed by GitHub
commit ddd47afa87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 385 additions and 12 deletions

View file

@ -0,0 +1,145 @@
"""Regression tests for two bug fixes:
Bug 1 save_section raises ValueError for 'meshcore_context'
Covered by test_meshcore_context_save_section_no_error (uses tmp config dir).
Bug 2 POST /api/config/test-llm "string indices must be integers, not 'str'"
The handler was calling backend.generate(str, list) instead of
generate(list[dict], str). The google backend then iterated over the string
char-by-char and blew up on `msg["role"]`. After the fix the handler calls
generate([{"role": "user", "content": "..."}], ""), which returns a plain
string the correct return type.
"""
from __future__ import annotations
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# -----------------------------------------------------------------------
# Stub heavy optional deps so config_routes can be imported without them.
# -----------------------------------------------------------------------
for _mod in ("openai", "aiosqlite", "anthropic", "google", "google.genai"):
sys.modules.setdefault(_mod, MagicMock())
# ==========================================================================
# Bug 1 — meshcore_context in SECTION_TO_FILE
# ==========================================================================
def test_meshcore_context_in_section_to_file():
"""'meshcore_context' must be present in SECTION_TO_FILE mapping to
config.yaml, otherwise save_section raises ValueError (HTTP 422)."""
from meshai.config_loader import SECTION_TO_FILE
assert "meshcore_context" in SECTION_TO_FILE, (
"meshcore_context missing from SECTION_TO_FILE"
)
assert SECTION_TO_FILE["meshcore_context"] == "config.yaml"
def test_meshcore_context_save_section_no_error(tmp_path):
"""save_section('meshcore_context', ...) must not raise ValueError.
Uses a minimal on-disk config.yaml so the saver has something to
round-trip without needing !include or live config infrastructure.
"""
import yaml
from meshai.config_loader import save_section
cfg_dir = tmp_path / "config"
cfg_dir.mkdir()
# Minimal seed so _load_yaml_preserve finds the file.
(cfg_dir / "config.yaml").write_text(
yaml.safe_dump({"timezone": "UTC"})
)
# Should not raise
result = save_section(
"meshcore_context",
{
"enable_passive_context": True,
"observe_channels": ["#general"],
"ignore_contacts": [],
"respond_to_dms": False,
},
cfg_dir,
)
assert result["saved"] is True
assert any("config.yaml" in f for f in result["files_written"])
# Verify the value was actually written to config.yaml
on_disk = yaml.safe_load((cfg_dir / "config.yaml").read_text())
assert "meshcore_context" in on_disk
assert on_disk["meshcore_context"]["observe_channels"] == ["#general"]
# ==========================================================================
# Bug 2 — POST /api/config/test-llm
# ==========================================================================
def _build_test_app():
"""Build a minimal FastAPI app wired to config_routes with a google config."""
from fastapi import FastAPI
from meshai.dashboard.api.config_routes import router
from meshai.config import Config, LLMConfig
app = FastAPI()
app.include_router(router, prefix="/api")
cfg = Config()
cfg.llm = LLMConfig(backend="google", model="gemini-2.5-flash", api_key="fake-key")
app.state.config = cfg
app.state.config_path = None # not needed for this route
return app
def test_test_llm_google_backend_returns_success():
"""POST /api/config/test-llm with google backend must return success:true.
The GoogleBackend.generate() returns a plain string. The old handler
passed (str, list) to generate() and crashed; the fixed handler passes
([{"role": "user", "content": "..."}], ""), which the mocked backend
receives as a proper list and returns the string reply.
"""
from fastapi.testclient import TestClient
app = _build_test_app()
# resolve_api_key() must return a non-empty string so the handler proceeds.
# GoogleBackend is mocked so no real HTTP call is made.
with (
patch.object(
app.state.config.__class__,
"resolve_api_key",
return_value="fake-key",
),
patch(
"meshai.backends.GoogleBackend",
) as MockGoogleBackend,
):
mock_instance = MagicMock()
# generate() returns a plain string — that is the real return type.
mock_instance.generate = AsyncMock(return_value="OK")
mock_instance.close = AsyncMock()
MockGoogleBackend.return_value = mock_instance
client = TestClient(app)
r = client.post("/api/config/test-llm")
assert r.status_code == 200, r.text
body = r.json()
assert body["success"] is True
assert body["response"] == "OK"
# Confirm generate was called with a list as first arg (not a string)
call_args = mock_instance.generate.call_args
messages_arg = call_args[0][0] if call_args[0] else call_args[1].get("messages")
assert isinstance(messages_arg, list), (
f"generate() first arg must be list[dict], got {type(messages_arg)}"
)
assert messages_arg[0]["role"] == "user"

View file

@ -0,0 +1,181 @@
"""Focused tests for the MeshCore DM delivery fix.
Verifies that send_message(..., destination=...) calls send_msg_with_retry
(not the old fire-and-forget send_msg) and correctly maps its return value
to True/False:
- non-error Event returned True (ACKed, delivered)
- None returned False (no ACK, delivery not confirmed)
The meshcore lib is mocked via sys.modules (same pattern as the existing
transport test module). _run_coro is patched to execute the coroutine
synchronously so no background event-loop thread is needed.
"""
import asyncio
import sys
import types
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Minimal fake meshcore module (guards against import errors if the real lib
# is absent, and avoids side-effects from the sys.modules entry in the other
# transport test module racing this one).
# ---------------------------------------------------------------------------
def _ensure_fake_meshcore():
if "meshcore" in sys.modules:
return
mod = types.ModuleType("meshcore")
class EventType:
CONTACT_MSG_RECV = "CONTACT_MSG_RECV"
CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV"
DISCONNECTED = "DISCONNECTED"
CONNECTED = "CONNECTED"
mod.EventType = EventType
class _FakeMeshCore:
self_info = {"public_key": "aabbccdd1122", "name": "FakeNode"}
contacts = {}
async def start_auto_message_fetching(self):
pass
async def stop_auto_message_fetching(self):
pass
async def disconnect(self):
pass
def subscribe(self, event_type, callback):
pass
def get_contact_by_key_prefix(self, prefix):
return None
@classmethod
async def create_tcp(cls, host, port,
auto_reconnect=True, max_reconnect_attempts=5):
return cls()
class commands:
@staticmethod
async def send_chan_msg(chan_idx, text):
result = MagicMock()
result.is_error.return_value = False
return result
@staticmethod
async def send_msg(dst, text):
result = MagicMock()
result.is_error.return_value = False
return result
@staticmethod
async def send_advert(flood=False):
pass
mod.MeshCore = _FakeMeshCore
sys.modules["meshcore"] = mod
_ensure_fake_meshcore()
from meshai.config import ConnectionConfig # noqa: E402
from meshai.transport.meshcore_transport import MeshCoreTransport # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mc_config():
return ConnectionConfig(meshcore_host="127.0.0.1", meshcore_port=5050)
def _transport_with_mc_mock():
"""Return a MeshCoreTransport with _mc as a MagicMock (no loop thread).
_run_coro is patched on the instance to run the coroutine synchronously via
asyncio.get_event_loop().run_until_complete(), bypassing the thread bridge.
This keeps tests fast and deterministic.
"""
cfg = _mc_config()
t = MeshCoreTransport(cfg)
mc = MagicMock()
t._mc = mc
t._connected = True
def _sync_run_coro(coro, timeout=None):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
t._run_coro = _sync_run_coro
return t, mc
# ---------------------------------------------------------------------------
# DM delivery tests
# ---------------------------------------------------------------------------
class TestMeshCoreDMDelivery:
"""send_message with destination= must use send_msg_with_retry, not send_msg."""
def test_acked_dm_returns_true_and_uses_send_msg_with_retry(self):
"""ACK received (non-error Event) → returns True; send_msg_with_retry called."""
t, mc = _transport_with_mc_mock()
ok_event = MagicMock()
ok_event.is_error.return_value = False
mc.commands.send_msg_with_retry = AsyncMock(return_value=ok_event)
mc.commands.send_msg = AsyncMock() # must NOT be called
result = t.send_message("reply text", destination="aabbccdd1122")
assert result is True
mc.commands.send_msg_with_retry.assert_awaited_once_with("aabbccdd1122", "reply text")
mc.commands.send_msg.assert_not_awaited()
def test_no_ack_returns_false(self):
"""No ACK (send_msg_with_retry returns None) → returns False."""
t, mc = _transport_with_mc_mock()
mc.commands.send_msg_with_retry = AsyncMock(return_value=None)
result = t.send_message("reply text", destination="aabbccdd1122")
assert result is False
mc.commands.send_msg_with_retry.assert_awaited_once_with("aabbccdd1122", "reply text")
def test_error_event_returns_false(self):
"""Error event returned by send_msg_with_retry → returns False."""
t, mc = _transport_with_mc_mock()
err_event = MagicMock()
err_event.is_error.return_value = True
mc.commands.send_msg_with_retry = AsyncMock(return_value=err_event)
result = t.send_message("fail text", destination="deadbeef0011")
assert result is False
def test_no_ack_logs_warning(self, caplog):
"""No ACK → a warning mentioning the destination is logged."""
import logging
t, mc = _transport_with_mc_mock()
mc.commands.send_msg_with_retry = AsyncMock(return_value=None)
with caplog.at_level(logging.WARNING):
t.send_message("msg", destination="deadbeef0011")
assert any(
"not ACKed" in r.getMessage() or "delivery not confirmed" in r.getMessage()
for r in caplog.records
)

View file

@ -326,24 +326,24 @@ class TestSendMessageChannel:
# ---------------------------------------------------------------------------
class TestSendMessageDM:
def test_dispatches_send_msg(self):
def test_dispatches_send_msg_with_retry(self):
t, mc, _ = _transport_with_mock_mc()
try:
ok = MagicMock()
ok.is_error.return_value = False
mc.commands.send_msg = AsyncMock(return_value=ok)
mc.commands.send_msg_with_retry = AsyncMock(return_value=ok)
result = t.send_message("hi DM", destination="aabbcc")
assert result is True
mc.commands.send_msg.assert_awaited_once_with("aabbcc", "hi DM")
mc.commands.send_msg_with_retry.assert_awaited_once_with("aabbcc", "hi DM")
finally:
_cleanup(t)
def test_send_msg_error_returns_false(self):
def test_send_msg_with_retry_error_returns_false(self):
t, mc, _ = _transport_with_mock_mc()
try:
err = MagicMock()
err.is_error.return_value = True
mc.commands.send_msg = AsyncMock(return_value=err)
mc.commands.send_msg_with_retry = AsyncMock(return_value=err)
assert t.send_message("hi", destination="deadbeef") is False
finally:
_cleanup(t)