mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
70a0fd1657
commit
ddd47afa87
6 changed files with 385 additions and 12 deletions
145
work/tests/test_fix_meshcore_save_and_llm_test.py
Normal file
145
work/tests/test_fix_meshcore_save_and_llm_test.py
Normal 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"
|
||||
181
work/tests/test_meshcore_dm_delivery.py
Normal file
181
work/tests/test_meshcore_dm_delivery.py
Normal 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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue