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:
authored and GitHub committed 2026-06-04 17:16:31 -07:00
1 parent 6703978c60
commit 163df17410
11 files changed
+414 -35

No files matched your search

+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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"
)
+88 -5
View File
@@ -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"}]
+70 -1
View File
@@ -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": []}) == []