Files
hermes-webui/tests/test_gateway_approval_runs_api.py
T

2293 lines
89 KiB
Python

"""Tests for the gateway runs-API approval bridge (#4203)."""
from __future__ import annotations
import io
import json
import socket
import threading
import urllib.error
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# 1. Capability detection
# ---------------------------------------------------------------------------
def test_gateway_capability_detection():
"""get_gateway_caps / gateway_supports_approval correctly parse /v1/capabilities."""
from api.config import (
gateway_approval_unavailable_reason,
gateway_supports_approval,
get_gateway_caps,
invalidate_gateway_caps,
)
# Clear any leftover cache state.
invalidate_gateway_caps()
def _fake_urlopen_capable(req, *, timeout=None):
assert req.full_url == "http://fake:1234/v1/capabilities"
assert req.get_header("Authorization") == "Bearer secret"
body = json.dumps({
"features": {
"approval_events": True,
"run_approval_response": True,
},
}).encode()
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_capable):
caps = get_gateway_caps("http://fake:1234", "secret")
assert caps["capabilities_reachable"] is True
assert caps["probe_error"] is None
assert gateway_approval_unavailable_reason("http://fake:1234", "secret") is None
assert gateway_supports_approval("http://fake:1234", "secret") is True
invalidate_gateway_caps()
def _fake_urlopen_incapable(req, *, timeout=None):
assert req.full_url == "http://fake:5678/v1/capabilities"
body = json.dumps({}).encode()
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_incapable):
caps = get_gateway_caps("http://fake:5678")
assert caps["capabilities_reachable"] is True
assert caps["probe_error"] is None
assert gateway_approval_unavailable_reason("http://fake:5678") == "unsupported"
assert gateway_supports_approval("http://fake:5678") is False
invalidate_gateway_caps()
def test_gateway_capability_detection_marks_probe_failures_unreachable():
"""Probe failures stay non-fatal but remain distinguishable from unsupported gateways."""
from api.config import (
gateway_approval_unavailable_reason,
gateway_supports_approval,
get_gateway_caps,
invalidate_gateway_caps,
)
invalidate_gateway_caps()
def _fake_urlopen_fail(req, *, timeout=None):
assert req.full_url == "http://fake:9999/v1/capabilities"
raise urllib.error.URLError(ConnectionRefusedError("connection refused"))
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_fail):
caps = get_gateway_caps("http://fake:9999", "secret")
assert caps["capabilities_reachable"] is False
assert caps["probe_error"]
assert gateway_approval_unavailable_reason("http://fake:9999", "secret") == "unreachable"
assert gateway_supports_approval("http://fake:9999", "secret") is False
invalidate_gateway_caps()
def test_gateway_capability_detection_treats_timeout_probe_as_reachable_unsupported():
"""Slow probes should preserve the reachable-but-unsupported warning contract."""
from api.config import (
gateway_approval_unavailable_reason,
gateway_supports_approval,
get_gateway_caps,
invalidate_gateway_caps,
)
invalidate_gateway_caps()
def _fake_urlopen_timeout(req, *, timeout=None):
assert req.full_url == "http://fake:8888/v1/capabilities"
raise socket.timeout("timed out")
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_timeout):
caps = get_gateway_caps("http://fake:8888", "secret")
assert caps["capabilities_reachable"] is True
assert caps["probe_error"]
assert gateway_approval_unavailable_reason("http://fake:8888", "secret") == "unsupported"
assert gateway_supports_approval("http://fake:8888", "secret") is False
invalidate_gateway_caps()
def test_gateway_capability_detection_treats_404_probe_as_reachable_unsupported():
"""Older reachable gateways can 404 /v1/capabilities without becoming "offline"."""
from api.config import (
gateway_approval_unavailable_reason,
gateway_supports_approval,
get_gateway_caps,
invalidate_gateway_caps,
)
invalidate_gateway_caps()
def _fake_urlopen_404(req, *, timeout=None):
assert req.full_url == "http://fake:7777/v1/capabilities"
raise urllib.error.HTTPError(req.full_url, 404, "Not Found", hdrs=None, fp=io.BytesIO(b""))
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_404):
caps = get_gateway_caps("http://fake:7777", "secret")
assert caps["capabilities_reachable"] is True
assert caps["probe_error"]
assert gateway_approval_unavailable_reason("http://fake:7777", "secret") == "unsupported"
assert gateway_supports_approval("http://fake:7777", "secret") is False
invalidate_gateway_caps()
def test_gateway_capability_cache_keeps_fresher_success_on_probe_race():
"""A slower failed probe must not overwrite a fresher successful capability result."""
from api.config import gateway_supports_approval, invalidate_gateway_caps
invalidate_gateway_caps()
first_probe_release = threading.Event()
second_probe_done = threading.Event()
call_count = {"value": 0}
class _JsonResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")
def read(self, _limit=None):
return self._payload
def __enter__(self):
return self
def __exit__(self, *args):
return None
def fake_urlopen(req, *, timeout=None):
call_count["value"] += 1
if call_count["value"] == 1:
second_probe_done.wait(timeout=5)
first_probe_release.wait(timeout=5)
raise urllib.error.URLError("slow probe failed")
second_probe_done.set()
return _JsonResponse({
"features": {
"approval_events": True,
"run_approval_response": True,
},
})
results = []
def worker():
results.append(gateway_supports_approval("http://fake:9999", "secret"))
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
t1 = threading.Thread(target=worker)
t2 = threading.Thread(target=worker)
t1.start()
second_probe_done.wait(timeout=5)
t2.start()
t2.join(timeout=5)
first_probe_release.set()
t1.join(timeout=5)
assert gateway_supports_approval("http://fake:9999", "secret") is True
assert results.count(True) == 2
invalidate_gateway_caps()
# ---------------------------------------------------------------------------
# 2. Runs-API submission path
# ---------------------------------------------------------------------------
def test_gateway_runs_api_submission():
"""When gateway_supports_approval returns True, the runs-API path is used."""
from api.config import STREAMS, STREAMS_LOCK
from api.gateway_chat import _run_gateway_chat_streaming
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-test-runs"
with STREAMS_LOCK:
STREAMS[stream_id] = q
runs_called = {"called": False}
captured = {}
original_text = "hello from runs"
def fake_runs_streaming(
session_id,
msg_text,
model,
workspace,
stream_id,
base_url,
api_key,
prefill_messages,
body_extras,
**kwargs,
):
runs_called["called"] = True
captured["body_extras"] = body_extras
return (original_text, {"input_tokens": 10, "output_tokens": 5})
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
mock_session.workspace = "/tmp"
mock_session.model = "test"
mock_session.model_provider = None
mock_session.profile = None
mock_session.context_messages = []
mock_session.messages = []
mock_session.pending_user_message = None
mock_session.pending_attachments = None
mock_session.pending_started_at = None
try:
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway", "HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1"}):
with patch("api.gateway_chat.gateway_supports_approval", lambda *_args, **_kwargs: True), \
patch("api.gateway_chat._run_gateway_runs_api_streaming", fake_runs_streaming), \
patch("api.gateway_chat._gateway_reasoning_effort_for_request", return_value="high"), \
patch("api.gateway_chat.get_session", return_value=mock_session), \
patch("api.gateway_chat._stream_writeback_is_current", return_value=True), \
patch("api.gateway_chat.merge_session_messages_append_only", return_value=[]):
_run_gateway_chat_streaming(
session_id="sess1",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
)
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
assert runs_called["called"], "The runs-API streaming path should have been invoked"
assert captured["body_extras"]["reasoning_effort"] == "high"
# ---------------------------------------------------------------------------
# 3. Approval event translation
# ---------------------------------------------------------------------------
def test_gateway_approval_event_translation():
"""_gateway_runs_approval_event maps actual gateway approval fields."""
from api.gateway_chat import _gateway_runs_approval_event
payload = {
"command": "rm -rf /tmp/x",
"description": "Dangerous command approval",
"pattern_key": "dangerous_command",
"pattern_keys": ["dangerous_command"],
"run_id": "run-999",
"approval_id": "appr-1",
"choices": ["once", "session", "always", "deny"],
}
result = _gateway_runs_approval_event(payload)
assert result is not None
assert result["tool"] == "dangerous_command"
assert result["command"] == "rm -rf /tmp/x"
assert result["description"] == "Dangerous command approval"
assert result["pattern_key"] == "dangerous_command"
assert result["pattern_keys"] == ["dangerous_command"]
assert result["choices"] == ["once", "session", "always", "deny"]
assert result["allow_permanent"] is True
assert result["risk_level"] == "high"
assert result["run_id"] == "run-999"
assert result["approval_id"] == "appr-1"
empty_id = _gateway_runs_approval_event({**payload, "approval_id": "", "id": ""})
assert empty_id["approval_id"] == ""
downgraded = _gateway_runs_approval_event({
"command": "rm -rf /tmp/x",
"description": "Dangerous command approval",
"pattern_key": "dangerous_command",
"pattern_keys": ["dangerous_command"],
"allow_permanent": False,
"choices": ["once", "session", "always", "deny"],
})
assert downgraded is not None
assert downgraded["allow_permanent"] is False
# Missing command/description/tool should return None.
assert _gateway_runs_approval_event({"risk_level": "high"}) is None
assert _gateway_runs_approval_event({}) is None
def test_gateway_runs_api_streaming_parses_real_run_events():
"""The runs-API bridge must parse the real gateway event payloads."""
from api.config import STREAM_PARTIAL_TEXT, STREAM_REASONING_TEXT
from api.gateway_chat import _STREAM_RUN_IDS, _run_gateway_runs_api_streaming
events = []
requests = []
stream_id = "sid-real-runs"
STREAM_PARTIAL_TEXT[stream_id] = ""
STREAM_REASONING_TEXT[stream_id] = ""
class _JsonResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")
def read(self, _limit=None):
return self._payload
def __enter__(self):
return self
def __exit__(self, *args):
return None
class _SseResponse:
def __init__(self, lines):
self._lines = lines
def __iter__(self):
return iter(self._lines)
def __enter__(self):
return self
def __exit__(self, *args):
return None
def fake_urlopen(req, *, timeout=None):
requests.append(req)
if req.full_url.endswith("/v1/runs"):
return _JsonResponse({"run_id": "run-abc"})
return _SseResponse([
b'data: {"event":"approval.request","command":"rm -rf /tmp/x","description":"Dangerous command approval","pattern_key":"dangerous_command","pattern_keys":["dangerous_command"],"choices":["once","session","always","deny"],"run_id":"run-abc","approval_id":"appr-1"}\n',
b'\n',
b'data: {"event":"reasoning.available","text":"thinking..."}\n',
b'\n',
b'data: {"event":"message.delta","delta":"Hello"}\n',
b'\n',
b'data: {"event":"run.completed","output":"Hello","usage":{"input_tokens":3,"output_tokens":1}}\n',
b'\n',
])
try:
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
final_text, usage = _run_gateway_runs_api_streaming(
session_id="sess1",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
base_url="http://gw:8642",
api_key="secret",
prefill_messages=[
{"role": "system", "content": "system prompt"},
{"role": "assistant", "content": "earlier reply"},
],
body_extras={"provider": "anthropic"},
put_gateway_event=lambda event, data: events.append((event, data)),
cancel_event=threading.Event(),
)
finally:
STREAM_PARTIAL_TEXT.pop(stream_id, None)
STREAM_REASONING_TEXT.pop(stream_id, None)
_STREAM_RUN_IDS.pop(stream_id, None)
run_req = requests[0]
run_body = json.loads(run_req.data.decode("utf-8"))
assert run_req.full_url == "http://gw:8642/v1/runs"
assert run_req.get_header("Authorization") == "Bearer secret"
assert run_body["input"] == "hi"
assert run_body["instructions"] == "system prompt"
assert run_body["conversation_history"] == [{"role": "assistant", "content": "earlier reply"}]
assert run_body["provider"] == "anthropic"
assert run_body["session_id"] == "sess1"
assert "messages" not in run_body
assert final_text == "Hello"
assert usage["input_tokens"] == 3
assert usage["output_tokens"] == 1
assert events[0][0] == "approval"
assert events[0][1]["description"] == "Dangerous command approval"
assert events[0][1]["approval_id"] == "appr-1"
assert events[1] == ("reasoning", {"text": "thinking..."})
assert events[2] == ("token", {"text": "Hello"})
import api.route_approvals as approvals
assert approvals.gateway_pending_mirror("sess1", run_id="run-abc") is None
def test_empty_id_runs_approval_reaches_real_response_lifecycle():
"""Runs API empty IDs are carried and resolved through the live mirror."""
from api.gateway_chat import _run_gateway_runs_api_streaming
import api.route_approvals as approvals
import api.routes as routes
for choice in ("once", "deny"):
choice_bytes = choice.encode()
sid = f"sess-real-{choice}"
stream_id = f"stream-real-{choice}"
events = []
class _JsonResponse:
def read(self, _limit=None, _choice_bytes=choice_bytes):
return b'{"run_id":"run-real-' + _choice_bytes + b'"}'
def __enter__(self):
return self
def __exit__(self, *args):
return None
class _SseResponse:
def __iter__(self):
return iter([
b'data: {"event":"approval.request","command":"echo x",'
b'"description":"approval","approval_id":""}\n',
b'\n', b'data: [DONE]\n', b'\n'
])
def __enter__(self):
return self
def __exit__(self, *args):
return None
def fake_urlopen(req, *, timeout=None):
return _JsonResponse() if req.full_url.endswith("/v1/runs") else _SseResponse()
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.routes.get_session", return_value=SimpleNamespace(active_stream_id=None)), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json", return_value={"ok": True}):
_run_gateway_runs_api_streaming(
sid, "hi", "test", "/tmp", stream_id, "http://gw:8642", "", [], {},
put_gateway_event=lambda event, data, _events=events: _events.append((event, data)),
cancel_event=threading.Event(),
)
approval_id = events[0][1]["approval_id"]
assert approval_id.startswith(f"gwrun:run-real-{choice}:")
assert approvals.gateway_pending_mirror(sid, approval_id=approval_id)["run_id"] == f"run-real-{choice}"
routes._handle_approval_respond(handler, {
"session_id": sid, "choice": choice, "approval_id": approval_id,
})
assert handler.send_response.call_args.args[0] == 200
assert json.loads(handler.wfile.getvalue().decode())["ok"] is True
assert approvals.gateway_pending_mirror(sid, approval_id=approval_id) is None
approvals._pending.pop(sid, None)
def test_gateway_mirror_match_is_session_scoped_and_retires_one_run():
import api.route_approvals as approvals
sid = "sess-multi-run"
other_sid = "sess-other-run"
approvals._pending.pop(sid, None)
approvals._pending.pop(other_sid, None)
try:
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-a", "command": "a"})
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-b", "command": "b"})
first = approvals.gateway_pending_mirror(sid, run_id="run-a")
second = approvals.gateway_pending_mirror(sid, run_id="run-b")
assert first["approval_id"].startswith("gwrun:run-a:")
assert second["approval_id"].startswith("gwrun:run-b:")
assert approvals.gateway_pending_mirror(other_sid, run_id="run-a") is None
assert approvals.retire_gateway_pending_mirror(sid, run_id="run-a") is True
assert approvals.gateway_pending_mirror(sid, run_id="run-a") is None
assert approvals.gateway_pending_mirror(sid, run_id="run-b") is not None
finally:
approvals._pending.pop(sid, None)
approvals._pending.pop(other_sid, None)
def test_gateway_mirror_exact_pair_lookup_skips_same_id_other_run():
import api.route_approvals as approvals
sid = "sess-same-id-other-run"
approvals._pending.pop(sid, None)
try:
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-old", "approval_id": "same-id", "command": "old"
})
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-live", "approval_id": "same-id", "command": "live"
})
assert approvals.gateway_pending_mirror(sid, approval_id="same-id") is None
live = approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-live")
assert live is not None
assert live["run_id"] == "run-live"
assert approvals.retire_gateway_pending_mirror(sid, approval_id="same-id", run_id="run-live")
assert approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-live") is None
assert approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-old") is not None
finally:
approvals._pending.pop(sid, None)
def test_gateway_approval_response_relay_cleans_only_selected_shared_id_run():
from api.gateway_chat import _STREAM_RUN_IDS
from api import routes
import api.route_approvals as approvals
sid = "sess-shared-id-relay"
stream_id = "sid-shared-id-relay"
_STREAM_RUN_IDS[stream_id] = "run-live"
approvals._pending.pop(sid, None)
try:
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-old", "approval_id": "same-id", "command": "old"
})
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-live", "approval_id": "same-id", "command": "live"
})
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval, \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""):
routes._handle_approval_respond(handler, {
"session_id": sid, "choice": "once", "approval_id": "same-id",
})
handler.send_response.assert_called_with(200)
respond_approval.assert_called_once_with("run-live", "same-id", "once")
assert approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-live") is None
assert approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-old") is not None
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
def test_gateway_approval_response_without_active_run_rejects_shared_upstream_id():
from api import routes
import api.route_approvals as approvals
sid = "sess-shared-id-no-active-run"
approvals._pending.pop(sid, None)
try:
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-a", "approval_id": "shared-upstream-id", "command": "a"
})
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-b", "approval_id": "shared-upstream-id", "command": "b"
})
mock_session = MagicMock()
mock_session.active_stream_id = None
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=True), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
routes._handle_approval_respond(handler, {
"session_id": sid, "choice": "once", "approval_id": "shared-upstream-id",
})
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.send_response.call_args.args[0] == 409
assert payload["code"] == "gateway_run_unavailable"
respond_approval.assert_not_called()
assert approvals.gateway_pending_mirror(sid, approval_id="shared-upstream-id", run_id="run-a") is not None
assert approvals.gateway_pending_mirror(sid, approval_id="shared-upstream-id", run_id="run-b") is not None
finally:
approvals._pending.pop(sid, None)
def test_gateway_approval_relay_does_not_unblock_unrelated_local_head():
from api.gateway_chat import _STREAM_RUN_IDS
from api import routes
import api.route_approvals as approvals
ta = pytest.importorskip(
"tools.approval",
reason="tools.approval not available in this environment",
)
sid = "sess-remote-relay-local-head"
stream_id = "sid-remote-relay-local-head"
_STREAM_RUN_IDS[stream_id] = "run-remote"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-remote", "approval_id": "remote-a", "command": "remote"
})
local_entry = ta._ApprovalEntry({
"command": "local",
"description": "local approval",
"approval_id": "local-b",
})
with approvals._lock:
approvals._gateway_queues[sid] = [local_entry]
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval, \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""):
routes._handle_approval_respond(handler, {
"session_id": sid, "choice": "once", "approval_id": "remote-a",
})
handler.send_response.assert_called_with(200)
respond_approval.assert_called_once_with("run-remote", "remote-a", "once")
assert local_entry.event.is_set() is False
assert local_entry.result is None
assert approvals.gateway_pending_mirror(sid, approval_id="remote-a", run_id="run-remote") is None
with approvals._lock:
assert sid in approvals._gateway_queues
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_empty_id_approvals_same_run_keep_distinct_fifo_cards():
import api.route_approvals as approvals
sid = "sess-same-run"
approvals._pending.pop(sid, None)
try:
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-a", "command": "first"})
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-a", "command": "second"})
queue = approvals._pending[sid]
assert [entry["command"] for entry in queue] == ["first", "second"]
assert queue[0]["approval_id"] != queue[1]["approval_id"]
assert approvals.retire_gateway_pending_mirror(sid, approval_id=queue[0]["approval_id"], run_id="run-a")
assert approvals._pending[sid][0]["command"] == "second"
finally:
approvals._pending.pop(sid, None)
def test_live_gateway_head_gets_token_scoped_approval_id():
import api.route_approvals as approvals
sid = "sess-live-head"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [SimpleNamespace(data={"run_id": "run-live", "command": "head"})]
with approvals._lock:
head, total, changed = approvals.reconcile_gateway_pending_mirror_locked(sid)
assert changed is True
assert total == 1
assert head["approval_id"].startswith("gwrun:run-live:")
assert approvals._gateway_queues[sid][0].data["approval_id"] == head["approval_id"]
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_live_gateway_head_emits_the_stored_token_scoped_identity():
import api.route_approvals as approvals
sid = "sess-live-head-emitted-id"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [SimpleNamespace(data={"run_id": "run-live", "command": "head"})]
approval_data = {"run_id": "run-live", "command": "head"}
approvals.submit_gateway_pending_mirror(sid, approval_data)
mirror = approvals.gateway_pending_mirror(sid, run_id="run-live")
assert mirror is not None
assert approval_data["approval_id"] == mirror["approval_id"]
assert approvals._gateway_queues[sid][0].data["approval_id"] == mirror["approval_id"]
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_distinct_same_run_approval_does_not_overwrite_live_head_identity():
import api.route_approvals as approvals
sid = "sess-same-run-distinct-approval"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [
SimpleNamespace(data={"run_id": "run-live", "approval_id": "appr-a", "command": "head"})
]
approvals.submit_gateway_pending_mirror(
sid,
{"run_id": "run-live", "approval_id": "appr-b", "command": "second"},
)
assert approvals._gateway_queues[sid][0].data["approval_id"] == "appr-a"
assert approvals.gateway_pending_mirror(sid, approval_id="appr-a", run_id="run-live") is not None
assert approvals.gateway_pending_mirror(sid, approval_id="appr-b", run_id="run-live") is not None
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_direct_remote_approval_still_mirrors_with_local_gateway_head():
import api.route_approvals as approvals
sid = "sess-local-head-remote-mirror"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [SimpleNamespace(data={"command": "local-head"})]
approvals.submit_gateway_pending_mirror(
sid,
{"run_id": "run-remote", "approval_id": "remote-a", "command": "remote"},
)
mirror = approvals.gateway_pending_mirror(sid, approval_id="remote-a", run_id="run-remote")
assert mirror is not None
assert mirror["run_id"] == "run-remote"
assert mirror["approval_id"] == "remote-a"
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_legacy_gateway_approval_without_run_gets_browser_visible_id():
import api.route_approvals as approvals
sid = "sess-legacy-no-run-id"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [SimpleNamespace(data={"command": "legacy"})]
approval = {"command": "legacy"}
approvals.submit_gateway_pending_mirror(sid, approval)
assert approval.get("approval_id")
queue = approvals._pending.get(sid) or []
assert queue[0]["approval_id"] == approval["approval_id"]
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_legacy_gateway_approval_without_run_keeps_its_own_id_beside_local_head():
import api.route_approvals as approvals
sid = "sess-legacy-no-run-local-head"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._pending[sid] = [{"approval_id": "local-id", "command": "local"}]
approvals._gateway_queues[sid] = [SimpleNamespace(data={"command": "legacy"})]
approval = {"command": "legacy"}
approvals.submit_gateway_pending_mirror(sid, approval)
assert approval.get("approval_id")
assert approval["approval_id"] != "local-id"
queue = approvals._pending.get(sid) or []
mirrors = [
entry for entry in queue
if entry.get(approvals._GATEWAY_MIRROR_FLAG)
and not str(entry.get("run_id") or "").strip()
]
assert mirrors
assert mirrors[-1]["approval_id"] == approval["approval_id"]
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_legacy_gateway_approval_without_run_keeps_its_own_id_beside_local_gateway_head():
import api.route_approvals as approvals
ta = pytest.importorskip(
"tools.approval",
reason="tools.approval not available in this environment",
)
sid = "sess-legacy-no-run-local-gateway-head"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [ta._ApprovalEntry({
"approval_id": "local-id",
"command": "local-head",
})]
approval = {"approval_id": "remote-id", "command": "legacy"}
approvals.submit_gateway_pending_mirror(sid, approval)
assert approval["approval_id"] == "remote-id"
queue = approvals._pending.get(sid) or []
mirrors = [
entry for entry in queue
if entry.get(approvals._GATEWAY_MIRROR_FLAG)
and not str(entry.get("run_id") or "").strip()
and entry.get("approval_id") == "remote-id"
]
assert mirrors
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_identical_legacy_gateway_approvals_without_run_keep_distinct_ids():
import api.route_approvals as approvals
sid = "sess-legacy-no-run-identical"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
first = {"command": "legacy"}
second = {"command": "legacy"}
approvals.submit_gateway_pending_mirror(sid, first)
approvals.submit_gateway_pending_mirror(sid, second)
assert first["approval_id"] != second["approval_id"]
queue = approvals._pending.get(sid) or []
mirrored_ids = [
entry.get("approval_id")
for entry in queue
if entry.get(approvals._GATEWAY_MIRROR_FLAG)
and not str(entry.get("run_id") or "").strip()
]
assert mirrored_ids == [first["approval_id"], second["approval_id"]]
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_identical_legacy_gateway_approvals_with_explicit_ids_keep_distinct_ids():
import api.route_approvals as approvals
sid = "sess-legacy-no-run-identical-explicit"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
first = {"approval_id": "upstream-a", "command": "legacy"}
second = {"approval_id": "upstream-b", "command": "legacy"}
approvals.submit_gateway_pending_mirror(sid, first)
approvals.submit_gateway_pending_mirror(sid, second)
assert first["approval_id"] == "upstream-a"
assert second["approval_id"] == "upstream-b"
queue = approvals._pending.get(sid) or []
mirrored_ids = [
entry.get("approval_id")
for entry in queue
if entry.get(approvals._GATEWAY_MIRROR_FLAG)
and not str(entry.get("run_id") or "").strip()
]
assert mirrored_ids == ["upstream-a", "upstream-b"]
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_reconcile_does_not_bind_live_token_by_approval_id_alone():
import api.route_approvals as approvals
sid = "sess-cross-run-shared-id"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._pending[sid] = [{
approvals._GATEWAY_MIRROR_FLAG: True,
"run_id": "run-stale",
"approval_id": "appr-shared",
"command": "stale",
}]
approvals._gateway_queues[sid] = [SimpleNamespace(data={
"run_id": "run-live",
"approval_id": "appr-shared",
"command": "live",
})]
with approvals._lock:
head, total, _changed = approvals.reconcile_gateway_pending_mirror_locked(sid)
queue = list(approvals._pending[sid])
assert total == 2
assert head["run_id"] == "run-live"
assert queue[0]["run_id"] == "run-live"
assert queue[1]["run_id"] == "run-stale"
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_terminal_run_retirement_removes_all_same_run_mirrors():
import api.route_approvals as approvals
sid = "sess-terminal-run"
approvals._pending.pop(sid, None)
try:
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-a", "command": "first"})
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-a", "command": "second"})
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-b", "command": "other"})
assert approvals.retire_gateway_pending_mirror(sid, run_id="run-a")
assert approvals.gateway_pending_mirror(sid, run_id="run-a") is None
assert approvals.gateway_pending_mirror(sid, run_id="run-b") is not None
finally:
approvals._pending.pop(sid, None)
def test_terminal_run_retirement_clears_same_run_gateway_queue_state():
import api.route_approvals as approvals
sid = "sess-terminal-run-queue"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [
SimpleNamespace(data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"}),
SimpleNamespace(data={"command": "local-head"}),
]
approvals.submit_gateway_pending_mirror(
sid,
{"run_id": "run-a", "approval_id": "appr-a", "command": "a"},
)
assert approvals.retire_gateway_pending_mirror(sid, run_id="run-a")
assert approvals.gateway_pending_mirror(sid, run_id="run-a") is None
assert not any(
str((getattr(entry, "data", None) or {}).get("run_id") or "").strip() == "run-a"
for entry in approvals._gateway_queues.get(sid, [])
)
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_terminal_run_retirement_clears_non_head_same_run_gateway_queue_state():
import api.route_approvals as approvals
sid = "sess-terminal-run-non-head-queue"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [
SimpleNamespace(data={"command": "local-head"}),
SimpleNamespace(data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"}),
]
assert approvals.retire_gateway_pending_mirror(sid, run_id="run-a")
assert approvals.gateway_pending_mirror(sid, run_id="run-a") is None
assert not any(
str((getattr(entry, "data", None) or {}).get("run_id") or "").strip() == "run-a"
for entry in approvals._gateway_queues.get(sid, [])
)
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_exact_one_retirement_keeps_same_run_gateway_queue_state():
import api.route_approvals as approvals
sid = "sess-exact-one-queue"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._pending[sid] = [
{
approvals._GATEWAY_MIRROR_FLAG: True,
"run_id": "run-a",
"approval_id": "appr-a",
"command": "a",
},
{
approvals._GATEWAY_MIRROR_FLAG: True,
"run_id": "run-a",
"approval_id": "appr-b",
"command": "b",
},
]
approvals._gateway_queues[sid] = [
SimpleNamespace(data={"run_id": "run-a", "approval_id": "appr-b", "command": "b"})
]
assert approvals.retire_gateway_pending_mirror(sid, approval_id="appr-a", run_id="run-a")
assert approvals.gateway_pending_mirror(sid, approval_id="appr-b", run_id="run-a") is not None
queue = approvals._gateway_queues.get(sid) or []
assert len(queue) == 1
assert queue[0].data["approval_id"] == "appr-b"
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_retire_gateway_pending_mirror_notifies_reconciled_successor_when_target_missing():
import api.route_approvals as approvals
sid = "sess-retire-notify-successor"
approvals._pending.pop(sid, None)
try:
approvals._pending[sid] = [{
approvals._GATEWAY_MIRROR_FLAG: True,
"run_id": "run-a",
"approval_id": "appr-b",
"command": "b",
}]
notifications = []
def fake_notify(_sid, head, total):
notifications.append((head["approval_id"] if head else None, total))
with patch.object(approvals, "_approval_sse_notify_locked", side_effect=fake_notify):
assert approvals.retire_gateway_pending_mirror(sid, approval_id="appr-a", run_id="run-a") is False
assert notifications[-1] == ("appr-b", 1)
finally:
approvals._pending.pop(sid, None)
def test_local_stop_uses_runs_api_stop_endpoint():
from api.gateway_chat import _STREAM_RUN_IDS, stop_gateway_run
stream_id = "stream-stop"
_STREAM_RUN_IDS[stream_id] = "run-stop"
class _Response:
status = 200
def __enter__(self):
return self
def __exit__(self, *args):
return None
def geturl(self):
return "http://gw:8642/v1/runs/run-stop/stop"
requests = []
class _Opener:
def open(self, req, *, timeout=None):
requests.append((req, timeout))
return _Response()
def fake_build_opener(*handlers):
assert handlers, "stop must install a no-redirect handler"
return _Opener()
try:
with patch("urllib.request.build_opener", side_effect=fake_build_opener), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value="secret"):
assert stop_gateway_run(stream_id) is True
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
request, timeout = requests[0]
assert request.full_url == "http://gw:8642/v1/runs/run-stop/stop"
assert request.get_method() == "POST"
assert request.get_header("Authorization") == "Bearer secret"
assert timeout == 10
def test_runs_api_cancel_after_run_creation_stops_remote_run_before_events():
from api.gateway_chat import _STREAM_RUN_IDS, _run_gateway_runs_api_streaming
stream_id = "sid-run-create-cancel"
cancel_event = threading.Event()
requests = []
class _JsonResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")
def read(self, _limit=None):
return self._payload
def __enter__(self):
return self
def __exit__(self, *args):
return None
def fake_urlopen(req, *, timeout=None):
requests.append(req.full_url)
if req.full_url.endswith("/v1/runs"):
cancel_event.set()
return _JsonResponse({"run_id": "run-pre-events-cancel"})
raise AssertionError(f"unexpected urlopen after pre-stream cancel: {req.full_url}")
try:
with patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.stop_gateway_run", return_value=True) as stop_run:
final_text, usage = _run_gateway_runs_api_streaming(
session_id="sess-run-create-cancel",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
base_url="http://gw:8642",
api_key="secret",
prefill_messages=[],
body_extras={},
put_gateway_event=lambda *_args, **_kwargs: None,
cancel_event=cancel_event,
cfg={},
)
assert final_text is None
assert usage == {}
stop_run.assert_called_once_with(stream_id)
assert requests == ["http://gw:8642/v1/runs"]
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
def test_runs_api_cancel_after_run_creation_keeps_stream_open_when_stop_fails():
from api.gateway_chat import _STREAM_RUN_IDS, _run_gateway_runs_api_streaming
stream_id = "sid-run-create-cancel-stop-failed"
cancel_event = threading.Event()
requests = []
events = []
class _JsonResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")
def read(self, _limit=None):
return self._payload
def __enter__(self):
return self
def __exit__(self, *args):
return None
class _SseResponse:
def __init__(self, lines):
self._lines = lines
def __iter__(self):
return iter(self._lines)
def __enter__(self):
return self
def __exit__(self, *args):
return None
def fake_urlopen(req, *, timeout=None):
requests.append(req.full_url)
if req.full_url.endswith("/v1/runs"):
cancel_event.set()
return _JsonResponse({"run_id": "run-pre-events-stop-failed"})
if req.full_url.endswith("/events"):
return _SseResponse([
b'data: {"event":"message.delta","delta":"Hello"}\n',
b'\n',
b'data: {"event":"run.completed","output":"Hello"}\n',
b'\n',
])
raise AssertionError(f"unexpected urlopen: {req.full_url}")
try:
with patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.stop_gateway_run", return_value=False) as stop_run:
final_text, usage = _run_gateway_runs_api_streaming(
session_id="sess-run-create-cancel-stop-failed",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
base_url="http://gw:8642",
api_key="secret",
prefill_messages=[],
body_extras={},
put_gateway_event=lambda event, data: events.append((event, data)),
cancel_event=cancel_event,
cfg={},
)
assert final_text == "Hello"
assert usage == {}
stop_run.assert_called_once_with(stream_id)
assert requests == [
"http://gw:8642/v1/runs",
"http://gw:8642/v1/runs/run-pre-events-stop-failed/events",
]
assert [event for event, _data in events] == ["token"]
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
def test_runs_api_marks_run_pending_before_request_preparation():
from api.gateway_chat import _STREAM_RUN_IDS, _run_gateway_runs_api_streaming, gateway_run_id_pending
stream_id = "sid-run-starting-prep"
cancel_event = threading.Event()
observed = {"pending_during_prepare": False, "pending_during_post": False}
class _JsonResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")
def read(self, _limit=None):
return self._payload
def __enter__(self):
return self
def __exit__(self, *args):
return None
class _SseResponse:
def __iter__(self):
return iter([
b'data: {"event":"run.completed","output":"Hello"}\n',
b'\n',
])
def __enter__(self):
return self
def __exit__(self, *args):
return None
def fake_strip(text):
observed["pending_during_prepare"] = gateway_run_id_pending(stream_id)
return text
def fake_urlopen(req, *, timeout=None):
if req.full_url.endswith("/v1/runs"):
observed["pending_during_post"] = gateway_run_id_pending(stream_id)
return _JsonResponse({"run_id": "run-starting"})
if req.full_url.endswith("/events"):
return _SseResponse()
raise AssertionError(f"unexpected urlopen: {req.full_url}")
try:
with patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.streaming._strip_oob_blocks", side_effect=fake_strip):
final_text, usage = _run_gateway_runs_api_streaming(
session_id="sess-run-starting-prep",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
base_url="http://gw:8642",
api_key="secret",
prefill_messages=[],
body_extras={},
put_gateway_event=lambda *_args, **_kwargs: None,
cancel_event=cancel_event,
cfg={},
session=SimpleNamespace(context_messages=[{"role": "user", "content": "ctx"}]),
)
assert final_text == "Hello"
assert usage == {}
assert observed["pending_during_prepare"] is True
assert observed["pending_during_post"] is True
assert gateway_run_id_pending(stream_id) is False
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
def test_gateway_worker_marks_run_pending_before_runs_api_prelude():
from api.gateway_chat import STREAMS, _run_gateway_chat_streaming, gateway_run_id_pending
stream_id = "sid-worker-run-starting"
observed = {"pending_during_support_check": False, "pending_during_runs_call": False}
STREAMS[stream_id] = SimpleNamespace(put_nowait=lambda *_args, **_kwargs: None)
session = SimpleNamespace(
profile=None,
workspace="/tmp",
context_messages=[],
messages=[],
active_stream_id=stream_id,
pending_user_source="webui",
process_wakeup_pause={},
save=lambda: None,
)
def fake_supports(_base_url, _api_key):
observed["pending_during_support_check"] = gateway_run_id_pending(stream_id)
return True
def fake_runs(*_args, **_kwargs):
observed["pending_during_runs_call"] = gateway_run_id_pending(stream_id)
return None, {}
with patch("api.gateway_chat.RunJournalWriter", return_value=SimpleNamespace(append_sse_event=lambda *_a, **_k: None)), \
patch("api.gateway_chat.get_session", return_value=session), \
patch("api.config.get_config", return_value={}), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.gateway_chat._gateway_use_runs_api_enabled", return_value=True), \
patch("api.gateway_chat.gateway_supports_approval", side_effect=fake_supports), \
patch("api.gateway_chat._gateway_reasoning_effort_for_request", return_value=None), \
patch("api.gateway_chat._run_gateway_runs_api_streaming", side_effect=fake_runs), \
patch("api.streaming._load_webui_prefill_context", return_value={}), \
patch("api.streaming._prefill_messages_with_webui_context", return_value=[]), \
patch("api.streaming._normalize_prefill_messages_before_user_turn", side_effect=lambda messages: messages), \
patch("api.streaming._public_prefill_context_status", return_value={}), \
patch("api.streaming._webui_ephemeral_system_prompt", return_value="sys"):
_run_gateway_chat_streaming(
session_id="sess-worker-run-starting",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
)
assert observed["pending_during_support_check"] is True
assert observed["pending_during_runs_call"] is True
assert gateway_run_id_pending(stream_id) is False
def test_local_stop_rejects_redirected_success():
from api.gateway_chat import _STREAM_RUN_IDS, stop_gateway_run
stream_id = "stream-stop-redirect"
_STREAM_RUN_IDS[stream_id] = "run-stop"
class _Response:
status = 200
def __enter__(self):
return self
def __exit__(self, *args):
return None
def geturl(self):
return "http://gw:8642/login"
class _Opener:
def open(self, req, *, timeout=None):
return _Response()
try:
with patch("urllib.request.build_opener", return_value=_Opener()), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=None):
assert stop_gateway_run(stream_id) is False
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
def test_chat_cancel_surfaces_redirected_gateway_stop(monkeypatch):
import urllib.parse
from api import routes
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
sid = "sess-cancel-stop-redirect"
stream_id = "stream-cancel-stop-redirect"
_STREAM_RUN_IDS[stream_id] = "run-stop"
approvals._pending.pop(sid, None)
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-stop", "command": "first"})
captured = {}
called = {"cancel": False}
monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args, **_kwargs: True)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _stream_id: sid)
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True))
monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: False)
def fake_j(handler, data, status=200, extra_headers=None):
captured["payload"] = data
captured["status"] = status
return data
monkeypatch.setattr(routes, "j", fake_j)
parsed = urllib.parse.urlparse(f"/api/chat/cancel?stream_id={stream_id}")
try:
routes.handle_get(object(), parsed)
assert captured["status"] == 502
assert captured["payload"] == {
"ok": False,
"cancelled": False,
"stream_id": stream_id,
"error": "Gateway stop failed",
}
assert called["cancel"] is False
assert sid in approvals._pending
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
def test_chat_cancel_retires_same_run_gateway_mirrors_after_stop(monkeypatch):
import urllib.parse
from api import routes
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
sid = "sess-cancel-stop"
stream_id = "stream-cancel-stop"
_STREAM_RUN_IDS[stream_id] = "run-stop"
approvals._pending.pop(sid, None)
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-stop", "command": "first"})
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-stop", "command": "second"})
captured = {}
monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args, **_kwargs: True)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _stream_id: sid)
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: True)
monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: True)
def fake_j(handler, data, status=200, extra_headers=None):
captured["payload"] = data
captured["status"] = status
return data
monkeypatch.setattr(routes, "j", fake_j)
parsed = urllib.parse.urlparse(f"/api/chat/cancel?stream_id={stream_id}")
try:
routes.handle_get(object(), parsed)
assert captured["status"] == 200
assert captured["payload"] == {"ok": True, "cancelled": True, "stream_id": stream_id}
assert sid not in approvals._pending
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
def test_chat_cancel_surfaces_gateway_stop_failure(monkeypatch):
import urllib.parse
from api import routes
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
sid = "sess-cancel-stop-fail"
stream_id = "stream-cancel-stop-fail"
_STREAM_RUN_IDS[stream_id] = "run-stop"
approvals._pending.pop(sid, None)
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-stop", "command": "first"})
captured = {}
called = {"cancel": False}
monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args, **_kwargs: True)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _stream_id: sid)
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True))
monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: False)
def fake_j(handler, data, status=200, extra_headers=None):
captured["payload"] = data
captured["status"] = status
return data
monkeypatch.setattr(routes, "j", fake_j)
parsed = urllib.parse.urlparse(f"/api/chat/cancel?stream_id={stream_id}")
try:
routes.handle_get(object(), parsed)
assert captured["status"] == 502
assert captured["payload"] == {
"ok": False,
"cancelled": False,
"stream_id": stream_id,
"error": "Gateway stop failed",
}
assert called["cancel"] is False
assert sid in approvals._pending
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
def test_chat_cancel_blocks_while_gateway_run_id_is_pending(monkeypatch):
import urllib.parse
from api import routes
stream_id = "stream-cancel-pending-run-id"
captured = {}
called = {"cancel": False, "stop": False}
monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True)
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True))
monkeypatch.setattr("api.gateway_chat.gateway_run_id_pending", lambda _stream_id: True)
monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: called.__setitem__("stop", True))
monkeypatch.setattr("time.sleep", lambda _seconds: None)
def fake_j(handler, data, status=200, extra_headers=None):
captured["payload"] = data
captured["status"] = status
return data
monkeypatch.setattr(routes, "j", fake_j)
parsed = urllib.parse.urlparse(f"/api/chat/cancel?stream_id={stream_id}")
routes.handle_get(object(), parsed)
assert captured["status"] == 502
assert captured["payload"] == {
"ok": False,
"cancelled": False,
"stream_id": stream_id,
"error": "Gateway stop failed",
}
assert called["cancel"] is False
assert called["stop"] is False
def test_chat_cancel_waits_for_pending_gateway_run_id_then_stops_remote_run(monkeypatch):
import urllib.parse
from api import routes
from api.gateway_chat import _STREAM_RUN_IDS
stream_id = "stream-cancel-pending-wait"
captured = {}
called = {"cancel": False, "stop": False, "pending": True}
def fake_sleep(_seconds):
_STREAM_RUN_IDS[stream_id] = "run-stop"
called["pending"] = False
monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True)
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True) or True)
monkeypatch.setattr("api.gateway_chat.gateway_run_id_pending", lambda _stream_id: called["pending"])
monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: called.__setitem__("stop", True) or True)
monkeypatch.setattr("time.sleep", fake_sleep)
def fake_j(handler, data, status=200, extra_headers=None):
captured["payload"] = data
captured["status"] = status
return data
monkeypatch.setattr(routes, "j", fake_j)
parsed = urllib.parse.urlparse(f"/api/chat/cancel?stream_id={stream_id}")
try:
routes.handle_get(object(), parsed)
assert captured["status"] == 200
assert captured["payload"] == {"ok": True, "cancelled": True, "stream_id": stream_id}
assert called["stop"] is True
assert called["cancel"] is True
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
def test_stale_gateway_card_does_not_relay_live_run():
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
sid = "sess-stale-gateway"
stream_id = "sid-stale-live-run"
_STREAM_RUN_IDS[stream_id] = "run-live"
approvals._pending.pop(sid, None)
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-live", "approval_id": "gwrun:run-live", "command": "echo live"
})
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json") as request_json:
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, {
"session_id": sid,
"choice": "once",
"approval_id": "gwrun:run-stale",
})
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.send_response.call_args.args[0] == 409
assert payload["code"] == "gateway_run_unavailable"
request_json.assert_not_called()
assert approvals.gateway_pending_mirror(sid, run_id="run-live") is not None
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
def test_retained_gateway_card_does_not_relay_different_active_run():
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
sid = "sess-retained-gateway"
stream_id = "sid-retained-live-run"
_STREAM_RUN_IDS[stream_id] = "run-live"
approvals._pending.pop(sid, None)
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-retained", "approval_id": "gwrun:run-retained", "command": "echo retained"
})
approvals.submit_gateway_pending_mirror(sid, {
"run_id": "run-live", "approval_id": "gwrun:run-live", "command": "echo live"
})
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json") as request_json:
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, {
"session_id": sid,
"choice": "once",
"approval_id": "gwrun:run-retained",
})
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.send_response.call_args.args[0] == 409
assert payload["code"] == "gateway_run_unavailable"
request_json.assert_not_called()
assert approvals.gateway_pending_mirror(sid, approval_id="gwrun:run-retained") is not None
assert approvals.gateway_pending_mirror(sid, approval_id="gwrun:run-live") is not None
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
def test_stale_same_run_gateway_card_does_not_relay_live_head():
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
sid = "sess-stale-same-run"
stream_id = "sid-stale-same-run"
_STREAM_RUN_IDS[stream_id] = "run-shared"
approvals._pending.pop(sid, None)
try:
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-shared", "command": "first"})
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-shared", "command": "second"})
queue = approvals._pending[sid]
stale_id = queue[0]["approval_id"]
live_id = queue[1]["approval_id"]
assert approvals.retire_gateway_pending_mirror(sid, approval_id=stale_id, run_id="run-shared")
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json") as request_json:
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, {
"session_id": sid,
"choice": "once",
"approval_id": stale_id,
})
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.send_response.call_args.args[0] == 409
assert payload["code"] == "gateway_run_unavailable"
request_json.assert_not_called()
assert approvals.gateway_pending_mirror(sid, approval_id=live_id) is not None
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
def test_stale_same_run_gateway_card_without_token_does_not_relay_live_head():
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
ta = pytest.importorskip(
"tools.approval",
reason="tools.approval not available in this environment",
)
sid = "sess-stale-same-run-no-token"
stream_id = "sid-stale-same-run-no-token"
_STREAM_RUN_IDS[stream_id] = "run-shared"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._pending[sid] = [{
approvals._GATEWAY_MIRROR_FLAG: True,
"run_id": "run-shared",
"approval_id": "stale-a",
"command": "stale",
}]
approvals._gateway_queues[sid] = [ta._ApprovalEntry({
"run_id": "run-shared",
"approval_id": "live-b",
"command": "live",
})]
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json") as request_json:
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, {
"session_id": sid,
"choice": "once",
"approval_id": "stale-a",
})
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.send_response.call_args.args[0] == 409
assert payload["code"] == "gateway_run_unavailable"
request_json.assert_not_called()
assert approvals.gateway_pending_mirror(sid, approval_id="live-b", run_id="run-shared") is not None
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_stale_same_run_gateway_card_without_token_after_teardown_does_not_relay_live_head():
import api.route_approvals as approvals
ta = pytest.importorskip(
"tools.approval",
reason="tools.approval not available in this environment",
)
sid = "sess-stale-same-run-no-token-teardown"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._pending[sid] = [{
approvals._GATEWAY_MIRROR_FLAG: True,
"run_id": "run-shared",
"approval_id": "stale-a",
"command": "stale",
}]
approvals._gateway_queues[sid] = [ta._ApprovalEntry({
"run_id": "run-shared",
"approval_id": "live-b",
"command": "live",
})]
mock_session = MagicMock()
mock_session.active_stream_id = None
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json") as request_json:
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, {
"session_id": sid,
"choice": "once",
"approval_id": "stale-a",
})
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.send_response.call_args.args[0] == 409
assert payload["code"] == "gateway_run_unavailable"
request_json.assert_not_called()
assert approvals.gateway_pending_mirror(sid, approval_id="live-b", run_id="run-shared") is not None
finally:
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_stale_same_run_gateway_card_without_token_does_not_relay_live_head_when_live_id_is_synthesized():
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
ta = pytest.importorskip(
"tools.approval",
reason="tools.approval not available in this environment",
)
sid = "sess-stale-same-run-no-token-synthesized-live-id"
stream_id = "sid-stale-same-run-no-token-synthesized-live-id"
_STREAM_RUN_IDS[stream_id] = "run-shared"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._pending[sid] = [{
approvals._GATEWAY_MIRROR_FLAG: True,
"run_id": "run-shared",
"approval_id": "stale-a",
"command": "stale",
}]
approvals._gateway_queues[sid] = [ta._ApprovalEntry({
"run_id": "run-shared",
"command": "live",
})]
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json") as request_json:
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, {
"session_id": sid,
"choice": "once",
"approval_id": "stale-a",
})
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.send_response.call_args.args[0] == 409
assert payload["code"] == "gateway_run_unavailable"
request_json.assert_not_called()
live_mirror = approvals.gateway_pending_mirror(sid, run_id="run-shared")
assert live_mirror is not None
assert live_mirror["approval_id"] != "stale-a"
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_token_stale_same_run_gateway_card_does_not_relay_advanced_live_head():
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
sid = "sess-token-stale-same-run"
stream_id = "sid-token-stale-same-run"
_STREAM_RUN_IDS[stream_id] = "run-shared"
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
try:
approvals._gateway_queues[sid] = [SimpleNamespace(data={
"run_id": "run-shared",
"approval_id": "gwrun:run-shared:a",
"command": "first",
})]
approvals.submit_gateway_pending_mirror(sid, dict(approvals._gateway_queues[sid][0].data))
stale_id = approvals._pending[sid][0]["approval_id"]
approvals._gateway_queues[sid] = [SimpleNamespace(data={
"run_id": "run-shared",
"approval_id": "gwrun:run-shared:b",
"command": "second",
})]
approvals.submit_gateway_pending_mirror(sid, dict(approvals._gateway_queues[sid][0].data))
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json") as request_json:
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, {
"session_id": sid,
"choice": "once",
"approval_id": stale_id,
})
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.send_response.call_args.args[0] == 409
assert payload["code"] == "gateway_run_unavailable"
request_json.assert_not_called()
assert approvals.gateway_pending_mirror(sid, approval_id="gwrun:run-shared:a") is None
assert approvals.gateway_pending_mirror(sid, approval_id="gwrun:run-shared:b") is not None
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
approvals._pending.pop(sid, None)
approvals._gateway_queues.pop(sid, None)
def test_gateway_runs_api_streaming_preserves_multimodal_input():
"""Attachment-backed runs requests must keep multimodal content lists."""
from api.gateway_chat import _STREAM_RUN_IDS, _run_gateway_runs_api_streaming
requests = []
multimodal_content = [
{"type": "input_text", "text": "describe this"},
{"type": "input_image", "image_url": "file:///tmp/demo.png"},
]
class _JsonResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")
def read(self, _limit=None):
return self._payload
def __enter__(self):
return self
def __exit__(self, *args):
return None
class _SseResponse:
def __iter__(self):
return iter([
b'data: {"event":"run.completed","output":"done","usage":{"input_tokens":1,"output_tokens":1}}\n',
b'\n',
])
def __enter__(self):
return self
def __exit__(self, *args):
return None
def fake_urlopen(req, *, timeout=None):
requests.append(req)
if req.full_url.endswith("/v1/runs"):
return _JsonResponse({"run_id": "run-mm"})
return _SseResponse()
try:
with patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.streaming._build_native_multimodal_message", return_value=multimodal_content):
_run_gateway_runs_api_streaming(
session_id="sess-mm",
msg_text="describe this",
model="test-model",
workspace="/tmp",
stream_id="sid-mm",
base_url="http://gw:8642",
api_key="secret",
prefill_messages=[],
body_extras={},
put_gateway_event=lambda *_args, **_kwargs: None,
cancel_event=threading.Event(),
attachments=[{"name": "demo.png"}],
cfg={},
)
finally:
_STREAM_RUN_IDS.pop("sid-mm", None)
run_body = json.loads(requests[0].data.decode("utf-8"))
assert run_body["input"] == [{"role": "user", "content": multimodal_content}]
assert run_body["input"][0]["role"] == "user"
assert run_body["input"][0]["content"] == multimodal_content
# ---------------------------------------------------------------------------
# 4. Cancelled runs path should not emit gateway_empty_response
# ---------------------------------------------------------------------------
def test_gateway_runs_api_cancel_does_not_emit_empty_response():
"""Cancelled runs-API turns should stop cleanly without empty-response errors."""
from api.config import STREAMS, STREAMS_LOCK
from api.gateway_chat import _run_gateway_chat_streaming
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-cancel"
with STREAMS_LOCK:
STREAMS[stream_id] = q
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
mock_session.workspace = "/tmp"
mock_session.model = "test"
mock_session.model_provider = None
mock_session.profile = None
mock_session.context_messages = []
mock_session.messages = []
mock_session.pending_user_message = None
mock_session.pending_attachments = None
mock_session.pending_started_at = None
def fake_runs_streaming(*args, **kwargs):
kwargs["put_gateway_event"]("cancel", {"message": "Cancelled by gateway"})
return None, {}
try:
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway", "HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1"}):
with patch("api.gateway_chat.gateway_supports_approval", return_value=True), \
patch("api.gateway_chat._run_gateway_runs_api_streaming", side_effect=fake_runs_streaming), \
patch("api.gateway_chat.get_session", return_value=mock_session):
_run_gateway_chat_streaming(
session_id="sess-cancel",
msg_text="stop",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
)
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
assert any(e[0] == "cancel" for e in events if isinstance(e, tuple)), events
assert not any(
e[0] == "apperror" and isinstance(e[1], dict) and e[1].get("type") == "gateway_empty_response"
for e in events if isinstance(e, tuple)
), events
# ---------------------------------------------------------------------------
# 5. Approval response relay
# ---------------------------------------------------------------------------
def test_gateway_approval_response_relay():
"""_handle_approval_respond relays the real gateway approval body."""
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
# Seed the mapping.
_STREAM_RUN_IDS["sid-relay"] = "run abc/1"
approvals.submit_gateway_pending_mirror("sess-relay", {
"run_id": "run abc/1", "approval_id": "appr x/y", "command": "echo x"
})
mock_session = MagicMock()
mock_session.active_stream_id = "sid-relay"
captured = {}
def fake_request_json(self, req):
captured["url"] = req.full_url
captured["body"] = json.loads(req.data)
return {"ok": True}
handler = MagicMock()
handler.wfile = io.BytesIO()
body = {"session_id": "sess-relay", "choice": "once", "approval_id": "appr x/y"}
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.runner_client.HttpRunnerClient._request_json", new=fake_request_json), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""):
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, body)
assert captured.get("url", "") == "http://gw:8642/v1/runs/run%20abc%2F1/approval"
assert captured["body"] == {"choice": "once", "approval_id": "appr x/y"}
handler.send_response.assert_called_with(200)
# Cleanup.
_STREAM_RUN_IDS.pop("sid-relay", None)
approvals._pending.pop("sess-relay", None)
def test_gateway_approval_response_without_approval_id_409s():
"""Gateway relay requires the emitted per-approval id, not bare run state."""
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
_STREAM_RUN_IDS["sid-relay-empty-id"] = "run abc/1"
approvals.submit_gateway_pending_mirror("sess-relay", {
"run_id": "run abc/1", "approval_id": "", "command": "echo x"
})
mock_session = MagicMock()
mock_session.active_stream_id = "sid-relay-empty-id"
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""), \
patch("api.runner_client.HttpRunnerClient._request_json") as request_json, \
patch("api.routes._resolve_approval_legacy", return_value=True) as cleanup:
from api.routes import _handle_approval_respond
_handle_approval_respond(
handler,
{"session_id": "sess-relay", "choice": "once", "approval_id": ""},
)
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
status = handler.send_response.call_args.args[0]
assert status == 409, f"status={status} payload={payload}"
assert payload["ok"] is False
assert payload["relayed"] is False
assert payload["code"] == "gateway_run_unavailable"
request_json.assert_not_called()
cleanup.assert_not_called()
_STREAM_RUN_IDS.pop("sid-relay-empty-id", None)
approvals._pending.pop("sess-relay", None)
def test_gateway_approval_response_relay_failure_returns_502():
"""Gateway relay failures must surface as HTTP errors to the frontend."""
from api.gateway_chat import _STREAM_RUN_IDS
from api.runner_client import RunnerClientError
import api.route_approvals as approvals
_STREAM_RUN_IDS["sid-relay-fail"] = "run-abc"
approvals.submit_gateway_pending_mirror("sess-relay", {
"run_id": "run-abc", "approval_id": "appr-x", "command": "echo x"
})
mock_session = MagicMock()
mock_session.active_stream_id = "sid-relay-fail"
handler = MagicMock()
handler.wfile = io.BytesIO()
body = {"session_id": "sess-relay", "choice": "once", "approval_id": "appr-x"}
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.runner_client.HttpRunnerClient.respond_approval", side_effect=RunnerClientError("relay failed")), \
patch("api.gateway_chat._gateway_base_url", return_value="http://gw:8642"), \
patch("api.gateway_chat._gateway_api_key", return_value=""):
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, body)
handler.send_response.assert_called_with(502)
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert payload["ok"] is False
assert payload["relayed"] is True
assert "relay failed" in payload["error"]
_STREAM_RUN_IDS.pop("sid-relay-fail", None)
approvals._pending.pop("sess-relay", None)
def test_gateway_approval_response_invalid_gateway_base_returns_502():
"""Misconfigured gateway bases must not fall through to the local approval path."""
from api.gateway_chat import _STREAM_RUN_IDS
import api.route_approvals as approvals
_STREAM_RUN_IDS["sid-relay-invalid-base"] = "run-abc"
approvals.submit_gateway_pending_mirror("sess-relay", {
"run_id": "run-abc", "approval_id": "appr-x", "command": "echo x"
})
mock_session = MagicMock()
mock_session.active_stream_id = "sid-relay-invalid-base"
handler = MagicMock()
handler.wfile = io.BytesIO()
body = {"session_id": "sess-relay", "choice": "once", "approval_id": "appr-x"}
with patch("api.routes.get_session", return_value=mock_session), \
patch("api.gateway_chat._gateway_base_url", return_value="file:///tmp/not-http"), \
patch("api.gateway_chat._gateway_api_key", return_value=""):
from api.routes import _handle_approval_respond
_handle_approval_respond(handler, body)
handler.send_response.assert_called_with(502)
payload = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert payload["ok"] is False
assert payload["relayed"] is True
assert "runner base_url must be http(s)" in payload["error"]
_STREAM_RUN_IDS.pop("sid-relay-invalid-base", None)
approvals._pending.pop("sess-relay", None)
# ---------------------------------------------------------------------------
# 6. Empty chat/completions response emits gateway_empty_response (not a
# misleading approval-unsupported banner)
# ---------------------------------------------------------------------------
def test_gateway_empty_response_no_approval_banner():
"""Empty response from chat/completions path emits gateway_empty_response, not gateway_approval_unsupported."""
from api.config import STREAMS, STREAMS_LOCK
from api.gateway_chat import _run_gateway_chat_streaming
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-fb"
with STREAMS_LOCK:
STREAMS[stream_id] = q
# Simulate an SSE stream that returns only [DONE] with no content.
sse_body = b"data: [DONE]\n\n"
def fake_urlopen(req, *, timeout=None):
resp = MagicMock()
resp.__iter__ = lambda s: iter(sse_body.split(b"\n"))
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
try:
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
with patch("api.gateway_chat.gateway_supports_approval", return_value=False), \
patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.get_session", return_value=MagicMock(
active_stream_id=stream_id, workspace="/tmp",
profile=None, context_messages=[], messages=[],
)):
_run_gateway_chat_streaming(
session_id="sess-fb",
msg_text="do something risky",
model="test",
workspace="/tmp",
stream_id=stream_id,
)
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
apperrors = [e for e in events if isinstance(e, tuple) and e[0] == "apperror"]
# The misleading gateway_approval_unsupported banner should no longer fire;
# the generic gateway_empty_response handler covers this case correctly.
assert not any(
isinstance(ev[1], dict) and ev[1].get("type") == "gateway_approval_unsupported"
for ev in apperrors
), f"gateway_approval_unsupported should not fire for generic empty responses: {apperrors}"
assert any(
isinstance(ev[1], dict) and ev[1].get("type") == "gateway_empty_response"
for ev in apperrors
), f"Expected gateway_empty_response apperror, got events: {apperrors}"
# ---------------------------------------------------------------------------
# 7. Chat/completions path unchanged for normal responses
# ---------------------------------------------------------------------------
def test_gateway_chat_completions_path_unchanged():
"""Non-stalling chat/completions turn completes without apperror events."""
from api.config import STREAMS, STREAMS_LOCK
from api.gateway_chat import _run_gateway_chat_streaming
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-ok"
with STREAMS_LOCK:
STREAMS[stream_id] = q
# Simulate a normal SSE response with content.
sse_body = (
b'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'
b"data: [DONE]\n\n"
)
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
mock_session.workspace = "/tmp"
mock_session.model = "test"
mock_session.model_provider = None
mock_session.profile = None
mock_session.context_messages = []
mock_session.messages = []
mock_session.pending_user_message = None
mock_session.pending_attachments = None
mock_session.pending_started_at = None
def fake_urlopen(req, *, timeout=None):
resp = MagicMock()
resp.__iter__ = lambda s: iter(sse_body.split(b"\n"))
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
try:
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
with patch("api.gateway_chat.gateway_supports_approval", return_value=False), \
patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.get_session", return_value=mock_session), \
patch("api.gateway_chat._stream_writeback_is_current", return_value=True), \
patch("api.gateway_chat.merge_session_messages_append_only", return_value=[]):
_run_gateway_chat_streaming(
session_id="sess-ok",
msg_text="hello",
model="test",
workspace="/tmp",
stream_id=stream_id,
)
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
apperrors = [e for e in events if isinstance(e, tuple) and e[0] == "apperror"]
assert not apperrors, f"No apperror expected for a normal response, got: {apperrors}"
tokens = [e for e in events if isinstance(e, tuple) and e[0] == "token"]
assert tokens, "Expected at least one token event"