From af98bad9dea42ed28b58affd9fc7f6f04b975f13 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Fri, 8 May 2026 09:19:41 -0700 Subject: [PATCH 1/5] fix: make kanban detail view scrollable --- static/style.css | 2 +- tests/test_kanban_ui_static.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/static/style.css b/static/style.css index 5ca1ebfa..26e32b05 100644 --- a/static/style.css +++ b/static/style.css @@ -2275,7 +2275,7 @@ main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;} main.main.showing-skills > #mainSkills{display:flex;} main.main.showing-memory > #mainMemory{display:flex;} main.main.showing-tasks > #mainTasks{display:flex;} -main.main.showing-kanban > #mainKanban{display:flex;} +main.main.showing-kanban > #mainKanban{display:flex;overflow-y:auto;} main.main.showing-workspaces > #mainWorkspaces{display:flex;} main.main.showing-profiles > #mainProfiles{display:flex;} main.main.showing-logs > #mainLogs{display:flex;} diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index b9ed8372..89e019ba 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -93,6 +93,17 @@ def test_kanban_board_has_native_css_classes(): assert "overflow-x:auto" in COMPACT_STYLE +def test_kanban_main_view_scrolls_when_task_preview_is_tall(): + """The app shell keeps body overflow hidden, so the Kanban main view + must own vertical scrolling. Otherwise a selected task with a long body + can push the board below the viewport with no way to reach it. + """ + assert re.search( + r"main\.main\.showing-kanban\s*>\s*#mainKanban\s*\{[^}]*display:flex;[^}]*overflow-y:auto;", + COMPACT_STYLE, + ), "Kanban main view must expose a vertical scrollbar when detail content is taller than the viewport" + + def test_kanban_i18n_keys_exist_in_every_locale_block(): locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S) assert len(locale_blocks) >= 8 From c4328c0a235e08579c5aa809e41bc9918ed71f42 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 8 May 2026 18:11:49 +0200 Subject: [PATCH 2/5] fix: keep streaming chat pinned after final render --- static/messages.js | 7 ++- static/ui.js | 44 ++++++++++++++++--- tests/test_dashboard_probe.py | 5 +++ tests/test_issue1560_password_env_var_lock.py | 7 ++- tests/test_issue1731_upward_scroll_unpins.py | 40 +++++++++++++++++ tests/test_streaming_markdown.py | 32 ++++++++++++++ 6 files changed, 126 insertions(+), 9 deletions(-) diff --git a/static/messages.js b/static/messages.js index f2203e92..95ca37d5 100644 --- a/static/messages.js +++ b/static/messages.js @@ -915,6 +915,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } _clearApprovalForOwner(); _clearClarifyForOwner('terminal'); + const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function') + ? _shouldFollowMessagesOnDomReplace() + : (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200))); if(isActiveSession){ S.activeStreamId=null; } @@ -994,7 +997,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // No-reply guard (#373): if agent returned nothing, show inline error if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});} if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); - syncTopbar();renderMessages({preserveScroll:true});loadDir('.'); + syncTopbar();renderMessages({preserveScroll:true}); + if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); + loadDir('.'); // TTS auto-read: speak the last assistant response if enabled (#499) if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300); } diff --git a/static/ui.js b/static/ui.js index 1ceed738..aec6ffac 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1500,7 +1500,10 @@ let _programmaticScroll=false; let _nearBottomCount=0; let _lastScrollTop=null; let _lastNonMessageScrollIntentMs=0; +let _lastMessageUpwardIntentMs=0; +let _messageUserUnpinned=false; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; +const MESSAGE_UPWARD_INTENT_MS=450; function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); const target=e&&e.target; @@ -1510,6 +1513,18 @@ function _recordNonMessageScrollIntent(e){ // session sidebar (or another independent pane) must not be immediately fought // by scrollIfPinned() writing #messages.scrollTop on the next token (#1784). if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now(); + else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){ + // User is intentionally moving upward in the transcript. Record the real + // input event so later scrollTop decreases caused by layout/windowing do + // not masquerade as user intent and strand live streaming away from bottom. + _lastMessageUpwardIntentMs=performance.now(); + _messageUserUnpinned=true; + _nearBottomCount=0; + _scrollPinned=false; + } +} +function _recentMessageUpwardIntent(){ + return performance.now()-_lastMessageUpwardIntentMs{ const top=el.scrollTop; const nearBottom=el.scrollHeight-top-el.clientHeight<250; - const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2; + // scrollToBottomBtn visibility is updated below after pin state settles. + const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2 && _recentMessageUpwardIntent(); _lastScrollTop=top; - if(movedUp){ _nearBottomCount=0; _scrollPinned=false; } // #1731 - else { _nearBottomCount=nearBottom?_nearBottomCount+1:0; _scrollPinned=_nearBottomCount>=2; } // #1360 + if(movedUp){ _nearBottomCount=0; _scrollPinned=false; _messageUserUnpinned=true; } // #1731 + else { _nearBottomCount=nearBottom?_nearBottomCount+1:0; _scrollPinned=_nearBottomCount>=2; if(_scrollPinned) _messageUserUnpinned=false; } // #1360 const btn=$('scrollToBottomBtn'); if(btn) btn.style.display=_scrollPinned?'none':'flex'; // Load older messages when scrolled near the top @@ -1822,16 +1838,32 @@ document.addEventListener('DOMContentLoaded',function(){ tooltip.addEventListener('click',function(e){e.stopPropagation();}); }); +function _setMessageScrollToBottom(){ + const el=$('messages'); + if(!el) return; + _programmaticScroll=true; + el.scrollTop=el.scrollHeight; + _lastScrollTop=el.scrollTop; + requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); }); +} +function _isMessagePaneNearBottom(threshold=250){ + const el=$('messages'); + if(!el) return false; + return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold; +} +function _shouldFollowMessagesOnDomReplace(){ + return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200)); +} function scrollIfPinned(){ if(!_scrollPinned) return; if(_recentNonMessageScrollIntent()) return; const el=$('messages'); - if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;setTimeout(()=>{_programmaticScroll=false;},0);} + if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;_lastScrollTop=el.scrollTop;requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });} } function scrollToBottom(){ _scrollPinned=true; - const el=$('messages'); - if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;setTimeout(()=>{_programmaticScroll=false;},0);} + _messageUserUnpinned=false; + _setMessageScrollToBottom(); const btn=$('scrollToBottomBtn'); if(btn) btn.style.display='none'; } diff --git a/tests/test_dashboard_probe.py b/tests/test_dashboard_probe.py index d65b3f26..7b353a31 100644 --- a/tests/test_dashboard_probe.py +++ b/tests/test_dashboard_probe.py @@ -111,6 +111,11 @@ def test_dashboard_target_validation_allows_only_loopback_base_urls(): def test_status_tries_default_loopback_targets_until_dashboard_found(monkeypatch): from api import dashboard_probe + # This test verifies the default auto-probe sequence. Other tests exercise + # .env/bootstrap behavior and may leave HERMES_WEBUI_HOST at 0.0.0.0 in the + # process env; make the default precondition explicit here. + monkeypatch.delenv("HERMES_WEBUI_HOST", raising=False) + attempts = [] def fake_probe(host, port, timeout=0.5, scheme="http"): diff --git a/tests/test_issue1560_password_env_var_lock.py b/tests/test_issue1560_password_env_var_lock.py index 587a9327..e303d9b9 100644 --- a/tests/test_issue1560_password_env_var_lock.py +++ b/tests/test_issue1560_password_env_var_lock.py @@ -168,10 +168,13 @@ def test_i18n_locked_string_mentions_env_var_name_in_all_locales(): # ── Live HTTP smoke test (env var NOT set in pytest) ────────────────────── -def test_get_settings_returns_password_env_var_false_when_unset(): +def test_get_settings_returns_password_env_var_false_when_unset(monkeypatch): """When HERMES_WEBUI_PASSWORD is not set in the test process, GET /api/settings must include `password_env_var: False`.""" - # The conftest server inherits this process's env; verify it's clean. + # Test the unset branch explicitly. Some suite neighbors intentionally set + # HERMES_WEBUI_PASSWORD while exercising the locked-password path. + monkeypatch.delenv('HERMES_WEBUI_PASSWORD', raising=False) + # The conftest server inherits a sanitized env; verify this process is clean. assert not os.getenv('HERMES_WEBUI_PASSWORD', '').strip(), \ 'this test requires HERMES_WEBUI_PASSWORD to be unset' diff --git a/tests/test_issue1731_upward_scroll_unpins.py b/tests/test_issue1731_upward_scroll_unpins.py index 70e6e999..e387e5c2 100644 --- a/tests/test_issue1731_upward_scroll_unpins.py +++ b/tests/test_issue1731_upward_scroll_unpins.py @@ -91,6 +91,46 @@ def test_upward_scroll_unpins_immediately_without_hysteresis(): ) +def test_upward_motion_only_unpins_after_recent_user_intent(): + """Layout/programmatic scrollTop decreases must not masquerade as user scroll-up. + + Long-session windowing can preserve/restore scroll positions while the live + stream is growing. If a plain scrollTop decrease always clears + ``_scrollPinned``, the viewport can be visually at bottom while the state says + "not pinned", so streaming stops auto-following. Explicit wheel/touch upward + input must still unpin immediately; passive layout movement must not. + """ + assert "let _lastMessageUpwardIntentMs=" in UI_JS, ( + "ui.js must track recent upward wheel/touch intent inside #messages so " + "programmatic/layout scroll changes do not permanently unpin streaming." + ) + assert "function _recentMessageUpwardIntent()" in UI_JS, ( + "ui.js must expose a recent upward transcript intent helper." + ) + block = _scroll_listener_block() + moved_idx = block.index("const movedUp=") + moved_expr = block[moved_idx : block.find(";", moved_idx)] + assert "_recentMessageUpwardIntent()" in moved_expr, ( + "movedUp must require recent wheel/touch upward intent, not only a " + "scrollTop decrease caused by DOM/layout changes." + ) + + +def test_wheel_touch_upward_intent_is_recorded_inside_messages(): + """Wheel/touch gestures inside #messages must mark real upward user intent.""" + fn_start = UI_JS.index("function _recordNonMessageScrollIntent") + fn_end = UI_JS.index("function _recentNonMessageScrollIntent", fn_start) + fn = UI_JS[fn_start:fn_end] + assert "_lastMessageUpwardIntentMs=performance.now()" in fn, ( + "_recordNonMessageScrollIntent must timestamp real upward transcript " + "wheel/touch gestures before clearing _scrollPinned." + ) + assert "e.deltaY<0" in fn and "e.type==='touchmove'" in fn, ( + "Both wheel-up and touchmove gestures inside #messages should count as " + "user upward intent." + ) + + def test_downward_path_preserves_macos_momentum_hysteresis(): """Downward / stationary motion must still go through the original hysteresis re-pin path so the #1360 macOS trackpad momentum protection diff --git a/tests/test_streaming_markdown.py b/tests/test_streaming_markdown.py index 76e1db7d..777e0428 100644 --- a/tests/test_streaming_markdown.py +++ b/tests/test_streaming_markdown.py @@ -418,6 +418,38 @@ class TestDoneEventSmd: "before renderMessages() in the 'done' handler source" ) + def test_done_handler_preserves_bottom_follow_on_final_render(self): + """Final DOM replacement must keep auto-following users at the bottom. + + The live stream path can be visually at bottom while _scrollPinned was + knocked false by history/windowing/layout preservation. On `done`, the + live DOM is replaced with persisted messages; if the handler blindly calls + renderMessages({preserveScroll:true}) while the pin flag is false, the + transcript can jump to the top. Capture bottom/follow intent before the + replacement and explicitly bottom only for those users. + """ + fn = self.get_fn() + assert fn, "'done' handler not found" + assert "shouldFollowOnDone" in fn, ( + "'done' handler must capture whether the viewed transcript should " + "continue following before replacing the live DOM." + ) + follow_idx = fn.index("shouldFollowOnDone") + render_idx = fn.index("renderMessages({preserveScroll:true})") + assert follow_idx < render_idx, ( + "Follow intent must be captured before renderMessages() replaces the " + "live transcript DOM." + ) + after_render = fn[render_idx:render_idx + 500] + assert "if(shouldFollowOnDone" in after_render and "scrollToBottom()" in after_render, ( + "After final render, done handler must call scrollToBottom() when the " + "user was pinned/near-bottom before DOM replacement." + ) + assert "_isMessagePaneNearBottom" in fn, ( + "Done follow capture must include a near-bottom DOM check, not only " + "the possibly-stale _scrollPinned flag." + ) + # ── 7. apperror event: smd parser ends cleanly ─────────────────────────────── From ccdc055c36973f5b06955e9dd8b7543943bcfb9a Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 9 May 2026 00:29:22 +0800 Subject: [PATCH 3/5] Fix workspace prefix sentinel handling --- api/streaming.py | 39 ++++++++++++++----- static/ui.js | 2 +- ...est_issue1913_workspace_prefix_sentinel.py | 34 ++++++++++++++++ tests/test_workspace_display_prefix.py | 3 +- 4 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 tests/test_issue1913_workspace_prefix_sentinel.py diff --git a/api/streaming.py b/api/streaming.py index f8f63e1e..b3bf664e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -586,9 +586,25 @@ def _message_text(value) -> str: return _strip_thinking_markup(str(value or '').strip()) -def _strip_workspace_prefix(text: str) -> str: - """Remove WebUI's model-facing workspace tag from display identity text.""" - return re.sub(r'^\s*\[Workspace:[^\]]+\]\s*', '', str(text or '')).strip() +_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*') +_LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*') + + +def _escape_workspace_prefix_path(path: str) -> str: + return str(path or '').replace('\\', '\\\\').replace(']', '\\]') + + +def _workspace_context_prefix(path: str) -> str: + return f"[Workspace::v1: {_escape_workspace_prefix_path(path)}]\n" + + +def _strip_workspace_prefix(text: str, *, include_legacy: bool = False) -> str: + """Remove WebUI-injected workspace tags without eating user-typed text.""" + value = str(text or '') + stripped = _WORKSPACE_PREFIX_RE.sub('', value, count=1) + if include_legacy and stripped == value: + stripped = _LEGACY_WORKSPACE_PREFIX_RE.sub('', value, count=1) + return stripped.strip() def _first_exchange_snippets(messages): @@ -1051,7 +1067,7 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option assistant_text = _strip_thinking_markup(assistant_text or '').strip() if not user_text: return None - user_text = re.sub(r'^\[Workspace:[^\]]+\]\s*', '', user_text) + user_text = _strip_workspace_prefix(user_text) user_text = re.sub(r'\s+', ' ', user_text).strip() assistant_text = re.sub(r'\s+', ' ', assistant_text).strip() combined = f"{user_text} {assistant_text}".strip().lower() @@ -1443,7 +1459,7 @@ def _message_identity(msg): # visible optimistic bubble contains only the human text. Treat them as # the same turn for merge/dedup purposes; otherwise compaction results # render two adjacent user bubbles ("Ok" and "[Workspace...]\nOk"). - text = _strip_workspace_prefix(text) + text = _strip_workspace_prefix(text, include_legacy=True) if not text and not msg.get('tool_call_id') and not msg.get('tool_calls'): return None return ( @@ -1482,7 +1498,12 @@ def _find_current_user_turn(messages, msg_text): if not isinstance(msg, dict) or msg.get('role') != 'user': continue fallback = idx - text = " ".join(_strip_workspace_prefix(_message_text(msg.get('content', ''))).split()) + text = " ".join( + _strip_workspace_prefix( + _message_text(msg.get('content', '')), + include_legacy=True, + ).split() + ) if needle and (needle in text or text in needle): return idx return fallback @@ -2534,15 +2555,15 @@ def _run_agent_streaming( # Prepend workspace context so the agent always knows which directory # to use for file operations, regardless of session age or AGENTS.md defaults. - workspace_ctx = f"[Workspace: {s.workspace}]\n" + workspace_ctx = _workspace_context_prefix(str(s.workspace)) workspace_system_msg = ( f"Active workspace at session start: {s.workspace}\n" - "Every user message is prefixed with [Workspace: /absolute/path] indicating the " + "Every user message is prefixed with [Workspace::v1: /absolute/path] indicating the " "workspace the user has selected in the web UI at the time they sent that message. " "This tag is the single authoritative source of the active workspace and updates " "with every message. It overrides any prior workspace mentioned in this system " "prompt, memory, or conversation history. Always use the value from the most recent " - "[Workspace: ...] tag as your default working directory for ALL file operations: " + "[Workspace::v1: ...] tag as your default working directory for ALL file operations: " "write_file, read_file, search_files, terminal workdir, and patch. " "Never fall back to a hardcoded path when this tag is present." ) diff --git a/static/ui.js b/static/ui.js index 1ceed738..4599dc50 100644 --- a/static/ui.js +++ b/static/ui.js @@ -70,7 +70,7 @@ function _isBacktickFenceClose(line,minLen){ */ function _stripWorkspaceDisplayPrefix(text){ - return String(text||'').replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim(); + return String(text||'').replace(/^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*/,'').trim(); } function _renderUserFencedBlocks(text){ const stash=[]; diff --git a/tests/test_issue1913_workspace_prefix_sentinel.py b/tests/test_issue1913_workspace_prefix_sentinel.py new file mode 100644 index 00000000..9f5e03f2 --- /dev/null +++ b/tests/test_issue1913_workspace_prefix_sentinel.py @@ -0,0 +1,34 @@ +from api.streaming import ( + _fallback_title_from_exchange, + _strip_workspace_prefix, + _workspace_context_prefix, +) + + +def test_workspace_prefix_strips_only_versioned_sentinel(): + assert _strip_workspace_prefix("[Workspace::v1: /tmp/project]\nHello") == "Hello" + assert _strip_workspace_prefix("[Workspace: /tmp/project]\nHello") == "[Workspace: /tmp/project]\nHello" + + +def test_workspace_prefix_escapes_paths_with_closing_brackets(): + prefix = _workspace_context_prefix("/tmp/proj-[wip]/src") + + assert prefix == "[Workspace::v1: /tmp/proj-[wip\\]/src]\n" + assert _strip_workspace_prefix(f"{prefix}Continue") == "Continue" + + +def test_legacy_workspace_prefix_only_strips_for_compatibility_callers(): + legacy = "[Workspace: /tmp/project]\nContinue" + + assert _strip_workspace_prefix(legacy) == legacy + assert _strip_workspace_prefix(legacy, include_legacy=True) == "Continue" + + +def test_user_typed_legacy_workspace_prefix_survives_fallback_title(): + title = _fallback_title_from_exchange( + "[Workspace: /tmp/project]\nExplain this literal prefix", + "Sure", + ) + + assert title is not None + assert title.startswith("Workspace tmp/project") diff --git a/tests/test_workspace_display_prefix.py b/tests/test_workspace_display_prefix.py index 98528011..7965362b 100644 --- a/tests/test_workspace_display_prefix.py +++ b/tests/test_workspace_display_prefix.py @@ -16,7 +16,8 @@ def test_workspace_display_prefix_helper_strips_leading_metadata_only(): assert end != -1, "user fenced block renderer not found after prefix stripper" helper = src[start:end] - assert r"^\s*\[Workspace:[^\]]+\]\s*" in helper + assert r"^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*" in helper + assert "[Workspace:[^\\]]+" not in helper assert ".trim()" in helper From cdbdc28f5cd0074ca6056cd4f74398a215b97a76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=B5=A9=E7=94=9F?= Date: Thu, 7 May 2026 20:25:04 +0800 Subject: [PATCH 4/5] fix(config): custom named provider API key resolution in WebUI - add robust custom provider credential/base_url resolver - apply fallback in streaming and routes agent init/self-heal paths - support slug normalization and config fallbacks for custom:* providers --- api/config.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++++ api/routes.py | 25 ++++++++++++- api/streaming.py | 23 ++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/api/config.py b/api/config.py index ef07c0ff..b93d9ba2 100644 --- a/api/config.py +++ b/api/config.py @@ -1596,6 +1596,102 @@ def resolve_model_provider(model_id: str) -> tuple: return model_id, config_provider, config_base_url +def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, str | None]: + """Return (api_key, base_url) for a named ``custom:*`` provider. + + Supports ``custom_providers[].api_key`` as either a literal key or + ``${ENV_VAR}``, and ``custom_providers[].key_env`` as an env-var hint. + Returns ``(None, None)`` when no named custom provider matches. + """ + pid = str(provider_id or "").strip().lower() + if not pid.startswith("custom:"): + return None, None + + def _slugify(value: str) -> str: + s = str(value or "").strip().lower().replace("_", "-").replace(" ", "-") + while "--" in s: + s = s.replace("--", "-") + return s.strip("-") + + slug = _slugify(pid.split(":", 1)[1].strip()) + if not slug: + return None, None + + # Read the live config snapshot to avoid stale module-level cache edge + # cases after profile switches or runtime config edits. + cfg_data = get_config() + + def _resolve_key(raw_api_key, raw_key_env) -> str | None: + api_key = None + if raw_api_key is not None: + key_text = str(raw_api_key).strip() + if key_text.startswith("${") and key_text.endswith("}") and len(key_text) > 3: + api_key = os.getenv(key_text[2:-1], "").strip() or None + elif key_text: + api_key = key_text + if not api_key: + key_env = str(raw_key_env or "").strip() + if key_env: + api_key = os.getenv(key_env, "").strip() or None + return api_key + + custom_providers = cfg_data.get("custom_providers", []) + if not isinstance(custom_providers, list): + custom_providers = [] + + for entry in custom_providers: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "").strip() + if not name: + continue + entry_slug = _slugify(name) + if entry_slug != slug: + continue + + base_url = str(entry.get("base_url") or "").strip() or None + api_key = _resolve_key(entry.get("api_key"), entry.get("key_env")) + return api_key, base_url + + # If exactly one custom provider is configured, use it as a pragmatic + # fallback for mismatched slugs (e.g. punctuation differences). + if len(custom_providers) == 1 and isinstance(custom_providers[0], dict): + entry = custom_providers[0] + return ( + _resolve_key(entry.get("api_key"), entry.get("key_env")), + str(entry.get("base_url") or "").strip() or None, + ) + + # Fallbacks for setups that don't use custom_providers names directly. + providers_cfg = cfg_data.get("providers", {}) + provider_specific = providers_cfg.get(pid, {}) if isinstance(providers_cfg, dict) else {} + provider_custom = providers_cfg.get("custom", {}) if isinstance(providers_cfg, dict) else {} + + model_cfg = cfg_data.get("model", {}) + model_provider = str(model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else "" + + fallback_base = None + for candidate in (provider_specific, provider_custom, model_cfg): + if isinstance(candidate, dict): + _base = str(candidate.get("base_url") or "").strip() + if _base: + fallback_base = _base + break + + fallback_key = None + if isinstance(provider_specific, dict): + fallback_key = _resolve_key(provider_specific.get("api_key"), provider_specific.get("key_env")) + if not fallback_key and isinstance(provider_custom, dict): + fallback_key = _resolve_key(provider_custom.get("api_key"), provider_custom.get("key_env")) + if not fallback_key and isinstance(model_cfg, dict) and model_provider in {"custom", pid, slug}: + fallback_key = _resolve_key(model_cfg.get("api_key"), model_cfg.get("key_env")) + + if fallback_key or fallback_base: + return fallback_key, fallback_base or None + + return None, None + + def model_with_provider_context(model_id: str, model_provider: str | None = None) -> str: """Return the model string to pass to ``resolve_model_provider()``. diff --git a/api/routes.py b/api/routes.py index 96a0048f..e524c9ed 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6639,7 +6639,10 @@ def _handle_chat_sync(handler, body): from run_agent import AIAgent with CHAT_LOCK: - from api.config import resolve_model_provider + from api.config import ( + resolve_model_provider, + resolve_custom_provider_connection, + ) _model, _provider, _base_url = resolve_model_provider( model_with_provider_context(s.model, getattr(s, "model_provider", None)) @@ -6665,6 +6668,12 @@ def _handle_chat_sync(handler, body): f"[webui] WARNING: resolve_runtime_provider failed: {_e}", flush=True, ) + if isinstance(_provider, str) and _provider.startswith("custom:"): + _cp_key, _cp_base = resolve_custom_provider_connection(_provider) + if not _api_key and _cp_key: + _api_key = _cp_key + if not _base_url and _cp_base: + _base_url = _cp_base agent = AIAgent( model=_model, provider=_provider, @@ -7427,6 +7436,13 @@ def _handle_session_compress(handler, body): except Exception as _e: logger.warning("resolve_runtime_provider failed for compression: %s", _e) + if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"): + _cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider) + if not resolved_api_key and _cp_key: + resolved_api_key = _cp_key + if not resolved_base_url and _cp_base: + resolved_base_url = _cp_base + if not resolved_api_key: return bad(handler, "No provider configured -- cannot compress.") @@ -8041,6 +8057,13 @@ def _handle_handoff_summary(handler, body): except Exception as _e: logger.warning("resolve_runtime_provider failed for handoff summary: %s", _e) + if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"): + _cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider) + if not resolved_api_key and _cp_key: + resolved_api_key = _cp_key + if not resolved_base_url and _cp_base: + resolved_base_url = _cp_base + if not resolved_api_key: summary_text = _fallback_handoff_summary(msgs) try: diff --git a/api/streaming.py b/api/streaming.py index f8f63e1e..0829cff0 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -26,6 +26,7 @@ from api.config import ( _get_session_agent_lock, _set_thread_env, _clear_thread_env, SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK, resolve_model_provider, + resolve_custom_provider_connection, model_with_provider_context, ) from api.helpers import redact_session_data, _redact_text @@ -2266,6 +2267,16 @@ def _run_agent_streaming( except Exception as _e: print(f"[webui] WARNING: resolve_runtime_provider failed: {_e}", flush=True) + # Named custom providers (custom:slug) may not be resolvable by + # hermes_cli.runtime_provider directly. Fall back to config.yaml + # custom_providers[] so WebUI can pass explicit creds/base_url. + if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"): + _cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider) + if not resolved_api_key and _cp_key: + resolved_api_key = _cp_key + if not resolved_base_url and _cp_base: + resolved_base_url = _cp_base + # Read per-profile config at call time (not module-level snapshot) from api.config import get_config as _get_config _cfg = _get_config() @@ -2725,6 +2736,12 @@ def _run_agent_streaming( resolved_provider = _heal_rt.get('provider') if not resolved_base_url: resolved_base_url = _heal_rt.get('base_url') + if isinstance(resolved_provider, str) and resolved_provider.startswith('custom:'): + _cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider) + if not resolved_api_key and _cp_key: + resolved_api_key = _cp_key + if not resolved_base_url and _cp_base: + resolved_base_url = _cp_base # Rebuild agent kwargs and create a fresh agent _agent_kwargs['api_key'] = resolved_api_key _agent_kwargs['base_url'] = resolved_base_url @@ -3284,6 +3301,12 @@ def _run_agent_streaming( resolved_provider = _heal_rt.get('provider') if not resolved_base_url: resolved_base_url = _heal_rt.get('base_url') + if isinstance(resolved_provider, str) and resolved_provider.startswith('custom:'): + _cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider) + if not resolved_api_key and _cp_key: + resolved_api_key = _cp_key + if not resolved_base_url and _cp_base: + resolved_base_url = _cp_base # Build a fresh agent with the new credentials _heal_kwargs = dict(_agent_kwargs) if '_agent_kwargs' in dir() else {} _heal_kwargs['api_key'] = resolved_api_key From 81da27f45d1e3c0ec3b99a7bda52c8e8e738a49d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 8 May 2026 17:07:16 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20stamp=20v0.51.27=20?= =?UTF-8?q?=E2=80=94=204-PR=20Release=20E1=20batch=20(workspace-prefix=20s?= =?UTF-8?q?entinel=20+=20custom-provider=20keys=20+=20scroll-pin=20+=20kan?= =?UTF-8?q?ban=20scroll)=20+=20Opus=20#1918=20absorbed=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 2 +- TESTING.md | 4 ++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fb4015..44417dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Hermes Web UI -- Changelog +## [v0.51.27] — 2026-05-08 — 4-PR contributor batch (Release E1: workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin retention, Kanban detail scrollable) + +### Fixed (4 PRs) + +- **PR #1916** by @Michaelyklam — Make Kanban detail view scrollable. The app shell sets `body { overflow: hidden }`, so the Kanban main view must own vertical scrolling. Pre-fix, a selected task with a long body could push the board below the viewport with no way to reach it. Fix: add `overflow-y: auto` to `main.main.showing-kanban > #mainKanban` (one CSS property + regression test). Closes #1915. + +- **PR #1914** by @ai-ag2026 — Keep streaming chat pinned after final render. During streaming, bottom-pinned scroll worked, but after the `done` event late Markdown layout growth could unpin the viewport — the user would see the last token, then suddenly the chat would scroll up by hundreds of pixels as render reflowed. Fix: add explicit upward-intent gating (`MESSAGE_UPWARD_INTENT_MS=450` ms window for wheel/touch events) so passive `scrollTop` decreases from windowing/reflow no longer count as user upward intent. Pre-replacement `shouldFollowOnDone` capture in `static/messages.js` calls `scrollToBottom()` if pin or near-bottom (`<=1200px`) was true before render. `scrollIfPinned` and `scrollToBottom` now write `_lastScrollTop` and clear the programmatic flag in a rAF so the next listener pass doesn't see a synthetic upward delta. + +- **PR #1918** by @franksong2702 — Fix workspace prefix sentinel handling (closes #1913 follow-up filed in v0.51.25). The pre-fix strip regex `^\s*\[Workspace:[^\]]+\]\s*` was too permissive — a user prompt starting with `[Workspace: /path/to/explain]` would be silently eaten, and workspace paths containing `]` would truncate at the first `]`. Fix introduces a versioned sentinel format `[Workspace::v1: ...]` (double-colon distinguishes from natural English) AND escapes `]` in the path with `\]`. New helpers: `_workspace_context_prefix(path)`, `_escape_workspace_prefix_path(path)`, and `_strip_workspace_prefix(text, *, include_legacy=False)` with optional legacy fallback for transcript-compaction identity matching during the migration window. Closes #1913. + + **Mid-stage absorbed fixes (per Opus advisor on stage-322):** + 1. **#1918 missed second injection site at `api/routes.py:6689`** (`_handle_chat_sync`, the `POST /api/chat` synchronous handler). Without this fix, the sync chat path would still inject legacy `[Workspace: ...]` while the streaming path injected `[Workspace::v1: ...]` — producing user bubbles that visibly leak the prefix on the sync surface, and a system-prompt format string that no longer matches reality. Maintainer routed the sync injection through `_workspace_context_prefix(...)` and updated the surrounding system-prompt text to v1 form, mirroring the streaming.py block. + 2. **#1918 backwards-compat gap in `static/ui.js:_stripWorkspaceDisplayPrefix`** — existing on-disk transcripts saved before the v1 migration still carry the legacy format. Without a JS legacy fallback, pre-upgrade sessions would render the literal `[Workspace: /tmp/proj]` prefix in user bubbles after upgrade. Maintainer added a legacy-regex fallback paralleling the Python `include_legacy=True` branch on the streaming side; updated the regression test that previously asserted the legacy regex was absent. + +- **PR #1814** by @hualong1009 — Custom named provider API key resolution. Adds new top-level helper `resolve_custom_provider_connection(provider_id) -> (api_key, base_url)` that resolves `custom:*` provider IDs to credentials from `config.yaml > custom_providers[]`. Supports `api_key` as literal value, `${ENV_VAR}` interpolation, or `key_env` env-var hint. Uses `get_config()` snapshot (per-profile aware). Fallback to single-entry `custom_providers` when slug doesn't match exactly. Also adds fallback in `api/streaming.py` self-heal paths so an agent rebuild after a transient failure can re-fetch credentials. **Deferral re-evaluated (per prior sweep notes):** the prior `maintainer-review` flag noted feared overlap with #1818, but #1818 already shipped (v0.51.19) with its slug-matching helpers. Re-checking against current master post-#1818: the new `resolve_custom_provider_connection()` is purely additive (no helper duplication). **Style observation (non-blocking)**: PR's local `_slugify` has slightly different normalization (`_` → `-`, collapse `--`, strip leading/trailing `-`) than master's canonical `_custom_provider_slug_from_name`. Internally self-consistent (both pid and entry name go through the same local slugify before comparison) so it works for matching, but a follow-up could unify the slug semantics. The 6-call-site fallback pattern (3 in `api/routes.py`, 3 in `api/streaming.py`) is also a candidate for a single `apply_custom_provider_fallback()` helper. + +### Tests + +4890 → **4898 collected, 4884 passing, 0 regressions** (+8 net new). Full suite ~145s on Python 3.11 (HERMES_HOME isolated). JS syntax check (`node -c`) passes on `static/messages.js` and `static/ui.js`. Browser API sanity harness (port 8789) all-green: 11 endpoints + 20 QA tests verified. Opus advisor pass: 2 BLOCKERS identified and fixed in-stage (per absorb-in-release default), then SHIP. + +### Pre-release verification + +- Full pytest under `HERMES_HOME` isolation: **4884 passed, 11 skipped, 1 xfailed, 2 xpassed, 8 subtests passed** in 145.18s. +- Browser API harness against stage-322 on port 8789: all 11 endpoints + 20 QA tests PASS. +- `node -c` on `static/messages.js`, `static/ui.js`: clean. +- Stage diff: 13 files, +348/-22 (pre-Opus-fix); 14 files, +382/-31 (post-Opus-fix incorporating the routes.py legacy-injection fix and ui.js legacy-fallback fix). +- Opus advisor pass on stage-322 brief: identified 2 BLOCKERS in PR #1918 (missed `routes.py` injection site + missing JS legacy fallback). Both absorbed in-stage per absorb-in-release default. Test that asserted "legacy regex absent" updated to assert legacy regex IS present (mirrors Python `include_legacy=True` branch). +- v0.51.26 fixes verified preserved across rebase: `_strip_workspace_prefix` (10), `on_interim_assistant` (2), `_max_iterations_cfg` (9), `if input_tokens > 0:` (1), `get_default_hermes_root` (3), `_sessionSegmentCount` (9), `_active_skills_dir` (6). +- Pre-stamp re-fetch of all 4 PR heads: no contributor force-pushes during the Opus window. + +### Opus-applied fixes (absorbed in-release) + +**From stage-322 absorption:** + +1. **#1918 second injection site** — `api/routes.py:_handle_chat_sync` was injecting legacy `[Workspace: ...]` and telling the agent that's the active format. Fixed: routed through `_workspace_context_prefix(str(s.workspace))`; updated surrounding system-prompt strings to reference `[Workspace::v1: ...]` consistently. + +2. **#1918 JS legacy fallback** — `static/ui.js:_stripWorkspaceDisplayPrefix` was changed to v1-only regex with no legacy fallback. Fixed: added fallthrough to legacy regex when v1 strip doesn't match, mirroring the Python `include_legacy=True` branch. Updated test `test_workspace_display_prefix_helper_strips_leading_metadata_only` to assert the legacy regex IS present (was inverted to assert it was absent). + ## [v0.51.26] — 2026-05-08 — 5-PR follow-on contributor batch (Release D: profile-isolation hardening across cache + skills + gateway-health, context-length config-override threading, sidebar segment count UI polish) ### Fixed (5 PRs + 1 absorbed test) diff --git a/ROADMAP.md b/ROADMAP.md index 509b73c0..41cfb98b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.26 (May 8, 2026) — 4890 tests collected — 5-PR Release D batch (gateway health root home, sidebar segment count, skills profile scoping, profile-home cache signature, context-length config overrides) +> Last updated: v0.51.27 (May 8, 2026) — 4898 tests collected — 4-PR Release E1 batch (workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin, Kanban detail scrollable) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index ab32a8bc..e906700c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.26, May 8, 2026* -*Total automated tests collected: 4890* +*Last updated: v0.51.27, May 8, 2026* +*Total automated tests collected: 4898* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /*