Keep reconnect confirmations transient so they stop consuming mobile composer space, while a single owned timer prevents stale notices from clearing newer status text. Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.6-luna Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""Regression coverage for #5139 gateway approval offline notice."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from api.config import STREAMS, STREAMS_LOCK, invalidate_gateway_caps
|
|
from api.gateway_chat import _run_gateway_chat_streaming
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
GATEWAY_CHAT = (REPO / "api" / "gateway_chat.py").read_text(encoding="utf-8")
|
|
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _run_gateway_warning_case(unavailable_reason: str) -> list:
|
|
events = []
|
|
q = MagicMock()
|
|
q.put_nowait = lambda item: events.append(item)
|
|
|
|
stream_id = f"sid-{unavailable_reason}"
|
|
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
|
|
mock_session._approval_notice_emitted = False
|
|
|
|
def fake_urlopen(req, *, timeout=None):
|
|
assert req.full_url == "http://127.0.0.1:8642/v1/chat/completions"
|
|
assert req.get_method() == "POST"
|
|
payload = req.data.decode("utf-8")
|
|
assert json.loads(payload)["messages"][-1]["content"] == "hi"
|
|
resp = MagicMock()
|
|
resp.__iter__ = lambda s: iter(
|
|
[
|
|
b'data: {"choices":[{"delta":{"content":"Done"}}]}\n',
|
|
b"\n",
|
|
b"data: [DONE]\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("api.gateway_chat.gateway_approval_unavailable_reason", return_value=unavailable_reason), \
|
|
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-warning",
|
|
msg_text="hi",
|
|
model="test-model",
|
|
workspace="/tmp",
|
|
stream_id=stream_id,
|
|
)
|
|
finally:
|
|
with STREAMS_LOCK:
|
|
STREAMS.pop(stream_id, None)
|
|
|
|
return events
|
|
|
|
|
|
def test_gateway_chat_emits_offline_warning_for_unreachable_probe():
|
|
events = _run_gateway_warning_case("unreachable")
|
|
warnings = [item for item in events if isinstance(item, tuple) and item[0] == "warning"]
|
|
assert warnings
|
|
assert warnings[0][1]["type"] == "approval_gateway_offline"
|
|
assert warnings[0][1]["message"] == "Gateway connection failed. Check that the connected Hermes gateway is running and reachable."
|
|
assert any(isinstance(item, tuple) and item[0] == "done" for item in events)
|
|
assert not any(
|
|
isinstance(item, tuple) and item[0] == "warning" and item[1].get("type") == "approval_gateway_unsupported"
|
|
for item in events
|
|
)
|
|
|
|
|
|
def test_gateway_chat_keeps_unsupported_warning_for_reachable_older_gateway():
|
|
events = _run_gateway_warning_case("unsupported")
|
|
warnings = [item for item in events if isinstance(item, tuple) and item[0] == "warning"]
|
|
assert warnings
|
|
assert warnings[0][1]["type"] == "approval_gateway_unsupported"
|
|
assert warnings[0][1]["message"] == "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this."
|
|
assert any(isinstance(item, tuple) and item[0] == "done" for item in events)
|
|
assert not any(
|
|
isinstance(item, tuple) and item[0] == "warning" and item[1].get("type") == "approval_gateway_offline"
|
|
for item in events
|
|
)
|
|
|
|
|
|
def test_gateway_chat_keeps_unsupported_warning_for_404_capabilities_probe():
|
|
events = []
|
|
q = MagicMock()
|
|
q.put_nowait = lambda item: events.append(item)
|
|
|
|
stream_id = "sid-http-404"
|
|
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
|
|
mock_session._approval_notice_emitted = False
|
|
|
|
def fake_urlopen(req, *, timeout=None):
|
|
if req.full_url == "http://127.0.0.1:8642/v1/capabilities":
|
|
raise urllib.error.HTTPError(req.full_url, 404, "Not Found", hdrs=None, fp=io.BytesIO(b""))
|
|
assert req.full_url == "http://127.0.0.1:8642/v1/chat/completions"
|
|
resp = MagicMock()
|
|
resp.__iter__ = lambda s: iter(
|
|
[
|
|
b'data: {"choices":[{"delta":{"content":"Done"}}]}\n',
|
|
b"\n",
|
|
b"data: [DONE]\n",
|
|
]
|
|
)
|
|
resp.__enter__ = lambda s: s
|
|
resp.__exit__ = lambda s, *a: None
|
|
return resp
|
|
|
|
invalidate_gateway_caps()
|
|
|
|
try:
|
|
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
|
|
with 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-404",
|
|
msg_text="hi",
|
|
model="test-model",
|
|
workspace="/tmp",
|
|
stream_id=stream_id,
|
|
)
|
|
finally:
|
|
with STREAMS_LOCK:
|
|
STREAMS.pop(stream_id, None)
|
|
invalidate_gateway_caps()
|
|
|
|
warnings = [item for item in events if isinstance(item, tuple) and item[0] == "warning"]
|
|
assert warnings
|
|
assert warnings[0][1]["type"] == "approval_gateway_unsupported"
|
|
assert warnings[0][1]["message"] == "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this."
|
|
assert any(isinstance(item, tuple) and item[0] == "done" for item in events)
|
|
assert not any(
|
|
isinstance(item, tuple) and item[0] == "warning" and item[1].get("type") == "approval_gateway_offline"
|
|
for item in events
|
|
)
|
|
|
|
|
|
def test_gateway_chat_keeps_unsupported_warning_for_timeout_capabilities_probe():
|
|
events = []
|
|
q = MagicMock()
|
|
q.put_nowait = lambda item: events.append(item)
|
|
|
|
stream_id = "sid-timeout"
|
|
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
|
|
mock_session._approval_notice_emitted = False
|
|
|
|
def fake_urlopen(req, *, timeout=None):
|
|
if req.full_url == "http://127.0.0.1:8642/v1/capabilities":
|
|
raise TimeoutError("timed out")
|
|
assert req.full_url == "http://127.0.0.1:8642/v1/chat/completions"
|
|
resp = MagicMock()
|
|
resp.__iter__ = lambda s: iter(
|
|
[
|
|
b'data: {"choices":[{"delta":{"content":"Done"}}]}\n',
|
|
b"\n",
|
|
b"data: [DONE]\n",
|
|
]
|
|
)
|
|
resp.__enter__ = lambda s: s
|
|
resp.__exit__ = lambda s, *a: None
|
|
return resp
|
|
|
|
invalidate_gateway_caps()
|
|
|
|
try:
|
|
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
|
|
with 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-timeout",
|
|
msg_text="hi",
|
|
model="test-model",
|
|
workspace="/tmp",
|
|
stream_id=stream_id,
|
|
)
|
|
finally:
|
|
with STREAMS_LOCK:
|
|
STREAMS.pop(stream_id, None)
|
|
invalidate_gateway_caps()
|
|
|
|
warnings = [item for item in events if isinstance(item, tuple) and item[0] == "warning"]
|
|
assert warnings
|
|
assert warnings[0][1]["type"] == "approval_gateway_unsupported"
|
|
assert warnings[0][1]["message"] == "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this."
|
|
assert any(isinstance(item, tuple) and item[0] == "done" for item in events)
|
|
assert not any(
|
|
isinstance(item, tuple) and item[0] == "warning" and item[1].get("type") == "approval_gateway_offline"
|
|
for item in events
|
|
)
|
|
|
|
|
|
def test_messages_js_handles_offline_warning_without_touching_unsupported_branch():
|
|
assert "d.type==='approval_gateway_offline'" in MESSAGES_JS
|
|
assert "Gateway offline" in MESSAGES_JS
|
|
assert "d.type==='approval_gateway_unsupported'" in MESSAGES_JS
|
|
assert "Approvals not supported" in MESSAGES_JS
|
|
assert "setComposerStatus(`${d.message||'Warning'}`,d.type==='fallback'?4000:undefined);" in MESSAGES_JS
|
|
|
|
|
|
def test_gateway_chat_source_mentions_offline_warning_type():
|
|
assert "approval_type = \"approval_gateway_offline\"" in GATEWAY_CHAT
|
|
assert "approval_type = \"approval_gateway_unsupported\"" in GATEWAY_CHAT
|
|
assert "approval_message = \"Gateway connection failed. Check that the connected Hermes gateway is running and reachable.\"" in GATEWAY_CHAT
|