diff --git a/CHANGELOG.md b/CHANGELOG.md index 3469fc50..b2173763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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/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/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/api/streaming.py b/api/streaming.py index 6c70234d..b4aefdcc 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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 '')) 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/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/static/messages.js b/static/messages.js index c2195150..01ea27d4 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; } @@ -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/static/sessions.js b/static/sessions.js index ba70b9fc..e5a0e2e3 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 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 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() diff --git a/tests/test_models_dev_reasoning.py b/tests/test_models_dev_reasoning.py index f474e3fe..8673a3e0 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,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, 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}) 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") diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index f31418eb..d1007924 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,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. 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 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,")