fix: relay approval SSE events on the legacy gateway chat/completions path
This commit is contained in:
1 parent
c1a24171e8
commit
f19b0ffc67
2 files changed
+148
No files matched your search
@@ -702,6 +702,29 @@ def _run_gateway_chat_streaming(
|
||||
put_gateway_event("reasoning", {"text": reason_delta})
|
||||
sse_event = "message"
|
||||
continue
|
||||
if sse_event in {"hermes.approval.request", "approval.request"}:
|
||||
approval_data = _gateway_runs_approval_event(payload)
|
||||
if approval_data:
|
||||
put_gateway_event("approval", approval_data)
|
||||
try:
|
||||
from api.route_approvals import submit_gateway_pending_mirror
|
||||
submit_gateway_pending_mirror(session_id, approval_data)
|
||||
except Exception:
|
||||
pass
|
||||
sse_event = "message"
|
||||
continue
|
||||
_payload_event = str(payload.get("event") or payload.get("type") or "").strip()
|
||||
if _payload_event in {"hermes.approval.request", "approval.request"}:
|
||||
approval_data = _gateway_runs_approval_event(payload)
|
||||
if approval_data:
|
||||
put_gateway_event("approval", approval_data)
|
||||
try:
|
||||
from api.route_approvals import submit_gateway_pending_mirror
|
||||
submit_gateway_pending_mirror(session_id, approval_data)
|
||||
except Exception:
|
||||
pass
|
||||
sse_event = "message"
|
||||
continue
|
||||
last_payload = payload
|
||||
reasoning_delta = _gateway_sse_reasoning_delta(payload)
|
||||
if reasoning_delta:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for approval event handling on the gateway legacy /v1/chat/completions path (#4549).
|
||||
|
||||
The legacy path is the default when HERMES_WEBUI_GATEWAY_USE_RUNS_API is not set.
|
||||
PR #4495 fixed the runs API path but left the legacy path without approval handling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from api.gateway_chat import _gateway_runs_approval_event
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
GATEWAY_CHAT_SRC = (REPO_ROOT / "api" / "gateway_chat.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_legacy_sse_loop():
|
||||
"""Extract the legacy /v1/chat/completions SSE parsing loop body."""
|
||||
marker = 'url = f"{base_url}/v1/chat/completions"'
|
||||
start = GATEWAY_CHAT_SRC.find(marker)
|
||||
assert start >= 0, "Legacy chat/completions path not found in gateway_chat.py"
|
||||
loop_marker = "with urllib.request.urlopen(req, timeout="
|
||||
loop_start = GATEWAY_CHAT_SRC.find(loop_marker, start)
|
||||
assert loop_start >= 0, "Legacy SSE loop not found"
|
||||
brace = GATEWAY_CHAT_SRC.find(":", loop_start)
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
while i < len(GATEWAY_CHAT_SRC) and depth:
|
||||
ch = GATEWAY_CHAT_SRC[i]
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
elif ch == "\n":
|
||||
rest = GATEWAY_CHAT_SRC[i + 1:]
|
||||
indent = len(rest) - len(rest.lstrip())
|
||||
if indent <= 12 and rest.lstrip() and not rest.lstrip().startswith("#"):
|
||||
pass
|
||||
i += 1
|
||||
return GATEWAY_CHAT_SRC[start:i]
|
||||
|
||||
|
||||
def test_legacy_loop_checks_approval_request_sse_event():
|
||||
"""Legacy SSE loop must handle `event: approval.request`."""
|
||||
loop = _extract_legacy_sse_loop()
|
||||
assert '"approval.request"' in loop, (
|
||||
"Legacy SSE loop must check for approval.request SSE event name"
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_loop_checks_hermes_approval_request_sse_event():
|
||||
"""Legacy SSE loop must handle `event: hermes.approval.request`."""
|
||||
loop = _extract_legacy_sse_loop()
|
||||
assert '"hermes.approval.request"' in loop, (
|
||||
"Legacy SSE loop must check for hermes.approval.request SSE event name"
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_loop_checks_inline_json_approval_event():
|
||||
"""Legacy SSE loop must derive event type from JSON payload for inline approval."""
|
||||
loop = _extract_legacy_sse_loop()
|
||||
assert 'payload.get("event")' in loop or "payload.get('event')" in loop, (
|
||||
"Legacy SSE loop must check payload JSON 'event' field for inline approval"
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_loop_calls_put_gateway_event_approval():
|
||||
"""Legacy SSE loop must relay approval via put_gateway_event('approval', ...)."""
|
||||
loop = _extract_legacy_sse_loop()
|
||||
assert 'put_gateway_event("approval"' in loop, (
|
||||
"Legacy SSE loop must call put_gateway_event with 'approval' event type"
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_loop_calls_submit_gateway_pending_mirror():
|
||||
"""Legacy SSE loop must mirror approval to polling state."""
|
||||
loop = _extract_legacy_sse_loop()
|
||||
assert "submit_gateway_pending_mirror" in loop, (
|
||||
"Legacy SSE loop must call submit_gateway_pending_mirror for polling fallback"
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_loop_reuses_gateway_runs_approval_event():
|
||||
"""Legacy SSE loop must reuse _gateway_runs_approval_event, not duplicate the mapping."""
|
||||
loop = _extract_legacy_sse_loop()
|
||||
assert "_gateway_runs_approval_event" in loop, (
|
||||
"Legacy SSE loop must call _gateway_runs_approval_event to map the payload"
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_loop_resets_sse_event_after_approval():
|
||||
"""Legacy SSE loop must reset sse_event to 'message' after handling approval."""
|
||||
loop = _extract_legacy_sse_loop()
|
||||
approval_idx = loop.find('"hermes.approval.request"')
|
||||
assert approval_idx >= 0
|
||||
block_after = loop[approval_idx:approval_idx + 800]
|
||||
assert 'sse_event = "message"' in block_after, (
|
||||
"Must reset sse_event to 'message' after approval handling to prevent bleed"
|
||||
)
|
||||
|
||||
|
||||
def test_approval_event_mapping_complete_payload():
|
||||
"""_gateway_runs_approval_event correctly maps a full approval payload."""
|
||||
result = _gateway_runs_approval_event({
|
||||
"command": "rm -rf /tmp/x",
|
||||
"description": "Dangerous command approval",
|
||||
"pattern_key": "dangerous_command",
|
||||
"pattern_keys": ["dangerous_command"],
|
||||
"approval_id": "appr-leg-1",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
})
|
||||
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["approval_id"] == "appr-leg-1"
|
||||
assert result["allow_permanent"] is True
|
||||
assert result["risk_level"] == "high"
|
||||
|
||||
|
||||
def test_approval_event_mapping_rejects_empty():
|
||||
"""Incomplete payload returns None."""
|
||||
assert _gateway_runs_approval_event({"risk_level": "high"}) is None
|
||||
assert _gateway_runs_approval_event({}) is None
|
||||
Reference in new issue
Block a user