* 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
190 lines
6.6 KiB
Python
190 lines
6.6 KiB
Python
import sys
|
|
import threading
|
|
import types
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import APIConnectionError
|
|
|
|
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
|
|
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
|
|
sys.modules.setdefault("fal_client", types.SimpleNamespace())
|
|
|
|
import run_agent
|
|
|
|
|
|
class FakeRequestClient:
|
|
def __init__(self, responder):
|
|
self._responder = responder
|
|
self._client = SimpleNamespace(is_closed=False)
|
|
self.chat = SimpleNamespace(
|
|
completions=SimpleNamespace(create=self._create)
|
|
)
|
|
self.responses = SimpleNamespace()
|
|
self.close_calls = 0
|
|
|
|
def _create(self, **kwargs):
|
|
return self._responder(**kwargs)
|
|
|
|
def close(self):
|
|
self.close_calls += 1
|
|
self._client.is_closed = True
|
|
|
|
|
|
class FakeSharedClient(FakeRequestClient):
|
|
pass
|
|
|
|
|
|
class OpenAIFactory:
|
|
def __init__(self, clients):
|
|
self._clients = list(clients)
|
|
self.calls = []
|
|
|
|
def __call__(self, **kwargs):
|
|
self.calls.append(dict(kwargs))
|
|
if not self._clients:
|
|
raise AssertionError("OpenAI factory exhausted")
|
|
return self._clients.pop(0)
|
|
|
|
|
|
def _build_agent(shared_client=None):
|
|
agent = run_agent.AIAgent.__new__(run_agent.AIAgent)
|
|
agent.api_mode = "chat_completions"
|
|
agent.provider = "openai-codex"
|
|
agent.base_url = "https://chatgpt.com/backend-api/codex"
|
|
agent.model = "gpt-5-codex"
|
|
agent.log_prefix = ""
|
|
agent.quiet_mode = True
|
|
agent._interrupt_requested = False
|
|
agent._interrupt_message = None
|
|
agent._client_lock = threading.RLock()
|
|
agent._client_kwargs = {"api_key": "***", "base_url": agent.base_url}
|
|
agent.client = shared_client or FakeSharedClient(lambda **kwargs: {"shared": True})
|
|
agent.stream_delta_callback = None
|
|
agent._stream_callback = None
|
|
agent.reasoning_callback = None
|
|
return agent
|
|
|
|
|
|
def _connection_error():
|
|
return APIConnectionError(
|
|
message="Connection error.",
|
|
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
|
|
)
|
|
|
|
|
|
def test_retry_after_api_connection_error_recreates_request_client(monkeypatch):
|
|
first_request = FakeRequestClient(lambda **kwargs: (_ for _ in ()).throw(_connection_error()))
|
|
second_request = FakeRequestClient(lambda **kwargs: {"ok": True})
|
|
factory = OpenAIFactory([first_request, second_request])
|
|
monkeypatch.setattr(run_agent, "OpenAI", factory)
|
|
|
|
agent = _build_agent()
|
|
|
|
with pytest.raises(APIConnectionError):
|
|
agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
|
|
result = agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
|
|
assert result == {"ok": True}
|
|
assert len(factory.calls) == 2
|
|
assert first_request.close_calls >= 1
|
|
assert second_request.close_calls >= 1
|
|
|
|
|
|
def test_closed_shared_client_is_recreated_before_request(monkeypatch):
|
|
stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used")))
|
|
stale_shared._client.is_closed = True
|
|
|
|
replacement_shared = FakeSharedClient(lambda **kwargs: {"replacement": True})
|
|
request_client = FakeRequestClient(lambda **kwargs: {"ok": "fresh-request-client"})
|
|
factory = OpenAIFactory([replacement_shared, request_client])
|
|
monkeypatch.setattr(run_agent, "OpenAI", factory)
|
|
|
|
agent = _build_agent(shared_client=stale_shared)
|
|
result = agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
|
|
assert result == {"ok": "fresh-request-client"}
|
|
assert agent.client is replacement_shared
|
|
assert stale_shared.close_calls >= 1
|
|
assert replacement_shared.close_calls == 0
|
|
assert len(factory.calls) == 2
|
|
|
|
|
|
def test_concurrent_requests_do_not_break_each_other_when_one_client_closes(monkeypatch):
|
|
first_started = threading.Event()
|
|
first_closed = threading.Event()
|
|
|
|
def first_responder(**kwargs):
|
|
first_started.set()
|
|
first_client.close()
|
|
first_closed.set()
|
|
raise _connection_error()
|
|
|
|
def second_responder(**kwargs):
|
|
assert first_started.wait(timeout=2)
|
|
assert first_closed.wait(timeout=2)
|
|
return {"ok": "second"}
|
|
|
|
first_client = FakeRequestClient(first_responder)
|
|
second_client = FakeRequestClient(second_responder)
|
|
factory = OpenAIFactory([first_client, second_client])
|
|
monkeypatch.setattr(run_agent, "OpenAI", factory)
|
|
|
|
agent = _build_agent()
|
|
results = {}
|
|
|
|
def run_call(name):
|
|
try:
|
|
results[name] = agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
except Exception as exc: # noqa: BLE001 - asserting exact type below
|
|
results[name] = exc
|
|
|
|
thread_one = threading.Thread(target=run_call, args=("first",), daemon=True)
|
|
thread_two = threading.Thread(target=run_call, args=("second",), daemon=True)
|
|
thread_one.start()
|
|
thread_two.start()
|
|
thread_one.join(timeout=5)
|
|
thread_two.join(timeout=5)
|
|
|
|
values = list(results.values())
|
|
assert sum(isinstance(value, APIConnectionError) for value in values) == 1
|
|
assert values.count({"ok": "second"}) == 1
|
|
assert len(factory.calls) == 2
|
|
|
|
|
|
|
|
def test_streaming_call_recreates_closed_shared_client_before_request(monkeypatch):
|
|
chunks = iter([
|
|
SimpleNamespace(
|
|
model="gpt-5-codex",
|
|
choices=[SimpleNamespace(delta=SimpleNamespace(content="Hello", tool_calls=None), finish_reason=None)],
|
|
),
|
|
SimpleNamespace(
|
|
model="gpt-5-codex",
|
|
choices=[SimpleNamespace(delta=SimpleNamespace(content=" world", tool_calls=None), finish_reason="stop")],
|
|
),
|
|
])
|
|
|
|
stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used")))
|
|
stale_shared._client.is_closed = True
|
|
|
|
replacement_shared = FakeSharedClient(lambda **kwargs: {"replacement": True})
|
|
request_client = FakeRequestClient(lambda **kwargs: chunks)
|
|
factory = OpenAIFactory([replacement_shared, request_client])
|
|
monkeypatch.setattr(run_agent, "OpenAI", factory)
|
|
|
|
agent = _build_agent(shared_client=stale_shared)
|
|
agent.stream_delta_callback = lambda _delta: None
|
|
# Force chat_completions mode so the streaming path uses
|
|
# chat.completions.create(stream=True) instead of Codex responses.stream()
|
|
agent.api_mode = "chat_completions"
|
|
response = agent._interruptible_streaming_api_call({"model": agent.model, "messages": []})
|
|
|
|
assert response.choices[0].message.content == "Hello world"
|
|
assert agent.client is replacement_shared
|
|
assert stale_shared.close_calls >= 1
|
|
assert request_client.close_calls >= 1
|
|
assert len(factory.calls) == 2
|