Files
hermes-agent/tests/tools/test_send_message_cross_loop.py
wansui 42dc0dea70 fix(send_message): cross-loop dispatch to live WeCom adapter
When send_message is invoked from the agent's worker thread (a different
event loop than the gateway's), awaiting the WeCom adapter directly can hang
because the adapter enqueues onto the gateway loop. Dispatch via
run_coroutine_threadsafe onto the gateway loop when the caller loop differs,
with caller-cancellation shielded so an already-enqueued send is not cancelled
mid-flight (which would otherwise cause a false-failure retry -> duplicate).
Recognizes WeCom native chat IDs as explicit send targets and whitelists WeCom
for media delivery. Part of the async queue design this branch introduces.
2026-08-27 07:33:36 -07:00

202 lines
7.2 KiB
Python

"""Regression tests for the cross-event-loop deadlock fix in send_message.
When the agent's tool worker thread calls _send_via_adapter() while the
adapter's queues live on the gateway's main event loop, the send must be
dispatched via run_coroutine_threadsafe to the gateway loop — NOT awaited
directly on the worker loop (which would deadlock due to the selector never
being woken by cross-thread future.set_result).
"""
import asyncio
import sys
import threading
from types import ModuleType, SimpleNamespace
import pytest
from gateway.config import Platform
class TestSendViaAdapterCrossLoopDispatch:
@pytest.mark.asyncio
async def test_cross_loop_dispatches_to_gateway_loop(self, monkeypatch):
"""adapter.send() runs on gateway loop, not the caller's loop."""
from tools.send_message_tool import _send_via_adapter
send_loop_id = {}
platform = Platform("wecom")
class FakeAdapter:
async def send(self, *, chat_id, content, metadata=None):
send_loop_id["loop"] = id(asyncio.get_running_loop())
return SimpleNamespace(success=True, message_id="cross-ok")
gateway_loop = asyncio.new_event_loop()
started = threading.Event()
def run_gateway():
asyncio.set_event_loop(gateway_loop)
started.set()
gateway_loop.run_forever()
t = threading.Thread(target=run_gateway, daemon=True)
t.start()
started.wait(timeout=2)
try:
runner = SimpleNamespace(
adapters={platform: FakeAdapter()},
_gateway_loop=gateway_loop,
)
fake_gateway_run = ModuleType("gateway.run")
fake_gateway_run._gateway_runner_ref = lambda: runner
monkeypatch.setitem(sys.modules, "gateway.run", fake_gateway_run)
result = await _send_via_adapter(
platform,
SimpleNamespace(extra={}),
"wr_group_123",
"hello from worker",
)
assert result == {"success": True, "message_id": "cross-ok"}
# Verify send() ran on the gateway loop, not our current loop
assert send_loop_id["loop"] == id(gateway_loop)
finally:
gateway_loop.call_soon_threadsafe(gateway_loop.stop)
t.join(timeout=2)
gateway_loop.close()
@pytest.mark.asyncio
async def test_same_loop_uses_direct_await(self, monkeypatch):
"""When current loop IS the gateway loop, adapter.send() is awaited
directly — no run_coroutine_threadsafe (which would self-lock)."""
from tools.send_message_tool import _send_via_adapter
current_loop = asyncio.get_running_loop()
platform = Platform("wecom")
called_directly = {}
class FakeAdapter:
async def send(self, *, chat_id, content, metadata=None):
called_directly["loop"] = id(asyncio.get_running_loop())
return SimpleNamespace(success=True, message_id="direct-ok")
runner = SimpleNamespace(
adapters={platform: FakeAdapter()},
_gateway_loop=current_loop,
)
fake_gateway_run = ModuleType("gateway.run")
fake_gateway_run._gateway_runner_ref = lambda: runner
monkeypatch.setitem(sys.modules, "gateway.run", fake_gateway_run)
result = await _send_via_adapter(
platform,
SimpleNamespace(extra={}),
"wr_group_456",
"direct send",
)
assert result == {"success": True, "message_id": "direct-ok"}
assert called_directly["loop"] == id(current_loop)
@pytest.mark.asyncio
async def test_gateway_loop_not_running_returns_error(self, monkeypatch):
"""When gateway loop exists but is stopped, return an error rather
than attempting direct await on a loop-bound adapter."""
from tools.send_message_tool import _send_via_adapter
stopped_loop = asyncio.new_event_loop()
stopped_loop.close()
platform = Platform("wecom")
class FakeAdapter:
async def send(self, *, chat_id, content, metadata=None):
raise AssertionError("should not be called")
runner = SimpleNamespace(
adapters={platform: FakeAdapter()},
_gateway_loop=stopped_loop,
)
fake_gateway_run = ModuleType("gateway.run")
fake_gateway_run._gateway_runner_ref = lambda: runner
monkeypatch.setitem(sys.modules, "gateway.run", fake_gateway_run)
result = await _send_via_adapter(
platform,
SimpleNamespace(extra={}),
"wr_group_789",
"should fail",
)
assert "error" in result
assert "not running" in result["error"]
@pytest.mark.asyncio
async def test_shield_prevents_cancel_of_enqueued_send(self, monkeypatch):
"""asyncio.shield ensures that cancelling the caller does NOT cancel
the already-dispatched send on the gateway loop."""
from tools.send_message_tool import _send_via_adapter
send_completed = asyncio.Event()
send_result_holder = {}
platform = Platform("wecom")
class FakeAdapter:
async def send(self, *, chat_id, content, metadata=None):
# Simulate a slow send (token bucket wait)
await asyncio.sleep(0.3)
send_result_holder["sent"] = True
send_completed.set()
return SimpleNamespace(success=True, message_id="shielded")
gateway_loop = asyncio.new_event_loop()
started = threading.Event()
def run_gateway():
asyncio.set_event_loop(gateway_loop)
started.set()
gateway_loop.run_forever()
t = threading.Thread(target=run_gateway, daemon=True)
t.start()
started.wait(timeout=2)
try:
runner = SimpleNamespace(
adapters={platform: FakeAdapter()},
_gateway_loop=gateway_loop,
)
fake_gateway_run = ModuleType("gateway.run")
fake_gateway_run._gateway_runner_ref = lambda: runner
monkeypatch.setitem(sys.modules, "gateway.run", fake_gateway_run)
# Start the send, then cancel the caller task after a short delay
async def do_send():
return await _send_via_adapter(
platform,
SimpleNamespace(extra={}),
"wr_group_shield",
"shielded msg",
)
task = asyncio.create_task(do_send())
await asyncio.sleep(0.1) # let it dispatch to gateway loop
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
# The send on the gateway loop should still complete despite cancel
fut = asyncio.run_coroutine_threadsafe(
asyncio.wait_for(send_completed.wait(), timeout=1.0),
gateway_loop,
)
fut.result(timeout=2)
assert send_result_holder.get("sent") is True
finally:
gateway_loop.call_soon_threadsafe(gateway_loop.stop)
t.join(timeout=2)
gateway_loop.close()