ariadne/tests/test_hermes_agent_client.py

372 lines
12 KiB
Python
Raw Normal View History

from __future__ import annotations
from ariadne.services import hermes_agent_client as module
class FakeClock:
def __init__(self) -> None:
self.now = 0.0
self.sleeps: list[float] = []
def time(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.now += seconds
class FakeResponse:
def __init__(self, status_code: int, payload: object = None) -> None:
self.status_code = status_code
self._payload = payload
def json(self): # type: ignore[no-untyped-def]
if isinstance(self._payload, Exception):
raise self._payload
return self._payload
def _install_client(monkeypatch, posts: list, gets: list) -> dict:
calls: dict = {"init_timeout": None, "posts": [], "gets": []}
def next_item(queue: list, label: str): # type: ignore[no-untyped-def]
assert queue, f"unexpected {label} request"
item = queue.pop(0)
if isinstance(item, Exception):
raise item
return item
class FakeClient:
def __init__(self, *, timeout=None) -> None: # type: ignore[no-untyped-def]
calls["init_timeout"] = timeout
def __enter__(self):
return self
def __exit__(self, *args) -> None: # type: ignore[no-untyped-def]
return None
def post(self, url, headers=None, json=None): # type: ignore[no-untyped-def]
calls["posts"].append((url, headers, json))
return next_item(posts, "post")
def get(self, url, headers=None): # type: ignore[no-untyped-def]
calls["gets"].append((url, headers))
return next_item(gets, "get")
monkeypatch.setattr(module.httpx, "Client", FakeClient)
return calls
def _install_clock(monkeypatch) -> FakeClock:
clock = FakeClock()
monkeypatch.setattr(module, "time", clock)
return clock
def _config(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"base_url": "http://hermes.hermes.svc.cluster.local:8642",
"api_key": "key-123",
"total_timeout_seconds": 30.0,
"poll_interval_seconds": 1.0,
"request_timeout_seconds": 7.0,
}
base.update(overrides)
return base
def _started(run_id: str = "run_" + "a" * 32) -> FakeResponse:
return FakeResponse(202, {"run_id": run_id, "status": "started"})
def test_run_triage_completes_happy_path(monkeypatch) -> None:
_install_clock(monkeypatch)
calls = _install_client(
monkeypatch,
posts=[_started("run_abc")],
gets=[
FakeResponse(200, {"object": "hermes.run", "status": "running"}),
FakeResponse(
200,
{
"object": "hermes.run",
"run_id": "run_abc",
"status": "completed",
"session_id": "sess-1",
"output": "Demo fixture failure confirmed.",
"usage": {"total_tokens": 10},
"error": None,
},
),
],
)
result = module.run_triage(_config(), "triage incident inc-42")
assert result.status == "completed"
assert result.output == "Demo fixture failure confirmed."
assert result.run_id == "run_abc"
assert result.session_id == "sess-1"
assert result.error is None
assert result.denied_approvals == 0
assert result.duration_seconds == 1.0
assert calls["init_timeout"] == 7.0
start_url, start_headers, start_body = calls["posts"][0]
assert start_url == "http://hermes.hermes.svc.cluster.local:8642/v1/runs"
assert start_headers == {"Authorization": "Bearer key-123"}
assert start_body == {"input": "triage incident inc-42"}
poll_url, poll_headers = calls["gets"][0]
assert poll_url == "http://hermes.hermes.svc.cluster.local:8642/v1/runs/run_abc"
assert poll_headers == {"Authorization": "Bearer key-123"}
def test_run_triage_denies_approvals_and_keeps_polling(monkeypatch) -> None:
_install_clock(monkeypatch)
waiting = {"status": "waiting_for_approval", "last_event": "approval_requested"}
calls = _install_client(
monkeypatch,
posts=[
_started("run_abc"),
FakeResponse(200, {"ok": True}),
ConnectionError("deny endpoint unreachable"),
FakeResponse(409, {"error": {"message": "already resolved"}}),
],
gets=[
FakeResponse(200, dict(waiting)),
FakeResponse(200, dict(waiting)),
FakeResponse(200, dict(waiting)),
FakeResponse(200, {"status": "completed", "output": "done", "session_id": "sess-2"}),
],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "completed"
assert result.denied_approvals == 1
deny_url, _headers, deny_body = calls["posts"][1]
assert deny_url.endswith("/v1/runs/run_abc/approval")
assert deny_body == {"choice": "deny"}
def test_run_triage_marks_lost_on_poll_404(monkeypatch) -> None:
_install_clock(monkeypatch)
_install_client(
monkeypatch,
posts=[_started("run_abc")],
gets=[FakeResponse(404, {"error": {"code": "run_not_found", "message": "unknown run"}})],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "lost"
assert result.run_id == "run_abc"
assert result.error == "run_not_found"
assert result.output is None
def test_run_triage_times_out_and_stops_run(monkeypatch) -> None:
clock = _install_clock(monkeypatch)
calls = _install_client(
monkeypatch,
posts=[_started("run_abc"), FakeResponse(200, {"status": "cancelling"})],
gets=[
FakeResponse(200, {"status": "running"}),
FakeResponse(200, {"status": "running"}),
],
)
result = module.run_triage(_config(total_timeout_seconds=2.0), "prompt")
assert result.status == "timeout"
assert result.run_id == "run_abc"
assert "total_timeout_after_2.0s" in (result.error or "")
stop_url, stop_headers, _body = calls["posts"][-1]
assert stop_url.endswith("/v1/runs/run_abc/stop")
assert stop_headers == {"Authorization": "Bearer key-123"}
assert clock.sleeps == [1.0, 1.0]
def test_run_triage_timeout_tolerates_stop_failure(monkeypatch) -> None:
_install_clock(monkeypatch)
_install_client(
monkeypatch,
posts=[_started("run_abc"), ConnectionError("stop unreachable")],
gets=[FakeResponse(200, {"status": "queued"})],
)
result = module.run_triage(_config(total_timeout_seconds=1.0), "prompt")
assert result.status == "timeout"
assert result.run_id == "run_abc"
def test_run_triage_retries_start_once_on_connection_error(monkeypatch) -> None:
_install_clock(monkeypatch)
calls = _install_client(
monkeypatch,
posts=[ConnectionError("connection refused"), _started("run_abc")],
gets=[FakeResponse(200, {"status": "completed", "output": "ok"})],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "completed"
assert len(calls["posts"]) == 2
def test_run_triage_retries_start_once_on_server_error(monkeypatch) -> None:
_install_clock(monkeypatch)
calls = _install_client(
monkeypatch,
posts=[FakeResponse(503, None), _started("run_abc")],
gets=[FakeResponse(200, {"status": "completed", "output": "ok"})],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "completed"
assert len(calls["posts"]) == 2
def test_run_triage_start_failure_after_retry_returns_error(monkeypatch) -> None:
_install_clock(monkeypatch)
calls = _install_client(
monkeypatch,
posts=[FakeResponse(500, None), ConnectionError("still down")],
gets=[],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "error"
assert result.run_id is None
assert "start_request_failed: still down" in (result.error or "")
assert len(calls["posts"]) == 2
assert calls["gets"] == []
def test_run_triage_does_not_retry_auth_failure(monkeypatch) -> None:
_install_clock(monkeypatch)
calls = _install_client(
monkeypatch,
posts=[FakeResponse(401, {"error": {"message": "invalid api key", "type": "invalid_request_error"}})],
gets=[],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "error"
assert result.error == "start_http_401: invalid api key"
assert len(calls["posts"]) == 1
def test_run_triage_start_missing_run_id_returns_error(monkeypatch) -> None:
_install_clock(monkeypatch)
_install_client(monkeypatch, posts=[FakeResponse(202, {"status": "started"})], gets=[])
result = module.run_triage(_config(), "prompt")
assert result.status == "error"
assert result.error == "start_missing_run_id"
def test_run_triage_tolerates_individual_poll_failures(monkeypatch) -> None:
clock = _install_clock(monkeypatch)
_install_client(
monkeypatch,
posts=[_started("run_abc")],
gets=[
ConnectionError("poll dropped"),
FakeResponse(500, None),
FakeResponse(200, ValueError("bad json body")),
FakeResponse(200, ["not", "a", "dict"]),
FakeResponse(200, {"status": "completed", "output": "recovered"}),
],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "completed"
assert result.output == "recovered"
assert clock.sleeps == [1.0, 1.0, 1.0, 1.0]
def test_run_triage_reports_failed_run_error(monkeypatch) -> None:
_install_clock(monkeypatch)
_install_client(
monkeypatch,
posts=[_started("run_abc")],
gets=[FakeResponse(200, {"status": "failed", "output": None, "error": "tool exploded"})],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "failed"
assert result.output is None
assert result.error == "tool exploded"
assert result.session_id is None
def test_run_triage_reports_cancelled_run(monkeypatch) -> None:
_install_clock(monkeypatch)
_install_client(
monkeypatch,
posts=[_started("run_abc")],
gets=[FakeResponse(200, {"status": "cancelled", "output": "partial notes", "error": None})],
)
result = module.run_triage(_config(), "prompt")
assert result.status == "cancelled"
assert result.output == "partial notes"
assert result.error is None
def test_run_triage_never_raises_on_client_setup_failure(monkeypatch) -> None:
_install_clock(monkeypatch)
class BoomClient:
def __init__(self, *, timeout=None) -> None: # type: ignore[no-untyped-def]
raise RuntimeError("no transport available")
monkeypatch.setattr(module.httpx, "Client", BoomClient)
result = module.run_triage(_config(), "prompt")
assert result.status == "error"
assert "unexpected_client_failure: no transport available" in (result.error or "")
assert result.denied_approvals == 0
def test_run_config_applies_defaults_and_normalization() -> None:
defaults = module._run_config({}) # noqa: SLF001
assert defaults.base_url == ""
assert defaults.api_key == ""
assert defaults.total_timeout_seconds == 420.0
assert defaults.poll_interval_seconds == 5.0
assert defaults.request_timeout_seconds == 15.0
parsed = module._run_config( # noqa: SLF001
{
"base_url": " http://hermes:8642/ ",
"api_key": "abc",
"total_timeout_seconds": "60",
"poll_interval_seconds": 0,
"request_timeout_seconds": "bad",
}
)
assert parsed.base_url == "http://hermes:8642"
assert parsed.total_timeout_seconds == 60.0
assert parsed.poll_interval_seconds == 5.0
assert parsed.request_timeout_seconds == 15.0
def test_payload_and_error_message_helpers() -> None:
assert module._json_payload(FakeResponse(200, ["list"])) == {} # noqa: SLF001
assert module._json_payload(FakeResponse(200, ValueError("nope"))) == {} # noqa: SLF001
assert module._error_message(FakeResponse(401, {"error": "denied"})) == "denied" # noqa: SLF001
assert module._error_message(FakeResponse(401, {"error": {"message": "bad key"}})) == "bad key" # noqa: SLF001
assert module._error_message(FakeResponse(401, {})) == "" # noqa: SLF001