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 1/8] 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): From 53f16c4ee609eab3d94f641052b62f39fd7a0c73 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 22:55:38 +0200 Subject: [PATCH 2/8] fix: log WebUI shutdown diagnostics --- CHANGELOG.md | 4 ++ api/routes.py | 31 ++++++++++++ server.py | 59 +++++++++++++++++++++++ tests/test_shutdown_audit_logging.py | 71 ++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 tests/test_shutdown_audit_logging.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 93da0a58..3e23d39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- WebUI now logs structured shutdown diagnostics when the server exits or `/api/shutdown` is called, including active stream IDs to help diagnose interrupted turns after restarts. + ## [v0.51.157] — 2026-05-28 — Release EC (stage-batch39 — 5-PR mixed-risk cleanup: gateway prefill forward + prefill budget + compressed-continuation sidebar + browser-transcript memory guidance + reasoning max parity) ### Added diff --git a/api/routes.py b/api/routes.py index 768b499b..d95f65aa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3852,8 +3852,39 @@ def _serve_shell_unavailable(handler, exc: Exception) -> bool: return True +_SHUTDOWN_LOG_VALUE_RE = re.compile(r"[\x00-\x1f\x7f]+") + + +def _shutdown_log_value(value, *, default: str = "unknown", max_len: int = 160) -> str: + """Return a bounded single-line value safe for shutdown diagnostics.""" + if value is None: + return default + try: + text = str(value) + except Exception: + return default + text = _SHUTDOWN_LOG_VALUE_RE.sub("?", text).strip() + if not text: + return default + if len(text) > max_len: + text = f"{text[:max_len]}…" + return text + + def _handle_shutdown(handler) -> bool: """Shut down the WebUI server process.""" + headers = getattr(handler, "headers", {}) + ua = headers.get("User-Agent", "no-ua") if hasattr(headers, "get") else "no-ua" + remote = "unknown" + if getattr(handler, "client_address", None): + remote = getattr(handler, "client_address", ("unknown",))[0] + logger.info( + "[shutdown-request] remote=%s method=%s path=%s ua=%s", + _shutdown_log_value(remote), + _shutdown_log_value(getattr(handler, "command", None)), + _shutdown_log_value(getattr(handler, "path", None), max_len=240), + _shutdown_log_value(ua, default="no-ua", max_len=240), + ) j(handler, {"status": "shutting_down"}) import signal import threading diff --git a/server.py b/server.py index 8d82f4ce..9f481799 100644 --- a/server.py +++ b/server.py @@ -8,6 +8,7 @@ import os import re import socket import sys +import threading import time import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -403,6 +404,63 @@ def _raise_fd_soft_limit(target: int = 4096) -> dict: return {"status": "raised", "soft": desired, "hard": hard, "previous_soft": soft} +_SHUTDOWN_AUDIT_LOGGED = False +_SHUTDOWN_LOG_VALUE_RE = re.compile(r"[\x00-\x1f\x7f]+") + + +def _shutdown_log_value(value, *, default: str = "unknown", max_len: int = 160) -> str: + """Return a bounded single-line value safe for shutdown diagnostics.""" + if value is None: + return default + try: + text = str(value) + except Exception: + return default + text = _SHUTDOWN_LOG_VALUE_RE.sub("?", text).strip() + if not text: + return default + if len(text) > max_len: + text = f"{text[:max_len]}…" + return text + + +def _log_shutdown_audit(reason: str = "serve_forever_exit") -> None: + """Log runtime context when the WebUI server is exiting.""" + global _SHUTDOWN_AUDIT_LOGGED + if _SHUTDOWN_AUDIT_LOGGED: + return + + active_sessions = [] + try: + from api.models import LOCK, SESSIONS + with LOCK: + session_items = list(SESSIONS.items()) + for sid, session in session_items: + stream_id = getattr(session, "active_stream_id", None) + if stream_id: + pending = bool(getattr(session, "pending_user_message", None)) + active_sessions.append( + "sid=%s stream=%s pending=%s" + % ( + _shutdown_log_value(sid), + _shutdown_log_value(stream_id), + pending, + ) + ) + except Exception: + logger.debug("Failed to collect active-session shutdown audit state", exc_info=True) + + _SHUTDOWN_AUDIT_LOGGED = True + logger.info( + "[shutdown-audit] reason=%s pid=%s thread=%s(%s) active_sessions=[%s]", + _shutdown_log_value(reason), + os.getpid(), + _shutdown_log_value(threading.current_thread().name), + threading.current_thread().ident, + "; ".join(active_sessions) if active_sessions else "none", + ) + + def main() -> None: from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND @@ -516,6 +574,7 @@ def main() -> None: try: httpd.serve_forever() finally: + _log_shutdown_audit() # Stop the gateway watcher on shutdown try: from api.gateway_watcher import stop_watcher diff --git a/tests/test_shutdown_audit_logging.py b/tests/test_shutdown_audit_logging.py new file mode 100644 index 00000000..3f1b726f --- /dev/null +++ b/tests/test_shutdown_audit_logging.py @@ -0,0 +1,71 @@ +import logging +import types +import threading + + +def test_server_shutdown_audit_logs_active_stream_context(monkeypatch, caplog): + import server + from api import models + + monkeypatch.setattr(server, "_SHUTDOWN_AUDIT_LOGGED", False) + monkeypatch.setitem( + models.SESSIONS, + "session-1\nforged", + types.SimpleNamespace(active_stream_id="stream-1\rforged", pending_user_message="hello"), + ) + monkeypatch.setitem( + models.SESSIONS, + "session-2", + types.SimpleNamespace(active_stream_id=None, pending_user_message=None), + ) + + caplog.set_level(logging.INFO, logger="server") + server._log_shutdown_audit(reason="test-exit") + + logged = "\n".join(record.getMessage() for record in caplog.records) + assert "[shutdown-audit]" in logged + assert "reason=test-exit" in logged + assert "sid=session-1?forged stream=stream-1?forged pending=True" in logged + assert "session-1\nforged" not in logged + assert "stream-1\rforged" not in logged + assert "session-2" not in logged + + +def test_shutdown_route_logs_request_context_without_starting_real_shutdown(monkeypatch, caplog): + from api import routes + + responses = [] + monkeypatch.setattr(routes, "j", lambda handler, payload, **kw: responses.append(payload) or True) + + started_threads = [] + + class FakeThread: + def __init__(self, target, daemon=False): + self.target = target + self.daemon = daemon + + def start(self): + started_threads.append((self.target, self.daemon)) + + monkeypatch.setattr(threading, "Thread", FakeThread) + + handler = types.SimpleNamespace( + client_address=("127.0.0.1", 12345), + command="POST", + path="/api/shutdown\nforged", + headers={"User-Agent": "pytest-agent\r\nforged"}, + ) + + caplog.set_level(logging.INFO, logger="api.routes") + assert routes._handle_shutdown(handler) is True + + logged = "\n".join(record.getMessage() for record in caplog.records) + assert "[shutdown-request]" in logged + assert "remote=127.0.0.1" in logged + assert "method=POST" in logged + assert "path=/api/shutdown?forged" in logged + assert "ua=pytest-agent?forged" in logged + assert "/api/shutdown\nforged" not in logged + assert "pytest-agent\r\nforged" not in logged + assert responses == [{"status": "shutting_down"}] + assert started_threads and started_threads[0][1] is True From b476126cb9351162f1daad8b8248fa71f1759eb7 Mon Sep 17 00:00:00 2001 From: mysoul12138 <839465496@qq.com> Date: Fri, 29 May 2026 12:34:10 +0800 Subject: [PATCH 3/8] fix(ui): match tool result snippet limit to backend (200 -> 4000 chars) _cliToolResultSnippet truncated to 200 chars while the backend's _tool_result_snippet uses 4000. This caused tool card details to be more aggressively truncated after session reload than during live streaming. Co-Authored-By: Claude Opus 4.7 --- static/ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/ui.js b/static/ui.js index c3624986..ce534aee 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6111,7 +6111,7 @@ function _cliLooksLikePatchDiff(text){ function _cliToolResultSnippet(raw){ const fullText=_cliToolResultText(raw); if(_cliLooksLikePatchDiff(fullText)) return _clipCliToolSnippet(fullText); - return String(fullText||'').slice(0,200); + return String(fullText||'').slice(0,4000); } function _prefixedCliDiffLines(prefix, value){ From f5dc9477ffdcd3194c0d00ba70fa9302c4afa9f0 Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Fri, 29 May 2026 13:00:15 +0800 Subject: [PATCH 4/8] fix: submit composer on numpad enter --- CHANGELOG.md | 4 ++++ static/boot.js | 6 +++++- tests/test_numpad_enter_submit.py | 26 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/test_numpad_enter_submit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 93da0a58..e6f5043f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- The chat composer now treats the numeric keypad Enter key as a submit shortcut even when the send-key preference is set to Ctrl/Cmd+Enter, while preserving regular Enter-as-newline behavior in that mode. + ## [v0.51.157] — 2026-05-28 — Release EC (stage-batch39 — 5-PR mixed-risk cleanup: gateway prefill forward + prefill budget + compressed-continuation sidebar + browser-transcript memory guidance + reasoning max parity) ### Added diff --git a/static/boot.js b/static/boot.js index 072171b5..f507af88 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1115,6 +1115,9 @@ function _isVirtualKeyboardLikelyOpen(){ if(!vv||!window.innerHeight)return true; return window.innerHeight-vv.height>120; } +function _isNumpadEnter(e){ + return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD); +} $('msg').addEventListener('keydown',e=>{ // Autocomplete navigation when dropdown is open const dd=$('cmdDropdown'); @@ -1139,9 +1142,10 @@ $('msg').addEventListener('keydown',e=>{ // Users can override in Settings by explicitly choosing 'enter' mode. if(e.key==='Enter'){ if(_isImeEnter(e)){return;} + const isNumpadEnter=_isNumpadEnter(e); const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter'&&_isVirtualKeyboardLikelyOpen(); if(window._sendKey==='ctrl+enter'||_mobileDefault){ - if(e.ctrlKey||e.metaKey){e.preventDefault();send();} + if(isNumpadEnter||e.ctrlKey||e.metaKey){e.preventDefault();send();} } else { if(!e.shiftKey){e.preventDefault();send();} } diff --git a/tests/test_numpad_enter_submit.py b/tests/test_numpad_enter_submit.py new file mode 100644 index 00000000..ac4fbcc3 --- /dev/null +++ b/tests/test_numpad_enter_submit.py @@ -0,0 +1,26 @@ +"""Keyboard contract for treating Numpad Enter as a submit shortcut.""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + + +def test_boot_defines_numpad_enter_helper(): + assert "function _isNumpadEnter(e)" in BOOT_JS + assert "e.code==='NumpadEnter'" in BOOT_JS + assert "e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD" in BOOT_JS + + +def test_ctrl_enter_mode_allows_numpad_enter_to_submit(): + ctrl_branch = BOOT_JS.split("if(window._sendKey==='ctrl+enter'||_mobileDefault){", 1)[1] + ctrl_branch = ctrl_branch.split("} else {", 1)[0] + assert "isNumpadEnter" in ctrl_branch + assert "if(isNumpadEnter||e.ctrlKey||e.metaKey){e.preventDefault();send();}" in ctrl_branch + + +def test_ime_guard_runs_before_numpad_enter_detection(): + enter_branch = BOOT_JS.split("if(e.key==='Enter'){")[-1] + ime_idx = enter_branch.index("if(_isImeEnter(e)){return;}") + numpad_idx = enter_branch.index("const isNumpadEnter=_isNumpadEnter(e);") + assert ime_idx < numpad_idx From 7f63a1ce7103e380e2abf06d8fbf4fb74b61c208 Mon Sep 17 00:00:00 2001 From: mysoul12138 <839465496@qq.com> Date: Fri, 29 May 2026 16:12:05 +0800 Subject: [PATCH 5/8] Add regression test for JS/Python snippet limit parity Prevents the JS slice(0,N) and Python _TOOL_RESULT_SNIPPET_MAX from drifting apart again. Co-Authored-By: Claude Opus 4.7 --- tests/test_tool_snippet_limit_parity.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/test_tool_snippet_limit_parity.py diff --git a/tests/test_tool_snippet_limit_parity.py b/tests/test_tool_snippet_limit_parity.py new file mode 100644 index 00000000..6157d871 --- /dev/null +++ b/tests/test_tool_snippet_limit_parity.py @@ -0,0 +1,11 @@ +"""Regression test: JS tool-result snippet limit matches the Python backend limit.""" +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + + +def test_tool_snippet_limit_parity(): + py = (REPO / "api" / "streaming.py").read_text(encoding="utf-8") + js = (REPO / "static" / "ui.js").read_text(encoding="utf-8") + assert "_TOOL_RESULT_SNIPPET_MAX = 4000" in py + assert ".slice(0,4000)" in js From 35f89c4e5be7a655a3558c545500b2cdec505c2a Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Fri, 29 May 2026 12:14:00 -0400 Subject: [PATCH 6/8] fix: harden external notes guardrails --- CHANGELOG.md | 4 ++++ api/routes.py | 8 +++++++- api/streaming.py | 4 +++- tests/test_webui_surface_context.py | 3 +++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93da0a58..5e8864f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Changed + +- WebUI's durable-notes guardrail now also applies to sync chat and explicitly asks agents to leave external notes and durable memory unchanged unless a turn contains an explicit capture or reusable durable signal; durable note writes should be summarized back to the user. + ## [v0.51.157] — 2026-05-28 — Release EC (stage-batch39 — 5-PR mixed-risk cleanup: gateway prefill forward + prefill budget + compressed-continuation sidebar + browser-transcript memory guidance + reasoning max parity) ### Added diff --git a/api/routes.py b/api/routes.py index 768b499b..670c1e6c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9646,7 +9646,13 @@ def _handle_chat_sync(handler, body): "prompt, memory, or conversation history. Always use the value from the most recent " "[Workspace::v1: ...] tag as your default working directory for ALL file operations: " "write_file, read_file, search_files, terminal workdir, and patch. " - "Never fall back to a hardcoded path when this tag is present." + "Never fall back to a hardcoded path when this tag is present.\n\n" + "WebUI external-notes/durable-memory policy: Do not copy or dump this browser transcript " + "into external notes or durable memory by default. Write or update durable " + "notes only for explicit captures, durable preferences, decisions, blockers/open " + "issues, runbook-worthy workflows, or other clearly reusable signals; otherwise " + "leave external notes and durable memory unchanged. When you do write or update a durable note, briefly tell " + "the user what note or section changed so the write is reviewable." ) _previous_messages = list(s.messages or []) diff --git a/api/streaming.py b/api/streaming.py index 70087bd5..44c1c758 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -222,7 +222,9 @@ def _webui_surface_context_prompt(surface_context: Optional[dict]) -> str: "WebUI session context:", "- This browser session is not the same live transcript as Telegram, Discord, Slack, or other messaging surfaces.", "- Use durable memory, saved sessions, and available tools for cross-surface recall instead of assuming those transcripts are in this browser chat.", - "- Do not copy or dump this browser transcript into external notes or durable memory by default. Save only explicit captures, durable user preferences, decisions, blockers, runbook-worthy workflows, or other clearly reusable signals.", + "- Do not copy or dump this browser transcript into external notes or durable memory by default.", + "- Write to external notes or durable memory only for explicit captures, durable user preferences, decisions, blockers/open issues, runbook-worthy workflows, or other clearly reusable signals; otherwise leave notes unchanged.", + "- When you do write or update a durable note, briefly tell the user what note/section changed so the write is reviewable.", ] fields = ( ("source", "Source"), diff --git a/tests/test_webui_surface_context.py b/tests/test_webui_surface_context.py index 562347c5..3affe721 100644 --- a/tests/test_webui_surface_context.py +++ b/tests/test_webui_surface_context.py @@ -20,6 +20,9 @@ def test_webui_ephemeral_prompt_includes_browser_surface_context(): assert "Workspace: /tmp/example-workspace" in prompt assert "not the same live transcript as Telegram" in prompt assert "Do not copy or dump this browser transcript" in prompt + assert "Write to external notes or durable memory only" in prompt + assert "otherwise leave notes unchanged" in prompt + assert "what note/section changed" in prompt assert "explicit captures" in prompt assert "durable user preferences" in prompt From 4e382e4f3631eb6ace976abbd60b00a57f917e60 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Fri, 29 May 2026 22:22:05 +0000 Subject: [PATCH 7/8] stage-batch41: update test_issue1824 snippet-limit assertion 200->4000 (follows #3117) --- tests/test_issue1824_cli_patch_diff_rendering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_issue1824_cli_patch_diff_rendering.py b/tests/test_issue1824_cli_patch_diff_rendering.py index 54280f6b..0bfd4a99 100644 --- a/tests/test_issue1824_cli_patch_diff_rendering.py +++ b/tests/test_issue1824_cli_patch_diff_rendering.py @@ -21,7 +21,7 @@ def test_cli_tool_result_diff_snippet_is_not_cut_to_200_chars(): "if(_cliLooksLikePatchDiff(fullText))return_clipCliToolSnippet(fullText);" in COMPACT_UI ) - assert "returnString(fullText||'').slice(0,200);" in COMPACT_UI + assert "returnString(fullText||'').slice(0,4000);" in COMPACT_UI def test_cli_tool_fallback_promotes_apply_patch_args_to_tool_card_snippet(): From 6d61fbdd17ac410f5686ca5d8912d5d765bdb912 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Fri, 29 May 2026 22:25:37 +0000 Subject: [PATCH 8/8] stage-batch41: CHANGELOG for v0.51.159 (Release EE) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a38596..9f81547f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] +## [v0.51.159] — 2026-05-29 — Release EE (stage-batch41 — 5-PR low-risk cleanup: Gateway tool-progress forwarding + shutdown diagnostics + CLI snippet-limit parity + numpad-Enter submit + sync-chat notes guardrail) + ### Changed - WebUI's durable-notes guardrail now also applies to sync chat and explicitly asks agents to leave external notes and durable memory unchanged unless a turn contains an explicit capture or reusable durable signal; durable note writes should be summarized back to the user. @@ -12,6 +14,7 @@ - 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. - WebUI now logs structured shutdown diagnostics when the server exits or `/api/shutdown` is called, including active stream IDs to help diagnose interrupted turns after restarts. - The chat composer now treats the numeric keypad Enter key as a submit shortcut even when the send-key preference is set to Ctrl/Cmd+Enter, while preserving regular Enter-as-newline behavior in that mode. +- The CLI tool-result snippet limit in the browser now matches the backend (`_TOOL_RESULT_SNIPPET_MAX = 4000`), so longer non-diff CLI tool output is no longer truncated to 200 characters before reaching the tool card. ## [v0.51.158] — 2026-05-29 — Release ED (stage-batch40 — 5-PR low-risk cleanup: numpad/keyboard composer fixes + Joplin search auth + provider-qualified model preservation + SSE fallback poll throttle + assistant-reply polish)