mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
403 lines
13 KiB
Python
403 lines
13 KiB
Python
"""Behavior contract for generation-safe Telegram polling progress."""
|
|
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
from plugins.platforms.telegram import adapter as tg_adapter
|
|
from plugins.platforms.telegram.adapter import TelegramAdapter
|
|
|
|
|
|
class _ControlledRequest:
|
|
"""Minimal PTB request double with controllable completion."""
|
|
|
|
instances = []
|
|
|
|
@staticmethod
|
|
def parse_json_payload(payload):
|
|
"""Match PTB's response authority used by the progress observer."""
|
|
return json.loads(payload.decode("utf-8", "replace"))
|
|
|
|
def __init__(self, *args, result=None, error=None, entered=None, release=None, **kwargs):
|
|
self.result = result
|
|
self.error = error
|
|
self.entered = entered
|
|
self.release = release
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
type(self).instances.append(self)
|
|
|
|
async def do_request(self, *args, **kwargs):
|
|
if self.entered is not None:
|
|
self.entered.set()
|
|
if self.release is not None:
|
|
await self.release.wait()
|
|
if self.error is not None:
|
|
raise self.error
|
|
return self.result
|
|
|
|
|
|
def _make_adapter() -> TelegramAdapter:
|
|
return TelegramAdapter(PlatformConfig(enabled=True, token="test-token"))
|
|
|
|
|
|
def _mock_polling_app(*, get_me=None):
|
|
app = MagicMock()
|
|
app.updater = MagicMock()
|
|
app.updater.running = True
|
|
app.updater.stop = AsyncMock()
|
|
app.updater.start_polling = AsyncMock()
|
|
app.bot = MagicMock()
|
|
app.bot.get_me = get_me or AsyncMock(return_value=MagicMock())
|
|
app.running = False
|
|
app.shutdown = AsyncMock()
|
|
return app
|
|
|
|
|
|
class _LifecycleBuilder:
|
|
def __init__(self, app):
|
|
self.app = app
|
|
self.polling_request = None
|
|
|
|
def token(self, _token):
|
|
return self
|
|
|
|
def request(self, _request):
|
|
return self
|
|
|
|
def get_updates_request(self, request):
|
|
self.polling_request = request
|
|
return self
|
|
|
|
def build(self):
|
|
return self.app
|
|
|
|
|
|
def _lifecycle_app():
|
|
app = MagicMock()
|
|
app.updater = MagicMock()
|
|
app.updater.running = True
|
|
app.updater.start_polling = AsyncMock()
|
|
app.updater.start_webhook = AsyncMock()
|
|
app.updater.stop = AsyncMock()
|
|
app.bot = MagicMock()
|
|
app.bot.delete_webhook = AsyncMock()
|
|
app.initialize = AsyncMock()
|
|
app.start = AsyncMock()
|
|
app.stop = AsyncMock()
|
|
app.shutdown = AsyncMock()
|
|
app.running = True
|
|
return app
|
|
|
|
|
|
def _configure_lifecycle_connect(monkeypatch, adapter, apps):
|
|
builders = [_LifecycleBuilder(app) for app in apps]
|
|
remaining = iter(builders)
|
|
|
|
class _Application:
|
|
@staticmethod
|
|
def builder():
|
|
return next(remaining)
|
|
|
|
async def _no_fallback_ips():
|
|
return []
|
|
|
|
monkeypatch.setattr(tg_adapter, "Application", _Application)
|
|
monkeypatch.setattr(tg_adapter, "HTTPXRequest", _ControlledRequest)
|
|
monkeypatch.setattr(tg_adapter, "discover_fallback_ips", _no_fallback_ips)
|
|
monkeypatch.setattr(tg_adapter, "resolve_proxy_url", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(adapter, "_acquire_platform_lock", lambda *args, **kwargs: True)
|
|
monkeypatch.setattr(adapter, "_release_platform_lock", MagicMock())
|
|
monkeypatch.setattr(adapter, "_fallback_ips", lambda: [])
|
|
monkeypatch.setattr(adapter, "_start_post_connect_housekeeping", MagicMock())
|
|
return builders
|
|
|
|
|
|
async def _cancel_task(task):
|
|
if task is None or task.done():
|
|
return
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
|
|
async def _request_for_generation(generation, request, *args):
|
|
"""Run a direct request double under the production polling context."""
|
|
generation_context = tg_adapter._POLLING_GENERATION_CONTEXT
|
|
token = generation_context.set(generation)
|
|
try:
|
|
return await request.do_request(*args)
|
|
finally:
|
|
generation_context.reset(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_polling_disconnect_webhook_reconnect_heals_webhook_send_path(monkeypatch):
|
|
adapter = _make_adapter()
|
|
polling_app = _lifecycle_app()
|
|
webhook_app = _lifecycle_app()
|
|
|
|
async def start_polling_with_progress(**_kwargs):
|
|
adapter._record_polling_progress(adapter._polling_generation)
|
|
|
|
polling_app.updater.start_polling = AsyncMock(
|
|
side_effect=start_polling_with_progress
|
|
)
|
|
_configure_lifecycle_connect(monkeypatch, adapter, [polling_app, webhook_app])
|
|
monkeypatch.delenv("TELEGRAM_WEBHOOK_URL", raising=False)
|
|
monkeypatch.delenv("TELEGRAM_WEBHOOK_SECRET", raising=False)
|
|
|
|
assert await adapter.connect() is True
|
|
assert adapter._webhook_mode is False
|
|
assert adapter._send_path_degraded is False
|
|
await adapter.disconnect()
|
|
|
|
monkeypatch.setenv("TELEGRAM_WEBHOOK_URL", "https://example.test/telegram")
|
|
monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "test-secret")
|
|
try:
|
|
assert await adapter.connect(is_reconnect=True) is True
|
|
webhook_app.updater.start_webhook.assert_awaited_once()
|
|
assert adapter._webhook_mode is True
|
|
assert adapter._polling_progress_accepting is False
|
|
assert adapter._send_path_degraded is False
|
|
finally:
|
|
await adapter.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_webhook_disconnect_polling_reconnect_resets_mode_and_waits_for_progress(
|
|
monkeypatch,
|
|
):
|
|
adapter = _make_adapter()
|
|
webhook_app = _lifecycle_app()
|
|
polling_app = _lifecycle_app()
|
|
builders = _configure_lifecycle_connect(
|
|
monkeypatch, adapter, [webhook_app, polling_app]
|
|
)
|
|
heartbeat_started = asyncio.Event()
|
|
heartbeat_modes = []
|
|
|
|
async def heartbeat():
|
|
heartbeat_modes.append(adapter._webhook_mode)
|
|
heartbeat_started.set()
|
|
await asyncio.Event().wait()
|
|
|
|
monkeypatch.setattr(adapter, "_polling_heartbeat_loop", heartbeat)
|
|
monkeypatch.setenv("TELEGRAM_WEBHOOK_URL", "https://example.test/telegram")
|
|
monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "test-secret")
|
|
|
|
assert await adapter.connect() is True
|
|
assert adapter._webhook_mode is True
|
|
assert adapter._polling_heartbeat_task is None
|
|
await adapter.disconnect()
|
|
|
|
monkeypatch.delenv("TELEGRAM_WEBHOOK_URL")
|
|
monkeypatch.delenv("TELEGRAM_WEBHOOK_SECRET")
|
|
try:
|
|
assert await adapter.connect(is_reconnect=True) is True
|
|
assert adapter._webhook_mode is False
|
|
assert adapter._polling_heartbeat_task is not None
|
|
assert not adapter._polling_heartbeat_task.done()
|
|
await asyncio.wait_for(heartbeat_started.wait(), timeout=1)
|
|
assert heartbeat_modes == [False]
|
|
assert adapter._send_path_degraded is True
|
|
|
|
generation = adapter._polling_generation
|
|
polling_request = builders[1].polling_request
|
|
polling_request.result = (200, b'{"ok":true,"result":[]}')
|
|
await _request_for_generation(generation, polling_request, "getUpdates")
|
|
await asyncio.wait_for(adapter._polling_progress_verifier_task, timeout=1)
|
|
assert adapter._send_path_degraded is False
|
|
finally:
|
|
await adapter.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_current_polling_generation_success_records_progress():
|
|
adapter = _make_adapter()
|
|
generation, progress = adapter._begin_polling_generation()
|
|
adapter._polling_network_error_count = 3
|
|
request = _ControlledRequest(result=(200, b'{"ok":true,"result":[]}'))
|
|
|
|
instrumented = adapter._instrument_polling_request(request)
|
|
result = await _request_for_generation(
|
|
generation, instrumented, "https://api.telegram.org/getUpdates"
|
|
)
|
|
|
|
assert instrumented is request
|
|
assert result == (200, b'{"ok":true,"result":[]}')
|
|
assert progress.is_set()
|
|
assert adapter._polling_network_error_count == 0
|
|
assert adapter._send_path_degraded is False
|
|
assert generation > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("error_type", [RuntimeError, asyncio.CancelledError])
|
|
async def test_unsuccessful_polling_request_does_not_record_progress(error_type):
|
|
adapter = _make_adapter()
|
|
generation, progress = adapter._begin_polling_generation()
|
|
adapter._polling_network_error_count = 3
|
|
request = adapter._instrument_polling_request(
|
|
_ControlledRequest(error=error_type("request did not complete"))
|
|
)
|
|
|
|
with pytest.raises(error_type):
|
|
await _request_for_generation(
|
|
generation, request, "https://api.telegram.org/getUpdates"
|
|
)
|
|
|
|
assert not progress.is_set()
|
|
assert adapter._polling_network_error_count == 3
|
|
assert adapter._send_path_degraded is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_error_response_does_not_record_polling_progress():
|
|
adapter = _make_adapter()
|
|
generation, progress = adapter._begin_polling_generation()
|
|
adapter._polling_network_error_count = 3
|
|
request = adapter._instrument_polling_request(
|
|
_ControlledRequest(result=(500, b"bad"))
|
|
)
|
|
|
|
result = await _request_for_generation(
|
|
generation, request, "https://api.telegram.org/getUpdates"
|
|
)
|
|
|
|
assert result == (500, b"bad")
|
|
assert not progress.is_set()
|
|
assert adapter._polling_network_error_count == 3
|
|
assert adapter._send_path_degraded is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_general_request_success_cannot_record_polling_progress(monkeypatch):
|
|
class _StopConnect(Exception):
|
|
pass
|
|
|
|
class _Builder:
|
|
def __init__(self):
|
|
self.general_request = None
|
|
self.polling_request = None
|
|
|
|
def token(self, _token):
|
|
return self
|
|
|
|
def request(self, request):
|
|
self.general_request = request
|
|
return self
|
|
|
|
def get_updates_request(self, request):
|
|
self.polling_request = request
|
|
return self
|
|
|
|
def build(self):
|
|
raise _StopConnect
|
|
|
|
builder = _Builder()
|
|
|
|
class _Application:
|
|
@staticmethod
|
|
def builder():
|
|
return builder
|
|
|
|
_ControlledRequest.instances = []
|
|
|
|
async def _no_fallback_ips():
|
|
return []
|
|
|
|
monkeypatch.setattr(tg_adapter, "Application", _Application)
|
|
monkeypatch.setattr(tg_adapter, "HTTPXRequest", _ControlledRequest)
|
|
monkeypatch.setattr(tg_adapter, "discover_fallback_ips", _no_fallback_ips)
|
|
monkeypatch.setattr(tg_adapter, "resolve_proxy_url", lambda *args, **kwargs: None)
|
|
|
|
adapter = _make_adapter()
|
|
monkeypatch.setattr(adapter, "_acquire_platform_lock", lambda *args, **kwargs: True)
|
|
monkeypatch.setattr(adapter, "_fallback_ips", lambda: [])
|
|
_, progress = adapter._begin_polling_generation()
|
|
|
|
assert await adapter.connect() is False
|
|
assert builder.general_request is _ControlledRequest.instances[0]
|
|
assert builder.polling_request is _ControlledRequest.instances[1]
|
|
|
|
builder.general_request.result = (200, b'{"ok":true}')
|
|
result = await builder.general_request.do_request("https://api.telegram.org/sendMessage")
|
|
|
|
assert result == (200, b'{"ok":true}')
|
|
assert not progress.is_set()
|
|
assert adapter._send_path_degraded is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_cancels_recovery_before_it_can_rearm_progress(monkeypatch):
|
|
adapter = _make_adapter()
|
|
adapter._app = _mock_polling_app()
|
|
adapter._app.updater.running = False
|
|
adapter._polling_error_callback_ref = MagicMock()
|
|
|
|
drain_entered = asyncio.Event()
|
|
release_drain = asyncio.Event()
|
|
start_entered = asyncio.Event()
|
|
release_start = asyncio.Event()
|
|
teardown_paused = asyncio.Event()
|
|
release_teardown = asyncio.Event()
|
|
|
|
async def immediate_backoff(_delay):
|
|
return None
|
|
|
|
async def blocked_drain():
|
|
drain_entered.set()
|
|
await release_drain.wait()
|
|
|
|
async def blocked_start_polling(**_kwargs):
|
|
start_entered.set()
|
|
await release_start.wait()
|
|
|
|
async def blocked_status_indicator(*, online):
|
|
assert online is False
|
|
teardown_paused.set()
|
|
await release_teardown.wait()
|
|
|
|
monkeypatch.setattr(tg_adapter.asyncio, "sleep", immediate_backoff)
|
|
monkeypatch.setattr(adapter, "_drain_polling_connections", blocked_drain)
|
|
monkeypatch.setattr(
|
|
adapter._app.updater, "start_polling", blocked_start_polling
|
|
)
|
|
monkeypatch.setattr(adapter, "_set_status_indicator", blocked_status_indicator)
|
|
|
|
recovery = asyncio.create_task(
|
|
adapter._handle_polling_network_error(ConnectionError("offline"))
|
|
)
|
|
adapter._polling_error_task = recovery
|
|
await drain_entered.wait()
|
|
|
|
disconnect = asyncio.create_task(adapter.disconnect())
|
|
await teardown_paused.wait()
|
|
|
|
try:
|
|
# Before the fix, disconnect pauses here before cancelling recovery.
|
|
# Releasing the recovery lets it begin a fresh generation after the
|
|
# teardown fence, and matching progress can then heal the adapter.
|
|
if not recovery.done():
|
|
release_drain.set()
|
|
await start_entered.wait()
|
|
|
|
rearmed_after_fence = adapter._polling_progress_accepting
|
|
adapter._record_polling_progress(adapter._polling_generation)
|
|
|
|
assert rearmed_after_fence is False
|
|
assert getattr(adapter, "_polling_teardown_started", False) is True
|
|
assert adapter._polling_progress_accepting is False
|
|
assert adapter._send_path_degraded is True
|
|
assert recovery.done()
|
|
finally:
|
|
release_drain.set()
|
|
release_start.set()
|
|
release_teardown.set()
|
|
for task in (recovery, disconnect):
|
|
if not task.done():
|
|
task.cancel()
|
|
await asyncio.gather(recovery, disconnect, return_exceptions=True)
|