Closes the full Matrix ↔ DAGI loop:
Egress:
- invoke Router POST /v1/agents/{agent_id}/infer (field: prompt, response: response)
- send_text() reply to Matrix room with idempotent txn_id = make_txn_id(room_id, event_id)
- empty reply → skip send (no spam)
- reply truncated to 4000 chars if needed
Audit (via sofiia-console POST /api/audit/internal):
- matrix.message.received (on ingress)
- matrix.agent.replied (on successful reply)
- matrix.error (on router/send failure, with error_code)
- fire-and-forget: audit failures never crash the loop
Router URL fix:
- DAGI_GATEWAY_URL now points to dagi-router-node1:8000 (not gateway:9300)
- Session ID: stable per room — matrix:{room_localpart} (memory context)
9 tests: invoke endpoint, fallback fields, audit write, full cycle,
dedupe, empty reply skip, metric callbacks
Made-with: Cursor
449 lines
16 KiB
Python
449 lines
16 KiB
Python
"""
|
|
Tests for services/matrix-bridge-dagi/app/ingress.py (M1.4 — egress + audit)
|
|
|
|
Strategy:
|
|
- mock MatrixClient sync_poll / send_text
|
|
- mock httpx client for router invoke and audit write
|
|
- verify: gateway invoked, send_text called with correct args
|
|
- verify: dedupe prevents double-invoke
|
|
- verify: audit events fire (received, replied, error)
|
|
- verify: empty reply skips send_text (no spam)
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
from unittest.mock import AsyncMock, MagicMock, patch, call
|
|
|
|
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.ingress import MatrixIngressLoop, _invoke_router, _write_audit # noqa: E402
|
|
from app.room_mapping import parse_room_map # noqa: E402
|
|
|
|
|
|
def run(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
ALLOWED = frozenset({"sofiia"})
|
|
ROOM_ID = "!QwHczWXgefDHBEVkTH:daarion.space"
|
|
ROOM_MAP_STR = f"sofiia:{ROOM_ID}"
|
|
ROUTER_URL = "http://dagi-router-node1:8000"
|
|
HS_URL = "http://dagi-synapse-node1:8008"
|
|
CONSOLE_URL = "http://dagi-sofiia-console-node1:8002"
|
|
INTERNAL_TOKEN = "test_internal_token_xyz"
|
|
TOKEN = "syt_fake_token"
|
|
BOT_USER = "@dagi_bridge:daarion.space"
|
|
USER = "@user:daarion.space"
|
|
|
|
MSG_EVENT = {
|
|
"type": "m.room.message",
|
|
"event_id": "$event1:server",
|
|
"sender": USER,
|
|
"content": {"msgtype": "m.text", "body": "Привіт Sofiia!"},
|
|
"origin_server_ts": 1000,
|
|
}
|
|
|
|
|
|
def _make_loop(**kwargs) -> MatrixIngressLoop:
|
|
room_map = parse_room_map(ROOM_MAP_STR, ALLOWED)
|
|
defaults = dict(
|
|
matrix_homeserver_url=HS_URL,
|
|
matrix_access_token=TOKEN,
|
|
matrix_user_id=BOT_USER,
|
|
router_url=ROUTER_URL,
|
|
node_id="NODA1",
|
|
room_map=room_map,
|
|
sofiia_console_url=CONSOLE_URL,
|
|
sofiia_internal_token=INTERNAL_TOKEN,
|
|
)
|
|
defaults.update(kwargs)
|
|
return MatrixIngressLoop(**defaults)
|
|
|
|
|
|
def _fake_sync(events: list) -> dict:
|
|
return {
|
|
"next_batch": "s_next",
|
|
"rooms": {"join": {ROOM_ID: {"timeline": {"events": events}}}},
|
|
}
|
|
|
|
|
|
def _ok_response(text: str = "Привіт! Я тут.") -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {"response": text, "model": "test", "tokens_used": 100}
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
|
|
|
|
def _audit_response() -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {"ok": True}
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
|
|
|
|
def _send_text_response() -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {"event_id": "$reply:server"}
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
|
|
|
|
# ── _invoke_router ─────────────────────────────────────────────────────────────
|
|
|
|
def test_invoke_router_correct_endpoint_and_field():
|
|
async def _inner():
|
|
captured = {}
|
|
|
|
async def fake_post(url, *, json=None, timeout=None):
|
|
captured["url"] = url
|
|
captured["payload"] = json
|
|
return _ok_response("pong!")
|
|
|
|
client = MagicMock()
|
|
client.post = fake_post
|
|
result = await _invoke_router(client, ROUTER_URL, "sofiia", "NODA1", "ping", "session-1")
|
|
|
|
assert "/v1/agents/sofiia/infer" in captured["url"]
|
|
assert captured["payload"]["prompt"] == "ping"
|
|
assert captured["payload"]["session_id"] == "session-1"
|
|
assert result == "pong!"
|
|
run(_inner())
|
|
|
|
|
|
def test_invoke_router_fallback_fields():
|
|
"""Should pick up text/content/message if response key missing."""
|
|
async def _inner():
|
|
async def fake_post(url, *, json=None, timeout=None):
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {"text": "hello from text field"}
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
|
|
client = MagicMock()
|
|
client.post = fake_post
|
|
result = await _invoke_router(client, ROUTER_URL, "sofiia", "NODA1", "hi", "s1")
|
|
assert result == "hello from text field"
|
|
run(_inner())
|
|
|
|
|
|
# ── _write_audit ───────────────────────────────────────────────────────────────
|
|
|
|
def test_write_audit_fires_to_console():
|
|
async def _inner():
|
|
captured = {}
|
|
|
|
async def fake_post(url, *, json=None, headers=None, timeout=None):
|
|
captured["url"] = url
|
|
captured["headers"] = headers
|
|
captured["json"] = json
|
|
return _audit_response()
|
|
|
|
client = MagicMock()
|
|
client.post = fake_post
|
|
|
|
await _write_audit(
|
|
client, CONSOLE_URL, INTERNAL_TOKEN,
|
|
event="matrix.message.received",
|
|
agent_id="sofiia", node_id="NODA1",
|
|
room_id=ROOM_ID, event_id="$e1",
|
|
status="ok",
|
|
)
|
|
assert "/api/audit/internal" in captured["url"]
|
|
assert captured["headers"]["X-Internal-Service-Token"] == INTERNAL_TOKEN
|
|
assert captured["json"]["event"] == "matrix.message.received"
|
|
run(_inner())
|
|
|
|
|
|
def test_write_audit_no_op_when_no_token():
|
|
async def _inner():
|
|
called = [False]
|
|
|
|
async def fake_post(*args, **kwargs):
|
|
called[0] = True
|
|
return _audit_response()
|
|
|
|
client = MagicMock()
|
|
client.post = fake_post
|
|
# Empty token — should not call
|
|
await _write_audit(client, CONSOLE_URL, "", "matrix.test", "sofiia", "NODA1", ROOM_ID, "$e1")
|
|
assert not called[0]
|
|
run(_inner())
|
|
|
|
|
|
def test_write_audit_never_raises():
|
|
async def _inner():
|
|
async def fake_post(*args, **kwargs):
|
|
raise ConnectionError("audit server down")
|
|
|
|
client = MagicMock()
|
|
client.post = fake_post
|
|
# Should not raise
|
|
await _write_audit(client, CONSOLE_URL, INTERNAL_TOKEN, "matrix.test", "sofiia", "NODA1", ROOM_ID, "$e1")
|
|
run(_inner())
|
|
|
|
|
|
# ── Full loop: ingress + egress + audit ────────────────────────────────────────
|
|
|
|
def test_loop_full_cycle_invoke_and_reply():
|
|
"""One message → router invoked → send_text called with reply."""
|
|
async def _inner():
|
|
router_calls: List[Dict] = []
|
|
send_calls: List[Dict] = []
|
|
audit_calls: List[Dict] = []
|
|
|
|
stop = asyncio.Event()
|
|
loop = _make_loop()
|
|
|
|
call_count = [0]
|
|
|
|
async def fake_sync_poll(**kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] > 1:
|
|
stop.set()
|
|
return {"next_batch": "end", "rooms": {}}
|
|
return _fake_sync([MSG_EVENT])
|
|
|
|
def fake_extract(sync_resp, room_id):
|
|
events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
|
|
return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]
|
|
|
|
async def fake_http_post(url, *, json=None, headers=None, timeout=None):
|
|
if "/infer" in url:
|
|
router_calls.append({"url": url, "json": json})
|
|
return _ok_response("Привіт! Я готова допомогти.")
|
|
elif "/audit/internal" in url:
|
|
audit_calls.append({"url": url, "json": json})
|
|
return _audit_response()
|
|
return _audit_response()
|
|
|
|
async def fake_send_text(room_id, text, txn_id):
|
|
send_calls.append({"room_id": room_id, "text": text, "txn_id": txn_id})
|
|
return {"event_id": "$reply_event"}
|
|
|
|
with patch("app.ingress.MatrixClient") as MockClient:
|
|
mock_mc = AsyncMock()
|
|
mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
|
|
mock_mc.__aexit__ = AsyncMock(return_value=False)
|
|
mock_mc.sync_poll = fake_sync_poll
|
|
mock_mc.join_room = AsyncMock()
|
|
mock_mc.mark_seen = MagicMock()
|
|
mock_mc.extract_room_messages = fake_extract
|
|
mock_mc.send_text = fake_send_text
|
|
|
|
# Patch MatrixClient.make_txn_id as static method
|
|
MockClient.return_value = mock_mc
|
|
MockClient.make_txn_id = lambda r, e: f"txn_{e}"
|
|
|
|
with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
|
|
mock_http = AsyncMock()
|
|
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
|
|
mock_http.__aexit__ = AsyncMock(return_value=False)
|
|
mock_http.post = fake_http_post
|
|
MockHTTP.return_value = mock_http
|
|
|
|
await asyncio.wait_for(loop.run(stop), timeout=3.0)
|
|
|
|
# Router invoked once
|
|
assert len(router_calls) == 1
|
|
assert "sofiia" in router_calls[0]["url"]
|
|
assert router_calls[0]["json"]["prompt"] == "Привіт Sofiia!"
|
|
|
|
# Reply sent once
|
|
assert len(send_calls) == 1
|
|
assert send_calls[0]["room_id"] == ROOM_ID
|
|
assert send_calls[0]["text"] == "Привіт! Я готова допомогти."
|
|
|
|
# Audit events: at least received + replied
|
|
audit_events = [a["json"]["event"] for a in audit_calls]
|
|
assert "matrix.message.received" in audit_events
|
|
assert "matrix.agent.replied" in audit_events
|
|
|
|
run(_inner())
|
|
|
|
|
|
def test_loop_deduplication_no_double_invoke():
|
|
"""Same event_id in two syncs → router called exactly once."""
|
|
async def _inner():
|
|
router_calls = [0]
|
|
seen = set()
|
|
|
|
stop = asyncio.Event()
|
|
loop = _make_loop()
|
|
|
|
call_count = [0]
|
|
|
|
async def fake_sync_poll(**kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] > 2:
|
|
stop.set()
|
|
return {"next_batch": "end", "rooms": {}}
|
|
return _fake_sync([MSG_EVENT])
|
|
|
|
def fake_extract(sync_resp, room_id):
|
|
events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
|
|
return [e for e in events if e.get("type") == "m.room.message"
|
|
and e.get("sender") != BOT_USER
|
|
and e.get("event_id") not in seen]
|
|
|
|
def fake_mark_seen(eid):
|
|
seen.add(eid)
|
|
|
|
async def fake_http_post(url, *, json=None, headers=None, timeout=None):
|
|
if "/infer" in url:
|
|
router_calls[0] += 1
|
|
return _ok_response("response")
|
|
return _audit_response()
|
|
|
|
with patch("app.ingress.MatrixClient") as MockClient:
|
|
mock_mc = AsyncMock()
|
|
mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
|
|
mock_mc.__aexit__ = AsyncMock(return_value=False)
|
|
mock_mc.sync_poll = fake_sync_poll
|
|
mock_mc.join_room = AsyncMock()
|
|
mock_mc.mark_seen = fake_mark_seen
|
|
mock_mc.extract_room_messages = fake_extract
|
|
mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"})
|
|
MockClient.return_value = mock_mc
|
|
MockClient.make_txn_id = lambda r, e: f"txn_{e}"
|
|
|
|
with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
|
|
mock_http = AsyncMock()
|
|
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
|
|
mock_http.__aexit__ = AsyncMock(return_value=False)
|
|
mock_http.post = fake_http_post
|
|
MockHTTP.return_value = mock_http
|
|
|
|
await asyncio.wait_for(loop.run(stop), timeout=3.0)
|
|
|
|
assert router_calls[0] == 1
|
|
|
|
run(_inner())
|
|
|
|
|
|
def test_loop_empty_reply_skips_send():
|
|
"""Empty reply from router → send_text NOT called."""
|
|
async def _inner():
|
|
send_called = [False]
|
|
stop = asyncio.Event()
|
|
loop = _make_loop()
|
|
|
|
call_count = [0]
|
|
|
|
async def fake_sync_poll(**kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] > 1:
|
|
stop.set()
|
|
return {"next_batch": "end", "rooms": {}}
|
|
return _fake_sync([MSG_EVENT])
|
|
|
|
def fake_extract(sync_resp, room_id):
|
|
events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
|
|
return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]
|
|
|
|
async def fake_http_post(url, *, json=None, headers=None, timeout=None):
|
|
if "/infer" in url:
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {"response": ""} # empty
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
return _audit_response()
|
|
|
|
async def fake_send_text(room_id, text, txn_id):
|
|
send_called[0] = True
|
|
return {"event_id": "$r"}
|
|
|
|
with patch("app.ingress.MatrixClient") as MockClient:
|
|
mock_mc = AsyncMock()
|
|
mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
|
|
mock_mc.__aexit__ = AsyncMock(return_value=False)
|
|
mock_mc.sync_poll = fake_sync_poll
|
|
mock_mc.join_room = AsyncMock()
|
|
mock_mc.mark_seen = MagicMock()
|
|
mock_mc.extract_room_messages = fake_extract
|
|
mock_mc.send_text = fake_send_text
|
|
MockClient.return_value = mock_mc
|
|
MockClient.make_txn_id = lambda r, e: f"txn_{e}"
|
|
|
|
with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
|
|
mock_http = AsyncMock()
|
|
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
|
|
mock_http.__aexit__ = AsyncMock(return_value=False)
|
|
mock_http.post = fake_http_post
|
|
MockHTTP.return_value = mock_http
|
|
|
|
await asyncio.wait_for(loop.run(stop), timeout=3.0)
|
|
|
|
assert not send_called[0]
|
|
|
|
run(_inner())
|
|
|
|
|
|
def test_loop_metric_callbacks_fire():
|
|
"""on_message_received and on_message_replied should be called."""
|
|
async def _inner():
|
|
received = []
|
|
replied = []
|
|
|
|
stop = asyncio.Event()
|
|
loop = _make_loop(
|
|
on_message_received=lambda r, a: received.append((r, a)),
|
|
on_message_replied=lambda r, a, s: replied.append((r, a, s)),
|
|
)
|
|
|
|
call_count = [0]
|
|
|
|
async def fake_sync_poll(**kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] > 1:
|
|
stop.set()
|
|
return {"next_batch": "end", "rooms": {}}
|
|
return _fake_sync([MSG_EVENT])
|
|
|
|
def fake_extract(sync_resp, room_id):
|
|
events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
|
|
return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]
|
|
|
|
async def fake_http_post(url, *, json=None, headers=None, timeout=None):
|
|
if "/infer" in url:
|
|
return _ok_response("test reply")
|
|
return _audit_response()
|
|
|
|
with patch("app.ingress.MatrixClient") as MockClient:
|
|
mock_mc = AsyncMock()
|
|
mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
|
|
mock_mc.__aexit__ = AsyncMock(return_value=False)
|
|
mock_mc.sync_poll = fake_sync_poll
|
|
mock_mc.join_room = AsyncMock()
|
|
mock_mc.mark_seen = MagicMock()
|
|
mock_mc.extract_room_messages = fake_extract
|
|
mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"})
|
|
MockClient.return_value = mock_mc
|
|
MockClient.make_txn_id = lambda r, e: f"txn_{e}"
|
|
|
|
with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
|
|
mock_http = AsyncMock()
|
|
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
|
|
mock_http.__aexit__ = AsyncMock(return_value=False)
|
|
mock_http.post = fake_http_post
|
|
MockHTTP.return_value = mock_http
|
|
|
|
await asyncio.wait_for(loop.run(stop), timeout=3.0)
|
|
|
|
assert len(received) == 1
|
|
assert received[0] == (ROOM_ID, "sofiia")
|
|
assert len(replied) == 1
|
|
assert replied[0][2] == "ok"
|
|
|
|
run(_inner())
|