From b2220a54b5c2b2e2fef4606884aff684cecc4e1b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 27 May 2026 21:38:06 -0600 Subject: [PATCH 01/13] fix: route openai-codex through models.dev so GPT-5.5 exposes xhigh - Remove openai-codex special case that called github_model_reasoning_efforts() - Codex now falls through to _models_dev_reasoning_efforts() (full efforts) - GitHub/Copilot still use the GitHub helper (caps at high) - Added regression tests for both behaviors --- api/config.py | 4 ---- tests/test_models_dev_reasoning.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/api/config.py b/api/config.py index cfe1cec8..5101fa04 100644 --- a/api/config.py +++ b/api/config.py @@ -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) diff --git a/tests/test_models_dev_reasoning.py b/tests/test_models_dev_reasoning.py index f474e3fe..adad9e43 100644 --- a/tests/test_models_dev_reasoning.py +++ b/tests/test_models_dev_reasoning.py @@ -2,6 +2,8 @@ import sys import types + +import pytest from types import SimpleNamespace @@ -78,6 +80,35 @@ 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_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, From 9ddf36f510dea272cddee95ec3ae9299add81379 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Thu, 28 May 2026 12:13:30 +0800 Subject: [PATCH 02/13] fix: reset _messagesTruncated flag on new session creation newSession() did not reset _messagesTruncated or _oldestIdx, unlike loadSession() which resets both at line 590. When a user switched from a long session (messages > _INITIAL_MSG_LIMIT) to a new session, the stale _messagesTruncated=true caused renderMessages() to show the 'Scroll up or click to load older messages' indicator on a fresh conversation with only 1 message. Add the same reset that loadSession() already performs so newSession() starts with clean pagination state. --- static/sessions.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/sessions.js b/static/sessions.js index fb51405e..39015f82 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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 From 07f61b72fbb15a459a96bf3bfff24176387409de Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 27 May 2026 22:26:24 -0600 Subject: [PATCH 03/13] test: add codex metadata=False case for reasoning efforts - Covers the openai-codex + supports_reasoning=False path now that Codex routes through _models_dev_reasoning_efforts. - Mirrors the existing false-suppression test for symmetry. - 9/9 tests pass. --- tests/test_models_dev_reasoning.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_models_dev_reasoning.py b/tests/test_models_dev_reasoning.py index adad9e43..8673a3e0 100644 --- a/tests/test_models_dev_reasoning.py +++ b/tests/test_models_dev_reasoning.py @@ -95,6 +95,19 @@ def test_codex_gpt55_uses_models_dev_including_xhigh(monkeypatch): 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 From bd8fd22d81208da0e3ac3f0c681247ad71964726 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 08:13:35 +0200 Subject: [PATCH 04/13] fix: show lineage representative in discoverability audit --- CHANGELOG.md | 3 ++ api/session_discoverability.py | 19 ++++++++- tests/test_session_discoverability_audit.py | 45 +++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..225652b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Changed +- 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. + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/api/session_discoverability.py b/api/session_discoverability.py index 0f218561..10541e59 100644 --- a/api/session_discoverability.py +++ b/api/session_discoverability.py @@ -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) diff --git a/tests/test_session_discoverability_audit.py b/tests/test_session_discoverability_audit.py index 1330aafd..19a5fa95 100644 --- a/tests/test_session_discoverability_audit.py +++ b/tests/test_session_discoverability_audit.py @@ -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}) From 2aeebf56acf43c10a8032c1165ae6f4bddaf628b Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 08:21:58 +0200 Subject: [PATCH 05/13] fix: tighten title language detection --- CHANGELOG.md | 4 ++++ api/streaming.py | 6 +++--- tests/test_title_aux_routing.py | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..29b79585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### 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) + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/api/streaming.py b/api/streaming.py index 6c70234d..55e4ae91 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1385,12 +1385,12 @@ 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 '' diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index f31418eb..be448fdf 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -229,6 +229,28 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase): 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']) + 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.""" from api.streaming import _generate_llm_session_title_via_aux From eb8ecb2e615dbdf9fadf22606a5ff571f2140a30 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 08:39:59 +0200 Subject: [PATCH 06/13] docs: clarify Docker localhost and sudo compose setup --- CHANGELOG.md | 4 ++++ docs/docker.md | 8 ++++++++ docs/onboarding.md | 18 ++++++++++++++--- tests/test_v050260_docker_invariants.py | 26 +++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..e9a82508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### 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 diff --git a/docs/docker.md b/docs/docker.md index 9ef9ccb9..d5a4186e 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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. diff --git a/docs/onboarding.md b/docs/onboarding.md index cedd06cd..5963d43f 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -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:/v1` with `api.local:host-gateway` in Compose `extra_hosts` | | Local server on another LAN machine | `http://:/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:/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 `/models` before saving. A successful probe fills the model dropdown. A failed probe blocks the setup step and shows an inline diff --git a/tests/test_v050260_docker_invariants.py b/tests/test_v050260_docker_invariants.py index f773e9d2..3b06575a 100644 --- a/tests/test_v050260_docker_invariants.py +++ b/tests/test_v050260_docker_invariants.py @@ -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 From c197e0c09153f75f353756a03fcc061f5073bdfe Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 09:02:06 +0200 Subject: [PATCH 07/13] fix: allow current-pane SSE reconnect when unfocused --- CHANGELOG.md | 4 +++ static/messages.js | 2 +- tests/test_issue3040_reattach_focus_gate.py | 37 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue3040_reattach_focus_gate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..66f29e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- 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) + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/static/messages.js b/static/messages.js index c2195150..5884dfad 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; } diff --git a/tests/test_issue3040_reattach_focus_gate.py b/tests/test_issue3040_reattach_focus_gate.py new file mode 100644 index 00000000..f860d28b --- /dev/null +++ b/tests/test_issue3040_reattach_focus_gate.py @@ -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() From b046cb42c3a47dd801f2b2fd13cc5d105f74283f Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 09:09:59 +0200 Subject: [PATCH 08/13] fix: remove German-only fallback title override --- CHANGELOG.md | 4 ++++ api/streaming.py | 7 ------- tests/test_title_aux_routing.py | 8 ++++++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..846048d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### 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) + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/api/streaming.py b/api/streaming.py index 6c70234d..eef1c819 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1869,13 +1869,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 '')) diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index f31418eb..6a2f8aa3 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -253,7 +253,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 +261,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. From 62efbfb13ff739c846657adbe011b9426a93e75e Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 09:14:46 +0200 Subject: [PATCH 09/13] fix: use generic title language prompt --- CHANGELOG.md | 4 ++++ api/streaming.py | 7 ------- tests/test_title_aux_routing.py | 20 ++++++++++++++++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..21524b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Changed + +- 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) + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/api/streaming.py b/api/streaming.py index 6c70234d..b7fd0a08 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1396,13 +1396,6 @@ def _detect_title_language(text: str) -> str: 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" diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index f31418eb..a87eb402 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -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,23 @@ 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_german_source_rejects_english_aux_title(self): """Regression: an English aux title must not overwrite a German conversation.""" From 0458a0a0656446972fed3bc5c3fef0c846942100 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Thu, 28 May 2026 08:33:50 -0400 Subject: [PATCH 10/13] fix: report stored upload filenames --- CHANGELOG.md | 4 ++++ api/upload.py | 2 +- tests/test_chat_upload_attachment_paths.py | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..9fce67ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- 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. + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/api/upload.py b/api/upload.py index dc0aa0c1..b36cde46 100644 --- a/api/upload.py +++ b/api/upload.py @@ -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, diff --git a/tests/test_chat_upload_attachment_paths.py b/tests/test_chat_upload_attachment_paths.py index 6e0fba38..08f7f2cc 100644 --- a/tests/test_chat_upload_attachment_paths.py +++ b/tests/test_chat_upload_attachment_paths.py @@ -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 From 0f26b99a1170ceaf27a3d91cc2d2538ee7349c83 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Thu, 28 May 2026 08:35:26 -0400 Subject: [PATCH 11/13] fix: preserve clarify fallback ownership --- CHANGELOG.md | 4 ++++ static/messages.js | 4 +++- .../test_session_runtime_ownership_invariants.py | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..e56a32a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- 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. + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/static/messages.js b/static/messages.js index c2195150..143a1611 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; diff --git a/tests/test_session_runtime_ownership_invariants.py b/tests/test_session_runtime_ownership_invariants.py index 68e1a10c..152d79b1 100644 --- a/tests/test_session_runtime_ownership_invariants.py +++ b/tests/test_session_runtime_ownership_invariants.py @@ -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") From 3f22902423a6231df0c356cc0cddb6a5a20ec38b Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Thu, 28 May 2026 08:40:51 -0400 Subject: [PATCH 12/13] fix: forward gateway image attachments --- CHANGELOG.md | 4 ++ api/gateway_chat.py | 11 ++++- tests/test_webui_gateway_chat_backend.py | 55 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a990d204..293228bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- 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. + ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) ### Added diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 1c658abb..69e653ac 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -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 diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index e2bed326..f9c7f94d 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -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,") From 95aa69f951b22e9ed722a9356323947a532f376b Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Thu, 28 May 2026 16:13:58 +0000 Subject: [PATCH 13/13] stage-batch35: stamp v0.51.153 / Release DY 11-PR low-risk cleanup: - #3043 openai-codex models.dev reasoning passes xhigh - #3044 reset _messagesTruncated on new session - #3047 discoverability: api lineage representative for stale CLI flag - #3049 title-language detection threshold + English false-positive fix - #3051 docker docs: sudo compose + Linux host-gateway - #3054 SSE reconnect: visible-but-unfocused current pane - #3055 fallback title: drop German-only Session Bilder case - #3056 title prompt: language-neutral instruction - #3070 /api/upload reports actual stored filename - #3071 clarify SSE fallback preserves owner session id - #3072 gateway-chat forwards image attachments as image_url parts --- CHANGELOG.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9501f5e4..33ed7ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,20 +3,23 @@ ## [Unreleased] +## [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