mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-06-04 08:00:23 +00:00
Merge pull request #3144 from nesquena/release/stage-batch41
Release v0.51.159 — Release EE (stage-batch41: 5-PR low-risk cleanup)
This commit is contained in:
@@ -3,6 +3,19 @@
|
||||
|
||||
## [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.
|
||||
|
||||
### 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.
|
||||
- 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)
|
||||
|
||||
### Changed
|
||||
|
||||
+56
-1
@@ -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)
|
||||
|
||||
@@ -276,13 +298,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]":
|
||||
@@ -291,6 +320,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:
|
||||
|
||||
+38
-1
@@ -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
|
||||
@@ -9648,7 +9679,13 @@ def _handle_chat_sync(handler, body):
|
||||
"[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.\n\n"
|
||||
f"{_WEBUI_PROGRESS_PROMPT}"
|
||||
f"{_WEBUI_PROGRESS_PROMPT}\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 [])
|
||||
|
||||
+3
-1
@@ -223,7 +223,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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -1127,6 +1127,9 @@ function _isVirtualKeyboardLikelyOpen(){
|
||||
function _hasFinePointerCoexisting(){
|
||||
try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; }
|
||||
}
|
||||
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');
|
||||
@@ -1151,12 +1154,13 @@ $('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
|
||||
&&!_hasFinePointerCoexisting()
|
||||
&&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();}
|
||||
}
|
||||
|
||||
+1
-1
@@ -6118,7 +6118,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){
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -15,6 +15,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,
|
||||
@@ -97,6 +98,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",
|
||||
@@ -190,7 +226,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'
|
||||
|
||||
@@ -213,7 +253,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,
|
||||
@@ -243,6 +285,25 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
||||
"webui session context",
|
||||
"Say hello",
|
||||
]
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
assert "Do not include terse planning fragments" in prompt
|
||||
|
||||
Reference in New Issue
Block a user