Merge remote-tracking branch 'origin/master' into fix/webui-bugfix-sweep
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
cf6e3111c4
18 files changed
+371
-34
No files matched your search
@@ -10,6 +10,28 @@
|
||||
- Added missing i18n keys used by command, cron, provider, search/default, and session-rename UI paths across supported locales so missing translations fall back to labels instead of raw key names.
|
||||
- Made workspace Git tests pin their temporary repository branch to `master` so the suite is independent of the host Git default-branch setting.
|
||||
|
||||
## [v0.51.153] — 2026-05-28 — Release DY (stage-batch35 — 11-PR low-risk cleanup: title-language + clarify SSE + upload filename + discoverability + SSE reconnect + gateway image + docker docs)
|
||||
|
||||
### Changed
|
||||
|
||||
- Local fallback title generation no longer has a German-only `Session Bilder` special case; it now uses the same generic topic extraction path as other fallback titles. (Refs #3040)
|
||||
- Title-generation prompts now use the same language-neutral "match the user language" instruction for every locale instead of adding German-only exemplars. (Refs #3040)
|
||||
- Session discoverability audit findings for stale persisted WebUI-as-CLI flags now report whether an API-visible lineage representative already covers the hidden snapshot, including the representative session id in JSON and Markdown output.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Title-language detection no longer treats common English tech/jargon text such as "session die" or DAS/DER references as German just because of shared tokens. (Refs #3040)
|
||||
- Clarify prompt SSE fallback polling now preserves its owner session id, matching approval polling behavior so terminal events from another session cannot stop the active clarify fallback poller.
|
||||
- Duplicate chat uploads now report the actual stored filename in `/api/upload` responses, so suffixed files such as `photo-1.png` do not appear under the original basename in WebUI attachment metadata.
|
||||
- Visible but unfocused chat windows now still attempt the immediate SSE reconnect for the current session; only a real session switch skips the reconnect path. (Refs #3040)
|
||||
- Gateway-backed WebUI chat now forwards current-turn image attachments as OpenAI-style multimodal `image_url` parts when native image input is enabled, matching the legacy WebUI runtime's image handoff.
|
||||
- New chat sessions reset `_messagesTruncated` / `_oldestIdx` so a fresh conversation never displays the stale "Scroll up or click to load older messages" indicator inherited from a previously-paginated session.
|
||||
- `openai-codex` reasoning-effort resolution now lets the existing `models.dev` metadata pass set the supported levels (including `xhigh`) instead of being silently clipped through the Copilot model heuristic.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify two Docker onboarding traps: `sudo docker compose` can mount `/root/.hermes` instead of the user's Hermes home on Linux, and Linux Docker Engine users should use a `host-gateway` alias such as `api.local` for host-local model servers instead of configuring `localhost` inside the container. (#3006, #3012)
|
||||
|
||||
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2202,10 +2202,6 @@ def resolve_model_reasoning_efforts(
|
||||
if provider in {"copilot", "github-copilot"}:
|
||||
return github_model_reasoning_efforts(hinted_model)
|
||||
|
||||
if provider == "openai-codex":
|
||||
bare = hinted_model.rsplit("/", 1)[-1]
|
||||
return github_model_reasoning_efforts(bare)
|
||||
|
||||
if provider == "lmstudio":
|
||||
probe_base = resolved_base_url or _get_provider_base_url(provider)
|
||||
opts = lmstudio_model_reasoning_options(hinted_model, probe_base)
|
||||
|
||||
+10
-1
@@ -202,10 +202,19 @@ def _run_gateway_chat_streaming(
|
||||
# Scope Gateway long-term continuity to this WebUI conversation
|
||||
# without exposing the browser's auth cookie or CSRF material.
|
||||
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
||||
message_content: Any = str(msg_text or "")
|
||||
if attachments:
|
||||
try:
|
||||
from api.streaming import _build_native_multimodal_message
|
||||
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
|
||||
except Exception:
|
||||
logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
body = {
|
||||
"model": model or "default",
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": str(msg_text or "")}],
|
||||
"messages": [{"role": "user", "content": message_content}],
|
||||
}
|
||||
if model_provider:
|
||||
body["provider"] = model_provider
|
||||
|
||||
@@ -252,15 +252,19 @@ def audit_session_discoverability(
|
||||
parent = _merged_field(sid, stores, "parent_session_id")
|
||||
parent_by_id[sid] = str(parent) if parent else None
|
||||
api_lineage_ids: set[str] = set()
|
||||
api_lineage_representative_by_id: dict[str, str] = {}
|
||||
for sid, row in api.items():
|
||||
explicit_root = row.get("_lineage_root_id")
|
||||
if explicit_root:
|
||||
api_lineage_ids.add(str(explicit_root))
|
||||
root_id = str(explicit_root)
|
||||
api_lineage_ids.add(root_id)
|
||||
api_lineage_representative_by_id.setdefault(root_id, sid)
|
||||
current = sid
|
||||
seen: set[str] = set()
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
api_lineage_ids.add(current)
|
||||
api_lineage_representative_by_id.setdefault(current, sid)
|
||||
current = parent_by_id.get(current) or ""
|
||||
|
||||
items: list[dict] = []
|
||||
@@ -282,6 +286,12 @@ def audit_session_discoverability(
|
||||
api_computed_is_cli = _computed_is_cli_session(api_row) if api_row else False
|
||||
index_is_cli = index_row.get("is_cli_session") is True
|
||||
sidecar_is_cli = sidecar.get("is_cli_session") is True
|
||||
lineage_root = _lineage_root(sid, parent_by_id)
|
||||
api_representative = api_lineage_representative_by_id.get(sid) or api_lineage_representative_by_id.get(lineage_root)
|
||||
api_lineage_extra = {
|
||||
"represented_by_api_lineage": bool(api_representative),
|
||||
"api_representative_session_id": api_representative,
|
||||
}
|
||||
if webui_origin and api_is_cli and api_computed_is_cli:
|
||||
items.append(_new_item(
|
||||
sid,
|
||||
@@ -295,6 +305,7 @@ def audit_session_discoverability(
|
||||
index_is_cli_session=index_row.get("is_cli_session"),
|
||||
sidecar_is_cli_session=sidecar.get("is_cli_session"),
|
||||
present_in=present_in,
|
||||
**api_lineage_extra,
|
||||
))
|
||||
elif webui_origin and (api_is_cli or index_is_cli or sidecar_is_cli):
|
||||
items.append(_new_item(
|
||||
@@ -309,12 +320,12 @@ def audit_session_discoverability(
|
||||
index_is_cli_session=index_row.get("is_cli_session"),
|
||||
sidecar_is_cli_session=sidecar.get("is_cli_session"),
|
||||
present_in=present_in,
|
||||
**api_lineage_extra,
|
||||
))
|
||||
|
||||
if message_count <= 0 or sid in api:
|
||||
continue
|
||||
|
||||
lineage_root = _lineage_root(sid, parent_by_id)
|
||||
is_hidden_snapshot = bool(sidecar.get("pre_compression_snapshot") or index_row.get("pre_compression_snapshot"))
|
||||
if sid in api_lineage_ids or lineage_root in api_lineage_ids:
|
||||
continue
|
||||
@@ -407,6 +418,10 @@ def render_discoverability_markdown(report: dict) -> str:
|
||||
lines.append(
|
||||
" - present_in: " + ", ".join(f"{k}={v}" for k, v in sorted(present.items()))
|
||||
)
|
||||
if item.get("represented_by_api_lineage"):
|
||||
lines.append(
|
||||
f" - represented_by_api_lineage: true via `{item.get('api_representative_session_id')}`"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
+3
-17
@@ -1385,24 +1385,17 @@ def _detect_title_language(text: str) -> str:
|
||||
return ''
|
||||
german_markers = {
|
||||
'warum', 'werden', 'wird', 'wurde', 'hier', 'nicht', 'mehr', 'alte', 'alten',
|
||||
'bilder', 'angezeigt', 'session', 'prüfe', 'ich', 'die', 'der', 'das', 'den',
|
||||
'und', 'oder', 'mit', 'für', 'von', 'zu', 'ist', 'sind', 'bitte', 'kannst',
|
||||
'bilder', 'angezeigt', 'prüfe', 'ich', 'und', 'oder', 'mit', 'für', 'von',
|
||||
'zu', 'ist', 'sind', 'bitte', 'kannst',
|
||||
}
|
||||
tokens = re.findall(r'[A-Za-zÀ-ÖØ-öø-ÿ]+', s)
|
||||
german_hits = sum(1 for tok in tokens if tok in german_markers)
|
||||
if re.search(r'[äöüß]', s) or german_hits >= 2:
|
||||
if re.search(r'[äöüß]', s) or german_hits >= 3:
|
||||
return 'de'
|
||||
return ''
|
||||
|
||||
|
||||
def _title_prompt_language_rule(user_text: str) -> str:
|
||||
lang = _detect_title_language(user_text)
|
||||
if lang == 'de':
|
||||
return (
|
||||
"Match the language of the user question.\n"
|
||||
"If the user writes German, output a German title.\n"
|
||||
"German good: Alte Session Bilder, WebUI Attachment-Pfade, Kontextkompression Status.\n"
|
||||
)
|
||||
return "Match the language of the user question.\n"
|
||||
|
||||
|
||||
@@ -1869,13 +1862,6 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option
|
||||
assistant_text = re.sub(r'\s+', ' ', assistant_text).strip()
|
||||
combined = f"{user_text} {assistant_text}".strip().lower()
|
||||
combined_raw = f"{user_text} {assistant_text}".strip()
|
||||
source_lang = _detect_title_language(user_text)
|
||||
|
||||
if source_lang == 'de' and 'bilder' in combined and 'session' in combined:
|
||||
if 'alt' in combined or 'alte' in combined or 'alten' in combined:
|
||||
return 'Alte Session Bilder'
|
||||
return 'Session Bilder'
|
||||
|
||||
def _contains_latin(text: str) -> bool:
|
||||
return bool(re.search(r'[A-Za-z]', text or ''))
|
||||
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ def handle_upload(handler):
|
||||
dest.write_bytes(file_bytes)
|
||||
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
|
||||
return j(handler, {
|
||||
'filename': safe_name,
|
||||
'filename': dest.name,
|
||||
'path': str(dest),
|
||||
'size': dest.stat().st_size,
|
||||
'mime': mime,
|
||||
|
||||
@@ -58,6 +58,14 @@ your real `~/.hermes` unless you intentionally want to test real state. Use an
|
||||
isolated Hermes home and follow
|
||||
[`docs/onboarding-agent-checklist.md`](onboarding-agent-checklist.md) instead.
|
||||
|
||||
> **Linux note**: run Compose as the user who owns the Hermes home. The command
|
||||
> `sudo docker compose up -d` can make Compose expand `${HOME}` as `/root`, so
|
||||
> the default `${HOME}/.hermes` bind mount becomes `/root/.hermes` instead of
|
||||
> your user's real Hermes directory. Prefer adding your user to the `docker group`
|
||||
> and running `docker compose up -d`; if you must preserve the caller environment
|
||||
> for a one-off root run, use `sudo -E docker compose up -d` and verify the
|
||||
> rendered mount with `docker compose config` first.
|
||||
|
||||
## Scheduled jobs and the gateway daemon
|
||||
|
||||
**Symptom**: Cron jobs created in the Tasks panel never fire. System Settings shows the orange "Gateway not configured" pill, and the Tasks panel shows the same banner above the job list.
|
||||
|
||||
+15
-3
@@ -121,12 +121,24 @@ API root. Common examples:
|
||||
| Ollama on the same non-Docker host | `http://127.0.0.1:11434/v1` |
|
||||
| LM Studio from Docker Desktop | `http://host.docker.internal:1234/v1` |
|
||||
| Ollama from Docker Desktop | `http://host.docker.internal:11434/v1` |
|
||||
| Local server from Linux Docker Engine | `http://api.local:<port>/v1` with `api.local:host-gateway` in Compose `extra_hosts` |
|
||||
| Local server on another LAN machine | `http://<lan-ip>:<port>/v1` |
|
||||
|
||||
Inside Docker, `localhost` means the WebUI container itself, not your Mac,
|
||||
Windows host, or another machine on your LAN. If LM Studio or Ollama is running
|
||||
outside the container, use `host.docker.internal` on Docker Desktop or the
|
||||
server's LAN IP address.
|
||||
Windows host, Linux host, or another machine on your LAN. If LM Studio or Ollama
|
||||
is running outside the container, use `host.docker.internal` on Docker Desktop,
|
||||
use the server's LAN IP address, or add a Linux Docker host alias:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
hermes-webui:
|
||||
extra_hosts:
|
||||
- "api.local:host-gateway"
|
||||
```
|
||||
|
||||
Then use `http://api.local:<port>/v1` as the Base URL. The alias avoids writing
|
||||
`localhost` in WebUI config where it would resolve to the container loopback
|
||||
instead of the host service.
|
||||
|
||||
The wizard probes `<base-url>/models` before saving. A successful probe fills
|
||||
the model dropdown. A failed probe blocks the setup step and shows an inline
|
||||
|
||||
+4
-2
@@ -2155,7 +2155,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// If the user has switched to a different session, don't attempt to
|
||||
// reconnect — the old stream's EventSource was closed intentionally
|
||||
// during session switch and reconnecting would leak a background stream.
|
||||
if(!_isSessionActivelyViewed(activeSid)) return;
|
||||
if(!_isSessionCurrentPane(activeSid)) return;
|
||||
if(_terminalStateReached || _streamFinalized){
|
||||
return;
|
||||
}
|
||||
@@ -3147,7 +3147,8 @@ function startClarifyPolling(sid) {
|
||||
});
|
||||
|
||||
_clarifyEventSource.onerror = function() {
|
||||
stopClarifyPolling();
|
||||
if (_clarifyEventSource) { try { _clarifyEventSource.close(); } catch(_){} _clarifyEventSource = null; }
|
||||
if (_clarifyHealthTimer) { clearInterval(_clarifyHealthTimer); _clarifyHealthTimer = null; }
|
||||
_startClarifyFallbackPoll(sid);
|
||||
};
|
||||
|
||||
@@ -3177,6 +3178,7 @@ function startClarifyPolling(sid) {
|
||||
}
|
||||
|
||||
function _startClarifyFallbackPoll(sid) {
|
||||
_clarifyPollingSessionId = sid || null;
|
||||
_clarifyFallbackTimer = setInterval(async () => {
|
||||
if (!S.session || S.session.session_id !== sid) {
|
||||
stopClarifyPolling(); _hideClarifyCardIfOwner(sid, true, 'session'); return;
|
||||
|
||||
@@ -455,6 +455,8 @@ async function newSession(flash, options={}){
|
||||
_newSessionInFlight=(async()=>{
|
||||
updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
_messagesTruncated=false;
|
||||
_oldestIdx=0;
|
||||
clearLiveToolCards();
|
||||
// One-shot profile-switch workspace: applied to the first new session after a profile
|
||||
// switch, then cleared. Use a dedicated flag so S._profileDefaultWorkspace (the
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MESSAGES_JS = ROOT / "static" / "messages.js"
|
||||
UPLOAD_PY = ROOT / "api" / "upload.py"
|
||||
|
||||
|
||||
def test_image_uploads_use_server_path_in_attached_files_context():
|
||||
@@ -19,3 +20,23 @@ def test_image_uploads_use_server_path_in_attached_files_context():
|
||||
|
||||
assert "uploadedPaths=uploaded.map(u=>u&&u.is_image?" not in src
|
||||
assert "uploadedPaths=uploaded.map(u=>u&&u.path?u.path" in src
|
||||
|
||||
|
||||
def test_duplicate_upload_response_reports_actual_stored_filename(tmp_path, monkeypatch):
|
||||
"""Duplicate upload names should report the suffixed stored basename."""
|
||||
monkeypatch.setenv("HERMES_WEBUI_ATTACHMENT_DIR", str(tmp_path))
|
||||
|
||||
from api.upload import _sanitize_upload_name, _upload_destination
|
||||
|
||||
safe_name = _sanitize_upload_name("photo.png")
|
||||
first = _upload_destination("session-a", safe_name)
|
||||
first.write_bytes(b"first")
|
||||
second = _upload_destination("session-a", safe_name)
|
||||
|
||||
assert first.name == "photo.png"
|
||||
assert second.name == "photo-1.png"
|
||||
|
||||
src = UPLOAD_PY.read_text(encoding="utf-8")
|
||||
handle_body = src[src.index("def handle_upload"):src.index("def extract_archive", src.index("def handle_upload"))]
|
||||
assert "'filename': dest.name" in handle_body
|
||||
assert "'filename': safe_name" not in handle_body
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Regression coverage for #3040 item 4: visible-but-unfocused stream reattach."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _chat_error_handler() -> str:
|
||||
return MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split(
|
||||
"source.addEventListener('cancel'", 1
|
||||
)[0]
|
||||
|
||||
|
||||
def test_visible_unfocused_current_session_still_attempts_sse_reconnect():
|
||||
"""The immediate chat SSE error path should only require current-pane ownership.
|
||||
|
||||
`_isSessionActivelyViewed()` also gates on document focus/visibility. Hidden tabs
|
||||
are already deferred by `_deferStreamErrorIfPageHidden(source)`, so the direct
|
||||
reconnect branch must not skip a visible-but-unfocused current session.
|
||||
"""
|
||||
handler = _chat_error_handler()
|
||||
assert "if(!_isSessionCurrentPane(activeSid)) return;" in handler
|
||||
assert "if(!_isSessionActivelyViewed(activeSid)) return;" not in handler
|
||||
assert handler.index("if(!_isSessionCurrentPane(activeSid)) return;") < handler.index(
|
||||
"if(!_reconnectAttempted && streamId)"
|
||||
)
|
||||
|
||||
|
||||
def test_reconnect_gate_comment_matches_current_pane_contract():
|
||||
handler = _chat_error_handler()
|
||||
guard_idx = handler.index("if(!_isSessionCurrentPane(activeSid)) return;")
|
||||
comment_window = handler[max(0, guard_idx - 260):guard_idx]
|
||||
assert "different session" in comment_window
|
||||
assert "visible" not in comment_window.lower()
|
||||
assert "focused" not in comment_window.lower()
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
@@ -78,6 +80,48 @@ def test_models_dev_false_suppresses_prefix_heuristic(monkeypatch):
|
||||
) == []
|
||||
|
||||
|
||||
def test_codex_gpt55_uses_models_dev_including_xhigh(monkeypatch):
|
||||
_install_fake_models_dev(
|
||||
monkeypatch,
|
||||
lambda provider, model: SimpleNamespace(supports_reasoning=True),
|
||||
)
|
||||
|
||||
import api.config as cfg
|
||||
|
||||
result = cfg.resolve_model_reasoning_efforts(
|
||||
"gpt-5.5", provider_id="openai-codex"
|
||||
)
|
||||
assert result == list(cfg.VALID_REASONING_EFFORTS)
|
||||
assert "xhigh" in result
|
||||
|
||||
|
||||
def test_codex_metadata_false_returns_empty(monkeypatch):
|
||||
_install_fake_models_dev(
|
||||
monkeypatch,
|
||||
lambda provider, model: SimpleNamespace(supports_reasoning=False),
|
||||
)
|
||||
|
||||
import api.config as cfg
|
||||
|
||||
assert cfg.resolve_model_reasoning_efforts(
|
||||
"gpt-5.5", provider_id="openai-codex"
|
||||
) == []
|
||||
|
||||
|
||||
def test_copilot_gpt55_caps_at_high(monkeypatch):
|
||||
import api.config as cfg
|
||||
|
||||
try:
|
||||
from hermes_cli.models import github_model_reasoning_efforts
|
||||
except ImportError:
|
||||
pytest.skip("hermes_cli not available")
|
||||
|
||||
result = cfg.resolve_model_reasoning_efforts(
|
||||
"gpt-5.5", provider_id="copilot"
|
||||
)
|
||||
assert "xhigh" not in result
|
||||
|
||||
|
||||
def test_get_reasoning_status_uses_config_default_model(monkeypatch, tmp_path):
|
||||
_install_fake_models_dev(
|
||||
monkeypatch,
|
||||
|
||||
@@ -98,6 +98,51 @@ def test_audit_reports_stale_cli_flag_on_webui_session(tmp_path):
|
||||
assert item["sidecar_is_cli_session"] is True
|
||||
|
||||
|
||||
def test_stale_flag_hidden_snapshot_reports_visible_api_representative(tmp_path):
|
||||
root = "webui-hidden-stale-cli-root"
|
||||
tip = "webui-visible-lineage-tip"
|
||||
_write_sidecar(
|
||||
tmp_path,
|
||||
root,
|
||||
messages=12,
|
||||
source_tag="webui",
|
||||
session_source="webui",
|
||||
is_cli_session=True,
|
||||
pre_compression_snapshot=True,
|
||||
)
|
||||
_write_index(
|
||||
tmp_path,
|
||||
{
|
||||
"session_id": root,
|
||||
"message_count": 12,
|
||||
"source_tag": "webui",
|
||||
"session_source": "webui",
|
||||
"is_cli_session": True,
|
||||
"pre_compression_snapshot": True,
|
||||
},
|
||||
)
|
||||
db = _state_db(
|
||||
tmp_path,
|
||||
[
|
||||
{"id": root, "source": "webui", "message_count": 12},
|
||||
{"id": tip, "source": "webui", "parent_session_id": root, "message_count": 14},
|
||||
],
|
||||
{root: 12, tip: 14},
|
||||
)
|
||||
|
||||
report = audit_session_discoverability(
|
||||
tmp_path,
|
||||
state_db_path=db,
|
||||
api_sessions=[{"session_id": tip, "message_count": 14, "parent_session_id": root, "_lineage_root_id": root}],
|
||||
)
|
||||
|
||||
item = next(item for item in report["items"] if item["session_id"] == root)
|
||||
assert item["kind"] == "persisted_source_flag_stale"
|
||||
assert item["present_in"] == {"sidecar": True, "index": True, "state_db": True, "api": False}
|
||||
assert item["represented_by_api_lineage"] is True
|
||||
assert item["api_representative_session_id"] == tip
|
||||
|
||||
|
||||
def test_audit_classifies_state_db_only_messageful_rows_as_missing_sidecar(tmp_path):
|
||||
sid = "state-only-session"
|
||||
db = _state_db(tmp_path, [{"id": sid, "source": "webui", "message_count": 5}], {sid: 5})
|
||||
|
||||
@@ -117,6 +117,21 @@ class TestSessionOwnedRuntimeInvariants:
|
||||
"the active pane currently owns."
|
||||
)
|
||||
|
||||
def test_clarify_sse_fallback_preserves_owner_session_id(self):
|
||||
messages = read("static/messages.js")
|
||||
start_clarify = _function_body(messages, "startClarifyPolling")
|
||||
fallback = _function_body(messages, "_startClarifyFallbackPoll")
|
||||
|
||||
assert "_clarifyPollingSessionId = sid || null" in fallback, (
|
||||
"Any clarify fallback poller should retain its owner session id."
|
||||
)
|
||||
onerror_idx = start_clarify.index("_clarifyEventSource.onerror")
|
||||
onerror_body = start_clarify[onerror_idx:start_clarify.index("};", onerror_idx)]
|
||||
assert "stopClarifyPolling();" not in onerror_body, (
|
||||
"SSE fallback must not clear _clarifyPollingSessionId before starting the fallback poller."
|
||||
)
|
||||
assert "_startClarifyFallbackPoll(sid)" in onerror_body
|
||||
|
||||
def test_live_stream_transport_and_inflight_state_remain_session_keyed(self):
|
||||
messages = read("static/messages.js")
|
||||
close_live = _function_body(messages, "closeLiveStream")
|
||||
|
||||
@@ -199,7 +199,7 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
|
||||
self.assertEqual(captured.get('api_key'), 'test-title-api-key')
|
||||
|
||||
def test_title_prompt_requires_matching_user_language(self):
|
||||
"""German conversation starts should not invite English title output."""
|
||||
"""Conversation starts should get a language-neutral match-language instruction."""
|
||||
from api.streaming import generate_title_raw_via_aux
|
||||
|
||||
mock_resp = types.SimpleNamespace(
|
||||
@@ -227,7 +227,45 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
|
||||
self.assertEqual(status, 'llm_aux')
|
||||
messages = captured.get('messages') or []
|
||||
self.assertIn('Match the language of the user question', messages[0]['content'])
|
||||
self.assertIn('If the user writes German, output a German title', messages[0]['content'])
|
||||
self.assertNotIn('If the user writes German', messages[0]['content'])
|
||||
self.assertNotIn('German good:', messages[0]['content'])
|
||||
|
||||
def test_title_prompt_language_rule_is_same_for_supported_locales(self):
|
||||
from api.streaming import _title_prompt_language_rule
|
||||
|
||||
expected = "Match the language of the user question.\n"
|
||||
examples = [
|
||||
'Warum werden hier die Bilder nicht angezeigt?',
|
||||
'Pourquoi les images ne sont-elles pas affichées ?',
|
||||
'¿Por qué no se muestran las imágenes?',
|
||||
'为什么图片没有显示?',
|
||||
'Why are the images not displayed?',
|
||||
]
|
||||
for text in examples:
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(_title_prompt_language_rule(text), expected)
|
||||
|
||||
def test_title_language_detection_avoids_english_tech_false_positives(self):
|
||||
"""English tech/jargon text must not be classified as German by shared tokens."""
|
||||
from api.streaming import _detect_title_language
|
||||
|
||||
examples = [
|
||||
'Why did the session die after the DAS storage failover?',
|
||||
'The session can die when DAS storage disconnects.',
|
||||
'Debug the session and DER certificate import failure.',
|
||||
]
|
||||
for text in examples:
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(_detect_title_language(text), '')
|
||||
|
||||
def test_title_language_detection_keeps_german_without_umlaut(self):
|
||||
"""German without umlauts still needs a language hint when evidence is specific."""
|
||||
from api.streaming import _detect_title_language
|
||||
|
||||
self.assertEqual(
|
||||
_detect_title_language('Warum werden hier die Bilder der alten Session nicht angezeigt?'),
|
||||
'de',
|
||||
)
|
||||
|
||||
def test_german_source_rejects_english_aux_title(self):
|
||||
"""Regression: an English aux title must not overwrite a German conversation."""
|
||||
@@ -253,7 +291,7 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
|
||||
self.assertEqual(status, 'llm_language_mismatch_aux')
|
||||
self.assertEqual(raw_preview, 'Old Session Image Display Issue')
|
||||
|
||||
def test_german_fallback_keeps_german_topic_words(self):
|
||||
def test_german_fallback_uses_generic_topic_extraction_without_literal_override(self):
|
||||
from api.streaming import _fallback_title_from_exchange
|
||||
|
||||
title = _fallback_title_from_exchange(
|
||||
@@ -261,7 +299,11 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
|
||||
'Ich prüfe die Rendering- und Attachment-Pfade im WebUI.',
|
||||
)
|
||||
|
||||
self.assertEqual(title, 'Alte Session Bilder')
|
||||
self.assertIsNotNone(title)
|
||||
self.assertIsInstance(title, str)
|
||||
self.assertNotEqual(title, 'Alte Session Bilder')
|
||||
self.assertNotEqual(title, 'Session Bilder')
|
||||
self.assertIn('Warum', title)
|
||||
|
||||
def test_configured_api_key_is_not_sent_to_caller_supplied_route(self):
|
||||
"""Regression: title task keys must not leak to explicit fallback routes.
|
||||
|
||||
@@ -262,3 +262,29 @@ def test_env_docker_example_warns_about_home_mode_asymmetry():
|
||||
".env.docker.example must include a MULTI-CONTAINER WARNING about "
|
||||
"the HERMES_HOME_MODE semantic asymmetry between WebUI and agent."
|
||||
)
|
||||
|
||||
|
||||
# ── 8: Docker localhost and sudo-compose troubleshooting (#3006, #3012) ────
|
||||
|
||||
|
||||
def test_docs_docker_warns_about_sudo_compose_home_expansion():
|
||||
"""REGRESSION (#3006): the Docker guide must explain that
|
||||
`sudo docker compose` can expand `${HOME}` to `/root`, mounting the wrong
|
||||
`.hermes` directory into the container."""
|
||||
src = (REPO / "docs" / "docker.md").read_text(encoding="utf-8")
|
||||
assert "sudo docker compose" in src
|
||||
assert "/root/.hermes" in src
|
||||
assert "sudo -E docker compose" in src
|
||||
assert "docker group" in src
|
||||
|
||||
|
||||
def test_onboarding_docs_cover_linux_host_gateway_for_container_localhost():
|
||||
"""REGRESSION (#3012): local-provider onboarding docs must include the
|
||||
Linux Docker host-gateway shape, not only Docker Desktop's
|
||||
`host.docker.internal` shortcut."""
|
||||
src = (REPO / "docs" / "onboarding.md").read_text(encoding="utf-8")
|
||||
assert "host.docker.internal" in src
|
||||
assert "host-gateway" in src
|
||||
assert "extra_hosts" in src
|
||||
assert "api.local" in src
|
||||
assert "localhost" in src and "container" in src
|
||||
@@ -1,4 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
import base64
|
||||
import json
|
||||
|
||||
import api.gateway_chat as gateway_chat
|
||||
import api.models as models
|
||||
@@ -117,3 +119,56 @@ 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"]
|
||||
|
||||
|
||||
def test_gateway_chat_worker_forwards_image_attachments_as_multimodal_parts(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())
|
||||
|
||||
image_bytes = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
|
||||
)
|
||||
image_path = tmp_path / "photo.png"
|
||||
image_path.write_bytes(image_bytes)
|
||||
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":"saw it"}}]}\n\n'
|
||||
yield b'data: [DONE]\n\n'
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
|
||||
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
s = new_session()
|
||||
stream_id = "stream-gateway-image-test"
|
||||
s.active_stream_id = stream_id
|
||||
s.save()
|
||||
STREAMS[stream_id] = create_stream_channel()
|
||||
|
||||
gateway_chat._run_gateway_chat_streaming(
|
||||
s.session_id,
|
||||
"What is in this image?",
|
||||
"test-model",
|
||||
str(tmp_path),
|
||||
stream_id,
|
||||
[{"path": str(image_path), "mime": "image/png", "is_image": True}],
|
||||
)
|
||||
|
||||
content = captured["body"]["messages"][0]["content"]
|
||||
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,")
|
||||
Reference in new issue
Block a user