HERMES-GIELIE/tests/test_runner_client.py
nesquena-hermes 33fbd9fc6d feat: add configured runner client boundary (#3073)
Opt-in HTTP runner-client boundary for the runner-local runtime adapter
(RFC hermes-run-adapter-contract / #1925, Slice 4c/4d). Default-OFF:
without HERMES_WEBUI_RUNNER_BASE_URL the factory preserves the bounded
'not configured' path; when set, WebUI acts only as a JSON HTTP client +
SSE bridge for start/observe/status/controls without owning runner maps.
New api/runner_client.py + additive _runner_* helpers in api/routes.py;
no change to the live _run_agent_streaming legacy path.

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
2026-05-31 17:37:11 +00:00

111 lines
4.1 KiB
Python

import json
import pytest
from api.runner_client import HttpRunnerClient, RunnerClientError, runner_client_configured
from api.runtime_adapter import StartRunRequest
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(self.payload).encode("utf-8")
def test_runner_client_is_default_off_without_endpoint():
assert runner_client_configured({}) is False
with pytest.raises(NotImplementedError, match="runner-local chat backend is not configured"):
HttpRunnerClient.from_env({})
def test_runner_client_start_run_posts_explicit_boundary_payload(monkeypatch):
captured = {}
def fake_urlopen(req, timeout=0):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["headers"] = dict(req.header_items())
captured["body"] = json.loads(req.data.decode("utf-8"))
return FakeResponse({"run_id": "run-1", "stream_id": "run-1", "status": "running"})
monkeypatch.setattr("api.runner_client.urllib.request.urlopen", fake_urlopen)
client = HttpRunnerClient(base_url="http://runner.local/", api_key="secret")
result = client.start_run(
StartRunRequest(
session_id="s1",
message="hello",
attachments=[{"path": "/tmp/a.png", "mime": "image/png"}],
workspace="/workspace",
profile="default",
provider="openai-codex",
model="gpt-5.5",
toolsets=["terminal"],
source="webui",
metadata={"route": "/api/chat/start"},
)
)
assert result["run_id"] == "run-1"
assert captured["url"] == "http://runner.local/v1/runs"
assert captured["method"] == "POST"
assert captured["headers"]["Authorization"] == "Bearer secret"
assert captured["body"] == {
"session_id": "s1",
"message": "hello",
"attachments": [{"path": "/tmp/a.png", "mime": "image/png"}],
"workspace": "/workspace",
"profile": "default",
"provider": "openai-codex",
"model": "gpt-5.5",
"toolsets": ["terminal"],
"source": "webui",
"metadata": {"route": "/api/chat/start"},
}
def test_runner_client_maps_observe_status_and_controls(monkeypatch):
calls = []
def fake_urlopen(req, timeout=0):
calls.append((req.get_method(), req.full_url, json.loads(req.data.decode("utf-8")) if req.data else None))
return FakeResponse({"ok": True, "status": "accepted"})
monkeypatch.setattr("api.runner_client.urllib.request.urlopen", fake_urlopen)
client = HttpRunnerClient(base_url="http://runner.local")
client.observe_run("run/1", cursor="event:2")
client.get_run("run/1")
client.cancel_run("run/1")
client.respond_approval("run/1", "approval/1", "once")
client.respond_clarify("run/1", "clarify/1", "answer")
client.queue_message("run/1", "next", mode="interrupt")
client.update_goal("session/1", "set", "finish")
assert calls == [
("GET", "http://runner.local/v1/runs/run%2F1/events?cursor=event%3A2", None),
("GET", "http://runner.local/v1/runs/run%2F1", None),
("POST", "http://runner.local/v1/runs/run%2F1/cancel", {}),
("POST", "http://runner.local/v1/runs/run%2F1/approvals/approval%2F1/respond", {"choice": "once"}),
("POST", "http://runner.local/v1/runs/run%2F1/clarifications/clarify%2F1/respond", {"response": "answer"}),
("POST", "http://runner.local/v1/runs/run%2F1/messages", {"message": "next", "mode": "interrupt"}),
("POST", "http://runner.local/v1/sessions/session%2F1/goal", {"action": "set", "text": "finish"}),
]
def test_runner_client_rejects_non_object_json(monkeypatch):
class ArrayResponse(FakeResponse):
def read(self):
return b"[]"
monkeypatch.setattr("api.runner_client.urllib.request.urlopen", lambda req, timeout=0: ArrayResponse({}))
with pytest.raises(RunnerClientError, match="non-object"):
HttpRunnerClient(base_url="http://runner.local").get_run("r1")