From b76a6dfedb1c0ae84c709b5ce1f25ae8725b19c5 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Thu, 28 May 2026 16:03:16 -0400 Subject: [PATCH] fix: forward gateway tool activity to webui --- CHANGELOG.md | 4 ++ api/gateway_chat.py | 57 ++++++++++++++++++++- tests/test_webui_gateway_chat_backend.py | 63 +++++++++++++++++++++++- 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 454dfc12..bf78790c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 25818084..6ea8a245 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -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: diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index 4b4fa414..783506d7 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -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):