Release v0.51.262 — Release ID (stage-r12) (#3617)
## Release v0.51.262 — Release ID (stage-r12) Phase-3 light slice (no-screenshot items) — 3 PRs. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3432 (#3532) | @franksong2702 | Normalize the recall-prefill terminal `user` turn so WebUI doesn't send adjacent `user` roles to strict chat templates (Mistral/Gemma/Jinja). `_normalize_prefill_messages_before_user_turn()` in both `streaming.py` + `gateway_chat.py`; drops only the terminal user tail, preserves assistant/system/mid-list context. **Rebased onto master** (was CONFLICTING). | | #2558 (#3516) | @rodboev | "Reveal in file manager" now translates container workspace paths (`/workspace`) back to the host mount path for Docker deployments (traversal-safe via `safe_resolve` + sibling-prefix guard). | ### Changed | Issue/PR | Author | Change | |----------|--------|--------| | (#3539) | @Lyr-GW | Completed the Chinese (Simplified) `zh` localization (MCP controls, tool-list pagination) with all interpolations preserved, and the language dropdown now applies the locale **instantly** on change. | ### Review fix absorbed (Codex) #3539 also added an `allowed=['en','zh']` filter to the Settings language dropdown, which dropped the 9 other shipped locales (it/ja/ru/es/de/pt/ko/fr/tr) — and since save falls back to `en` when the select has no matching option, an existing user of those locales would be **silently reset to English** on a Settings save. Removed the filter (dropdown enumerates all `LOCALES` again, matching master); partially-translated locales fall back per-key to English at render. + regression test `test_issue3539_language_dropdown_all_locales.py`. ### Gate - Full pytest suite: **7701 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (dropdown drops-locales) → fixed → **SAFE TO SHIP** (verified prefill drops only terminal user tail in both paths, Docker path-translation traversal-safe, zh interpolations preserved) Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: Lyr-GW <Lyr-GW@users.noreply.github.com>
This commit is contained in:
11 files changed
+414
-35
No files matched your search
@@ -3,6 +3,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.262] — 2026-06-04 — Release ID (stage-r12 — zh localization + Docker reveal-path + recall-prefill role fix)
|
||||
|
||||
### Fixed
|
||||
- **WebUI no longer sends adjacent `user` roles to strict chat templates when recall prefill is configured.** A configured recall prefill could end with `role:user`, and both in-process streaming and Gateway chat then appended the live browser user turn — producing consecutive `user` messages that strict templates (Mistral/Gemma/Jinja) reject. A new `_normalize_prefill_messages_before_user_turn()` drops a terminal `user` prefill turn at that boundary (in both `api/streaming.py` and `api/gateway_chat.py`), preserving safe assistant prefill context. (#3432, @franksong2702)
|
||||
- **"Reveal in file manager" now works for Docker deployments** where the workspace is a host directory mounted into the container — container paths (e.g. `/workspace`) are translated back to the host path so the reveal resolves on the host. (#2558, @rodboev)
|
||||
|
||||
### Changed
|
||||
- **Completed the Chinese (Simplified) localization** — translated the remaining untranslated `zh` strings (MCP server controls, tool-list pagination/summary) to Chinese, preserving all interpolation placeholders. (@Lyr-GW)
|
||||
|
||||
## [v0.51.261] — 2026-06-04 — Release IC (stage-r11 — live Todos panel via a todo_state SSE contract)
|
||||
|
||||
### Fixed
|
||||
|
||||
+4
-1
@@ -249,6 +249,7 @@ def _run_gateway_chat_streaming(
|
||||
from api.streaming import (
|
||||
_load_webui_prefill_context,
|
||||
_prefill_messages_with_webui_context,
|
||||
_normalize_prefill_messages_before_user_turn,
|
||||
_public_prefill_context_status,
|
||||
_webui_ephemeral_system_prompt,
|
||||
)
|
||||
@@ -269,9 +270,11 @@ def _run_gateway_chat_streaming(
|
||||
},
|
||||
config_data=cfg,
|
||||
)
|
||||
prefill_messages = _prefill_messages_with_webui_context(prefill_context, cfg)
|
||||
prefill_messages = _normalize_prefill_messages_before_user_turn(prefill_messages)
|
||||
prefill_messages = [
|
||||
{"role": "system", "content": _gateway_system_prompt},
|
||||
*_prefill_messages_with_webui_context(prefill_context, cfg),
|
||||
*prefill_messages,
|
||||
]
|
||||
put_gateway_event("context_status", {
|
||||
"session_id": session_id,
|
||||
|
||||
+21
-5
@@ -12262,14 +12262,28 @@ def _handle_file_reveal(handler, body):
|
||||
# what was missing).
|
||||
return bad(handler, f"File not found: {target}", 404)
|
||||
|
||||
target_str = str(target)
|
||||
|
||||
# Optional Docker host/container path translation (mirrors _handle_file_open_vscode).
|
||||
from api.config import get_config as _get_cfg # noqa: PLC0415
|
||||
vscode_cfg = _get_cfg().get("vscode", {})
|
||||
if not isinstance(vscode_cfg, dict):
|
||||
vscode_cfg = {}
|
||||
container_prefix = vscode_cfg.get("container_path_prefix", "")
|
||||
host_prefix = vscode_cfg.get("host_path_prefix", "")
|
||||
if container_prefix and host_prefix:
|
||||
_norm = container_prefix.rstrip('/') + '/'
|
||||
if target_str.startswith(_norm) or target_str == container_prefix.rstrip('/'):
|
||||
target_str = host_prefix + target_str[len(container_prefix):]
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
subprocess.Popen(["open", "-R", str(target)])
|
||||
subprocess.Popen(["open", "-R", target_str])
|
||||
elif system == "Windows":
|
||||
subprocess.Popen(["explorer.exe", "/select," + str(target)])
|
||||
subprocess.Popen(["explorer.exe", "/select," + target_str])
|
||||
else:
|
||||
# Linux / other — open parent directory
|
||||
subprocess.Popen(["xdg-open", str(target.parent)])
|
||||
subprocess.Popen(["xdg-open", str(Path(target_str).parent)])
|
||||
|
||||
return j(handler, {"ok": True, "path": body["path"]})
|
||||
except (ValueError, PermissionError, OSError) as e:
|
||||
@@ -12342,8 +12356,10 @@ def _handle_file_open_vscode(handler, body):
|
||||
vscode_cfg = {}
|
||||
container_prefix = vscode_cfg.get("container_path_prefix", "")
|
||||
host_prefix = vscode_cfg.get("host_path_prefix", "")
|
||||
if container_prefix and host_prefix and target_str.startswith(container_prefix):
|
||||
target_str = host_prefix + target_str[len(container_prefix):]
|
||||
if container_prefix and host_prefix:
|
||||
_norm = container_prefix.rstrip('/') + '/'
|
||||
if target_str.startswith(_norm) or target_str == container_prefix.rstrip('/'):
|
||||
target_str = host_prefix + target_str[len(container_prefix):]
|
||||
|
||||
cmd = vscode_cfg.get("command", "code")
|
||||
# Resolve the command to an absolute path so subprocess.Popen finds it
|
||||
|
||||
@@ -657,6 +657,33 @@ def _prefill_messages_with_webui_context(prefill_context: dict, config_data: Opt
|
||||
return list(prefill_context.get("messages") or [])
|
||||
|
||||
|
||||
def _normalize_prefill_messages_before_user_turn(prefill_messages: list[dict]) -> list[dict]:
|
||||
"""Ensure WebUI prefill does not end with user role before an appended turn.
|
||||
|
||||
Some upstream prefill sources can end with `role: user` (for example,
|
||||
session context or recall snippets). WebUI always appends the current user
|
||||
turn after prefill in the streaming path, so a terminal user role creates an
|
||||
adjacent user/user sequence that strict chat templates (Gemma, Mistral/Jinja)
|
||||
reject.
|
||||
|
||||
To keep behavior scoped, only consecutive terminal user messages are removed
|
||||
just before that boundary; earlier roles remain untouched.
|
||||
"""
|
||||
sanitized = list(prefill_messages or [])
|
||||
n_dropped = 0
|
||||
while sanitized:
|
||||
last_message = sanitized[-1]
|
||||
if not isinstance(last_message, dict):
|
||||
break
|
||||
if str(last_message.get("role") or "").strip().lower() != "user":
|
||||
break
|
||||
sanitized.pop()
|
||||
n_dropped += 1
|
||||
if n_dropped:
|
||||
logger.debug("Dropped %d trailing user message(s) from prefill", n_dropped)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool:
|
||||
"""Return True if *new* messages (beyond ``prev_count``) contain an
|
||||
assistant message with non-empty content.
|
||||
@@ -5167,6 +5194,7 @@ def _run_agent_streaming(
|
||||
_cfg = _get_config()
|
||||
_prefill_context = _load_webui_prefill_context(_cfg)
|
||||
_prefill_messages = _prefill_messages_with_webui_context(_prefill_context, _cfg)
|
||||
_prefill_messages = _normalize_prefill_messages_before_user_turn(_prefill_messages)
|
||||
put('context_status', {
|
||||
'session_id': session_id,
|
||||
'prefill': _public_prefill_context_status(_prefill_context),
|
||||
|
||||
+22
-22
@@ -7874,11 +7874,11 @@ const LOCALES = {
|
||||
mcp_tool_count: '{0} 个工具',
|
||||
mcp_enabled_yes: '已启用',
|
||||
mcp_enabled_no: '已禁用',
|
||||
mcp_enable_server: 'Enable this MCP server',
|
||||
mcp_disable_server: 'Disable this MCP server',
|
||||
mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`,
|
||||
mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`,
|
||||
mcp_toggle_failed: 'Failed to update MCP server.',
|
||||
mcp_enable_server: '启用此 MCP 服务器',
|
||||
mcp_disable_server: '禁用此 MCP 服务器',
|
||||
mcp_enabled_toast: (name) => `MCP 服务器「${name}」已启用。`,
|
||||
mcp_disabled_toast: (name) => `MCP 服务器「${name}」已禁用。`,
|
||||
mcp_toggle_failed: '更新 MCP 服务器失败。',
|
||||
mcp_tools_title: 'MCP 工具',
|
||||
mcp_tools_desc: '搜索活跃 MCP 服务器中的已知工具。',
|
||||
mcp_tools_search_placeholder: '按名称、服务器或描述搜索工具…',
|
||||
@@ -7887,20 +7887,20 @@ const LOCALES = {
|
||||
mcp_tools_load_failed: '加载 MCP 工具失败。',
|
||||
mcp_tools_schema_empty: '无参数。',
|
||||
mcp_tools_runtime_note: '工具清单仅使用已知的活跃 MCP 运行时数据;WebUI 不会启动或探测服务器。',
|
||||
mcp_tools_summary_no_matches: (query,total) => `No MCP tools matching “${query}” (${total} total MCP tools).`,
|
||||
mcp_tools_summary_none: 'No MCP tools to show.',
|
||||
mcp_tools_summary_matching: (query) => ` matching “${query}”`,
|
||||
mcp_tools_summary_total_note: (total) => ` (${total} total MCP tools)`,
|
||||
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `Showing ${start}-${end} of ${filtered} MCP tools${searchNote}${totalNote}. Page ${page} of ${pages}.`,
|
||||
mcp_tools_page_size_prefix: 'Show',
|
||||
mcp_tools_page_size_suffix: 'per page',
|
||||
mcp_tools_per_page_aria: 'MCP tools per page',
|
||||
mcp_tools_inactive_configured_servers: (servers) => `Configured but inactive in this WebUI runtime: ${servers}.`,
|
||||
mcp_tools_pagination_label: 'MCP tools pagination',
|
||||
mcp_tools_previous_page: '‹ Prev',
|
||||
mcp_tools_previous_page_aria: 'Previous MCP tools page',
|
||||
mcp_tools_next_page: 'Next ›',
|
||||
mcp_tools_next_page_aria: 'Next MCP tools page',
|
||||
mcp_tools_summary_no_matches: (query,total) => `没有匹配「${query}」的 MCP 工具(共 ${total} 个)。`,
|
||||
mcp_tools_summary_none: '没有 MCP 工具可显示。',
|
||||
mcp_tools_summary_matching: (query) => ` 匹配「${query}」`,
|
||||
mcp_tools_summary_total_note: (total) => `(共 ${total} 个)`,
|
||||
mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `显示第 ${start}-${end} 个,共 ${filtered} 个 MCP 工具${searchNote}${totalNote}。第 ${page}/${pages} 页。`,
|
||||
mcp_tools_page_size_prefix: '每页显示',
|
||||
mcp_tools_page_size_suffix: '个',
|
||||
mcp_tools_per_page_aria: '每页 MCP 工具数',
|
||||
mcp_tools_inactive_configured_servers: (servers) => `已配置但在此 WebUI 运行环境中未激活:${servers}。`,
|
||||
mcp_tools_pagination_label: 'MCP 工具分页',
|
||||
mcp_tools_previous_page: '‹ 上一页',
|
||||
mcp_tools_previous_page_aria: '上一页 MCP 工具',
|
||||
mcp_tools_next_page: '下一页 ›',
|
||||
mcp_tools_next_page_aria: '下一页 MCP 工具',
|
||||
thinking: '思考过程',
|
||||
expand_all: '全部展开',
|
||||
collapse_all: '全部折叠',
|
||||
@@ -7966,7 +7966,7 @@ const LOCALES = {
|
||||
session_toolsets_desc: '限制此会话可用工具(留空 = 使用全局配置)',
|
||||
session_toolsets_global: '全局(默认)',
|
||||
session_toolsets_custom: '自定义',
|
||||
session_toolsets_placeholder: 'tool1, tool2, \u2026', // TODO: translate
|
||||
session_toolsets_placeholder: 'tool1, tool2, …', // 保持英文——这是占位符示例
|
||||
session_toolsets_apply: '应用',
|
||||
session_toolsets_clear: '清除(使用全局)',
|
||||
session_toolsets_applied: '工具集已更新',
|
||||
@@ -8138,7 +8138,7 @@ const LOCALES = {
|
||||
copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84',
|
||||
open_in_vscode: '在VS Code中打开',
|
||||
open_in_vscode_failed: '在VS Code中打开失败:',
|
||||
download_folder: 'Download Folder', // TODO: translate
|
||||
download_folder: '下载文件夹',
|
||||
path_copied: '\u6587\u4ef6\u8def\u5f84\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f',
|
||||
path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a',
|
||||
session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd',
|
||||
@@ -8999,7 +8999,7 @@ const LOCALES = {
|
||||
settings_label_tts_engine: 'TTS 引擎',
|
||||
settings_desc_tts_engine: '选择语音引擎。Edge TTS 通过服务器使用微软神经语音。',
|
||||
checkpoint_date: '日期',
|
||||
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
|
||||
checkpoint_diff_files_changed: (n) => `${n} 个文件已更改`,
|
||||
checkpoint_diff_no_changes: '此检查点与当前工作区之间无差异。',
|
||||
checkpoint_diff_title: '检查点变更',
|
||||
checkpoint_empty: '此工作区未找到检查点。',
|
||||
|
||||
+4
-1
@@ -6282,7 +6282,10 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
}
|
||||
langSel.value=resolvedLanguage;
|
||||
langSel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
|
||||
langSel.addEventListener('change',function(){
|
||||
if(typeof setLocale==='function'){setLocale(this.value);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}
|
||||
_schedulePreferencesAutosave();
|
||||
},{once:false});
|
||||
}
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for issue #2558 -- container path translation for Reveal in File Manager.
|
||||
|
||||
Pins that _handle_file_reveal applies the same container_path_prefix /
|
||||
host_path_prefix substitution used by _handle_file_open_vscode.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
ROUTES = ROOT / "api" / "routes.py"
|
||||
|
||||
|
||||
class TestRevealPathTranslation:
|
||||
def test_handler_supports_path_prefix_mapping(self):
|
||||
"""_handle_file_reveal must contain container_path_prefix / host_path_prefix
|
||||
so Docker users get the same path translation as _handle_file_open_vscode."""
|
||||
src = ROUTES.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"def _handle_file_reveal\(handler, body\):.*?(?=\ndef )",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "_handle_file_reveal not found in api/routes.py"
|
||||
body = m.group(0)
|
||||
assert "container_path_prefix" in body
|
||||
assert "host_path_prefix" in body
|
||||
|
||||
def test_handler_uses_target_str_in_subprocess(self):
|
||||
"""The subprocess dispatch must use the translated string, not str(target)."""
|
||||
src = ROUTES.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"def _handle_file_reveal\(handler, body\):.*?(?=\ndef )",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m
|
||||
body = m.group(0)
|
||||
# After translation the variable must be named target_str (not str(target))
|
||||
assert "target_str" in body
|
||||
# Ensure the translation assignment is present
|
||||
assert "target_str = host_prefix + target_str[len(container_prefix):]" in body
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Regression: the Settings language dropdown must list ALL locales in LOCALES.
|
||||
|
||||
#3539 (zh localization) briefly added an `allowed=['en','zh']` filter to the
|
||||
language `<select>` population in loadSettingsPanel(). Because saveSettings()
|
||||
falls back to 'en' when the select has no matching option, that filter would
|
||||
silently reset an existing it/ja/ru/es/de/pt/ko/fr/tr user to English on the
|
||||
next Settings save. The dropdown must keep enumerating every LOCALES entry;
|
||||
partially-translated locales fall back per-key to English at render time, which
|
||||
is the established behavior — far better than dropping the user's choice.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
PANELS_JS = (Path(__file__).resolve().parents[1] / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _language_dropdown_block() -> str:
|
||||
# Anchor on the dropdown-population site (langSel.innerHTML='' precedes the
|
||||
# LOCALES enumeration); there is an earlier settingsLanguage reference for
|
||||
# the apply-on-load path, so don't anchor on the first match.
|
||||
i = PANELS_JS.index("langSel.innerHTML=''")
|
||||
return PANELS_JS[i:i + 700]
|
||||
|
||||
|
||||
def test_language_dropdown_lists_all_locales_no_allowlist():
|
||||
block = _language_dropdown_block()
|
||||
# It must iterate every LOCALES entry...
|
||||
assert "Object.entries(LOCALES)" in block, (
|
||||
"the language dropdown must enumerate all LOCALES entries"
|
||||
)
|
||||
# ...with NO hardcoded allow-list filter that drops existing locales.
|
||||
assert "allowed=[" not in block.replace(" ", "") and "allowed = [" not in block, (
|
||||
"the language dropdown must NOT filter LOCALES to a hardcoded allow-list "
|
||||
"(that silently resets existing non-en/zh users to English on save)"
|
||||
)
|
||||
assert "if(!allowed.includes(code))" not in block.replace(" ", ""), (
|
||||
"no allow-list `continue` guard may skip locales in the dropdown"
|
||||
)
|
||||
|
||||
|
||||
def test_language_change_applies_locale_instantly():
|
||||
"""#3539 keeper: changing the dropdown applies the locale live, not just on save."""
|
||||
block = _language_dropdown_block()
|
||||
assert "setLocale(this.value)" in block.replace(" ", "").replace("\n", "") or "setLocale(this.value)" in block, (
|
||||
"language change should call setLocale() for instant apply"
|
||||
)
|
||||
@@ -247,7 +247,16 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
|
||||
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
|
||||
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {"status": "loaded", "source": "test", "label": "test", "message_count": 1, "messages": [{"role": "user", "content": "prefill"}]})
|
||||
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {
|
||||
"status": "loaded",
|
||||
"source": "test",
|
||||
"label": "test",
|
||||
"message_count": 2,
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "prefill summary"},
|
||||
{"role": "user", "content": "prefill"},
|
||||
],
|
||||
})
|
||||
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: list(ctx["messages"]) + [{"role": "user", "content": "webui session context"}])
|
||||
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
@@ -296,12 +305,13 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
||||
# The moved session/delivery context must be present in the system prompt.
|
||||
assert "Connected Platforms:" in system_msg["content"]
|
||||
assert "Delivery options for scheduled tasks:" in system_msg["content"]
|
||||
# The recall prefill + the actual user turn follow the system message.
|
||||
# The gateway path keeps safe recall prefill context while removing
|
||||
# terminal user-role prefill before the actual browser user turn.
|
||||
assert [m["content"] for m in payload["messages"][1:]] == [
|
||||
"prefill",
|
||||
"webui session context",
|
||||
"prefill summary",
|
||||
"Say hello",
|
||||
]
|
||||
assert [m["role"] for m in payload["messages"]] == ["system", "assistant", "user"]
|
||||
events = []
|
||||
while not subscriber.empty():
|
||||
events.append(subscriber.get_nowait())
|
||||
@@ -323,6 +333,77 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
||||
}) in events
|
||||
|
||||
|
||||
def test_gateway_chat_worker_normalizes_prefill_slice_before_system_prefix(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
yield b'data: {"choices":[{"delta":{"content":"done"}}]}\n\n'
|
||||
yield b'data: [DONE]\n\n'
|
||||
|
||||
prefill_raw = [
|
||||
{"role": "assistant", "content": "prefill summary"},
|
||||
{"role": "user", "content": "first terminal user"},
|
||||
{"role": "user", "content": "second terminal user"},
|
||||
]
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return FakeResponse()
|
||||
|
||||
original_normalizer = streaming._normalize_prefill_messages_before_user_turn
|
||||
|
||||
def recording_normalizer(messages):
|
||||
captured["normalizer_input"] = list(messages)
|
||||
return original_normalizer(messages)
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
|
||||
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {
|
||||
"status": "loaded",
|
||||
"source": "test",
|
||||
"label": "test",
|
||||
"message_count": len(prefill_raw),
|
||||
"messages": prefill_raw,
|
||||
})
|
||||
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: list(ctx["messages"]))
|
||||
monkeypatch.setattr(streaming, "_normalize_prefill_messages_before_user_turn", recording_normalizer)
|
||||
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
s = new_session()
|
||||
stream_id = "stream-gateway-prefill-slice-test"
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = "Say hello"
|
||||
s.pending_attachments = []
|
||||
s.save()
|
||||
STREAMS[stream_id] = create_stream_channel()
|
||||
|
||||
gateway_chat._run_gateway_chat_streaming(
|
||||
s.session_id,
|
||||
"Say hello",
|
||||
"test-model",
|
||||
str(tmp_path),
|
||||
stream_id,
|
||||
[],
|
||||
)
|
||||
|
||||
assert captured["normalizer_input"] == prefill_raw
|
||||
payload_messages = captured["body"]["messages"]
|
||||
assert [m["role"] for m in payload_messages] == ["system", "assistant", "user"]
|
||||
assert [m["content"] for m in payload_messages[1:]] == ["prefill summary", "Say hello"]
|
||||
|
||||
|
||||
def test_gateway_chat_worker_backfills_context_only_turns_into_display(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
@@ -583,7 +664,9 @@ def test_gateway_chat_worker_forwards_image_attachments_as_multimodal_parts(tmp_
|
||||
content = captured["body"]["messages"][-1]["content"]
|
||||
assert captured["body"]["messages"][0]["role"] == "system"
|
||||
assert "Final visible assistant replies" in captured["body"]["messages"][0]["content"]
|
||||
assert captured["body"]["messages"][1] == {"role": "user", "content": "webui session context"}
|
||||
image_payload = captured["body"]["messages"][1]
|
||||
assert image_payload["role"] == "user"
|
||||
assert image_payload["content"][0] == {"type": "text", "text": "What is in this image?"}
|
||||
assert content[0] == {"type": "text", "text": "What is in this image?"}
|
||||
assert content[1]["type"] == "image_url"
|
||||
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
@@ -130,3 +130,83 @@ def test_next_webui_turn_context_includes_state_db_external_messages(monkeypatch
|
||||
"external gateway user",
|
||||
"external gateway assistant",
|
||||
]
|
||||
|
||||
|
||||
def test_webui_streaming_normalizes_trailing_prefill_user_before_current_turn(monkeypatch, tmp_path):
|
||||
import api.config as config
|
||||
import api.models as models
|
||||
import api.streaming as streaming
|
||||
from api.models import new_session
|
||||
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
index_file = session_dir / "_index.json"
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
|
||||
monkeypatch.setattr(models, "SESSIONS", OrderedDict(), raising=False)
|
||||
monkeypatch.setattr(config, "SESSION_DIR", session_dir, raising=False)
|
||||
monkeypatch.setattr(config, "SESSION_INDEX_FILE", index_file, raising=False)
|
||||
monkeypatch.setattr(streaming, "SESSION_DIR", session_dir, raising=False)
|
||||
monkeypatch.setattr(models, "_active_state_db_path", lambda: tmp_path / "state.db", raising=False)
|
||||
config.STREAMS.clear()
|
||||
config.CANCEL_FLAGS.clear()
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, prefill_messages=None, **kwargs):
|
||||
captured["prefill_messages"] = kwargs.get("prefill_messages")
|
||||
if prefill_messages is not None:
|
||||
captured["prefill_messages"] = prefill_messages
|
||||
self.context_compressor = None
|
||||
self.ephemeral_system_prompt = None
|
||||
|
||||
def run_conversation(self, **kwargs):
|
||||
return {
|
||||
"completed": True,
|
||||
"final_response": "ok",
|
||||
"messages": [
|
||||
{"role": "user", "content": kwargs.get("persist_user_message", "")},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(streaming, "_get_ai_agent", lambda: FakeAgent)
|
||||
monkeypatch.setattr(streaming, "resolve_model_provider", lambda *args, **kwargs: ("test-model", None, None))
|
||||
monkeypatch.setattr(streaming, "get_config", lambda: {})
|
||||
monkeypatch.setattr(config, "get_config", lambda: {})
|
||||
monkeypatch.setattr(config, "_resolve_cli_toolsets", lambda *args, **kwargs: [])
|
||||
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {
|
||||
"status": "loaded",
|
||||
"source": "test",
|
||||
"label": "test",
|
||||
"message_count": 2,
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "prefill summary"},
|
||||
{"role": "user", "content": "webui session context"},
|
||||
],
|
||||
})
|
||||
|
||||
s = new_session(workspace=str(tmp_path))
|
||||
stream_id = "stream-prefill-boundary-normalization"
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = "new webui turn"
|
||||
s.pending_started_at = 0.0
|
||||
s.save(touch_updated_at=False)
|
||||
models.SESSIONS[s.session_id] = s
|
||||
|
||||
config.STREAMS[stream_id] = queue.Queue()
|
||||
try:
|
||||
streaming._run_agent_streaming(
|
||||
session_id=s.session_id,
|
||||
msg_text="new webui turn",
|
||||
model="test-model",
|
||||
workspace=str(tmp_path),
|
||||
stream_id=stream_id,
|
||||
attachments=[],
|
||||
)
|
||||
finally:
|
||||
config.STREAMS.pop(stream_id, None)
|
||||
|
||||
prefill = captured.get("prefill_messages") or []
|
||||
assert prefill == [{"role": "assistant", "content": "prefill summary"}]
|
||||
@@ -1,4 +1,10 @@
|
||||
from api.streaming import _webui_ephemeral_system_prompt, _prefill_messages_with_webui_context
|
||||
import logging
|
||||
|
||||
from api.streaming import (
|
||||
_normalize_prefill_messages_before_user_turn,
|
||||
_prefill_messages_with_webui_context,
|
||||
_webui_ephemeral_system_prompt,
|
||||
)
|
||||
|
||||
|
||||
def test_webui_ephemeral_prompt_includes_browser_surface_context():
|
||||
@@ -79,6 +85,69 @@ def test_prefill_no_longer_adds_session_context_user_message():
|
||||
assert "Connected Platforms" not in result[0].get("content", "")
|
||||
|
||||
|
||||
def test_prefill_boundary_normalizer_removes_terminal_user_tail():
|
||||
"""Trailing user messages are removed until the first non-user boundary."""
|
||||
|
||||
raw = [
|
||||
{"role": "assistant", "content": "recall summary"},
|
||||
{"role": "user", "content": "session context"},
|
||||
]
|
||||
|
||||
assert _normalize_prefill_messages_before_user_turn(raw) == [
|
||||
{"role": "assistant", "content": "recall summary"},
|
||||
]
|
||||
assert _normalize_prefill_messages_before_user_turn([
|
||||
{"role": "assistant", "content": "prefill"},
|
||||
{"role": "user", "content": "legacy user"},
|
||||
{"role": "assistant", "content": "assistant follow-up"},
|
||||
]) == [
|
||||
{"role": "assistant", "content": "prefill"},
|
||||
{"role": "user", "content": "legacy user"},
|
||||
{"role": "assistant", "content": "assistant follow-up"},
|
||||
]
|
||||
assert _normalize_prefill_messages_before_user_turn([
|
||||
{"role": "assistant", "content": "tail",},
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "user", "content": "second"},
|
||||
]) == [
|
||||
{"role": "assistant", "content": "tail",},
|
||||
]
|
||||
assert _normalize_prefill_messages_before_user_turn([]) == []
|
||||
assert _normalize_prefill_messages_before_user_turn([{"role": "user", "content": "only user"}]) == []
|
||||
|
||||
|
||||
def test_prefill_boundary_normalizer_logs_when_user_tail_dropped(caplog):
|
||||
"""Dropping trailing user messages is logged with the count."""
|
||||
caplog.set_level(logging.DEBUG, logger="api.streaming")
|
||||
|
||||
messages = [
|
||||
{"role": "assistant", "content": "prefill"},
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "user", "content": "second"},
|
||||
]
|
||||
|
||||
assert _normalize_prefill_messages_before_user_turn(messages) == [
|
||||
{"role": "assistant", "content": "prefill"},
|
||||
]
|
||||
assert any(
|
||||
rec.message == "Dropped 2 trailing user message(s) from prefill" for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_prefill_boundary_normalizer_no_log_when_no_terminal_user(caplog):
|
||||
"""No-op normalization must not emit the prefill-dropping debug log."""
|
||||
caplog.set_level(logging.DEBUG, logger="api.streaming")
|
||||
|
||||
messages = [
|
||||
{"role": "assistant", "content": "turn 1"},
|
||||
{"role": "user", "content": "turn 2"},
|
||||
{"role": "assistant", "content": "turn 3"},
|
||||
]
|
||||
|
||||
assert _normalize_prefill_messages_before_user_turn(messages) == messages
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
def test_prefill_preserves_empty_and_none_messages():
|
||||
"""Edge cases: empty prefill stays empty, missing key returns empty."""
|
||||
assert _prefill_messages_with_webui_context({"messages": []}) == []
|
||||
|
||||
Reference in new issue
Block a user