fix: forward gateway tool activity to webui

This commit is contained in:
AJV20 2026-05-28 16:03:16 -04:00
parent 5ec136af3b
commit b76a6dfedb
3 changed files with 122 additions and 2 deletions

View file

@ -3,6 +3,10 @@
## [Unreleased]
### Fixed
- Gateway-backed browser chat now forwards Hermes Gateway `hermes.tool.progress` SSE events into WebUI's live tool/activity stream, so Gateway runs no longer appear idle while server-side tools are running.
## [v0.51.156] — 2026-05-28 — Release EB (stage-batch38 — 2-PR Tier B cleanup: WebUI request/runtime hardening + chat-start provider fallback)
### Fixed

View file

@ -142,6 +142,28 @@ def _gateway_stream_usage(payload: dict) -> dict:
}
def _gateway_tool_progress_event(payload: dict) -> tuple[str, dict] | None:
"""Translate Hermes Gateway tool-progress SSE payloads to WebUI events."""
if not isinstance(payload, dict):
return None
name = str(payload.get("tool") or payload.get("name") or payload.get("function_name") or "").strip()
if not name or name.startswith("_"):
return None
status = str(payload.get("status") or "running").strip().lower()
tid = payload.get("toolCallId") or payload.get("tool_call_id") or payload.get("id")
is_complete = status in {"completed", "complete", "success", "error", "failed"}
event_payload = {
"event_type": "tool.completed" if is_complete else "tool.started",
"name": name,
"preview": payload.get("label") or payload.get("preview"),
"args": payload.get("args") if isinstance(payload.get("args"), dict) else {},
"is_error": status in {"error", "failed"},
}
if tid:
event_payload["tid"] = str(tid)
return ("tool_complete" if is_complete else "tool"), event_payload
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
@ -260,13 +282,20 @@ def _run_gateway_chat_streaming(
)
update_active_run(stream_id, phase="gateway-request")
last_payload = {}
sse_event = "message"
with urllib.request.urlopen(req, timeout=600) as resp:
for raw_line in resp:
if cancel_event.is_set():
put_gateway_event("cancel", {"message": "Cancelled by user"})
return
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or not line.startswith("data:"):
if not line:
sse_event = "message"
continue
if line.startswith("event:"):
sse_event = line[6:].strip() or "message"
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
@ -275,6 +304,32 @@ def _run_gateway_chat_streaming(
payload = json.loads(data)
except json.JSONDecodeError:
continue
if sse_event == "hermes.tool.progress":
translated = _gateway_tool_progress_event(payload)
if translated:
event_name, event_payload = translated
if stream_id in STREAM_LIVE_TOOL_CALLS:
if event_name == "tool":
STREAM_LIVE_TOOL_CALLS[stream_id].append({
"name": event_payload.get("name"),
"args": event_payload.get("args") or {},
"done": False,
**({"tid": event_payload.get("tid")} if event_payload.get("tid") else {}),
})
else:
for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]):
if shared_tc.get("done"):
continue
if (
event_payload.get("tid") and shared_tc.get("tid") == event_payload.get("tid")
) or shared_tc.get("name") == event_payload.get("name"):
shared_tc["done"] = True
shared_tc["is_error"] = bool(event_payload.get("is_error"))
break
put_gateway_event(event_name, event_payload)
update_active_run(stream_id, phase="gateway-tool", latest_tool=event_payload.get("name"))
sse_event = "message"
continue
last_payload = payload
delta = _gateway_sse_delta(payload)
if delta:

View file

@ -14,6 +14,7 @@ from api.gateway_chat import (
_gateway_http_error_event,
_gateway_sse_delta,
_gateway_stream_usage,
_gateway_tool_progress_event,
gateway_chat_config_status,
webui_chat_backend_mode,
webui_gateway_chat_enabled,
@ -96,6 +97,41 @@ def test_gateway_stream_usage_normalizes_token_names():
assert _gateway_stream_usage({}) == {}
def test_gateway_tool_progress_event_translates_gateway_lifecycle_payloads():
assert _gateway_tool_progress_event(
{
"tool": "terminal",
"label": "terminal: pytest",
"toolCallId": "call-1",
"status": "running",
}
) == (
"tool",
{
"event_type": "tool.started",
"name": "terminal",
"preview": "terminal: pytest",
"args": {},
"is_error": False,
"tid": "call-1",
},
)
assert _gateway_tool_progress_event(
{"tool": "terminal", "toolCallId": "call-1", "status": "completed"}
) == (
"tool_complete",
{
"event_type": "tool.completed",
"name": "terminal",
"preview": None,
"args": {},
"is_error": False,
"tid": "call-1",
},
)
assert _gateway_tool_progress_event({"tool": "_thinking", "status": "running"}) is None
def test_gateway_http_401_reports_gateway_auth_not_provider_key():
exc = urllib.error.HTTPError(
"http://gateway.local/v1/chat/completions",
@ -189,7 +225,11 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
return False
def __iter__(self):
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"terminal","label":"terminal: pytest","toolCallId":"call-1","status":"running"}\n\n'
yield b'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n'
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"terminal","toolCallId":"call-1","status":"completed"}\n\n'
yield b'data: {"choices":[{"delta":{"content":"lo"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}\n\n'
yield b'data: [DONE]\n\n'
@ -210,7 +250,9 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
s.pending_attachments = []
s.pending_started_at = 123
s.save()
STREAMS[stream_id] = create_stream_channel()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
@ -234,6 +276,25 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
assert captured["headers"]["X-hermes-session-id"] == s.session_id
assert captured["headers"]["X-hermes-session-key"] == f"webui:{s.session_id}"
assert '"stream": true' in captured["body"]
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
assert ("tool", {
"event_type": "tool.started",
"name": "terminal",
"preview": "terminal: pytest",
"args": {},
"is_error": False,
"tid": "call-1",
}) in events
assert ("tool_complete", {
"event_type": "tool.completed",
"name": "terminal",
"preview": None,
"args": {},
"is_error": False,
"tid": "call-1",
}) in events
def test_gateway_chat_worker_forwards_image_attachments_as_multimodal_parts(tmp_path, monkeypatch):