mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
* refactor: re-architect tests to mirror the codebase
* Update tests.yml
* fix: add missing tool_error imports after registry refactor
* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist
patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.
* fix(tests): fix update_check and telegram xdist failures
- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
directly, it uses get_hermes_home() from hermes_constants.
- test_telegram_conflict/approval_buttons: provide real exception classes
for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
except clause in connect() doesn't fail with "catching classes that do
not inherit from BaseException" when xdist pollutes sys.modules.
* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
162 lines
5.9 KiB
Python
162 lines
5.9 KiB
Python
"""Tests for the AsyncHttpxClientWrapper.__del__ neuter fix.
|
|
|
|
The OpenAI SDK's ``AsyncHttpxClientWrapper.__del__`` schedules
|
|
``aclose()`` via ``asyncio.get_running_loop().create_task()``. When GC
|
|
fires during CLI idle time, prompt_toolkit's event loop picks up the task
|
|
and crashes with "Event loop is closed" because the underlying TCP
|
|
transport is bound to a dead worker loop.
|
|
|
|
The three-layer defence:
|
|
1. ``neuter_async_httpx_del()`` replaces ``__del__`` with a no-op.
|
|
2. A custom asyncio exception handler silences residual errors.
|
|
3. ``cleanup_stale_async_clients()`` evicts stale cache entries.
|
|
"""
|
|
|
|
import asyncio
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layer 1: neuter_async_httpx_del
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNeuterAsyncHttpxDel:
|
|
"""Verify neuter_async_httpx_del replaces __del__ on the SDK class."""
|
|
|
|
def test_del_becomes_noop(self):
|
|
"""After neuter, __del__ should do nothing (no RuntimeError)."""
|
|
from agent.auxiliary_client import neuter_async_httpx_del
|
|
|
|
try:
|
|
from openai._base_client import AsyncHttpxClientWrapper
|
|
except ImportError:
|
|
pytest.skip("openai SDK not installed")
|
|
|
|
# Save original so we can restore
|
|
original_del = AsyncHttpxClientWrapper.__del__
|
|
try:
|
|
neuter_async_httpx_del()
|
|
# The patched __del__ should be a no-op lambda
|
|
assert AsyncHttpxClientWrapper.__del__ is not original_del
|
|
# Calling it should not raise, even without a running loop
|
|
wrapper = MagicMock(spec=AsyncHttpxClientWrapper)
|
|
AsyncHttpxClientWrapper.__del__(wrapper) # Should be silent
|
|
finally:
|
|
# Restore original to avoid leaking into other tests
|
|
AsyncHttpxClientWrapper.__del__ = original_del
|
|
|
|
def test_neuter_idempotent(self):
|
|
"""Calling neuter twice doesn't break anything."""
|
|
from agent.auxiliary_client import neuter_async_httpx_del
|
|
|
|
try:
|
|
from openai._base_client import AsyncHttpxClientWrapper
|
|
except ImportError:
|
|
pytest.skip("openai SDK not installed")
|
|
|
|
original_del = AsyncHttpxClientWrapper.__del__
|
|
try:
|
|
neuter_async_httpx_del()
|
|
first_del = AsyncHttpxClientWrapper.__del__
|
|
neuter_async_httpx_del()
|
|
second_del = AsyncHttpxClientWrapper.__del__
|
|
# Both calls should succeed; the class should have a no-op
|
|
assert first_del is not original_del
|
|
assert second_del is not original_del
|
|
finally:
|
|
AsyncHttpxClientWrapper.__del__ = original_del
|
|
|
|
def test_neuter_graceful_without_sdk(self):
|
|
"""neuter_async_httpx_del doesn't raise if the openai SDK isn't installed."""
|
|
from agent.auxiliary_client import neuter_async_httpx_del
|
|
|
|
with patch.dict("sys.modules", {"openai._base_client": None}):
|
|
# Should not raise
|
|
neuter_async_httpx_del()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layer 3: cleanup_stale_async_clients
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCleanupStaleAsyncClients:
|
|
"""Verify stale cache entries are evicted and force-closed."""
|
|
|
|
def test_removes_stale_entries(self):
|
|
"""Entries with a closed loop should be evicted."""
|
|
from agent.auxiliary_client import (
|
|
_client_cache,
|
|
_client_cache_lock,
|
|
cleanup_stale_async_clients,
|
|
)
|
|
|
|
# Create a loop, close it, make a cache entry
|
|
loop = asyncio.new_event_loop()
|
|
loop.close()
|
|
|
|
mock_client = MagicMock()
|
|
# Give it _client attribute for _force_close_async_httpx
|
|
mock_client._client = MagicMock()
|
|
mock_client._client.is_closed = False
|
|
|
|
key = ("test_stale", True, "", "", id(loop))
|
|
with _client_cache_lock:
|
|
_client_cache[key] = (mock_client, "test-model", loop)
|
|
|
|
try:
|
|
cleanup_stale_async_clients()
|
|
with _client_cache_lock:
|
|
assert key not in _client_cache, "Stale entry should be removed"
|
|
finally:
|
|
# Clean up in case test fails
|
|
with _client_cache_lock:
|
|
_client_cache.pop(key, None)
|
|
|
|
def test_keeps_live_entries(self):
|
|
"""Entries with an open loop should be preserved."""
|
|
from agent.auxiliary_client import (
|
|
_client_cache,
|
|
_client_cache_lock,
|
|
cleanup_stale_async_clients,
|
|
)
|
|
|
|
loop = asyncio.new_event_loop() # NOT closed
|
|
|
|
mock_client = MagicMock()
|
|
key = ("test_live", True, "", "", id(loop))
|
|
with _client_cache_lock:
|
|
_client_cache[key] = (mock_client, "test-model", loop)
|
|
|
|
try:
|
|
cleanup_stale_async_clients()
|
|
with _client_cache_lock:
|
|
assert key in _client_cache, "Live entry should be preserved"
|
|
finally:
|
|
loop.close()
|
|
with _client_cache_lock:
|
|
_client_cache.pop(key, None)
|
|
|
|
def test_keeps_entries_without_loop(self):
|
|
"""Sync entries (cached_loop=None) should be preserved."""
|
|
from agent.auxiliary_client import (
|
|
_client_cache,
|
|
_client_cache_lock,
|
|
cleanup_stale_async_clients,
|
|
)
|
|
|
|
mock_client = MagicMock()
|
|
key = ("test_sync", False, "", "", 0)
|
|
with _client_cache_lock:
|
|
_client_cache[key] = (mock_client, "test-model", None)
|
|
|
|
try:
|
|
cleanup_stale_async_clients()
|
|
with _client_cache_lock:
|
|
assert key in _client_cache, "Sync entry should be preserved"
|
|
finally:
|
|
with _client_cache_lock:
|
|
_client_cache.pop(key, None)
|